diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..78be535 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,21 @@ +name: build + +on: + push: + branches: [ main, "feature/**" ] + pull_request: + branches: [ main ] + +jobs: + build: + name: 빌드 · 단위 테스트 + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: 빌드 + run: dotnet build SoundVisualizer.slnx -c Release + - name: 단위 테스트 + run: dotnet test SoundVisualizer.Tests/SoundVisualizer.Tests.csproj -c Release --no-build diff --git a/.gitignore b/.gitignore index dca8637..e73bbb5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ obj/ *.suo *.sln.docstates .agents/mcp_config.json +*.lscache diff --git a/README.ko.md b/README.ko.md index 076ff1d..824a941 100644 --- a/README.ko.md +++ b/README.ko.md @@ -4,7 +4,7 @@
-# Sound Visualizer +# SoundVisualizer SoundVisualizer @@ -20,9 +20,11 @@
-> **Sound Visualizer**는 컴퓨터의 시스템 오디오를 실시간으로 캡처하여 방향·크기·의미를 그래픽 오버레이로 변환하는 데스크톱 애플리케이션입니다. +> **SoundVisualizer**는 컴퓨터의 시스템 오디오를 실시간으로 캡처하여 방향·크기·의미를 그래픽 오버레이로 변환하는 데스크톱 애플리케이션입니다. 단순한 파형 시각화를 넘어, **YAMNet**과 **ONNX Runtime** 기반의 AI 사운드 분류기를 내장하여 현재 재생 중인 소리의 종류(배경음, 대화음, 위협음 등)를 실시간으로 인지하고 피드백을 제공합니다. +> 📱 안드로이드 버전도 있습니다: [SoundVisualizer for Android](https://github.com/bibibic76/SoundVisualizer) + --- ## 📖 목차 @@ -31,7 +33,6 @@ - [시스템 및 AI 아키텍처](#-시스템-및-ai-아키텍처) - [설치 및 실행 방법](#-설치-및-실행-방법) - [빌드 가이드 (개발자용)](#-빌드-가이드-개발자용) -- [팀원 및 역할](#-팀원-및-역할) - [라이선스](#-라이선스) --- @@ -55,21 +56,40 @@ - **원형 모드 (Circle)**: 화면 중앙을 기점으로 바깥쪽으로 퍼져나가는 원형 파동을 렌더링합니다. - **패드 모드 (Pad)**: 지정된 공간 그리드(방향)에 고정된 패드 형태로 소리를 표현합니다. - **외곽선 모드 (Outline)**: 파도 모드를 기반으로, 파도의 상한 외곽선 테두리만 표현하여 화면 가림을 최소화합니다. +- 30 FPS부터 모니터 주사율까지, 최대 프레임을 얼마로 두어도 그래픽이 같은 빠르기로 움직입니다. ### 2. 온스크린 실시간 제어 및 에디터 전체 화면 게임을 내리지 않고도 오버레이 상태에서 즉각적인 수정이 가능합니다. - **F2 / F3 단축키**: 사운드 모드 및 시각화 모드 실시간 변경. 핫키는 변경 가능합니다. -- **에디터 모드 (기본 F4키)**: 화면에 나타나는 가이드라인 경계를 직접 드래그하여 시각화 영역의 크기와 한계를 자유롭게 리사이징합니다. 색상, 투명도, AI 감지 민감도 등 모든 설정 또한 패널에서 조정 가능합니다. +- **에디터 모드 (기본 F4키)**: 화면에 나타나는 가이드라인 경계를 직접 드래그하여 시각화 영역의 크기와 한계를 자유롭게 리사이징합니다. 색상, 투명도 등 모든 설정 또한 패널에서 조정 가능합니다. ### 3. 하드웨어 인식 7.1 다중 채널 지원 - 시스템의 오디오 채널 구성(Stereo, 5.1, 7.1 Surround)을 자동 인식하고, 소리의 발생 방향(각 채널)을 정확하게 매핑하여 입체적인 시각 효과를 렌더링합니다. +- 사운드 모드는 실행할 때와 출력 장치가 바뀔 때 그 장치에 맞춰지며, 직접 바꿀 수도 있습니다. +- 스테레오 출력에서는 좌우에 함께 있는 소리를 화면 위쪽에, 좌우 차이만 옆쪽에 그립니다. 그래서 가운데에서 나는 소리가 양쪽에서 나는 소리처럼 보이지 않습니다. ### 4. AI 실시간 사운드 분류 (ONNX & YAMNet) - **3-Class 분류 체계**: 모든 소리를 `Ambient(환경음)`, `Speech(대화음)`, `Danger(위협음)` 3가지로 분석하여 카테고리별로 지정된 UI 색상과 투명도로 동적 시각 피드백을 제공합니다. 색상과 투명도는 커스터마이징이 가능합니다. -- **Gunshot Booster**: 게임 및 영상 내의 총소리를 놓치지 않도록 특수 학습된 부스터 모델이 가동되어 위협음 탐지 민감도를 향상합니다. - -### 5. 글로벌 다국어 지원 -- 한국어, 영어, 일본어, 중국어, 스페인어, 프랑스어, 독일어, 러시아어 등 8개 국어를 지원합니다. +- **위협 단서**: 사이렌, 경보, 폭발음, 폭죽 같은 경고음은 모델의 상위 후보 다섯 개 안에(5% 이상) 들기만 해도 `위협음`으로 봅니다. 음악이나 게임 소리에 묻혀도 놓치지 않기 위해서입니다. 총소리 이름은 오경보를 막기 위해 이렇게 끌어올리지 않습니다. +- **흔들리지 않는 색**: `위협음`은 조건을 넘는 첫 결과에 바로 바뀌고, `대화음`과 `환경음`은 두 번 연달아 같아야 바뀌어 색이 깜빡이지 않습니다. 조용한 동안에는 AI 가 쉬고, 소리가 나면 바로 다시 듣습니다. +- **모든 채널을 듣습니다**: 5.1/7.1 환경에서는 앞쪽뿐 아니라 뒤쪽·옆쪽 채널의 소리도 AI 가 듣습니다. +- **늦게 온 위협음**: `위협음` 판정이 소리보다 조금 늦게 와도, 그 소리를 나던 때의 크기로 그립니다. 앞서 숨겨 둔 종류였던 소리도 그렇습니다. + +### 5. 소리마다 종류 정하기 (분류 탭) +- AI 가 알아듣는 소리 521개를 안드로이드 버전과 같은 묶음으로 보여 주고, 묶음마다(고급 모드에서는 소리마다) `환경음`, `대화음`, `위협음` 가운데 무엇으로 볼지 선택합니다. +- 이름으로 찾고, 종류나 바꾼 소리로 거르고, 한 번에 모두 되돌릴 수 있습니다. 바꾼 것은 오버레이의 AI 에 바로 반영됩니다. + +### 6. 도움말, 안내, 트레이 아이콘 +- **도움말 탭**: 시작하기, 표현 모드, 소리 종류, 단축키, 자주 묻는 질문, 정보가 미리 채워진 GitHub 이슈나 메일로 보내는 제보 버튼, 오픈소스 라이선스를 담았습니다. +- **안내**: 캡처할 출력 장치가 없을 때, 소리 종류 구분을 쓸 수 없을 때, 시각화가 멈춘 까닭을 홈 탭에 알려 줍니다. +- **트레이 아이콘**: 알림 영역에서 시각화를 켜고 끄거나, 런처로 돌아오거나, 종료할 수 있습니다. +- **튜토리얼**: 처음 열면 실제 그리기 엔진으로 소리, 방향, 소리 종류가 어떻게 그려지는지 짧게 보여 줍니다. 홈 탭에서 다시 볼 수 있습니다. +- **개발자 모드**: AI 가 들은 소리(상위 다섯 개, 확신도, 걸린 시간)를 오버레이 왼쪽 위에 띄우고, 모든 결과를 앱 옆 `ai-log` 폴더의 CSV 파일로 기록할 수 있습니다. +- **키보드와 화면 읽어주기**: 런처의 모든 칸에 Tab 키로 닿습니다. 탭 줄에서는 화살표로 탭을 옮기고, 어디서나 `Ctrl + Tab` 으로 다음 탭으로 갑니다. 대화 창과 오버레이 편집 패널은 `Esc` 로 닫습니다. 언어 목록은 화살표로 열어 둘러보고 `Enter` 로 선택할 때만 언어가 바뀝니다. 아이콘 버튼, 단축키 버튼, 색 버튼, 소리 목록은 내레이터·NVDA 같은 화면 읽어주기가 이름으로 읽고, 실행 상태와 안내, 바꾼 소리 수는 바뀔 때 바로 읽힙니다. 편집 화면에서 마우스로 끄는 값은 모두 패널의 슬라이더로도 바꿀 수 있습니다. + +### 7. 글로벌 다국어 지원 +- 한국어, 영어, 일본어, 중국어(간체·번체), 베트남어, 태국어, 인도네시아어, 스페인어, 포르투갈어(브라질), 프랑스어, 이탈리아어, 독일어, 러시아어, 폴란드어, 터키어, 힌디어, 아랍어(오른쪽에서 왼쪽) 등 18개 언어를 지원합니다. 기본값은 Windows 표시 언어를 따릅니다. +- 화면 글꼴은 Pretendard 이고, Pretendard 에 없는 문자는 Windows 글꼴로 보입니다. --- @@ -78,20 +98,23 @@ 본 프로젝트는 고성능 실시간 처리를 위해 철저한 메모리 관리 및 백그라운드 최적화로 설계되었습니다. * **Core Audio API (WASAPI)**: Windows 시스템 전체 오디오를 딜레이 없이 루프백 캡처. -* **DSP & FFT 연산**: 백그라운드 오디오 스레드에서 초당 수십 번의 고속 주파수 변환 연산 수행. +* **채널별 크기 측정**: 캡처 스레드가 버퍼마다 채널별 최고 크기를 재고, 렌더 루프는 지난 프레임 뒤로 가장 큰 값을 가져갑니다. * **Zero-Allocation 렌더링**: C# / WPF 환경에서 GC(가비지 컬렉터)의 개입을 최소화하여 프레임 드랍 방지. -* **ONNX 추론 파이프라인**: 16kHz 모노 변환 및 Log-mel 스펙트로그램 전처리를 거친 후, `Microsoft.ML.OnnxRuntime` 엔진으로 전이 학습된 YAMNet 가중치 기반 실시간 분류 수행. +* **프레임과 무관한 움직임, 쉬는 오버레이**: 스무딩은 60 FPS 기준으로 맞춥니다. 1초 동안 보이는 것이 없으면 프레임마다 그리지 않고 쉬다가, 소리가 나면 바로 깨어납니다. +* **ONNX 추론 파이프라인**: 250ms 마다 최근 0.975초의 소리를 모노로 섞고, 앨리어싱 방지 필터를 거쳐 16kHz 로 바꾼 뒤, 모델이 만들어질 때와 같은 방식의 Log-mel 스펙트로그램으로 바꿔 `Microsoft.ML.OnnxRuntime` 의 YAMNet 으로 분류합니다. 모델 파일은 불러올 때 고정된 SHA-256 으로 확인하고, 각 단계는 기준 출력과 비교하는 단위 테스트로 검증합니다. --- ## 🚀 설치 및 실행 방법 -Sound Visualizer는 설치가 필요 없는 가벼운 **포터블(Portable)** 패키지로 제공됩니다. +SoundVisualizer는 설치가 필요 없는 가벼운 **포터블(Portable)** 패키지로 제공됩니다. Windows 10/11 (64비트)에서 돌아가며, .NET 을 따로 설치하지 않아도 됩니다. 1. **[GitHub Releases](https://github.com/amophi/SoundVisualizer/releases)** 페이지에 접속합니다. -2. 최신 버전의 `SoundVisualizer.zip` 파일을 다운로드 및 압축 해제합니다. +2. 최신 버전의 `SoundVisualizer_vX.Y.zip` 파일을 다운로드해 원하는 곳에 압축 해제합니다. 3. 폴더 내의 **`SoundVisualizer.exe`**를 실행합니다. -4. 런처에서 언어 및 초기 설정을 마친 후, **Start**를 클릭하여 오버레이를 활성화합니다. +4. 런처에서 언어 및 초기 설정을 마친 후, **실행**을 눌러 오버레이를 켭니다. + +설정은 `SoundVisualizer.exe` 옆의 `settings.json` 에 저장되므로, 폴더를 그대로 옮기거나 복사해도 됩니다. --- @@ -99,13 +122,19 @@ Sound Visualizer는 설치가 필요 없는 가벼운 **포터블(Portable)** 코드 수정 및 기여를 위해 소스에서 직접 빌드하는 방법입니다. -1. **요구 사항**: Windows 10/11, Visual Studio 2022 (.NET 데스크톱 개발 워크로드), .NET 10.0 SDK +1. **요구 사항**: Windows 10/11, .NET 10 SDK. Visual Studio 2026 (.NET 데스크톱 개발 워크로드)은 있어도 되고 없어도 됩니다. 2. **리포지토리 클론**: ```bash git clone https://github.com/amophi/SoundVisualizer.git ``` -3. Visual Studio에서 `SoundVisualizer.slnx` 솔루션을 엽니다. -4. `Release` 또는 `Debug` 구성 선택 후 솔루션을 빌드(`Ctrl + Shift + B`)하고 실행(`F5`)합니다. +3. 리포지토리 폴더에서 빌드하고 테스트합니다. + ```bash + dotnet build SoundVisualizer.slnx -c Release + dotnet test SoundVisualizer.Tests/SoundVisualizer.Tests.csproj + ``` + 또는 Visual Studio 에서 `SoundVisualizer.slnx` 를 열어 빌드(`Ctrl + Shift + B`)하고 실행(`F5`)합니다. + +솔루션은 세 프로젝트로 나뉩니다. `SoundVisualizer`(WPF 앱), `SoundVisualizer.Core`(오디오, AI, 렌더링 계산, 설정, 18개 언어 문자열 표. 화면에 의존하지 않음), `SoundVisualizer.Tests`(xUnit 테스트. AI 각 단계의 기준 출력 비교 포함). --- diff --git a/README.md b/README.md index 8452cfa..ae2b19b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@
-# Sound Visualizer +# SoundVisualizer SoundVisualizer @@ -20,9 +20,11 @@
-> **Sound Visualizer** is a desktop application that captures real-time system audio and translates its direction, intensity, and meaning into a graphical overlay. +> **SoundVisualizer** is a desktop application that captures real-time system audio and translates its direction, intensity, and meaning into a graphical overlay. Going beyond simple waveform rendering, it integrates an AI sound classifier powered by **YAMNet** and **ONNX Runtime** to instantly identify the type of sound playing (ambient, speech, danger) and provide adaptive visual feedback. +> 📱 There is also an Android version: [SoundVisualizer for Android](https://github.com/bibibic76/SoundVisualizer). + --- ## 📖 Table of Contents @@ -31,7 +33,6 @@ Going beyond simple waveform rendering, it integrates an AI sound classifier pow - [System & AI Architecture](#-system--ai-architecture) - [Installation & Quick Start](#-installation--quick-start) - [Build Guide (For Developers)](#-build-guide-for-developers) -- [Team Members](#-team-members) - [License](#-license) --- @@ -40,7 +41,7 @@ Going beyond simple waveform rendering, it integrates an AI sound classifier pow ### Enhancing Media Accessibility for the Deaf and Hard of Hearing Modern media content, including YouTube, OTT, and gaming, relies heavily on spatial audio to maximize immersion. However, this creates a severe barrier for the deaf and hard of hearing. Traditional subtitle systems only deliver dialogue, completely omitting crucial acoustic information such as **sound direction, footsteps, or urgent sound effects (e.g., gunshots).** -Sound Visualizer was created to break down these barriers by **visualizing the intensity, classification, and spatial direction of invisible sounds.** Our primary goal is to resolve this information asymmetry and guarantee an equal, fully accessible media consumption environment for everyone. +SoundVisualizer was created to break down these barriers by **visualizing the intensity, classification, and spatial direction of invisible sounds.** Our primary goal is to resolve this information asymmetry and guarantee an equal, fully accessible media consumption environment for everyone. ### Expanding Utility for All Users This overlay technology is also highly beneficial for gamers, providing a **tactical visual indicator (situational awareness)** in competitive environments. Additionally, it serves as a perfect alternative for users consuming media in public or silent environments where audio output is restricted. @@ -55,21 +56,40 @@ We provide 4 unique rendering modes that intuitively map sound intensity and dir - **Circle Mode**: Radiates circular ripples outward from the center of the screen. - **Pad Mode**: Displays glowing pads anchored to specific spatial grid directions. - **Outline Mode**: A minimalist variation of Wave mode, showing only the thin borders of the wave to minimize screen occlusion. +- Animations move at the same speed at any frame rate, from 30 FPS up to your monitor's refresh rate. ### 2. On-Screen Real-Time Editor (F4) Modify settings instantly while in a full-screen application or game without minimizing the window. - **F2 / F3 Hotkeys**: Switch between sound modes and visualization modes on the fly. Hotkeys can be customized. -- **Editor Mode (Default F4)**: Drag the guideline boundaries on your screen to physically resize the rendering limits in real-time. Adjust colors, opacity, AI detection sensitivities, and all other settings directly from the pop-up control panel. +- **Editor Mode (Default F4)**: Drag the guideline boundaries on your screen to physically resize the rendering limits in real-time. Adjust colors, opacity, and all other settings directly from the pop-up control panel. ### 3. Hardware-Aware Multi-Channel Support - Automatically detects the system's audio configuration (Stereo, 5.1, 7.1 Surround) and accurately maps the sound's origin (Front/Back/Left/Right) to produce a 3D visual effect on a 2D screen. +- The sound mode switches to match your output device whenever the visualizer starts or the device changes, and you can still change it yourself. +- With stereo output, sound common to both channels is drawn at the top and only the difference between left and right goes to the sides, so a centred sound no longer looks like sound coming from both sides. ### 4. Real-Time AI Sound Classification (ONNX & YAMNet) - **3-Class Detection**: Analyzes all incoming audio into `Ambient`, `Speech`, and `Danger` categories. Each category triggers independent, customizable UI colors and dynamic opacity changes. -- **Gunshot Booster**: An auxiliary, highly-sensitive model designed specifically to ensure critical warning sounds (like gunshots in games/movies) are never missed. - -### 5. Comprehensive Localization -- Fully supports 8 languages: English, Korean, Japanese, Chinese, Spanish, French, German, and Russian. +- **Warning cues**: Sirens, alarms, explosions, fireworks and similar warning sounds count as `Danger` even when they are only among the model's top five candidates (at 5% or more), so music or game audio can't drown them out. Gunshot labels are not promoted this way, to avoid false alarms. +- **Stable colors**: `Danger` appears on the first qualifying result; `Speech` and `Ambient` need two in a row, so the color doesn't flicker. The AI pauses while it's quiet and resumes as soon as sound returns. +- **Every channel counts**: On 5.1/7.1 systems, the AI hears the rear and side channels too, not just the front. +- **Late danger**: If a `Danger` result arrives a moment after the sound, the overlay still draws that sound at the size it had, even when its earlier type was hidden. + +### 5. Choose How Each Sound Counts (Sounds Tab) +- Browse the 521 sounds the AI knows, grouped like the Android version, and choose whether each group (or, in advanced mode, each sound) counts as `Ambient`, `Speech`, or `Danger`. +- Search by name, filter by type or by the sounds you changed, and reset everything in one step. The overlay's AI follows your changes immediately. + +### 6. Help, Notices, and Tray Icon +- **Help tab**: Getting started, visual modes, sound types, hotkeys, FAQ, a report button that opens a prefilled GitHub issue or email, and the open source licenses. +- **Notices**: The Home tab tells you when there is no playback device to capture, when sound type detection is unavailable, or why the visualizer stopped. +- **Tray icon**: Start or stop the visualizer, return to the launcher, or exit from the notification area. +- **Tutorial**: The first time you open the app, a short tutorial shows how sound, direction, and sound types are drawn, using the real renderer. You can open it again from the Home tab. +- **Developer mode**: Shows what the AI heard (its top five sounds, how sure it was, and how long it took) at the top left of the overlay, and can record every result to a CSV file in the `ai-log` folder next to the app. +- **Keyboard and screen readers**: Every control in the launcher can be reached with the Tab key. On the tab row, the arrow keys switch tabs, and `Ctrl + Tab` moves to the next tab from anywhere. Dialogs and the overlay editor close with `Esc`. The language list opens with the arrow keys so you can browse it, and the language only changes when you choose one with `Enter`. Screen readers such as Narrator and NVDA read icon buttons, hotkey buttons, color buttons, and the sound list by name, and announce the running status, notices, and the number of changed sounds as they change. Everything you can drag with the mouse in the overlay editor can also be changed with the sliders in its panel. + +### 7. Comprehensive Localization +- Supports 18 languages: Korean, English, Japanese, Chinese (Simplified and Traditional), Vietnamese, Thai, Indonesian, Spanish, Portuguese (Brazil), French, Italian, German, Russian, Polish, Turkish, Hindi, and Arabic (right-to-left). By default it follows the Windows display language. +- The interface uses the Pretendard font, with Windows fonts for scripts Pretendard doesn't cover. --- @@ -78,40 +98,49 @@ Modify settings instantly while in a full-screen application or game without min The project is rigorously engineered for high-performance, real-time background processing with minimal system overhead. * **Core Audio API (WASAPI)**: Loopback captures system-wide audio with zero latency. -* **DSP & FFT Computation**: High-speed frequency transformation calculations run entirely on a background audio thread multiple times per second. +* **Per-Channel Levels**: The capture thread measures each channel's peak level for every buffer, and the render loop takes the loudest value since the previous frame. * **Zero-Allocation Rendering**: The WPF/C# rendering loop is designed to minimize Garbage Collector (GC) allocation, entirely preventing frame drops. -* **ONNX Inference Pipeline**: Audio is converted to 16kHz mono, processed into Log-mel spectrograms, and fed into a custom-trained YAMNet model via the `Microsoft.ML.OnnxRuntime` engine for instantaneous classification. +* **Frame-Rate Independent and Idle-Aware**: Smoothing is normalized to a 60 FPS reference. When nothing has been visible for a second, the overlay stops redrawing every frame and wakes as soon as sound arrives. +* **ONNX Inference Pipeline**: Every 250 ms, the last 0.975 seconds of audio is downmixed to mono, resampled to 16 kHz through an anti-aliasing filter, turned into the log-mel spectrogram the model was exported with, and classified by YAMNet via `Microsoft.ML.OnnxRuntime`. The model files are checked against pinned SHA-256 hashes at load. Each step is unit-tested against reference outputs. --- ## 🚀 Installation & Quick Start -Sound Visualizer is provided as a lightweight, **portable (no-install)** open-source package. +SoundVisualizer is provided as a lightweight, **portable (no-install)** open-source package for Windows 10/11 (64-bit). You don't need to install .NET. 1. Go to the **[GitHub Releases](https://github.com/amophi/SoundVisualizer/releases)** page. -2. Download and extract the latest `SoundVisualizer.zip` file to any location on your PC. +2. Download the latest `SoundVisualizer_vX.Y.zip` file and extract it to any location on your PC. 3. Run **`SoundVisualizer.exe`**. 4. Configure your initial settings and language in the launcher, then click **Start** to activate the overlay. +Your settings are saved in `settings.json` next to `SoundVisualizer.exe`, so the folder can be moved or copied as is. + --- ## 🛠 Build Guide (For Developers) To build from source to modify the code or contribute to the project: -1. **Prerequisites**: Windows 10/11, Visual Studio 2022 (with .NET Desktop Development workload), and .NET 10.0 SDK. +1. **Prerequisites**: Windows 10/11 and the .NET 10 SDK. Visual Studio 2026 (with the .NET Desktop Development workload) is optional. 2. **Clone the repository**: ```bash git clone https://github.com/amophi/SoundVisualizer.git ``` -3. Open the `SoundVisualizer.slnx` solution file in Visual Studio 2022. -4. Select `Release` or `Debug` configuration, build (`Ctrl + Shift + B`), and press `F5` to run. +3. Build and test from the repository folder: + ```bash + dotnet build SoundVisualizer.slnx -c Release + dotnet test SoundVisualizer.Tests/SoundVisualizer.Tests.csproj + ``` + Or open `SoundVisualizer.slnx` in Visual Studio, build (`Ctrl + Shift + B`), and press `F5` to run. + +The solution has three projects: `SoundVisualizer` (the WPF app), `SoundVisualizer.Core` (audio, AI, rendering logic, settings, and the 18 language tables, with no UI dependency), and `SoundVisualizer.Tests` (xUnit tests, including reference outputs for each AI step). --- ## 📄 License -Sound Visualizer is distributed under the **AGPL v3** license to encourage a virtuous cycle in the open-source community. Anyone is welcome to modify the code and share custom UI themes or streaming plugins. See the [LICENSE](LICENSE) file for more information. +SoundVisualizer is distributed under the **AGPL v3** license to encourage a virtuous cycle in the open-source community. Anyone is welcome to modify the code and share custom UI themes or streaming plugins. See the [LICENSE](LICENSE) file for more information. For licensing and copyright information regarding third-party libraries (NAudio, ONNX Runtime, etc.) and AI models used in this project, please refer to the [THIRD_PARTY.md](THIRD_PARTY.md) file. diff --git a/SoundVisualizer.Core/Ai/AiAudioBuffer.cs b/SoundVisualizer.Core/Ai/AiAudioBuffer.cs new file mode 100644 index 0000000..e9e9937 --- /dev/null +++ b/SoundVisualizer.Core/Ai/AiAudioBuffer.cs @@ -0,0 +1,150 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Numerics; + +namespace SoundVisualizer.Ai +{ + /// + /// AI 만 쓰는 캡처 레이트 모노 링 버퍼(Android 의 AiAudioBuffer.kt). 시각화 경로와 따로 돈다. + /// + /// 캡처 스레드가 로 넣고, 추론 스레드가 로 최근 한 창을 + /// 가져간다. 링은 잠금으로 지킨다. 섞을 때 쓰는 임시 버퍼는 할당을 피하려고 하나를 다시 쓰므로 + /// 를 부르는 스레드는 하나여야 한다. NAudio 는 캡처 버퍼를 재사용하므로 넣는 즉시 링으로 복사한다. + /// 레이트와 채널 수는 만들 때 정해진다. 장치 형식이 바뀌면 새로 만든다. + /// + public sealed class AiAudioBuffer + { + public const int DefaultCaptureSampleRate = 44100; + public const int RingSeconds = 2; + + /// Android 가 받는 레이트(16·44.1·48kHz)의 링 크기. 인덱스를 비트 마스크로 돌리려고 2의 거듭제곱이다. + public const int MaxRingSize = 131072; + + private readonly object _lock = new(); + private readonly int _captureSampleRate; + private readonly int _channels; + private readonly float[] _ring; + private readonly int _ringMask; + private readonly int _retainedSamples; + private readonly int _windowSamples; + private int _tail; + private int _count; + + /// 섞은 모노를 링에 넣기 전에 잠깐 두는 곳. 버퍼마다 할당하지 않으려고 다시 쓴다. + private readonly float[] _monoScratch = new float[MaxRingSize / 2]; + + /// AI 가 받지 않는 레이트(). + public AiAudioBuffer(int captureSampleRate = DefaultCaptureSampleRate, int channels = 2) + { + if (!AiCaptureSampleRatePolicy.IsSupportedForAi(captureSampleRate)) + throw new ArgumentOutOfRangeException(nameof(captureSampleRate), captureSampleRate, "Unsupported AI capture sample rate"); + + _captureSampleRate = captureSampleRate; + _channels = channels; + // 2초를 남긴다. 44.1kHz 보다 낮은 레이트도 44.1kHz 의 2초만큼 둔다(Android 와 같다). + _retainedSamples = Math.Max(DefaultCaptureSampleRate * RingSeconds, captureSampleRate * RingSeconds); + // PC 에서 더한 것: 88.2kHz 부터는 2초가 131,072 를 넘고, 176.4·192kHz 는 한 창(171,990·187,200샘플)조차 넘는다. + // 2초를 담는 가장 작은 2의 거듭제곱으로 늘린다(88.2·96kHz 262,144, 176.4·192kHz 524,288). Android 레이트는 그대로다. + int capacity = Math.Max(MaxRingSize, (int)BitOperations.RoundUpToPowerOf2((uint)_retainedSamples)); + _ring = new float[capacity]; + _ringMask = capacity - 1; + _windowSamples = CaptureAudioMath.CaptureSamplesForOneYamnetWindow(captureSampleRate); + } + + public int SampleRate => _captureSampleRate; + + public int ChannelCount => _channels; + + /// 링에 담을 수 있는 모노 샘플 수(2의 거듭제곱). 한 창과 2초가 모두 들어간다. + public int RingCapacity => _ring.Length; + + public int AvailableSamples + { + get + { + lock (_lock) return _count; + } + } + + public void Reset() + { + lock (_lock) + { + _tail = 0; + _count = 0; + } + } + + /// + /// 이번 캡처 버퍼에서 실제로 녹음된 인터리브 float PCM(채널 수 × 프레임)을 모노로 섞어 링에 넣는다. 캡처 스레드에서 부른다. + /// NAudio 의 바이트 버퍼는 MemoryMarshal.Cast<byte, float>(buffer.AsSpan(0, bytesRecorded)) 로 넘긴다. + /// + public void IngestInterleaved(ReadOnlySpan pcm) + { + if (pcm.IsEmpty || _channels <= 0) return; + + // PC 에서 더한 것: 임시 버퍼(65,536프레임)보다 긴 버퍼는 프레임 단위로 나눠 넣는다. Android 는 여기서 예외를 + // 던졌는데, 캡처 스레드에서 던지면 캡처가 멈춘다. 나눠 넣어도 링에 남는 소리는 같다. + long maxChunkFloats = (long)_monoScratch.Length * _channels; + while (!pcm.IsEmpty) + { + ReadOnlySpan chunk = pcm.Length > maxChunkFloats ? pcm[..(int)maxChunkFloats] : pcm; + int frames = CaptureAudioMath.DownmixInterleavedToMono(chunk, _channels, _monoScratch); + if (frames <= 0) return; + AppendMono(_monoScratch.AsSpan(0, frames)); + pcm = pcm[chunk.Length..]; + } + } + + /// 이미 모노인 캡처 레이트 샘플을 넣는다(테스트·도구용). 어느 스레드에서 불러도 된다. + public void IngestMono(ReadOnlySpan mono) + { + if (mono.IsEmpty) return; + AppendMono(mono); + } + + private void AppendMono(ReadOnlySpan monoChunk) + { + lock (_lock) + { + for (int i = 0; i < monoChunk.Length; i++) + { + _ring[_tail] = monoChunk[i]; + _tail = (_tail + 1) & _ringMask; + } + // 링이 넘치면 가장 오래된 샘플부터 밀려나고, 2초를 넘는 앞부분은 버린다. + // (Android 의 head 포인터는 읽는 쪽이 쓰지 않아 남은 수만 센다. 결과는 같다.) + _count = (int)Math.Min((long)_count + monoChunk.Length, _retainedSamples); + } + } + + /// + /// 최근 .Length 개를 오른쪽에 맞춰 복사하고, 모자라면 앞을 0 으로 채운다. + /// 복사 시점에 링에 있던 샘플 수를 돌려준다. 추론 스레드에서 부른다. + /// + public int CopyTailRightPadded(Span destination) + { + lock (_lock) + { + int take = Math.Min(_count, destination.Length); + int destinationStart = destination.Length - take; + destination[..destinationStart].Clear(); + if (take > 0) + { + int ringStart = (_tail - take) & _ringMask; + int firstPart = Math.Min(take, _ring.Length - ringStart); + _ring.AsSpan(ringStart, firstPart).CopyTo(destination[destinationStart..]); + _ring.AsSpan(0, take - firstPart).CopyTo(destination[(destinationStart + firstPart)..]); + } + return _count; + } + } + + /// 16kHz 한 창()을 만들 만큼 쌓였는지. + public bool HasEnoughForYamnetWindow() + { + lock (_lock) return _count >= _windowSamples; + } + } +} diff --git a/SoundVisualizer.Core/Ai/AiCaptureInferenceGate.cs b/SoundVisualizer.Core/Ai/AiCaptureInferenceGate.cs new file mode 100644 index 0000000..741666d --- /dev/null +++ b/SoundVisualizer.Core/Ai/AiCaptureInferenceGate.cs @@ -0,0 +1,50 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Ai +{ + /// + /// 실시간 AI 파이프라인의 캡처 입구(Android 의 AiCaptureInferenceGate.kt). + /// + /// 소리는 게이트가 닫혀 있어도 늘 링에 넣는다. 그래야 다시 열릴 때 그 앞의 소리까지 한 창으로 본다. + /// 게이트는 준비된 창으로 추론을 시작해도 되는지만 정한다. Android 에서는 internal 이지만, 파이프라인을 앱 쪽에서 + /// 엮을 수 있게 public 으로 둔다. + /// + public sealed class AiCaptureInferenceGate + { + private readonly AiAudioBuffer _audioBuffer; + private readonly AiSilenceGate _silenceGate; + + public AiCaptureInferenceGate(AiAudioBuffer audioBuffer, AiSilenceGate? silenceGate = null) + { + _audioBuffer = audioBuffer ?? throw new ArgumentNullException(nameof(audioBuffer)); + _silenceGate = silenceGate ?? new AiSilenceGate(); + } + + /// 캡처 버퍼 하나를 넣는다. 캡처 스레드 하나에서만 부른다(). + public void IngestInterleaved(ReadOnlySpan pcm, long nowMs) + { + // 링에 먼저 넣고 게이트를 연다. 열린 게이트를 본 추론 스레드는 그 소리가 든 창을 반드시 가져간다. + _audioBuffer.IngestInterleaved(pcm); + _silenceGate.OnInterleavedPcm(pcm, nowMs); + } + + public bool IsInferenceOpen(long nowMs) => _silenceGate.IsOpen(nowMs); + + public void OnInferenceCompleted(long snapshotTimeMs) + { + _silenceGate.OnInferenceCompleted(snapshotTimeMs); + } + + public void OnInferenceFailed(long snapshotTimeMs) + { + _silenceGate.OnInferenceFailed(snapshotTimeMs); + } + + /// 게이트만 비운다. 링은 파이프라인이 따로 비운다(Android 와 같다). + public void Reset() + { + _silenceGate.Reset(); + } + } +} diff --git a/SoundVisualizer.Core/Ai/AiCaptureSampleRatePolicy.cs b/SoundVisualizer.Core/Ai/AiCaptureSampleRatePolicy.cs new file mode 100644 index 0000000..3b6d6e6 --- /dev/null +++ b/SoundVisualizer.Core/Ai/AiCaptureSampleRatePolicy.cs @@ -0,0 +1,56 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Ai +{ + /// + /// 어떤 캡처 레이트를 AI 에 넣을지 정한다(Android 의 AiCaptureSampleRatePolicy.kt). 링 버퍼와 리샘플러가 이 목록을 따른다. + /// + /// 16·44.1·48kHz 는 Android 와 같다. 16kHz 는 바꾸지 않고 그대로 넣는다. + /// PC 에서 더한 것: 루프백 캡처는 장치의 믹스 형식 레이트 그대로 받으므로 레이트가 Windows 의 장치 설정을 따른다. + /// 그래서 흔한 88.2·96·176.4·192kHz 도 FIR 을 레이트에 비례해 늘려() + /// 44.1·48kHz 와 같은 통과·차단 특성으로 받는다. 그 밖의 레이트는 Android 처럼 AI 를 끄고 화면만 돈다. + /// + public static class AiCaptureSampleRatePolicy + { + public const int DefaultCaptureSampleRate = 48000; + + private static readonly int[] PreferredCaptureRates = { 48000, 44100 }; + + public static bool IsSupportedForAi(int sampleRate) => sampleRate switch + { + 16000 or 44100 or 48000 => true, + 88200 or 96000 or 176400 or 192000 => true, // PC 에서 더한 레이트 + _ => false, + }; + + /// + /// 시도할 캡처 레이트의 순서. 보고된 장치 레이트는 AI 가 받을 수 있을 때만 맨 앞에 두고, 아니면 48·44.1kHz 를 먼저 + /// 해 본 뒤 마지막에 화면만 도는 대안으로 남긴다. 0 이하나 null 은 보고되지 않은 것으로 본다. + /// + public static int[] OrderedCaptureCandidates(int? reportedSampleRate) + { + int? reported = reportedSampleRate > 0 ? reportedSampleRate : null; + var candidates = new List(PreferredCaptureRates.Length + 1); + + if (reported is int supported && IsSupportedForAi(supported)) candidates.Add(supported); + foreach (int rate in PreferredCaptureRates) + { + if (!candidates.Contains(rate)) candidates.Add(rate); + } + if (reported is int fallback && !candidates.Contains(fallback)) candidates.Add(fallback); + + return candidates.ToArray(); + } + + /// 후보 가운데 장치가 받아 준 첫 레이트. 하나도 없으면 . + public static int SelectCaptureRate(int? reportedSampleRate, Func isAccepted) + { + foreach (int rate in OrderedCaptureCandidates(reportedSampleRate)) + { + if (isAccepted(rate)) return rate; + } + return DefaultCaptureSampleRate; + } + } +} diff --git a/SoundVisualizer.Core/Ai/AiClassificationResult.cs b/SoundVisualizer.Core/Ai/AiClassificationResult.cs new file mode 100644 index 0000000..05fd2b9 --- /dev/null +++ b/SoundVisualizer.Core/Ai/AiClassificationResult.cs @@ -0,0 +1,57 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; + +namespace SoundVisualizer.Ai +{ + /// + /// 추론 한 번의 결과. 오버레이·진동·개발자 모드가 읽는 불변 스냅샷이다(모바일 AiClassificationResult.kt). + /// + /// ·· 는 히스테리시스를 거친 확정 값이라 이번 프레임보다 + /// 오래된 것일 수 있다. 만 이번 프레임의 값이다. 필드끼리는 한 결과 안에서만 짝이 맞으므로 + /// 여러 필드를 쓰는 쪽은 결과를 한 번만 읽어 쓴다. + /// + public sealed class AiClassificationResult + { + /// 확정한 소리 종류. 화면 색·표시 여부·진동은 이것만 보면 된다. + public required SoundType Coarse { get; init; } + + /// 확정할 때의 영어 클래스 이름(가려진 위협음이나 안전 단서로 바뀐 이름일 수 있다). 아직 확정한 것이 없으면 빈 문자열. + public required string Display { get; init; } + + /// 확정할 때의 확신도. + public required float Confidence { get; init; } + + /// 이번 프레임의 모델 원출력 상위 5개. 사용자 지정으로 거르지 않는다. + public IReadOnlyList Top5 { get; init; } = []; + + /// 이번 프레임에서 안전 단서로 위협음으로 올렸는지. + public bool DangerCuePromoted { get; init; } + + /// 이번 프레임이 확신도 기준을 넘었는지. 못 넘었으면 확정 값은 이전 그대로다. + public required bool MeetsThreshold { get; init; } + + /// 결과를 만든 벽시계 시각(유닉스 밀리초). + public required long TimestampMs { get; init; } + + public double PreprocessMs { get; init; } + + public double YamnetMs { get; init; } + + /// 전처리 시작부터 후처리 끝까지. + public double TotalMs { get; init; } + + /// 이 결과에 쓴 유효한 사용자 지정 수. + public int MappingOverrideCount { get; init; } + + /// 이 결과에 쓴 매핑(). + public string MappingSignature { get; init; } = "default"; + + /// + /// 이 결과를 만들 때 받은 사용자 지정 원본(걸러내기 전). 설정이 바뀌면 다음 추론을 기다리지 않고 옛 결과를 바로 숨기는 데 쓴다 + /// (). + /// + internal FrozenDictionary MappingInputs { get; init; } = FrozenDictionary.Empty; + } +} diff --git a/SoundVisualizer.Core/Ai/AiClassificationStage.cs b/SoundVisualizer.Core/Ai/AiClassificationStage.cs new file mode 100644 index 0000000..69f28e2 --- /dev/null +++ b/SoundVisualizer.Core/Ai/AiClassificationStage.cs @@ -0,0 +1,137 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; +using System.Diagnostics; + +namespace SoundVisualizer.Ai +{ + /// + /// YAMNet 확률 521개 → 화면이 쓸 확정 결과. 모바일 RealtimeAiPipeline 에서 모델 뒤쪽(사용자 매핑 갱신, 투표, 안전 단서, + /// 후처리, 결과 게시)만 옮겼다. 오디오 버퍼·전처리·모델 실행·250ms 주기는 갖고 있지 않다. + /// + /// 스레드: ·Process· 은 추론 스레드에서만(또는 서로 겹치지 않게) 부른다. + /// 은 렌더 스레드 등 어디서 불러도 된다. 결과는 불변이고 참조 하나로 게시하므로 읽는 쪽에 락이 필요 없다. + /// + public sealed class AiClassificationStage + { + private readonly YamnetClassMap _classes; + private readonly YamnetCoarseClassifier _classifier; + private readonly AiPostProcessor _postProcessor = new(); + private FrozenDictionary _previousOverrides = FrozenDictionary.Empty; + private YamnetMappingPolicy _mapping = YamnetMappingPolicy.Default; + private AiClassificationResult? _lastResult; + + public AiClassificationStage(YamnetClassMap classes) + { + _classifier = new YamnetCoarseClassifier(classes); + _classes = classes; + } + + public YamnetClassMap Classes => _classes; + + /// 지금 쓰는 매핑 스냅샷. + public YamnetMappingPolicy Mapping => _mapping; + + /// + /// 설정의 사용자 지정(영어 클래스 이름 → "ambient"/"speech"/"danger")을 반영한다. 추론 틱마다 먼저 부르고, 무음이라 추론을 + /// 건너뛰는 틱에도 부른다. 그래야 이전 매핑에서 확정한 위협음이 소리가 다시 날 때 새 매핑으로 넘어오지 않는다. + /// + /// 원본이 이전과 같으면 아무것도 하지 않는다. 다르면 새 스냅샷을 만들고, 실제 매핑(서명)까지 달라졌으면 히스테리시스를 + /// 처음부터 다시 시작하고 마지막 결과를 지운다. 그랬으면 true 를 돌려준다. + /// + public bool UpdateMapping(IReadOnlyDictionary overrides) + { + ArgumentNullException.ThrowIfNull(overrides); + if (SameEntries(_previousOverrides, overrides)) return false; + + var next = YamnetMappingPolicy.From(overrides, _classes); + bool restarted = next.Signature != _mapping.Signature; + if (restarted) + { + _postProcessor.Reset(); + Volatile.Write(ref _lastResult, null); + } + // 입력을 복사해 둔다. 설정 쪽 맵이 나중에 바뀌어도 이 결과가 어떤 입력으로 만들어졌는지는 그대로 남는다. + _previousOverrides = overrides.ToFrozenDictionary(StringComparer.Ordinal); + _mapping = next; + return restarted; + } + + /// 소요시간을 따로 재지 않을 때. 시간 필드는 이 호출 안에서 걸린 시간만 담는다. + public AiClassificationResult Process(ReadOnlySpan probabilities) => + Process(probabilities, Stopwatch.GetTimestamp(), preprocessMs: 0, yamnetMs: 0); + + /// 모델 출력으로 결과를 만들고 게시한다. + /// YAMNet softmax 확률 521개. 길이가 다르면 . + /// 전처리를 시작할 때 로 잰 값. 전체 소요시간을 여기서부터 잰다. + /// 리샘플·log-mel 에 걸린 시간. 결과에 그대로 담는다. + /// 모델 실행에 걸린 시간. 결과에 그대로 담는다. + public AiClassificationResult Process(ReadOnlySpan probabilities, long startedTimestamp, double preprocessMs, double yamnetMs) + { + var mapping = _mapping; + var pre = _classifier.Classify(probabilities, mapping); + var decision = YamnetSafetyCueDecision.Decide(_classes, pre, mapping); + // 사용자 매핑으로 거른 치명적 단서. 기준 완화와 즉시 확정에 같은 값을 넣어, 후처리가 위협음에서 뺀 라벨을 이름 키워드로 + // 다시 잡지 않게 한다. 그래서 모바일이 만들던 상위 후보 요약 문자열은 쓰이지 않으므로 만들지 않는다. + bool hasCritical = mapping.HasCriticalDangerCue(_classes, decision.PostClassIndex, decision.PostDisplay, pre.Top5); + var post = _postProcessor.Process(new AiPostProcessor.FrameInput( + Coarse: decision.PostCoarse, + Display: decision.PostDisplay, + Confidence: decision.PostConfidence, + DangerCuePromoted: decision.DangerCuePromoted, + HasStrongDangerCue: decision.HasStrongDangerCue, + HasCriticalDangerCue: hasCritical, + CriticalDangerEvent: hasCritical)); + + var result = new AiClassificationResult + { + Coarse = post.UiCoarse, + Display = post.UiDisplay, + Confidence = post.UiConfidence, + Top5 = pre.Top5, + DangerCuePromoted = decision.DangerCuePromoted, + MeetsThreshold = post.MeetsThreshold, + TimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + PreprocessMs = preprocessMs, + YamnetMs = yamnetMs, + TotalMs = Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds, + MappingOverrideCount = mapping.OverrideCount, + MappingSignature = mapping.Signature, + MappingInputs = _previousOverrides, + }; + Volatile.Write(ref _lastResult, result); + return result; + } + + /// + /// 마지막 결과. 아직 없거나, 결과를 만든 뒤 설정의 사용자 지정이 바뀌었으면(다음 추론 전이라도) null 이다. + /// 위협음에서 뺀 소리가 다음 추론까지 위협음 색으로 남지 않게 한다. 화면은 null 이면 환경음으로 그린다. + /// + public AiClassificationResult? LastClassification(IReadOnlyDictionary currentOverrides) + { + ArgumentNullException.ThrowIfNull(currentOverrides); + var last = Volatile.Read(ref _lastResult); + return last is not null && SameEntries(last.MappingInputs, currentOverrides) ? last : null; + } + + /// 캡처를 다시 시작할 때 부른다. 확정 상태와 마지막 결과를 지운다. 매핑 스냅샷은 그대로 둔다. + public void Reset() + { + _postProcessor.Reset(); + Volatile.Write(ref _lastResult, null); + } + + // 모바일의 맵 비교(overrides != previousOverrides)와 같다. 스냅샷 쪽을 돌아서 틱마다 할당하지 않는다. + private static bool SameEntries(FrozenDictionary snapshot, IReadOnlyDictionary current) + { + if (ReferenceEquals(snapshot, current)) return true; + if (snapshot.Count != current.Count) return false; + foreach (var (name, label) in snapshot) + { + if (!current.TryGetValue(name, out var other) || !string.Equals(label, other, StringComparison.Ordinal)) return false; + } + return true; + } + } +} diff --git a/SoundVisualizer.Core/Ai/AiPostProcessor.cs b/SoundVisualizer.Core/Ai/AiPostProcessor.cs new file mode 100644 index 0000000..8c0ca8d --- /dev/null +++ b/SoundVisualizer.Core/Ai/AiPostProcessor.cs @@ -0,0 +1,210 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Ai +{ + /// + /// 확신도 기준과 히스테리시스로 화면에 보낼 라벨을 확정한다. 모바일 AiPostProcessor.kt 와 같다. + /// + /// 라벨이 추론(250ms)마다 흔들리지 않게 환경음·대화음은 기준을 넘는 프레임이 두 번 이어져야 바뀐다. 위협음은 놓치면 안 되므로 + /// 첫 프레임에 바로 확정하고, 위협음에서 벗어날 때도 확신도가 0.27 이상이면 한 프레임으로 충분하다. + /// 기준을 못 넘은 프레임은 아무것도 바꾸지 않으므로 이전 라벨이 그대로 남는다. + /// + /// 상태를 가지므로 한 스레드(추론 스레드)에서만 부른다. + /// + public sealed class AiPostProcessor + { + public const float DefaultBaseThreshold = 0.25f; + public const int CoarseHysteresisThreshold = 2; + public const int DangerHysteresisThreshold = 1; + public const float DangerImmediateSwitchConfidence = 0.28f; + public const float DangerExitRelaxedConfidence = 0.27f; + + // 기준 완화. 안전 단서로 올린 위협음은 0.12, 상위 5개에 강한·치명적 단서가 있는 위협음은 0.20 까지 내린다. + // 대화음은 기본 기준을 낮게 줘도 0.25 아래로 내리지 않는다. + private const float PromotedCueThreshold = 0.12f; + private const float DangerCueThreshold = 0.20f; + private const float SpeechMinThreshold = 0.25f; + + /// 한 프레임의 입력. 안전 단서 판단()을 거친 라벨이다. + /// 이번 프레임의 종류. + /// 이번 프레임의 영어 클래스 이름. 확정되면 화면에 간다. + /// 이번 프레임의 확신도. + /// 안전 단서로 위협음으로 올렸는지(기준을 0.12 까지 내린다). + /// 상위 5개에 강한 위협 단서가 있는지(기준 완화용). + /// 표시 이름이나 상위 5개에 치명적 단서(총기, 폭발, 폭죽)가 있는지(기준 완화용). + /// 상위 후보 이름을 이은 문자열. 가 없을 때만 치명적 사건 판단에 쓴다. + /// + /// 사용자 매핑을 반영해 미리 정한 치명적 사건 여부. 파이프라인은 늘 넣는다. 없으면(null) 이름 키워드로 판단하는데, + /// 그러면 위협음에서 뺀 라벨도 다시 걸리므로 매핑을 아는 쪽은 꼭 넣는다. + /// + public readonly record struct FrameInput( + SoundType Coarse, + string Display, + float Confidence, + bool DangerCuePromoted = false, + bool HasStrongDangerCue = false, + bool HasCriticalDangerCue = false, + string TopKSummary = "", + bool? CriticalDangerEvent = null); + + /// + /// 처리 뒤 상태. 만 이번 프레임의 값이고, Confirmed 는 이전 프레임에서 확정한 값일 수 있다. + /// 는 바뀌려고 기다리는 종류이고, 없으면 null 이다(모바일은 빈 문자열). + /// + public readonly record struct FrameResult( + float EffectiveThreshold, + bool MeetsThreshold, + SoundType? CandidateCoarse, + int CandidateStreak, + SoundType ConfirmedCoarse, + string ConfirmedDisplay, + float ConfirmedConfidence) + { + // 모바일은 옛 Booster 미리보기 때문에 화면용 값을 따로 두었다. 미리보기를 없앤 지금은 늘 확정 값과 같다. + public SoundType UiCoarse => ConfirmedCoarse; + + public string UiDisplay => ConfirmedDisplay; + + public float UiConfidence => ConfirmedConfidence; + } + + private readonly float _baseConfidenceThreshold; + private SoundType _confirmedCoarse = SoundType.Ambient; + private string _confirmedDisplay = ""; + private float _confirmedConfidence; + private SoundType? _candidateCoarse; + private int _candidateStreak; + + public AiPostProcessor(float baseConfidenceThreshold = DefaultBaseThreshold) + { + _baseConfidenceThreshold = baseConfidenceThreshold; + } + + /// + /// 이번 프레임의 기준. 안전 단서로 올린 위협음 0.12, 강한·치명적 단서가 있는 위협음 0.20 까지 내리고, 대화음은 0.25 아래로 내리지 않는다. + /// + public static float ComputeEffectiveThreshold( + SoundType coarse, + float baseThreshold, + bool hasStrongDangerCue, + bool hasCriticalDangerCue, + bool dangerCuePromoted = false) + { + float effective = baseThreshold; + if (coarse == SoundType.Danger && dangerCuePromoted) + { + effective = Math.Min(effective, PromotedCueThreshold); + } + else if (coarse == SoundType.Danger && (hasStrongDangerCue || hasCriticalDangerCue)) + { + effective = Math.Min(effective, DangerCueThreshold); + } + else if (coarse == SoundType.Speech) + { + effective = Math.Max(effective, SpeechMinThreshold); + } + return effective; + } + + /// 치명적 단서: 총기, 폭발, 폭죽. 확신도가 낮아도 위협음으로 바로 확정한다. + public static bool IsCriticalDangerKeyword(string? name) + { + if (string.IsNullOrEmpty(name)) return false; + if (YamnetSafetyCueDecision.IsGunshotKeyword(name)) return true; + string s = name.ToLowerInvariant(); + return s.Contains("explosion") || s.Contains("fireworks") || s.Contains("firecracker"); + } + + public static bool IsCriticalDangerEvent(string? display, string? topKSummary) => + IsCriticalDangerKeyword(display) || IsCriticalDangerKeyword(topKSummary); + + /// 처음 상태(환경음, 이름 없음)로 돌린다. 캡처를 다시 시작하거나 사용자 매핑이 바뀌면 부른다. + public void Reset() + { + _confirmedCoarse = SoundType.Ambient; + _confirmedDisplay = ""; + _confirmedConfidence = 0f; + _candidateCoarse = null; + _candidateStreak = 0; + } + + public FrameResult Process(FrameInput frame) + { + float effective = ComputeEffectiveThreshold( + frame.Coarse, + _baseConfidenceThreshold, + frame.HasStrongDangerCue, + frame.HasCriticalDangerCue, + frame.DangerCuePromoted); + bool meets = frame.Confidence >= effective; + bool critical = frame.CriticalDangerEvent ?? IsCriticalDangerEvent(frame.Display, frame.TopKSummary); + + // 매개변수 없는 생성자로 만든 입력은 이름이 null 이다. 확정 이름은 늘 문자열로 둔다. + ApplyCoarseHysteresis(meets, frame.Coarse, frame.Display ?? "", frame.Confidence, critical); + + return new FrameResult( + EffectiveThreshold: effective, + MeetsThreshold: meets, + CandidateCoarse: _candidateCoarse, + CandidateStreak: _candidateStreak, + ConfirmedCoarse: _confirmedCoarse, + ConfirmedDisplay: _confirmedDisplay, + ConfirmedConfidence: _confirmedConfidence); + } + + private void ApplyCoarseHysteresis(bool meetsThreshold, SoundType newCoarse, string display, float confidence, bool criticalDangerEvent) + { + if (!meetsThreshold) return; + + if (newCoarse == _confirmedCoarse) + { + _candidateStreak = 0; + _candidateCoarse = null; + _confirmedDisplay = display; + _confirmedConfidence = confidence; + return; + } + + if (newCoarse == SoundType.Danger && + (confidence >= DangerImmediateSwitchConfidence || criticalDangerEvent)) + { + Confirm(newCoarse, display, confidence); + return; + } + + if (newCoarse == _candidateCoarse) + { + _candidateStreak++; + } + else + { + _candidateCoarse = newCoarse; + _candidateStreak = 1; + } + + int required = newCoarse == SoundType.Danger ? DangerHysteresisThreshold : CoarseHysteresisThreshold; + // 위협음이 끝났다는 확신이 충분하면 한 프레임 만에 놓아 준다. 위협음 색이 소리보다 오래 남지 않게 한다. + if (_confirmedCoarse == SoundType.Danger && + newCoarse != SoundType.Danger && + confidence >= DangerExitRelaxedConfidence) + { + required = 1; + } + + if (_candidateStreak >= required) + { + Confirm(newCoarse, display, confidence); + } + } + + private void Confirm(SoundType coarse, string display, float confidence) + { + _confirmedCoarse = coarse; + _confirmedDisplay = display; + _confirmedConfidence = confidence; + _candidateStreak = 0; + _candidateCoarse = null; + } + } +} diff --git a/SoundVisualizer.Core/Ai/AiSilenceGate.cs b/SoundVisualizer.Core/Ai/AiSilenceGate.cs new file mode 100644 index 0000000..259db8c --- /dev/null +++ b/SoundVisualizer.Core/Ai/AiSilenceGate.cs @@ -0,0 +1,114 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Ai +{ + /// + /// 최근 캡처에 소리가 있는 동안만 실시간 추론을 열어 둔다(Android 의 AiSilenceGate.kt). 조용할 때 CPU 를 쓰지 않으려는 것이다. + /// + /// 마지막 소리가 YAMNet 창을 빠져나간 뒤에도, 완전히 조용한 창으로 추론이 두 번 끝나 후처리의 히스테리시스가 정리될 + /// 때까지는 열어 둔다. 그래야 소리가 그친 뒤 마지막 라벨이 남지 않는다. 캡처 스레드가 소리를 알리고 추론 스레드가 결과를 + /// 알리므로 상태는 한 잠금 안에서 함께 바꾼다. 시각은 단조 증가하는 밀리초( 등)다. + /// + public sealed class AiSilenceGate + { + /// 소리가 있다고 보는 원래 크기. Android 에서 진동과 시각화가 깨어나는 기준(0.01)과 같다. + public const float ActiveThreshold = 0.01f; + + /// 실시간으로 계속 들어오는 소리가 YAMNet 창(15,600샘플 = 0.975초)을 다 빠져나가는 데 걸리는 시간. + public const long YamnetWindowMs = CaptureAudioMath.RequiredMono16kSamples * 1000L / CaptureAudioMath.TargetSampleRate; + + /// + /// 가장 긴 종류 전환 히스테리시스를 정리하는 데 필요한 완전히 조용한 추론 수. + /// Android 의 AiPostProcessor.COARSE_HYSTERESIS_THRESHOLD(2) 와 같아야 한다. + /// + public const int RequiredSilentInferenceCompletions = 2; + + /// + /// 추론이 계속 실패하면 창 네 개만큼 지난 뒤 다시 시도하지 않는다. 성공하면 이 대비를 지우고 정상 횟수를 그대로 요구하므로 + /// 잠깐의 실패로 정리가 짧아지지 않는다. + /// + public const long FailureFallbackMs = YamnetWindowMs * 4; + + private const long NoTimestamp = long.MinValue; + + private readonly object _stateLock = new(); + private readonly float _activeThreshold; + private long _lastActiveInputMs = NoTimestamp; + private int _silentInferenceCompletions; + private long _firstConsecutiveFailureMs = NoTimestamp; + + public AiSilenceGate(float activeThreshold = ActiveThreshold) + { + _activeThreshold = activeThreshold; + } + + /// + /// 캡처 버퍼 하나를 본다. 섞기 전 인터리브 PCM 의 최고값으로 재므로 채널 수와 상관없이 어느 한 채널(LFE 포함)만 커도 열린다. + /// 캡처 스레드에서 부른다. + /// + public void OnInterleavedPcm(ReadOnlySpan interleaved, long nowMs) + { + if (CaptureAudioMath.MaxAbsoluteInterleavedPeak(interleaved) > _activeThreshold) + { + lock (_stateLock) + { + _lastActiveInputMs = nowMs; + _silentInferenceCompletions = 0; + _firstConsecutiveFailureMs = NoTimestamp; + } + } + } + + public bool IsOpen(long nowMs) + { + lock (_stateLock) + { + if (_lastActiveInputMs == NoTimestamp) return false; + if (nowMs - _lastActiveInputMs < YamnetWindowMs) return true; + if (_silentInferenceCompletions >= RequiredSilentInferenceCompletions) return false; + return _firstConsecutiveFailureMs == NoTimestamp || + nowMs - _firstConsecutiveFailureMs < FailureFallbackMs; + } + } + + /// 에 가져간 창의 추론이 성공했다. + public void OnInferenceCompleted(long snapshotTimeMs) + { + lock (_stateLock) + { + if (_lastActiveInputMs == NoTimestamp) return; + if (snapshotTimeMs - _lastActiveInputMs < YamnetWindowMs) return; + _firstConsecutiveFailureMs = NoTimestamp; + if (_silentInferenceCompletions < RequiredSilentInferenceCompletions) + { + _silentInferenceCompletions++; + } + } + } + + /// 소리가 다 빠져나간 창에서 연달아 실패할 때만 제한 시간을 잰다. + public void OnInferenceFailed(long snapshotTimeMs) + { + lock (_stateLock) + { + if (_lastActiveInputMs == NoTimestamp) return; + if (snapshotTimeMs - _lastActiveInputMs < YamnetWindowMs) return; + if (_firstConsecutiveFailureMs == NoTimestamp) + { + _firstConsecutiveFailureMs = snapshotTimeMs; + } + } + } + + public void Reset() + { + lock (_stateLock) + { + _lastActiveInputMs = NoTimestamp; + _silentInferenceCompletions = 0; + _firstConsecutiveFailureMs = NoTimestamp; + } + } + } +} diff --git a/SoundVisualizer.Core/Ai/CaptureAudioMath.cs b/SoundVisualizer.Core/Ai/CaptureAudioMath.cs new file mode 100644 index 0000000..8959651 --- /dev/null +++ b/SoundVisualizer.Core/Ai/CaptureAudioMath.cs @@ -0,0 +1,338 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Ai +{ + /// + /// 캡처한 소리를 YAMNet 입력(16kHz 모노)으로 바꾸는 계산. Android 의 CaptureAudioMath.kt 를 옮겼다. + /// + /// 1·2채널과 16·44.1·48kHz 는 Android 와 같은 순서로 같은 float·double 계산을 한다. Python 기준 구현과 1e-5 안으로 + /// 맞추는 기준 데이터 테스트가 두 앱을 함께 묶는다. + /// PC 에서 더한 것은 두 가지다. 5.1·7.1 채널을 섞는 법()과 + /// 88.2·96·176.4·192kHz 에서 FIR 을 레이트에 비례해 늘리는 것()이다. + /// + public static class CaptureAudioMath + { + public const int TargetSampleRate = 16000; + + /// YAMNet 한 창(0.975초)의 16kHz 샘플 수. log-mel 의 창 400 + 홉 160 × (96프레임 − 1) = 15,600 이다. + public const int RequiredMono16kSamples = 400 + 160 * (96 - 1); + + // 44.1·48kHz 설계(차단 7.8kHz, β 8.6). YAMNet 멜 대역의 끝(7,500Hz)은 −3dB 안으로 남는다. 8~8.5kHz 는 덜 막히지만 + // 16kHz 로 줄이면 7.5~8kHz 로 접혀 멜 대역 밖에 떨어지고, 9kHz 부터는 45dB 넘게 막힌다. + private const int BaseFirTaps = 96; + private const double FirCutoffHz = 7800.0; + private const double FirKaiserBeta = 8.6; + private const int MaxCachedPhases = 1024; + + // 5.1·7.1 → 스테레오 계수. 스피커로 다시 내보낼 때 쓰는 ChannelDownmix 와 같은 ITU-R BS.775 값이다. + // 둘이 어긋나지 않는지는 테스트가 ChannelDownmix 의 결과와 비교해 지킨다. + private const float KCenter = 0.707f; // -3dB + private const float KSide = 0.707f; // -3dB + private const float KBack = 0.500f; // -6dB + + /// 레이트마다 한 번 만들어 250ms 마다 다시 쓴다. 만든 뒤에는 바뀌지 않아 잠금 밖에서 읽어도 된다. + private sealed class FirTable + { + public FirTable(int taps, int phaseCount, float[] coefficients) + { + Taps = taps; + PhaseCount = phaseCount; + Coefficients = coefficients; + } + + public int Taps { get; } + + public int PhaseCount { get; } + + /// 위상 p 의 계수는 [p × Taps, (p + 1) × Taps) 에 있다. + public float[] Coefficients { get; } + } + + private static readonly object FirTablesLock = new(); + private static readonly Dictionary FirTables = new(); + + /// 16kHz 15,600샘플을 만드는 데 필요한 캡처 레이트의 샘플 수(올림). + public static int CaptureSamplesForOneYamnetWindow(int captureSampleRate) + { + return (int)Math.Ceiling(RequiredMono16kSamples * (double)captureSampleRate / TargetSampleRate); + } + + /// + /// 섞기 전 캡처 PCM 의 가장 큰 절댓값. 무음 게이트가 쓴다. + /// 섞은 뒤에 재면 좌우 위상이 반대인 짧은 소리가 서로 지워져 게이트가 열리지 않는다. + /// PC 의 5.1·7.1 도 LFE 를 포함한 모든 채널을 그대로 본다. NaN 은 건너뛴다. + /// + public static float MaxAbsoluteInterleavedPeak(ReadOnlySpan interleaved) + { + float peak = 0f; + foreach (float value in interleaved) + { + float sample = Math.Abs(value); + if (sample > peak) peak = sample; + } + return peak; + } + + /// + /// 인터리브 PCM 을 모노 프레임으로 섞어 앞쪽에 쓰고 프레임 수를 돌려준다. + /// 채널 수로 나눠 떨어지지 않는 끝의 샘플은 버린다. + /// + /// 1채널은 그대로, 2채널은 (L+R)/2 로 Android 와 같다. + /// PC 에서 더한 것: 6채널(FL FR FC LFE SL SR)과 8채널(FL FR FC LFE BL BR SL SR)은 ChannelDownmix 와 같은 + /// ITU-R BS.775 계수로 스테레오를 만든 뒤(LFE 는 뺀다) (L+R)/2 를 한다. 예전 PC 코드는 FL·FR·FC 만 AI 에 넣어 + /// 뒤·옆 스피커로만 나는 소리를 AI 가 듣지 못했다. 스피커용과 달리 ±1 로 자르지 않는다. 자르면 큰 소리의 파형이 + /// 바뀌어 AI 가 다른 소리로 듣는다(AI 입력은 소리로 내보내지 않으므로 1 을 넘어도 된다). + /// 그 밖의 채널 수는 Android 처럼 모든 채널의 평균이다. + /// + /// 이 프레임 수보다 짧다. + public static int DownmixInterleavedToMono(ReadOnlySpan interleaved, int channels, Span destination) + { + if (interleaved.Length <= 0 || channels <= 0) return 0; + int frames = interleaved.Length / channels; + if (frames <= 0) return 0; + if (frames > destination.Length) + throw new ArgumentException($"destination must hold {frames} frames", nameof(destination)); + + switch (channels) + { + case 1: + interleaved[..frames].CopyTo(destination); + return frames; + case 6: + Downmix51ToMono(interleaved, frames, destination); + return frames; + case 8: + Downmix71ToMono(interleaved, frames, destination); + return frames; + } + + int si = 0; + for (int f = 0; f < frames; f++) + { + float sum = 0f; + for (int c = 0; c < channels; c++) + { + sum += interleaved[si++]; + } + destination[f] = sum / channels; + } + return frames; + } + + /// L = FL + 0.707·FC + 0.707·SL, R 도 같은 꼴(ChannelDownmix 와 같은 식). LFE 는 뺀다. + private static void Downmix51ToMono(ReadOnlySpan src, int frames, Span dst) + { + for (int f = 0; f < frames; f++) + { + int o = f * 6; + float fl = src[o + 0]; + float fr = src[o + 1]; + float fc = src[o + 2]; + // o + 3 은 LFE + float sl = src[o + 4]; + float sr = src[o + 5]; + + float left = fl + KCenter * fc + KSide * sl; + float right = fr + KCenter * fc + KSide * sr; + dst[f] = (left + right) / 2f; + } + } + + /// L = FL + 0.707·FC + 0.707·SL + 0.5·BL, R 도 같은 꼴(ChannelDownmix 와 같은 식). LFE 는 뺀다. + private static void Downmix71ToMono(ReadOnlySpan src, int frames, Span dst) + { + for (int f = 0; f < frames; f++) + { + int o = f * 8; + float fl = src[o + 0]; + float fr = src[o + 1]; + float fc = src[o + 2]; + // o + 3 은 LFE + float bl = src[o + 4]; + float br = src[o + 5]; + float sl = src[o + 6]; + float sr = src[o + 7]; + + float left = fl + KCenter * fc + KSide * sl + KBack * bl; + float right = fr + KCenter * fc + KSide * sr + KBack * br; + dst[f] = (left + right) / 2f; + } + } + + /// + /// 이 레이트에서 쓰는 FIR 탭 수. Android 가 받는 44.1·48kHz 와 그 밖의 레이트는 96탭이다. + /// + /// PC 에서 더한 것: 88.2·176.4kHz 는 96 × 레이트 ÷ 44,100, 96·192kHz 는 96 × 레이트 ÷ 48,000 (192·384탭)으로 늘린다. + /// Kaiser 창 FIR 의 전이 대역 폭(Hz)은 레이트 ÷ 탭 수에 비례하므로 이렇게 해야 7.8kHz 언저리의 통과·차단이 + /// 44.1·48kHz 와 같다. 96탭 그대로면 96kHz 에서 전이 대역이 두 배로 넓어져 7kHz 가 깎이고 9kHz 가 덜 막힌다. + /// + public static int FirTapsFor(int sourceSampleRate) => sourceSampleRate switch + { + 88200 or 176400 => BaseFirTaps * sourceSampleRate / 44100, + 96000 or 192000 => BaseFirTaps * sourceSampleRate / 48000, + _ => BaseFirTaps, + }; + + /// + /// 모노 캡처를 16kHz 로 바꿔 전체를 채운다(보통 개). + /// + /// 16kHz 는 그대로 복사한다. 16kHz 보다 낮으면 예전 선형 보간을 그대로 둔다(AI 는 이 레이트를 받지 않는다). + /// 더 높으면 Kaiser 창(β 8.6) sinc 저역통과 FIR(차단 7.8kHz)을 출력 샘플마다 원본 위치에서 계산한다. + /// 위치는 정수(i × 레이트 ÷ 16,000 의 몫과 나머지)로 세어 15,600샘플 동안 어긋나지 않고, 나머지로 고르는 위상별 + /// 계수 표는 레이트마다 한 번만 만든다(위상 수: 44.1kHz 160개, 88.2kHz 80개, 176.4kHz 40개, 48·96·192kHz 1개). + /// 원본 범위 밖은 0 으로 보므로 창 가장자리는 FIR 의 일부만 더한다. 곱과 합은 double, 계수와 결과는 float 로 Android 와 같다. + /// + public static void ResampleMonoFloatTo16kCustom(ReadOnlySpan source, int sourceSampleRate, Span destination) + { + destination.Clear(); + if (source.Length <= 0) return; + + if (sourceSampleRate == TargetSampleRate) + { + int copyLength = Math.Min(source.Length, destination.Length); + source[..copyLength].CopyTo(destination); + return; + } + + if (sourceSampleRate < TargetSampleRate) + { + LinearResample(source, sourceSampleRate, destination); + return; + } + + int sourceAvailable = source.Length; + FirTable table = FirTableFor(sourceSampleRate); + int taps = table.Taps; + int halfTaps = taps / 2; + float[] coefficients = table.Coefficients; + for (int i = 0; i < destination.Length; i++) + { + long positionNumerator = (long)i * sourceSampleRate; + int center = (int)(positionNumerator / TargetSampleRate); + int phase = PhaseIndex(positionNumerator % TargetSampleRate, table.PhaseCount); + int phaseStart = phase * taps; + int @base = center - halfTaps + 1; + int firstTap = Math.Max(0, -@base); + int lastTapExclusive = Math.Min(taps, sourceAvailable - @base); + double sum = 0.0; + for (int tap = firstTap; tap < lastTapExclusive; tap++) + { + sum += (double)coefficients[phaseStart + tap] * source[@base + tap]; + } + destination[i] = (float)sum; + } + } + + /// 16kHz 보다 낮은 레이트(AI 는 받지 않는다)에서 예전 보간을 그대로 둔다. + private static void LinearResample(ReadOnlySpan source, int sourceSampleRate, Span destination) + { + int sourceAvailable = source.Length; + double factor = (double)sourceSampleRate / TargetSampleRate; + for (int i = 0; i < destination.Length; i++) + { + double sourcePosition = i * factor; + int index1 = (int)sourcePosition; + int index2 = index1 + 1; + if (index1 >= sourceAvailable) continue; + float alpha = (float)(sourcePosition - index1); + float value1 = source[index1]; + float value2 = index2 < sourceAvailable ? source[index2] : value1; + destination[i] = (1f - alpha) * value1 + alpha * value2; + } + } + + private static FirTable FirTableFor(int sourceSampleRate) + { + if (sourceSampleRate <= 0) + throw new ArgumentOutOfRangeException(nameof(sourceSampleRate), sourceSampleRate, "sample rate must be positive"); + + lock (FirTablesLock) + { + if (FirTables.TryGetValue(sourceSampleRate, out FirTable? cached)) return cached; + + int taps = FirTapsFor(sourceSampleRate); + int exactPhaseCount = TargetSampleRate / GreatestCommonDivisor(sourceSampleRate, TargetSampleRate); + int phaseCount = Math.Min(exactPhaseCount, MaxCachedPhases); + var coefficients = new float[phaseCount * taps]; + for (int phase = 0; phase < phaseCount; phase++) + { + CreateFirCoefficients( + sourceSampleRate, + (double)phase / phaseCount, + coefficients.AsSpan(phase * taps, taps)); + } + + var table = new FirTable(taps, phaseCount, coefficients); + FirTables[sourceSampleRate] = table; + return table; + } + } + + private static int PhaseIndex(long remainder, int phaseCount) + { + if (phaseCount == 1) return 0; + long rounded = (remainder * phaseCount + TargetSampleRate / 2) / TargetSampleRate; + return (int)(rounded % phaseCount); + } + + /// + /// 원본 위치가 정수에서 만큼 지난 출력 샘플의 계수. 탭 수는 넘겨받은 칸 수이고, + /// 합이 1 이 되게 맞춰 상수 입력이 그대로 나온다. 계수를 float 로 한 번 줄인 뒤 double 합으로 나누는 순서까지 Android 와 같다. + /// + private static void CreateFirCoefficients(int sourceSampleRate, double fractionalPosition, Span coefficients) + { + int taps = coefficients.Length; + int halfTaps = taps / 2; + double windowScale = BesselI0(FirKaiserBeta); + double normalizedCutoff = 2.0 * FirCutoffHz / sourceSampleRate; + double sum = 0.0; + for (int tap = 0; tap < taps; tap++) + { + int sampleOffset = tap - halfTaps + 1; + double time = sampleOffset - fractionalPosition; + double normalizedRadius = Math.Min(1.0, Math.Abs(time) / halfTaps); + double window = BesselI0(FirKaiserBeta * Math.Sqrt(1.0 - normalizedRadius * normalizedRadius)) / windowScale; + double value = normalizedCutoff * Sinc(normalizedCutoff * time) * window; + coefficients[tap] = (float)value; + sum += value; + } + for (int tap = 0; tap < taps; tap++) + { + coefficients[tap] = (float)(coefficients[tap] / sum); + } + } + + private static double Sinc(double value) + { + return Math.Abs(value) < 1e-12 ? 1.0 : Math.Sin(Math.PI * value) / (Math.PI * value); + } + + private static double BesselI0(double value) + { + double term = 1.0; + double sum = 1.0; + int k = 1; + while (true) + { + term *= (value * value / 4.0) / ((double)k * k); + sum += term; + if (term < sum * 1e-15) return sum; + k++; + } + } + + private static int GreatestCommonDivisor(int a, int b) + { + int x = Math.Abs(a); + int y = Math.Abs(b); + while (y != 0) + { + int remainder = x % y; + x = y; + y = remainder; + } + return x; + } + } +} diff --git a/SoundVisualizer.Core/Ai/InternalsVisibleToTests.cs b/SoundVisualizer.Core/Ai/InternalsVisibleToTests.cs new file mode 100644 index 0000000..74cd261 --- /dev/null +++ b/SoundVisualizer.Core/Ai/InternalsVisibleToTests.cs @@ -0,0 +1,7 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Runtime.CompilerServices; + +// 라벨 목록을 공개하지 않고도, 목록의 이름이 실제 YAMNet 클래스 이름과 맞는지 테스트가 직접 확인할 수 있게 한다. +[assembly: InternalsVisibleTo("SoundVisualizer.Tests")] diff --git a/SoundVisualizer.Core/Ai/ModelFileIntegrity.cs b/SoundVisualizer.Core/Ai/ModelFileIntegrity.cs new file mode 100644 index 0000000..160d9d6 --- /dev/null +++ b/SoundVisualizer.Core/Ai/ModelFileIntegrity.cs @@ -0,0 +1,62 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Security.Cryptography; + +namespace SoundVisualizer.Ai +{ + /// + /// 앱과 함께 배포한 모델 파일이 고정해 둔 바로 그 파일인지 확인한다. Android 의 ModelCacheIntegrity.kt 에서 검사 부분만 옮겼다. + /// + /// Android 는 assets 를 앱 저장소로 복사하면서 크기와 SHA-256 을 맞춰 보고, 어긋난 사본은 지우고 다시 복사한다. + /// PC 는 실행 파일 옆의 파일을 그대로 쓰므로 다시 복사할 원본이 없다. 그래서 불러오기 전에 확인만 하고, 어긋나면 + /// 예외를 던져 앱이 AI 없이 화면만 돌게 한다. 잘린 파일, 크기는 같은데 깨진 파일, 다른 버전의 모델이 모두 여기서 걸린다. + /// + internal static class ModelFileIntegrity + { + /// 파일 하나의 기대값. 은 소문자 16진수 64자다. + internal sealed record ExpectedFile(string Name, long ByteCount, string Sha256); + + /// + /// 파일이 있고 크기와 SHA-256 이 모두 맞는지. 크기가 다르면 해시를 계산하지 않는다. + /// 읽다가 난 I/O 오류는 false 로 바꾸지 않고 그대로 던진다(Android 와 같다). + /// + public static bool IsValid(string path, ExpectedFile expected) + { + ArgumentNullException.ThrowIfNull(expected); + var file = new FileInfo(path); + return file.Exists && file.Length == expected.ByteCount && Sha256Hex(path) == expected.Sha256; + } + + /// + /// 의 파일을 모두 확인한다. 무엇이 어떻게 다른지를 메시지에 담는다. + /// + /// 파일이 없다. + /// 크기나 SHA-256 이 배포한 파일과 다르다. + public static void Verify(string directory, IReadOnlyList files) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(files); + foreach (var expected in files) + { + string path = Path.Combine(directory, expected.Name); + var file = new FileInfo(path); + if (!file.Exists) + throw new FileNotFoundException($"Model file '{expected.Name}' is missing", path); + if (file.Length != expected.ByteCount) + throw new InvalidDataException( + $"Model file '{expected.Name}' is {file.Length} bytes, expected {expected.ByteCount} (truncated or a different model): {path}"); + string actual = Sha256Hex(path); + if (actual != expected.Sha256) + throw new InvalidDataException( + $"Model file '{expected.Name}' has SHA-256 {actual}, expected {expected.Sha256} (damaged or a different model): {path}"); + } + } + + internal static string Sha256Hex(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexStringLower(SHA256.HashData(stream)); + } + } +} diff --git a/SoundVisualizer.Core/Ai/QualcommSourceAudioPreprocessor.cs b/SoundVisualizer.Core/Ai/QualcommSourceAudioPreprocessor.cs new file mode 100644 index 0000000..113b637 --- /dev/null +++ b/SoundVisualizer.Core/Ai/QualcommSourceAudioPreprocessor.cs @@ -0,0 +1,231 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Ai +{ + /// + /// Qualcomm 의 YAMNet 레시피가 쓰는 원본 전처리를 옮겨, 16kHz 모노 15,600샘플(0.975초)을 YAMNet 입력 + /// [1, 1, 96, 64] 로그멜로 바꾼다. 결과는 시간 우선(time * 64 + mel)으로 편 6,144칸이다. + /// + /// 출처(고정한 버전): + /// - qai-hub-models commit 7925bdd04586a87b09914164903f51b3f889c4aa + /// - yamnet/app.py::preprocessing_yamnet_from_source + /// - yamnet/model.py 가 torch_audioset commit e8852c5 를 고정한다 + /// - w-hc/torch_audioset commit e8852c53becef811784754a2de9c4617d8db2156 + /// - torch_input_processing.py::WaveformToInput + /// - torch_input_processing.py::VGGishLogMelSpectrogram + /// + /// torch.stft(center=True, pad_mode="reflect") 처럼 양쪽을 256샘플씩 반사로 늘려 98프레임을 만들고 앞 96프레임만 쓴다. + /// 400점 periodic Hann 창을 512점 FFT 가운데에 놓고, 파워가 아닌 크기(magnitude)에 torchaudio 기본 HTK 멜 필터 + /// (연속 주파수 위의 삼각형, 125~7,500Hz, 64대역, 정규화 없음)를 곱한 뒤 ln(x + 0.001) 을 취한다. + /// + /// 모바일 판 QualcommSourceAudioPreprocessor.kt 를 계산 순서와 자료형(double)까지 그대로 옮겼다. Python 기준 + /// 데이터와의 오차를 Kotlin 과 같은 한도(최대 1e-5, 평균 1e-7) 안에 두려면 순서를 바꾸거나 float 로 줄이면 안 된다. + /// 다만 창과 멜 경계를 만드는 cos·expm1 은 플랫폼마다 마지막 자리(1ulp)가 달라 표가 Kotlin 과 비트까지 같지는 않다. + /// 그 차이는 1e-15 수준이라 float 로 줄인 출력에는 드물게 1ulp 로만 드러날 수 있다. + /// + /// 창·멜 필터·FFT 표는 생성할 때 한 번만 만든다. 작업 버퍼를 인스턴스가 들고 있으므로 스레드 안전하지 않다. + /// 추론 스레드 하나가 인스턴스 하나를 쓴다. + /// + public sealed class QualcommSourceAudioPreprocessor + { + public const int SampleRate = 16000; + public const int MelBins = 64; + public const int TimeFrames = 96; + public const int WindowLength = 400; + public const int HopLength = 160; + public const int FftSize = 512; + public const float MelFMin = 125f; + public const float MelFMax = 7500f; + + /// + /// 로그 앞에 더하는 값. Kotlin 은 0.001f 를 double 로 넓혀(0.0010000000474974513) 더하므로 float 로 둔다. + /// 0.001(double) 로 바꾸면 기준 데이터와의 오차가 Kotlin 과 달라진다. + /// + public const float LogEps = 0.001f; + + /// 한 번에 쓰는 입력 길이. 400 + 160 × 95 = 15,600샘플. + public const int RequiredMono16kSamples = WindowLength + HopLength * (TimeFrames - 1); + + /// 출력 길이. 96 × 64 = 6,144칸. + public const int LogMelSize = TimeFrames * MelBins; + + private const int FreqBins = FftSize / 2 + 1; + private const int ReflectPadding = FftSize / 2; + private const int WindowOffset = (FftSize - WindowLength) / 2; + + private readonly double[] _hannWindow = CreateHannWindow(WindowLength); + private readonly double[][] _melFilterBank = + CreateTorchaudioHtkMelFilterBank(MelBins, FftSize, SampleRate, MelFMin, MelFMax); + private readonly int[] _melStartBins = new int[MelBins]; + private readonly int[] _melEndBins = new int[MelBins]; + private readonly Radix2Fft _fft = new(FftSize); + + private readonly float[] _fitted = new float[RequiredMono16kSamples]; + private readonly double[] _fftReal = new double[FftSize]; + private readonly double[] _fftImag = new double[FftSize]; + private readonly double[] _magnitude = new double[FreqBins]; + + public QualcommSourceAudioPreprocessor() + { + // 필터마다 0 이 아닌 구간만 더한다. 0 을 더해도 합은 그대로이므로 결과는 같고 계산만 준다. + for (int mel = 0; mel < MelBins; mel++) + { + double[] weights = _melFilterBank[mel]; + int start = 0; + while (start < FreqBins && weights[start] == 0.0) start++; + int last = FreqBins - 1; + while (last >= start && weights[last] == 0.0) last--; + _melStartBins[mel] = start; + _melEndBins[mel] = Math.Min(FreqBins, last + 1); + } + } + + /// + /// 의 로그멜을 새 배열에 담아 돌려준다(Kotlin 과 같은 꼴). 실시간 경로에서는 매번 새 배열을 + /// 만들지 않도록 버퍼를 받는 쪽을 쓴다. + /// + public float[] ComputeLogMelSpectrogram(ReadOnlySpan mono16k) + { + var logMel = new float[LogMelSize]; + ComputeLogMelSpectrogram(mono16k, logMel); + return logMel; + } + + /// + /// 16kHz 모노 소리의 [1, 1, 96, 64] 로그멜을 앞쪽 6,144칸에 시간 우선으로 쓴다. + /// 입력이 15,600샘플보다 길면 앞 15,600샘플만 쓰고, 짧으면 뒤를 0 으로 채운 다음 반사 패딩을 한다(고정한 원본과 같다). + /// 메모리를 새로 잡지 않는다. + /// + public void ComputeLogMelSpectrogram(ReadOnlySpan mono16k, Span logMel) + { + if (logMel.Length < LogMelSize) + throw new ArgumentException($"logMel must hold {LogMelSize} values", nameof(logMel)); + + int copyLength = Math.Min(mono16k.Length, RequiredMono16kSamples); + mono16k[..copyLength].CopyTo(_fitted); + Array.Clear(_fitted, copyLength, RequiredMono16kSamples - copyLength); + + for (int time = 0; time < TimeFrames; time++) + { + Array.Clear(_fftReal); + Array.Clear(_fftImag); + + // torch.stft(center=True, pad_mode="reflect") 는 양쪽을 256샘플씩 늘린 뒤, + // 400점 창을 512점 FFT 가운데(56번째 칸부터)에 놓는다. + int unpaddedFrameStart = time * HopLength - ReflectPadding; + for (int windowIndex = 0; windowIndex < WindowLength; windowIndex++) + { + int fftIndex = WindowOffset + windowIndex; + int sourceIndex = ReflectIndex(unpaddedFrameStart + fftIndex, RequiredMono16kSamples); + _fftReal[fftIndex] = (double)_fitted[sourceIndex] * _hannWindow[windowIndex]; + } + + _fft.Transform(_fftReal, _fftImag); + for (int frequency = 0; frequency < FreqBins; frequency++) + { + double real = _fftReal[frequency]; + double imaginary = _fftImag[frequency]; + _magnitude[frequency] = Math.Sqrt(real * real + imaginary * imaginary); + } + + for (int mel = 0; mel < MelBins; mel++) + { + double sum = 0.0; + double[] weights = _melFilterBank[mel]; + for (int frequency = _melStartBins[mel]; frequency < _melEndBins[mel]; frequency++) + { + sum += weights[frequency] * _magnitude[frequency]; + } + logMel[time * MelBins + mel] = (float)Math.Log(sum + (double)LogEps); + } + } + } + + /// numpy·torch 의 reflect: 가장자리 샘플을 되풀이하지 않고 접는다(-1 → 1, length → length - 2). + private static int ReflectIndex(int index, int length) + { + System.Diagnostics.Debug.Assert(length > 1, "reflect padding requires at least two samples"); + int reflected = index; + while (reflected < 0 || reflected >= length) + { + reflected = reflected < 0 ? -reflected : 2 * length - 2 - reflected; + } + return reflected; + } + + /// periodic Hann(torch.hann_window 기본값). 분모가 length - 1 이 아니라 length 다. + private static double[] CreateHannWindow(int length) + { + var window = new double[length]; + for (int index = 0; index < length; index++) + { + window[index] = 0.5 - 0.5 * Math.Cos(2.0 * Math.PI * index / length); + } + return window; + } + + /// + /// torchaudio.functional.melscale_fbanks 의 기본값(HTK 척도, norm=None). 삼각형을 FFT 빈 번호로 반올림하지 않고 + /// 각 빈의 연속 주파수 위에서 잰다. 결과는 [멜 대역][FFT 빈] 이다. + /// + private static double[][] CreateTorchaudioHtkMelFilterBank( + int melBins, + int fftSize, + int sampleRate, + double minimumFrequency, + double maximumFrequency) + { + int frequencyBins = fftSize / 2 + 1; + double minimumMel = HertzToHtkMel(minimumFrequency); + double maximumMel = HertzToHtkMel(maximumFrequency); + var frequencyEdges = new double[melBins + 2]; + for (int edge = 0; edge < frequencyEdges.Length; edge++) + { + double mel = minimumMel + (maximumMel - minimumMel) * edge / (melBins + 1.0); + frequencyEdges[edge] = HtkMelToHertz(mel); + } + + var weights = new double[melBins][]; + for (int mel = 0; mel < melBins; mel++) + { + weights[mel] = new double[frequencyBins]; + double lower = frequencyEdges[mel]; + double center = frequencyEdges[mel + 1]; + double upper = frequencyEdges[mel + 2]; + for (int frequencyBin = 0; frequencyBin < frequencyBins; frequencyBin++) + { + double frequency = frequencyBin * (sampleRate / 2.0) / (frequencyBins - 1); + double downSlope = (frequency - lower) / (center - lower); + double upSlope = (upper - frequency) / (upper - center); + weights[mel][frequencyBin] = Math.Max(0.0, Math.Min(downSlope, upSlope)); + } + } + return weights; + } + + private static double HertzToHtkMel(double hertz) => 1127.0 * Log1P(hertz / 700.0); + + private static double HtkMelToHertz(double mel) => 700.0 * ExpM1(mel / 1127.0); + + // double.LogP1·ExpM1 은 Log(x + 1)·Exp(x) − 1 을 그대로 계산해 x 가 0 에 가까우면 자릿수를 잃는다. + // Kotlin 의 ln1p·expm1(fdlibm) 처럼 어디서나 정확한 값을 내도록 Kahan 의 보정을 쓴다. + private static double Log1P(double x) + { + double u = 1.0 + x; + if (u == 1.0) return x; + if (double.IsPositiveInfinity(u)) return u; + return Math.Log(u) * x / (u - 1.0); + } + + private static double ExpM1(double x) + { + double u = Math.Exp(x); + if (u == 1.0) return x; + if (double.IsPositiveInfinity(u)) return u; + double uMinusOne = u - 1.0; + if (uMinusOne == -1.0) return -1.0; + return uMinusOne * x / Math.Log(u); + } + } +} diff --git a/SoundVisualizer.Core/Ai/Radix2Fft.cs b/SoundVisualizer.Core/Ai/Radix2Fft.cs new file mode 100644 index 0000000..407e2e9 --- /dev/null +++ b/SoundVisualizer.Core/Ai/Radix2Fft.cs @@ -0,0 +1,118 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Numerics; + +namespace SoundVisualizer.Ai +{ + /// + /// 기수 2 Cooley–Tukey FFT(제자리 계산). 모바일 판 AudioPreprocessor.kt 의 fftInPlace 를 곱셈·덧셈 순서까지 + /// 그대로 옮겼다. 로그멜 기준 데이터와의 오차를 Kotlin 과 같게 두려면 계산 순서가 같아야 한다. + /// + /// Kotlin 은 단계마다 회전 인자를 w ← w·w_len 점화식으로 블록마다 처음부터 다시 만든다. 여기서는 같은 점화식으로 + /// 생성할 때 한 번만 표를 만든다. 같은 곱셈을 같은 순서로 하므로 블록마다 다시 만든 값과 비트까지 같고, + /// 호출마다 삼각함수와 점화식 곱셈을 하지 않아도 된다. + /// + /// 표는 만든 뒤 읽기만 하므로 여러 스레드가 함께 써도 된다. 버퍼는 부르는 쪽이 준다. + /// + internal sealed class Radix2Fft + { + private readonly int[] _bitReversed; + + // 단계(len = 2·half)마다 w_0..w_{half-1} 을 [half - 1, 2·half - 1) 에 둔다. 모두 합쳐 Size - 1 칸이다. + private readonly double[] _twiddleRe; + private readonly double[] _twiddleIm; + + public Radix2Fft(int size) + { + if (!BitOperations.IsPow2(size)) + throw new ArgumentException("FFT size must be a power of two", nameof(size)); + + Size = size; + _bitReversed = CreateBitReversedIndices(size); + _twiddleRe = new double[size - 1]; + _twiddleIm = new double[size - 1]; + + for (int len = 2; len <= size; len <<= 1) + { + double ang = -2.0 * Math.PI / len; + double wLenRe = Math.Cos(ang); + double wLenIm = Math.Sin(ang); + int half = len >> 1; + double wRe = 1.0; + double wIm = 0.0; + for (int j = 0; j < half; j++) + { + _twiddleRe[half - 1 + j] = wRe; + _twiddleIm[half - 1 + j] = wIm; + double nextWRe = wRe * wLenRe - wIm * wLenIm; + double nextWIm = wRe * wLenIm + wIm * wLenRe; + wRe = nextWRe; + wIm = nextWIm; + } + } + } + + public int Size { get; } + + /// · 를 제자리에서 이산 푸리에 변환한다(지수 부호 −). + public void Transform(Span real, Span imag) + { + int n = Size; + if (real.Length != n || imag.Length != n) + throw new ArgumentException($"FFT buffers must hold exactly {n} values"); + + for (int i = 0; i < n; i++) + { + int j = _bitReversed[i]; + if (j > i) + { + double tr = real[i]; real[i] = real[j]; real[j] = tr; + double ti = imag[i]; imag[i] = imag[j]; imag[j] = ti; + } + } + + for (int len = 2; len <= n; len <<= 1) + { + int half = len >> 1; + int twiddleStart = half - 1; + for (int i = 0; i < n; i += len) + { + for (int j = 0; j < half; j++) + { + double wRe = _twiddleRe[twiddleStart + j]; + double wIm = _twiddleIm[twiddleStart + j]; + int top = i + j; + int bottom = top + half; + double uRe = real[top]; + double uIm = imag[top]; + double vRe = real[bottom] * wRe - imag[bottom] * wIm; + double vIm = real[bottom] * wIm + imag[bottom] * wRe; + real[top] = uRe + vRe; + imag[top] = uIm + vIm; + real[bottom] = uRe - vRe; + imag[bottom] = uIm - vIm; + } + } + } + } + + private static int[] CreateBitReversedIndices(int n) + { + int bits = BitOperations.TrailingZeroCount(n); + var indices = new int[n]; + for (int i = 0; i < n; i++) + { + int v = i; + int r = 0; + for (int b = 0; b < bits; b++) + { + r = (r << 1) | (v & 1); + v >>= 1; + } + indices[i] = r; + } + return indices; + } + } +} diff --git a/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs b/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs new file mode 100644 index 0000000..3f5ea49 --- /dev/null +++ b/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs @@ -0,0 +1,517 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; +using System.Diagnostics; + +namespace SoundVisualizer.Ai +{ + /// + /// 실시간 AI 분류(Android 의 RealtimeAiPipeline.kt). 캡처 스레드가 넣은 소리를 배경 스레드가 250ms 마다 꺼내 + /// 링 → 16kHz → Qualcomm 로그멜 → YAMNet → 투표·안전 단서·후처리() 를 거쳐 확정 결과를 게시한다. + /// + /// 스레드: + /// - 는 캡처 스레드 하나에서만 부른다. 링에 복사만 하고 바로 돌아온다. + /// - 은 어느 스레드에서나 부른다. 불변 결과의 참조 하나를 잠금 없이 읽는다. + /// - ·· 는 UI 스레드 등 어디서나 부른다. 돌고 있는 추론을 최대 1초 기다린다. + /// - 틱은 이 클래스가 띄우는 배경 스레드 "SV-AiTick" 에서 돈다(Android 는 Dispatchers.Default 코루틴). + /// 시각은 모두 단조 증가하는 밀리초 하나()로 잰다. + /// + /// PC 에서 바꾼 것: + /// - 이 돌고 있는 틱을 기다리고 마지막 결과를 바로 숨기며, 는 앞 틱이 끝난 뒤에 비운다. + /// Android 서비스는 늦게 끝난 추론이 비운 뒤의 결과를 덮지 않도록 읽는 쪽을 떼고 AI 재시작을 미뤘는데, 여기서는 그럴 필요가 없다. + /// - 가 1초 안에 추론을 기다리지 못하면 Android 는 세션을 버리지만(누수), 여기서는 그 추론이 끝나면서 닫는다. + /// + public sealed class RealtimeAiPipeline : IDisposable + { + /// 틱 시작에서 다음 시작까지(#379). + public const long PredictIntervalMs = 250; + + /// 추론이 150ms 넘게 걸리는 느린 PC 에서도 쉬지 않고 연달아 돌지 않게 하는 최소 휴식. + internal const long MinTickRestMs = 100; + + /// Stop·Dispose 가 돌고 있는 추론을 기다리는 한도. UI 스레드를 오래 붙잡지 않는다(Android 는 메인 스레드 ANR). + internal const int CloseWaitMs = 1000; + + private const string Tag = nameof(RealtimeAiPipeline); + private const long FailureLogIntervalMs = 2000; + private const long NoTimestamp = long.MinValue; + + private static readonly IReadOnlyDictionary NoOverrides = FrozenDictionary.Empty; + + private readonly AiAudioBuffer _buffer; + private readonly AiCaptureInferenceGate _gate; + private readonly AiClassificationStage _stage; + private readonly QualcommSourceAudioPreprocessor _frontend = new(); + private readonly IYamnetModel _model; + private readonly Func> _mappingOverrides; + private readonly Action? _tickFailed; + private readonly Func _clock; + private readonly Func _waitForNextTick; + private readonly bool _runScheduler; + + // 추론을 한 번에 하나로 줄 세운다. 모델·후처리·작업 버퍼는 이 잠금 안에서만 쓴다. + private readonly object _inferLock = new(); + // Start·Stop·Dispose 끼리의 순서를 지킨다. 추론 잠금을 기다리는 동안에도 잡고 있으므로 틱은 이 잠금을 잡지 않는다. + private readonly object _lifecycleLock = new(); + private Thread? _schedulerThread; + private CancellationTokenSource? _schedulerStop; + private bool _running; + private int _closed; + private bool _modelDisposed; + + private long _inferenceExecutions; + private long _inferenceCompletions; + private long _silenceSkippedTicks; + private long _lastFailureLogMs = NoTimestamp; + + private readonly float[] _captureScratch; + private readonly float[] _mono16k = new float[CaptureAudioMath.RequiredMono16kSamples]; + private readonly float[] _logMel = new float[QualcommSourceAudioPreprocessor.LogMelSize]; + private readonly float[] _logits = new float[YamnetInference.NumClasses]; + private readonly float[] _probabilities = new float[YamnetInference.NumClasses]; + + /// 캡처 레이트. 링·리샘플러가 이 레이트로 만들어지므로 장치 형식이 바뀌면 새로 만든다. + /// 인터리브 채널 수(1 이상). + /// 파이프라인이 넘겨받아 에서 닫는다. + /// YAMNet 클래스 521개. + /// 지금의 사용자 지정. null 이면 늘 비어 있다. + /// 틱 실패를 받을 곳. null 이면 으로 2초에 한 번까지 남긴다. + /// 단조 밀리초. 테스트가 시각을 정한다. 기본은 . + /// 다음 틱까지 쉰다. 멈추라는 신호를 받으면 false. 테스트가 기다리지 않고 한 걸음씩 돌린다. + /// false 면 가 상태만 비우고 스레드를 띄우지 않는다. 테스트가 를 직접 부른다. + internal RealtimeAiPipeline( + int captureSampleRate, + int channels, + IYamnetModel model, + YamnetClassMap classes, + Func>? mappingOverrides = null, + Action? tickFailed = null, + Func? clock = null, + Func? waitForNextTick = null, + bool runScheduler = true) + { + ValidateArguments(captureSampleRate, channels, classes); + ArgumentNullException.ThrowIfNull(model); + _buffer = new AiAudioBuffer(captureSampleRate, channels); + _gate = new AiCaptureInferenceGate(_buffer); + _stage = new AiClassificationStage(classes); + _model = model; + _mappingOverrides = mappingOverrides ?? (static () => NoOverrides); + _tickFailed = tickFailed; + _clock = clock ?? (static () => Environment.TickCount64); + _waitForNextTick = waitForNextTick ?? WaitForNextTick; + _runScheduler = runScheduler; + _captureScratch = new float[CaptureAudioMath.CaptureSamplesForOneYamnetWindow(captureSampleRate)]; + } + + /// + /// 모델을 불러와 파이프라인을 만든다. 아직 돌지 않으므로 를 부른다. 모델 확인과 세션 생성에 수십 ms 가 + /// 걸리므로 UI 스레드가 아닌 곳에서 부른다(Android 의 SV-AiInit). 실패하면 앱은 AI 없이 화면만 돌고 "AI 를 쓸 수 없음" 을 알린다. + /// + /// 캡처 레이트(). + /// 캡처 버퍼의 인터리브 채널 수. + /// yamnet.onnx 와 yamnet.data 가 있는 폴더. + /// yamnet_class_map.csv 에서 읽은 클래스 521개(). + /// + /// 지금의 사용자 지정(영어 클래스 이름 → "ambient"/"speech"/"danger"). 틱마다, 그리고 을 부를 때마다 + /// 부르므로 여러 스레드에서 불려도 안전하고 가벼워야 한다(설정이 바뀔 때 통째로 바꿔 끼우는 불변 맵의 참조를 돌려주는 식). + /// 예외를 던지면 안 된다. null 을 돌려주면 사용자 지정이 없는 것으로 본다. + /// + /// 틱 실패를 틱 스레드에서 받는다. null 이면 으로 2초에 한 번까지 남긴다. + /// AI 가 받지 않는 캡처 레이트이거나 채널 수가 1 보다 작다. + /// 모델 파일이 없다. + /// 모델 파일이 배포한 것과 다르거나 입출력이 YAMNet 계약과 다르다. + /// ONNX Runtime 이 세션을 만들지 못했다. + public static RealtimeAiPipeline Create( + int captureSampleRate, + int channels, + string modelDirectory, + YamnetClassMap classes, + Func>? mappingOverrides = null, + Action? tickFailed = null) + { + // 모델을 읽기 전에 거른다. 화면만 돌 레이트라면 15MB 를 해시하고 세션을 만들 까닭이 없다. + ValidateArguments(captureSampleRate, channels, classes); + var model = YamnetInference.Create(modelDirectory); + try + { + return new RealtimeAiPipeline(captureSampleRate, channels, model, classes, mappingOverrides, tickFailed); + } + catch + { + model.Dispose(); + throw; + } + } + + public int SampleRate => _buffer.SampleRate; + + public int ChannelCount => _buffer.ChannelCount; + + /// 뒤, · 전. + public bool IsRunning => Volatile.Read(ref _running) && !IsClosed; + + private bool IsClosed => Volatile.Read(ref _closed) != 0; + + /// + /// 캡처 버퍼 하나(인터리브 float PCM, 채널 수 × 프레임)를 링에 복사한다. 추론은 하지 않고 바로 돌아온다. + /// 캡처 스레드 하나에서만 부른다. 돌고 있지 않을 때( 전, 뒤)와 닫힌 뒤에는 버린다. + /// NAudio 의 바이트 버퍼는 MemoryMarshal.Cast<byte, float>(e.Buffer.AsSpan(0, e.BytesRecorded)) 로 넘긴다. + /// + public void Ingest(ReadOnlySpan interleaved) + { + if (!Volatile.Read(ref _running) || IsClosed) return; + // 섞기 전의 원래 채널로 무음을 판단하고, 게이트가 닫혀 있어도 늘 링에 남긴다. 다시 열릴 때 그 앞의 소리까지 한 창으로 본다. + _gate.IngestInterleaved(interleaved, _clock()); + } + + /// + /// 마지막 확정 결과. 화면은 null 이면 환경음으로 그린다. 어느 스레드에서나 부를 수 있고 잠그지 않는다. + /// 돌고 있지 않거나(시작 전, 멈춘 뒤, 닫힌 뒤), 첫 추론이 아직 끝나지 않았거나, 결과를 만든 뒤 사용자 지정이 바뀌었으면 + /// (다음 추론 전이라도) null 이다. 필드끼리는 한 결과 안에서만 짝이 맞으므로 여러 필드를 쓰는 쪽은 한 번만 읽는다. + /// + public AiClassificationResult? LastClassification() + { + if (!Volatile.Read(ref _running) || IsClosed) return null; + return _stage.LastClassification(CurrentOverrides()); + } + + /// + /// 추론을 시작한다. 링·무음 게이트·히스테리시스·마지막 결과를 비우고 틱 스레드를 띄운다. 이미 돌고 있거나 닫혔으면 아무것도 + /// 하지 않는다. 앞선 실행의 추론이 아직 돌고 있으면 그 추론이 끝난 뒤에 비운다. 그래야 그 결과가 비운 뒤에 되살아나지 않는다. + /// + /// 앞선 실행의 추론이 1초 안에 끝나지 않았다. + public void Start() + { + lock (_lifecycleLock) + { + if (IsClosed || Volatile.Read(ref _running)) return; + + if (!Monitor.TryEnter(_inferLock, CloseWaitMs)) + throw new TimeoutException("The previous AI inference did not finish within 1 s"); + try + { + ResetStateLocked(); + } + finally + { + Monitor.Exit(_inferLock); + } + + if (_runScheduler) + { + var stop = new CancellationTokenSource(); + CancellationToken token = stop.Token; + var thread = new Thread(() => RunScheduler(token)) + { + IsBackground = true, + Name = "SV-AiTick", + }; + try + { + thread.Start(); + } + catch + { + stop.Dispose(); + throw; + } + _schedulerThread = thread; + _schedulerStop = stop; + } + Volatile.Write(ref _running, true); + } + } + + /// + /// 추론을 멈춘다. 마지막 결과는 바로 숨기고( 이 null), 다음 틱을 막고, 돌고 있는 틱이 끝나기를 + /// 최대 1초 기다린다. 다시 할 수 있다. 여러 번 불러도 된다. + /// + public void Stop() => StopScheduler(Environment.TickCount64 + CloseWaitMs); + + /// + /// 멈추고 모델을 닫는다. 여러 번 불러도 된다. 닫은 뒤에는 추론하지 않고, 는 버리고, + /// 은 null 이다. 돌고 있는 추론은 끝나기를 기다리되 1초까지만 기다린다. 그 안에 끝나지 않으면 + /// 기다리지 않고 돌아오고, 그 추론이 끝나면서 모델을 닫는다. 어느 경우에도 쓰는 중인 세션을 닫지 않는다. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _closed, 1) != 0) return; + long deadlineMs = Environment.TickCount64 + CloseWaitMs; + StopScheduler(deadlineMs); + + // 멈추기는 다음 틱만 막는다. 이미 모델 안에 들어간 추론이 있으면 끝나기를 기다린 뒤에 세션을 닫아야 해제한 메모리를 쓰지 않는다. + if (Monitor.TryEnter(_inferLock, RemainingMs(deadlineMs))) + { + try + { + DisposeModelLocked(); + } + finally + { + Monitor.Exit(_inferLock); + } + } + else + { + AppLog.Warn(Tag, "Inference still running after 1 s; the running tick releases the YAMNet session when it finishes"); + } + } + + /// + /// 다음 틱까지 쉴 시간(Kotlin remainingTickDelayMs, #379). 틱 시작에서 다음 시작까지를 로 맞추려고 + /// 틱에 걸린 시간을 빼되, 오래 걸린 틱 뒤에도 는 쉰다. 끝이 시작보다 앞이면 걸린 시간을 0 으로 본다. + /// + internal static long RemainingTickDelayMs(long intervalMs, long tickStartedAtMs, long tickFinishedAtMs, long minimumRestMs) + { + long tickDurationMs = Math.Max(0L, tickFinishedAtMs - tickStartedAtMs); + return Math.Max(intervalMs - tickDurationMs, minimumRestMs); + } + + /// + /// 틱 한 번(Kotlin runTickInternal). 스케줄러가 마다 부르고, 테스트는 시각을 정해 직접 부른다. + /// 예외를 던지지 않는다. 실패는 알리고 무음 게이트에 넘긴다. + /// + internal AiTickOutcome RunTickOnce(long nowMs) + { + if (IsClosed) return AiTickOutcome.Closed; + if (!_buffer.HasEnoughForYamnetWindow()) return AiTickOutcome.NotEnoughAudio; + // 앞 틱이 아직 돌고 있으면 기다리지 않고 건너뛴다(Kotlin 의 tryLock). + if (!Monitor.TryEnter(_inferLock)) return AiTickOutcome.Busy; + + (AiTickOutcome Outcome, Exception? Failure) tick; + try + { + tick = RunTickLocked(nowMs); + } + finally + { + // Dispose 가 기다리다 포기한 뒤에 끝난 틱이면 여기서 닫는다. 잠금 안이라 다른 추론과 겹치지 않는다. + if (IsClosed) DisposeModelLocked(); + Monitor.Exit(_inferLock); + } + + if (tick.Failure is not null) ReportTickFailure(tick.Failure); + return tick.Outcome; + } + + /// 추론 횟수(테스트와 진단용). 가 0 으로 되돌린다. + internal InferenceStats Stats => new( + Interlocked.Read(ref _inferenceExecutions), + Interlocked.Read(ref _inferenceCompletions), + Interlocked.Read(ref _silenceSkippedTicks)); + + /// 마지막 추론의 16kHz 입력. 다음 틱이 덮어쓰므로 스케줄러를 돌리지 않는 테스트만 읽는다. + internal ReadOnlySpan Mono16kForTest => _mono16k; + + /// 마지막 추론의 로그멜. + internal ReadOnlySpan LogMelForTest => _logMel; + + /// 마지막 추론의 softmax 확률. + internal ReadOnlySpan ProbabilitiesForTest => _probabilities; + + /// 닫기 경합 테스트가 틱을 링 검사에 세워 두는 데 쓴다. + internal AiAudioBuffer AudioBufferForTest => _buffer; + + private (AiTickOutcome Outcome, Exception? Failure) RunTickLocked(long nowMs) + { + // Dispose 가 잠금을 잡기 직전에 위의 검사를 지났을 수 있으므로 잠금 안에서 다시 본다. + if (IsClosed) return (AiTickOutcome.Closed, null); + + try + { + // 무음이라 건너뛰는 틱에도 먼저 반영한다. 이전 매핑에서 확정한 위협음이 새 매핑으로 넘어가지 않게 한다. + _stage.UpdateMapping(CurrentOverrides()); + } + catch (Exception ex) + { + return (AiTickOutcome.Failed, ex); + } + + if (!_gate.IsInferenceOpen(nowMs)) + { + Interlocked.Increment(ref _silenceSkippedTicks); + return (AiTickOutcome.SkippedForSilence, null); + } + + try + { + InferLocked(); + } + catch (Exception ex) + { + _gate.OnInferenceFailed(nowMs); + return (AiTickOutcome.Failed, ex); + } + // 결과를 게시한 뒤에만 완료로 센다. 닫기 경합 테스트가 잠깐 보이는 결과 대신 완료 수로 확인한다(Kotlin 과 같다). + Interlocked.Increment(ref _inferenceCompletions); + _gate.OnInferenceCompleted(nowMs); + return (AiTickOutcome.Completed, null); + } + + private void InferLocked() + { + Interlocked.Increment(ref _inferenceExecutions); + long started = Stopwatch.GetTimestamp(); + _buffer.CopyTailRightPadded(_captureScratch); + CaptureAudioMath.ResampleMonoFloatTo16kCustom(_captureScratch, _buffer.SampleRate, _mono16k); + _frontend.ComputeLogMelSpectrogram(_mono16k, _logMel); + long preprocessed = Stopwatch.GetTimestamp(); + _model.Infer(_logMel, _logits, _probabilities); + long inferred = Stopwatch.GetTimestamp(); + _stage.Process( + _probabilities, + started, + preprocessMs: Stopwatch.GetElapsedTime(started, preprocessed).TotalMilliseconds, + yamnetMs: Stopwatch.GetElapsedTime(preprocessed, inferred).TotalMilliseconds); + } + + private void RunScheduler(CancellationToken stop) + { + try + { + while (!stop.IsCancellationRequested) + { + long startedMs = _clock(); + try + { + RunTickOnce(startedMs); + } + catch (Exception ex) + { + // RunTickOnce 는 실패를 스스로 받아 알린다. 그래도 새어 나온 예외가 루프를 죽이지 않게 한 번 더 막는다. + AppLog.Warn(Tag, "AI tick failed", ex); + } + long delayMs = RemainingTickDelayMs(PredictIntervalMs, startedMs, _clock(), MinTickRestMs); + if (!_waitForNextTick(delayMs, stop)) return; + } + } + catch (Exception ex) + { + // 여기까지 오는 것은 시계나 대기의 결함뿐이다. 앱을 죽이지 않고 AI 만 멈춘다. + AppLog.Warn(Tag, "AI scheduler stopped unexpectedly", ex); + } + } + + private static bool WaitForNextTick(long delayMs, CancellationToken stop) => + !stop.WaitHandle.WaitOne(TimeSpan.FromMilliseconds(delayMs)); + + private void StopScheduler(long deadlineMs) + { + Thread? thread; + CancellationTokenSource? stop; + lock (_lifecycleLock) + { + Volatile.Write(ref _running, false); + thread = _schedulerThread; + stop = _schedulerStop; + _schedulerThread = null; + _schedulerStop = null; + stop?.Cancel(); + } + if (thread is null) return; + // 틱 스레드 안(실패 알림 등)에서 불렸으면 자기 자신은 기다릴 수 없다. 스레드는 지금 틱을 마치고 스스로 빠져나간다. + if (thread == Thread.CurrentThread) return; + + if (thread.Join(RemainingMs(deadlineMs))) + { + stop?.Dispose(); + } + else + { + // 대기 핸들을 아직 쓰고 있을 수 있으므로 토큰은 버리지 않고 GC 에 맡긴다. + AppLog.Warn(Tag, "AI tick is still running after 1 s; it stops after the current inference"); + } + } + + private void ResetStateLocked() + { + _buffer.Reset(); + _gate.Reset(); + _stage.Reset(); + Interlocked.Exchange(ref _inferenceExecutions, 0); + Interlocked.Exchange(ref _inferenceCompletions, 0); + Interlocked.Exchange(ref _silenceSkippedTicks, 0); + } + + /// 추론 잠금 안에서만 부른다. 그래서 추론과 겹치지 않고 두 번 닫지도 않는다. + private void DisposeModelLocked() + { + if (_modelDisposed) return; + _modelDisposed = true; + try + { + _model.Dispose(); + } + catch (Exception ex) + { + AppLog.Warn(Tag, "Failed to release the YAMNet session", ex); + } + } + + private void ReportTickFailure(Exception failure) + { + if (_tickFailed is not null) + { + try + { + _tickFailed(failure); + } + catch (Exception callbackFailure) + { + AppLog.Warn(Tag, "AI tick failure callback threw", callbackFailure); + } + return; + } + + // 모델이 계속 실패하면 틱마다(초당 4번) 같은 줄이 쌓이므로 2초에 한 번만 남긴다. + long now = Environment.TickCount64; + long last = Interlocked.Read(ref _lastFailureLogMs); + if (last != NoTimestamp && now - last < FailureLogIntervalMs) return; + Interlocked.Exchange(ref _lastFailureLogMs, now); + AppLog.Warn(Tag, "AI tick failed", failure); + } + + private IReadOnlyDictionary CurrentOverrides() => _mappingOverrides() ?? NoOverrides; + + private static int RemainingMs(long deadlineMs) => + (int)Math.Clamp(deadlineMs - Environment.TickCount64, 0L, CloseWaitMs); + + private static void ValidateArguments(int captureSampleRate, int channels, YamnetClassMap classes) + { + if (!AiCaptureSampleRatePolicy.IsSupportedForAi(captureSampleRate)) + throw new ArgumentOutOfRangeException(nameof(captureSampleRate), captureSampleRate, "Unsupported AI capture sample rate; run visual-only"); + if (channels < 1) + throw new ArgumentOutOfRangeException(nameof(channels), channels, "Channel count must be at least 1"); + ArgumentNullException.ThrowIfNull(classes); + if (classes.Count != YamnetCoarseClassifier.NumClasses) + throw new ArgumentException($"Expected {YamnetCoarseClassifier.NumClasses} class names, got {classes.Count}", nameof(classes)); + } + + /// 추론 횟수. Executed 는 시작한 추론, Completed 는 결과를 게시하고 끝난 추론이다. + internal readonly record struct InferenceStats(long Executed, long Completed, long SkippedForSilence); + } + + /// 틱 한 번이 어떻게 끝났는지. 테스트와 진단용이다. + internal enum AiTickOutcome + { + /// 닫힌 뒤라 아무것도 하지 않았다. + Closed, + + /// 링에 한 창(0.975초)이 아직 모이지 않았다. 사용자 지정도 반영하지 않았다(Kotlin 과 같다). + NotEnoughAudio, + + /// 앞 틱이 아직 돌고 있어 건너뛰었다. + Busy, + + /// 무음 게이트가 닫혀 추론을 건너뛰었다. 사용자 지정은 반영했다. + SkippedForSilence, + + /// 추론하고 결과를 게시했다. + Completed, + + /// 사용자 지정을 읽거나 추론하다 실패했다. 알렸고, 추론 실패는 무음 게이트에도 넘겼다. + Failed, + } +} diff --git a/SoundVisualizer.Core/Ai/SoundType.cs b/SoundVisualizer.Core/Ai/SoundType.cs new file mode 100644 index 0000000..f86249e --- /dev/null +++ b/SoundVisualizer.Core/Ai/SoundType.cs @@ -0,0 +1,68 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Ai +{ + /// + /// AI 가 확정하는 소리 종류. 화면 색·표시 여부·진동 설정이 이 값으로 갈린다. + /// + /// 를 0 으로 둔다. 모바일 판은 결과가 없을 때와 모르는 라벨을 모두 환경음으로 그리므로, + /// 초기화하지 않은 값(default)도 같은 뜻이 되게 한다. + /// + public enum SoundType + { + /// 환경음. + Ambient = 0, + + /// 대화음. 알아듣는 말만이 아니라 사람이 내는 소리 전반(웃음, 울음, 고함 등)이다. + Speech = 1, + + /// 위협음. + Danger = 2, + } + + /// + /// 과 저장용 문자열 사이의 변환. + /// + /// 문자열은 모바일 판(AiClassification.AMBIENT·SPEECH·DANGER, 설정의 sound_types JSON)과 글자까지 같다. + /// 사용자가 바꾼 소리 종류를 "영어 클래스 이름 → 이 문자열" 맵으로 저장하므로, 두 판이 같은 설정을 읽고 쓸 수 있게 맞춘다. + /// + public static class SoundTypeLabels + { + public const string Ambient = "ambient"; + public const string Speech = "speech"; + public const string Danger = "danger"; + + /// 저장할 문자열. 정의되지 않은 값은 프로그램 오류라 예외를 던진다. + public static string ToLabel(this SoundType type) => type switch + { + SoundType.Ambient => Ambient, + SoundType.Speech => Speech, + SoundType.Danger => Danger, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "알 수 없는 소리 종류"), + }; + + /// + /// 저장된 문자열을 읽는다. 세 문자열과 대소문자까지 같을 때만 true 다. 모바일 판도 "Danger" 처럼 + /// 조금이라도 다른 값은 사용자 지정에서 버리므로 여기서 너그럽게 읽지 않는다. + /// + public static bool TryParse(string? label, out SoundType type) + { + switch (label) + { + case Ambient: + type = SoundType.Ambient; + return true; + case Speech: + type = SoundType.Speech; + return true; + case Danger: + type = SoundType.Danger; + return true; + default: + type = SoundType.Ambient; + return false; + } + } + } +} diff --git a/SoundVisualizer.Core/Ai/YamnetClassMap.cs b/SoundVisualizer.Core/Ai/YamnetClassMap.cs new file mode 100644 index 0000000..a90346f --- /dev/null +++ b/SoundVisualizer.Core/Ai/YamnetClassMap.cs @@ -0,0 +1,196 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections; +using System.Globalization; +using System.Text; + +namespace SoundVisualizer.Ai +{ + /// + /// yamnet_class_map.csv 의 클래스 이름 목록(모델 출력 번호 → 영어 display_name). 사용자 지정 종류도 이 이름을 키로 쓴다. + /// + /// 모바일 판은 이름 목록만 들고 추론 때마다 이름을 소문자로 바꿔 키워드를 찾는다(효과음에 가려진 위협음을 찾을 때는 521개 전부). + /// 여기서는 만들 때 이름마다 기본 종류와 키워드 판정을 한 번 계산해 두고, 추론 중에는 번호로 꺼내 쓴다. + /// 규칙은 문자열을 받는 함수( 등) 한 곳에만 있고 이 표는 그 결과를 담기만 하므로, + /// 같은 이름이면 결과도 같다. + /// + public sealed class YamnetClassMap : IReadOnlyList + { + private readonly string[] _names; + private readonly SoundType[] _defaultCoarse; + private readonly YamnetLabelTraits[] _traits; + private readonly int[] _defaultDangerIndices; + private readonly Dictionary _indexByName; + + /// 이름 목록을 복사해 판정을 미리 계산한다. 분류기는 521개만 받지만 목록 자체는 길이를 따지지 않는다. + public YamnetClassMap(IReadOnlyList names) + { + ArgumentNullException.ThrowIfNull(names); + int count = names.Count; + _names = new string[count]; + _defaultCoarse = new SoundType[count]; + _traits = new YamnetLabelTraits[count]; + _indexByName = new Dictionary(count, StringComparer.Ordinal); + var dangers = new List(); + for (int i = 0; i < count; i++) + { + string name = names[i] ?? throw new ArgumentException($"Class name at {i} is null", nameof(names)); + _names[i] = name; + _defaultCoarse[i] = YamnetThreeClassMapper.MapDisplayNameToCoarse(name); + _traits[i] = ComputeTraits(name); + _indexByName.TryAdd(name, i); // 같은 이름이 또 있으면 앞 번호를 남긴다(List.indexOf 와 같다). + if (_defaultCoarse[i] == SoundType.Danger) dangers.Add(i); + } + _defaultDangerIndices = dangers.ToArray(); + } + + public int Count => _names.Length; + + public string this[int index] => _names[index]; + + /// 이름과 정확히 같은(대소문자 포함) 클래스의 번호. 없으면 -1. + public int IndexOf(string? displayName) => + displayName is not null && _indexByName.TryGetValue(displayName, out int index) ? index : -1; + + /// 사용자 지정을 반영하지 않은 기본 종류. 와 같은 값이다. + public SoundType DefaultCoarse(int index) => _defaultCoarse[index]; + + public IEnumerator GetEnumerator() => ((IEnumerable)_names).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// 파일에서 읽는다. 앱과 테스트는 Models/yamnet_class_map.csv 를 쓴다. + public static YamnetClassMap Load(string csvPath) + { + using var stream = File.OpenRead(csvPath); + return Load(stream); + } + + public static YamnetClassMap Load(Stream csv) => new(LoadClassNames(csv)); + + /// + /// CSV(index, mid, display_name — 따옴표로 감싼 칸 가능)에서 521개 이름을 읽는다. 모바일 YamnetCoarseClassifier.loadClassNames 와 같다. + /// 번호가 숫자가 아닌 줄(머리글)과 범위를 벗어난 번호는 건너뛰고, 빠진 번호는 "class_{번호}" 로 남긴다. + /// 받은 스트림은 닫지 않는다. + /// + public static IReadOnlyList LoadClassNames(Stream csv) + { + ArgumentNullException.ThrowIfNull(csv); + var names = new string[YamnetCoarseClassifier.NumClasses]; + for (int i = 0; i < names.Length; i++) names[i] = "class_" + i.ToString(CultureInfo.InvariantCulture); + + using var reader = new StreamReader(csv, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + string? line; + while ((line = reader.ReadLine()) is not null) + { + var parts = ParseCsvLine(line); + if (parts.Count == 0) continue; + if (!int.TryParse(parts[0].Trim().Trim('"'), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int index)) continue; + if (index < 0 || index >= names.Length) continue; + + string name; + if (parts.Count >= 3) name = parts[2].Trim(); + else if (parts.Count >= 2) name = parts[1].Trim(); + else continue; + + if (name.Length >= 2 && name[0] == '"' && name[^1] == '"') + { + name = name[1..^1].Replace("\"\"", "\""); + } + if (name.Length > 0) names[index] = name; + } + return names; + } + + /// yamnet_class_map.csv 에 맞춘 최소한의 RFC 4180 식 분리. 따옴표 안의 쉼표와 두 번 쓴 따옴표("")를 처리한다. + internal static List ParseCsvLine(string line) + { + var output = new List(3); + var sb = new StringBuilder(); + bool inQuotes = false; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (c == '"') + { + if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') + { + sb.Append('"'); + i++; + } + else + { + inQuotes = !inQuotes; + } + } + else if (c == ',' && !inQuotes) + { + output.Add(sb.ToString()); + sb.Clear(); + } + else + { + sb.Append(c); + } + } + output.Add(sb.ToString()); + return output; + } + + /// 기본 매핑이 위협음인 클래스 번호. 오름차순이라 확률이 같으면 앞 번호가 남는 것까지 모바일의 전체 순회와 같다. + internal ReadOnlySpan DefaultDangerIndices => _defaultDangerIndices; + + internal YamnetLabelTraits TraitsAt(int index) => _traits[index]; + + /// + /// 의 판정. 그 번호의 이름과 같으면 미리 계산한 값을 쓰고, 다르면(직접 만든 결과 등) 이름으로 다시 계산한다. + /// 결과 안의 표시 이름은 늘 그 번호의 클래스 이름이라 추론 중에는 계산하는 일이 없다. + /// + internal YamnetLabelTraits TraitsOf(int index, string? name) => + (uint)index < (uint)_names.Length && string.Equals(_names[index], name, StringComparison.Ordinal) + ? _traits[index] + : ComputeTraits(name); + + /// 목록이 없으면 이름으로 계산한다. 문자열만 받는 공개 함수와 번호를 아는 추론 경로가 같은 구현을 쓰게 한다. + internal static YamnetLabelTraits TraitsOf(YamnetClassMap? classes, int index, string? name) => + classes is null ? ComputeTraits(name) : classes.TraitsOf(index, name); + + internal static YamnetLabelTraits ComputeTraits(string? name) + { + var traits = YamnetLabelTraits.None; + if (YamnetCoarseClassifier.IsGameMixMaskDisplay(name)) traits |= YamnetLabelTraits.GameMixMask; + if (YamnetSafetyCueDecision.IsSpeechLikeDisplay(name)) traits |= YamnetLabelTraits.SpeechLikeDisplay; + if (YamnetSafetyCueDecision.IsSilenceLikeDisplay(name)) traits |= YamnetLabelTraits.SilenceLikeDisplay; + if (YamnetSafetyCueDecision.IsGunshotKeyword(name)) traits |= YamnetLabelTraits.Gunshot; + if (YamnetSafetyCueDecision.IsStrongDangerKeyword(name)) traits |= YamnetLabelTraits.StrongDangerCue; + if (AiPostProcessor.IsCriticalDangerKeyword(name)) traits |= YamnetLabelTraits.CriticalDanger; + return traits; + } + } + + /// 클래스 이름에서 미리 계산해 두는 키워드 판정. 각 값은 같은 이름의 공개 문자열 함수 결과다. + [Flags] + internal enum YamnetLabelTraits : byte + { + None = 0, + + /// : 음악·게임·효과음이라 위협음을 가릴 수 있는 1위. + GameMixMask = 1 << 0, + + /// 말소리 계열 표시 이름. 안전 단서 승격과 가려진 위협음 찾기를 막는다. + SpeechLikeDisplay = 1 << 1, + + /// 무음 계열 표시 이름. 안전 단서 승격과 가려진 위협음 찾기를 막는다. + SilenceLikeDisplay = 1 << 2, + + /// + Gunshot = 1 << 3, + + /// + StrongDangerCue = 1 << 4, + + /// + CriticalDanger = 1 << 5, + } +} diff --git a/SoundVisualizer.Core/Ai/YamnetCoarseClassifier.cs b/SoundVisualizer.Core/Ai/YamnetCoarseClassifier.cs new file mode 100644 index 0000000..de08963 --- /dev/null +++ b/SoundVisualizer.Core/Ai/YamnetCoarseClassifier.cs @@ -0,0 +1,273 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Ai +{ + /// + /// YAMNet 확률 521개 → 상위 5개 → (효과음·음악에 가려진 위협음이면 표시 이름을 그쪽으로) → 상위 3개 확률 투표. + /// 모바일 YamnetCoarseClassifier.kt 와 같다. 안전 단서는 , 확신도 기준과 + /// 히스테리시스는 가 맡는다. 옛 PC 판의 Gunshot Booster 모델은 모바일처럼 없앴다. + /// + public sealed class YamnetCoarseClassifier + { + public const int NumClasses = 521; + public const int TopK = 5; + public const int VoteK = 3; + + // 가려진 위협음을 표시 이름으로 고르는 문턱: max(0.06, 1위 확률 × 0.22). + private const float GameMixMinDangerProbability = 0.06f; + private const float GameMixRelativeToTop = 0.22f; + + private readonly YamnetClassMap _classes; + + public YamnetCoarseClassifier(YamnetClassMap classes) + { + ArgumentNullException.ThrowIfNull(classes); + if (classes.Count != NumClasses) + throw new ArgumentException($"Expected {NumClasses} class names, got {classes.Count}", nameof(classes)); + _classes = classes; + } + + /// 이름 목록에서 바로 만든다. 판정을 다시 계산하므로 같은 목록이면 하나를 나눠 쓴다. + public YamnetCoarseClassifier(IReadOnlyList classNames) + : this(classNames as YamnetClassMap ?? new YamnetClassMap(classNames)) + { + } + + public YamnetClassMap Classes => _classes; + + /// 상위 후보 하나. 은 영어 클래스 이름이다. + public readonly record struct TopClassHit(int Index, string Name, float Probability); + + /// 상위 3개를 종류별로 더한 확률. + public readonly record struct VoteScores(float Ambient, float Speech, float Danger); + + /// + /// 한 프레임의 투표 결과. + /// + /// 사용자 지정까지 반영한 상위 3개 투표. + /// + /// 같은 상위 3개를 기본 매핑으로만 투표한 결과. 사용자가 어떤 소리를 대화음으로 바꿔도 다른 독립 경보까지 + /// 막지 않도록, 안전 단서의 "대화음이면 올리지 않음" 은 이 값으로 판단한다(#283 B). + /// + /// 표시 이름. 1위이거나, 가려진 위협음을 고른 경우 그 위협음 이름이다. + /// 의 확률. + /// 의 클래스 번호. 후보가 없으면 -1. + /// 걸러내지 않은 모델 원출력 상위 5개. 사용자 지정과 상관없다. + /// 사용자 지정을 반영한 투표 점수. + /// 사용자 지정을 반영한 투표 점수. + /// 사용자 지정을 반영한 투표 점수. + /// 가려진 위협음을 표시 이름으로 골랐는지. 투표는 그대로 원래 상위 3개다. + public sealed record Result( + SoundType Coarse, + SoundType DefaultCoarse, + string DisplayName, + float Confidence, + int YamnetClassIndex, + IReadOnlyList Top5, + float AmbientScore, + float SpeechScore, + float DangerScore, + bool GameMixPreferenceApplied) + { + // 모바일 data class 처럼 상위 5개도 내용으로 비교한다. record 기본 비교는 목록을 참조로만 본다. + public bool Equals(Result? other) => + other is not null && + Coarse == other.Coarse && + DefaultCoarse == other.DefaultCoarse && + DisplayName == other.DisplayName && + Confidence.Equals(other.Confidence) && + YamnetClassIndex == other.YamnetClassIndex && + Top5.SequenceEqual(other.Top5) && + AmbientScore.Equals(other.AmbientScore) && + SpeechScore.Equals(other.SpeechScore) && + DangerScore.Equals(other.DangerScore) && + GameMixPreferenceApplied == other.GameMixPreferenceApplied; + + public override int GetHashCode() => + HashCode.Combine(Coarse, DefaultCoarse, DisplayName, Confidence, YamnetClassIndex, Top5.Count, GameMixPreferenceApplied); + } + + /// + /// 확률 521개를 투표한다. 은 추론 한 번 동안 바뀌지 않는 사용자 지정 스냅샷이고, 없으면 기본 매핑이다. + /// 상위 5개 배열은 스택에 두고(모바일은 추론마다 새로 만든다) 힙에는 결과와 상위 5개 목록만 남긴다. + /// + public Result Classify(ReadOnlySpan probabilities, YamnetMappingPolicy? mapping = null) + { + mapping ??= YamnetMappingPolicy.Default; + if (probabilities.Length != NumClasses) + throw new ArgumentException($"Expected {NumClasses} probabilities, got {probabilities.Length}", nameof(probabilities)); + + Span topIdx = stackalloc int[TopK]; + Span topProbs = stackalloc float[TopK]; + ComputeTop5(probabilities, topIdx, topProbs); + + int maxIndex = topIdx[0]; + float conf = maxIndex >= 0 ? topProbs[0] : 0f; + string display = maxIndex >= 0 ? _classes[maxIndex] : ""; + + var (shownIndex, shownConf, shownDisplay) = + PreferDangerWhenTopIsMaskedByGameMix(probabilities, maxIndex, conf, display, mapping); + bool gameMixApplied = shownIndex != maxIndex || shownConf != conf || shownDisplay != display; + + var scores = CoarseVoteScores(topIdx, topProbs, VoteK, mapping); + var coarse = VoteCoarseFromScores(scores); + + int count = 0; + for (int i = 0; i < TopK; i++) + { + if (topIdx[i] >= 0) count++; + } + var top5 = new TopClassHit[count]; + for (int i = 0, j = 0; i < TopK; i++) + { + int idx = topIdx[i]; + if (idx < 0) continue; + top5[j++] = new TopClassHit(idx, _classes[idx], topProbs[i]); + } + + return new Result( + Coarse: coarse, + DefaultCoarse: mapping.OverrideCount == 0 ? coarse : VoteCoarseFromTop5(topIdx, topProbs, VoteK), + DisplayName: shownDisplay, + Confidence: shownConf, + YamnetClassIndex: shownIndex, + Top5: top5, + AmbientScore: scores.Ambient, + SpeechScore: scores.Speech, + DangerScore: scores.Danger, + GameMixPreferenceApplied: gameMixApplied); + } + + /// + /// 확률이 큰 5개. 앞선 값보다 "커야" 자리를 차지하므로 같은 확률이면 앞 번호가 남는다. 남는 자리는 번호 -1, 확률 -1 이다. + /// + public void ComputeTop5(ReadOnlySpan probs, Span outIndices, Span outProbs) + { + if (outIndices.Length < TopK || outProbs.Length < TopK) + throw new ArgumentException($"Output spans must hold {TopK} entries"); + for (int i = 0; i < TopK; i++) + { + outIndices[i] = -1; + outProbs[i] = -1f; + } + int n = Math.Min(probs.Length, _classes.Count); + for (int i = 0; i < n; i++) + { + float p = probs[i]; + for (int j = 0; j < TopK; j++) + { + if (p > outProbs[j]) + { + for (int k = TopK - 1; k > j; k--) + { + outProbs[k] = outProbs[k - 1]; + outIndices[k] = outIndices[k - 1]; + } + outProbs[j] = p; + outIndices[j] = i; + break; + } + } + } + } + + /// 앞의 개(최대 5개) 후보 확률을 의 종류별로 더한다. + public VoteScores CoarseVoteScores(ReadOnlySpan topIndices, ReadOnlySpan topProbs, int k, YamnetMappingPolicy? mapping = null) + { + mapping ??= YamnetMappingPolicy.Default; + float danger = 0f; + float speech = 0f; + float ambient = 0f; + int limit = Math.Min(TopK, k); + for (int idx = 0; idx < limit; idx++) + { + int i = topIndices[idx]; + if (i < 0) continue; + float p = topProbs[idx]; + switch (mapping.CoarseAt(_classes, i)) + { + case SoundType.Danger: + danger += p; + break; + case SoundType.Speech: + speech += p; + break; + default: + ambient += p; + break; + } + } + return new VoteScores(Ambient: ambient, Speech: speech, Danger: danger); + } + + /// 기본 매핑으로 투표한다(사용자 지정 무시). + public SoundType VoteCoarseFromTop5(ReadOnlySpan topIndices, ReadOnlySpan topProbs, int k) => + VoteCoarseFromScores(CoarseVoteScores(topIndices, topProbs, k)); + + /// 점수가 가장 큰 종류. 같으면 위협음 → 대화음 → 환경음 순으로 고른다. 놓치면 더 위험한 쪽이 이긴다. + public static SoundType VoteCoarseFromScores(VoteScores scores) + { + if (scores.Danger >= scores.Speech && scores.Danger >= scores.Ambient) return SoundType.Danger; + if (scores.Speech >= scores.Ambient) return SoundType.Speech; + return SoundType.Ambient; + } + + /// + /// 1위가 위협음을 가릴 수 있는 소리(효과음, 음악, 비디오 게임)인지. 게임·영상 속 총성은 효과음이나 음악이 1위를 차지하기 쉽다. + /// 말소리·무음 계열 이름은 제외한다. + /// + public static bool IsGameMixMaskDisplay(string? display) + { + if (string.IsNullOrEmpty(display)) return false; + // 모바일은 이 두 판정을 분류기와 안전 단서에 같은 내용으로 따로 두는데, 여기서는 한 벌을 같이 쓴다. + if (YamnetSafetyCueDecision.IsSpeechLikeDisplay(display) || YamnetSafetyCueDecision.IsSilenceLikeDisplay(display)) return false; + if (YamnetThreeClassMapper.IsGenericSoundEffectLabel(display)) return true; + string s = display.ToLowerInvariant(); + return s.Contains("music") || s.Contains("video game"); + } + + /// + /// 1위가 효과음·음악이면, 기본 매핑이 위협음인 클래스 중 확률이 가장 큰 것을 찾아 max(0.06, 1위 × 0.22) 이상이면 + /// 표시 이름과 확신도를 그쪽으로 바꾼다. 투표에는 영향이 없다. + /// 사용자가 위협음에서 뺀 라벨은 고르지 않고, 새로 위협음으로 지정한 라벨은 투표에만 들어가므로 여기서 찾지 않는다. + /// + public (int Index, float Confidence, string Display) PreferDangerWhenTopIsMaskedByGameMix( + ReadOnlySpan probs, + int maxIndex, + float conf, + string display, + YamnetMappingPolicy? mapping = null) + { + mapping ??= YamnetMappingPolicy.Default; + if (maxIndex < 0 || maxIndex >= _classes.Count || probs.Length != _classes.Count) + { + return (maxIndex, conf, display); + } + if ((_classes.TraitsOf(maxIndex, display) & YamnetLabelTraits.GameMixMask) == 0) + { + return (maxIndex, conf, display); + } + + // 모바일은 521개 이름을 매번 소문자로 바꿔 기본 종류를 다시 구한다. 미리 모아 둔 위협음 번호만 돈다. + int bestDangerIdx = -1; + float bestDangerProb = 0f; + foreach (int i in _classes.DefaultDangerIndices) + { + if (!mapping.AllowsSafetyCue(_classes[i])) continue; + if (probs[i] > bestDangerProb) + { + bestDangerProb = probs[i]; + bestDangerIdx = i; + } + } + if (bestDangerIdx < 0) return (maxIndex, conf, display); + + float topProb = probs[maxIndex]; + float bar = Math.Max(GameMixMinDangerProbability, topProb * GameMixRelativeToTop); + if (bestDangerProb < bar) return (maxIndex, conf, display); + + return (bestDangerIdx, bestDangerProb, _classes[bestDangerIdx]); + } + } +} diff --git a/SoundVisualizer.Core/Ai/YamnetInference.cs b/SoundVisualizer.Core/Ai/YamnetInference.cs new file mode 100644 index 0000000..1873e66 --- /dev/null +++ b/SoundVisualizer.Core/Ai/YamnetInference.cs @@ -0,0 +1,247 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; + +namespace SoundVisualizer.Ai +{ + /// + /// YAMNet ONNX 실행(Android 의 YamnetInference.kt). 로그멜 [1, 1, 96, 64] → 로짓 521개 → softmax 확률. + /// + /// 가중치는 yamnet.data 에 따로 있고 ONNX Runtime 은 그 파일을 yamnet.onnx 가 있는 폴더에서 찾는다. 그래서 두 파일을 한 폴더에 두고 + /// 경로로 세션을 만든다. 만들기 전에 두 파일의 크기와 SHA-256 을 Android 와 같은 고정값으로 확인한다(). + /// 세션 설정도 Android 와 같다. 연산 스레드를 하나로 묶어 캡처·화면과 CPU 를 다투지 않게 한다. + /// + /// PC 에서 바꾼 것: 입력·출력 버퍼를 한 번만 잡아 IoBinding 으로 묶어 두고, 로짓과 확률은 부르는 쪽 칸에 쓴다. 그래서 추론마다 + /// 관리 힙에 할당하지 않는다(Android 는 추론마다 텐서와 결과 배열을 새로 만든다). 대신 스레드 안전하지 않으므로 한 번에 한 스레드만 + /// 부른다. 이 추론 잠금으로 줄 세운다. + /// + public sealed class YamnetInference : IYamnetModel + { + public const string InputName = "audio"; + public const string OutputName = "class_scores"; + public const int NumClasses = 521; + public const int TimeFrames = 96; + public const int MelBins = 64; + public const int LogMelSize = TimeFrames * MelBins; + + private static readonly long[] InputShape = { 1, 1, TimeFrames, MelBins }; + private static readonly long[] OutputShape = { 1, NumClasses }; + + private readonly InferenceSession _session; + private readonly float[] _input; + private readonly float[] _output; + private readonly OrtValue _inputValue; + private readonly OrtValue _outputValue; + private readonly RunOptions _runOptions; + private readonly OrtIoBinding _binding; + private bool _disposed; + + private YamnetInference(InferenceSession session) + { + ValidateContract(session); + _session = session; + // 고정 힙에 둔다. ONNX Runtime 이 두 배열의 주소를 들고 있는 동안 GC 가 옮기거나 힙을 조각내지 않게 한다. + _input = GC.AllocateArray(LogMelSize, pinned: true); + _output = GC.AllocateArray(NumClasses, pinned: true); + try + { + _inputValue = OrtValue.CreateTensorValueFromMemory(_input, InputShape); + _outputValue = OrtValue.CreateTensorValueFromMemory(_output, OutputShape); + _runOptions = new RunOptions(); + // 묶어 둔 입력은 복사하지 않고 주소로 읽으므로, _input 을 고쳐 쓰면 다음 실행이 새 값을 본다. + _binding = session.CreateIoBinding(); + _binding.BindInput(InputName, _inputValue); + _binding.BindOutput(OutputName, _outputValue); + } + catch + { + ReleaseBuffers(); + throw; + } + } + + /// + /// 의 yamnet.onnx·yamnet.data 로 세션을 만든다. 실패하면 앱은 AI 없이 화면만 돈다. + /// + /// 모델 파일이 없다. + /// 모델 파일이 배포한 것과 다르거나(크기·SHA-256), 입출력이 YAMNet 계약과 다르다. + /// ONNX Runtime 이 세션을 만들지 못했다. + public static YamnetInference Create(string modelDirectory) + { + ArgumentException.ThrowIfNullOrEmpty(modelDirectory); + string directory = Path.GetFullPath(modelDirectory); + ModelFileIntegrity.Verify(directory, YamnetModelFiles.All); + + InferenceSession session; + using (var options = new SessionOptions + { + IntraOpNumThreads = 1, + InterOpNumThreads = 1, + ExecutionMode = ExecutionMode.ORT_SEQUENTIAL, + GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, + }) + { + session = new InferenceSession(Path.Combine(directory, YamnetModelFiles.Onnx.Name), options); + } + + try + { + return new YamnetInference(session); + } + catch + { + // 계약 확인이나 버퍼 준비에 실패했다. 세션을 닫되 닫다가 난 오류가 원래 예외를 덮지 않게 한다(Kotlin closeOnFailure). + try + { + session.Dispose(); + } + catch (Exception cleanupFailure) + { + AppLog.Warn(nameof(YamnetInference), "Failed to release a rejected YAMNet session", cleanupFailure); + } + throw; + } + } + + /// + /// 로그멜 6,144칸(시간 우선 time × 64 + mel, 의 출력)으로 추론해 로짓 521개를 + /// 에, softmax 확률을 에 쓴다. 할당하지 않는다. + /// + /// 닫은 뒤에 불렀다. 닫힌 네이티브 세션에 들어가기 전에 여기서 막는다. + public void Infer(ReadOnlySpan logMel, Span logits, Span probabilities) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (logMel.Length != LogMelSize) + throw new ArgumentException($"Expected log-mel size {LogMelSize}, got {logMel.Length}", nameof(logMel)); + if (logits.Length != NumClasses) + throw new ArgumentException($"Expected {NumClasses} logits, got {logits.Length}", nameof(logits)); + if (probabilities.Length != NumClasses) + throw new ArgumentException($"Expected {NumClasses} probabilities, got {probabilities.Length}", nameof(probabilities)); + + logMel.CopyTo(_input); + _session.RunWithBinding(_runOptions, _binding); + _output.AsSpan().CopyTo(logits); + Softmax(logits, probabilities); + } + + /// 진단용 한 줄. 예: in=audio[1,1,96,64] out=class_scores[1,521] + public string InputOutputSummary() + { + ObjectDisposedException.ThrowIf(_disposed, this); + return $"in={InputName}[{string.Join(",", _session.InputMetadata[InputName].Dimensions)}] " + + $"out={OutputName}[{string.Join(",", _session.OutputMetadata[OutputName].Dimensions)}]"; + } + + /// + /// 세션과 버퍼를 놓는다. 여러 번 불러도 된다. 다른 스레드의 와 겹쳐 부르면 안 된다 + /// (파이프라인은 추론 잠금 안에서만 부른다). + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + ReleaseBuffers(); + _session.Dispose(); + } + + /// + /// Kotlin 의 softmax 와 같은 순서로 계산한다. 최댓값을 뺀 차를 float 로 구한 뒤 double 로 넓혀 exp 하고, 합은 double 로 더하고, + /// float 로 줄인 exp 값을 그 합으로 나눈다. 합이 0 이하이거나 유한하지 않으면(NaN·무한 로짓) 모두 1/n 이다. + /// Python 기준 구현은 차를 double 로 구하므로 Python 과는 float 마지막 자리에서만 다를 수 있다. + /// + public static void Softmax(ReadOnlySpan logits, Span probabilities) + { + if (probabilities.Length < logits.Length) + throw new ArgumentException($"probabilities must hold {logits.Length} values", nameof(probabilities)); + if (logits.IsEmpty) return; + + float max = logits[0]; + for (int i = 1; i < logits.Length; i++) + { + if (logits[i] > max) max = logits[i]; + } + + double sum = 0.0; + for (int i = 0; i < logits.Length; i++) + { + // (float) 로 차를 float 에서 반올림한다. Kotlin 은 Float 끼리 뺀 뒤 toDouble() 한다. + double e = Math.Exp((float)(logits[i] - max)); + probabilities[i] = (float)e; + sum += e; + } + + if (sum <= 0.0 || !double.IsFinite(sum)) + { + probabilities[..logits.Length].Fill(1f / logits.Length); + return; + } + for (int i = 0; i < logits.Length; i++) + { + probabilities[i] = (float)(probabilities[i] / sum); + } + } + + /// Android 처럼 첫 입력·출력의 이름과 입력 모양을 보고, PC 는 IoBinding 이 기대는 개수·자료형·출력 모양까지 본다. + private static void ValidateContract(InferenceSession session) + { + if (session.InputNames.Count != 1 || session.InputNames[0] != InputName) + throw new InvalidDataException($"Expected a single input '{InputName}', got [{string.Join(", ", session.InputNames)}]"); + if (session.OutputNames.Count != 1 || session.OutputNames[0] != OutputName) + throw new InvalidDataException($"Expected a single output '{OutputName}', got [{string.Join(", ", session.OutputNames)}]"); + RequireFloatTensor(session.InputMetadata[InputName], InputName, InputShape); + RequireFloatTensor(session.OutputMetadata[OutputName], OutputName, OutputShape); + } + + private static void RequireFloatTensor(NodeMetadata metadata, string name, long[] shape) + { + bool matches = metadata.IsTensor && + metadata.ElementDataType == TensorElementType.Float && + metadata.Dimensions.Length == shape.Length && + metadata.Dimensions.Select(dimension => (long)dimension).SequenceEqual(shape); + if (!matches) + { + throw new InvalidDataException( + $"Expected '{name}' as float[{string.Join(",", shape)}], got {metadata.ElementDataType}[{string.Join(",", metadata.Dimensions)}]"); + } + } + + private void ReleaseBuffers() + { + // 생성자 도중에 실패하면 일부는 아직 없다. + _binding?.Dispose(); + _runOptions?.Dispose(); + _outputValue?.Dispose(); + _inputValue?.Dispose(); + } + } + + /// + /// 파이프라인이 쓰는 모델 단계. 실제로는 이고, 테스트는 오래 걸리거나 실패하는 모델로 바꿔 끼워 + /// 닫기 경합과 실패 경로를 순서대로 재현한다. + /// + internal interface IYamnetModel : IDisposable + { + /// + void Infer(ReadOnlySpan logMel, Span logits, Span probabilities); + } + + /// + /// 배포하는 YAMNet 두 파일의 크기와 SHA-256. Android 의 YamnetModelFiles 와 글자까지 같아야 하고, 모델을 바꾸면 두 앱에서 함께 고친다. + /// + internal static class YamnetModelFiles + { + public static readonly ModelFileIntegrity.ExpectedFile Onnx = new( + "yamnet.onnx", + 25_592L, + "290369c40886a4ae948f77671fff901023eec81484ac393465dfb1b342b1ca85"); + + public static readonly ModelFileIntegrity.ExpectedFile Data = new( + "yamnet.data", + 14_915_108L, + "aa05b5b196bdfd74fb59ae4cbba22578c5ab25b9e792e52867678844bcecc839"); + + public static readonly IReadOnlyList All = [Onnx, Data]; + } +} diff --git a/SoundVisualizer.Core/Ai/YamnetMappingPolicy.cs b/SoundVisualizer.Core/Ai/YamnetMappingPolicy.cs new file mode 100644 index 0000000..f44dd34 --- /dev/null +++ b/SoundVisualizer.Core/Ai/YamnetMappingPolicy.cs @@ -0,0 +1,103 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; + +namespace SoundVisualizer.Ai +{ + /// + /// 사용자가 분류 탭에서 바꾼 소리 종류의 불변 스냅샷. 추론 한 번의 모든 단계(투표, 가려진 위협음, 안전 단서, 치명적 단서)가 + /// 같은 스냅샷을 본다. 모바일 YamnetMappingPolicy.kt 와 같다. + /// + /// 위협음에서 뺀 라벨은 투표뿐 아니라 키워드로 다시 들어오는 길(가려진 위협음, 안전 단서, 치명적 단서와 그에 따른 기준 완화)에서도 + /// 빠진다. 새로 위협음으로 지정한 라벨은 투표에만 들어간다. + /// + public sealed class YamnetMappingPolicy + { + /// 사용자 지정이 없는 기본 매핑. + public static YamnetMappingPolicy Default { get; } = new(FrozenDictionary.Empty); + + private readonly FrozenDictionary _overrides; + + private YamnetMappingPolicy(FrozenDictionary overrides) + { + _overrides = overrides; + // 해시가 아니라 내용 그대로다. 평가 CSV 에 남긴 값만으로 그때의 매핑을 되살릴 수 있다. + // 순서는 모바일의 toSortedMap 과 같은 서수(UTF-16) 순이다. 문화권 정렬을 쓰면 대소문자가 섞인 이름에서 어긋난다. + Signature = overrides.Count == 0 + ? "default" + : string.Join(";", overrides + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .Select(entry => entry.Key + "=" + entry.Value.ToLabel())); + } + + /// 기본 종류와 다른, 유효한 사용자 지정 수. + public int OverrideCount => _overrides.Count; + + /// "default", 또는 영어 이름 순으로 "이름=종류;이름=종류". 개발자 모드 기록과 매핑이 바뀌었는지 판단하는 데 쓴다. + public string Signature { get; } + + /// 이 매핑에서 의 종류. 사용자 지정이 없으면 기본 종류다. + public SoundType Coarse(string name) => + _overrides.TryGetValue(name, out var coarse) ? coarse : YamnetThreeClassMapper.MapDisplayNameToCoarse(name); + + /// 추론 경로용. 기본 종류를 이름에서 다시 구하지 않고 미리 계산한 값을 쓴다. + internal SoundType CoarseAt(YamnetClassMap classes, int index) => + _overrides.TryGetValue(classes[index], out var coarse) ? coarse : classes.DefaultCoarse(index); + + /// + /// 이 라벨을 키워드 기반 안전 단서로 써도 되는지. 위협음에서 뺀 라벨은 키워드로 다시 들어오지 못한다. + /// 사용자 지정이 위협음인 라벨은 true 지만, 그런 라벨이 단서 키워드에 걸리지 않으면 결국 투표에만 쓰인다. + /// + public bool AllowsSafetyCue(string name) => + !_overrides.TryGetValue(name, out var coarse) || coarse == SoundType.Danger; + + /// 표시 이름이나 상위 5개 중 하나가, 위협음에서 빼지 않은 치명적 단서(총기, 폭발, 폭죽)인지. + public bool HasCriticalDangerCue(string display, IReadOnlyList top5) => + HasCriticalDangerCue(null, -1, display, top5); + + /// 추론 경로용. 같은 판정을 미리 계산한 값으로 한다( 가 null 이면 이름으로 계산한다). + internal bool HasCriticalDangerCue( + YamnetClassMap? classes, + int displayIndex, + string display, + IReadOnlyList top5) + { + if (AllowsSafetyCue(display) && + (YamnetClassMap.TraitsOf(classes, displayIndex, display) & YamnetLabelTraits.CriticalDanger) != 0) + { + return true; + } + for (int i = 0; i < top5.Count; i++) + { + var hit = top5[i]; + if (AllowsSafetyCue(hit.Name) && + (YamnetClassMap.TraitsOf(classes, hit.Index, hit.Name) & YamnetLabelTraits.CriticalDanger) != 0) + { + return true; + } + } + return false; + } + + /// + /// 저장된 사용자 지정(영어 클래스 이름 → "ambient"/"speech"/"danger")에서 스냅샷을 만든다. 입력은 복사하므로 나중에 바뀌어도 상관없다. + /// 목록에 정확히(대소문자 포함) 있는 이름, 세 문자열 중 하나인 값, 기본 종류와 다른 값만 남긴다. 남는 것이 없으면 다. + /// + public static YamnetMappingPolicy From(IReadOnlyDictionary overrides, IReadOnlyList classNames) + { + ArgumentNullException.ThrowIfNull(overrides); + ArgumentNullException.ThrowIfNull(classNames); + var names = new HashSet(classNames, StringComparer.Ordinal); + var valid = new Dictionary(StringComparer.Ordinal); + foreach (var (name, label) in overrides) + { + if (name is null || !names.Contains(name)) continue; + if (!SoundTypeLabels.TryParse(label, out var coarse)) continue; + if (coarse == YamnetThreeClassMapper.MapDisplayNameToCoarse(name)) continue; + valid[name] = coarse; + } + return valid.Count == 0 ? Default : new YamnetMappingPolicy(valid.ToFrozenDictionary(StringComparer.Ordinal)); + } + } +} diff --git a/SoundVisualizer.Core/Ai/YamnetSafetyCueDecision.cs b/SoundVisualizer.Core/Ai/YamnetSafetyCueDecision.cs new file mode 100644 index 0000000..90101b8 --- /dev/null +++ b/SoundVisualizer.Core/Ai/YamnetSafetyCueDecision.cs @@ -0,0 +1,186 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; + +namespace SoundVisualizer.Ai +{ + /// + /// 상위 5개 안에 분명한 경보·사이렌·폭발 단서가 있으면 투표가 환경음이어도 위협음으로 올린다. 모바일 YamnetSafetyCueDecision.kt 와 같다. + /// + /// 옛 Gunshot Booster 경로는 보조 모델 점수 채택과 이 안전 정책을 함께 했다. 모델 점수 쪽은 없앴고 이 정책만 남긴다. + /// 총기 라벨은 이 길로 올리지 않는다(총소리는 투표로만 위협음이 된다). + /// + public static class YamnetSafetyCueDecision + { + /// 단서가 상위 5개 안에서 이 확률 이상이어야 올린다(#353, #358). + public const float StrongDangerCueMinProbability = 0.05f; + + /// 표시 이름의 확신도가 이보다 낮으면 올리지 않는다. + public const float MinDisplayConfidence = 0.12f; + + /// + /// 안전 단서 판단 결과. Pre 는 투표 결과, Post 는 승격을 반영한 결과다. 승격해도 는 단서의 확률이 + /// 아니라 원래 표시 이름의 확신도다(모바일과 같다). + /// + public readonly record struct Result( + SoundType PreCoarse, + string PreDisplay, + int PreClassIndex, + float PreConfidence, + SoundType PostCoarse, + string PostDisplay, + int PostClassIndex, + float PostConfidence, + bool HasGunshotCue, + bool HasStrongDangerCue, + bool DangerCuePromoted); + + /// + /// 를 만든 분류기와 같은 , 같은 으로 부른다. + /// + public static Result Decide(YamnetClassMap classNames, YamnetCoarseClassifier.Result pre, YamnetMappingPolicy? mapping = null) + { + ArgumentNullException.ThrowIfNull(classNames); + ArgumentNullException.ThrowIfNull(pre); + mapping ??= YamnetMappingPolicy.Default; + + // 사용자가 위협음에서 뺀 라벨은 번호를 -1 로 지워 단서로 쓰지 않는다. 확률 자리는 남겨 순위는 그대로 둔다. + Span topIndices = stackalloc int[5]; + Span topProbabilities = stackalloc float[5]; + topIndices.Fill(-1); + topProbabilities.Fill(-1f); + for (int i = 0; i < pre.Top5.Count && i < 5; i++) + { + var hit = pre.Top5[i]; + topIndices[i] = mapping.AllowsSafetyCue(hit.Name) ? hit.Index : -1; + topProbabilities[i] = hit.Probability; + } + + bool hasGunshotCue = HasGunshotCueInTop5(topIndices, classNames); + bool hasStrongDangerCue = HasStrongDangerCueInTop5(topIndices, topProbabilities, classNames); + var displayTraits = classNames.TraitsOf(pre.YamnetClassIndex, pre.DisplayName); + bool blockPromotion = + // 사용자가 대화음으로 지정한 라벨 때문에 다른 독립 단서까지 막지 않게, 기본 매핑의 투표로 본다(#283 B). + // 단서에서 빼는 것은 위의 사용자 지정을 그대로 따른다. + pre.DefaultCoarse == SoundType.Speech || + (displayTraits & (YamnetLabelTraits.SpeechLikeDisplay | YamnetLabelTraits.SilenceLikeDisplay)) != 0 || + pre.Confidence < MinDisplayConfidence; + int cueIndex = TryPickBestStrongDangerDisplay(topIndices, topProbabilities, classNames); + // 총기 후보가 함께 있어도 충분히 강한 다른 단서는 막지 않는다(#351). 총기 라벨 자체는 여기서 올리지 않는다. + bool promote = !blockPromotion && hasStrongDangerCue && cueIndex >= 0; + + return new Result( + PreCoarse: pre.Coarse, + PreDisplay: pre.DisplayName, + PreClassIndex: pre.YamnetClassIndex, + PreConfidence: pre.Confidence, + PostCoarse: promote ? SoundType.Danger : pre.Coarse, + PostDisplay: promote ? classNames[cueIndex] : pre.DisplayName, + PostClassIndex: promote ? cueIndex : pre.YamnetClassIndex, + PostConfidence: pre.Confidence, + HasGunshotCue: hasGunshotCue, + HasStrongDangerCue: hasStrongDangerCue, + DangerCuePromoted: promote); + } + + public static bool IsGunshotKeyword(string? name) + { + if (string.IsNullOrEmpty(name)) return false; + string s = name.ToLowerInvariant(); + return s.Contains("gunshot") || s.Contains("gunfire") || s.Contains("machine gun") || + s.Contains("artillery") || s.Contains("fusillade") || s.Contains("cap gun"); + } + + /// 안전 단서가 되는 이름. 총기, 폭발·폭죽, siren·alarm(alarm clock 제외), 승인한 경적·깨짐 라벨이다. + public static bool IsStrongDangerKeyword(string? name) + { + if (string.IsNullOrEmpty(name)) return false; + if (IsGunshotKeyword(name)) return true; + string s = name.ToLowerInvariant(); + if (s.Contains("alarm clock")) return false; + if (ApprovedAttentionCueLabels.Contains(s)) return true; + return s.Contains("explosion") || s.Contains("fireworks") || s.Contains("firecracker") || + s.Contains("siren") || s.Contains("alarm"); + } + + // 모바일은 이 두 함수를 분류기(YamnetCoarseClassifier)에도 같은 내용으로 따로 둔다. 여기서는 한 벌만 두고 같이 쓴다. + internal static bool IsSpeechLikeDisplay(string? display) + { + if (string.IsNullOrEmpty(display)) return false; + string s = display.ToLowerInvariant(); + return s.Contains("speech") || s.Contains("conversation") || s.Contains("narration") || + s.Contains("speaking") || s.Contains("babbling") || s.Contains("whisper") || + s.Contains("singing") || s.Contains("choir") || s.Contains("laughter") || + s.Contains("crying") || s.Contains("sobbing") || s.Contains("shout"); + } + + internal static bool IsSilenceLikeDisplay(string? display) + { + if (string.IsNullOrEmpty(display)) return false; + string s = display.ToLowerInvariant(); + return s.Contains("silence") || s.Contains("quiet") || s.Contains("background noise"); + } + + private static bool HasGunshotCueInTop5(ReadOnlySpan topIndices, YamnetClassMap classNames) + { + foreach (int index in topIndices) + { + if (index >= 0 && (classNames.TraitsAt(index) & YamnetLabelTraits.Gunshot) != 0) return true; + } + return false; + } + + private static bool HasStrongDangerCueInTop5(ReadOnlySpan topIndices, ReadOnlySpan topProbabilities, YamnetClassMap classNames) + { + for (int position = 0; position < topIndices.Length; position++) + { + int index = topIndices[position]; + if (index >= 0 && topProbabilities[position] >= StrongDangerCueMinProbability && + (classNames.TraitsAt(index) & YamnetLabelTraits.StrongDangerCue) != 0) + { + return true; + } + } + return false; + } + + /// 총기가 아닌 단서 중 확률이 가장 큰 것의 번호. 없으면 -1. 총기 후보의 확률을 다른 약한 단서에 빌려주지 않는다. + private static int TryPickBestStrongDangerDisplay(ReadOnlySpan topIndices, ReadOnlySpan topProbabilities, YamnetClassMap classNames) + { + int bestIndex = -1; + float bestProbability = 0f; + for (int position = 0; position < topIndices.Length; position++) + { + int index = topIndices[position]; + if (index < 0) continue; + var traits = classNames.TraitsAt(index); + if ((traits & YamnetLabelTraits.Gunshot) != 0) continue; + float probability = topProbabilities[position]; + if (probability >= StrongDangerCueMinProbability && + (traits & YamnetLabelTraits.StrongDangerCue) != 0 && + probability > bestProbability) + { + bestIndex = index; + bestProbability = probability; + } + } + return bestIndex; + } + + /// + /// 3분류 매퍼가 이미 위협음으로 보는 정확한 라벨 중 안전 단서로도 승인한 것(소문자). 비슷한 이름이 따라오지 않게 정확히 같은 이름만 본다. + /// + internal static readonly FrozenSet ApprovedAttentionCueLabels = new[] + { + "vehicle horn, car horn, honking", + "air horn, truck horn", + "burst, pop", + "boom", + "bang", + "smash, crash", + "breaking", + "shatter", + }.ToFrozenSet(StringComparer.Ordinal); + } +} diff --git a/SoundVisualizer.Core/Ai/YamnetThreeClassMapper.cs b/SoundVisualizer.Core/Ai/YamnetThreeClassMapper.cs new file mode 100644 index 0000000..a3eb31e --- /dev/null +++ b/SoundVisualizer.Core/Ai/YamnetThreeClassMapper.cs @@ -0,0 +1,115 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; + +namespace SoundVisualizer.Ai +{ + /// + /// YAMNet 클래스 이름(영어 display_name) → 기본 소리 종류. 모바일 YamnetThreeClassMapper.kt 와 규칙이 같다. + /// + /// 위협음 → 대화음 → 환경음 순서로 보고 어디에도 걸리지 않으면 환경음이다. 실제 521개 클래스는 위협음 37개, + /// 대화음 33개, 환경음 451개로 나뉜다. 옛 PC 판이 위협음으로 보던 총소리 대리 라벨(Plop, Gargling, 비·폭포 소리)과 + /// 발소리는 모바일에서 원래 뜻대로 환경음으로 돌렸으므로(#319) 여기에도 없다. + /// + /// 이름을 소문자로 바꿔 비교하므로 부를 때마다 문자열을 하나 만든다. 추론 중에는 부르지 않고, + /// 이 클래스마다 한 번 계산해 둔 값을 쓴다. + /// + public static class YamnetThreeClassMapper + { + /// 이름에 "sound effect" 가 든 범용 효과음 라벨인지. 효과음에 가려진 위협음을 찾을 때 쓴다. + public static bool IsGenericSoundEffectLabel(string? displayName) + { + if (string.IsNullOrEmpty(displayName)) return false; + return displayName.ToLowerInvariant().Contains("sound effect"); + } + + /// 사용자 지정을 반영하지 않은 기본 종류. 이름이 없으면 환경음이고, 목록에 없는 이름도 같은 키워드 규칙으로 본다. + public static SoundType MapDisplayNameToCoarse(string? displayName) + { + if (string.IsNullOrEmpty(displayName)) return SoundType.Ambient; + string s = displayName.ToLowerInvariant(); + if (MatchesDanger(s)) return SoundType.Danger; + if (MatchesSpeech(s)) return SoundType.Speech; + if (MatchesAmbient(s)) return SoundType.Ambient; + return SoundType.Ambient; + } + + private static bool MatchesDanger(string s) + { + // 주의를 끌어야 하는 신호·갑작스러운 사건으로 승인한 라벨(#343). 정확히 같은 이름만 본다. + // 투표만 바꾸고, 안전 단서 목록(YamnetSafetyCueDecision)에는 넣지 않는다. + if (AttentionLabels.Contains(s)) return true; + // 발소리는 환경음이다. 경적은 이름에 horn 이 든 소리 전부가 아니라 차량 경적 두 라벨만 위협음이다. + if (s == "vehicle horn, car horn, honking") return true; + if (s == "air horn, truck horn") return true; + // 바로 알아야 하는 소리. Rain·Wind 처럼 비슷한 이름이 따라오지 않게 정확히 같은 이름만 본다. + if (s == "chainsaw" || s == "thunder" || s == "thunderstorm") return true; + if (s.Contains("gunshot") || s.Contains("gunfire") || s.Contains("machine gun") || + s.Contains("artillery") || s.Contains("fusillade") || s.Contains("cap gun")) + { + return true; + } + if (s.Contains("explosion") || s.Contains("fireworks") || s.Contains("firecracker")) return true; + // 터지거나 부서지는 소리는 YAMNet 이 원인(총성인지 풍선인지)을 가리지 못해도 위협음으로 본다. + if (s == "burst, pop" || s == "boom" || s == "bang" || + s == "smash, crash" || s == "breaking" || s == "shatter") + { + return true; + } + if (s.Contains("civil defense siren")) return true; + if (s.Contains("police car") && s.Contains("siren")) return true; + if (s.Contains("ambulance") && s.Contains("siren")) return true; + if (s.Contains("fire engine") || s.Contains("fire truck")) return true; + if (s.Contains("siren") && !s.Contains("telephone")) return true; + if (s.Contains("smoke detector") || s.Contains("fire alarm")) return true; + if (s == "alarm" || s.Contains("car alarm")) return true; + return false; + } + + private static bool MatchesSpeech(string s) + { + // 악기 이름이다. 사람 목소리가 아니다. + if (s == "singing bowl") return false; + // 대화음은 알아듣는 말만이 아니라 사람이 내는 소리 전체다. 개의 낑낑거림 같은 비슷한 이름이 따라오지 않게 + // 정확히 같은 이름만 넣는다(#345). + if (HumanVocalizationLabels.Contains(s)) return true; + if (s.Contains("speech") || s.Contains("conversation") || s.Contains("narration")) return true; + if (s.Contains("speaking") || s.Contains("babbling")) return true; + if (s.Contains("shout") || s.Contains("whisper") || s.Contains("screaming")) return true; + if (s.Contains("laughter") || s.Contains("crying") || s.Contains("sobbing")) return true; + if (s.Contains("singing") || s.Contains("choir") || s.Contains("rapping")) return true; + if (s.Contains("crowd") || s.Contains("chatter") || s.Contains("hubbub")) return true; + if (s.Contains("children playing")) return true; + return false; + } + + // 어디에도 안 걸린 소리도 환경음이라 결과는 바뀌지 않는다. 일부러 환경음으로 둔 소리를 적어 두고, + // 모바일 코드와 줄마다 맞춰 보기 쉽게 그대로 옮긴다. + private static bool MatchesAmbient(string s) + { + if (s.Contains("wind") || s.Contains("rustling leaves")) return true; + if (s.Contains("traffic")) return true; + if (s.Contains("music") || s.EndsWith(" music", StringComparison.Ordinal)) return true; + if (s.Contains("environmental noise") || s.Contains("field recording")) return true; + if (s.Contains("ocean") || s.Contains("waves") || s.Contains("rain") || s.Contains("thunder")) return true; + if (s.Contains("white noise") || s.Contains("pink noise") || s.Contains("static")) return true; + return false; + } + + /// 위협음으로 투표하는 주의 신호 9개(#343). 소문자. 테스트가 실제 클래스 이름과 맞는지 확인한다. + internal static readonly FrozenSet AttentionLabels = new[] + { + "reversing beeps", "train horn", "train whistle", "foghorn", "bicycle bell", + "emergency vehicle", "skidding", "tire squeal", "slam", + }.ToFrozenSet(StringComparer.Ordinal); + + /// 대화음으로 옮긴 사람 소리 11개(#345). 소문자. 테스트가 실제 클래스 이름과 맞는지 확인한다. + internal static readonly FrozenSet HumanVocalizationLabels = new[] + { + "bellow", "whoop", "yell", "cheering", + "giggle", "snicker", "belly laugh", "chuckle, chortle", + "baby cry, infant cry", "whimper", "wail, moan", + }.ToFrozenSet(StringComparer.Ordinal); + } +} diff --git a/SoundVisualizer.Core/AppLog.cs b/SoundVisualizer.Core/AppLog.cs new file mode 100644 index 0000000..9463b74 --- /dev/null +++ b/SoundVisualizer.Core/AppLog.cs @@ -0,0 +1,24 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Diagnostics; + +namespace SoundVisualizer +{ + /// + /// 조용히 삼키던 실패를 남긴다. 사용자에게 보이지는 않고, DebugView 나 디버거 출력 창에서 볼 수 있다. + /// 설정 파일, 캡처 장치, AI 모델처럼 실패해도 앱이 계속 돌아야 하는 곳에서 쓴다. + /// + public static class AppLog + { + /// 정상 동작의 이정표(예: AI 를 어떤 형식으로 시작했는지). 문제를 되짚을 때 쓴다. + public static void Info(string tag, string message) => Trace.WriteLine($"[SoundVisualizer:{tag}] {message}"); + + public static void Warn(string tag, string message, Exception? error = null) + { + Trace.WriteLine(error == null + ? $"[SoundVisualizer:{tag}] {message}" + : $"[SoundVisualizer:{tag}] {message} ({error.GetType().Name}: {error.Message})"); + } + } +} diff --git a/SoundVisualizer.Core/AppSettings.cs b/SoundVisualizer.Core/AppSettings.cs new file mode 100644 index 0000000..c60e1ec --- /dev/null +++ b/SoundVisualizer.Core/AppSettings.cs @@ -0,0 +1,531 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System; +using System.IO; +using System.Text.Json; +using System.Collections.Frozen; +using System.Collections.Generic; +using SoundVisualizer.Localization; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer +{ + public class VisualModeSettings + { + public double Intensity { get; set; } = 50.0; + public double PositionSpeed { get; set; } = 20.0; + public double Sensitivity { get; set; } = 3.75; + public double VisualOpacity { get; set; } = 50.0; + public bool IsGlowMode { get; set; } = false; + public double GlowIntensity { get; set; } = 0.0; + public double CircleRadius { get; set; } = 40.0; + public bool IntensityAsOpacity { get; set; } = false; + public double OpacityFixedSize { get; set; } = 30.0; + public double OpacityFixedMaxOpacity { get; set; } = 100.0; + } + + public static class AppSettings + { + // 모드별 개별 설정 인스턴스. 기본값은 VisualModeSettings 의 초기값 한 곳에만 둔다. + public static VisualModeSettings WaveMode { get; set; } = new VisualModeSettings(); + public static VisualModeSettings PadMode { get; set; } = new VisualModeSettings(); + public static VisualModeSettings CircleMode { get; set; } = new VisualModeSettings(); + public static VisualModeSettings OutlineMode { get; set; } = new VisualModeSettings(); + + // 단축키 기본값(가상 키 코드). F2 사운드 모드, F3 표현 모드, F4 오버레이 설정. + public const int DefaultSoundModeKey = 0x71; + public const int DefaultVisualModeKey = 0x72; + public const int DefaultEditModeKey = 0x73; + public const double DefaultTargetFps = 144.0; + + // 1. 파도의 크기 (현재 모드값 반환/설정) + public static double WaveIntensity + { + get => GetCurrentModeSettings().Intensity; + set => GetCurrentModeSettings().Intensity = value; + } + + // 2. 파도 위치 변화 속도 + public static double WavePositionSpeed + { + get => GetCurrentModeSettings().PositionSpeed; + set => GetCurrentModeSettings().PositionSpeed = value; + } + + // 3. 파도의 민감성/떨림 + public static double WaveSensitivity + { + get => GetCurrentModeSettings().Sensitivity; + set => GetCurrentModeSettings().Sensitivity = value; + } + + // 4. 파도/그래픽 투명도 (0 ~ 100) + public static double VisualOpacity + { + get => GetCurrentModeSettings().VisualOpacity; + set => GetCurrentModeSettings().VisualOpacity = value; + } + + // 5. 시각화 모드 (0 = Wave 모드, 1 = Pad 모드, 2 = Circle 모드) + public static int VisualMode { get; set; } = 0; + + // 5.5. 원형 모드 반지름 (10 ~ 100) + public static double CircleRadius + { + get => GetCurrentModeSettings().CircleRadius; + set => GetCurrentModeSettings().CircleRadius = value; + } + + // 6. 투명도로 세기 표현 (크기 고정) + public static bool IntensityAsOpacity + { + get => GetCurrentModeSettings().IntensityAsOpacity; + set => GetCurrentModeSettings().IntensityAsOpacity = value; + } + + public static double OpacityFixedSize + { + get => GetCurrentModeSettings().OpacityFixedSize; + set => GetCurrentModeSettings().OpacityFixedSize = value; + } + + public static double OpacityFixedMaxOpacity + { + get => GetCurrentModeSettings().OpacityFixedMaxOpacity; + set => GetCurrentModeSettings().OpacityFixedMaxOpacity = value; + } + + // 6. 스테레오 확장 모드 + public static int SoundMode { get; set; } = 2; + + // 6.5. 광원(Glow) 효과 모드 + public static bool IsGlowMode + { + get => GetCurrentModeSettings().IsGlowMode; + set => GetCurrentModeSettings().IsGlowMode = value; + } + + public static double GlowIntensity + { + get => GetCurrentModeSettings().GlowIntensity; + set => GetCurrentModeSettings().GlowIntensity = value; + } + + // 7. 단축키 설정 + public static List StereoUpmixKeyBind { get; set; } = new List { DefaultSoundModeKey }; // F2 + public static List VisualModeKeyBind { get; set; } = new List { DefaultVisualModeKey }; // F3 + public static List EditModeKeyBind { get; set; } = new List { DefaultEditModeKey }; // F4 + + // 구버전 호환용 + public static int StereoUpmixHotkey { get; set; } = DefaultSoundModeKey; + public static int VisualModeHotkey { get; set; } = DefaultVisualModeKey; + public static int EditModeHotkey { get; set; } = DefaultEditModeKey; + + // 7. 화면 언어 설정. "system"(Windows 화면 언어를 따름) 또는 언어 코드("ko", "zh-Hans" 등). 새로 설치하면 system 이다. + public const string DefaultLanguage = UiLanguages.SystemCode; + public static string Language { get; set; } = DefaultLanguage; + + // 8.5. 관리자 모드 + public static bool IsAdminMode { get; set; } = false; + + // 8.5.1. 개발자 모드의 결과 기록(모바일 developer_record, #193). 개발자 모드를 꺼도 값은 남고, 오버레이가 떠 있고 개발자 모드가 + // 켜져 있을 때만 기록한다(Diagnostics.AiDebugHud). 켜 두고 잊으면 디스크를 먹으므로 기본은 꺼짐이다. + public static bool DeveloperRecord { get; set; } = false; + + public static bool AutoMinimizeOnLaunch { get; set; } = false; + + // 8.6. 렌더링 최대 프레임 + public static double TargetFps { get; set; } = DefaultTargetFps; + + // 9. 화면 표시 여부 (환경음, 말소리, 강조음) + public static bool ShowAmbient { get; set; } = true; + public static bool ShowSpeech { get; set; } = true; + public static bool ShowDanger { get; set; } = true; + + // 10. 화면 표시 색상 (환경음, 말소리, 강조음) + public const string DefaultColorAmbient = "#FFFFFFFF"; + public const string DefaultColorSpeech = "#FFFFFF00"; + public const string DefaultColorDanger = "#FFFF0000"; + public static string ColorAmbient { get; set; } = DefaultColorAmbient; + public static string ColorSpeech { get; set; } = DefaultColorSpeech; + public static string ColorDanger { get; set; } = DefaultColorDanger; + + // 11. 소리 탭에서 바꾼 소리 종류(모바일 sound_types). 키는 모델의 영어 소리 이름, 값은 SoundTypeLabels 의 문자열이고 + // 기본 종류와 같은 값은 두지 않는다(SoundTypeOverrides). 오버레이의 AI 가 틱마다 다른 스레드에서 읽으므로 고치지 않는 맵을 + // 통째로 바꿔 끼운다. 넣을 때 얼려 두어, 넘긴 쪽이 나중에 맵을 고쳐도 AI 가 반쯤 바뀐 맵을 읽는 일이 없다. + private static readonly IReadOnlyDictionary NoSoundTypes = FrozenDictionary.Empty; + private static IReadOnlyDictionary _soundTypes = NoSoundTypes; + + public static IReadOnlyDictionary SoundTypes + { + get => Volatile.Read(ref _soundTypes); + set => Volatile.Write(ref _soundTypes, Freeze(value ?? throw new ArgumentNullException(nameof(value)))); + } + + // 12. 소리 탭을 고급 모드(소리를 하나씩 바꾸는 화면)로 볼지(모바일 classify_advanced). 바꾼 종류와 따로 두는 화면 설정이다. + public static bool ClassifyAdvanced { get; set; } = false; + + // 13. 튜토리얼을 본 적이 있는지(모바일 tutorial_seen). 아직이면 런처를 열 때 튜토리얼부터 띄운다. 튜토리얼을 닫으면(끝까지 넘기거나 + // 건너뛰면) true 가 되고, 그 뒤로는 홈의 "튜토리얼 보기"로만 연다. 저장한 적이 없으면 읽을 때 정한다(Load). + // 보는 사람에 대한 값이라 전체 초기화(ResetToDefaults)가 건드리지 않는다. 모양 설정을 되돌렸다고 튜토리얼이 다시 뜨면 안 된다. + public static bool TutorialSeen { get; set; } = false; + + /// 설정 파일 위치. 포터블 배포라 실행 파일 옆에 둔다. 테스트는 임시 파일로 바꾼다. + public static string SettingsFilePath { get; set; } = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json"); + + /// + /// 저장을 이만큼 미뤄 모은다. 슬라이더를 끌면 값이 바뀔 때마다 저장을 부르는데, 그때마다 파일을 쓰면 + /// UI 스레드가 디스크를 기다린다. 마지막 변경 뒤 이만큼 조용하면 한 번 쓴다. + /// + public const int SaveDelayMs = 400; + + private static readonly object SaveGate = new(); + private static System.Threading.Timer? _saveTimer; + private static bool _savePending; + + /// 지금 표현 모드()의 설정. 오버레이 엔진이 프레임마다 읽는다. + public static VisualModeSettings CurrentModeSettings => GetCurrentModeSettings(); + + private static VisualModeSettings GetCurrentModeSettings() + { + if (VisualMode == 0) return WaveMode; + if (VisualMode == 1) return PadMode; + if (VisualMode == 2) return CircleMode; + return OutlineMode; + } + + public static void Load() + { + // 튜토리얼(모바일 loadTutorialSeen): 설정 파일이 없으면 새로 설치한 것이라 아직 보지 않았다. 파일이 있으면 이미 앱을 써 본 + // 사람이라, 아래에서 저장한 값을 읽지 못하면(이 기능 전의 파일, 깨진 파일) 본 것으로 둔다. + // 모바일은 정한 값을 곧바로 저장한다(기기 표시가 먼저 저장되면 새 설치를 쓰던 사람으로 잘못 친다). PC 는 저장할 때마다 모든 + // 항목을 쓰므로, 무엇을 먼저 저장하든 정한 값이 함께 남는다. 파일이 없는 동안은 늘 새 설치로 읽힌다. + TutorialSeen = File.Exists(SettingsFilePath); + try + { + if (File.Exists(SettingsFilePath)) + { + var json = File.ReadAllText(SettingsFilePath); + var data = JsonSerializer.Deserialize(json); + if (data != null) + { + VisualMode = data.VisualMode; + SoundMode = data.SoundMode; + if (data.StereoUpmixKeyBind != null && data.StereoUpmixKeyBind.Count > 0) StereoUpmixKeyBind = data.StereoUpmixKeyBind; + else StereoUpmixKeyBind = new List { data.StereoUpmixHotkey != 0 ? data.StereoUpmixHotkey : DefaultSoundModeKey }; + + if (data.VisualModeKeyBind != null && data.VisualModeKeyBind.Count > 0) VisualModeKeyBind = data.VisualModeKeyBind; + else VisualModeKeyBind = new List { data.VisualModeHotkey != 0 ? data.VisualModeHotkey : DefaultVisualModeKey }; + + if (data.EditModeKeyBind != null && data.EditModeKeyBind.Count > 0) EditModeKeyBind = data.EditModeKeyBind; + else EditModeKeyBind = new List { data.EditModeHotkey != 0 ? data.EditModeHotkey : DefaultEditModeKey }; + + // 예전 판은 언어 이름("日本語")이나 약어("KOR")를 적었다. 읽을 때 코드로 옮기고, 다음 저장부터 코드로 쓴다. + Language = UiLanguages.NormalizeSetting(data.Language); + + IsAdminMode = data.IsAdminMode; + DeveloperRecord = data.DeveloperRecord; + AutoMinimizeOnLaunch = data.AutoMinimizeOnLaunch; + if (data.TargetFps >= 30.0) TargetFps = data.TargetFps; + ShowAmbient = data.ShowAmbient; + ShowSpeech = data.ShowSpeech; + ShowDanger = data.ShowDanger; + ColorAmbient = data.ColorAmbient ?? DefaultColorAmbient; + ColorSpeech = data.ColorSpeech ?? DefaultColorSpeech; + ColorDanger = data.ColorDanger ?? DefaultColorDanger; + + // 소리 탭이 생기기 전의 설정 파일에는 둘 다 없다. 그때는 바꾼 소리가 없고 기본 모드로 연다. + SoundTypes = ReadSoundTypes(data.SoundTypes); + ClassifyAdvanced = data.ClassifyAdvanced ?? DefaultClassifyAdvanced(SoundTypes); + + // 튜토리얼 항목이 없는 파일은 이 기능 전부터 쓰던 사람의 것이다. 이미 앱을 알므로 튜토리얼을 띄우지 않는다. + TutorialSeen = data.TutorialSeen ?? true; + + // 하이브리드 모드 로드 (모드별 설정이 없는 구버전 호환) + if (data.WaveMode != null) WaveMode = data.WaveMode; + else WaveMode = new VisualModeSettings { Intensity = data.WaveIntensity, PositionSpeed = data.WavePositionSpeed, Sensitivity = data.WaveSensitivity, VisualOpacity = data.VisualOpacity, IsGlowMode = data.IsGlowMode, GlowIntensity = data.GlowIntensity, CircleRadius = data.CircleRadius }; + + if (data.PadMode != null) PadMode = data.PadMode; + else PadMode = new VisualModeSettings { Intensity = data.WaveIntensity, PositionSpeed = data.WavePositionSpeed, Sensitivity = data.WaveSensitivity, VisualOpacity = data.VisualOpacity, IsGlowMode = data.IsGlowMode, GlowIntensity = data.GlowIntensity, CircleRadius = data.CircleRadius }; + + if (data.CircleMode != null) CircleMode = data.CircleMode; + else CircleMode = new VisualModeSettings { Intensity = data.WaveIntensity, PositionSpeed = data.WavePositionSpeed, Sensitivity = data.WaveSensitivity, VisualOpacity = data.VisualOpacity, IsGlowMode = data.IsGlowMode, GlowIntensity = data.GlowIntensity, CircleRadius = data.CircleRadius }; + + if (data.OutlineMode != null) OutlineMode = data.OutlineMode; + else OutlineMode = new VisualModeSettings { Intensity = data.WaveIntensity, PositionSpeed = data.WavePositionSpeed, Sensitivity = data.WaveSensitivity, VisualOpacity = data.VisualOpacity, IsGlowMode = data.IsGlowMode, GlowIntensity = data.GlowIntensity, CircleRadius = data.CircleRadius }; + + int maxRate = GetMonitorRefreshRate(); + if (TargetFps > maxRate) TargetFps = maxRate; + } + } + } + catch (Exception ex) + { + AppLog.Warn("settings", "설정 파일을 읽지 못해 기본값으로 시작합니다.", ex); + } + } + + /// + /// 저장을 예약한다. 안에 다시 부르면 마지막 것 한 번만 쓴다. + /// 부른 스레드(보통 UI 스레드)에서 쓰므로 설정을 고치는 쪽과 엇갈리지 않는다. 앱을 닫을 때는 를 부른다. + /// + public static void Save() + { + lock (SaveGate) + { + _savePending = true; + _saveContext = SynchronizationContext.Current; + _saveTimer ??= new System.Threading.Timer(OnSaveTimer, null, Timeout.Infinite, Timeout.Infinite); + _saveTimer.Change(SaveDelayMs, Timeout.Infinite); + } + } + + /// 예약된 저장이 있으면 지금 쓴다. 앱을 닫을 때와 오버레이 편집을 마칠 때 부른다. + public static void Flush() + { + lock (SaveGate) + { + if (!_savePending) return; + _savePending = false; + _saveTimer?.Change(Timeout.Infinite, Timeout.Infinite); + WriteFile(); + } + } + + /// 미루지 않고 바로 쓴다. 예약된 저장도 이것으로 끝난다. + public static void SaveNow() + { + lock (SaveGate) + { + _savePending = false; + _saveTimer?.Change(Timeout.Infinite, Timeout.Infinite); + WriteFile(); + } + } + + /// 예약된 저장이 남아 있는지. 테스트가 쓴다. + public static bool HasPendingSave + { + get { lock (SaveGate) return _savePending; } + } + + private static SynchronizationContext? _saveContext; + + private static void OnSaveTimer(object? state) + { + SynchronizationContext? context; + lock (SaveGate) context = _saveContext; + if (context != null) context.Post(_ => Flush(), null); + else Flush(); + } + + /// + /// 언어를 뺀 모든 설정을 기본값으로 되돌린다(전체 초기화 버튼). 단축키는 실제로 쓰는 키 목록까지 되돌린다. + /// 저장은 부르는 쪽이 한다. + /// + /// 소리 탭에서 바꾼 소리 종류와 고급 모드도 건드리지 않는다(모바일과 같다). 모바일에서 바꾼 종류를 지우는 것은 소리 탭의 + /// 모두 되돌리기뿐이고(바꾼 수를 보이고 한 번 더 묻는다), 고급 모드는 어느 되돌리기도 건드리지 않는 화면 설정이다. + /// 소리를 하나씩 골라 둔 것을 모양 설정을 되돌리다 함께 잃지 않게 한다. + /// + public static void ResetToDefaults() + { + WaveMode = new VisualModeSettings(); + PadMode = new VisualModeSettings(); + CircleMode = new VisualModeSettings(); + OutlineMode = new VisualModeSettings(); + + VisualMode = 0; + SoundMode = 2; // 실행 중이면 장치의 채널 수에 맞춰 다시 정해진다 + + StereoUpmixKeyBind = new List { DefaultSoundModeKey }; + VisualModeKeyBind = new List { DefaultVisualModeKey }; + EditModeKeyBind = new List { DefaultEditModeKey }; + StereoUpmixHotkey = DefaultSoundModeKey; + VisualModeHotkey = DefaultVisualModeKey; + EditModeHotkey = DefaultEditModeKey; + + IsAdminMode = false; + DeveloperRecord = false; + AutoMinimizeOnLaunch = false; + TargetFps = Math.Min(DefaultTargetFps, GetMonitorRefreshRate()); + + ShowAmbient = true; + ShowSpeech = true; + ShowDanger = true; + ColorAmbient = DefaultColorAmbient; + ColorSpeech = DefaultColorSpeech; + ColorDanger = DefaultColorDanger; + } + + private static void WriteFile() + { + try + { + var data = new SettingsData + { + VisualMode = VisualMode, + SoundMode = SoundMode, + StereoUpmixKeyBind = StereoUpmixKeyBind, + VisualModeKeyBind = VisualModeKeyBind, + EditModeKeyBind = EditModeKeyBind, + Language = Language, + + IsAdminMode = IsAdminMode, + DeveloperRecord = DeveloperRecord, + AutoMinimizeOnLaunch = AutoMinimizeOnLaunch, + TargetFps = TargetFps, + ShowAmbient = ShowAmbient, + ShowSpeech = ShowSpeech, + ShowDanger = ShowDanger, + ColorAmbient = ColorAmbient, + ColorSpeech = ColorSpeech, + ColorDanger = ColorDanger, + SoundTypes = WriteSoundTypes(SoundTypes), + ClassifyAdvanced = ClassifyAdvanced, + TutorialSeen = TutorialSeen, + + WaveMode = WaveMode, + PadMode = PadMode, + CircleMode = CircleMode, + OutlineMode = OutlineMode, + + // 구버전 호환성 저장을 위해 현재 모드의 최종 설정값도 루트 필드에 동시 저장 + WaveIntensity = WaveIntensity, + WavePositionSpeed = WavePositionSpeed, + WaveSensitivity = WaveSensitivity, + VisualOpacity = VisualOpacity, + CircleRadius = CircleRadius, + IsGlowMode = IsGlowMode, + GlowIntensity = GlowIntensity, + OpacityFixedMaxOpacity = OpacityFixedMaxOpacity + }; + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(SettingsFilePath, json); + } + catch (Exception ex) + { + // 쓰기 금지 폴더(Program Files 등)에 풀어 둔 경우 여기로 온다. 설정은 이번 실행 동안만 남는다. + AppLog.Warn("settings", $"설정을 저장하지 못했습니다: {SettingsFilePath}", ex); + } + } + + /// 바꿔 끼울 맵. 이미 얼린 맵(글자 그대로 비교)이면 그대로 쓰고, 아니면 얼린 복사본을 만든다. + private static IReadOnlyDictionary Freeze(IReadOnlyDictionary types) + { + if (types.Count == 0) return NoSoundTypes; + if (types is FrozenDictionary frozen && ReferenceEquals(frozen.Comparer, StringComparer.Ordinal)) return frozen; + return types.ToFrozenDictionary(StringComparer.Ordinal); + } + + /// + /// 저장된 소리 종류(이름 → 라벨 JSON 객체). 문자열이 아닌 값은 버리고, 남은 것은 로 + /// 거른다(세 라벨이 아닌 값과 지금 기본 종류와 같은 값을 버린다). 객체가 아니면 바꾼 것이 없는 것으로 본다(모바일 loadSoundTypes). + /// 항목을 JSON 그대로 받는 것은 이 값 하나가 깨졌다고 다른 설정까지 기본값으로 시작하지 않게 하려는 것이다. + /// + private static IReadOnlyDictionary ReadSoundTypes(JsonElement? stored) + { + if (stored is not { ValueKind: JsonValueKind.Object } json) return NoSoundTypes; + var pairs = new List>(); + foreach (var property in json.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.String) pairs.Add(new(property.Name, property.Value.GetString()!)); + } + return SoundTypeOverrides.Sanitize(pairs); + } + + /// 이름 순서로 적는다. 얼린 맵은 순서가 정해져 있지 않아, 그대로 적으면 같은 값도 저장할 때마다 줄이 뒤섞인다. + private static JsonElement WriteSoundTypes(IReadOnlyDictionary types) + { + var sorted = new SortedDictionary(StringComparer.Ordinal); + foreach (var (name, label) in types) sorted[name] = label; + return JsonSerializer.SerializeToElement(sorted); + } + + /// + /// 고급 모드를 저장한 적이 없을 때의 값(모바일 loadClassifyAdvanced). 이미 소리를 하나씩 바꿔 둔 사람이면 고급 모드로 연다. + /// 기본 모드에서 묶음을 고르면 하나씩 바꾼 것이 먼저 묻지 않고 덮어써질 수 있어서다. 기본 모드로 돌아갈 때는 탭이 먼저 알린다. + /// 모바일은 정한 값을 곧바로 저장하지만 PC 는 저장할 때마다 모든 항목을 쓰므로, 다음 저장에서 함께 남는다. + /// + private static bool DefaultClassifyAdvanced(IReadOnlyDictionary soundTypes) => soundTypes.Count > 0; + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern IntPtr GetDC(IntPtr hWnd); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [System.Runtime.InteropServices.DllImport("gdi32.dll")] + private static extern int GetDeviceCaps(IntPtr hDC, int nIndex); + + private const int VREFRESH = 116; + + public static int GetMonitorRefreshRate() + { + IntPtr hDC = GetDC(IntPtr.Zero); + try + { + int rate = GetDeviceCaps(hDC, VREFRESH); + return rate > 0 ? rate : 60; // 디바이스 주사율 질의 실패 시 안전하게 60Hz 리턴 + } + catch + { + return 60; + } + finally + { + ReleaseDC(IntPtr.Zero, hDC); + } + } + + private class SettingsData + { + public double WaveIntensity { get; set; } = 50.0; + public double WavePositionSpeed { get; set; } = 20.0; + public double WaveSensitivity { get; set; } = 3.75; + public double VisualOpacity { get; set; } = 50.0; + public int VisualMode { get; set; } = 0; + public double CircleRadius { get; set; } = 40.0; + public int SoundMode { get; set; } = 2; + public bool IsGlowMode { get; set; } = false; + public double GlowIntensity { get; set; } = 0.0; + public double OpacityFixedMaxOpacity { get; set; } = 100.0; + public List StereoUpmixKeyBind { get; set; } = new List(); + public List VisualModeKeyBind { get; set; } = new List(); + public List EditModeKeyBind { get; set; } = new List(); + + // 구버전 호환 + public int StereoUpmixHotkey { get; set; } = 0x71; + public int VisualModeHotkey { get; set; } = 0x72; + public int EditModeHotkey { get; set; } = 0x73; + // JSON 이름은 예전 그대로 Language 다. 언어 항목이 없던 아주 예전 설정 파일은 한국어로 읽던 그대로 둔다. + public string? Language { get; set; } = "KOR"; + + public bool IsAdminMode { get; set; } = false; + // 결과 기록이 생기기 전의 설정 파일에는 없어 꺼진 채로 읽힌다. + public bool DeveloperRecord { get; set; } = false; + public bool AutoMinimizeOnLaunch { get; set; } = false; + public double TargetFps { get; set; } = 144.0; + public bool ShowAmbient { get; set; } = true; + public bool ShowSpeech { get; set; } = true; + public bool ShowDanger { get; set; } = true; + public string ColorAmbient { get; set; } = "#FFFFFFFF"; + public string ColorSpeech { get; set; } = "#FFFFFF00"; + public string ColorDanger { get; set; } = "#FFFF0000"; + + // 소리 탭. 예전 설정 파일에는 없어 null 로 읽힌다. 소리 종류는 JSON 그대로 받아 ReadSoundTypes 가 거른다. + public JsonElement? SoundTypes { get; set; } + public bool? ClassifyAdvanced { get; set; } + + // 튜토리얼을 본 적이 있는지. 예전 설정 파일에는 없어 null 로 읽힌다(그때는 본 것으로 친다). + public bool? TutorialSeen { get; set; } + + public VisualModeSettings WaveMode { get; set; } = new VisualModeSettings(); + public VisualModeSettings PadMode { get; set; } = new VisualModeSettings(); + public VisualModeSettings CircleMode { get; set; } = new VisualModeSettings(); + public VisualModeSettings OutlineMode { get; set; } = new VisualModeSettings(); + } + } +} + + diff --git a/SoundVisualizer.Core/Audio/CaptureLevel.cs b/SoundVisualizer.Core/Audio/CaptureLevel.cs new file mode 100644 index 0000000..cd2b624 --- /dev/null +++ b/SoundVisualizer.Core/Audio/CaptureLevel.cs @@ -0,0 +1,67 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Audio +{ + /// + /// 지금 소리 크기(0..1). 가장 최근 캡처 버퍼의 채널 가운데 가장 큰 피크다(모바일 AudioEngine.currentLevel). 개발자 모드 HUD 와 + /// 결과 기록의 lvl 칸이 이 값이고, "왜 아무것도 그려지지 않았나" 를 조용해서인지 AI 탓인지 가르는 데 쓴다. + /// + /// 와 달리 읽어도 비우지 않고 버퍼끼리 모으지도 않는다. 버퍼 하나의 값이라 "그사이 소리가 났는가" 는 + /// 이것으로 묻지 않는다(모바일도 같다). + /// + /// 채널은 캡처한 것을 모두 본다(모바일 캡처는 좌우 둘뿐이다). 그리는 쪽()은 사운드 + /// 모드가 그리지 못하는 채널을 빼지만, AI 무음 게이트는 버퍼의 모든 채널에서 가장 큰 값을 본다. 이 값은 AI 쪽에 맞춘다. 그래야 + /// 이 값이 아래에 머물 때 "AI 가 추론을 건너뛰었다" 로 읽을 수 있다. + /// + /// PC 에서 더한 것: WASAPI 루프백은 아무것도 재생하지 않으면 버퍼를 보내지 않는다. 그대로 두면 마지막 버퍼의 값이 남아 조용한데도 + /// 소리가 나는 것처럼 읽힌다. 그래서 마지막 버퍼가 보다 오래되면 0 으로 읽는다. 모바일 캡처는 조용해도 + /// 버퍼를 계속 보내 이런 일이 없다. + /// + /// 캡처 스레드가 버퍼마다 하고, UI 스레드가 한다. 할당은 없다. + /// + public sealed class CaptureLevel + { + /// + /// 마지막 버퍼가 이보다 오래되면(밀리초) 소리가 없는 것으로 읽는다. 재생하는 동안 버퍼는 10~50ms 마다 오므로, 그 다섯 배가 넘게 + /// 오지 않으면 재생이 멈춘 것이다. + /// + public const long StaleAfterMs = 250; + + private readonly object _gate = new(); + private float _level; + private long _receivedMs; + private bool _hasBuffer; + + /// + /// 버퍼 하나의 채널별 최고값을 받는다. 앞 버퍼의 값은 버린다. NaN 인 채널은 건너뛴다(장치가 깨진 값을 보내도 HUD 가 NaN 이 되지 않게). + /// + /// 이 버퍼의 채널별 최고값(0..1). 순서는 상관없다. + /// 받은 시각(단조 밀리초, ). 와 같은 시계다. + public void Offer(ReadOnlySpan channelPeaks, long nowMs) + { + float level = 0f; + foreach (float peak in channelPeaks) + { + if (peak > level) level = peak; // NaN 은 비교가 거짓이라 건너뛴다 + } + lock (_gate) + { + _level = level; + _receivedMs = nowMs; + _hasBuffer = true; + } + } + + /// 지금 소리 크기. 버퍼를 받은 적이 없거나 마지막 버퍼가 보다 오래되면 0 이다. + /// 읽는 시각(단조 밀리초, 와 같은 시계). + public float Read(long nowMs) + { + lock (_gate) + { + if (!_hasBuffer || nowMs - _receivedMs > StaleAfterMs) return 0f; + return _level; + } + } + } +} diff --git a/SoundVisualizer.Core/Audio/ChannelDownmix.cs b/SoundVisualizer.Core/Audio/ChannelDownmix.cs new file mode 100644 index 0000000..092315e --- /dev/null +++ b/SoundVisualizer.Core/Audio/ChannelDownmix.cs @@ -0,0 +1,106 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Runtime.InteropServices; + +namespace SoundVisualizer.Audio +{ + /// + /// 캡처한 소리의 채널 수를 출력 장치에 맞춘다(VB-CABLE 로 받은 소리를 실제 스피커로 다시 내보낼 때). + /// + /// 입력은 이번 버퍼에서 **실제로 녹음된 바이트만** 받는다. NAudio 는 캡처 버퍼를 재사용하므로 배열 전체를 넘기면 + /// 지난 버퍼의 남은 소리까지 스피커로 나간다. + /// + /// 7.1·5.1 → 2ch 는 ITU-R BS.775 계수로 섞고 LFE 는 뺀다. 채널 순서는 WASAPI 표준을 따른다. + /// 7.1 은 FL, FR, FC, LFE, BL, BR, SL, SR 이고, 5.1 은 FL, FR, FC, LFE 뒤에 서라운드 한 쌍이다. + /// + public static class ChannelDownmix + { + private const float KCenter = 0.707f; // -3dB + private const float KSide = 0.707f; // -3dB + private const float KBack = 0.500f; // -6dB + + /// + /// 의 채널 수를 로 바꿔 앞쪽에 쓴다. + /// 버퍼가 모자라면 넉넉하게 새로 만든다. 쓴 바이트 수를 돌려준다. + /// 7.1·5.1 float → 2ch 는 섞고, 그 밖에는 앞쪽 채널을 골라 쓰거나 첫 채널을 복제한다. + /// + public static int Convert(ReadOnlySpan input, int inChannels, int bytesPerSample, int outChannels, ref byte[] outputBuffer) + { + if (inChannels <= 0 || outChannels <= 0 || bytesPerSample <= 0) return 0; + + int frames = input.Length / (inChannels * bytesPerSample); + int neededSize = frames * outChannels * bytesPerSample; + if (outputBuffer.Length < neededSize) + { + outputBuffer = new byte[neededSize * 2]; // 다음 버퍼가 조금 커도 다시 만들지 않게 넉넉하게 + } + + var output = outputBuffer.AsSpan(0, neededSize); + var usedInput = input[..(frames * inChannels * bytesPerSample)]; + + if (bytesPerSample == 4 && outChannels == 2 && inChannels == 8) + { + Downmix8To2(MemoryMarshal.Cast(usedInput), MemoryMarshal.Cast(output)); + return neededSize; + } + if (bytesPerSample == 4 && outChannels == 2 && inChannels == 6) + { + Downmix6To2(MemoryMarshal.Cast(usedInput), MemoryMarshal.Cast(output)); + return neededSize; + } + + // 샘플 단위로 바이트를 옮긴다. float 가 아닌 형식(16·24bit PCM)도 그대로 맞는다. + for (int f = 0; f < frames; f++) + { + for (int ch = 0; ch < outChannels; ch++) + { + int srcCh = ch < inChannels ? ch : 0; + usedInput.Slice((f * inChannels + srcCh) * bytesPerSample, bytesPerSample) + .CopyTo(output.Slice((f * outChannels + ch) * bytesPerSample, bytesPerSample)); + } + } + return neededSize; + } + + /// L = FL + 0.707·FC + 0.707·SL + 0.5·BL, R 도 같은 꼴. LFE 는 뺀다. + private static void Downmix8To2(ReadOnlySpan src, Span dst) + { + int frames = src.Length / 8; + for (int i = 0; i < frames; i++) + { + int o = i * 8; + float fl = src[o + 0]; + float fr = src[o + 1]; + float fc = src[o + 2]; + // o + 3 은 LFE + float bl = src[o + 4]; + float br = src[o + 5]; + float sl = src[o + 6]; + float sr = src[o + 7]; + + dst[i * 2 + 0] = Math.Clamp(fl + KCenter * fc + KSide * sl + KBack * bl, -1f, 1f); + dst[i * 2 + 1] = Math.Clamp(fr + KCenter * fc + KSide * sr + KBack * br, -1f, 1f); + } + } + + /// L = FL + 0.707·FC + 0.707·서라운드 L, R 도 같은 꼴. LFE 는 뺀다. + private static void Downmix6To2(ReadOnlySpan src, Span dst) + { + int frames = src.Length / 6; + for (int i = 0; i < frames; i++) + { + int o = i * 6; + float fl = src[o + 0]; + float fr = src[o + 1]; + float fc = src[o + 2]; + // o + 3 은 LFE + float sl = src[o + 4]; + float sr = src[o + 5]; + + dst[i * 2 + 0] = Math.Clamp(fl + KCenter * fc + KSide * sl, -1f, 1f); + dst[i * 2 + 1] = Math.Clamp(fr + KCenter * fc + KSide * sr, -1f, 1f); + } + } + } +} diff --git a/SoundVisualizer.Core/Audio/PeakAccumulator.cs b/SoundVisualizer.Core/Audio/PeakAccumulator.cs new file mode 100644 index 0000000..a207efa --- /dev/null +++ b/SoundVisualizer.Core/Audio/PeakAccumulator.cs @@ -0,0 +1,69 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Audio +{ + /// + /// 캡처 스레드가 버퍼마다 잰 채널별 최고값을, 렌더 스레드가 프레임마다 가져갈 때까지 모아 둔다. + /// + /// 프레임 사이에 버퍼가 여러 개 오면 마지막 값만 남기지 않고 가장 큰 값을 남긴다. 그래야 30~60ms 짜리 총성이 + /// 다음 버퍼의 조용한 값에 덮여 사라지지 않는다. 캡처 스레드와 렌더 스레드가 같은 필드를 따로 고치던 경합도 없앤다 + /// (렌더 스레드의 감쇠가 막 들어온 값을 덮어쓸 수 있었다). + /// + /// 채널 순서는 FL, FR, FC, BL, BR, SL, SR, LFE 이다. + /// + public sealed class PeakAccumulator + { + public const int ChannelCount = 8; + + private readonly object _gate = new(); + private readonly float[] _peaks = new float[ChannelCount]; + private bool _hasData; + + /// 버퍼 하나의 채널별 최고값을 더한다. 이미 모인 값보다 큰 것만 남는다. 캡처 스레드에서 부른다. + public void Offer(float fl, float fr, float fc, float bl, float br, float sl, float sr, float lfe) + { + lock (_gate) + { + Max(0, fl); Max(1, fr); Max(2, fc); Max(3, bl); + Max(4, br); Max(5, sl); Max(6, sr); Max(7, lfe); + _hasData = true; + } + } + + /// + /// 지난번에 가져간 뒤로 모인 값을 에 쓰고 비운다. 그사이 버퍼가 하나도 오지 않았으면 + /// false 를 돌려주고 아무것도 쓰지 않는다(렌더 쪽은 그때 이전 값을 줄여 나간다). 렌더 스레드에서 부른다. + /// + public bool TryTake(Span destination) + { + if (destination.Length < ChannelCount) + throw new ArgumentException($"destination must hold {ChannelCount} channels", nameof(destination)); + + lock (_gate) + { + if (!_hasData) return false; + _peaks.AsSpan().CopyTo(destination); + Array.Clear(_peaks); + _hasData = false; + return true; + } + } + + /// 모인 값을 버린다. 캡처를 다시 시작할 때 부른다. + public void Clear() + { + lock (_gate) + { + Array.Clear(_peaks); + _hasData = false; + } + } + + private void Max(int index, float value) + { + // NaN 은 버린다. 장치가 깨진 값을 보내도 그래픽이 굳지 않게 한다. + if (value > _peaks[index]) _peaks[index] = value; + } + } +} diff --git a/SoundVisualizer.Core/ChoiceRows.cs b/SoundVisualizer.Core/ChoiceRows.cs new file mode 100644 index 0000000..0016216 --- /dev/null +++ b/SoundVisualizer.Core/ChoiceRows.cs @@ -0,0 +1,36 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer +{ + /// + /// 같은 너비의 선택지 칸(표현 모드, 사운드 모드)을 한 줄에 몇 개씩 둘지. WPF 의 EqualChoicePanel 이 쓴다. + /// 안드로이드 판 choicesPerRow(#304)와 같은 규칙이다. + /// + public static class ChoiceRows + { + /// + /// 모두 한 줄부터 하나씩 줄이며, 가장 넓은 칸이 줄을 바꾸지 않고 들어가는 첫 수를 고른다. + /// 칸을 좁힌 채 글자를 접으면 WPF 는 단어 가운데서도 끊고("Outli/ne"), 접지 않으면 글자가 잘리기 때문이다. + /// 마지막 줄에 한 칸만 남는 나눔(네 칸의 3+1, 다섯 칸의 4+1)은 건너뛴다. 어느 수에도 안 들어가면 한 칸씩이다. + /// + /// 칸 수 + /// 줄 전체 너비 + /// 칸 사이 간격 + /// 가장 넓은 칸이 줄을 바꾸지 않을 때의 너비(안쪽 여백 포함) + public static int PerRow(int count, double rowWidth, double gap, double widestCell) + { + if (count <= 1) return 1; + for (int perRow = count; perRow >= 1; perRow--) + { + bool lonelyLastRow = perRow != count && count % perRow != 0 && count % perRow < perRow - 1; + if (lonelyLastRow) continue; + if (widestCell <= CellWidth(rowWidth, perRow, gap)) return perRow; + } + return 1; + } + + /// 한 줄에 perRow 칸을 둘 때 칸 하나의 너비. + public static double CellWidth(double rowWidth, int perRow, double gap) => Math.Max(0, (rowWidth - gap * (perRow - 1)) / Math.Max(1, perRow)); + } +} diff --git a/SoundVisualizer.Core/ComboListKeys.cs b/SoundVisualizer.Core/ComboListKeys.cs new file mode 100644 index 0000000..9e965bf --- /dev/null +++ b/SoundVisualizer.Core/ComboListKeys.cs @@ -0,0 +1,59 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer +{ + /// 콤보에 누른 키. WPF 의 Key 를 Core 에서 쓰려고 필요한 것만 옮겼다. + public enum ComboKey + { + Other, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Enter, + Space, + Tab, + } + + /// 키를 콤보에 넘기기 전에 할 일. + public enum ComboKeyAction + { + /// 콤보가 늘 하던 대로 한다. + Default, + + /// 값을 바꾸지 않고 목록을 연다(키는 콤보에 넘기지 않는다). + OpenList, + + /// 고르지 않고 목록을 닫은 뒤 초점을 다음(Shift 면 앞) 칸으로 옮긴다. + CloseAndMoveFocus, + } + + /// + /// 고르는 즉시 앱 전체가 바뀌는 콤보(언어)의 키 규칙. WPF 콤보는 닫혀 있을 때 화살표·Home/End·글자 키로 바로 다음 항목을 골라서, + /// 키를 누를 때마다 언어(글꼴, 쓰는 방향, 모든 글자)가 바뀌었다. 그래서 닫혀 있을 때는 이 키들로 목록을 열기만 하고, + /// 열린 목록에서 둘러본 뒤 Enter(또는 Alt+↑, F4, 마우스 클릭)로 고를 때만 바꾼다. Esc 는 고르지 않고 닫는다. + /// WAI-ARIA 의 "select-only combobox" 와 같은 동작이다. WPF 의 ui:CommitOnCloseComboBox 가 쓴다. + /// + public static class ComboListKeys + { + /// + /// 키를 콤보에 넘기기 전에 할 일. 닫힌 콤보의 이동 키와 Enter·스페이스는 목록을 연다. 열린 목록의 Tab 은 고르지 않고 닫은 뒤 + /// 다음 칸으로 간다(WPF 는 목록 안의 다음 항목으로만 돌아 목록을 빠져나가지 못한다). Alt 와 함께 누른 키(Alt+↓ 는 WPF 가 이미 + /// 연다)와 그 밖의 키(F4, Esc)는 콤보가 하던 대로 둔다. 열린 목록의 이동 키는 WPF 가 항목을 옮겨 보이기만 하므로 손대지 않는다. + /// + public static ComboKeyAction OnKeyDown(ComboKey key, bool alt, bool listOpen) + { + if (alt || key == ComboKey.Other) return ComboKeyAction.Default; + if (listOpen) return key == ComboKey.Tab ? ComboKeyAction.CloseAndMoveFocus : ComboKeyAction.Default; + return key == ComboKey.Tab ? ComboKeyAction.Default : ComboKeyAction.OpenList; + } + + /// 닫힌 콤보에 친 글자도 목록부터 연다. 닫힌 채로 글자를 찾으면 찾은 항목이 바로 골라지기 때문이다. + public static bool TextOpensList(string? text, bool listOpen) => !listOpen && !string.IsNullOrEmpty(text); + } +} diff --git a/SoundVisualizer.Core/Diagnostics/AiDebugCsv.cs b/SoundVisualizer.Core/Diagnostics/AiDebugCsv.cs new file mode 100644 index 0000000..b62ae69 --- /dev/null +++ b/SoundVisualizer.Core/Diagnostics/AiDebugCsv.cs @@ -0,0 +1,120 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using System.Text; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Diagnostics +{ + /// + /// AI 판정 하나를 CSV 한 줄로 바꾼다(모바일 AiDebugCsv.kt, #193). 파일로 쓰는 일은 가 한다. + /// + /// 머리글, 칸 순서, 따옴표, 숫자 서식이 모바일과 글자까지 같다. 두 판의 기록을 같은 스크립트로 읽고 나란히 비교한다. + /// HUD 는 지금 순간만 보여주므로, 총소리 한 발처럼 짧은 판정은 이 기록으로 되짚는다. + /// + public static class AiDebugCsv + { + /// top-5 를 몇 칸까지 적는지. 모델이 덜 내놓으면 남은 칸은 비워 둔다. + public const int TopSlots = 5; + + /// 값이 없을 때 넣는 것. + public const string None = ""; + + /// + /// capture_source 칸. PC 는 시스템 소리(WASAPI 루프백)만 받으므로 늘 이 값이다. 모바일은 실행마다 고른 + /// InternalPlayback·Microphone 을 적는다. + /// + public const string CaptureSource = "Loopback"; + + public static IReadOnlyList Columns { get; } = BuildColumns(); + + /// 머리글 한 줄. 파일을 새로 열 때 한 번만 쓴다. 칸 이름에는 쉼표가 없어 따옴표로 감싸지 않는다. + public static string Header { get; } = string.Join(",", Columns); + + /// 결과 하나를 한 줄로. 줄 끝 문자는 붙이지 않는다. + /// 적을 결과. + /// + /// 읽은 시각(유닉스 밀리초). 와의 차이가 age 다. 무음이면 추론을 건너뛰고 + /// 마지막 결과를 들고 있으므로, 나이가 자라는 것이 보여야 "추론이 멈췄다" 를 알 수 있다. + /// + /// 그때의 소리 크기. "왜 아무것도 안 그려졌나" 를 조용해서인지 AI 탓인지 가른다. + /// + /// 그 종류 표시가 켜져 있었는지. 꺼 두면 오버레이가 아무것도 그리지 않으므로, 이것 없이는 설정 상태를 "AI 가 못 잡았다" 로 오독한다. + /// + public static string Row(AiClassificationResult result, long nowMs, float level, bool shown) + { + ArgumentNullException.ThrowIfNull(result); + var row = new StringBuilder(384); + Cell(row, Integer(nowMs)); + Cell(row, Integer(result.TimestampMs)); + // 벽시계는 뒤로 튈 수 있다. 음수 나이는 뜻이 없으니 0 으로 붙인다. + Cell(row, Integer(Math.Max(nowMs - result.TimestampMs, 0L))); + Cell(row, CaptureSource); + Cell(row, result.Coarse.ToLabel()); + Cell(row, result.Display); + Cell(row, Prob(result.Confidence)); + Cell(row, Bool(result.MeetsThreshold)); + Cell(row, Bool(result.DangerCuePromoted)); + for (int slot = 0; slot < TopSlots; slot++) + { + bool has = slot < result.Top5.Count; + Cell(row, has ? result.Top5[slot].Name : None); + Cell(row, has ? Prob(result.Top5[slot].Probability) : None); + } + Cell(row, Prob(level)); + Cell(row, Bool(shown)); + Cell(row, Millis(result.PreprocessMs)); + Cell(row, Millis(result.YamnetMs)); + Cell(row, Millis(result.TotalMs)); + Cell(row, result.MappingOverrideCount.ToString(CultureInfo.InvariantCulture)); + Cell(row, result.MappingSignature); + return row.ToString(); + } + + private static IReadOnlyList BuildColumns() + { + var columns = new List + { + "time_ms", + "result_ms", + "age_ms", + "capture_source", + "coarse", + "display", + "confidence", + // 임계값 자체는 결과에 실리지 않는다(후처리 안에만 있다). 넘었는지만 적는다. + "meets_threshold", + "danger_cue_promoted", + }; + for (int slot = 1; slot <= TopSlots; slot++) + { + columns.Add(string.Create(CultureInfo.InvariantCulture, $"top{slot}_name")); + columns.Add(string.Create(CultureInfo.InvariantCulture, $"top{slot}_prob")); + } + columns.AddRange(["level", "shown", "preprocess_ms", "yamnet_ms", "total_ms", "mapping_override_count", "mapping_signature"]); + return columns.AsReadOnly(); + } + + /// + /// 칸 하나를 따옴표로 감싸 붙인다. YAMNet 이름에는 쉼표가 들어간다("Gunshot, gunfire"). 감싸지 않으면 그 줄만 칸이 하나 밀려 + /// 표 전체가 어긋난다. 이름에 든 따옴표는 두 번 적는다. 모바일처럼 숫자 칸도 모두 감싼다. + /// + private static void Cell(StringBuilder row, string value) + { + if (row.Length > 0) row.Append(','); + row.Append('"').Append(value.Replace("\"", "\"\"", StringComparison.Ordinal)).Append('"'); + } + + private static string Integer(long value) => value.ToString(CultureInfo.InvariantCulture); + + /// Kotlin Boolean.toString 과 같은 소문자. .NET 의 bool.ToString 은 "True" 다. + private static string Bool(bool value) => value ? "true" : "false"; + + /// 확률·소리 크기는 다섯 자리로. 로캘을 고정한다(아랍어 로캘에서 소수점이 아랍 숫자로 나가면 표가 읽히지 않는다). + private static string Prob(float value) => float.IsNaN(value) ? None : JavaNumberFormat.Fixed(value, 5); + + /// 소요 시간은 한 자리면 된다. + private static string Millis(double value) => double.IsNaN(value) ? None : JavaNumberFormat.Fixed(value, 1); + } +} diff --git a/SoundVisualizer.Core/Diagnostics/AiDebugHud.cs b/SoundVisualizer.Core/Diagnostics/AiDebugHud.cs new file mode 100644 index 0000000..ca13557 --- /dev/null +++ b/SoundVisualizer.Core/Diagnostics/AiDebugHud.cs @@ -0,0 +1,109 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Diagnostics +{ + /// + /// 오버레이 하나의 개발자 모드 HUD 와 결과 기록을 언제 돌리고 멈출지 정한다(모바일 AiDebugOverlay 의 produceState 와 DisposableEffect). + /// + /// - HUD 는 오버레이가 열려 있고 개발자 모드가 켜져 있는 동안만 마다 읽는다(). + /// - 기록은 거기에 기록 스위치까지 켜져 있을 때만 돈다(). 셋 가운데 하나라도 바뀌면 그 자리에서 켜고 끈다. + /// 개발자 모드를 꺼도 기록 스위치의 값은 남아, 다시 켜면 새 파일에 기록한다(모바일과 같다). + /// - 읽을 때마다 결과를 기록기에 넘긴다. 같은 결과를 거듭 넘겨도 기록기가 한 줄만 남긴다(). + /// - 오버레이를 닫으면() 기록기를 닫는다. 그 뒤에는 무엇을 넘겨도 다시 켜지 않는다. + /// + /// 화면과 타이머는 오버레이가 맡고, 여기는 무엇을 할 차례인지만 정해 기기 없이 검사한다(AiDebugHudTests). UI 스레드에서만 쓴다. + /// 기록기는 디스크를 기다리지 않으므로() 읽을 때마다 불러도 된다. 닫을 때만 남은 줄을 쓰기를 1초까지 기다린다. + /// + public sealed class AiDebugHud : IDisposable + { + /// HUD 를 다시 읽는 간격(밀리초, 모바일 DEBUG_POLL_MS). 추론이 250ms 마다라 그보다 촘촘해야 짧은 판정 하나를 건너뛰지 않는다. + public const int PollIntervalMs = 100; + + private readonly IAiDebugRecorder _recorder; + private bool _developerMode; + private bool _record; + private bool _closed; + + /// 앱이 쓰는 HUD. 기록은 실행 파일 폴더(쓸 수 없으면 LocalAppData)의 ai-log 에 남는다(). + public AiDebugHud() + : this(new AiDebugRecorder()) + { + } + + /// 켜고 끌 기록기. 이 HUD 가 넘겨받아 에서 닫는다. 테스트가 가짜나 임시 폴더의 기록기를 준다. + internal AiDebugHud(IAiDebugRecorder recorder) + { + ArgumentNullException.ThrowIfNull(recorder); + _recorder = recorder; + } + + /// HUD 를 보이고 마다 읽을 차례인지. 오버레이가 열려 있고 개발자 모드가 켜져 있다. + public bool IsPolling => !_closed && _developerMode; + + /// + /// 결과를 기록할 차례인지. 읽는 중이고 기록 스위치도 켜져 있다. 기록기가 상한이나 쓰기 실패로 스스로 멈춘 것은 여기에 나타나지 + /// 않는다(모바일과 같다). 스위치를 껐다 켜면 새 파일로 다시 시작한다. + /// + public bool IsRecording => IsPolling && _record; + + /// + /// 지금 설정을 알린다. 오버레이가 연 직후와 프레임마다(쉬는 동안에는 확인할 때마다) 부르므로, 런처·편집 패널·초기화 어디서 바꿔도 + /// 곧바로 따른다. 기록할 차례가 되면 기록기를 켜고, 아니게 되면 끈다. 같은 값이면 아무것도 하지 않는다. 닫은 뒤에는 무시한다. + /// + /// 이 바뀌었으면 true. 부르는 쪽이 HUD 를 보이거나 숨기고 타이머를 켜고 끈다. + public bool Update(bool developerMode, bool record) + { + if (_closed) return false; + bool wasPolling = IsPolling; + bool wasRecording = IsRecording; + _developerMode = developerMode; + _record = record; + + if (IsRecording != wasRecording) + { + if (IsRecording) _recorder.Start(); + else _recorder.Stop(); + } + return IsPolling != wasPolling; + } + + /// + /// HUD 를 한 번 읽어 그 글자를 돌려준다. 기록할 차례이고 결과가 있으면 같은 값으로 기록기에도 넘긴다. 값은 부르는 쪽이 이 순간 + /// 한 번씩 읽어 넘긴다. 쓰는 스레드가 나중에 상태를 읽으면 그사이 바뀐 값이 적힌다. + /// + /// 마지막 결과. 아직 없거나 AI 가 돌지 않으면 null. + /// 지금 벽시계 시각(유닉스 밀리초, 와 같은 시계). + /// 지금 소리 크기(0..1, ). + /// 그 결과의 종류를 표시하도록 켜 두었는지. 결과가 없으면 false. + /// AI 를 만들지 못해 분류가 아예 돌지 않으면 false. 만드는 중에는 true 다. + public AiDebugLines Poll(AiClassificationResult? result, long nowMs, float level, bool shown, bool aiAvailable) + { + if (IsRecording && result is not null) _recorder.Offer(result, nowMs, level, shown); + return AiDebugText.Format(result, nowMs, level, shown, aiAvailable); + } + + /// 오버레이를 닫았다. 기록기를 닫는다(남은 줄을 쓰고 파일을 닫기를 1초까지 기다린다). 여러 번 불러도 된다. + public void Dispose() + { + if (_closed) return; + _closed = true; + _recorder.Dispose(); + } + } + + /// 결과 기록기(). 가 켜고 끄고, 테스트는 가짜로 바꿔 끼운다. + internal interface IAiDebugRecorder : IDisposable + { + /// + void Start(); + + /// + void Stop(); + + /// + void Offer(AiClassificationResult result, long nowMs, float level, bool shown); + } +} diff --git a/SoundVisualizer.Core/Diagnostics/AiDebugLogWriter.cs b/SoundVisualizer.Core/Diagnostics/AiDebugLogWriter.cs new file mode 100644 index 0000000..2d5be4b --- /dev/null +++ b/SoundVisualizer.Core/Diagnostics/AiDebugLogWriter.cs @@ -0,0 +1,73 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Diagnostics +{ + /// + /// AI 판정을 받아 CSV 줄로 쌓는다(모바일 AiDebugLogWriter.kt, #193). 어디에 쌓느냐는 sink 가 정한다. 파일이든 테스트의 문자열이든. + /// + /// 세 가지를 여기서 지킨다. + /// - 머리글은 한 번만. 첫 줄을 쓸 때 같이 나간다. 결과가 한 번도 없으면 아무것도 쓰지 않는다. + /// - 같은 결과는 한 번만. HUD 는 추론(250ms)보다 자주 읽어 같은 결과를 여러 번 본다. + /// 가 앞서 쓴 것과 같으면 건너뛴다. + /// - 상한에 닿으면 멈춘다. 초당 네 줄이 하루면 수십 MB 가 된다. 켜 둔 것을 잊은 PC 의 디스크를 먹지 않게 한다. + /// 멈춘 뒤에는 조용히 아무것도 쓰지 않는다( 로 알 수 있다). + /// + /// 한 스레드에서만 쓴다( 의 쓰는 스레드). + /// + public sealed class AiDebugLogWriter + { + /// 기본 상한 20MB. 초당 네 줄(한 줄 약 350바이트)이면 하루쯤 담긴다. + public const long DefaultMaxBytes = 20L * 1024 * 1024; + + private readonly TextWriter _sink; + private readonly long _maxBytes; + private bool _wroteHeader; + private long _written; + private long? _lastResultMs; + + public AiDebugLogWriter(TextWriter sink, long maxBytes = DefaultMaxBytes) + { + ArgumentNullException.ThrowIfNull(sink); + _sink = sink; + _maxBytes = maxBytes; + } + + /// 쓴 줄 수(머리글은 세지 않는다). + public int Rows { get; private set; } + + /// 상한에 닿아 그만 쓰기로 했는지. + public bool Stopped { get; private set; } + + /// 결과 하나를 쌓는다. 실제로 한 줄을 썼으면 true, 같은 결과라 건너뛰었거나 상한에 닿았으면 false. + /// 상한은 쓰고 나서 본다. 상한을 넘기는 그 줄까지는 쓰고 멈춘다(모바일과 같다). + public bool Write(AiClassificationResult result, long nowMs, float level, bool shown) + { + ArgumentNullException.ThrowIfNull(result); + if (Stopped) return false; + if (result.TimestampMs == _lastResultMs) return false; + + if (!_wroteHeader) + { + Append(AiDebugCsv.Header); + _wroteHeader = true; + } + Append(AiDebugCsv.Row(result, nowMs, level, shown)); + _lastResultMs = result.TimestampMs; + Rows++; + if (_written >= _maxBytes) Stopped = true; + return true; + } + + private void Append(string line) + { + // 줄 끝은 모바일 파일과 같은 \n 하나다. WriteLine 은 Windows 에서 \r\n 을 쓴다. + _sink.Write(line); + _sink.Write('\n'); + // 한 글자를 한 바이트로 센다. 이름이 아스키라 거의 맞고, 상한은 정확할 필요가 없다. + _written += line.Length + 1; + } + } +} diff --git a/SoundVisualizer.Core/Diagnostics/AiDebugRecorder.cs b/SoundVisualizer.Core/Diagnostics/AiDebugRecorder.cs new file mode 100644 index 0000000..0bdf804 --- /dev/null +++ b/SoundVisualizer.Core/Diagnostics/AiDebugRecorder.cs @@ -0,0 +1,410 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Security; +using System.Text; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Diagnostics +{ + /// + /// 개발자 모드의 결과 기록을 파일로 남긴다(모바일 AiDebugRecorder.kt, #193). 줄을 만드는 규칙은 에 있다. + /// + /// 켜면 <실행 파일 폴더>/ai-log/ai-yyyyMMdd-HHmmss.csv 를 새로 열고, 끄면 닫는다. 앱이 설정 파일도 실행 파일 옆에 두는 + /// 포터블 배포라 기록도 옆에 둔다. 그 폴더에 쓸 수 없으면(Program Files 에 풀어 둔 경우 등) %LOCALAPPDATA%/SoundVisualizer/ai-log/ 에 둔다. + /// + /// 디스크는 쓰는 스레드에서만 만진다. 부르는 곳은 오버레이의 개발자 모드 HUD(UI 스레드, )라, 거기서 폴더를 + /// 확인하거나 파일을 열거나 쓰면 그리는 프레임이 밀린다. 그래서 는 큐에 넣고 바로 돌아오고, 폴더를 고르는 일까지 쓰는 스레드가 한다(#199, #208). + /// + /// 켠 회차마다 큐와 쓰는 스레드를 따로 둔다. 은 쓰는 스레드를 기다리지 않으므로 껐다 곧바로 켜면 옛 스레드가 아직 + /// 살아 있다. 큐가 하나면 두 스레드가 나눠 먹어 줄이 두 파일로 갈렸다(#208). 회차마다 따로면 옛 스레드는 자기 줄만 비우고 나가고, + /// 새 줄은 새 스레드만 본다. + /// + /// 스레드: ·· 는 UI 스레드에서 부른다(어느 스레드여도 되고, 디스크를 기다리지 않는다). + /// 는 오버레이를 닫을 때 부르며, 남은 줄을 쓰고 파일을 닫기를 1초까지 기다린다. + /// + /// 모바일과 다른 점: + /// - 폴더를 고르려면 실제로 만들어 봐야 해서 폴더와 파일을 첫 줄이 올 때 함께 정한다. 그래서 은 파일이 + /// 생긴 뒤에 채워진다(모바일은 쓰는 스레드가 시작할 때 경로만 정한다). + /// - 같은 초에 다시 켜면 모바일은 같은 이름의 파일을 덮어쓴다. 여기서는 앞 회차를 지우지 않게 ai-…-2.csv 처럼 번호를 붙인다. + /// Windows 에서는 옛 스레드가 아직 연 파일을 다시 열 수도 없다. + /// - 가 같은 결과를 큐에 거듭 넣지 않는다. HUD 는 추론(250ms)보다 자주(100ms) 읽어 같은 결과를 두세 번 넘기는데, + /// 모바일은 그것을 모두 큐에 넣고 쓰는 쪽에서 거른다. 남는 줄은 모바일과 같다(그 결과를 처음 본 때의 값이 남는다). + /// - 끈 뒤에 들어온 줄은 받지 않고, 끄기 전에 받은 줄은 모두 쓴다. 모바일은 끄는 순간과 겹친 줄이 아무도 비우지 않는 큐에 남을 수 있다. + /// - 쓰다 실패해도 회차를 닫는다. 모바일은 상한과 열기 실패에서만 닫아, 쓰기 실패 뒤에도 오버레이가 아무도 비우지 않는 큐에 줄을 넣는다. + /// + public sealed class AiDebugRecorder : IAiDebugRecorder + { + /// 기록을 두는 폴더 이름. 모바일과 같다. + public const string DirectoryName = "ai-log"; + + /// + /// 한 회차의 큐에 담아 두는 줄 수. 넘치면 오래된 것을 버린다. 초당 네 줄이니 50초 분량이다. 디스크가 그만큼 밀리는 일은 없고, + /// 있다면 그 PC 에서는 기록 자체가 뜻이 없다. + /// + internal const int QueueCapacity = 200; + + /// 가 쓰는 스레드를 기다리는 한도. 앱을 닫는 UI 스레드를 오래 잡지 않는다. + internal const int CloseWaitMs = 1000; + + /// 같은 초에 다시 켠 회차가 붙이는 번호의 끝. 사람이 스위치를 누르는 간격이라 실제로는 2 를 넘지 않는다. + private const int MaxNameAttempts = 100; + + private const string Tag = nameof(AiDebugRecorder); + + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + private readonly string? _portableBase; + private readonly string? _fallbackBase; + private readonly long _maxBytes; + private readonly Func _clock; + private readonly Func _createFile; + + // 켜기·끄기·닫기와, 쓰는 스레드가 스스로 회차를 닫는 일의 순서를 지킨다. 디스크를 기다리는 동안에는 잡지 않는다. + private readonly object _lifecycleLock = new(); + // 아직 끝나지 않은 쓰는 스레드들. 끈 회차가 남은 줄을 쓰는 중이면 그 스레드도 여기 있다. + private readonly List _writers = []; + private Session? _session; + private string? _currentFile; + private bool _disposed; + + /// 앱이 쓰는 기록기. 폴더는 실행 파일 폴더, 거기에 쓸 수 없으면 %LOCALAPPDATA%/SoundVisualizer 다. + public AiDebugRecorder() + : this(portableBase: null, fallbackBase: null) + { + } + + /// 먼저 쓰는 폴더의 부모. null 이면 실행 파일 폴더(). + /// 거기에 쓸 수 없을 때의 부모. null 이면 %LOCALAPPDATA%/SoundVisualizer. + /// 파일 하나의 상한. 테스트가 작게 준다. + /// 파일 이름에 쓰는 지역 시각. 테스트가 정한다. + /// + /// 새 파일을 만든다. 같은 이름이 있으면 을 던진다. 테스트가 쓰는 스레드를 세워 두거나 권한 없음을 흉내 낸다. + /// + internal AiDebugRecorder( + string? portableBase, + string? fallbackBase, + long maxBytes = AiDebugLogWriter.DefaultMaxBytes, + Func? clock = null, + Func? createFile = null) + { + _portableBase = portableBase; + _fallbackBase = fallbackBase; + _maxBytes = maxBytes; + _clock = clock ?? (static () => DateTime.Now); + _createFile = createFile ?? CreateNewFile; + } + + /// 켜져 있는지. 상한에 닿았거나 파일을 만들거나 쓰지 못해 쓰는 쪽이 스스로 닫으면 false 가 된다. + public bool IsRecording => Volatile.Read(ref _session) is not null; + + /// 지금 쓰고 있는 파일의 전체 경로. 꺼져 있거나, 켰지만 첫 줄이 와서 파일이 생기기 전이면 null. + public string? CurrentFile => Volatile.Read(ref _currentFile); + + /// 지금 회차에서 큐가 차서 버린 줄 수. 0 이 아니면 디스크가 따라오지 못한 것이다. 꺼져 있으면 0. + public int Dropped => Volatile.Read(ref _session)?.Dropped ?? 0; + + /// + /// 기록을 시작한다. 이미 켜져 있거나 닫은 뒤면 아무것도 하지 않는다(설정을 두 번 켜도 파일이 갈리지 않게). + /// + /// 여기서는 디스크를 만지지 않는다. 파일 이름에 쓸 시각만 정하고 쓰는 스레드를 띄운다. 줄이 한 번도 오지 않으면 폴더도 파일도 + /// 만들지 않는다. 켜 보고 바로 끈 PC 에 빈 파일이 남지 않는 편이 낫다. 파일을 만들지 못하면 쓰는 스레드가 로그를 남기고 그 회차를 + /// 닫는다. 기록이 안 되는 것이 앱이 멈추는 것보다 낫다. + /// + public void Start() + { + string stamp; + lock (_lifecycleLock) + { + if (_disposed || _session is not null) return; + stamp = _clock().ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + var mine = new Session(stamp); + var writer = new Thread(() => Run(mine)) + { + IsBackground = true, + Name = "SV-AiDebugRecord", + }; + writer.Start(); + // 쓰는 스레드는 끝날 때 이 잠금 안에서 자기를 뺀다. 잠금을 쥔 채 넣으므로 빼기보다 넣기가 먼저다. + _writers.Add(writer); + Volatile.Write(ref _currentFile, null); + Volatile.Write(ref _session, mine); + } + AppLog.Info(Tag, $"기록 시작: ai-{stamp}.csv (폴더는 첫 줄이 올 때 쓰는 스레드가 정한다)"); + } + + /// + /// 기록을 멈춘다. 꺼져 있으면 아무것도 하지 않는다. 쓰는 스레드를 기다리지 않는다. 그 스레드는 끄기 전에 받은 줄을 모두 쓰고 + /// 파일을 닫은 뒤 스스로 나간다. + /// + public void Stop() + { + Session? leaving; + lock (_lifecycleLock) + { + leaving = _session; + if (leaving is null) return; + Volatile.Write(ref _session, null); + // 지금 쓰고 있는 파일이다. 끈 뒤에도 남겨 두면 켠 상태와 끈 상태를 가릴 수 없다. + Volatile.Write(ref _currentFile, null); + } + leaving.Deactivate(); + } + + /// + /// 결과 하나를 지금 회차의 큐에 넣고 바로 돌아온다. 꺼져 있으면 아무것도 하지 않는다. HUD 가 읽을 때마다(100ms) 부르며, + /// 렌더 루프에서 프레임마다 불러도 될 만큼 가볍다. + /// 같은 결과( 가 같은 것)를 거듭 넘기면 처음 넘긴 것만 남는다. + /// + /// 마지막 결과. + /// 지금 벽시계 시각(유닉스 밀리초). + /// 지금 소리 크기(0..1). + /// 그 종류의 표시가 켜져 있는지. + public void Offer(AiClassificationResult result, long nowMs, float level, bool shown) + { + ArgumentNullException.ThrowIfNull(result); + Volatile.Read(ref _session)?.Offer(new Row(result, nowMs, level, shown)); + } + + /// + /// 기록을 멈추고, 쓰는 스레드들이 남은 줄을 쓰고 파일을 닫기를 1초까지 기다린다. 앱을 닫을 때 부른다. 쓰는 스레드는 배경 스레드라 + /// 기다리지 않으면 프로세스가 끝나면서 받아 둔 줄을 잃는다. 닫은 뒤에는 가 아무것도 하지 않는다. 여러 번 불러도 된다. + /// + public void Dispose() + { + lock (_lifecycleLock) + { + if (_disposed) return; + _disposed = true; + } + Stop(); + if (!WaitForWriters(CloseWaitMs)) + AppLog.Warn(Tag, "쓰는 스레드가 1초 안에 끝나지 않았다. 프로세스가 끝나면 남은 줄을 잃을 수 있다"); + } + + /// 지금 살아 있는 쓰는 스레드가 모두 끝나기를 기다린다. 모두 끝났으면 true. 끄지 않은 회차의 스레드는 끝나지 않는다. + internal bool WaitForWriters(int timeoutMs) + { + Thread[] writers; + lock (_lifecycleLock) writers = [.. _writers]; + long deadline = Environment.TickCount64 + timeoutMs; + foreach (var writer in writers) + { + if (!writer.Join((int)Math.Max(0L, deadline - Environment.TickCount64))) return false; + } + return true; + } + + /// 파일 이름. 같은 초에 다시 켠 회차만 번호가 붙는다. + internal static string FileName(string stamp, int attempt) => + attempt == 1 ? $"ai-{stamp}.csv" : string.Create(CultureInfo.InvariantCulture, $"ai-{stamp}-{attempt}.csv"); + + /// 쓰는 스레드의 본체. 자기 회차의 큐만 본다. + private void Run(Session mine) + { + TextWriter? writer = null; + AiDebugLogWriter? log = null; + string? path = null; + try + { + while (mine.TryTake(out var row)) + { + if (writer is null || log is null) + { + // 파일은 첫 줄이 올 때 만든다. 어느 폴더에 둘지도 이때 실제로 만들어 보고 정한다. + if (!TryOpen(mine.Stamp, out writer, out path)) + { + // 회차를 닫아, 오버레이가 아무도 비우지 않는 큐에 줄을 넣지 않게 한다. + CloseSession(mine); + return; + } + log = new AiDebugLogWriter(writer, _maxBytes); + PublishCurrentFile(mine, path); + } + + // 줄마다 디스크에 밀어 넣는다. 모아 두면 기록 중에 연 파일에 최근 몇 초가 비어, 그 구간을 "결과가 없었다" 로 읽는다. + // 초당 네 번 쓰는 비용은 프레임마다 그리는 것 옆에서 없는 셈이다. + if (log.Write(row.Result, row.NowMs, row.Level, row.Shown)) writer.Flush(); + if (log.Stopped) + { + // 상태를 사실에 맞춘다. 닫지 않으면 오버레이가 아무도 비우지 않는 큐에 계속 줄을 넣는다. + AppLog.Warn(Tag, $"기록 상한에 닿아 멈춘다: {log.Rows}줄, {Path.GetFileName(path)}"); + CloseSession(mine); + break; + } + } + } + catch (Exception ex) + { + // 이 스레드에서 새어 나간 예외는 앱을 끝낸다. 디스크가 가득 찼거나 쓰던 파일에 쓸 수 없게 된 경우가 여기로 온다. + AppLog.Warn(Tag, "기록을 쓰다 실패해 멈춘다", ex); + CloseSession(mine); + } + finally + { + try + { + writer?.Dispose(); + } + catch (Exception ex) + { + AppLog.Warn(Tag, "기록 파일을 닫다 실패했다", ex); + } + string what = path is null ? "파일을 만들지 않았다" : $"{log?.Rows ?? 0}줄, {Path.GetFileName(path)}"; + AppLog.Info(Tag, $"기록 끝: {what}, 버린 줄 {mine.Dropped}"); + lock (_lifecycleLock) _writers.Remove(Thread.CurrentThread); + } + } + + /// 폴더를 고르고 새 파일을 만든다. 쓰는 스레드에서만 부른다. 실행 파일 폴더가 먼저고, 거기에 쓸 수 없으면 LocalAppData 다. + private bool TryOpen(string stamp, [NotNullWhen(true)] out TextWriter? writer, [NotNullWhen(true)] out string? path) + { + foreach (string directory in CandidateDirectories()) + { + try + { + Directory.CreateDirectory(directory); + for (int attempt = 1; attempt <= MaxNameAttempts; attempt++) + { + string candidate = Path.Combine(directory, FileName(stamp, attempt)); + Stream stream; + try + { + stream = _createFile(candidate); + } + catch (IOException) when (File.Exists(candidate)) + { + // 같은 초에 켠 앞 회차의 파일이다. 덮어쓰지 않고 다음 번호로 간다. + continue; + } + writer = new StreamWriter(stream, Utf8NoBom); + path = candidate; + AppLog.Info(Tag, $"기록 파일: {candidate}"); + return true; + } + AppLog.Warn(Tag, $"같은 시각의 기록 파일이 너무 많다: {directory}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + // 쓸 수 없는 폴더다(Program Files, 읽기 전용 매체, 폴더 자리에 같은 이름의 파일 등). 다음 후보로 간다. + AppLog.Warn(Tag, $"기록 폴더에 쓸 수 없다: {directory}", ex); + } + } + writer = null; + path = null; + return false; + } + + /// 기록 폴더 후보. 포터블 배포라 실행 파일 옆이 먼저다(설정 파일도 그 옆에 둔다). + private IEnumerable CandidateDirectories() + { + yield return Path.Combine(_portableBase ?? AppContext.BaseDirectory, DirectoryName); + // 알려진 폴더를 찾는 일도 쓰는 스레드에서 한다. + string fallbackBase = _fallbackBase ?? DefaultFallbackBase(); + if (fallbackBase.Length > 0) yield return Path.Combine(fallbackBase, DirectoryName); + } + + private static string DefaultFallbackBase() + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return localAppData.Length == 0 ? "" : Path.Combine(localAppData, "SoundVisualizer"); + } + + /// 새 파일을 만든다. 같은 이름이 있으면 . 쓰는 동안에도 다른 프로그램이 읽을 수 있게 연다. + private static Stream CreateNewFile(string path) => + new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + + private void PublishCurrentFile(Session mine, string path) + { + lock (_lifecycleLock) + { + // 그사이 꺼졌으면 남기지 않는다. 끈 상태에서 파일이 보이면 켜져 있는 것으로 읽힌다. + if (_session == mine) Volatile.Write(ref _currentFile, path); + } + } + + /// + /// 쓰는 쪽이 스스로 그만둘 때(상한, 열기·쓰기 실패) 자기 회차를 닫는다. 이미 다른 회차가 켜져 있으면 건드리지 않는다. + /// 남의 회차를 끄면 켜 둔 사람의 기록이 조용히 멈춘다. + /// + private void CloseSession(Session mine) + { + mine.Deactivate(); + lock (_lifecycleLock) + { + if (_session != mine) return; + Volatile.Write(ref _session, null); + Volatile.Write(ref _currentFile, null); + } + } + + /// 오버레이가 결과를 본 순간의 값. 쓰는 스레드가 나중에 상태를 읽지 않도록 그때 고정한다. + private readonly record struct Row(AiClassificationResult Result, long NowMs, float Level, bool Shown); + + /// 켠 한 회차. 큐와 "아직 받는지" 를 함께 들고 있어 회차끼리 섞이지 않는다. + private sealed class Session(string stamp) + { + private readonly object _gate = new(); + private readonly Queue _queue = new(QueueCapacity); + private bool _active = true; + private bool _hasOffered; + private long _lastOfferedMs; + private int _dropped; + + /// 파일 이름에 쓰는 켠 시각. + public string Stamp { get; } = stamp; + + public int Dropped + { + get + { + lock (_gate) return _dropped; + } + } + + public void Offer(in Row row) + { + lock (_gate) + { + // 끈 뒤에 들어온 줄은 받지 않는다. 받은 줄은 쓰는 스레드가 모두 쓴다. + if (!_active) return; + // HUD 는 추론보다 자주 읽어 같은 결과를 거듭 넘긴다. 큐가 같은 결과로 차서 새 결과를 밀어내지 않게 여기서 거른다. + long timestampMs = row.Result.TimestampMs; + if (_hasOffered && _lastOfferedMs == timestampMs) return; + _hasOffered = true; + _lastOfferedMs = timestampMs; + if (_queue.Count >= QueueCapacity) + { + // 가장 오래된 것을 버리고 새것을 넣는다. 최근 것이 궁금한 도구라 뒤를 살린다. + _queue.Dequeue(); + _dropped++; + } + _queue.Enqueue(row); + Monitor.Pulse(_gate); + } + } + + /// 다음 줄을 기다려 꺼낸다. 꺼졌고 남은 줄도 없으면 false. + public bool TryTake(out Row row) + { + lock (_gate) + { + while (_queue.Count == 0 && _active) Monitor.Wait(_gate); + return _queue.TryDequeue(out row); + } + } + + /// 더 받지 않는다. 기다리던 쓰는 스레드를 깨워 남은 줄을 쓰고 나가게 한다. + public void Deactivate() + { + lock (_gate) + { + _active = false; + Monitor.PulseAll(_gate); + } + } + } + } +} diff --git a/SoundVisualizer.Core/Diagnostics/AiDebugText.cs b/SoundVisualizer.Core/Diagnostics/AiDebugText.cs new file mode 100644 index 0000000..dcd4f60 --- /dev/null +++ b/SoundVisualizer.Core/Diagnostics/AiDebugText.cs @@ -0,0 +1,156 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Diagnostics +{ + /// + /// 개발자 모드 HUD 에 뿌릴 글자들(모바일 AiDebugLines). 화면 배치는 오버레이가 한다. 모바일 HUD 의 첫 줄은 + /// Coarse ● Label Confidence 이고(점은 의 색), 그 아래로 Detail, Verdict, Timing, top-5 가 한 줄씩이다. + /// + /// 점을 칠할 색의 소리 종류(사용자가 그 종류에 고른 색). 결과가 없으면 null. + /// 소리 종류를 대문자로. 결과가 없으면 왜 없는지를 대신 넣는다. + /// 모델이 말한 이름(번역하지 않은 YAMNet 클래스 이름). + /// 확신도 두 자리. + /// 임계값·나이. + /// YAMNet 위험 단서로 올렸는지. + /// 소리 크기·표시 여부·단계별 소요시간. + /// 모델이 가장 높게 본 이름들. 한 줄에 하나씩 순위·이름·확률. + public sealed record AiDebugLines( + SoundType? ColorType, + string Coarse, + string Label, + string Confidence, + string Detail, + string Verdict, + string Timing, + IReadOnlyList Top5) + { + // 모바일 data class 처럼 top-5 도 내용으로 비교한다. 오버레이는 지난번과 같으면 글자를 다시 넣지 않는다. + public bool Equals(AiDebugLines? other) => + other is not null && + ColorType == other.ColorType && + Coarse == other.Coarse && + Label == other.Label && + Confidence == other.Confidence && + Detail == other.Detail && + Verdict == other.Verdict && + Timing == other.Timing && + Top5.SequenceEqual(other.Top5); + + public override int GetHashCode() => + HashCode.Combine(ColorType, Coarse, Label, Confidence, Detail, Verdict, Timing, Top5.Count); + } + + /// + /// AI 분류 결과를 개발자 모드 HUD 의 글자로 바꾼다(모바일 AiDebugText.kt). + /// + /// 이 도구의 목적은 분류가 맞는지 사람이 눈으로 채점하는 것이라, 모델이 실제로 말한 것을 손대지 않고 보여준다. + /// 라벨을 번역하지 않는 이유도 같다. 확신도가 낮을 때도 숨기지 않는다. 무엇을 왜 보여주는지는 모바일 docs/ARCHITECTURE.md 4장에 있다. + /// 화면과 상관없는 순수 함수라 어느 스레드에서 불러도 된다. 부를 때마다 문자열을 여럿 만들므로 렌더 프레임마다가 아니라 HUD 를 고칠 때 + /// (모바일은 100ms 마다. 추론 주기 250ms 보다 촘촘해야 짧은 위협음 판정 하나를 건너뛰지 않는다) 부른다. + /// + public static class AiDebugText + { + /// 값이 없거나 뜻이 없을 때. 숫자 자리에 NaN 이 그대로 뜨는 것을 막는다. + public const string None = "-"; + + /// 모델을 못 불러와 분류가 아예 돌지 않는 상태. + public const string CoarseAiOff = "AI OFF"; + + /// 분류는 돌지만 첫 결과가 아직 없는 상태. 캡처 직후와 장치를 바꿔 AI 를 다시 만든 직후가 여기다. + public const string CoarseWaiting = "WAIT"; + + /// + /// top-5 이름 칸의 폭(글자 수). 고정폭 글꼴에서 순위·확률까지 한 줄이 폰 폭 안에 들어간다. + /// 더 긴 이름("Vehicle horn, car horn, honking" 등)은 줄여서 끝에 를 붙인다. + /// + public const int Top5NameWidth = 26; + + public const string Ellipsis = "…"; + + /// + /// HUD 한 줄에 들어가는 글자 수. 이보다 길면 폰에서 줄이 두 줄로 꺾인다(#165). 폭 411dp 폰에서 고정폭 11sp 글자(6.6dp)가 + /// 383dp 에 58자다. PC 화면은 훨씬 넓지만 두 판이 같은 줄을 만들도록 같은 한도를 지킨다(나이를 짧게 쓰는 규칙이 여기서 나온다). + /// + public const int HudMaxColumns = 58; + + /// 마지막 결과. 아직 없거나 AI 가 돌지 않으면 null. + /// 지금 벽시계 시각(유닉스 밀리초, 와 같은 시계). + /// 지금 소리 크기(0..1). 모바일은 가장 최근 버퍼의 좌우 중 큰 피크를 쓴다. + /// 그 종류의 표시가 켜져 있는지. 결과가 없으면 false. + /// AI 를 만들지 못해 분류가 아예 돌지 않으면 false. 만드는 중에는 true 다. + public static AiDebugLines Format(AiClassificationResult? result, long nowMs, float level, bool shown, bool aiAvailable) + { + string levelText = "lvl " + TwoDecimals(level); + + if (result is null) + { + // 분류기가 없거나 첫 추론이 끝나지 않았다. 낡은 줄을 남겨 두면 그것을 지금 결과로 착각하므로, + // 값이 없다는 것을 그대로 보여준다. + return new AiDebugLines( + ColorType: null, + Coarse: aiAvailable ? CoarseWaiting : CoarseAiOff, + Label: aiAvailable ? "no result yet" : "model load failed", + Confidence: None, + Detail: None, + Verdict: None, + Timing: levelText, + Top5: []); + } + + var top5 = new string[Math.Min(result.Top5.Count, 5)]; + for (int i = 0; i < top5.Length; i++) + { + var hit = result.Top5[i]; + top5[i] = string.Create(CultureInfo.InvariantCulture, $"{i + 1} {FitName(hit.Name)} {TwoDecimals(hit.Probability)}"); + } + + return new AiDebugLines( + ColorType: result.Coarse, + Coarse: result.Coarse.ToLabel().ToUpperInvariant(), + // 확정된 이름이 아직 없으면 빈 문자열이 온다. 임계값을 한 번도 넘지 못한 상태다. + Label: result.Display.Length == 0 ? "(none)" : result.Display, + Confidence: TwoDecimals(result.Confidence), + Detail: $"thr {YesNo(result.MeetsThreshold)} age {AgeText(result.TimestampMs, nowMs)}", + // 투표와 따로, YAMNet 의 강한 위험 단서로 위협음을 지켰는지. + Verdict: $"cue {YesNo(result.DangerCuePromoted)}", + Timing: $"{levelText} shown {YesNo(shown)} {TimingOf(result)}", + Top5: top5); + } + + /// 고정폭 칸에 맞춘 이름. 짧으면 공백으로 채워 확률이 세로 한 열에 선다. + private static string FitName(string name) => + name.Length > Top5NameWidth + ? string.Concat(name.AsSpan(0, Top5NameWidth - Ellipsis.Length), Ellipsis) + : name.PadRight(Top5NameWidth); + + private static string TimingOf(AiClassificationResult result) => + $"{WholeMillis(result.TotalMs)}ms ({WholeMillis(result.PreprocessMs)}/{WholeMillis(result.YamnetMs)})"; + + /// + /// 결과가 나온 뒤 흐른 시간. 무음이면 추론을 건너뛰고 마지막 결과를 그대로 들고 있으므로, 이 값이 계속 자라는 것이 + /// "지금 추론이 돌지 않는다" 는 신호다. 벽시계라 시계가 뒤로 조정되면 음수가 될 수 있다. 그때는 0 으로 본다. + /// + /// 오래될수록 짧게 쓴다. 100초가 넘으면 소수점은 뜻이 없고, 그대로 두면 123.4s 처럼 길어져 detail 줄이 + /// 를 넘는다(#165). 값 자리는 늘 5글자 이하다. + /// + private static string AgeText(long timestampMs, long nowMs) + { + long elapsed = Math.Max(nowMs - timestampMs, 0L); + if (elapsed < 99_950L) return JavaNumberFormat.Fixed(elapsed / 1000.0, 1) + "s"; // 0.0s ~ 99.9s + if (elapsed < 999_500L) return string.Create(CultureInfo.InvariantCulture, $"{(elapsed + 500L) / 1000L}s"); // 100s ~ 999s + return string.Create(CultureInfo.InvariantCulture, $"{(elapsed + 30_000L) / 60_000L}m"); // 17m ~ + } + + private static string TwoDecimals(float value) => + float.IsNaN(value) ? None : JavaNumberFormat.Fixed(value, 2); + + private static string WholeMillis(double value) => + double.IsNaN(value) ? None : JavaNumberFormat.Fixed(value, 0); + + private static string YesNo(bool value) => value ? "Y" : "N"; + } +} diff --git a/SoundVisualizer.Core/Diagnostics/JavaNumberFormat.cs b/SoundVisualizer.Core/Diagnostics/JavaNumberFormat.cs new file mode 100644 index 0000000..4410afd --- /dev/null +++ b/SoundVisualizer.Core/Diagnostics/JavaNumberFormat.cs @@ -0,0 +1,40 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; + +namespace SoundVisualizer.Diagnostics +{ + /// + /// 소수를 모바일 판(Kotlin String.format(Locale.US, "%.Nf", value))과 같은 글자로 적는다. + /// + /// .NET 의 "F" 서식은 이진값 그대로를 반올림하고 딱 절반이면 짝수 쪽으로 보낸다(0.15 → "0.1", 0.25 → "0.2", 12.5 → "12"). + /// Java 는 값을 가장 짧은 십진수(Double.toString 의 자릿수)로 적은 뒤 5 에서 올린다(0.15 → "0.2", 0.25 → "0.3", 12.5 → "13"). + /// HUD 의 나이는 밀리초를 1000 으로 나눈 값이라 이 경계에 자주 걸린다. 그대로 두면 같은 결과를 두 판이 다르게 적어, + /// 두 기기의 기록을 나란히 놓고 볼 때 헷갈린다. 로캘은 늘 고정이다(아랍어 로캘에서는 숫자가 아랍 숫자로 나간다). + /// + internal static class JavaNumberFormat + { + /// + /// 소수점 아래 자리로 적는다. float 는 double 로 넓혀서 넘긴다. Java 의 Formatter 도 + /// float 를 double 로 바꾼 뒤 적으므로, 0.145f 는 0.14499999582767487 로 보고 "0.14" 가 된다. + /// + public static string Fixed(double value, int decimals) + { + // NaN·Infinity·-Infinity 는 Java 와 글자가 같다. 값이 없다는 표시("-" 등)는 부르는 쪽이 정한다. + if (!double.IsFinite(value)) return value.ToString(CultureInfo.InvariantCulture); + + string format = "F" + decimals.ToString(CultureInfo.InvariantCulture); + // Java 는 크기만 적고 부호를 앞에 붙인다. 그래서 -0.0 과 -0.001 이 "-0.00" 이 된다. + string sign = double.IsNegative(value) ? "-" : ""; + double magnitude = Math.Abs(value); + // 가장 짧은 왕복 표현은 17자리 이하라 decimal 에 그대로 담긴다. decimal 범위(약 7.9e28)를 넘는 값만 + // .NET 서식으로 적는다. 여기서 적는 확률·소리 크기·밀리초는 그 근처에도 가지 않는다. + string shortest = magnitude.ToString("R", CultureInfo.InvariantCulture); + string text = decimal.TryParse(shortest, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal exact) + ? Math.Round(exact, decimals, MidpointRounding.AwayFromZero).ToString(format, CultureInfo.InvariantCulture) + : magnitude.ToString(format, CultureInfo.InvariantCulture); + return sign + text; + } + } +} diff --git a/SoundVisualizer.Core/Help/AppVersionText.cs b/SoundVisualizer.Core/Help/AppVersionText.cs new file mode 100644 index 0000000..e07d455 --- /dev/null +++ b/SoundVisualizer.Core/Help/AppVersionText.cs @@ -0,0 +1,35 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Help +{ + /// + /// 실행 파일의 버전(AssemblyInformationalVersion)을 화면과 제보에 맞게 적는다. + /// + /// .NET SDK 는 이 값 뒤에 빌드한 커밋을 붙인다("1.0.0+a8d9c377c4a4..."). 앱 정보에는 사람이 읽는 앞부분만 보이고, + /// 제보에는 커밋을 짧게(7자) 남긴다. 같은 버전 번호로 여러 번 빌드해도 어느 빌드에서 난 문제인지 가릴 수 있다. + /// + public static class AppVersionText + { + private const int ShortCommitLength = 7; + + /// 앱 정보에 보일 버전. 빌드 정보(+ 뒤)를 뗀다. 값이 없으면 빈 문자열. + public static string Display(string? informationalVersion) + { + string value = (informationalVersion ?? "").Trim(); + int plus = value.IndexOf('+', StringComparison.Ordinal); + return plus < 0 ? value : value[..plus]; + } + + /// 제보에 넣을 버전. 빌드 정보가 있으면 7자로 줄여 붙인다("1.0.0+a8d9c37"). + public static string ForReport(string? informationalVersion) + { + string value = (informationalVersion ?? "").Trim(); + int plus = value.IndexOf('+', StringComparison.Ordinal); + if (plus < 0) return value; + string build = value[(plus + 1)..]; + if (build.Length == 0) return value[..plus]; + return value[..plus] + "+" + (build.Length > ShortCommitLength ? build[..ShortCommitLength] : build); + } + } +} diff --git a/SoundVisualizer.Core/Help/LicenseNotices.cs b/SoundVisualizer.Core/Help/LicenseNotices.cs new file mode 100644 index 0000000..6d819e0 --- /dev/null +++ b/SoundVisualizer.Core/Help/LicenseNotices.cs @@ -0,0 +1,99 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Reflection; +using System.Text; + +namespace SoundVisualizer.Help +{ + /// + /// 도움말의 "오픈소스 라이선스" 창에 보이는 고지. 안드로이드 LicenseAssets.kt 를 옮겼다. + /// + /// 저장소의 고지 파일을 복사하지 않고 제자리에서 Core 에 넣는다(csproj 의 EmbeddedResource). 안드로이드는 assets 에 복사본을 + /// 두고 원본과 같은지 테스트로 지켰는데, 여기서는 복사본이 없어 원본을 고치면 다음 빌드에 그대로 들어간다. + /// + public static class LicenseNotices + { + /// 창에 보일 고지 하나. 는 저장소 뿌리에서의 경로이고 창에서 고지의 머리가 된다. + public sealed record Document(string Path, string ResourceName); + + /// + /// 창에 보이는 순서. 무엇을 넣었는지(라이브러리·모델·글꼴 목록) → 앱에 넣은 글꼴의 라이선스(OFL) → 분류 탭 소리 이름의 고지 → + /// 이 앱의 라이선스(AGPL). csproj 의 LogicalName 과 맞춘다. + /// + public static IReadOnlyList Documents { get; } = new[] + { + new Document("THIRD_PARTY.md", "SoundVisualizer.Licenses.THIRD_PARTY.md"), + new Document("SoundVisualizer/Fonts/Pretendard-LICENSE.txt", "SoundVisualizer.Licenses.Pretendard-LICENSE.txt"), + new Document("SoundVisualizer.Core/Sounds/Data/NOTICE.txt", "SoundVisualizer.Licenses.NOTICE.txt"), + new Document("LICENSE", "SoundVisualizer.Licenses.LICENSE"), + }; + + /// 이보다 짧은 '=' '-' 줄은 구분선이 아니라 내용으로 본다. + private const int MinRulerLength = 10; + + private static readonly Assembly Owner = typeof(LicenseNotices).Assembly; + + /// 고지 하나의 전문. 넣어 둔 리소스가 없으면 (빌드 설정이 틀렸다). + public static string Read(Document document) + { + using var stream = Owner.GetManifestResourceStream(document.ResourceName) + ?? throw new InvalidOperationException("고지 리소스가 없습니다: " + document.ResourceName); + using var reader = new StreamReader(stream, Encoding.UTF8); // BOM 이 있어도 떼고 읽는다 + return reader.ReadToEnd(); + } + + /// + /// 창에 보일 전문. 고지마다 저장소 경로를 머리로 달고 빈 줄 둘로 띄운다. 줄 끝은 \n 이다. + /// Pretendard 의 OFL 처럼 첫 줄만 봐서는 무엇인지 모르는 고지가 있어 머리를 단다. + /// + public static string ReadAll() + { + var text = new StringBuilder(); + foreach (var document in Documents) + { + if (text.Length > 0) text.Append("\n\n\n"); + text.Append("==== ").Append(document.Path).Append(" ====\n\n"); + text.Append(Read(document).Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd()); + } + return text.Append('\n').ToString(); + } + + /// + /// 고지를 창에 보일 글로 바꾼다. 줄 끝을 \n 으로 맞추고 구분선을 창 폭에 맞춘다. + /// 저장소에는 LF 로 들어 있지만 Windows 에서 받으면(core.autocrlf) 작업 폴더가 CRLF 가 되고, 그 폴더에서 빌드하면 + /// 리소스에도 \r 이 들어간다. + /// + public static string DisplayText(string raw, int columns) => + FitRulers(raw.Replace("\r\n", "\n", StringComparison.Ordinal), columns); + + /// + /// 줄 전체가 한 글자('=' 또는 '-')로 된 구분선을 글자까지 줄인다. 다른 줄은 손대지 않는다. + /// 줄 끝은 \n 이어야 한다. + /// + /// 고지는 80칸에 맞춰 적혀 있어, 창이 그보다 좁으면 구분선 하나가 '=' 로 가득 찬 두세 줄로 꺾인다(안드로이드 #167). + /// 창이 더 넓으면 원래 길이 그대로 둔다. 파일 자체는 고치지 않는다. 저장소에서 읽는 사람에게는 80칸이 맞다. + /// 안드로이드는 '=' 만 보았지만 PC 판 고지에는 '-' 구분선(NOTICE 80칸, OFL 59칸)도 있어 함께 맞춘다. + /// + public static string FitRulers(string text, int columns) + { + if (columns < 1) return text; + var lines = text.Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + if (IsRuler(lines[i]) && lines[i].Length > columns) lines[i] = new string(lines[i][0], columns); + } + return string.Join("\n", lines); + } + + private static bool IsRuler(string line) + { + if (line.Length < MinRulerLength || (line[0] != '=' && line[0] != '-')) return false; + foreach (char c in line) + { + if (c != line[0]) return false; + } + return true; + } + } +} diff --git a/SoundVisualizer.Core/Help/ReportLink.cs b/SoundVisualizer.Core/Help/ReportLink.cs new file mode 100644 index 0000000..c8325b4 --- /dev/null +++ b/SoundVisualizer.Core/Help/ReportLink.cs @@ -0,0 +1,55 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Help +{ + /// + /// 제보용 GitHub 이슈 주소, 메일(mailto) 주소, 환경 정보 문구를 만든다. 안드로이드 ReportLink.kt 를 옮겼다. + /// + /// 화면이나 운영체제 API 없이 문자열만 다루므로 단위 테스트로 고정한다. 넣는 값은 의 인자뿐이라 + /// 소리, 장치 이름, 사용자 경로가 섞일 수 없다. 환경 정보의 이름표는 번역하지 않는다. 제보를 읽는 쪽이 어느 언어로 온 제보든 + /// 같은 꼴로 읽을 수 있어야 한다. + /// + public static class ReportLink + { + /// 새 이슈 화면. PC 판 저장소다(안드로이드 판과 다르다). + public const string IssuesUrl = "https://github.com/amophi/SoundVisualizer/issues/new"; + + /// 메일 제보를 받는 곳. 안드로이드 판과 같은 주소다. + public const string ReportEmail = "nawwhod@gmail.com"; + + /// 제보 본문 끝에 붙는 네 줄. 줄은 \n 으로 나눈다. + public static string EnvironmentBlock(string appVersion, string windows, string dotnet, string language) => + string.Join("\n", + "App version: " + appVersion, + "Windows: " + windows, + ".NET: " + dotnet, + "App language: " + language); + + /// + /// 제목과 본문을 미리 채운 새 이슈 주소. GitHub 은 title·body 쿼리로 새 이슈 화면을 채워 준다. + /// 값은 모두 인코딩하므로 사용자가 쓸 자리에 줄바꿈이나 한글이 있어도 주소가 깨지지 않는다. + /// + public static string IssueUrl(string title, string bodyPrompt, string environment) => + IssuesUrl + "?title=" + Encode(title) + "&body=" + Encode(Body(bodyPrompt, environment)); + + /// 사용자가 쓸 자리와 환경 정보를 합친 제보 본문. 이슈와 메일이 같은 본문을 쓴다. + public static string Body(string bodyPrompt, string environment) => + bodyPrompt.Length == 0 ? environment : bodyPrompt + "\n\n" + environment; + + /// + /// 제목과 본문을 채운 mailto 주소. 메일 본문의 줄바꿈은 RFC 6068 대로 CRLF(%0D%0A)로 적는다. + /// 안드로이드는 Gmail 이 받는 \n 그대로 넣었지만, Windows 의 메일 앱은 여러 가지라 규격을 따른다. + /// + public static string MailtoUri(string address, string subject, string bodyPrompt, string environment) => + "mailto:" + address + "?subject=" + Encode(subject) + "&body=" + Encode(MailBody(bodyPrompt, environment)); + + /// 메일에 들어가는 본문. 와 같고 줄바꿈만 CRLF 다. + public static string MailBody(string bodyPrompt, string environment) => + Body(bodyPrompt, environment).Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\n", "\r\n", StringComparison.Ordinal); + + // RFC 3986 의 비예약 문자만 남기고 모두 %XX 로 적는다. 공백은 %20 이다. 안드로이드 URLEncoder 는 공백을 + 로 적었는데, + // mailto 쿼리에서 + 는 공백이 아니라 글자 그대로라 그쪽은 따로 %20 으로 바꿨다. 여기서는 처음부터 %20 이라 둘이 같다. + private static string Encode(string value) => Uri.EscapeDataString(value); + } +} diff --git a/SoundVisualizer.Core/KeyedList.cs b/SoundVisualizer.Core/KeyedList.cs new file mode 100644 index 0000000..ed2eeab --- /dev/null +++ b/SoundVisualizer.Core/KeyedList.cs @@ -0,0 +1,85 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer +{ + /// + /// 화면 목록의 줄(보기 객체)을 새 항목 목록에 맞춘다. 키가 같은 줄은 버리지 않고 고쳐 쓰고, 없어진 줄만 지우고 새 줄만 넣는다. + /// 모바일 LazyColumn 의 키와 같은 일을 WPF 의 ObservableCollection 에 한다. + /// + /// 통째로 바꿔 끼우면 목록이 줄을 모두 새로 만들어, 종류를 고른 카드가 키보드 초점을 잃고 고급 모드 스위치를 누른 줄도 초점을 + /// 잃는다. 남는 줄끼리의 앞뒤 순서가 그대로이면(소리 탭은 늘 그렇다) 지우기와 넣기만 하고 줄을 옮기지 않는다. + /// + public static class KeyedList + { + /// + /// 를 의 차례로 맞춘다. 끝나면 rows[i] 의 키는 items[i] 의 키와 같다. + /// + /// 화면 목록. 키가 겹치지 않아야 한다. + /// 새 항목. 키가 겹치지 않아야 한다. + /// 항목의 키. + /// 줄의 키. + /// 새 키의 줄을 만든다. 만든 줄에 항목을 넣는 것까지 한다. + /// 남는 줄에 새 항목을 넣는다. 바뀐 것이 없으면 아무것도 알리지 않게 하는 것은 줄의 몫이다. + public static void Sync( + IList rows, + IReadOnlyList items, + Func itemKey, + Func rowKey, + Func create, + Action update) + { + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(itemKey); + ArgumentNullException.ThrowIfNull(rowKey); + ArgumentNullException.ThrowIfNull(create); + ArgumentNullException.ThrowIfNull(update); + + var wanted = new HashSet(StringComparer.Ordinal); + foreach (TItem item in items) + { + if (!wanted.Add(itemKey(item))) throw new ArgumentException("키가 겹칩니다: " + itemKey(item), nameof(items)); + } + + // 1. 없어진 줄을 뒤에서부터 지운다. + for (int i = rows.Count - 1; i >= 0; i--) + { + if (!wanted.Contains(rowKey(rows[i]))) rows.RemoveAt(i); + } + + var remaining = new Dictionary(StringComparer.Ordinal); + foreach (TRow row in rows) remaining.Add(rowKey(row), row); + + // 2. 앞에서부터 맞춘다. 제자리의 줄은 고쳐 쓰고, 새 키는 그 자리에 넣는다. 남는 줄의 순서가 바뀐 때만(소리 탭에는 없다) 옮긴다. + for (int i = 0; i < items.Count; i++) + { + TItem item = items[i]; + string key = itemKey(item); + if (i < rows.Count && string.Equals(rowKey(rows[i]), key, StringComparison.Ordinal)) + { + update(rows[i], item); + } + else if (remaining.TryGetValue(key, out TRow? row)) + { + rows.RemoveAt(IndexOf(rows, key, rowKey, i + 1)); + rows.Insert(i, row); + update(row, item); + } + else + { + rows.Insert(i, create(item)); + } + } + } + + private static int IndexOf(IList rows, string key, Func rowKey, int start) + { + for (int i = start; i < rows.Count; i++) + { + if (string.Equals(rowKey(rows[i]), key, StringComparison.Ordinal)) return i; + } + throw new InvalidOperationException("줄을 찾지 못했습니다: " + key); + } + } +} diff --git a/SoundVisualizer.Core/Localization/Localizer.cs b/SoundVisualizer.Core/Localization/Localizer.cs new file mode 100644 index 0000000..27ad2bc --- /dev/null +++ b/SoundVisualizer.Core/Localization/Localizer.cs @@ -0,0 +1,107 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Concurrent; +using System.Globalization; + +namespace SoundVisualizer.Localization +{ + /// + /// 화면 문자열을 지금 언어의 표에서 찾는다. 그 언어 표에 없는 키는 영어 표에서 찾고, 영어에도 없으면 키를 그대로 돌려준다 + /// (빈칸보다 키가 보여야 빠진 문자열을 바로 알아챈다). + /// + /// 언어를 바꿀 때는 (코드, 그 언어 표, 영어 표) 묶음을 새로 만들어 참조 하나로 바꿔 끼운다. 그래서 다른 스레드(캡처 스레드의 + /// 오류 문구 등)가 읽는 도중에 언어가 바뀌어도 두 언어가 섞인 결과를 보지 않는다. + /// + public sealed class Localizer + { + private sealed record Snapshot(string Code, StringTable Current, StringTable Fallback); + + private readonly Func _tables; + private readonly ConcurrentDictionary _reportedMissing = new(StringComparer.Ordinal); + private Snapshot _snapshot; + + /// Core 에 넣어 둔 표를 쓴다. 영어로 시작한다. + public Localizer() : this(StringTables.Get) + { + } + + /// 표를 주는 함수를 바꿔 끼운다(테스트용). + internal Localizer(Func tables) + { + _tables = tables; + _snapshot = Build(UiLanguages.DefaultCode); + } + + /// 지금 화면 언어의 코드. 지원하지 않는 코드를 받았으면 영어다. + public string Code => Volatile.Read(ref _snapshot).Code; + + public bool IsRightToLeft => UiLanguages.IsRightToLeft(Code); + + /// 화면 언어를 바꾼다. "system" 같은 설정값이 아니라 로 푼 코드를 받는다. + public void SetLanguage(string code) => Volatile.Write(ref _snapshot, Build(code)); + + public string Get(string key) => Lookup(Volatile.Read(ref _snapshot), key); + + /// + /// 문자열의 {0}, {1} 자리에 값을 넣는다. 숫자는 InvariantCulture 로 적는다(FPS, % 같은 기술 표기가 Windows 지역 설정을 따라 + /// 바뀌지 않게). 번역문의 자리표시자가 깨졌으면 영어 문장으로 채운다. 테스트가 막는 경우지만 화면이 멈추지는 않게 한다. + /// + public string Format(string key, params object?[] args) + { + var snapshot = Volatile.Read(ref _snapshot); + string template = Lookup(snapshot, key); + try + { + return string.Format(CultureInfo.InvariantCulture, template, args); + } + catch (FormatException ex) + { + AppLog.Warn("l10n", $"{snapshot.Code} 문자열 '{key}' 의 자리표시자가 맞지 않아 영어로 보여 줍니다.", ex); + if (snapshot.Fallback.TryGet(key, out var english)) + { + try + { + return string.Format(CultureInfo.InvariantCulture, english, args); + } + catch (FormatException) + { + } + } + return template; + } + } + + private string Lookup(Snapshot snapshot, string key) + { + if (snapshot.Current.TryGet(key, out var text)) return text; + if (snapshot.Fallback.TryGet(key, out text)) return text; + + // 화면 갱신마다 부르는 곳이 있어, 같은 키는 한 번만 남긴다. + if (_reportedMissing.TryAdd(key, 0)) AppLog.Warn("l10n", $"문자열 키 '{key}' 가 영어 표에도 없습니다."); + return key; + } + + private Snapshot Build(string code) + { + string resolved = UiLanguages.Find(code)?.Code ?? UiLanguages.DefaultCode; + var fallback = Load(UiLanguages.DefaultCode) ?? StringTable.Empty(UiLanguages.DefaultCode); + var current = resolved == UiLanguages.DefaultCode ? fallback : Load(resolved) ?? StringTable.Empty(resolved); + return new Snapshot(resolved, current, fallback); + } + + private StringTable? Load(string code) + { + try + { + return _tables(code); + } + catch (FormatException ex) + { + // 깨진 번역 파일 하나 때문에 앱이 뜨지 않으면 안 된다. 그 언어는 영어로 보인다. + AppLog.Warn("l10n", $"{code} 문자열 표를 읽지 못해 영어로 보여 줍니다.", ex); + return null; + } + } + } +} diff --git a/SoundVisualizer.Core/Localization/StringTable.cs b/SoundVisualizer.Core/Localization/StringTable.cs new file mode 100644 index 0000000..d3c8ae5 --- /dev/null +++ b/SoundVisualizer.Core/Localization/StringTable.cs @@ -0,0 +1,67 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace SoundVisualizer.Localization +{ + /// + /// 한 언어의 화면 문자열 표. 만든 뒤에는 바뀌지 않으므로 여러 스레드가 잠금 없이 읽는다. + /// + /// 파일은 평평한 JSON 객체 하나다({ "launcher.tab.home": "홈" }). 번역 도구가 그대로 열 수 있게 중첩과 주석은 쓰지 않고, + /// 키는 화면별로 점을 찍어 묶는다. 값이 문자열이 아니거나 같은 키가 두 번 나오면 읽지 않는다(뒤엣것이 조용히 이기면 번역이 사라진다). + /// + public sealed class StringTable + { + private readonly FrozenDictionary _entries; + + private StringTable(string code, FrozenDictionary entries) + { + Code = code; + _entries = entries; + } + + /// 표의 언어 코드(). + public string Code { get; } + + /// 키와 문자열. 읽기 전용이다. + public IReadOnlyDictionary Entries => _entries; + + public bool TryGet(string key, [MaybeNullWhen(false)] out string value) => _entries.TryGetValue(key, out value); + + /// 아직 번역 파일이 없는 언어의 표. 모든 키가 영어로 넘어간다. + public static StringTable Empty(string code) => new(code, FrozenDictionary.Empty); + + /// JSON 을 읽는다. 형식이 맞지 않으면 을 던진다. + public static StringTable Parse(string code, string json) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + throw new FormatException($"{code}: 문자열 표가 올바른 JSON 이 아닙니다({ex.Message}).", ex); + } + + using (document) + { + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new FormatException($"{code}: 문자열 표는 JSON 객체 하나여야 합니다."); + + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) + throw new FormatException($"{code}: '{property.Name}' 의 값이 문자열이 아닙니다."); + if (!entries.TryAdd(property.Name, property.Value.GetString()!)) + throw new FormatException($"{code}: '{property.Name}' 키가 두 번 나옵니다."); + } + return new StringTable(code, entries.ToFrozenDictionary(StringComparer.Ordinal)); + } + } + } +} diff --git a/SoundVisualizer.Core/Localization/StringTables.cs b/SoundVisualizer.Core/Localization/StringTables.cs new file mode 100644 index 0000000..a677438 --- /dev/null +++ b/SoundVisualizer.Core/Localization/StringTables.cs @@ -0,0 +1,42 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Concurrent; +using System.Reflection; + +namespace SoundVisualizer.Localization +{ + /// + /// Core 에 넣어 둔 언어별 문자열 표(Localization/Strings/{코드}.json)를 읽는다. 언어마다 처음 쓸 때 한 번만 읽는다. + /// 번역 파일이 아직 없는 언어는 null 이고, 그 언어의 화면은 키마다 영어로 나온다. + /// + public static class StringTables + { + // csproj 의 EmbeddedResource LogicalName 과 맞춘다. + private const string ResourcePrefix = "SoundVisualizer.Localization.Strings."; + private const string ResourceSuffix = ".json"; + + private static readonly Assembly Owner = typeof(StringTables).Assembly; + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + /// 표 파일이 들어 있는 언어 코드(파일 이름 그대로). + public static IReadOnlyList AvailableCodes { get; } = Owner.GetManifestResourceNames() + .Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal) && name.EndsWith(ResourceSuffix, StringComparison.Ordinal)) + .Select(name => name[ResourcePrefix.Length..^ResourceSuffix.Length]) + .OrderBy(code => code, StringComparer.Ordinal) + .ToArray(); + + /// 표 파일의 내용. 파일이 없으면 null. + public static string? ReadJson(string code) + { + using var stream = Owner.GetManifestResourceStream(ResourcePrefix + code + ResourceSuffix); + if (stream == null) return null; + using var reader = new StreamReader(stream); // BOM 이 있어도 떼고 읽는다 + return reader.ReadToEnd(); + } + + /// 언어의 표. 파일이 없으면 null, 파일이 깨졌으면 . + public static StringTable? Get(string code) => + Cache.GetOrAdd(code, c => ReadJson(c) is { } json ? StringTable.Parse(c, json) : null); + } +} diff --git a/SoundVisualizer.Core/Localization/Strings/ar.json b/SoundVisualizer.Core/Localization/Strings/ar.json new file mode 100644 index 0000000..7ed2d1e --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/ar.json @@ -0,0 +1,294 @@ +{ + "common.language": "اللغة", + "common.reset_defaults": "استعادة الإعدادات التلقائية", + "common.minimize": "تصغير", + "common.close": "إغلاق", + "language.system": "الإعداد التلقائي للنظام", + + "launcher.window_title": "نافذة تشغيل SoundVisualizer", + "launcher.tab.home": "الرئيسية", + "launcher.tab.settings": "الإعدادات", + "launcher.tab.sounds": "الأصوات", + "launcher.tab.help": "المساعدة", + "launcher.tab.more_info": "معرفة المزيد", + "launcher.home.desc": "يرسم على شاشتك الأصوات التي لا تراها.\nمن الألعاب إلى الأفلام، عِش تجربة جديدة كليًا.", + "launcher.home.start": "تشغيل", + "launcher.home.stop": "إيقاف", + "launcher.home.tutorial": "عرض البرنامج التعليمي", + "launcher.status.running": "الحالة: قيد التشغيل", + "launcher.status.idle": "الحالة: جاهز للتشغيل", + + "settings.section.mode": "إعدادات الوضع", + "settings.visual_mode.title": "وضع العرض", + "settings.visual_mode.desc": "اختر الشكل الأساسي للرسومات التي تظهر على الشاشة.", + "settings.mode.wave": "الموجة", + "settings.mode.pad": "الأشرطة", + "settings.mode.circle": "الدائرة", + "settings.mode.outline": "الإطار", + "settings.sound_mode.title": "وضع الصوت", + "settings.sound_mode.desc": "اختر قنوات الصوت التي تناسب مكبرات الصوت لديك.\nلمكبرات الصوت ثنائية القناة اختر «قناتان»، ولأنظمة الصوت المحيطي اختر «5.1 قناة» أو «7.1 قناة».", + "settings.channels.2": "قناتان", + "settings.channels.5_1": "5.1 قناة", + "settings.channels.7_1": "7.1 قناة", + "settings.max_fps.title": "أقصى معدل للإطارات", + "settings.max_fps.desc": "يضبط أعلى معدل إطارات تُرسم به الرسومات.", + "settings.section.mode_detail": "إعدادات كل وضع", + "settings.mode_card.wave": "وضع الموجة", + "settings.mode_card.pad": "وضع الأشرطة", + "settings.mode_card.circle": "وضع الدائرة", + "settings.mode_card.outline": "وضع الإطار", + "settings.size_lock.title": "تثبيت الحجم", + "settings.size_lock.desc": "يُبقي الرسومات بحجم ثابت، ويُظهر شدة الصوت من خلال الشفافية بدلًا من الحجم.", + "settings.fixed_size.wave": "حجم الموجة", + "settings.fixed_size.pad": "حجم الأشرطة", + "settings.fixed_size.circle": "حجم الدائرة", + "settings.fixed_size.outline": "سُمك الإطار", + "settings.fixed_size.desc": "يضبط الحجم الثابت الذي تبقى عليه الرسومات بينما تتغير شفافيتها.", + "settings.max_opacity.title": "أقصى عتامة", + "settings.max_opacity.desc": "مدى عتامة الرسومات عند أعلى مستوى للصوت.", + "settings.size.title": "الحجم", + "settings.size.desc.wave": "يضبط ارتفاع الموجة ومدى امتلائها بشكل عام.", + "settings.size.desc.pad": "يضبط مدى سُمك الأشرطة وعرضها عند تفاعلها مع الصوت.", + "settings.size.desc.circle": "يضبط مدى بروز المعادل الدائري إلى الخارج عند تشغيل الصوت.", + "settings.size.desc.outline": "يضبط ارتفاع موجة الإطار ومدى امتلائها بشكل عام.", + "settings.opacity.title": "العتامة", + "settings.opacity.desc.wave": "كلما زادت القيمة بدت الرسومات أوضح، وكلما قلّت بدت أبهت وأكثر شفافية.", + "settings.opacity.desc.pad": "كلما زادت القيمة بدت الرسومات أوضح، وكلما قلّت بدت أبهت وأكثر شفافية.", + "settings.opacity.desc.circle": "كلما زادت القيمة بدت الرسومات أوضح، وكلما قلّت بدت أبهت وأكثر شفافية.", + "settings.opacity.desc.outline": "كلما زادت القيمة بدت الرسومات أوضح، وكلما قلّت بدت أبهت وأكثر شفافية.", + "settings.speed.title": "السرعة", + "settings.speed.desc.wave": "يضبط سرعة تدفق الموجة وتموّجها.", + "settings.speed.desc.pad": "يضبط سرعة توجّه الأشرطة نحو الجهة التي يأتي منها الصوت.", + "settings.speed.desc.circle": "يضبط سرعة تموّج المعادل الدائري ودورانه استجابةً للصوت.", + "settings.speed.desc.outline": "يضبط سرعة تدفق موجة الإطار وتموّجها.", + "settings.sensitivity.title": "الحساسية", + "settings.sensitivity.desc.wave": "يضبط مدى حساسية استجابة الموجة واندفاعها حتى مع الأصوات الهادئة.", + "settings.sensitivity.desc.pad": "يضبط الأشرطة لتلتقط الصوت بشكل أفضل وتبرز أكثر.", + "settings.sensitivity.desc.circle": "يضبط مدى دقة تجزّؤ سعة الدائرة عند استجابتها للصوت.", + "settings.sensitivity.desc.outline": "يضبط مدى حساسية استجابة موجة الإطار واندفاعها حتى مع الأصوات الهادئة.", + "settings.glow.title": "التوهج", + "settings.glow.desc.wave": "يضيف توهجًا ناعمًا يشبه ضوء النيون على طول حافة الموجة، ويضبط قوته.\nملاحظة: يستهلك المزيد من موارد GPU.", + "settings.glow.desc.pad": "يضيف حول الأشرطة توهجًا ناعمًا يشبه الهالة.\nملاحظة: يستهلك المزيد من موارد GPU.", + "settings.glow.desc.circle": "يضيف هالة متلألئة على طول حافة المعادل الدائري.\nملاحظة: يستهلك المزيد من موارد GPU.", + "settings.glow.desc.outline": "يضيف توهجًا ناعمًا يشبه ضوء النيون إلى الإطار، ويضبط قوته.\nملاحظة: يستهلك المزيد من موارد GPU.", + "settings.radius.title": "نصف القطر", + "settings.radius.desc": "يضبط نصف قطر الحلقة التي تتوسط الشاشة في وضع الدائرة.", + "settings.section.sound_types": "أنواع الأصوات (AI)", + "settings.show_ambient": "إظهار الأصوات المحيطة", + "settings.show_speech": "إظهار الكلام", + "settings.show_danger": "إظهار أصوات الخطر", + "settings.set_color": "تعيين اللون", + "settings.set_color_for": "تعيين اللون: {0}", + "settings.section.hotkeys": "مفاتيح الاختصار", + "settings.hotkey.visual.title": "تبديل وضع العرض", + "settings.hotkey.visual.desc": "مفتاح اختصار يغيّر الشكل فورًا أثناء التشغيل.", + "settings.hotkey.sound.title": "تبديل وضع الصوت", + "settings.hotkey.sound.desc": "مفتاح اختصار يغيّر وضع الصوت فورًا أثناء التشغيل.", + "settings.hotkey.edit.title": "إعدادات التراكب", + "settings.hotkey.edit.desc": "مفتاح اختصار يفتح محرر التراكب أو يغلقه.", + "settings.hotkey.set": "تعيين مفتاح", + "settings.section.advanced": "الإعدادات المتقدّمة", + "settings.developer_mode.title": "وضع المطوّر", + "settings.developer_mode.desc": "يعرض في الزاوية العلوية اليسرى من التراكب ما سمعه الذكاء الاصطناعي (اسم الصوت الذي أبلغ عنه ومدى ثقته والوقت المستغرق)، ومعدل الإطارات (FPS) في الزاوية العلوية اليمنى. تظهر هذه المعلومات بالإنجليزية وليست مخصّصة للاستخدام اليومي.", + "settings.developer_record.title": "تسجيل النتائج في ملف", + "settings.developer_record.desc": "يكتب كل نتيجة للذكاء الاصطناعي (أسماء الأصوات الأعلى تقييمًا ومدى ثقته والأوقات المستغرقة) في ملف CSV جديد داخل مجلد ai-log بجوار SoundVisualizer.exe، لتتمكن لاحقًا من مراجعة ما سمعه. وإذا تعذّرت الكتابة في ذلك المجلد، يُحفظ الملف في %LOCALAPPDATA%\\SoundVisualizer\\ai-log. قد تكبر الملفات كثيرًا، لذا أوقف التسجيل عند الانتهاء.", + "settings.auto_minimize.title": "تصغير نافذة التشغيل عند البدء", + "settings.auto_minimize.desc": "يصغّر نافذة التشغيل هذه تلقائيًا عند بدء التراكب.", + + "sounds.title": "اختيار أنواع الأصوات", + "sounds.desc": "تُجمع الأصوات التي يتعرّف عليها الذكاء الاصطناعي مع الأصوات المشابهة لها في مجموعات، وتُعرض حسب النوع الذي تُصنَّف ضمنه تلقائيًا. إذا غيّرت نوع مجموعة، فستأخذ أصواتها فورًا لون النوع الجديد.", + "sounds.sibling_note": "كثيرًا ما يسمع الذكاء الاصطناعي الصوت الواحد بعدة أسماء معًا. على سبيل المثال، تُسمع «صفارة إنذار» أيضًا على أنها «إنذار»، وهو في مجموعة أخرى. إذا ظل صوت يظهر بنوعه القديم بعد التغيير، فغيّر أيضًا المجموعات أو الأصوات المرتبطة به.", + "sounds.advanced.title": "الوضع المتقدّم", + "sounds.advanced.desc": "يعرض كل صوت ضمن مجموعته، لتتمكن من تغيير الأصوات بشكل فردي.", + "sounds.search.hint": "البحث عن صوت", + "sounds.search.clear": "محو نص البحث", + "sounds.filter.title": "تصفية", + "sounds.filter.all": "الكل", + "sounds.filter.changed": "المعدّلة", + "sounds.type.ambient": "الأصوات المحيطة", + "sounds.type.speech": "الكلام", + "sounds.type.danger": "أصوات الخطر", + "sounds.type.default": "{0} (تلقائي)", + "sounds.type.mixed": "مختلط", + "sounds.changed_count": "الأصوات المعدّلة: {0}", + "sounds.changed_from": "النوع التلقائي: {0}", + "sounds.reset.button": "إعادة ضبط الكل", + "sounds.reset.title": "هل تريد إعادة ضبط كل الأصوات؟", + "sounds.reset.message": "ستعود جميع الأصوات التي غيّرتها إلى نوعها التلقائي.", + "sounds.reset.confirm": "إعادة ضبط", + "sounds.reset.cancel": "إلغاء", + "sounds.empty": "لا توجد أصوات مطابقة.", + "sounds.group.mixed_note": "بعض الأصوات غُيّرت بشكل فردي", + "sounds.group.changed_line": "الآن: {0} · النوع التلقائي: {1}", + "sounds.overwrite.title": "هل تريد تغيير المجموعة كلها؟", + "sounds.overwrite.message": "عدد أصوات مجموعة «{0}» التي ستتحوّل إلى «{2}»: {1}. وستُستبدل أيضًا الأصوات التي غيّرتها بشكل فردي.", + "sounds.overwrite.confirm": "تغيير", + "sounds.overwrite.cancel": "إلغاء", + "sounds.basic_warning.title": "هل تريد التبديل إلى الوضع الأساسي؟", + "sounds.basic_warning.message": "في بعض المجموعات أصوات غيّرتها بشكل فردي. في الوضع الأساسي، إذا اخترت نوعًا لإحدى هذه المجموعات، فستأخذ كل أصواتها هذا النوع، وتُستبدل التغييرات الفردية التي أجريتها في تلك المجموعة. لن يتغيّر شيء قبل أن تختار.", + "sounds.basic_warning.confirm": "التبديل إلى الأساسي", + "sounds.basic_warning.cancel": "البقاء في المتقدّم", + "sounds.cd.sound": "{0}، النوع: {1}", + "sounds.cd.sound_changed": "{0}، النوع: {1}، النوع التلقائي: {2}", + "sounds.cd.group": "مجموعة {0}، النوع: {1}، عدد الأصوات: {2} ({3})", + "sounds.cd.group_changed": "مجموعة {0}، النوع: {1}، النوع التلقائي: {2}، عدد الأصوات: {3} ({4})", + "sounds.cd.group_header": "مجموعة {0}، عدد الأصوات: {1}", + + "hotkey.prompt": "اضغط على مفتاح (Esc للإلغاء)", + "hotkey.none": "لا يوجد", + "hotkey.button_name": "{0}، مفتاح الاختصار: {1}", + + "help.start.title": "بدء الاستخدام", + "help.start.1": "اختر اللغة في أعلى هذه النافذة، وغيّر شكل الرسومات من علامة تبويب «الإعدادات» إن أردت.", + "help.start.2": "في «الرئيسية»، انقر على «{0}».", + "help.start.3": "تُرسم الرسومات على حواف الشاشة فوق النوافذ الأخرى مثل الألعاب ومقاطع الفيديو. وتمرّ نقرات الماوس عبرها، لذا يمكنك مواصلة استخدام النافذة التي تحتها.", + "help.start.4": "لإيقافه، انقر على «{0}» في «الرئيسية»، أو انقر بزر الماوس الأيمن على أيقونة SoundVisualizer في منطقة الإعلامات بشريط المهام واختر «{0}».", + "help.modes.title": "أوضاع العرض", + "help.mode.wave": "تندفع حافة الشاشة بالكامل نحو الداخل بحسب شدة الصوت، ويندفع الجانب الذي يأتي منه الصوت إلى عمق أكبر.", + "help.mode.pad": "تضيء أشرطة في 8 مواضع حول حافة الشاشة، وهو أوضح وضع لإظهار الاتجاه.", + "help.mode.circle": "تنتفخ حلقة في وسط الشاشة باتجاه مصدر الصوت.", + "help.mode.outline": "يرسم خطًا رفيعًا يمثّل حدود الموجة فقط، لذا يغطي مساحة أقل من شاشة اللعبة.", + "help.modes.note": "تُحفظ إعدادات مثل «الحجم» و«العتامة» بشكل منفصل لكل وضع، ويمكنك تغييرها من علامة تبويب «الإعدادات».", + "help.sound.title": "أنواع الأصوات", + "help.sound.kinds": "يصنّف الذكاء الاصطناعي ما تسمعه إلى أصوات محيطة وكلام وأصوات خطر. الألوان التلقائية هي: الأبيض للأصوات المحيطة، والأصفر للكلام، والأحمر لأصوات الخطر. يمكنك تغيير لون كل نوع واختيار إظهاره أو إخفائه من علامة تبويب «الإعدادات».", + "help.sound.groups": "في علامة تبويب «الأصوات» تُجمع الأصوات المتشابهة في مجموعات، وتختار أنت النوع الذي تُحسب عليه كل مجموعة. فإذا غيّرت مجموعة «جرس الباب» مثلًا إلى أصوات الخطر، فحين يتعرّف الذكاء الاصطناعي على جرس الباب أو طرق الباب، يأخذ الصوت لون أصوات الخطر. لكن الصوت الذي تضيفه بنفسك إلى أصوات الخطر يشارك فقط في تصويت الذكاء الاصطناعي على النوع، لذلك لا يعني هذا أن كل رنين لجرس الباب سيظهر على أنه من أصوات الخطر.", + "help.sound.advanced": "لتغيير الأصوات بشكل فردي، فعّل الوضع المتقدّم.", + "help.sound.danger": "عند رصد صوت خطر (مثل طلق ناري)، يتغير اللون فورًا وليس تدريجيًا.", + "help.pc.title": "إعدادات الكمبيوتر واختصارات لوحة المفاتيح", + "help.sound_mode.title": "وضع الصوت", + "help.sound_mode.body": "لتمييز الأصوات من الأمام والخلف أيضًا، إلى جانب اليسار واليمين، اضبط جهاز الإخراج في إعدادات الصوت في Windows على صوت محيطي 5.1 (6 قنوات) أو 7.1 (8 قنوات). أما مع الستيريو العادي (قناتان)، فيميّز التطبيق بين اليسار واليمين، لكنه لا يميّز بين الأمام والخلف. يتوافق «وضع الصوت» في علامة تبويب «الإعدادات» مع جهاز الإخراج عند بدء التشغيل وعند تغيّر الجهاز، ويمكنك أيضًا تغييره بنفسك.", + "help.sound_mode.tip": "💡 إذا لم يكن لديك جهاز إخراج 7.1، فيمكنك تثبيت {0} وضبط جهاز الإخراج على 7.1 قناة لعرض التراكب بصوت محيطي 7.1.", + "help.sound_mode.tip_link": "برنامج التشغيل الافتراضي VB-CABLE", + "help.hotkeys.title": "مفاتيح اختصار مخصّصة تعمل فورًا", + "help.hotkeys.body": "في علامة تبويب «الإعدادات»، انقر على زر مفتاح الاختصار، ثم اضغط على مفتاح واحد أو مجموعة مفاتيح (مثل Ctrl + Shift + A) لتغيير مفتاح الاختصار كما تريد. ويؤدي الضغط على مفتاح الاختصار إلى تبديل الوضع فورًا، حتى عندما يعمل التطبيق في الخلفية.", + "help.hotkeys.defaults": "افتراضيًا، يبدّل F2 وضع الصوت، ويبدّل F3 وضع العرض، ويفتح F4 إعدادات التراكب.", + "help.size_lock.title": "وضع تثبيت الحجم", + "help.size_lock.body": "يُبقي وضع تثبيت الحجم الرسومات بحجم ثابت، ويستخدم شفافية تتناسب مع شدة الصوت. وهو مفيد عندما تريد إبقاء الرسومات بالحجم نفسه مع رؤية تفاعلها مع الصوت.", + "help.editor.title": "وضع تحرير التراكب المباشر", + "help.editor.body": "اضغط على مفتاح الاختصار المعيَّن (المفتاح التلقائي F4) لتشغيل وضع تحرير التراكب. يمكنك السحب على الشاشة بالماوس لضبط الحد الأقصى لحجم الرسومات، وضبط التفاصيل بدقة في لوحة الإعدادات التي تظهر.", + "help.developer.title": "وضع المطوّر", + "help.developer.body": "عند تشغيل وضع المطوّر، يظهر في الزاوية العلوية اليسرى من التراكب ما سمعه الذكاء الاصطناعي (اسم الصوت ومدى ثقته والوقت المستغرق)، ويظهر معدل الإطارات (FPS) في الزاوية العلوية اليمنى. وإذا شغّلت أيضًا «{0}»، فستُحفظ كل نتيجة في ملف CSV. وهذا مفيد عندما تريد التحقق من أن النظام يعمل بشكل صحيح.", + "help.notes.title": "معلومات مفيدة", + "help.note.direction_surround": "عند الإخراج بقنوات 5.1 أو 7.1، يعرض أعلى الشاشة الصوت القادم من الأمام، ويعرض أسفلها الصوت القادم من الخلف. ويعرض الجانبان الأيسر والأيمن الصوت القادم من اليسار واليمين، أما الرسومات التي تظهر من كل جوانب الشاشة في الوقت نفسه فهي أصوات محيطة أو أصوات بلا اتجاه.", + "help.note.direction_stereo": "عند الإخراج بقناتين (ستيريو)، يميّز التطبيق بين اليسار واليمين، لكنه لا يميّز بين الأمام والخلف. وعندها تُمثّل الرسومات في أعلى الشاشة وأسفلها الأصوات المسموعة بالتساوي من الجانبين، كما تعبّر عن الإحساس المكاني.", + "help.note.sources": "يعرض التطبيق صوت كل التطبيقات التي تعمل عبر جهاز التشغيل الافتراضي في Windows. ولا يُعرض الصوت الذي يرسله تطبيق إلى جهاز آخر، مثل سماعة رأس تستخدمها للدردشة الصوتية فقط.", + "help.note.volume": "إذا كان مستوى صوت تطبيق ما منخفضًا جدًا أو مكتومًا، داخل التطبيق نفسه أو في خالط مستوى الصوت في Windows، فقد لا يكاد صوته يظهر أو لا يظهر إطلاقًا. إذا كانت استجابة الرسومات ضعيفة، فارفع هذا الصوت قليلًا أو زِد «الحساسية» من علامة تبويب «الإعدادات».", + "help.note.ai": "قد يخطئ الذكاء الاصطناعي في التصنيف. لا تعتمد على هذا التطبيق وحده في المواقف التي تتعلق بسلامتك.", + "help.faq.title": "الأسئلة الشائعة", + "help.faq.stopped_q": "توقف الصوت المرئي دون أن أوقفه بنفسي.", + "help.faq.stopped_a": "يتوقف عندما يتوقف التقاط الصوت ولا يعود خلال بضع ثوانٍ، مثلًا عند فصل جهاز التشغيل وعدم توفر جهاز آخر. وعندها تعرض «الرئيسية» السبب. وصّل مكبرات صوت أو سماعات، أو عيّن جهاز تشغيل افتراضيًا في إعدادات الصوت في Windows، ثم شغّله من جديد.", + "help.faq.no_graphic_q": "لا تظهر أي رسومات.", + "help.faq.no_graphic_a": "تحقّق من أن الصوت يعمل عبر جهاز التشغيل الافتراضي في Windows وأنه غير مكتوم، ومن عدم وجود تنبيه في «الرئيسية». وإذا كانت لعبة تعمل في وضع ملء الشاشة الحصري، فجرّب وضع النافذة بلا حدود أو وضع النافذة. تأكّد أيضًا من أن «العتامة» ليست منخفضة جدًا في علامة تبويب «الإعدادات»، وأن إظهار نوع الصوت الحالي مفعّل.", + "help.faq.performance_q": "هل يستهلك الكثير من المعالج أو بطاقة الرسومات؟", + "help.faq.performance_a": "لتخفيف الحمل، خفّض «{0}» في علامة تبويب «الإعدادات» أو أوقف «{1}».", + "help.faq.language_q": "كيف أغيّر لغة التطبيق؟", + "help.faq.language_a": "اختر لغة من قائمة «{0}» في أعلى هذه النافذة. ويمكنك أثناء التشغيل تغييرها أيضًا من إعدادات التراكب. عند اختيار «{1}» يستخدم التطبيق لغة العرض في Windows.", + "help.report.title": "الإبلاغ عن مشكلة", + "help.report.desc": "أخبرنا عن خطأ برمجي، أو عن صوت عُرض على أنه من نوع غير صحيح. يُفتح GitHub في متصفحك مع ملء معلومات النظام الظاهرة أدناه مسبقًا، لذا يلزمك حساب على GitHub. إذا لم يكن لديك حساب، فأرسل إلينا رسالة بالبريد الإلكتروني بدلًا من ذلك. لا يتم إرسال أي صوت على الإطلاق.", + "help.report.github": "الإبلاغ عبر GitHub", + "help.report.mail": "الإبلاغ عبر البريد الإلكتروني", + "help.report.copy": "نسخ معلومات النظام", + "help.report.copied": "تم نسخ معلومات النظام.", + "help.report.copy_failed": "تعذّر النسخ إلى الحافظة. حاول مرة أخرى.", + "help.report.no_browser": "تعذّر فتح متصفح. انسخ معلومات النظام وأرسلها إلينا بدلًا من ذلك.", + "help.report.no_mail": "لا يوجد تطبيق بريد إلكتروني لإرسال الرسالة. انسخ معلومات النظام وأرسلها بطريقة أخرى.", + "help.report.issue_title": "بلاغ من التطبيق", + "help.report.issue_body": "ماذا حدث؟\n\nما الذي كنت تستمع إليه (أي تطبيق، وما نوع الصوت)؟\n\nما الذي كنت تتوقع حدوثه بدلًا من ذلك؟", + "help.about.title": "حول التطبيق", + "help.about.version": "الإصدار {0}", + "help.about.license": "يتم توزيع هذا التطبيق بموجب GNU Affero General Public License v3.0 (AGPL-3.0). أسماء الأصوات ومجموعاتها في علامة تبويب «الأصوات» مبنية على أنطولوجيا AudioSet ومرخّصة بموجب CC BY-SA 4.0 (راجع «تراخيص البرامج المفتوحة المصدر» أدناه).", + "help.licenses.button": "تراخيص البرامج المفتوحة المصدر", + "help.licenses.title": "تراخيص البرامج المفتوحة المصدر", + "help.licenses.close": "إغلاق", + + "notice.ai_unavailable": "لا يمكن تحديد نوع الصوت حاليًا، لذا تظهر جميع الأصوات بلون الأصوات المحيطة. جرّب إيقاف الصوت المرئي ثم تشغيله مرة أخرى.", + "notice.ai_unavailable_hidden": "لا يمكن تحديد نوع الصوت حاليًا، لذا تُعدّ جميع الأصوات أصواتًا محيطة، لكن إظهار الأصوات المحيطة معطّل، فلا يظهر أي شيء. فعّل «{0}» من علامة تبويب «الإعدادات»، أو جرّب إيقاف الصوت المرئي ثم تشغيله مرة أخرى.", + "notice.stopped.title": "تم إيقاف الصوت المرئي", + "notice.stopped.no_device": "تعذّر فتح أي جهاز تشغيل، لذا لا يمكن التقاط الصوت. وصّل مكبرات صوت أو سماعات، أو عيّن جهاز تشغيل افتراضيًا في إعدادات الصوت في Windows، ثم شغّله من جديد.", + "notice.stopped.capture_error": "توقف التقاط الصوت بسبب خطأ، لذا توقفت الرسومات.", + "notice.dismiss": "إخفاء التنبيه", + + "tray.open": "فتح نافذة التشغيل", + "tray.exit": "خروج", + + "more_info.title": "مستودع GitHub والمساهمة", + "more_info.body": "SoundVisualizer مشروع مفتوح المصدر، وشيفرته المصدرية متاحة للجميع على GitHub. يمكنك المشاركة فيه بالإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة، أو الإسهام في التطوير. انقر على الزر أدناه لزيارة مستودع GitHub الرسمي! ويمكنك أيضًا التحقق من أحدث إصدار وتنزيله من علامة تبويب Releases على GitHub.", + "more_info.github_button": "زيارة مستودع GitHub", + + "dialog.notice_title": "تنبيه", + "dialog.already_running": "التراكب قيد التشغيل بالفعل.", + "dialog.reset_title": "إعادة ضبط الإعدادات", + "dialog.reset_wave": "هل تريد إعادة إعدادات وضع الموجة إلى القيم التلقائية؟", + "dialog.reset_pad": "هل تريد إعادة إعدادات وضع الأشرطة إلى القيم التلقائية؟", + "dialog.reset_circle": "هل تريد إعادة إعدادات وضع الدائرة إلى القيم التلقائية؟", + "dialog.reset_outline": "هل تريد إعادة إعدادات وضع الإطار إلى القيم التلقائية؟", + "dialog.reset_sound_types": "هل تريد إعادة إعدادات أنواع الأصوات إلى القيم التلقائية؟", + "dialog.reset_all_title": "استعادة الإعدادات التلقائية", + "dialog.reset_all": "هل تريد بالتأكيد إعادة جميع الإعدادات إلى القيم التلقائية؟", + "dialog.link_error": "تعذّر فتح الرابط: {0}", + + "editor.title": "إعدادات التراكب", + "editor.drag_tip": "اسحب الخطوط الإرشادية على الشاشة بالماوس لضبط حد ارتفاع موجة التراكب وحجم مركزها في الوقت الفعلي!", + "editor.glow": "تأثير التوهج", + "editor.glow_strength": "قوة التوهج", + "editor.color": "اللون", + "editor.hotkeys": "إعدادات مفاتيح الاختصار", + "editor.developer_mode_desc": "يعرض ما سمعه الذكاء الاصطناعي (أعلى اليسار) ومعدل الإطارات (أعلى اليمين)", + "editor.save_close_hint": "للحفظ والإغلاق", + "editor.close_overlay": "إغلاق التراكب", + "editor.guide.limit": "حد {0}", + "editor.guide.size": "حجم {0}: {1}٪", + "editor.guide.base_radius": "نصف القطر الأساسي: {0}", + "editor.guide.circle_size": "حجم الدائرة: {0}٪", + + "hud.visual_mode": "وضع العرض: [{0}] {1}", + "hud.sound_mode": "وضع الصوت: [{0}] {1}", + "hud.overlay_settings": "إعدادات التراكب: [{0}]", + "hud.status.ready": "النظام جاهز", + "hud.status.stereo": "صوت الكمبيوتر: {0} قناة (ستيريو)\nالمصدر: في الانتظار...", + "hud.status.surround": "صوت الكمبيوتر: 8 قنوات (7.1)\nالمصدر: في الانتظار...", + + "color_picker.title": "اختيار لون", + "color_picker.hex": "رمز HEX", + "color_picker.cancel": "إلغاء", + "color_picker.apply": "تطبيق", + "color_picker.area": "التشبع والسطوع", + "color_picker.area_value": "التشبع {0}٪، السطوع {1}٪", + "color_picker.hue": "تدرّج اللون", + "color_picker.keys_help": "استخدم مفاتيح الأسهم لتغييره. اضغط مع الاستمرار على Shift للتغيير بخطوات أكبر.", + "color_picker.presets": "ألوان شائعة", + "color_picker.preset.white": "أبيض", + "color_picker.preset.red": "أحمر", + "color_picker.preset.orange": "برتقالي", + "color_picker.preset.yellow": "أصفر", + "color_picker.preset.green": "أخضر", + "color_picker.preset.cyan": "سماوي", + "color_picker.preset.blue": "أزرق", + "color_picker.preset.magenta": "ماجنتا", + + "audio.error.no_device": "لم يتم العثور على جهاز إخراج.", + "audio.error.capture_stopped": "توقّف التقاط الصوت بشكل غير متوقع: {0}", + "audio.error.capture_start_failed": "تعذّر بدء محرك التقاط الصوت: {0}", + + "tutorial.window_title": "البرنامج التعليمي", + "tutorial.skip": "تخطي", + "tutorial.previous": "الصفحة السابقة", + "tutorial.next": "التالي", + "tutorial.done": "حسنًا", + "tutorial.page_status": "الصفحة {0} من {1}", + "tutorial.pause": "إيقاف الرسوم المتحركة مؤقتًا", + "tutorial.play": "تشغيل الرسوم المتحركة", + "tutorial.sound_on": "تشغيل الصوت", + "tutorial.sound_off": "إيقاف الصوت", + "tutorial.sound.title": "شاهِد الصوت على شاشتك", + "tutorial.sound.body": "يُرسم صوت اللعبة أو الفيديو على حواف الشاشة. كلما ارتفع الصوت، رُسم بحجم أكبر. لا يُعرض إلا الصوت الذي يتم تشغيله على هذا الكمبيوتر. لا يلتقط التطبيق الأصوات من حولك.", + "tutorial.direction.title": "يُظهر من أين يأتي الصوت", + "tutorial.direction.body": "عند الإخراج بقناتين (ستيريو)، يُرسم الصوت القادم من اليسار أكبر على يسار الشاشة، والصوت القادم من اليمين أكبر على يمينها. وعند الإخراج بقنوات 5.1 أو 7.1، يميّز التطبيق أيضًا بين الأمام والخلف: يُرسم الصوت القادم من الأمام في أعلى الشاشة، والصوت القادم من الخلف في أسفلها.", + "tutorial.types.title": "لكل نوع من الأصوات لونه الخاص", + "tutorial.types.body": "يصنّف الذكاء الاصطناعي الأصوات إلى ثلاثة أنواع، ويرسم كل نوع بلونه الخاص. يمكنك تغيير الألوان من علامة تبويب «الإعدادات».", + "tutorial.type.ambient_examples": "الموسيقى والمطر وكل الأصوات الأخرى", + "tutorial.type.speech_examples": "أصوات الناس", + "tutorial.type.danger_examples": "طلقات نارية، انفجارات، صفارات إنذار", + "tutorial.start.title": "طريقة التشغيل والإيقاف", + "tutorial.start.hotkeys": "أثناء التشغيل، اضغط على {0} لتبديل وضع الصوت، وعلى {1} لتبديل وضع العرض، وعلى {2} لفتح إعدادات التراكب." +} diff --git a/SoundVisualizer.Core/Localization/Strings/de.json b/SoundVisualizer.Core/Localization/Strings/de.json new file mode 100644 index 0000000..5a0ed4a --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/de.json @@ -0,0 +1,294 @@ +{ + "common.language": "Sprache", + "common.reset_defaults": "Auf Standardwerte zurücksetzen", + "common.minimize": "Minimieren", + "common.close": "Schließen", + "language.system": "Systemstandard", + + "launcher.window_title": "SoundVisualizer Launcher", + "launcher.tab.home": "Startseite", + "launcher.tab.settings": "Einstellungen", + "launcher.tab.sounds": "Geräusche", + "launcher.tab.help": "Hilfe", + "launcher.tab.more_info": "Mehr erfahren", + "launcher.home.desc": "Macht Geräusche direkt auf Ihrem Bildschirm sichtbar.\nOb Gaming oder Film – erleben Sie etwas ganz Neues.", + "launcher.home.start": "Starten", + "launcher.home.stop": "Beenden", + "launcher.home.tutorial": "Tutorial ansehen", + "launcher.status.running": "Status: Aktiv", + "launcher.status.idle": "Status: Bereit", + + "settings.section.mode": "Moduseinstellungen", + "settings.visual_mode.title": "Darstellungsmodus", + "settings.visual_mode.desc": "Wählen Sie die Grundform der Grafiken, die auf dem Bildschirm angezeigt werden.", + "settings.mode.wave": "Welle", + "settings.mode.pad": "Pad", + "settings.mode.circle": "Kreis", + "settings.mode.outline": "Kontur", + "settings.sound_mode.title": "Audiomodus", + "settings.sound_mode.desc": "Wählen Sie die Audiokanäle, die zu Ihren Lautsprechern passen.\nBei 2-Kanal-Lautsprechern wählen Sie „2 Kanäle“, bei Surround-Lautsprechern „5.1 Kanäle“ oder „7.1 Kanäle“.", + "settings.channels.2": "2 Kanäle", + "settings.channels.5_1": "5.1 Kanäle", + "settings.channels.7_1": "7.1 Kanäle", + "settings.max_fps.title": "Max. Bildrate", + "settings.max_fps.desc": "Legt die höchste Bildrate fest, mit der die Grafiken gezeichnet werden.", + "settings.section.mode_detail": "Einstellungen je Modus", + "settings.mode_card.wave": "Modus „Welle“", + "settings.mode_card.pad": "Modus „Pad“", + "settings.mode_card.circle": "Modus „Kreis“", + "settings.mode_card.outline": "Modus „Kontur“", + "settings.size_lock.title": "Größe fixieren", + "settings.size_lock.desc": "Hält die Grafiken auf einer festen Größe und zeigt die Lautstärke stattdessen über die Transparenz an.", + "settings.fixed_size.wave": "Wellengröße", + "settings.fixed_size.pad": "Pad-Größe", + "settings.fixed_size.circle": "Kreisgröße", + "settings.fixed_size.outline": "Konturstärke", + "settings.fixed_size.desc": "Legt fest, welche feste Größe die Grafiken behalten, während sich ihre Transparenz ändert.", + "settings.max_opacity.title": "Max. Deckkraft", + "settings.max_opacity.desc": "Wie deckend die Grafiken beim lautesten Geräusch werden.", + "settings.size.title": "Größe", + "settings.size.desc.wave": "Legt fest, wie hoch die Welle ist und wie voll sie insgesamt wirkt.", + "settings.size.desc.pad": "Legt fest, wie dick und breit die Pads werden, wenn sie auf Geräusche reagieren.", + "settings.size.desc.circle": "Legt fest, wie weit sich der Kreis-Equalizer nach außen wölbt, wenn Ton wiedergegeben wird.", + "settings.size.desc.outline": "Legt fest, wie hoch die Konturwelle ist und wie voll sie insgesamt wirkt.", + "settings.opacity.title": "Deckkraft", + "settings.opacity.desc.wave": "Höhere Werte wirken kräftiger, niedrigere blasser und durchsichtiger.", + "settings.opacity.desc.pad": "Höhere Werte wirken kräftiger, niedrigere blasser und durchsichtiger.", + "settings.opacity.desc.circle": "Höhere Werte wirken kräftiger, niedrigere blasser und durchsichtiger.", + "settings.opacity.desc.outline": "Höhere Werte wirken kräftiger, niedrigere blasser und durchsichtiger.", + "settings.speed.title": "Tempo", + "settings.speed.desc.wave": "Legt fest, wie schnell die Welle fließt und wogt.", + "settings.speed.desc.pad": "Legt fest, wie schnell sich die Pads in die Richtung drehen, aus der ein Geräusch kommt.", + "settings.speed.desc.circle": "Legt fest, wie schnell der Kreis-Equalizer wogt und sich dreht, wenn er auf Geräusche reagiert.", + "settings.speed.desc.outline": "Legt fest, wie schnell die Konturwelle fließt und wogt.", + "settings.sensitivity.title": "Empfindlichkeit", + "settings.sensitivity.desc.wave": "Legt fest, wie empfindlich die Welle reagiert und ausschlägt – auch bei leisen Geräuschen.", + "settings.sensitivity.desc.pad": "Stellt die Pads so ein, dass sie Geräusche besser erfassen und deutlicher hervortreten.", + "settings.sensitivity.desc.circle": "Legt fest, wie fein sich die Amplitude des Kreises aufteilt, wenn er auf Geräusche reagiert.", + "settings.sensitivity.desc.outline": "Legt fest, wie empfindlich die Konturwelle reagiert und ausschlägt – auch bei leisen Geräuschen.", + "settings.glow.title": "Leuchten", + "settings.glow.desc.wave": "Fügt am Rand der Welle ein weiches Neonleuchten hinzu und regelt dessen Stärke.\nHinweis: Dies beansprucht zusätzliche GPU-Ressourcen.", + "settings.glow.desc.pad": "Fügt um die Pads ein weiches, auraartiges Leuchten hinzu.\nHinweis: Dies beansprucht zusätzliche GPU-Ressourcen.", + "settings.glow.desc.circle": "Fügt am Rand des Kreis-Equalizers einen schimmernden Lichtkranz hinzu.\nHinweis: Dies beansprucht zusätzliche GPU-Ressourcen.", + "settings.glow.desc.outline": "Fügt der Kontur ein weiches Neonleuchten hinzu und regelt dessen Stärke.\nHinweis: Dies beansprucht zusätzliche GPU-Ressourcen.", + "settings.radius.title": "Radius", + "settings.radius.desc": "Legt den Radius des Rings in der Bildschirmmitte im Modus „Kreis“ fest.", + "settings.section.sound_types": "Geräuscharten (KI)", + "settings.show_ambient": "Umgebungsgeräusche", + "settings.show_speech": "Stimmen", + "settings.show_danger": "Gefahrengeräusche", + "settings.set_color": "Farbe wählen", + "settings.set_color_for": "Farbe wählen: {0}", + "settings.section.hotkeys": "Tastenkürzel", + "settings.hotkey.visual.title": "Darstellungsmodus wechseln", + "settings.hotkey.visual.desc": "Tastenkürzel, das die Form während der Ausführung in Echtzeit ändert.", + "settings.hotkey.sound.title": "Audiomodus wechseln", + "settings.hotkey.sound.desc": "Tastenkürzel, das den Audiomodus während der Ausführung in Echtzeit ändert.", + "settings.hotkey.edit.title": "Overlay-Einstellungen", + "settings.hotkey.edit.desc": "Tastenkürzel, das den Overlay-Editor öffnet oder schließt.", + "settings.hotkey.set": "Tastenkürzel festlegen", + "settings.section.advanced": "Erweiterte Einstellungen", + "settings.developer_mode.title": "Entwicklermodus", + "settings.developer_mode.desc": "Zeigt oben links im Overlay, was die KI gehört hat (den gemeldeten Geräuschnamen, wie sicher sie war und wie lange es gedauert hat), und oben rechts die FPS. Die Anzeige ist auf Englisch und nicht für den Alltag gedacht.", + "settings.developer_record.title": "Ergebnisse in eine Datei schreiben", + "settings.developer_record.desc": "Schreibt jedes KI-Ergebnis (die am höchsten bewerteten Geräuschnamen, wie sicher sich die KI war, und die Zeiten) in eine neue CSV-Datei im Ordner ai-log neben SoundVisualizer.exe, damit Sie später nachsehen können, was sie gehört hat. Ist dieser Ordner nicht beschreibbar, landet die Datei in %LOCALAPPDATA%\\SoundVisualizer\\ai-log. Die Dateien können groß werden. Schalten Sie die Option aus, wenn Sie fertig sind.", + "settings.auto_minimize.title": "Launcher beim Start minimieren", + "settings.auto_minimize.desc": "Minimiert dieses Launcher-Fenster automatisch, wenn das Overlay startet.", + + "sounds.title": "Geräuscharten wählen", + "sounds.desc": "Die Geräusche, welche die KI erkennt, sind in Gruppen ähnlicher Geräusche zusammengefasst und nach der Art geordnet, zu der sie standardmäßig zählen. Ändern Sie die Art einer Gruppe, richtet sich die Farbe ihrer Geräusche sofort danach.", + "sounds.sibling_note": "Die KI erkennt ein Geräusch oft unter mehreren Namen zugleich. Eine Sirene wird zum Beispiel auch als „Alarm“ erkannt, und „Alarm“ gehört zu einer anderen Gruppe. Wird ein Geräusch nach einer Änderung noch in seiner alten Art angezeigt, ändern Sie auch die zugehörigen Gruppen oder Geräusche.", + "sounds.advanced.title": "Erweiterter Modus", + "sounds.advanced.desc": "Zeigt jedes Geräusch in seiner Gruppe, damit Sie Geräusche einzeln ändern können.", + "sounds.search.hint": "Geräusche suchen", + "sounds.search.clear": "Suchbegriff löschen", + "sounds.filter.title": "Filter", + "sounds.filter.all": "Alle", + "sounds.filter.changed": "Geändert", + "sounds.type.ambient": "Umgebungsgeräusche", + "sounds.type.speech": "Stimmen", + "sounds.type.danger": "Gefahrengeräusche", + "sounds.type.default": "{0} (Standard)", + "sounds.type.mixed": "Gemischt", + "sounds.changed_count": "Geänderte Geräusche: {0}", + "sounds.changed_from": "Standard: {0}", + "sounds.reset.button": "Alle zurücksetzen", + "sounds.reset.title": "Alle Geräusche zurücksetzen?", + "sounds.reset.message": "Alle Geräusche, die Sie geändert haben, erhalten wieder ihre standardmäßige Art.", + "sounds.reset.confirm": "Zurücksetzen", + "sounds.reset.cancel": "Abbrechen", + "sounds.empty": "Keine passenden Geräusche.", + "sounds.group.mixed_note": "Einige Geräusche einzeln geändert", + "sounds.group.changed_line": "Jetzt: {0} · Standard: {1}", + "sounds.overwrite.title": "Ganze Gruppe ändern?", + "sounds.overwrite.message": "Geräusche in der Gruppe „{0}“, die zur Art „{2}“ wechseln: {1}. Auch einzeln geänderte Geräusche werden überschrieben.", + "sounds.overwrite.confirm": "Ändern", + "sounds.overwrite.cancel": "Abbrechen", + "sounds.basic_warning.title": "Zum einfachen Modus wechseln?", + "sounds.basic_warning.message": "Einige Gruppen enthalten Geräusche, die Sie einzeln geändert haben. Wenn Sie im einfachen Modus die Art einer solchen Gruppe wählen, erhalten alle ihre Geräusche diese Art, und Ihre einzelnen Änderungen in dieser Gruppe werden überschrieben. Bis Sie eine Art wählen, ändert sich nichts.", + "sounds.basic_warning.confirm": "Zum einfachen Modus", + "sounds.basic_warning.cancel": "Erweitert bleiben", + "sounds.cd.sound": "{0}, Art: {1}", + "sounds.cd.sound_changed": "{0}, Art: {1}, Standard: {2}", + "sounds.cd.group": "Gruppe {0}, Art: {1}, Geräusche: {2} ({3})", + "sounds.cd.group_changed": "Gruppe {0}, Art: {1}, Standard: {2}, Geräusche: {3} ({4})", + "sounds.cd.group_header": "Gruppe {0}, Geräusche: {1}", + + "hotkey.prompt": "Drücken Sie eine Taste (Esc zum Abbrechen)", + "hotkey.none": "Keine", + "hotkey.button_name": "{0}, Tastenkürzel: {1}", + + "help.start.title": "Erste Schritte", + "help.start.1": "Wählen Sie oben in diesem Fenster die Sprache aus, und ändern Sie bei Bedarf im Tab „Einstellungen“ das Aussehen der Grafik.", + "help.start.2": "Klicken Sie im Tab „Startseite“ auf „{0}“.", + "help.start.3": "Die Grafik wird an den Bildschirmrändern über anderen Fenstern wie Spielen und Videos dargestellt. Mausklicks gehen durch sie hindurch, sodass Sie das Fenster darunter weiter bedienen können.", + "help.start.4": "Zum Ausschalten klicken Sie im Tab „Startseite“ auf „{0}“, oder Sie klicken mit der rechten Maustaste auf das Sound-Visualizer-Symbol im Infobereich der Taskleiste und wählen „{0}“.", + "help.modes.title": "Darstellungsmodi", + "help.mode.wave": "Der gesamte Bildschirmrand schiebt sich je nach Lautstärke nach innen. Auf der Seite, von der das Geräusch kommt, schiebt er sich weiter hinein.", + "help.mode.pad": "An 8 Stellen am Bildschirmrand leuchten Balken auf. So ist die Richtung am deutlichsten zu erkennen.", + "help.mode.circle": "Ein Ring in der Bildschirmmitte dehnt sich in Richtung des Geräuschs aus.", + "help.mode.outline": "Zeichnet nur eine dünne Kontur der Welle und verdeckt so weniger von Ihrem Spiel.", + "help.modes.note": "Einstellungen wie „Größe“ und „Deckkraft“ werden für jeden Modus separat gespeichert und lassen sich im Tab „Einstellungen“ ändern.", + "help.sound.title": "Geräuscharten", + "help.sound.kinds": "Die KI teilt Geräusche in drei Arten ein: Umgebungsgeräusche, Stimmen und Gefahrengeräusche. Standardmäßig sind Umgebungsgeräusche weiß, Stimmen gelb und Gefahrengeräusche rot. Im Tab „Einstellungen“ können Sie für jede Art die Farbe ändern und festlegen, ob sie angezeigt wird.", + "help.sound.groups": "Im Tab „Geräusche“ sind ähnliche Geräusche in Gruppen zusammengefasst, und Sie legen für jede Gruppe fest, zu welcher Art sie zählt. Ändern Sie zum Beispiel die Gruppe „Türklingel“ in Gefahrengeräusche, dann erhält ein Geräusch, das die KI als Türklingel oder Klopfen erkennt, die Farbe für Gefahrengeräusche. Ein Geräusch, das Sie neu zu den Gefahrengeräuschen zählen, fließt allerdings nur in die Abstimmung der KI über die Art ein. Das heißt also nicht, dass jede Türklingel als Gefahrengeräusch angezeigt wird.", + "help.sound.advanced": "Um Geräusche einzeln zu ändern, schalten Sie „Erweiterter Modus“ ein.", + "help.sound.danger": "Wird ein Gefahrengeräusch (z. B. ein Schuss) erkannt, wechselt die Farbe sofort statt allmählich.", + "help.pc.title": "PC-Einstellungen und Tastenkürzel", + "help.sound_mode.title": "Audiomodus", + "help.sound_mode.body": "Damit neben links und rechts auch vorne und hinten unterschieden werden, stellen Sie Ihr Ausgabegerät in den Windows-Soundeinstellungen auf „5.1-Surround“ (6 Kanäle) oder „7.1-Surround“ (8 Kanäle) ein. Bei normalem Stereo (2 Kanäle) werden links und rechts unterschieden, vorne und hinten jedoch nicht. Der „Audiomodus“ im Tab „Einstellungen“ passt sich beim Start und bei einem Gerätewechsel an das Ausgabegerät an; Sie können ihn auch selbst ändern.", + "help.sound_mode.tip": "💡 Wenn Sie kein 7.1-Ausgabegerät haben, können Sie den {0} installieren und das Ausgabegerät auf 7.1 Kanäle einstellen, um das Overlay mit 7.1-Surround-Sound anzuzeigen.", + "help.sound_mode.tip_link": "virtuellen Treiber VB-CABLE", + "help.hotkeys.title": "Eigene Tastenkürzel in Echtzeit", + "help.hotkeys.body": "Klicken Sie im Tab „Einstellungen“ auf eine Tastenkürzel-Schaltfläche und drücken Sie eine einzelne Taste oder eine Tastenkombination (z. B. Ctrl + Shift + A), um das Tastenkürzel nach Wunsch zu ändern. Wenn Sie das Tastenkürzel drücken, wechselt der Modus sofort – auch wenn die App im Hintergrund läuft.", + "help.hotkeys.defaults": "Standardmäßig wechselt F2 den Audiomodus und F3 den Darstellungsmodus, und F4 öffnet die Overlay-Einstellungen.", + "help.size_lock.title": "Modus „Größe fixieren“", + "help.size_lock.body": "Im Modus „Größe fixieren“ behalten die Grafiken eine feste Größe, und die Lautstärke wird über eine proportionale Transparenz dargestellt. Das ist praktisch, wenn die Grafiken gleich groß bleiben sollen, Sie aber trotzdem sehen möchten, wie sie auf Geräusche reagieren.", + "help.editor.title": "Overlay-Bearbeitungsmodus in Echtzeit", + "help.editor.body": "Drücken Sie das zugewiesene Tastenkürzel (standardmäßig F4), um den Overlay-Bearbeitungsmodus einzuschalten. Sie können auf dem Bildschirm mit der Maus ziehen, um die Größengrenze der Grafiken anzupassen, und im eingeblendeten Einstellungsbereich Details feinjustieren.", + "help.developer.title": "Entwicklermodus", + "help.developer.body": "Wenn Sie den Entwicklermodus einschalten, zeigt das Overlay oben links, was die KI gehört hat (den Geräuschnamen, wie sicher sie war und wie lange es gedauert hat), und oben rechts die FPS. Schalten Sie zusätzlich „{0}“ ein, wird jedes Ergebnis in einer CSV-Datei gespeichert. Das ist nützlich, um zu prüfen, ob das System richtig funktioniert.", + "help.notes.title": "Gut zu wissen", + "help.note.direction_surround": "Bei 5.1- oder 7.1-Kanal-Ausgabe zeigt der obere Bildschirmrand Geräusche von vorne, der untere Geräusche von hinten. Die linke und rechte Seite zeigen Geräusche von links und rechts. Grafiken, die gleichzeitig von allen Seiten des Bildschirms kommen, stehen für Umgebungsgeräusche oder Geräusche ohne Richtung.", + "help.note.direction_stereo": "Bei 2-Kanal-Ausgabe (Stereo) werden links und rechts unterschieden, vorne und hinten jedoch nicht. Grafiken am oberen und unteren Bildschirmrand zeigen dann Geräusche, die auf beiden Seiten gleich stark zu hören sind, sowie den Raumeindruck.", + "help.note.sources": "Angezeigt wird der Ton aller Apps, die über das Standard-Wiedergabegerät von Windows wiedergegeben werden. Ton, den eine App an ein anderes Gerät sendet, etwa an ein Headset nur für den Sprachchat, wird nicht angezeigt.", + "help.note.volume": "Wenn die Lautstärke einer App sehr niedrig oder stummgeschaltet ist, in der App selbst oder im Lautstärkemixer von Windows, ist ihr Ton womöglich kaum oder gar nicht zu sehen. Wenn die Grafik schwach reagiert, drehen Sie diesen Ton etwas auf oder erhöhen Sie im Tab „Einstellungen“ die „Empfindlichkeit“.", + "help.note.ai": "Die Einordnung durch die KI kann falsch sein. Verlassen Sie sich in Situationen, in denen es um Sicherheit geht, nicht allein auf diese App.", + "help.faq.title": "Häufig gestellte Fragen", + "help.faq.stopped_q": "Die Tonvisualisierung hat sich ausgeschaltet, ohne dass ich sie beendet habe.", + "help.faq.stopped_a": "Sie schaltet sich aus, wenn die Audioerfassung stoppt und sich nicht innerhalb weniger Sekunden erholt, zum Beispiel wenn das Wiedergabegerät getrennt wird und kein anderes verfügbar ist. Der Tab „Startseite“ zeigt dann den Grund an. Schließen Sie Lautsprecher oder Kopfhörer an oder legen Sie in den Windows-Soundeinstellungen ein Standard-Wiedergabegerät fest, und starten Sie sie dann erneut.", + "help.faq.no_graphic_q": "Ich sehe keine Grafiken.", + "help.faq.no_graphic_a": "Prüfen Sie, ob der Ton über das Standard-Wiedergabegerät von Windows wiedergegeben wird und nicht stummgeschaltet ist und ob im Tab „Startseite“ ein Hinweis angezeigt wird. Wenn ein Spiel im exklusiven Vollbildmodus läuft, probieren Sie den randlosen Fenstermodus oder den Fenstermodus. Achten Sie außerdem im Tab „Einstellungen“ darauf, dass die „Deckkraft“ nicht zu niedrig eingestellt und die Anzeige der aktuellen Geräuschart nicht ausgeschaltet ist.", + "help.faq.performance_q": "Braucht die App viel CPU- oder GPU-Leistung?", + "help.faq.performance_a": "Um sie zu entlasten, verringern Sie im Tab „Einstellungen“ die „{0}“ oder schalten Sie „{1}“ aus.", + "help.faq.language_q": "Wie ändere ich die App-Sprache?", + "help.faq.language_a": "Wählen Sie sie in der Liste „{0}“ oben in diesem Fenster aus. Während die Tonvisualisierung läuft, können Sie sie auch in den Overlay-Einstellungen ändern. „{1}“ übernimmt die Anzeigesprache von Windows.", + "help.report.title": "Problem melden", + "help.report.desc": "Melden Sie uns einen Fehler oder ein Geräusch, das als falsche Geräuschart angezeigt wurde. GitHub öffnet sich in Ihrem Browser mit den unten stehenden Systeminformationen bereits eingetragen. Dafür benötigen Sie ein GitHub-Konto. Wenn Sie keines haben, schicken Sie uns stattdessen eine E-Mail. Es werden niemals Audiodaten gesendet.", + "help.report.github": "Auf GitHub melden", + "help.report.mail": "Per E-Mail melden", + "help.report.copy": "Systeminformationen kopieren", + "help.report.copied": "Systeminformationen kopiert.", + "help.report.copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen. Versuchen Sie es erneut.", + "help.report.no_browser": "Es konnte kein Browser geöffnet werden. Kopieren Sie stattdessen die Systeminformationen und senden Sie sie uns.", + "help.report.no_mail": "Keine E-Mail-App zum Senden vorhanden. Kopieren Sie die Systeminformationen und senden Sie sie auf anderem Weg.", + "help.report.issue_title": "Meldung aus der App", + "help.report.issue_body": "Was ist passiert?\n\nWas haben Sie gerade gehört (App, Art des Geräuschs)?\n\nWas hätten Sie stattdessen erwartet?", + "help.about.title": "Über diese App", + "help.about.version": "Version {0}", + "help.about.license": "Diese App wird unter der GNU Affero General Public License v3.0 (AGPL-3.0) bereitgestellt. Die Namen und Gruppen der Geräusche im Tab „Geräusche“ beruhen auf der AudioSet-Ontologie und stehen unter der Lizenz CC BY-SA 4.0 (siehe „Open-Source-Lizenzen“ unten).", + "help.licenses.button": "Open-Source-Lizenzen", + "help.licenses.title": "Open-Source-Lizenzen", + "help.licenses.close": "Schließen", + + "notice.ai_unavailable": "Die Erkennung der Geräuscharten funktioniert nicht. Daher werden alle Geräusche in der Farbe für Umgebungsgeräusche angezeigt. Schalten Sie die Tonvisualisierung aus und wieder ein.", + "notice.ai_unavailable_hidden": "Die Erkennung der Geräuscharten funktioniert nicht. Daher zählen alle Geräusche als Umgebungsgeräusche, deren Anzeige jedoch ausgeschaltet ist, sodass nichts angezeigt wird. Schalten Sie im Tab „Einstellungen“ „{0}“ ein, oder schalten Sie die Tonvisualisierung aus und wieder ein.", + "notice.stopped.title": "Tonvisualisierung ausgeschaltet", + "notice.stopped.no_device": "Es konnte kein Wiedergabegerät geöffnet werden, daher kann kein Ton erfasst werden. Schließen Sie Lautsprecher oder Kopfhörer an oder legen Sie in den Windows-Soundeinstellungen ein Standard-Wiedergabegerät fest, und starten Sie dann erneut.", + "notice.stopped.capture_error": "Die Audioerfassung wurde durch einen Fehler gestoppt, daher ist die Grafik aus.", + "notice.dismiss": "Hinweis ausblenden", + + "tray.open": "Launcher öffnen", + "tray.exit": "Programm beenden", + + "more_info.title": "GitHub-Repository und Mitwirkung", + "more_info.body": "SoundVisualizer ist ein Open-Source-Projekt, dessen Quellcode öffentlich auf GitHub verfügbar ist. Sie können mitmachen, indem Sie Fehler melden, Funktionen vorschlagen oder zur Entwicklung beitragen. Klicken Sie auf die Schaltfläche unten, um das offizielle GitHub-Repository zu besuchen! Im Tab „Releases“ auf GitHub können Sie außerdem nach der neuesten Version suchen und sie herunterladen.", + "more_info.github_button": "GitHub-Repository besuchen", + + "dialog.notice_title": "Hinweis", + "dialog.already_running": "Das Overlay läuft bereits.", + "dialog.reset_title": "Einstellungen zurücksetzen", + "dialog.reset_wave": "Einstellungen des Modus „Welle“ auf die Standardwerte zurücksetzen?", + "dialog.reset_pad": "Einstellungen des Modus „Pad“ auf die Standardwerte zurücksetzen?", + "dialog.reset_circle": "Einstellungen des Modus „Kreis“ auf die Standardwerte zurücksetzen?", + "dialog.reset_outline": "Einstellungen des Modus „Kontur“ auf die Standardwerte zurücksetzen?", + "dialog.reset_sound_types": "Einstellungen der Geräuscharten auf die Standardwerte zurücksetzen?", + "dialog.reset_all_title": "Auf Standardwerte zurücksetzen", + "dialog.reset_all": "Möchten Sie wirklich alle Einstellungen auf die Standardwerte zurücksetzen?", + "dialog.link_error": "Der Link konnte nicht geöffnet werden: {0}", + + "editor.title": "Overlay-Einstellungen", + "editor.drag_tip": "Ziehen Sie die Hilfslinien auf dem Bildschirm mit der Maus, um die Höhengrenze der Overlay-Welle und die Größe der Mitte in Echtzeit anzupassen!", + "editor.glow": "Leuchteffekt", + "editor.glow_strength": "Leuchtstärke", + "editor.color": "Farbe", + "editor.hotkeys": "Einstellungen für Tastenkürzel", + "editor.developer_mode_desc": "Zeigt, was die KI gehört hat (oben links), und die FPS (oben rechts)", + "editor.save_close_hint": "zum Speichern und Schließen", + "editor.close_overlay": "Overlay beenden", + "editor.guide.limit": "Grenze im Modus „{0}“", + "editor.guide.size": "Größe im Modus „{0}“: {1} %", + "editor.guide.base_radius": "Grundradius: {0}", + "editor.guide.circle_size": "Kreisgröße: {0} %", + + "hud.visual_mode": "Darstellungsmodus: [{0}] {1}", + "hud.sound_mode": "Audiomodus: [{0}] {1}", + "hud.overlay_settings": "Overlay-Einstellungen: [{0}]", + "hud.status.ready": "System bereit", + "hud.status.stereo": "PC-Audio: {0}ch (Stereo)\nQuelle: wartet...", + "hud.status.surround": "PC-Audio: 8ch (7.1)\nQuelle: wartet...", + + "color_picker.title": "Farbe auswählen", + "color_picker.hex": "HEX-Code", + "color_picker.cancel": "Abbrechen", + "color_picker.apply": "Übernehmen", + "color_picker.area": "Sättigung und Helligkeit", + "color_picker.area_value": "Sättigung {0} %, Helligkeit {1} %", + "color_picker.hue": "Farbton", + "color_picker.keys_help": "Mit den Pfeiltasten ändern. Mit gedrückter Umschalttaste in größeren Schritten.", + "color_picker.presets": "Häufige Farben", + "color_picker.preset.white": "Weiß", + "color_picker.preset.red": "Rot", + "color_picker.preset.orange": "Orange", + "color_picker.preset.yellow": "Gelb", + "color_picker.preset.green": "Grün", + "color_picker.preset.cyan": "Türkis", + "color_picker.preset.blue": "Blau", + "color_picker.preset.magenta": "Magenta", + + "audio.error.no_device": "Es wurde kein Ausgabegerät gefunden.", + "audio.error.capture_stopped": "Die Audioerfassung wurde unerwartet beendet: {0}", + "audio.error.capture_start_failed": "Die Erfassungs-Engine konnte nicht gestartet werden: {0}", + + "tutorial.window_title": "Tutorial", + "tutorial.skip": "Überspringen", + "tutorial.previous": "Vorherige Seite", + "tutorial.next": "Weiter", + "tutorial.done": "Verstanden", + "tutorial.page_status": "Seite {0} von {1}", + "tutorial.pause": "Animation anhalten", + "tutorial.play": "Animation abspielen", + "tutorial.sound_on": "Ton einschalten", + "tutorial.sound_off": "Ton ausschalten", + "tutorial.sound.title": "Geräusche auf dem Bildschirm sehen", + "tutorial.sound.body": "Der Ton Ihres Spiels oder Videos wird an den Bildschirmrändern dargestellt. Je lauter er ist, desto größer wird er gezeichnet. Angezeigt wird nur der Ton, der auf diesem PC wiedergegeben wird. Geräusche um Sie herum werden nicht erfasst.", + "tutorial.direction.title": "Zeigt, woher ein Geräusch kommt", + "tutorial.direction.body": "Bei 2-Kanal-Ausgabe (Stereo) wird ein Geräusch von links auf der linken Seite größer dargestellt, ein Geräusch von rechts auf der rechten Seite. Bei 5.1- oder 7.1-Kanal-Ausgabe unterscheidet die App auch vorne und hinten: Geräusche von vorne erscheinen am oberen Bildschirmrand, Geräusche von hinten am unteren.", + "tutorial.types.title": "Jede Geräuschart hat ihre eigene Farbe", + "tutorial.types.body": "Die KI teilt Geräusche in drei Arten ein und stellt jede Art in einer eigenen Farbe dar. Die Farben können Sie im Tab „Einstellungen“ ändern.", + "tutorial.type.ambient_examples": "Musik, Regen und alles andere", + "tutorial.type.speech_examples": "Menschen, die sprechen", + "tutorial.type.danger_examples": "Schüsse, Explosionen, Sirenen", + "tutorial.start.title": "Ein- und ausschalten", + "tutorial.start.hotkeys": "Während die Tonvisualisierung läuft, wechseln Sie mit {0} den Audiomodus, mit {1} den Darstellungsmodus und öffnen mit {2} die Overlay-Einstellungen." +} diff --git a/SoundVisualizer.Core/Localization/Strings/en.json b/SoundVisualizer.Core/Localization/Strings/en.json new file mode 100644 index 0000000..34b6858 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/en.json @@ -0,0 +1,294 @@ +{ + "common.language": "Language", + "common.reset_defaults": "Reset to defaults", + "common.minimize": "Minimize", + "common.close": "Close", + "language.system": "System default", + + "launcher.window_title": "SoundVisualizer Launcher", + "launcher.tab.home": "Home", + "launcher.tab.settings": "Settings", + "launcher.tab.sounds": "Sounds", + "launcher.tab.help": "Help", + "launcher.tab.more_info": "Learn more", + "launcher.home.desc": "Draws the sounds you can't see right on your screen.\nFrom gaming to movies, try a whole new experience.", + "launcher.home.start": "Start", + "launcher.home.stop": "Stop", + "launcher.home.tutorial": "View tutorial", + "launcher.status.running": "Status: Running", + "launcher.status.idle": "Status: Ready", + + "settings.section.mode": "Mode settings", + "settings.visual_mode.title": "Visual mode", + "settings.visual_mode.desc": "Choose the basic shape of the graphics drawn on screen.", + "settings.mode.wave": "Wave", + "settings.mode.pad": "Pad", + "settings.mode.circle": "Circle", + "settings.mode.outline": "Outline", + "settings.sound_mode.title": "Sound mode", + "settings.sound_mode.desc": "Choose the sound channels that match your speakers.\nFor 2-channel speakers, choose '2 channels'. For surround speakers, choose '5.1 channels' or '7.1 channels'.", + "settings.channels.2": "2 channels", + "settings.channels.5_1": "5.1 channels", + "settings.channels.7_1": "7.1 channels", + "settings.max_fps.title": "Max frame rate", + "settings.max_fps.desc": "Sets the highest frame rate the graphics are drawn at.", + "settings.section.mode_detail": "Settings by mode", + "settings.mode_card.wave": "Wave mode", + "settings.mode_card.pad": "Pad mode", + "settings.mode_card.circle": "Circle mode", + "settings.mode_card.outline": "Outline mode", + "settings.size_lock.title": "Lock size", + "settings.size_lock.desc": "Keeps the graphics at a fixed size and shows loudness through their transparency instead.", + "settings.fixed_size.wave": "Wave size", + "settings.fixed_size.pad": "Pad size", + "settings.fixed_size.circle": "Circle size", + "settings.fixed_size.outline": "Outline thickness", + "settings.fixed_size.desc": "Sets the fixed size the graphics stay at while their transparency changes.", + "settings.max_opacity.title": "Max opacity", + "settings.max_opacity.desc": "How opaque the graphics get at the loudest sound.", + "settings.size.title": "Size", + "settings.size.desc.wave": "Adjusts the height of the wave and how full it looks overall.", + "settings.size.desc.pad": "Adjusts how thick and wide the pads get when they react to sound.", + "settings.size.desc.circle": "Adjusts how far the circle equalizer bulges out when sound plays.", + "settings.size.desc.outline": "Adjusts the height of the outline wave and how full it looks overall.", + "settings.opacity.title": "Opacity", + "settings.opacity.desc.wave": "Higher values draw the wave bolder; lower values let more of the game or screen behind it show through.", + "settings.opacity.desc.pad": "Higher values draw the pads bolder; lower values make them fainter and more transparent.", + "settings.opacity.desc.circle": "Higher values draw the circle bolder; lower values make it fainter and more transparent.", + "settings.opacity.desc.outline": "Higher values draw the outline wave bolder; lower values make it fainter and more transparent.", + "settings.speed.title": "Speed", + "settings.speed.desc.wave": "Adjusts how fast the wave flows and ripples.", + "settings.speed.desc.pad": "Adjusts how fast the pads turn toward the direction a sound comes from.", + "settings.speed.desc.circle": "Adjusts how fast the circle equalizer ripples and turns in response to sound.", + "settings.speed.desc.outline": "Adjusts how fast the outline wave flows and ripples.", + "settings.sensitivity.title": "Sensitivity", + "settings.sensitivity.desc.wave": "Adjusts how sensitively the wave reacts and surges, even to quiet sounds.", + "settings.sensitivity.desc.pad": "Adjusts the pads so they pick up sound better and stand out more.", + "settings.sensitivity.desc.circle": "Adjusts how finely the circle's amplitude breaks up as it reacts to sound.", + "settings.sensitivity.desc.outline": "Adjusts how sensitively the outline wave reacts and surges, even to quiet sounds.", + "settings.glow.title": "Glow", + "settings.glow.desc.wave": "Adds a soft neon glow along the edge of the wave and adjusts its strength.\nNote: uses extra GPU resources.", + "settings.glow.desc.pad": "Adds a soft, aura-like glow around the pads.\nNote: uses extra GPU resources.", + "settings.glow.desc.circle": "Adds a shimmering halo along the edge of the circle equalizer.\nNote: uses extra GPU resources.", + "settings.glow.desc.outline": "Adds a soft neon glow to the outline and adjusts its strength.\nNote: uses extra GPU resources.", + "settings.radius.title": "Radius", + "settings.radius.desc": "Sets the radius of the center ring in Circle mode.", + "settings.section.sound_types": "Sound types (AI)", + "settings.show_ambient": "Show ambient sounds", + "settings.show_speech": "Show speech", + "settings.show_danger": "Show danger sounds", + "settings.set_color": "Set color", + "settings.set_color_for": "Set color: {0}", + "settings.section.hotkeys": "Hotkeys", + "settings.hotkey.visual.title": "Switch visual mode", + "settings.hotkey.visual.desc": "Hotkey that changes the shape in real time while running.", + "settings.hotkey.sound.title": "Switch sound mode", + "settings.hotkey.sound.desc": "Hotkey that changes the sound mode in real time while running.", + "settings.hotkey.edit.title": "Overlay settings", + "settings.hotkey.edit.desc": "Hotkey that opens or closes the overlay editor.", + "settings.hotkey.set": "Set hotkey", + "settings.section.advanced": "Advanced settings", + "settings.developer_mode.title": "Developer mode", + "settings.developer_mode.desc": "Shows what the AI heard (the sound name it reported, how sure it was, and how long it took) in the top-left corner of the overlay, and the FPS in the top-right. The readout is in English and is not meant for everyday use.", + "settings.developer_record.title": "Record results to a file", + "settings.developer_record.desc": "Writes every AI result (the sound names it ranked highest, how sure it was, and the timings) to a new CSV file in the ai-log folder next to SoundVisualizer.exe, so you can look back at what it heard. If that folder can't be written to, the file goes to %LOCALAPPDATA%\\SoundVisualizer\\ai-log instead. Files can grow large; turn it off when you are done.", + "settings.auto_minimize.title": "Minimize launcher on start", + "settings.auto_minimize.desc": "Automatically minimizes this launcher window when the overlay starts.", + + "sounds.title": "Choose sound types", + "sounds.desc": "Sounds the AI recognizes are grouped with similar sounds, under the type each counts as by default. Change a group’s type, and the color of its sounds follows right away.", + "sounds.sibling_note": "The AI often hears one sound under several names at once. A siren, for example, is also heard as “Alarm”, which is in another group. If a sound still shows its old type after a change, change the related groups or sounds too.", + "sounds.advanced.title": "Advanced mode", + "sounds.advanced.desc": "Shows every sound in its group, so you can change sounds one by one.", + "sounds.search.hint": "Search sounds", + "sounds.search.clear": "Clear search", + "sounds.filter.title": "Filter", + "sounds.filter.all": "All", + "sounds.filter.changed": "Changed", + "sounds.type.ambient": "Ambient", + "sounds.type.speech": "Speech", + "sounds.type.danger": "Danger", + "sounds.type.default": "{0} (default)", + "sounds.type.mixed": "Mixed", + "sounds.changed_count": "Sounds changed: {0}", + "sounds.changed_from": "Default: {0}", + "sounds.reset.button": "Reset all", + "sounds.reset.title": "Reset all sounds?", + "sounds.reset.message": "Every sound you changed goes back to its default type.", + "sounds.reset.confirm": "Reset", + "sounds.reset.cancel": "Cancel", + "sounds.empty": "No matching sounds.", + "sounds.group.mixed_note": "Some sounds changed one by one", + "sounds.group.changed_line": "Now: {0} · Default: {1}", + "sounds.overwrite.title": "Change the whole group?", + "sounds.overwrite.message": "Sounds in the “{0}” group that will change to {2}: {1}. Sounds you changed one by one are overwritten too.", + "sounds.overwrite.confirm": "Change", + "sounds.overwrite.cancel": "Cancel", + "sounds.basic_warning.title": "Switch to basic mode?", + "sounds.basic_warning.message": "Some groups have sounds you changed one by one. In basic mode, choosing a type for one of these groups gives every sound in it that type, and your one-by-one changes in that group are overwritten. Nothing changes until you choose.", + "sounds.basic_warning.confirm": "Switch to basic", + "sounds.basic_warning.cancel": "Stay in advanced", + "sounds.cd.sound": "{0}, type: {1}", + "sounds.cd.sound_changed": "{0}, type: {1}, default: {2}", + "sounds.cd.group": "{0} group, type: {1}, sounds: {2} ({3})", + "sounds.cd.group_changed": "{0} group, type: {1}, default: {2}, sounds: {3} ({4})", + "sounds.cd.group_header": "{0} group, sounds: {1}", + + "hotkey.prompt": "Press a key (Esc to cancel)", + "hotkey.none": "None", + "hotkey.button_name": "{0}, hotkey: {1}", + + "help.start.title": "Getting started", + "help.start.1": "Pick your language at the top of this window, and change how the graphics look in the Settings tab if you like.", + "help.start.2": "On Home, click “{0}”.", + "help.start.3": "The graphics are drawn along the edges of the screen, on top of other windows such as games and videos. Clicks pass through them, so you can keep using the window underneath.", + "help.start.4": "To turn it off, click “{0}” on Home, or right-click the SoundVisualizer icon in the notification area of the taskbar and choose “{0}”.", + "help.modes.title": "Visual modes", + "help.mode.wave": "The whole edge of the screen pushes inward with the loudness. The side the sound comes from pushes in deeper.", + "help.mode.pad": "Bars light up at 8 spots around the edge of the screen. Shows direction most clearly.", + "help.mode.circle": "A ring in the center of the screen swells toward the sound.", + "help.mode.outline": "Draws only a thin outline of the wave, so it covers less of your game.", + "help.modes.note": "Settings like size and opacity are saved separately for each mode and can be changed in the Settings tab.", + "help.sound.title": "Sound types", + "help.sound.kinds": "AI sorts what you hear into ambient, speech and danger sounds. By default, ambient is white, speech is yellow and danger is red. You can change the color of each type and whether it's shown in the Settings tab.", + "help.sound.groups": "In the Sounds tab, similar sounds are grouped, and you pick which type each group counts as. For example, change the “Doorbell” group to Danger, and when the AI hears a doorbell or a knock it gets the danger color. A sound you newly set to Danger only takes part in the AI's vote on the type, though, so this doesn't mean every doorbell will be shown as danger.", + "help.sound.advanced": "Turn on Advanced mode to change sounds one by one.", + "help.sound.danger": "When a danger sound (like a gunshot) is detected, the color changes instantly instead of fading.", + "help.pc.title": "PC settings and hotkeys", + "help.sound_mode.title": "Sound mode", + "help.sound_mode.body": "To see sounds from the front and back as well as from the left and right, set your output device to '5.1 Surround' (6 channels) or '7.1 Surround' (8 channels) in the Windows sound settings. With ordinary stereo (2 channels), the graphics can show left and right, but not front and back. 'Sound mode' in the Settings tab switches to match your output device when the visualizer starts or the device changes, and you can still change it yourself.", + "help.sound_mode.tip": "💡 If you don't have a 7.1 output device, you can install the {0} and set the output device to 7.1 channels to show the overlay with 7.1 surround sound.", + "help.sound_mode.tip_link": "VB-CABLE virtual driver", + "help.hotkeys.title": "Real-time custom hotkeys", + "help.hotkeys.body": "In the Settings tab, click a hotkey button and press a single key or a key combination (e.g. Ctrl + Shift + A) to change the hotkey as you like. Pressing the hotkey, even while the app is in the background, switches the mode instantly.", + "help.hotkeys.defaults": "By default, F2 switches the sound mode, F3 switches the visual mode and F4 opens the overlay settings.", + "help.size_lock.title": "Lock size mode", + "help.size_lock.body": "Lock size mode keeps the graphics at a fixed size by using transparency in proportion to the loudness of the sound. It's useful when you want to keep the graphics the same size while still seeing them react to sound.", + "help.editor.title": "Real-time overlay edit mode", + "help.editor.body": "Press the assigned hotkey (F4 by default) to turn on the overlay edit mode. You can drag on the screen with your mouse to adjust the size limit of the graphics, and fine-tune the details in the settings panel that pops up.", + "help.developer.title": "Developer mode", + "help.developer.body": "Turning on Developer mode shows what the AI heard (the sound name, how sure it was, and how long it took) in the top-left corner of the overlay, and the FPS in the top-right. If you also turn on “{0}”, every result is saved to a CSV file. It's useful when you want to check that the system is working properly.", + "help.notes.title": "Good to know", + "help.note.direction_surround": "With 5.1 or 7.1 channel output, the top of the screen shows sound from the front and the bottom shows sound from behind. The left and right sides show sound from the left and right, and graphics coming from every side at once are ambient or non-directional sounds.", + "help.note.direction_stereo": "With 2-channel (stereo) output, it can tell left from right, but not front from back. Graphics at the top and bottom of the screen then show sounds heard equally on both sides and a sense of space.", + "help.note.sources": "It shows the sound of every app that plays through the default playback device in Windows. Sound that an app sends to another device, such as a headset used only for voice chat, isn't shown.", + "help.note.volume": "If an app's volume is turned down very low or muted, in the app itself or in the Windows volume mixer, its sound may barely show or not show at all. If the graphics don't respond well, turn that sound up a little or raise Sensitivity in the Settings tab.", + "help.note.ai": "AI classification can be wrong. Don't rely on this app as your only safeguard when safety is at stake.", + "help.faq.title": "FAQ", + "help.faq.stopped_q": "It turned off without me stopping it.", + "help.faq.stopped_a": "It turns off when sound capture stops and doesn't recover within a few seconds, for example when the playback device is disconnected and no other device is available. Home then shows why. Connect speakers or headphones, or set a default playback device in the Windows sound settings, then start it again.", + "help.faq.no_graphic_q": "I don't see any graphics.", + "help.faq.no_graphic_a": "Check that the sound is playing through the default playback device in Windows and isn't muted, and whether Home shows a notice. If a game runs in exclusive full-screen mode, try its borderless or windowed mode. Also make sure opacity isn't too low in Settings, and that showing the current sound type isn't turned off.", + "help.faq.performance_q": "Does it use a lot of CPU or GPU?", + "help.faq.performance_a": "To make it lighter, lower “{0}” in the Settings tab or turn off “{1}”.", + "help.faq.language_q": "How do I change the app language?", + "help.faq.language_a": "Pick one from the “{0}” list at the top of this window. While the visualizer is running, you can also change it in the overlay settings. “{1}” follows the Windows display language.", + "help.report.title": "Report a problem", + "help.report.desc": "Tell us about a bug, or a sound that was shown as the wrong type. GitHub opens in your browser with the environment info below filled in, so it needs a GitHub account. Without one, send us an email instead. No audio is ever sent.", + "help.report.github": "Report on GitHub", + "help.report.mail": "Report by email", + "help.report.copy": "Copy environment info", + "help.report.copied": "Environment info copied.", + "help.report.copy_failed": "Couldn't copy to the clipboard. Try again.", + "help.report.no_browser": "Couldn't open a browser. Copy the environment info and send it to us instead.", + "help.report.no_mail": "No email app to send with. Copy the environment info and send it another way.", + "help.report.issue_title": "Report from the app", + "help.report.issue_body": "What happened?\n\nWhat were you listening to (app, kind of sound)?\n\nWhat did you expect instead?", + "help.about.title": "About", + "help.about.version": "Version {0}", + "help.about.license": "This app is distributed under the GNU Affero General Public License v3.0 (AGPL-3.0). The sound names and sound groups in the Sounds tab are based on the AudioSet Ontology and are licensed under CC BY-SA 4.0 (see “Open source licenses” below).", + "help.licenses.button": "Open source licenses", + "help.licenses.title": "Open source licenses", + "help.licenses.close": "Close", + + "notice.ai_unavailable": "Sound type detection isn't working, so every sound is shown in the ambient color. Try turning the visualizer off and on again.", + "notice.ai_unavailable_hidden": "Sound type detection isn't working, so every sound counts as ambient, and ambient sounds are turned off, so nothing is shown. Turn on “{0}” in the Settings tab, or try turning the visualizer off and on again.", + "notice.stopped.title": "SoundVisualizer turned off", + "notice.stopped.no_device": "No playback device could be opened, so sound can't be captured. Connect speakers or headphones, or set a default playback device in the Windows sound settings, then start again.", + "notice.stopped.capture_error": "Sound capture stopped because of an error, so the graphics are off.", + "notice.dismiss": "Dismiss notice", + + "tray.open": "Open launcher", + "tray.exit": "Exit", + + "more_info.title": "GitHub repository and contributing", + "more_info.body": "SoundVisualizer is an open-source project, and its source code is public on GitHub. You can take part by reporting bugs, suggesting features, or contributing to development. Click the button below to visit the official GitHub repository! You can also check for and download the latest version on the Releases tab on GitHub.", + "more_info.github_button": "Visit the GitHub repository", + + "dialog.notice_title": "Notice", + "dialog.already_running": "The overlay is already running.", + "dialog.reset_title": "Reset settings", + "dialog.reset_wave": "Reset the Wave mode settings to their defaults?", + "dialog.reset_pad": "Reset the Pad mode settings to their defaults?", + "dialog.reset_circle": "Reset the Circle mode settings to their defaults?", + "dialog.reset_outline": "Reset the Outline mode settings to their defaults?", + "dialog.reset_sound_types": "Reset the sound type settings to their defaults?", + "dialog.reset_all_title": "Reset to defaults", + "dialog.reset_all": "Are you sure you want to reset all settings to their defaults?", + "dialog.link_error": "Couldn't open the link: {0}", + + "editor.title": "Overlay settings", + "editor.drag_tip": "Drag the guidelines on the screen with your mouse to adjust the height limit and center size of the overlay wave in real time!", + "editor.glow": "Glow effect", + "editor.glow_strength": "Glow strength", + "editor.color": "Color", + "editor.hotkeys": "Hotkey settings", + "editor.developer_mode_desc": "Shows what the AI heard (top left) and the FPS (top right)", + "editor.save_close_hint": "key to save and close", + "editor.close_overlay": "Exit overlay", + "editor.guide.limit": "{0} limit", + "editor.guide.size": "{0} size: {1}%", + "editor.guide.base_radius": "Base radius: {0}", + "editor.guide.circle_size": "Circle size: {0}%", + + "hud.visual_mode": "Visual mode: [{0}] {1}", + "hud.sound_mode": "Sound mode: [{0}] {1}", + "hud.overlay_settings": "Overlay settings: [{0}]", + "hud.status.ready": "System ready", + "hud.status.stereo": "PC audio: {0}ch (stereo)\nSource: waiting...", + "hud.status.surround": "PC audio: 8ch (7.1)\nSource: waiting...", + + "color_picker.title": "Pick a color", + "color_picker.hex": "HEX code", + "color_picker.cancel": "Cancel", + "color_picker.apply": "Apply", + "color_picker.area": "Saturation and brightness", + "color_picker.area_value": "Saturation {0}%, brightness {1}%", + "color_picker.hue": "Hue", + "color_picker.keys_help": "Use the arrow keys to change it. Hold Shift to change it in bigger steps.", + "color_picker.presets": "Common colors", + "color_picker.preset.white": "White", + "color_picker.preset.red": "Red", + "color_picker.preset.orange": "Orange", + "color_picker.preset.yellow": "Yellow", + "color_picker.preset.green": "Green", + "color_picker.preset.cyan": "Cyan", + "color_picker.preset.blue": "Blue", + "color_picker.preset.magenta": "Magenta", + + "audio.error.no_device": "No output device was found.", + "audio.error.capture_stopped": "Capture stopped unexpectedly: {0}", + "audio.error.capture_start_failed": "Couldn't start the capture engine: {0}", + + "tutorial.window_title": "Tutorial", + "tutorial.skip": "Skip", + "tutorial.previous": "Previous page", + "tutorial.next": "Next", + "tutorial.done": "Got it", + "tutorial.page_status": "Page {0} of {1}", + "tutorial.pause": "Pause animation", + "tutorial.play": "Play animation", + "tutorial.sound_on": "Turn on sound", + "tutorial.sound_off": "Turn off sound", + "tutorial.sound.title": "See sound on your screen", + "tutorial.sound.body": "Sound from your game or video is drawn along the edges of the screen. The louder it is, the bigger it is drawn. Only sound playing on this PC is shown. Sounds around you are not picked up.", + "tutorial.direction.title": "Shows where sound comes from", + "tutorial.direction.body": "With 2-channel (stereo) output, a sound on the left is drawn bigger on the left, and a sound on the right on the right. With 5.1 or 7.1 channel output, it also tells front from back: sound from the front is drawn at the top of the screen and sound from behind at the bottom.", + "tutorial.types.title": "Each type of sound gets its own color", + "tutorial.types.body": "AI sorts sounds into three types and draws each in its own color. You can change the colors in the Settings tab.", + "tutorial.type.ambient_examples": "Music, rain and everything else", + "tutorial.type.speech_examples": "Voices", + "tutorial.type.danger_examples": "Gunshots, explosions, sirens", + "tutorial.start.title": "How to turn it on and off", + "tutorial.start.hotkeys": "While it's running, press {0} to switch the sound mode, {1} to switch the visual mode and {2} to open the overlay settings." +} diff --git a/SoundVisualizer.Core/Localization/Strings/es.json b/SoundVisualizer.Core/Localization/Strings/es.json new file mode 100644 index 0000000..119fadf --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/es.json @@ -0,0 +1,294 @@ +{ + "common.language": "Idioma", + "common.reset_defaults": "Restablecer valores predeterminados", + "common.minimize": "Minimizar", + "common.close": "Cerrar", + "language.system": "Idioma del sistema", + + "launcher.window_title": "Lanzador de SoundVisualizer", + "launcher.tab.home": "Inicio", + "launcher.tab.settings": "Ajustes", + "launcher.tab.sounds": "Sonidos", + "launcher.tab.help": "Ayuda", + "launcher.tab.more_info": "Más información", + "launcher.home.desc": "Dibuja en tu pantalla los sonidos que no puedes ver.\nDe los juegos al cine, descubre una experiencia nueva.", + "launcher.home.start": "Iniciar", + "launcher.home.stop": "Detener", + "launcher.home.tutorial": "Ver tutorial", + "launcher.status.running": "Estado: en ejecución", + "launcher.status.idle": "Estado: listo", + + "settings.section.mode": "Ajustes de modo", + "settings.visual_mode.title": "Modo visual", + "settings.visual_mode.desc": "Elige la forma básica de los gráficos que se dibujan en la pantalla.", + "settings.mode.wave": "Onda", + "settings.mode.pad": "Pad", + "settings.mode.circle": "Círculo", + "settings.mode.outline": "Contorno", + "settings.sound_mode.title": "Modo de sonido", + "settings.sound_mode.desc": "Elige los canales de sonido que correspondan a tus altavoces.\nCon altavoces de 2 canales, elige “2 canales”. Con altavoces envolventes, elige “5.1 canales” o “7.1 canales”.", + "settings.channels.2": "2 canales", + "settings.channels.5_1": "5.1 canales", + "settings.channels.7_1": "7.1 canales", + "settings.max_fps.title": "Velocidad de fotogramas máx.", + "settings.max_fps.desc": "Ajusta la velocidad de fotogramas máxima a la que se dibujan los gráficos.", + "settings.section.mode_detail": "Ajustes por modo", + "settings.mode_card.wave": "Modo Onda", + "settings.mode_card.pad": "Modo Pad", + "settings.mode_card.circle": "Modo Círculo", + "settings.mode_card.outline": "Modo Contorno", + "settings.size_lock.title": "Bloquear tamaño", + "settings.size_lock.desc": "Mantiene los gráficos con un tamaño fijo y muestra el volumen del sonido mediante la transparencia.", + "settings.fixed_size.wave": "Tamaño de la onda", + "settings.fixed_size.pad": "Tamaño del pad", + "settings.fixed_size.circle": "Tamaño del círculo", + "settings.fixed_size.outline": "Grosor del contorno", + "settings.fixed_size.desc": "Ajusta el tamaño fijo que mantienen los gráficos mientras cambia su transparencia.", + "settings.max_opacity.title": "Opacidad máx.", + "settings.max_opacity.desc": "La opacidad que alcanzan los gráficos con el sonido más fuerte.", + "settings.size.title": "Tamaño", + "settings.size.desc.wave": "Ajusta la altura de la onda y lo voluminosa que se ve en conjunto.", + "settings.size.desc.pad": "Ajusta el grosor y el ancho que alcanzan los pads al reaccionar al sonido.", + "settings.size.desc.circle": "Ajusta cuánto sobresale el ecualizador circular cuando hay sonido.", + "settings.size.desc.outline": "Ajusta la altura de la onda de contorno y lo voluminosa que se ve en conjunto.", + "settings.opacity.title": "Opacidad", + "settings.opacity.desc.wave": "Con valores altos, los gráficos se ven más intensos; con valores bajos, más tenues y transparentes.", + "settings.opacity.desc.pad": "Con valores altos, los gráficos se ven más intensos; con valores bajos, más tenues y transparentes.", + "settings.opacity.desc.circle": "Con valores altos, los gráficos se ven más intensos; con valores bajos, más tenues y transparentes.", + "settings.opacity.desc.outline": "Con valores altos, los gráficos se ven más intensos; con valores bajos, más tenues y transparentes.", + "settings.speed.title": "Velocidad", + "settings.speed.desc.wave": "Ajusta la velocidad con la que la onda fluye y ondula.", + "settings.speed.desc.pad": "Ajusta la rapidez con la que los pads giran hacia la dirección de la que viene un sonido.", + "settings.speed.desc.circle": "Ajusta la rapidez con la que el ecualizador circular ondula y gira en respuesta al sonido.", + "settings.speed.desc.outline": "Ajusta la velocidad con la que la onda de contorno fluye y ondula.", + "settings.sensitivity.title": "Sensibilidad", + "settings.sensitivity.desc.wave": "Ajusta con qué sensibilidad reacciona y se agita la onda, incluso con sonidos suaves.", + "settings.sensitivity.desc.pad": "Ajusta los pads para que capten mejor el sonido y destaquen más.", + "settings.sensitivity.desc.circle": "Ajusta con qué detalle se fragmenta la amplitud del círculo al reaccionar al sonido.", + "settings.sensitivity.desc.outline": "Ajusta con qué sensibilidad reacciona y se agita la onda de contorno, incluso con sonidos suaves.", + "settings.glow.title": "Resplandor", + "settings.glow.desc.wave": "Añade un suave resplandor de neón al borde de la onda y ajusta su intensidad.\nNota: consume recursos adicionales de la GPU.", + "settings.glow.desc.pad": "Añade un resplandor suave, como un aura, alrededor de los pads.\nNota: consume recursos adicionales de la GPU.", + "settings.glow.desc.circle": "Añade un halo reluciente a lo largo del borde del ecualizador circular.\nNota: consume recursos adicionales de la GPU.", + "settings.glow.desc.outline": "Añade un suave resplandor de neón al contorno y ajusta su intensidad.\nNota: consume recursos adicionales de la GPU.", + "settings.radius.title": "Radio", + "settings.radius.desc": "Ajusta el radio del anillo central en el modo Círculo.", + "settings.section.sound_types": "Tipos de sonido (IA)", + "settings.show_ambient": "Sonido ambiente", + "settings.show_speech": "Voz", + "settings.show_danger": "Peligro", + "settings.set_color": "Elegir color", + "settings.set_color_for": "Elegir color: {0}", + "settings.section.hotkeys": "Atajos de teclado", + "settings.hotkey.visual.title": "Cambiar modo visual", + "settings.hotkey.visual.desc": "Atajo que cambia la forma en tiempo real durante la ejecución.", + "settings.hotkey.sound.title": "Cambiar modo de sonido", + "settings.hotkey.sound.desc": "Atajo que cambia el modo de sonido en tiempo real durante la ejecución.", + "settings.hotkey.edit.title": "Ajustes de superposición", + "settings.hotkey.edit.desc": "Atajo que abre o cierra el editor de la superposición.", + "settings.hotkey.set": "Asignar atajo", + "settings.section.advanced": "Ajustes avanzados", + "settings.developer_mode.title": "Modo de desarrollador", + "settings.developer_mode.desc": "Muestra en la esquina superior izquierda de la superposición lo que oyó la IA (el nombre del sonido que indicó, su grado de seguridad y cuánto tardó) y, en la esquina superior derecha, los FPS. La información aparece en inglés y no está pensada para el uso diario.", + "settings.developer_record.title": "Guardar los resultados en un archivo", + "settings.developer_record.desc": "Guarda cada resultado de la IA (los nombres de sonido que puntuó más alto, su grado de seguridad y los tiempos) en un archivo CSV nuevo dentro de la carpeta ai-log, junto a SoundVisualizer.exe, para que puedas revisar después lo que oyó. Si no se puede escribir en esa carpeta, el archivo se guarda en %LOCALAPPDATA%\\SoundVisualizer\\ai-log. Los archivos pueden ocupar mucho, así que desactívalo cuando termines.", + "settings.auto_minimize.title": "Minimizar el lanzador al iniciar", + "settings.auto_minimize.desc": "Minimiza automáticamente esta ventana del lanzador cuando se inicia la superposición.", + + "sounds.title": "Elige los tipos de sonido", + "sounds.desc": "Los sonidos que reconoce la IA están agrupados con otros parecidos, según el tipo que tienen por defecto. Si cambias el tipo de un grupo, el color de sus sonidos cambia al instante.", + "sounds.sibling_note": "La IA suele reconocer un mismo sonido con varios nombres a la vez. Por ejemplo, una sirena también se reconoce como “Alarma”, que está en otro grupo. Si después de un cambio un sonido se sigue mostrando con su tipo anterior, cambia también los grupos o sonidos relacionados.", + "sounds.advanced.title": "Modo avanzado", + "sounds.advanced.desc": "Muestra cada sonido dentro de su grupo para que puedas cambiarlos uno a uno.", + "sounds.search.hint": "Buscar sonidos", + "sounds.search.clear": "Borrar búsqueda", + "sounds.filter.title": "Filtro", + "sounds.filter.all": "Todos", + "sounds.filter.changed": "Cambiados", + "sounds.type.ambient": "Sonido ambiente", + "sounds.type.speech": "Voz", + "sounds.type.danger": "Peligro", + "sounds.type.default": "{0} (por defecto)", + "sounds.type.mixed": "Mixto", + "sounds.changed_count": "Sonidos cambiados: {0}", + "sounds.changed_from": "Por defecto: {0}", + "sounds.reset.button": "Restablecer todo", + "sounds.reset.title": "¿Restablecer todos los sonidos?", + "sounds.reset.message": "Todos los sonidos que hayas cambiado volverán a su tipo por defecto.", + "sounds.reset.confirm": "Restablecer", + "sounds.reset.cancel": "Cancelar", + "sounds.empty": "No hay sonidos que coincidan.", + "sounds.group.mixed_note": "Algunos sonidos cambiados uno a uno", + "sounds.group.changed_line": "Ahora: {0} · Por defecto: {1}", + "sounds.overwrite.title": "¿Cambiar todo el grupo?", + "sounds.overwrite.message": "Sonidos del grupo “{0}” que pasarán a “{2}”: {1}. También se sobrescribirán los que cambiaste uno a uno.", + "sounds.overwrite.confirm": "Cambiar", + "sounds.overwrite.cancel": "Cancelar", + "sounds.basic_warning.title": "¿Cambiar al modo básico?", + "sounds.basic_warning.message": "Algunos grupos tienen sonidos que cambiaste uno a uno. En el modo básico, si eliges un tipo para uno de esos grupos, todos sus sonidos pasan a ese tipo y se sobrescriben los cambios que hiciste uno a uno en ese grupo. No cambia nada hasta que elijas.", + "sounds.basic_warning.confirm": "Cambiar a básico", + "sounds.basic_warning.cancel": "Seguir en avanzado", + "sounds.cd.sound": "{0}, tipo: {1}", + "sounds.cd.sound_changed": "{0}, tipo: {1}, por defecto: {2}", + "sounds.cd.group": "Grupo {0}, tipo: {1}, sonidos: {2} ({3})", + "sounds.cd.group_changed": "Grupo {0}, tipo: {1}, por defecto: {2}, sonidos: {3} ({4})", + "sounds.cd.group_header": "Grupo {0}, sonidos: {1}", + + "hotkey.prompt": "Presiona una tecla (Esc para cancelar)", + "hotkey.none": "Ninguno", + "hotkey.button_name": "{0}, atajo: {1}", + + "help.start.title": "Primeros pasos", + "help.start.1": "Elige el idioma en la parte superior de esta ventana y, si quieres, cambia el aspecto de los gráficos en la pestaña Ajustes.", + "help.start.2": "En la pestaña Inicio, haz clic en “{0}”.", + "help.start.3": "Los gráficos se dibujan en los bordes de la pantalla, por encima de otras ventanas, como juegos y videos. Los clics atraviesan los gráficos, así que puedes seguir usando la ventana que está debajo.", + "help.start.4": "Para apagarlo, haz clic en “{0}” en la pestaña Inicio, o haz clic con el botón derecho en el icono de SoundVisualizer del área de notificación de la barra de tareas y elige “{0}”.", + "help.modes.title": "Modos visuales", + "help.mode.wave": "Todo el borde de la pantalla avanza hacia dentro según el volumen. El lado del que viene el sonido avanza más.", + "help.mode.pad": "Se iluminan barras en 8 puntos del borde de la pantalla. Es el modo que muestra la dirección con más claridad.", + "help.mode.circle": "Un anillo en el centro de la pantalla se expande hacia el lado del que viene el sonido.", + "help.mode.outline": "Dibuja solo un contorno fino de la onda, así que tapa menos la pantalla del juego.", + "help.modes.note": "Opciones como Tamaño y Opacidad se guardan por separado para cada modo y puedes cambiarlas en la pestaña Ajustes.", + "help.sound.title": "Tipos de sonido", + "help.sound.kinds": "La IA clasifica el audio en sonido ambiente, voz y peligro. De forma predeterminada, el sonido ambiente se muestra en blanco, la voz en amarillo y el peligro en rojo. En la pestaña Ajustes puedes cambiar el color de cada tipo y elegir si se muestra o no.", + "help.sound.groups": "En la pestaña Sonidos, los sonidos parecidos están agrupados y eliges de qué tipo es cada grupo. Por ejemplo, si cambias el grupo “Timbre de la puerta” a Peligro, cuando la IA reconozca un timbre de la puerta o golpes en la puerta, el sonido tendrá el color de peligro. Eso sí, un sonido que tú cambias a Peligro solo participa en la votación de la IA sobre el tipo, así que no significa que cada timbre de la puerta se vaya a mostrar como peligro.", + "help.sound.advanced": "Activa el modo avanzado para cambiar los sonidos uno a uno.", + "help.sound.danger": "Cuando se detecta un sonido de peligro (como un disparo), el color cambia al instante en lugar de hacerlo poco a poco.", + "help.pc.title": "Ajustes de PC y atajos de teclado", + "help.sound_mode.title": "Modo de sonido", + "help.sound_mode.body": "Para distinguir también los sonidos de delante y de detrás, además de los de la izquierda y la derecha, configura el dispositivo de salida como “Envolvente 5.1” (6 canales) o “Envolvente 7.1” (8 canales) en la configuración de sonido de Windows. Con un estéreo normal (2 canales), la app distingue la izquierda de la derecha, pero no lo que está delante de lo que está detrás. El “Modo de sonido” de la pestaña Ajustes se adapta al dispositivo de salida al iniciar y cuando cambia el dispositivo, y también puedes cambiarlo tú.", + "help.sound_mode.tip": "💡 Si no tienes un dispositivo de salida 7.1, puedes instalar el {0} y configurar el dispositivo de salida en 7.1 canales para mostrar la superposición con sonido envolvente 7.1.", + "help.sound_mode.tip_link": "controlador virtual VB-CABLE", + "help.hotkeys.title": "Atajos personalizados en tiempo real", + "help.hotkeys.body": "En la pestaña Ajustes, haz clic en un botón de atajo y presiona una tecla o una combinación de teclas (p. ej., Ctrl + Shift + A) para cambiar el atajo a tu gusto. Al presionar el atajo, el modo cambia al instante, incluso con la app en segundo plano.", + "help.hotkeys.defaults": "De forma predeterminada, F2 cambia el modo de sonido, F3 cambia el modo visual y F4 abre los ajustes de superposición.", + "help.size_lock.title": "Modo de tamaño bloqueado", + "help.size_lock.body": "El modo de tamaño bloqueado mantiene los gráficos con un tamaño fijo y usa una transparencia proporcional al volumen del sonido. Es útil si quieres que los gráficos conserven siempre el mismo tamaño y aun así ver cómo reaccionan al sonido.", + "help.editor.title": "Modo de edición de la superposición en tiempo real", + "help.editor.body": "Presiona el atajo asignado (F4 de forma predeterminada) para activar el modo de edición de la superposición. Puedes arrastrar sobre la pantalla para ajustar el límite de tamaño de los gráficos y afinar los detalles en el panel de ajustes que aparece.", + "help.developer.title": "Modo de desarrollador", + "help.developer.body": "Al activar el modo de desarrollador, la superposición muestra arriba a la izquierda lo que oyó la IA (el nombre del sonido, su grado de seguridad y cuánto tardó) y arriba a la derecha los FPS. Si además activas “{0}”, cada resultado se guarda en un archivo CSV. Es útil para comprobar que el sistema funciona correctamente.", + "help.notes.title": "Ten en cuenta", + "help.note.direction_surround": "Con salida de 5.1 o 7.1 canales, la parte superior de la pantalla muestra el sonido que viene de delante, y la inferior, el que viene de detrás. Los lados izquierdo y derecho muestran el sonido que viene de la izquierda y de la derecha, y los gráficos que aparecen a la vez desde todos los lados de la pantalla corresponden a sonido ambiente o a sonidos sin dirección.", + "help.note.direction_stereo": "Con salida de 2 canales (estéreo), la app distingue la izquierda de la derecha, pero no lo que está delante de lo que está detrás. En ese caso, los gráficos de las partes superior e inferior de la pantalla representan los sonidos que se oyen por igual en ambos lados y la sensación de espacio.", + "help.note.sources": "Se muestra el sonido de todas las apps que se reproducen por el dispositivo de reproducción predeterminado de Windows. No se muestra el sonido que una app envía a otro dispositivo, como unos auriculares que solo usas para el chat de voz.", + "help.note.volume": "Si el volumen de una app está muy bajo o silenciado, en la propia app o en el mezclador de volumen de Windows, su sonido puede apenas mostrarse o no mostrarse en absoluto. Si los gráficos no responden bien, sube un poco ese sonido o aumenta la Sensibilidad en la pestaña Ajustes.", + "help.note.ai": "La IA puede equivocarse al clasificar los sonidos. No confíes solo en esta app cuando tu seguridad esté en juego.", + "help.faq.title": "Preguntas frecuentes", + "help.faq.stopped_q": "Se apagó sin que yo lo detuviera.", + "help.faq.stopped_a": "Se apaga cuando la captura de sonido se detiene y no se recupera en unos segundos, por ejemplo, cuando se desconecta el dispositivo de reproducción y no hay otro disponible. En ese caso, la pestaña Inicio te indica el motivo. Conecta altavoces o auriculares, o establece un dispositivo de reproducción predeterminado en la configuración de sonido de Windows, y vuelve a iniciarlo.", + "help.faq.no_graphic_q": "No veo ningún gráfico.", + "help.faq.no_graphic_a": "Comprueba que el sonido se reproduce por el dispositivo de reproducción predeterminado de Windows y no está silenciado, y si la pestaña Inicio muestra algún aviso. Si un juego se ejecuta en pantalla completa exclusiva, prueba su modo sin bordes o en ventana. En la pestaña Ajustes, asegúrate también de que la Opacidad no esté demasiado baja y de que no esté desactivada la visualización del tipo de sonido que se está reproduciendo.", + "help.faq.performance_q": "¿Usa mucha CPU o GPU?", + "help.faq.performance_a": "Para que consuma menos, baja “{0}” en la pestaña Ajustes o desactiva “{1}”.", + "help.faq.language_q": "¿Cómo cambio el idioma de la app?", + "help.faq.language_a": "Elige uno en la lista “{0}” de la parte superior de esta ventana. Mientras se ejecuta, también puedes cambiarlo en los ajustes de superposición. Con “{1}”, la app usa el idioma de visualización de Windows.", + "help.report.title": "Informar de un problema", + "help.report.desc": "Avísanos de un error o de un sonido que se mostró con el tipo de sonido equivocado. GitHub se abre en tu navegador con la información del sistema de abajo ya completada, así que necesitas una cuenta de GitHub. Si no tienes una, envíanos un correo electrónico en su lugar. Nunca se envía ningún audio.", + "help.report.github": "Informar en GitHub", + "help.report.mail": "Informar por correo", + "help.report.copy": "Copiar información del sistema", + "help.report.copied": "Información del sistema copiada.", + "help.report.copy_failed": "No se pudo copiar al portapapeles. Inténtalo de nuevo.", + "help.report.no_browser": "No se pudo abrir un navegador. Copia la información del sistema y envíanosla de otra forma.", + "help.report.no_mail": "No hay ninguna app de correo para enviar el informe. Copia la información del sistema y envíala de otra forma.", + "help.report.issue_title": "Informe desde la app", + "help.report.issue_body": "¿Qué pasó?\n\n¿Qué estabas escuchando (app, tipo de sonido)?\n\n¿Qué esperabas que pasara en su lugar?", + "help.about.title": "Acerca de", + "help.about.version": "Versión {0}", + "help.about.license": "Esta app se distribuye bajo la GNU Affero General Public License v3.0 (AGPL-3.0). Los nombres y los grupos de sonidos de la pestaña Sonidos se basan en la ontología de AudioSet y se distribuyen bajo la licencia CC BY-SA 4.0 (consulta “Licencias de código abierto” más abajo).", + "help.licenses.button": "Licencias de código abierto", + "help.licenses.title": "Licencias de código abierto", + "help.licenses.close": "Cerrar", + + "notice.ai_unavailable": "La detección del tipo de sonido no funciona, así que todos los sonidos se muestran en el color del sonido ambiente. Prueba a apagar y volver a encender la visualización.", + "notice.ai_unavailable_hidden": "La detección del tipo de sonido no funciona, así que todos los sonidos cuentan como sonido ambiente, y el sonido ambiente está oculto, por lo que no se muestra nada. Activa “{0}” en la pestaña Ajustes o prueba a apagar y volver a encender la visualización.", + "notice.stopped.title": "El visor de sonido se apagó", + "notice.stopped.no_device": "No se pudo abrir ningún dispositivo de reproducción, así que no se puede capturar el sonido. Conecta altavoces o auriculares, o establece un dispositivo de reproducción predeterminado en la configuración de sonido de Windows, y vuelve a iniciarlo.", + "notice.stopped.capture_error": "La captura de sonido se detuvo por un error, así que los gráficos están desactivados.", + "notice.dismiss": "Cerrar aviso", + + "tray.open": "Abrir el lanzador", + "tray.exit": "Salir", + + "more_info.title": "Repositorio de GitHub y cómo contribuir", + "more_info.body": "SoundVisualizer es un proyecto de código abierto y su código fuente es público en GitHub. Puedes participar informando de errores, sugiriendo funciones o contribuyendo al desarrollo. ¡Haz clic en el botón de abajo para visitar el repositorio oficial de GitHub! También puedes buscar y descargar la versión más reciente en la pestaña Releases de GitHub.", + "more_info.github_button": "Visitar el repositorio de GitHub", + + "dialog.notice_title": "Aviso", + "dialog.already_running": "La superposición ya se está ejecutando.", + "dialog.reset_title": "Restablecer ajustes", + "dialog.reset_wave": "¿Restablecer los ajustes del modo Onda a sus valores predeterminados?", + "dialog.reset_pad": "¿Restablecer los ajustes del modo Pad a sus valores predeterminados?", + "dialog.reset_circle": "¿Restablecer los ajustes del modo Círculo a sus valores predeterminados?", + "dialog.reset_outline": "¿Restablecer los ajustes del modo Contorno a sus valores predeterminados?", + "dialog.reset_sound_types": "¿Restablecer los ajustes de los tipos de sonido a sus valores predeterminados?", + "dialog.reset_all_title": "Restablecer valores predeterminados", + "dialog.reset_all": "¿Seguro que quieres restablecer todos los ajustes a sus valores predeterminados?", + "dialog.link_error": "No se pudo abrir el enlace: {0}", + + "editor.title": "Ajustes de superposición", + "editor.drag_tip": "¡Arrastra las guías de la pantalla para ajustar en tiempo real el límite de altura y el tamaño del centro de la onda de la superposición!", + "editor.glow": "Efecto de resplandor", + "editor.glow_strength": "Intensidad", + "editor.color": "Color", + "editor.hotkeys": "Ajustes de atajos", + "editor.developer_mode_desc": "Lo que oyó la IA (arriba a la izquierda) y los FPS (arriba a la derecha)", + "editor.save_close_hint": "para guardar y cerrar", + "editor.close_overlay": "Cerrar la superposición", + "editor.guide.limit": "Límite en modo {0}", + "editor.guide.size": "Tamaño en modo {0}: {1} %", + "editor.guide.base_radius": "Radio base: {0}", + "editor.guide.circle_size": "Tamaño del círculo: {0} %", + + "hud.visual_mode": "Modo visual: [{0}] {1}", + "hud.sound_mode": "Modo de sonido: [{0}] {1}", + "hud.overlay_settings": "Ajustes de superposición: [{0}]", + "hud.status.ready": "Sistema listo", + "hud.status.stereo": "Audio del PC: {0}ch (estéreo)\nFuente: esperando...", + "hud.status.surround": "Audio del PC: 8ch (7.1)\nFuente: esperando...", + + "color_picker.title": "Elige un color", + "color_picker.hex": "Código HEX", + "color_picker.cancel": "Cancelar", + "color_picker.apply": "Aplicar", + "color_picker.area": "Saturación y brillo", + "color_picker.area_value": "Saturación {0} %, brillo {1} %", + "color_picker.hue": "Tono", + "color_picker.keys_help": "Usa las teclas de flecha para cambiarlo. Mantén presionada la tecla Shift para avanzar en pasos más grandes.", + "color_picker.presets": "Colores habituales", + "color_picker.preset.white": "Blanco", + "color_picker.preset.red": "Rojo", + "color_picker.preset.orange": "Naranja", + "color_picker.preset.yellow": "Amarillo", + "color_picker.preset.green": "Verde", + "color_picker.preset.cyan": "Turquesa", + "color_picker.preset.blue": "Azul", + "color_picker.preset.magenta": "Magenta", + + "audio.error.no_device": "No se encontró ningún dispositivo de salida.", + "audio.error.capture_stopped": "La captura se detuvo inesperadamente: {0}", + "audio.error.capture_start_failed": "No se pudo iniciar el motor de captura: {0}", + + "tutorial.window_title": "Tutorial", + "tutorial.skip": "Omitir", + "tutorial.previous": "Página anterior", + "tutorial.next": "Siguiente", + "tutorial.done": "Entendido", + "tutorial.page_status": "Página {0} de {1}", + "tutorial.pause": "Pausar animación", + "tutorial.play": "Reproducir animación", + "tutorial.sound_on": "Activar sonido", + "tutorial.sound_off": "Desactivar sonido", + "tutorial.sound.title": "Mira el sonido en tu pantalla", + "tutorial.sound.body": "El sonido de tu juego o video se dibuja en los bordes de la pantalla. Cuanto más fuerte es, más grande se dibuja. Solo se muestra el sonido que se reproduce en este PC. Los sonidos de tu entorno no se captan.", + "tutorial.direction.title": "Muestra de dónde viene el sonido", + "tutorial.direction.body": "Con salida de 2 canales (estéreo), un sonido que viene de la izquierda se dibuja más grande a la izquierda, y uno que viene de la derecha, a la derecha. Con salida de 5.1 o 7.1 canales, también distingue entre delante y detrás: el sonido que viene de delante se dibuja en la parte superior de la pantalla, y el que viene de detrás, en la inferior.", + "tutorial.types.title": "Cada tipo de sonido tiene su color", + "tutorial.types.body": "La IA clasifica los sonidos en tres tipos y dibuja cada uno con su propio color. Puedes cambiar los colores en la pestaña Ajustes.", + "tutorial.type.ambient_examples": "Música, lluvia y todo lo demás", + "tutorial.type.speech_examples": "Personas que hablan", + "tutorial.type.danger_examples": "Disparos, explosiones, sirenas", + "tutorial.start.title": "Cómo encenderlo y apagarlo", + "tutorial.start.hotkeys": "Mientras se ejecuta, pulsa {0} para cambiar el modo de sonido, {1} para cambiar el modo visual y {2} para abrir los ajustes de superposición." +} diff --git a/SoundVisualizer.Core/Localization/Strings/fr.json b/SoundVisualizer.Core/Localization/Strings/fr.json new file mode 100644 index 0000000..0f40142 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/fr.json @@ -0,0 +1,294 @@ +{ + "common.language": "Langue", + "common.reset_defaults": "Restaurer les valeurs par défaut", + "common.minimize": "Réduire", + "common.close": "Fermer", + "language.system": "Langue du système", + + "launcher.window_title": "Lanceur SoundVisualizer", + "launcher.tab.home": "Accueil", + "launcher.tab.settings": "Paramètres", + "launcher.tab.sounds": "Sons", + "launcher.tab.help": "Aide", + "launcher.tab.more_info": "En savoir plus", + "launcher.home.desc": "Dessine sur votre écran les sons que vous ne pouvez pas voir.\nDes jeux vidéo aux films, découvrez une toute nouvelle expérience.", + "launcher.home.start": "Démarrer", + "launcher.home.stop": "Arrêter", + "launcher.home.tutorial": "Voir le tutoriel", + "launcher.status.running": "État : actif", + "launcher.status.idle": "État : prêt", + + "settings.section.mode": "Paramètres du mode", + "settings.visual_mode.title": "Mode visuel", + "settings.visual_mode.desc": "Choisissez la forme de base des graphismes dessinés à l'écran.", + "settings.mode.wave": "Vague", + "settings.mode.pad": "Pavé", + "settings.mode.circle": "Cercle", + "settings.mode.outline": "Contour", + "settings.sound_mode.title": "Mode audio", + "settings.sound_mode.desc": "Choisissez les canaux audio adaptés à vos haut-parleurs.\nPour des haut-parleurs à 2 canaux, choisissez « 2 canaux ». Pour des haut-parleurs surround, choisissez « 5.1 canaux » ou « 7.1 canaux ».", + "settings.channels.2": "2 canaux", + "settings.channels.5_1": "5.1 canaux", + "settings.channels.7_1": "7.1 canaux", + "settings.max_fps.title": "Fréquence d'images max", + "settings.max_fps.desc": "Définit la fréquence d'images maximale à laquelle les graphismes sont dessinés.", + "settings.section.mode_detail": "Réglages par mode", + "settings.mode_card.wave": "Mode Vague", + "settings.mode_card.pad": "Mode Pavé", + "settings.mode_card.circle": "Mode Cercle", + "settings.mode_card.outline": "Mode Contour", + "settings.size_lock.title": "Verrouiller la taille", + "settings.size_lock.desc": "Garde les graphismes à une taille fixe et traduit plutôt le volume sonore en transparence.", + "settings.fixed_size.wave": "Taille de la vague", + "settings.fixed_size.pad": "Taille du pavé", + "settings.fixed_size.circle": "Taille du cercle", + "settings.fixed_size.outline": "Épaisseur du trait", + "settings.fixed_size.desc": "Règle la taille fixe que gardent les graphismes pendant que leur transparence varie.", + "settings.max_opacity.title": "Opacité max", + "settings.max_opacity.desc": "Opacité atteinte par les graphismes quand le son est le plus fort.", + "settings.size.title": "Taille", + "settings.size.desc.wave": "Règle la hauteur de la vague et son ampleur générale.", + "settings.size.desc.pad": "Règle l'épaisseur et la largeur des pavés quand ils réagissent au son.", + "settings.size.desc.circle": "Règle à quel point l'égaliseur circulaire gonfle quand il y a du son.", + "settings.size.desc.outline": "Règle la hauteur de la vague de contour et son ampleur générale.", + "settings.opacity.title": "Opacité", + "settings.opacity.desc.wave": "Plus la valeur est élevée, plus les graphismes sont marqués ; plus elle est basse, plus ils sont pâles et transparents.", + "settings.opacity.desc.pad": "Plus la valeur est élevée, plus les graphismes sont marqués ; plus elle est basse, plus ils sont pâles et transparents.", + "settings.opacity.desc.circle": "Plus la valeur est élevée, plus les graphismes sont marqués ; plus elle est basse, plus ils sont pâles et transparents.", + "settings.opacity.desc.outline": "Plus la valeur est élevée, plus les graphismes sont marqués ; plus elle est basse, plus ils sont pâles et transparents.", + "settings.speed.title": "Vitesse", + "settings.speed.desc.wave": "Règle la vitesse à laquelle la vague s'écoule et ondule.", + "settings.speed.desc.pad": "Règle la vitesse à laquelle les pavés pivotent vers la direction d'où vient un son.", + "settings.speed.desc.circle": "Règle la vitesse à laquelle l'égaliseur circulaire ondule et tourne en réaction au son.", + "settings.speed.desc.outline": "Règle la vitesse à laquelle la vague de contour s'écoule et ondule.", + "settings.sensitivity.title": "Sensibilité", + "settings.sensitivity.desc.wave": "Règle la sensibilité de la vague, qui réagit et s'agite même aux sons faibles.", + "settings.sensitivity.desc.pad": "Règle les pavés pour qu'ils captent mieux le son et ressortent davantage.", + "settings.sensitivity.desc.circle": "Règle la finesse avec laquelle l'amplitude du cercle se fragmente en réagissant au son.", + "settings.sensitivity.desc.outline": "Règle la sensibilité de la vague de contour, qui réagit et s'agite même aux sons faibles.", + "settings.glow.title": "Halo", + "settings.glow.desc.wave": "Ajoute un doux halo néon le long du bord de la vague et en règle l'intensité.\nRemarque : consomme davantage de ressources GPU.", + "settings.glow.desc.pad": "Ajoute un halo doux, comme une aura, autour des pavés.\nRemarque : consomme davantage de ressources GPU.", + "settings.glow.desc.circle": "Ajoute un halo scintillant le long du bord de l'égaliseur circulaire.\nRemarque : consomme davantage de ressources GPU.", + "settings.glow.desc.outline": "Ajoute un doux halo néon au contour et en règle l'intensité.\nRemarque : consomme davantage de ressources GPU.", + "settings.radius.title": "Rayon", + "settings.radius.desc": "Règle le rayon de l'anneau central en mode Cercle.", + "settings.section.sound_types": "Types de sons (IA)", + "settings.show_ambient": "Afficher l'ambiance", + "settings.show_speech": "Afficher les voix", + "settings.show_danger": "Afficher les dangers", + "settings.set_color": "Choisir la couleur", + "settings.set_color_for": "Choisir la couleur : {0}", + "settings.section.hotkeys": "Raccourcis clavier", + "settings.hotkey.visual.title": "Changer de mode visuel", + "settings.hotkey.visual.desc": "Raccourci qui change la forme en temps réel pendant l'exécution.", + "settings.hotkey.sound.title": "Changer de mode audio", + "settings.hotkey.sound.desc": "Raccourci qui change le mode audio en temps réel pendant l'exécution.", + "settings.hotkey.edit.title": "Paramètres de l'overlay", + "settings.hotkey.edit.desc": "Raccourci qui ouvre ou ferme l'éditeur de l'overlay.", + "settings.hotkey.set": "Définir le raccourci", + "settings.section.advanced": "Paramètres avancés", + "settings.developer_mode.title": "Mode développeur", + "settings.developer_mode.desc": "Affiche en haut à gauche de l'overlay ce que l'IA a entendu (le nom du son indiqué, son degré de certitude et le temps de traitement), et les FPS en haut à droite. Ces informations sont en anglais et ne sont pas destinées à un usage quotidien.", + "settings.developer_record.title": "Enregistrer les résultats dans un fichier", + "settings.developer_record.desc": "Enregistre chaque résultat de l'IA (les noms de sons les mieux classés, son degré de certitude et les temps de traitement) dans un nouveau fichier CSV du dossier ai-log, à côté de SoundVisualizer.exe, pour revoir ensuite ce qu'elle a entendu. Si ce dossier n'est pas accessible en écriture, le fichier va dans %LOCALAPPDATA%\\SoundVisualizer\\ai-log. Les fichiers peuvent devenir volumineux. Désactivez l'option une fois terminé.", + "settings.auto_minimize.title": "Réduire le lanceur au démarrage", + "settings.auto_minimize.desc": "Réduit automatiquement la fenêtre du lanceur au démarrage de l'overlay.", + + "sounds.title": "Choisir les types de sons", + "sounds.desc": "Les sons que l'IA reconnaît sont regroupés avec des sons proches, selon le type auquel chacun appartient par défaut. Si vous changez le type d'un groupe, la couleur de ses sons suit aussitôt le nouveau type.", + "sounds.sibling_note": "L'IA reconnaît souvent un même son sous plusieurs noms à la fois. Une sirène, par exemple, est aussi reconnue comme « Alarme », qui se trouve dans un autre groupe. Si un son s'affiche encore avec son ancien type après une modification, modifiez aussi les groupes ou les sons liés.", + "sounds.advanced.title": "Mode avancé", + "sounds.advanced.desc": "Affiche chaque son dans son groupe pour que vous puissiez modifier les sons un par un.", + "sounds.search.hint": "Rechercher un son", + "sounds.search.clear": "Effacer la recherche", + "sounds.filter.title": "Filtre", + "sounds.filter.all": "Tous", + "sounds.filter.changed": "Modifiés", + "sounds.type.ambient": "Ambiance", + "sounds.type.speech": "Voix", + "sounds.type.danger": "Danger", + "sounds.type.default": "{0} (par défaut)", + "sounds.type.mixed": "Mixte", + "sounds.changed_count": "Sons modifiés : {0}", + "sounds.changed_from": "Par défaut : {0}", + "sounds.reset.button": "Tout réinitialiser", + "sounds.reset.title": "Réinitialiser tous les sons ?", + "sounds.reset.message": "Tous les sons que vous avez modifiés reprendront leur type par défaut.", + "sounds.reset.confirm": "Réinitialiser", + "sounds.reset.cancel": "Annuler", + "sounds.empty": "Aucun son ne correspond.", + "sounds.group.mixed_note": "Certains sons modifiés un par un", + "sounds.group.changed_line": "Actuel : {0} · Par défaut : {1}", + "sounds.overwrite.title": "Modifier tout le groupe ?", + "sounds.overwrite.message": "Sons du groupe « {0} » qui passeront en {2} : {1}. Les sons modifiés un par un seront eux aussi remplacés.", + "sounds.overwrite.confirm": "Modifier", + "sounds.overwrite.cancel": "Annuler", + "sounds.basic_warning.title": "Passer au mode simple ?", + "sounds.basic_warning.message": "Certains groupes contiennent des sons que vous avez modifiés un par un. En mode simple, si vous choisissez un type pour l'un de ces groupes, tous ses sons prennent ce type et vos modifications individuelles dans ce groupe sont remplacées. Rien ne change tant que vous ne choisissez pas.", + "sounds.basic_warning.confirm": "Passer au mode simple", + "sounds.basic_warning.cancel": "Rester en mode avancé", + "sounds.cd.sound": "{0}, type : {1}", + "sounds.cd.sound_changed": "{0}, type : {1}, par défaut : {2}", + "sounds.cd.group": "Groupe {0}, type : {1}, sons : {2} ({3})", + "sounds.cd.group_changed": "Groupe {0}, type : {1}, par défaut : {2}, sons : {3} ({4})", + "sounds.cd.group_header": "Groupe {0}, sons : {1}", + + "hotkey.prompt": "Appuyez sur une touche (Esc pour annuler)", + "hotkey.none": "Aucun", + "hotkey.button_name": "{0}, raccourci : {1}", + + "help.start.title": "Pour commencer", + "help.start.1": "Choisissez la langue en haut de cette fenêtre et, si vous le souhaitez, modifiez l'apparence des graphismes dans l'onglet Paramètres.", + "help.start.2": "Dans l'onglet Accueil, cliquez sur « {0} ».", + "help.start.3": "Les graphismes s'affichent le long des bords de l'écran, par-dessus les autres fenêtres comme les jeux et les vidéos. Les clics les traversent : vous pouvez donc continuer à utiliser la fenêtre en dessous.", + "help.start.4": "Pour désactiver la visualisation, cliquez sur « {0} » dans l'onglet Accueil, ou faites un clic droit sur l'icône SoundVisualizer dans la zone de notification de la barre des tâches et choisissez « {0} ».", + "help.modes.title": "Modes visuels", + "help.mode.wave": "Tous les bords de l'écran se resserrent vers l'intérieur en fonction du volume sonore, et davantage du côté d'où vient le son.", + "help.mode.pad": "Des barres s'allument à 8 endroits sur le bord de l'écran. C'est le mode qui montre la direction le plus clairement.", + "help.mode.circle": "Un anneau au centre de l'écran gonfle du côté d'où vient le son.", + "help.mode.outline": "Dessine uniquement un fin contour de la vague, pour moins masquer votre jeu.", + "help.modes.note": "Les réglages comme « Taille » et « Opacité » sont enregistrés séparément pour chaque mode et peuvent être modifiés dans l'onglet Paramètres.", + "help.sound.title": "Types de sons", + "help.sound.kinds": "L'IA classe les sons entendus en trois types : ambiance, voix et danger. Par défaut, l'ambiance s'affiche en blanc, les voix en jaune et les dangers en rouge. Dans l'onglet Paramètres, vous pouvez changer la couleur de chaque type et choisir de l'afficher ou non.", + "help.sound.groups": "Dans l'onglet Sons, les sons proches sont regroupés et vous choisissez à quel type correspond chaque groupe. Par exemple, si vous passez le groupe « Sonnette » en Danger, quand l'IA reconnaît une sonnette ou des coups à la porte, le son prend la couleur des sons de danger. Toutefois, un son que vous passez vous-même en Danger ne participe qu'au vote de l'IA sur le type, donc chaque sonnette ne sera pas forcément affichée comme un danger.", + "help.sound.advanced": "Activez le mode avancé pour modifier les sons un par un.", + "help.sound.danger": "Lorsqu'un son de danger (comme un coup de feu) est détecté, la couleur change instantanément, sans transition progressive.", + "help.pc.title": "Paramètres PC et raccourcis clavier", + "help.sound_mode.title": "Mode audio", + "help.sound_mode.body": "Pour distinguer aussi l'avant de l'arrière, en plus de la gauche et de la droite, configurez votre périphérique de sortie en « Surround 5.1 » (6 canaux) ou « Surround 7.1 » (8 canaux) dans les paramètres de son de Windows. En stéréo classique (2 canaux), l'application distingue la gauche de la droite, mais pas l'avant de l'arrière. Le « Mode audio » de l'onglet Paramètres s'adapte au périphérique de sortie au démarrage et lorsque le périphérique change, et vous pouvez aussi le modifier vous-même.", + "help.sound_mode.tip": "💡 Si vous n'avez pas de périphérique de sortie 7.1, vous pouvez installer le {0} et configurer le périphérique de sortie en 7.1 canaux pour afficher l'overlay en son surround 7.1.", + "help.sound_mode.tip_link": "pilote virtuel VB-CABLE", + "help.hotkeys.title": "Raccourcis personnalisés en temps réel", + "help.hotkeys.body": "Dans l'onglet Paramètres, cliquez sur un bouton de raccourci, puis appuyez sur une touche ou une combinaison de touches (par ex. Ctrl + Shift + A) pour changer le raccourci à votre guise. Appuyer sur le raccourci change aussitôt de mode, même quand l'application est en arrière-plan.", + "help.hotkeys.defaults": "Par défaut, F2 change le mode audio, F3 change le mode visuel et F4 ouvre les paramètres de l'overlay.", + "help.size_lock.title": "Mode taille verrouillée", + "help.size_lock.body": "Le mode taille verrouillée garde les graphismes à une taille fixe en utilisant une transparence proportionnelle au volume sonore. C'est utile si vous voulez que les graphismes gardent toujours la même taille tout en continuant à les voir réagir au son.", + "help.editor.title": "Mode d'édition de l'overlay en temps réel", + "help.editor.body": "Appuyez sur le raccourci attribué (F4 par défaut) pour activer le mode d'édition de l'overlay. Vous pouvez faire glisser la souris à l'écran pour ajuster la limite de taille des graphismes, puis affiner les détails dans le panneau de paramètres qui s'affiche.", + "help.developer.title": "Mode développeur", + "help.developer.body": "Une fois activé, le mode développeur affiche en haut à gauche de l'overlay ce que l'IA a entendu (le nom du son, son degré de certitude et le temps de traitement) et les FPS en haut à droite. Si vous activez aussi « {0} », chaque résultat est enregistré dans un fichier CSV. C'est utile pour vérifier que le système fonctionne correctement.", + "help.notes.title": "Bon à savoir", + "help.note.direction_surround": "Avec une sortie 5.1 ou 7.1 canaux, le haut de l'écran montre les sons venant de l'avant, et le bas, ceux venant de l'arrière. Les côtés gauche et droit montrent les sons venant de la gauche et de la droite, et les graphismes qui apparaissent en même temps de tous les côtés de l'écran correspondent à des sons d'ambiance ou sans direction.", + "help.note.direction_stereo": "Avec une sortie 2 canaux (stéréo), l'application distingue la gauche de la droite, mais pas l'avant de l'arrière. Les graphismes en haut et en bas de l'écran représentent alors les sons entendus de façon similaire des deux côtés, ainsi que la sensation d'espace.", + "help.note.sources": "L'application affiche le son de toutes les applications diffusé par le périphérique de lecture par défaut de Windows. Le son qu'une application envoie vers un autre périphérique, par exemple un casque réservé au chat vocal, n'est pas affiché.", + "help.note.volume": "Si le volume d'une application est très bas ou coupé, dans l'application elle-même ou dans le mélangeur de volume de Windows, son son peut à peine s'afficher, voire pas du tout. Si les graphismes réagissent mal, montez un peu ce son ou augmentez la Sensibilité dans l'onglet Paramètres.", + "help.note.ai": "L'IA peut se tromper dans la classification des sons. Lorsque votre sécurité est en jeu, ne comptez pas uniquement sur cette application.", + "help.faq.title": "Questions fréquentes", + "help.faq.stopped_q": "La visualisation s'est désactivée sans que je l'aie arrêtée.", + "help.faq.stopped_a": "La visualisation se désactive quand la capture du son s'arrête et ne reprend pas en quelques secondes, par exemple quand le périphérique de lecture est débranché et qu'aucun autre n'est disponible. L'onglet Accueil en indique alors la raison. Branchez des haut-parleurs ou un casque, ou définissez un périphérique de lecture par défaut dans les paramètres de son de Windows, puis redémarrez-la.", + "help.faq.no_graphic_q": "Aucun graphisme ne s'affiche.", + "help.faq.no_graphic_a": "Vérifiez que le son passe par le périphérique de lecture par défaut de Windows et qu'il n'est pas coupé, et regardez si l'onglet Accueil affiche un message. Si un jeu tourne en plein écran exclusif, essayez son mode fenêtré sans bordure ou fenêtré. Dans l'onglet Paramètres, vérifiez aussi que le réglage « Opacité » n'est pas trop faible et que l'affichage du type de son entendu n'est pas désactivé.", + "help.faq.performance_q": "L'application utilise-t-elle beaucoup le processeur ou la carte graphique ?", + "help.faq.performance_a": "Pour l'alléger, baissez « {0} » dans l'onglet Paramètres ou désactivez « {1} ».", + "help.faq.language_q": "Comment changer la langue de l'application ?", + "help.faq.language_a": "Choisissez-la dans la liste « {0} » en haut de cette fenêtre. Pendant la visualisation, vous pouvez aussi la changer dans les paramètres de l'overlay. Avec « {1} », l'application suit la langue d'affichage de Windows.", + "help.report.title": "Signaler un problème", + "help.report.desc": "Signalez-nous un bug, ou un son dont le type affiché était incorrect. GitHub s'ouvre dans votre navigateur avec les informations système ci-dessous déjà renseignées : il vous faut donc un compte GitHub. Si vous n'en avez pas, envoyez-nous plutôt un e-mail. Aucune donnée audio n'est jamais envoyée.", + "help.report.github": "Signaler sur GitHub", + "help.report.mail": "Signaler par e-mail", + "help.report.copy": "Copier les informations système", + "help.report.copied": "Informations système copiées.", + "help.report.copy_failed": "Impossible de copier dans le presse-papiers. Réessayez.", + "help.report.no_browser": "Impossible d'ouvrir un navigateur. Copiez plutôt les informations système et envoyez-les-nous.", + "help.report.no_mail": "Aucune application d'e-mail pour l'envoi. Copiez les informations système et transmettez-les autrement.", + "help.report.issue_title": "Signalement depuis l'application", + "help.report.issue_body": "Que s'est-il passé ?\n\nQu'écoutiez-vous (application, type de son) ?\n\nÀ quoi vous attendiez-vous plutôt ?", + "help.about.title": "À propos", + "help.about.version": "Version {0}", + "help.about.license": "Cette application est distribuée sous la GNU Affero General Public License v3.0 (AGPL-3.0). Les noms et les groupes de sons de l'onglet Sons sont basés sur l'ontologie AudioSet et distribués sous la licence CC BY-SA 4.0 (voir « Licences open source » ci-dessous).", + "help.licenses.button": "Licences open source", + "help.licenses.title": "Licences open source", + "help.licenses.close": "Fermer", + + "notice.ai_unavailable": "La détection du type de son ne fonctionne pas : tous les sons s'affichent dans la couleur de l'ambiance. Essayez de désactiver puis de réactiver la visualisation.", + "notice.ai_unavailable_hidden": "La détection du type de son ne fonctionne pas : tous les sons comptent comme de l'ambiance, mais l'affichage de l'ambiance est désactivé, donc rien ne s'affiche. Activez « {0} » dans l'onglet Paramètres, ou essayez de désactiver puis de réactiver la visualisation.", + "notice.stopped.title": "Visualiseur audio désactivé", + "notice.stopped.no_device": "Aucun périphérique de lecture n'a pu être ouvert, le son ne peut donc pas être capturé. Branchez des haut-parleurs ou un casque, ou définissez un périphérique de lecture par défaut dans les paramètres de son de Windows, puis redémarrez.", + "notice.stopped.capture_error": "La capture du son s'est arrêtée à cause d'une erreur : les graphismes sont désactivés.", + "notice.dismiss": "Masquer le message", + + "tray.open": "Ouvrir le lanceur", + "tray.exit": "Quitter", + + "more_info.title": "Dépôt GitHub et contributions", + "more_info.body": "SoundVisualizer est un projet open source dont le code source est public sur GitHub. Vous pouvez y participer en signalant des bugs, en suggérant des fonctionnalités ou en contribuant au développement. Cliquez sur le bouton ci-dessous pour visiter le dépôt GitHub officiel ! Vous pouvez aussi vérifier et télécharger la dernière version dans l'onglet Releases de GitHub.", + "more_info.github_button": "Visiter le dépôt GitHub", + + "dialog.notice_title": "Information", + "dialog.already_running": "L'overlay est déjà en cours d'exécution.", + "dialog.reset_title": "Réinitialiser les paramètres", + "dialog.reset_wave": "Restaurer les valeurs par défaut du mode Vague ?", + "dialog.reset_pad": "Restaurer les valeurs par défaut du mode Pavé ?", + "dialog.reset_circle": "Restaurer les valeurs par défaut du mode Cercle ?", + "dialog.reset_outline": "Restaurer les valeurs par défaut du mode Contour ?", + "dialog.reset_sound_types": "Restaurer les valeurs par défaut des types de sons ?", + "dialog.reset_all_title": "Restaurer les valeurs par défaut", + "dialog.reset_all": "Voulez-vous vraiment restaurer les valeurs par défaut de tous les paramètres ?", + "dialog.link_error": "Impossible d'ouvrir le lien : {0}", + + "editor.title": "Paramètres de l'overlay", + "editor.drag_tip": "Faites glisser les repères à l'écran avec la souris pour ajuster en temps réel la limite de hauteur et la taille du centre de la vague de l'overlay !", + "editor.glow": "Effet de halo", + "editor.glow_strength": "Intensité", + "editor.color": "Couleur", + "editor.hotkeys": "Paramètres des raccourcis", + "editor.developer_mode_desc": "Ce que l'IA a entendu (en haut à gauche) et les FPS (en haut à droite)", + "editor.save_close_hint": "pour enregistrer et fermer", + "editor.close_overlay": "Quitter l'overlay", + "editor.guide.limit": "Limite en mode {0}", + "editor.guide.size": "Taille en mode {0} : {1} %", + "editor.guide.base_radius": "Rayon de base : {0}", + "editor.guide.circle_size": "Taille du cercle : {0} %", + + "hud.visual_mode": "Mode visuel : [{0}] {1}", + "hud.sound_mode": "Mode audio : [{0}] {1}", + "hud.overlay_settings": "Paramètres de l'overlay : [{0}]", + "hud.status.ready": "Système prêt", + "hud.status.stereo": "Audio du PC : {0}ch (stéréo)\nSource : en attente...", + "hud.status.surround": "Audio du PC : 8ch (7.1)\nSource : en attente...", + + "color_picker.title": "Choisir une couleur", + "color_picker.hex": "Code HEX", + "color_picker.cancel": "Annuler", + "color_picker.apply": "Appliquer", + "color_picker.area": "Saturation et luminosité", + "color_picker.area_value": "Saturation {0} %, luminosité {1} %", + "color_picker.hue": "Teinte", + "color_picker.keys_help": "Utilisez les touches fléchées pour le modifier. Maintenez la touche Maj enfoncée pour avancer par grands pas.", + "color_picker.presets": "Couleurs courantes", + "color_picker.preset.white": "Blanc", + "color_picker.preset.red": "Rouge", + "color_picker.preset.orange": "Orange", + "color_picker.preset.yellow": "Jaune", + "color_picker.preset.green": "Vert", + "color_picker.preset.cyan": "Turquoise", + "color_picker.preset.blue": "Bleu", + "color_picker.preset.magenta": "Magenta", + + "audio.error.no_device": "Aucun périphérique de sortie n'a été trouvé.", + "audio.error.capture_stopped": "La capture s'est arrêtée de manière inattendue : {0}", + "audio.error.capture_start_failed": "Impossible de démarrer le moteur de capture : {0}", + + "tutorial.window_title": "Tutoriel", + "tutorial.skip": "Passer", + "tutorial.previous": "Page précédente", + "tutorial.next": "Suivant", + "tutorial.done": "J'ai compris", + "tutorial.page_status": "Page {0} sur {1}", + "tutorial.pause": "Mettre l'animation en pause", + "tutorial.play": "Lire l'animation", + "tutorial.sound_on": "Activer le son", + "tutorial.sound_off": "Couper le son", + "tutorial.sound.title": "Voir le son sur votre écran", + "tutorial.sound.body": "Le son de votre jeu ou de votre vidéo est dessiné le long des bords de l'écran. Plus le son est fort, plus le dessin est grand. Seul le son diffusé sur ce PC est affiché. Les sons autour de vous ne sont pas captés.", + "tutorial.direction.title": "Indique d'où vient le son", + "tutorial.direction.body": "Avec une sortie 2 canaux (stéréo), un son venant de la gauche est dessiné plus grand à gauche, et un son venant de la droite, à droite. Avec une sortie 5.1 ou 7.1 canaux, l'application distingue aussi l'avant de l'arrière : le son venant de l'avant est dessiné en haut de l'écran, et celui venant de l'arrière, en bas.", + "tutorial.types.title": "Chaque type de son a sa couleur", + "tutorial.types.body": "L'IA classe les sons en trois types et dessine chaque type d'une couleur différente. Vous pouvez changer les couleurs dans l'onglet Paramètres.", + "tutorial.type.ambient_examples": "Musique, pluie et tout le reste", + "tutorial.type.speech_examples": "Personnes qui parlent", + "tutorial.type.danger_examples": "Coups de feu, explosions, sirènes", + "tutorial.start.title": "Activer et désactiver la visualisation", + "tutorial.start.hotkeys": "Pendant la visualisation, appuyez sur {0} pour changer de mode audio, sur {1} pour changer de mode visuel et sur {2} pour ouvrir les paramètres de l'overlay." +} diff --git a/SoundVisualizer.Core/Localization/Strings/hi.json b/SoundVisualizer.Core/Localization/Strings/hi.json new file mode 100644 index 0000000..79765fc --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/hi.json @@ -0,0 +1,294 @@ +{ + "common.language": "भाषा", + "common.reset_defaults": "डिफ़ॉल्ट पर रीसेट करें", + "common.minimize": "छोटा करें", + "common.close": "बंद करें", + "language.system": "सिस्टम डिफ़ॉल्ट", + + "launcher.window_title": "SoundVisualizer लॉन्चर", + "launcher.tab.home": "होम", + "launcher.tab.settings": "सेटिंग", + "launcher.tab.sounds": "आवाज़ें", + "launcher.tab.help": "सहायता", + "launcher.tab.more_info": "और जानें", + "launcher.home.desc": "जो आवाज़ें दिखती नहीं, उन्हें स्क्रीन पर दिखाता है।\nगेमिंग से लेकर फ़िल्मों तक, एक बिल्कुल नया अनुभव पाएं।", + "launcher.home.start": "शुरू करें", + "launcher.home.stop": "बंद करें", + "launcher.home.tutorial": "ट्यूटोरियल देखें", + "launcher.status.running": "स्थिति: चालू है", + "launcher.status.idle": "स्थिति: तैयार", + + "settings.section.mode": "मोड सेटिंग", + "settings.visual_mode.title": "विज़ुअल मोड", + "settings.visual_mode.desc": "स्क्रीन पर बनने वाले ग्राफ़िक्स का बुनियादी आकार चुनें।", + "settings.mode.wave": "लहर", + "settings.mode.pad": "पैड", + "settings.mode.circle": "वृत्त", + "settings.mode.outline": "आउटलाइन", + "settings.sound_mode.title": "साउंड मोड", + "settings.sound_mode.desc": "अपने स्पीकर के हिसाब से साउंड चैनल चुनें।\n2-चैनल स्पीकर के लिए “2 चैनल” चुनें। सराउंड स्पीकर के लिए “5.1 चैनल” या “7.1 चैनल” चुनें।", + "settings.channels.2": "2 चैनल", + "settings.channels.5_1": "5.1 चैनल", + "settings.channels.7_1": "7.1 चैनल", + "settings.max_fps.title": "अधिकतम फ़्रेम रेट", + "settings.max_fps.desc": "ग्राफ़िक्स ज़्यादा से ज़्यादा किस फ़्रेम रेट पर बनें, यह तय करता है।", + "settings.section.mode_detail": "हर मोड की सेटिंग", + "settings.mode_card.wave": "लहर मोड", + "settings.mode_card.pad": "पैड मोड", + "settings.mode_card.circle": "वृत्त मोड", + "settings.mode_card.outline": "आउटलाइन मोड", + "settings.size_lock.title": "साइज़ तय रखें", + "settings.size_lock.desc": "ग्राफ़िक्स का साइज़ एक जैसा रखता है और आवाज़ कितनी तेज़ है, यह पारदर्शिता से दिखाता है।", + "settings.fixed_size.wave": "लहर का साइज़", + "settings.fixed_size.pad": "पैड का साइज़", + "settings.fixed_size.circle": "वृत्त का साइज़", + "settings.fixed_size.outline": "आउटलाइन की मोटाई", + "settings.fixed_size.desc": "वह तय साइज़ सेट करता है, जिस पर ग्राफ़िक्स बने रहते हैं और सिर्फ़ उनकी पारदर्शिता बदलती है।", + "settings.max_opacity.title": "अधिकतम गहरापन", + "settings.max_opacity.desc": "सबसे तेज़ आवाज़ पर ग्राफ़िक्स कितने गहरे दिखेंगे।", + "settings.size.title": "साइज़", + "settings.size.desc.wave": "लहर की ऊंचाई और वह कुल मिलाकर कितनी भरी हुई दिखती है, यह तय करता है।", + "settings.size.desc.pad": "आवाज़ पर प्रतिक्रिया देते समय पैड कितने मोटे और चौड़े हों, यह तय करता है।", + "settings.size.desc.circle": "आवाज़ चलने पर गोल इक्वलाइज़र बाहर की ओर कितना उभरता है, यह तय करता है।", + "settings.size.desc.outline": "आउटलाइन लहर की ऊंचाई और वह कुल मिलाकर कितनी भरी हुई दिखती है, यह तय करता है।", + "settings.opacity.title": "गहरापन", + "settings.opacity.desc.wave": "वैल्यू ज़्यादा होने पर ग्राफ़िक्स गहरे दिखते हैं। कम होने पर हल्के और पारदर्शी दिखते हैं।", + "settings.opacity.desc.pad": "वैल्यू ज़्यादा होने पर ग्राफ़िक्स गहरे दिखते हैं। कम होने पर हल्के और पारदर्शी दिखते हैं।", + "settings.opacity.desc.circle": "वैल्यू ज़्यादा होने पर ग्राफ़िक्स गहरे दिखते हैं। कम होने पर हल्के और पारदर्शी दिखते हैं।", + "settings.opacity.desc.outline": "वैल्यू ज़्यादा होने पर ग्राफ़िक्स गहरे दिखते हैं। कम होने पर हल्के और पारदर्शी दिखते हैं।", + "settings.speed.title": "स्पीड", + "settings.speed.desc.wave": "लहर कितनी तेज़ी से बहती और लहराती है, यह तय करता है।", + "settings.speed.desc.pad": "पैड कितनी तेज़ी से उस दिशा की ओर मुड़ते हैं जहां से आवाज़ आती है, यह तय करता है।", + "settings.speed.desc.circle": "गोल इक्वलाइज़र आवाज़ पर कितनी तेज़ी से लहराता और घूमता है, यह तय करता है।", + "settings.speed.desc.outline": "आउटलाइन लहर कितनी तेज़ी से बहती और लहराती है, यह तय करता है।", + "settings.sensitivity.title": "संवेदनशीलता", + "settings.sensitivity.desc.wave": "धीमी आवाज़ों पर भी लहर कितनी संवेदनशीलता से प्रतिक्रिया देती और उछलती है, यह तय करता है।", + "settings.sensitivity.desc.pad": "पैड को इस तरह बदलता है कि वे आवाज़ को बेहतर पकड़ें और ज़्यादा उभरकर दिखें।", + "settings.sensitivity.desc.circle": "आवाज़ पर प्रतिक्रिया देते समय वृत्त का आयाम कितनी बारीकी से टूटता है, यह तय करता है।", + "settings.sensitivity.desc.outline": "धीमी आवाज़ों पर भी आउटलाइन लहर कितनी संवेदनशीलता से प्रतिक्रिया देती और उछलती है, यह तय करता है।", + "settings.glow.title": "चमक", + "settings.glow.desc.wave": "लहर के किनारे पर हल्की नियॉन चमक जोड़ता है और उसका स्तर तय करता है।\nध्यान दें: इससे GPU के ज़्यादा संसाधन इस्तेमाल होते हैं।", + "settings.glow.desc.pad": "पैड के चारों ओर आभा जैसी हल्की चमक जोड़ता है।\nध्यान दें: इससे GPU के ज़्यादा संसाधन इस्तेमाल होते हैं।", + "settings.glow.desc.circle": "गोल इक्वलाइज़र के किनारे पर झिलमिलाता प्रभामंडल बनाता है।\nध्यान दें: इससे GPU के ज़्यादा संसाधन इस्तेमाल होते हैं।", + "settings.glow.desc.outline": "आउटलाइन पर हल्की नियॉन चमक जोड़ता है और उसका स्तर तय करता है।\nध्यान दें: इससे GPU के ज़्यादा संसाधन इस्तेमाल होते हैं।", + "settings.radius.title": "त्रिज्या", + "settings.radius.desc": "वृत्त मोड में बीच वाले घेरे की त्रिज्या बदलता है।", + "settings.section.sound_types": "आवाज़ के प्रकार (AI)", + "settings.show_ambient": "माहौल की आवाज़ें दिखाएं", + "settings.show_speech": "बातचीत दिखाएं", + "settings.show_danger": "खतरे की आवाज़ें दिखाएं", + "settings.set_color": "रंग सेट करें", + "settings.set_color_for": "रंग सेट करें: {0}", + "settings.section.hotkeys": "शॉर्टकट कुंजियां", + "settings.hotkey.visual.title": "विज़ुअल मोड बदलें", + "settings.hotkey.visual.desc": "चलते समय आकार को तुरंत बदलने वाली शॉर्टकट कुंजी।", + "settings.hotkey.sound.title": "साउंड मोड बदलें", + "settings.hotkey.sound.desc": "चलते समय साउंड मोड को तुरंत बदलने वाली शॉर्टकट कुंजी।", + "settings.hotkey.edit.title": "ओवरले सेटिंग", + "settings.hotkey.edit.desc": "ओवरले एडिटर को खोलने या बंद करने वाली शॉर्टकट कुंजी।", + "settings.hotkey.set": "कुंजी सेट करें", + "settings.section.advanced": "एडवांस सेटिंग", + "settings.developer_mode.title": "डेवलपर मोड", + "settings.developer_mode.desc": "AI ने क्या सुना (उसका बताया ध्वनि नाम, वह कितना पक्का था और कितना समय लगा), यह ओवरले के ऊपरी बाएं कोने में और FPS ऊपरी दाएं कोने में दिखाता है। यह जानकारी अंग्रेज़ी में दिखती है और रोज़मर्रा के इस्तेमाल के लिए नहीं है।", + "settings.developer_record.title": "नतीजे फ़ाइल में सेव करें", + "settings.developer_record.desc": "AI के हर नतीजे (सबसे ऊंची रैंक वाले ध्वनि नाम, वह कितना पक्का था और कितना समय लगा) को SoundVisualizer.exe के पास वाले ai-log फ़ोल्डर में एक नई CSV फ़ाइल में लिखता है, ताकि आप बाद में देख सकें कि उसने क्या सुना। अगर उस फ़ोल्डर में लिखा नहीं जा सकता, तो फ़ाइल %LOCALAPPDATA%\\SoundVisualizer\\ai-log में सेव होती है। फ़ाइलें बड़ी हो सकती हैं, इसलिए काम पूरा होने पर इसे बंद कर दें।", + "settings.auto_minimize.title": "शुरू करने पर लॉन्चर छोटा करें", + "settings.auto_minimize.desc": "ओवरले शुरू होने पर इस लॉन्चर विंडो को अपने आप छोटा कर देता है।", + + "sounds.title": "आवाज़ों के प्रकार चुनें", + "sounds.desc": "AI जिन आवाज़ों को पहचानता है, वे मिलती-जुलती आवाज़ों के साथ समूहों में रखी गई हैं और अपने डिफ़ॉल्ट प्रकार के हिसाब से दिखती हैं। किसी समूह का प्रकार बदलने पर, उसकी आवाज़ों का रंग तुरंत नए प्रकार के हिसाब से बदल जाता है।", + "sounds.sibling_note": "AI अक्सर एक ही आवाज़ को एक साथ कई नामों से पहचानता है। जैसे, सायरन “अलार्म” के रूप में भी पहचाना जाता है, जो किसी दूसरे समूह में है। बदलाव के बाद भी अगर कोई आवाज़ पुराने प्रकार में दिखे, तो उससे जुड़े समूह या आवाज़ें भी बदलें।", + "sounds.advanced.title": "एडवांस मोड", + "sounds.advanced.desc": "हर आवाज़ को उसके समूह में दिखाता है, ताकि आप आवाज़ें एक-एक करके बदल सकें।", + "sounds.search.hint": "आवाज़ें खोजें", + "sounds.search.clear": "खोज मिटाएं", + "sounds.filter.title": "फ़िल्टर", + "sounds.filter.all": "सभी", + "sounds.filter.changed": "बदली गई", + "sounds.type.ambient": "माहौल की आवाज़ें", + "sounds.type.speech": "बातचीत", + "sounds.type.danger": "खतरे की आवाज़ें", + "sounds.type.default": "{0} (डिफ़ॉल्ट)", + "sounds.type.mixed": "मिला-जुला", + "sounds.changed_count": "बदली गई आवाज़ें: {0}", + "sounds.changed_from": "डिफ़ॉल्ट: {0}", + "sounds.reset.button": "सभी रीसेट करें", + "sounds.reset.title": "सभी आवाज़ें रीसेट करें?", + "sounds.reset.message": "आपने जिन आवाज़ों का प्रकार बदला है, वे सभी वापस अपने डिफ़ॉल्ट प्रकार पर आ जाएंगी।", + "sounds.reset.confirm": "रीसेट करें", + "sounds.reset.cancel": "रद्द करें", + "sounds.empty": "मेल खाने वाली कोई आवाज़ नहीं मिली।", + "sounds.group.mixed_note": "कुछ आवाज़ें एक-एक करके बदली गईं", + "sounds.group.changed_line": "अभी: {0} · डिफ़ॉल्ट: {1}", + "sounds.overwrite.title": "पूरा समूह बदलें?", + "sounds.overwrite.message": "“{0}” समूह में “{2}” प्रकार में बदलने वाली आवाज़ें: {1}। एक-एक करके बदली गई आवाज़ों का प्रकार भी बदल जाएगा।", + "sounds.overwrite.confirm": "बदलें", + "sounds.overwrite.cancel": "रद्द करें", + "sounds.basic_warning.title": "बेसिक मोड पर जाएं?", + "sounds.basic_warning.message": "कुछ समूहों में ऐसी आवाज़ें हैं जिन्हें आपने एक-एक करके बदला है। बेसिक मोड में ऐसे किसी समूह का प्रकार चुनने पर, उसकी सभी आवाज़ों को वही प्रकार मिल जाता है और उस समूह में एक-एक करके किए गए आपके बदलाव मिट जाते हैं। जब तक आप कुछ नहीं चुनते, कुछ नहीं बदलता।", + "sounds.basic_warning.confirm": "बेसिक मोड पर जाएं", + "sounds.basic_warning.cancel": "एडवांस मोड में रहें", + "sounds.cd.sound": "{0}, प्रकार: {1}", + "sounds.cd.sound_changed": "{0}, प्रकार: {1}, डिफ़ॉल्ट: {2}", + "sounds.cd.group": "{0} समूह, प्रकार: {1}, आवाज़ें: {2} ({3})", + "sounds.cd.group_changed": "{0} समूह, प्रकार: {1}, डिफ़ॉल्ट: {2}, आवाज़ें: {3} ({4})", + "sounds.cd.group_header": "{0} समूह, आवाज़ें: {1}", + + "hotkey.prompt": "कोई कुंजी दबाएं (रद्द करने के लिए Esc)", + "hotkey.none": "कोई नहीं", + "hotkey.button_name": "{0}, शॉर्टकट कुंजी: {1}", + + "help.start.title": "शुरुआत कैसे करें", + "help.start.1": "इस विंडो में ऊपर भाषा चुनें, और चाहें तो “सेटिंग” टैब में ग्राफ़िक्स का रूप बदलें।", + "help.start.2": "“होम” टैब में “{0}” पर क्लिक करें।", + "help.start.3": "ग्राफ़िक्स स्क्रीन के किनारों पर, गेम और वीडियो जैसी दूसरी विंडो के ऊपर बनते हैं। माउस क्लिक ग्राफ़िक्स के आर-पार चले जाते हैं, इसलिए आप नीचे वाली विंडो का इस्तेमाल करते रह सकते हैं।", + "help.start.4": "बंद करने के लिए, “होम” टैब में “{0}” पर क्लिक करें, या टास्कबार के सूचना क्षेत्र में SoundVisualizer आइकन पर राइट-क्लिक करके “{0}” चुनें।", + "help.modes.title": "विज़ुअल मोड", + "help.mode.wave": "आवाज़ जितनी तेज़ होती है, स्क्रीन का पूरा किनारा उतना ही अंदर की ओर बढ़ता है। जिस तरफ़ से आवाज़ आती है, वह हिस्सा और ज़्यादा अंदर तक आता है।", + "help.mode.pad": "स्क्रीन के किनारों पर 8 जगह पट्टियां चमकती हैं। इसमें दिशा सबसे साफ़ दिखती है।", + "help.mode.circle": "स्क्रीन के बीच में बना घेरा उस ओर फूलता है, जिस ओर से आवाज़ आती है।", + "help.mode.outline": "लहर की सिर्फ़ पतली बाहरी रेखा बनाता है, इसलिए गेम की स्क्रीन कम ढकती है।", + "help.modes.note": "“साइज़” और “गहरापन” जैसी सेटिंग हर मोड के लिए अलग से सेव होती हैं। इन्हें “सेटिंग” टैब में बदला जा सकता है।", + "help.sound.title": "आवाज़ के प्रकार", + "help.sound.kinds": "AI सुनाई देने वाली आवाज़ों को तीन प्रकारों में बांटता है: माहौल की आवाज़ें, बातचीत और खतरे की आवाज़ें। डिफ़ॉल्ट रूप से माहौल की आवाज़ें सफ़ेद, बातचीत पीले और खतरे की आवाज़ें लाल रंग में दिखती हैं। “सेटिंग” टैब में हर प्रकार का रंग बदला जा सकता है और यह भी चुना जा सकता है कि उसे दिखाना है या नहीं।", + "help.sound.groups": "“आवाज़ें” टैब में मिलती-जुलती आवाज़ें समूहों में रखी जाती हैं, और आप चुनते हैं कि हर समूह किस प्रकार में गिना जाए। जैसे, “दरवाज़े की घंटी” समूह को खतरे की आवाज़ों में बदलने पर, जब AI को दरवाज़े की घंटी या दरवाज़ा खटखटाना सुनाई देता है, तो उस आवाज़ को खतरे की आवाज़ों वाला रंग मिलता है। लेकिन जिस आवाज़ को आप खुद खतरे की आवाज़ों में रखते हैं, वह सिर्फ़ उस वोट में शामिल होती है जिससे AI प्रकार तय करता है, इसलिए इसका मतलब यह नहीं है कि हर बार दरवाज़े की घंटी बजने पर वह खतरे की आवाज़ के रूप में दिखेगी।", + "help.sound.advanced": "आवाज़ें एक-एक करके बदलने के लिए एडवांस मोड चालू करें।", + "help.sound.danger": "खतरे की आवाज़ (जैसे गोली चलने की आवाज़) पहचानी जाने पर रंग धीरे-धीरे बदलने के बजाय तुरंत बदल जाता है।", + "help.pc.title": "PC सेटिंग और शॉर्टकट कुंजियां", + "help.sound_mode.title": "साउंड मोड", + "help.sound_mode.body": "बाएं-दाएं के साथ आगे-पीछे की आवाज़ें भी अलग से देखने के लिए, Windows की साउंड सेटिंग में अपने आउटपुट डिवाइस को “5.1 सराउंड” (6 चैनल) या “7.1 सराउंड” (8 चैनल) पर सेट करें। सामान्य स्टीरियो (2 चैनल) में ऐप बाएं और दाएं का फ़र्क पहचान सकता है, लेकिन आगे और पीछे का नहीं। “सेटिंग” टैब का “साउंड मोड” शुरू होने पर और आउटपुट डिवाइस बदलने पर उसी डिवाइस के हिसाब से अपने-आप बदल जाता है, और आप इसे खुद भी बदल सकते हैं।", + "help.sound_mode.tip": "💡 अगर आपके पास 7.1 आउटपुट डिवाइस नहीं है, तो {0} इंस्टॉल करें और आउटपुट डिवाइस को 7.1 चैनल पर सेट करें। इससे ओवरले को 7.1 सराउंड साउंड के साथ दिखाया जा सकता है।", + "help.sound_mode.tip_link": "VB-CABLE वर्चुअल ड्राइवर", + "help.hotkeys.title": "अपनी पसंद की शॉर्टकट कुंजियां", + "help.hotkeys.body": "“सेटिंग” टैब में किसी शॉर्टकट बटन पर क्लिक करें, फिर कोई एक कुंजी या कुंजियों का कॉम्बिनेशन (जैसे Ctrl + Shift + A) दबाकर शॉर्टकट को अपनी पसंद से बदलें। ऐप बैकग्राउंड में चल रहा हो, तब भी शॉर्टकट कुंजी दबाने पर मोड तुरंत बदल जाता है।", + "help.hotkeys.defaults": "डिफ़ॉल्ट रूप से F2 साउंड मोड बदलता है, F3 विज़ुअल मोड बदलता है और F4 ओवरले सेटिंग खोलता है।", + "help.size_lock.title": "साइज़ तय रखने वाला मोड", + "help.size_lock.body": "साइज़ तय रखने वाला मोड, आवाज़ की तेज़ी के अनुपात में पारदर्शिता बदलकर ग्राफ़िक्स का साइज़ एक जैसा रखता है। यह तब काम आता है, जब आप ग्राफ़िक्स का साइज़ एक जैसा रखते हुए भी देखना चाहते हैं कि वे आवाज़ पर कैसे प्रतिक्रिया देते हैं।", + "help.editor.title": "रियल-टाइम ओवरले एडिट मोड", + "help.editor.body": "ओवरले एडिट मोड चालू करने के लिए तय की गई शॉर्टकट कुंजी (डिफ़ॉल्ट रूप से F4) दबाएं। स्क्रीन पर माउस से खींचकर ग्राफ़िक्स के साइज़ की सीमा बदली जा सकती है, और खुलने वाले सेटिंग पैनल में बारीकियां ठीक की जा सकती हैं।", + "help.developer.title": "डेवलपर मोड", + "help.developer.body": "डेवलपर मोड चालू करने पर ओवरले के ऊपरी बाएं कोने में दिखता है कि AI ने क्या सुना (ध्वनि का नाम, वह कितना पक्का था और कितना समय लगा), और ऊपरी दाएं कोने में FPS दिखता है। अगर आप “{0}” भी चालू करते हैं, तो हर नतीजा एक CSV फ़ाइल में सेव होता है। यह तब काम आता है, जब आप जांचना चाहते हैं कि सिस्टम ठीक से काम कर रहा है या नहीं।", + "help.notes.title": "काम की बातें", + "help.note.direction_surround": "5.1 या 7.1 चैनल आउटपुट में, स्क्रीन का ऊपरी हिस्सा सामने से आने वाली आवाज़ और निचला हिस्सा पीछे से आने वाली आवाज़ दिखाता है। बाईं और दाईं तरफ़ बाएं और दाएं से आने वाली आवाज़ दिखती है, और स्क्रीन के हर तरफ़ से एक साथ आने वाले ग्राफ़िक्स माहौल की आवाज़ें या बिना दिशा वाली आवाज़ें होते हैं।", + "help.note.direction_stereo": "2 चैनल (स्टीरियो) आउटपुट में यह ऐप बाएं और दाएं का फ़र्क पहचान सकता है, लेकिन आगे और पीछे का नहीं। तब स्क्रीन के ऊपर और नीचे दिखने वाले ग्राफ़िक्स, दोनों तरफ़ बराबर आने वाली आवाज़ों और स्थानिक प्रभाव को दिखाते हैं।", + "help.note.sources": "Windows के डिफ़ॉल्ट प्लेबैक डिवाइस से चलने वाले सभी ऐप की आवाज़ दिखाई जाती है। कोई ऐप जो आवाज़ किसी दूसरे डिवाइस पर भेजता है, जैसे सिर्फ़ वॉइस चैट के लिए इस्तेमाल होने वाला हेडसेट, वह नहीं दिखती।", + "help.note.volume": "अगर किसी ऐप का वॉल्यूम, ऐप में या Windows वॉल्यूम मिक्सर में, बहुत कम या म्यूट है, तो उसकी आवाज़ मुश्किल से या बिल्कुल नहीं दिख सकती है। अगर ग्राफ़िक्स ठीक से प्रतिक्रिया न दें, तो उस आवाज़ को थोड़ा बढ़ाएं या “सेटिंग” टैब में “संवेदनशीलता” बढ़ाएं।", + "help.note.ai": "AI का वर्गीकरण गलत हो सकता है। जहां सुरक्षा का सवाल हो, वहां सिर्फ़ इसी ऐप पर भरोसा न करें।", + "help.faq.title": "अक्सर पूछे जाने वाले सवाल", + "help.faq.stopped_q": "मेरे बंद किए बिना ही यह बंद हो गया।", + "help.faq.stopped_a": "जब आवाज़ कैप्चर होना रुक जाता है और कुछ सेकंड में फिर शुरू नहीं होता, तो यह बंद हो जाता है, जैसे जब प्लेबैक डिवाइस निकाल दिया जाए और कोई दूसरा डिवाइस उपलब्ध न हो। तब “होम” टैब में वजह दिखती है। स्पीकर या हेडफ़ोन जोड़ें, या Windows की साउंड सेटिंग में डिफ़ॉल्ट प्लेबैक डिवाइस सेट करें, फिर इसे दोबारा शुरू करें।", + "help.faq.no_graphic_q": "ग्राफ़िक्स नहीं दिख रहे हैं।", + "help.faq.no_graphic_a": "देखें कि आवाज़ Windows के डिफ़ॉल्ट प्लेबैक डिवाइस से चल रही है और म्यूट नहीं है, और “होम” टैब में कोई सूचना तो नहीं दिख रही। अगर कोई गेम एक्सक्लूसिव फ़ुल-स्क्रीन मोड में चल रहा है, तो उसका बॉर्डरलेस या विंडो मोड आज़माएं। यह भी देखें कि “सेटिंग” टैब में “गहरापन” बहुत कम न हो और अभी चल रही आवाज़ के प्रकार को दिखाने की सेटिंग बंद न हो।", + "help.faq.performance_q": "क्या यह बहुत ज़्यादा CPU या GPU इस्तेमाल करता है?", + "help.faq.performance_a": "इसे हल्का करने के लिए, “सेटिंग” टैब में “{0}” कम करें या “{1}” बंद करें।", + "help.faq.language_q": "ऐप की भाषा कैसे बदलें?", + "help.faq.language_a": "इस विंडो में ऊपर दी गई “{0}” सूची से भाषा चुनें। चालू रहने के दौरान आप इसे ओवरले सेटिंग में भी बदल सकते हैं। “{1}” चुनने पर ऐप Windows की डिस्प्ले भाषा में दिखेगा।", + "help.report.title": "समस्या की रिपोर्ट करें", + "help.report.desc": "किसी गड़बड़ी (बग) के बारे में बताएं, या ऐसी आवाज़ के बारे में जिसका प्रकार गलत दिखाया गया हो। आपके ब्राउज़र में GitHub खुलेगा, जिसमें नीचे दी गई सिस्टम की जानकारी पहले से भरी होगी, इसलिए GitHub खाता ज़रूरी है। खाता न हो, तो इसके बजाय हमें ईमेल भेजें। कोई भी ऑडियो कभी नहीं भेजा जाता।", + "help.report.github": "GitHub पर रिपोर्ट करें", + "help.report.mail": "ईमेल से रिपोर्ट करें", + "help.report.copy": "सिस्टम की जानकारी कॉपी करें", + "help.report.copied": "सिस्टम की जानकारी कॉपी हो गई।", + "help.report.copy_failed": "क्लिपबोर्ड पर कॉपी नहीं हो सका। फिर से कोशिश करें।", + "help.report.no_browser": "ब्राउज़र नहीं खुल सका। इसके बजाय सिस्टम की जानकारी कॉपी करके हमें भेजें।", + "help.report.no_mail": "भेजने के लिए कोई ईमेल ऐप नहीं है। सिस्टम की जानकारी कॉपी करें और किसी दूसरे तरीके से भेजें।", + "help.report.issue_title": "ऐप से भेजी गई रिपोर्ट", + "help.report.issue_body": "क्या हुआ?\n\nआप क्या सुन रहे थे (कौन-सा ऐप, किस तरह की आवाज़)?\n\nइसके बजाय आपको क्या होने की उम्मीद थी?", + "help.about.title": "ऐप के बारे में", + "help.about.version": "वर्शन {0}", + "help.about.license": "यह ऐप GNU Affero General Public License v3.0 (AGPL-3.0) के तहत उपलब्ध कराया जाता है। “आवाज़ें” टैब में आवाज़ों के नाम और समूह AudioSet ऑन्टोलॉजी पर आधारित हैं और CC BY-SA 4.0 के तहत उपलब्ध हैं (नीचे “ओपन सोर्स लाइसेंस” देखें)।", + "help.licenses.button": "ओपन सोर्स लाइसेंस", + "help.licenses.title": "ओपन सोर्स लाइसेंस", + "help.licenses.close": "बंद करें", + + "notice.ai_unavailable": "आवाज़ का प्रकार पहचाना नहीं जा पा रहा है, इसलिए सभी आवाज़ें माहौल की आवाज़ों के रंग में दिख रही हैं। विज़ुअलाइज़र को बंद करके दोबारा चालू करके देखें।", + "notice.ai_unavailable_hidden": "आवाज़ का प्रकार पहचाना नहीं जा पा रहा है, इसलिए सभी आवाज़ें माहौल की आवाज़ें मानी जा रही हैं, लेकिन माहौल की आवाज़ें दिखाना बंद है, इसलिए कुछ भी नहीं दिख रहा। “सेटिंग” टैब में “{0}” चालू करें, या विज़ुअलाइज़र को बंद करके दोबारा चालू करके देखें।", + "notice.stopped.title": "आवाज़ विज़ुअलाइज़र बंद हो गया", + "notice.stopped.no_device": "कोई प्लेबैक डिवाइस नहीं खुल सका, इसलिए आवाज़ कैप्चर नहीं हो सकती। स्पीकर या हेडफ़ोन जोड़ें, या Windows की साउंड सेटिंग में डिफ़ॉल्ट प्लेबैक डिवाइस सेट करें, फिर दोबारा शुरू करें।", + "notice.stopped.capture_error": "गड़बड़ी की वजह से आवाज़ कैप्चर होना रुक गया, इसलिए ग्राफ़िक्स बंद हो गए।", + "notice.dismiss": "जानकारी हटाएं", + + "tray.open": "लॉन्चर खोलें", + "tray.exit": "बाहर निकलें", + + "more_info.title": "GitHub रिपॉज़िटरी और योगदान", + "more_info.body": "SoundVisualizer एक ओपन-सोर्स प्रोजेक्ट है, और इसका सोर्स कोड GitHub पर सबके लिए उपलब्ध है। आप बग की रिपोर्ट करके, नई सुविधाओं का सुझाव देकर या डेवलपमेंट में योगदान देकर इसमें हिस्सा ले सकते हैं। आधिकारिक GitHub रिपॉज़िटरी पर जाने के लिए नीचे दिए गए बटन पर क्लिक करें! GitHub के Releases टैब में आप नया वर्शन देख और डाउनलोड भी कर सकते हैं।", + "more_info.github_button": "GitHub रिपॉज़िटरी पर जाएं", + + "dialog.notice_title": "सूचना", + "dialog.already_running": "ओवरले पहले से चल रहा है।", + "dialog.reset_title": "सेटिंग रीसेट करें", + "dialog.reset_wave": "लहर मोड की सेटिंग को डिफ़ॉल्ट पर रीसेट करें?", + "dialog.reset_pad": "पैड मोड की सेटिंग को डिफ़ॉल्ट पर रीसेट करें?", + "dialog.reset_circle": "वृत्त मोड की सेटिंग को डिफ़ॉल्ट पर रीसेट करें?", + "dialog.reset_outline": "आउटलाइन मोड की सेटिंग को डिफ़ॉल्ट पर रीसेट करें?", + "dialog.reset_sound_types": "आवाज़ के प्रकारों की सेटिंग को डिफ़ॉल्ट पर रीसेट करें?", + "dialog.reset_all_title": "डिफ़ॉल्ट पर रीसेट करें", + "dialog.reset_all": "क्या आप सच में सभी सेटिंग को डिफ़ॉल्ट पर रीसेट करना चाहते हैं?", + "dialog.link_error": "लिंक नहीं खुल सका: {0}", + + "editor.title": "ओवरले सेटिंग", + "editor.drag_tip": "ओवरले की लहर की ऊंचाई की सीमा और बीच का साइज़ रियल-टाइम में बदलने के लिए, स्क्रीन पर दिख रही गाइड लाइनों को माउस से खींचें!", + "editor.glow": "चमक प्रभाव", + "editor.glow_strength": "चमक का स्तर", + "editor.color": "रंग", + "editor.hotkeys": "शॉर्टकट कुंजियों की सेटिंग", + "editor.developer_mode_desc": "AI ने क्या सुना (ऊपर बाएं) और FPS (ऊपर दाएं) दिखाता है", + "editor.save_close_hint": "दबाकर सेव करें और बंद करें", + "editor.close_overlay": "ओवरले बंद करें", + "editor.guide.limit": "{0} की सीमा", + "editor.guide.size": "{0} का साइज़: {1}%", + "editor.guide.base_radius": "मूल त्रिज्या: {0}", + "editor.guide.circle_size": "वृत्त का साइज़: {0}%", + + "hud.visual_mode": "विज़ुअल मोड: [{0}] {1}", + "hud.sound_mode": "साउंड मोड: [{0}] {1}", + "hud.overlay_settings": "ओवरले सेटिंग: [{0}]", + "hud.status.ready": "सिस्टम तैयार है", + "hud.status.stereo": "PC ऑडियो: {0} चैनल (स्टीरियो)\nस्रोत: इंतज़ार हो रहा है...", + "hud.status.surround": "PC ऑडियो: 8 चैनल (7.1)\nस्रोत: इंतज़ार हो रहा है...", + + "color_picker.title": "रंग चुनें", + "color_picker.hex": "HEX कोड", + "color_picker.cancel": "रद्द करें", + "color_picker.apply": "लागू करें", + "color_picker.area": "संतृप्ति और चमक", + "color_picker.area_value": "संतृप्ति {0}%, चमक {1}%", + "color_picker.hue": "रंगत", + "color_picker.keys_help": "बदलने के लिए तीर कुंजियों का इस्तेमाल करें। बड़े कदमों में बदलने के लिए Shift दबाकर रखें।", + "color_picker.presets": "अक्सर इस्तेमाल होने वाले रंग", + "color_picker.preset.white": "सफ़ेद", + "color_picker.preset.red": "लाल", + "color_picker.preset.orange": "नारंगी", + "color_picker.preset.yellow": "पीला", + "color_picker.preset.green": "हरा", + "color_picker.preset.cyan": "आसमानी", + "color_picker.preset.blue": "नीला", + "color_picker.preset.magenta": "मैजेंटा", + + "audio.error.no_device": "कोई आउटपुट डिवाइस नहीं मिला।", + "audio.error.capture_stopped": "ऑडियो कैप्चर अचानक रुक गया: {0}", + "audio.error.capture_start_failed": "कैप्चर इंजन शुरू नहीं हो सका: {0}", + + "tutorial.window_title": "ट्यूटोरियल", + "tutorial.skip": "छोड़ें", + "tutorial.previous": "पिछला पेज", + "tutorial.next": "आगे बढ़ें", + "tutorial.done": "ठीक है", + "tutorial.page_status": "{1} में से पेज {0}", + "tutorial.pause": "ऐनिमेशन रोकें", + "tutorial.play": "ऐनिमेशन चलाएं", + "tutorial.sound_on": "आवाज़ चालू करें", + "tutorial.sound_off": "आवाज़ बंद करें", + "tutorial.sound.title": "आवाज़ को स्क्रीन पर देखें", + "tutorial.sound.body": "आपके गेम या वीडियो की आवाज़ स्क्रीन के किनारों पर बनाई जाती है। आवाज़ जितनी तेज़ होती है, उतनी ही बड़ी दिखती है। सिर्फ़ इस PC पर चल रही आवाज़ ही दिखाई जाती है। यह ऐप आपके आस-पास की आवाज़ें नहीं सुनता।", + "tutorial.direction.title": "आवाज़ किस तरफ़ से आ रही है, यह दिखाता है", + "tutorial.direction.body": "2 चैनल (स्टीरियो) आउटपुट में बाईं तरफ़ से आने वाली आवाज़ स्क्रीन की बाईं तरफ़ ज़्यादा बड़ी दिखती है, और दाईं तरफ़ से आने वाली आवाज़ दाईं तरफ़। 5.1 या 7.1 चैनल आउटपुट में यह ऐप आगे और पीछे का फ़र्क भी पहचानता है: सामने से आने वाली आवाज़ स्क्रीन के ऊपरी हिस्से में और पीछे से आने वाली आवाज़ निचले हिस्से में दिखती है।", + "tutorial.types.title": "आवाज़ के हर प्रकार का अपना रंग है", + "tutorial.types.body": "AI आवाज़ों को तीन प्रकारों में बांटता है और हर प्रकार को अलग रंग में बनाता है। रंग “सेटिंग” टैब में बदले जा सकते हैं।", + "tutorial.type.ambient_examples": "संगीत, बारिश और बाकी सब", + "tutorial.type.speech_examples": "लोगों की आवाज़ें", + "tutorial.type.danger_examples": "गोलियों की आवाज़, धमाके, सायरन", + "tutorial.start.title": "चालू और बंद कैसे करें", + "tutorial.start.hotkeys": "चालू रहने के दौरान, साउंड मोड बदलने के लिए {0}, विज़ुअल मोड बदलने के लिए {1}, और ओवरले सेटिंग खोलने के लिए {2} दबाएं।" +} diff --git a/SoundVisualizer.Core/Localization/Strings/id.json b/SoundVisualizer.Core/Localization/Strings/id.json new file mode 100644 index 0000000..9359d17 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/id.json @@ -0,0 +1,294 @@ +{ + "common.language": "Bahasa", + "common.reset_defaults": "Reset ke default", + "common.minimize": "Minimalkan", + "common.close": "Tutup", + "language.system": "Default sistem", + + "launcher.window_title": "Peluncur SoundVisualizer", + "launcher.tab.home": "Beranda", + "launcher.tab.settings": "Setelan", + "launcher.tab.sounds": "Suara", + "launcher.tab.help": "Bantuan", + "launcher.tab.more_info": "Pelajari lebih lanjut", + "launcher.home.desc": "Suara yang tak terlihat kini tergambar langsung di layar Anda.\nDari bermain game hingga menonton film, rasakan pengalaman yang benar-benar baru.", + "launcher.home.start": "Mulai", + "launcher.home.stop": "Hentikan", + "launcher.home.tutorial": "Lihat tutorial", + "launcher.status.running": "Status: Berjalan", + "launcher.status.idle": "Status: Siap", + + "settings.section.mode": "Setelan mode", + "settings.visual_mode.title": "Mode visual", + "settings.visual_mode.desc": "Pilih bentuk dasar grafik yang digambar di layar.", + "settings.mode.wave": "Ombak", + "settings.mode.pad": "Pad", + "settings.mode.circle": "Lingkar", + "settings.mode.outline": "Kontur", + "settings.sound_mode.title": "Mode suara", + "settings.sound_mode.desc": "Pilih saluran suara yang sesuai dengan speaker Anda.\nUntuk speaker 2 saluran, pilih “2 saluran”. Untuk speaker surround, pilih “5.1 saluran” atau “7.1 saluran”.", + "settings.channels.2": "2 saluran", + "settings.channels.5_1": "5.1 saluran", + "settings.channels.7_1": "7.1 saluran", + "settings.max_fps.title": "Frame rate maksimum", + "settings.max_fps.desc": "Mengatur frame rate tertinggi untuk menggambar grafik.", + "settings.section.mode_detail": "Setelan per mode", + "settings.mode_card.wave": "Mode Ombak", + "settings.mode_card.pad": "Mode Pad", + "settings.mode_card.circle": "Mode Lingkar", + "settings.mode_card.outline": "Mode Kontur", + "settings.size_lock.title": "Kunci ukuran", + "settings.size_lock.desc": "Menjaga ukuran grafik tetap dan menunjukkan kerasnya suara melalui opasitas.", + "settings.fixed_size.wave": "Ukuran ombak", + "settings.fixed_size.pad": "Ukuran pad", + "settings.fixed_size.circle": "Ukuran lingkar", + "settings.fixed_size.outline": "Ketebalan kontur", + "settings.fixed_size.desc": "Mengatur ukuran tetap yang dipertahankan grafik saat opasitasnya berubah.", + "settings.max_opacity.title": "Opasitas maks", + "settings.max_opacity.desc": "Seberapa pekat grafik saat suara paling keras.", + "settings.size.title": "Ukuran", + "settings.size.desc.wave": "Mengatur tinggi ombak dan seberapa penuh tampilannya secara keseluruhan.", + "settings.size.desc.pad": "Mengatur ketebalan dan lebar pad saat bereaksi terhadap suara.", + "settings.size.desc.circle": "Mengatur seberapa jauh equalizer lingkar menggembung keluar saat suara terdengar.", + "settings.size.desc.outline": "Mengatur tinggi ombak kontur dan seberapa penuh tampilannya secara keseluruhan.", + "settings.opacity.title": "Opasitas", + "settings.opacity.desc.wave": "Makin tinggi nilainya, makin pekat grafiknya; makin rendah, makin samar dan transparan.", + "settings.opacity.desc.pad": "Makin tinggi nilainya, makin pekat grafiknya; makin rendah, makin samar dan transparan.", + "settings.opacity.desc.circle": "Makin tinggi nilainya, makin pekat grafiknya; makin rendah, makin samar dan transparan.", + "settings.opacity.desc.outline": "Makin tinggi nilainya, makin pekat grafiknya; makin rendah, makin samar dan transparan.", + "settings.speed.title": "Kecepatan", + "settings.speed.desc.wave": "Mengatur seberapa cepat ombak mengalir dan beriak.", + "settings.speed.desc.pad": "Mengatur seberapa cepat pad berputar ke arah datangnya suara.", + "settings.speed.desc.circle": "Mengatur seberapa cepat equalizer lingkar beriak dan berputar mengikuti suara.", + "settings.speed.desc.outline": "Mengatur seberapa cepat ombak kontur mengalir dan beriak.", + "settings.sensitivity.title": "Kepekaan", + "settings.sensitivity.desc.wave": "Mengatur seberapa peka ombak bereaksi dan bergelora, bahkan terhadap suara pelan.", + "settings.sensitivity.desc.pad": "Mengatur pad agar lebih mudah menangkap suara dan tampak lebih menonjol.", + "settings.sensitivity.desc.circle": "Mengatur seberapa halus amplitudo lingkar terpecah saat bereaksi terhadap suara.", + "settings.sensitivity.desc.outline": "Mengatur seberapa peka ombak kontur bereaksi dan bergelora, bahkan terhadap suara pelan.", + "settings.glow.title": "Efek cahaya", + "settings.glow.desc.wave": "Menambahkan pendar neon yang lembut di sepanjang tepi ombak dan mengatur kekuatannya.\nCatatan: menggunakan sumber daya GPU tambahan.", + "settings.glow.desc.pad": "Menambahkan pendar lembut seperti aura di sekitar pad.\nCatatan: menggunakan sumber daya GPU tambahan.", + "settings.glow.desc.circle": "Menambahkan lingkaran cahaya berkilau di sepanjang tepi equalizer lingkar.\nCatatan: menggunakan sumber daya GPU tambahan.", + "settings.glow.desc.outline": "Menambahkan pendar neon yang lembut pada kontur dan mengatur kekuatannya.\nCatatan: menggunakan sumber daya GPU tambahan.", + "settings.radius.title": "Radius", + "settings.radius.desc": "Mengatur radius cincin di tengah layar pada mode Lingkar.", + "settings.section.sound_types": "Jenis suara (AI)", + "settings.show_ambient": "Tampilkan suara latar", + "settings.show_speech": "Tampilkan percakapan", + "settings.show_danger": "Tampilkan suara bahaya", + "settings.set_color": "Atur warna", + "settings.set_color_for": "Atur warna: {0}", + "settings.section.hotkeys": "Tombol pintasan", + "settings.hotkey.visual.title": "Ganti mode visual", + "settings.hotkey.visual.desc": "Tombol pintasan untuk mengganti bentuk secara langsung saat berjalan.", + "settings.hotkey.sound.title": "Ganti mode suara", + "settings.hotkey.sound.desc": "Tombol pintasan untuk mengganti mode suara secara langsung saat berjalan.", + "settings.hotkey.edit.title": "Setelan overlay", + "settings.hotkey.edit.desc": "Tombol pintasan untuk membuka atau menutup editor overlay.", + "settings.hotkey.set": "Atur pintasan", + "settings.section.advanced": "Setelan lanjutan", + "settings.developer_mode.title": "Mode pengembang", + "settings.developer_mode.desc": "Menampilkan apa yang didengar AI (nama suara yang dilaporkan, seberapa yakin, dan berapa lama prosesnya) di sudut kiri atas overlay, serta FPS di sudut kanan atas. Tampilan ini berbahasa Inggris dan tidak ditujukan untuk penggunaan sehari-hari.", + "settings.developer_record.title": "Catat hasil ke file", + "settings.developer_record.desc": "Menulis setiap hasil AI (nama suara dengan peringkat tertinggi, seberapa yakin, dan waktu prosesnya) ke file CSV baru di folder ai-log di samping SoundVisualizer.exe, sehingga Anda bisa melihat kembali apa yang didengarnya. Jika folder itu tidak bisa ditulisi, file disimpan di %LOCALAPPDATA%\\SoundVisualizer\\ai-log. File bisa menjadi besar, jadi matikan setelah selesai.", + "settings.auto_minimize.title": "Minimalkan peluncur saat mulai", + "settings.auto_minimize.desc": "Otomatis meminimalkan jendela peluncur ini saat overlay dimulai.", + + "sounds.title": "Pilih jenis suara", + "sounds.desc": "Suara yang dikenali AI dikelompokkan dengan suara yang mirip, sesuai jenis default masing-masing. Jika Anda mengubah jenis suatu grup, warna suara di dalamnya langsung mengikuti jenis yang baru.", + "sounds.sibling_note": "AI sering mengenali satu suara dengan beberapa nama sekaligus. Misalnya, sirene juga dikenali sebagai “Alarm”, yang ada di grup lain. Jika suatu suara masih tampil dengan jenis lamanya setelah diubah, ubah juga grup atau suara yang terkait.", + "sounds.advanced.title": "Mode lanjutan", + "sounds.advanced.desc": "Menampilkan setiap suara dalam grupnya agar Anda dapat mengubah suara satu per satu.", + "sounds.search.hint": "Cari suara", + "sounds.search.clear": "Hapus pencarian", + "sounds.filter.title": "Filter", + "sounds.filter.all": "Semua", + "sounds.filter.changed": "Diubah", + "sounds.type.ambient": "Suara latar", + "sounds.type.speech": "Percakapan", + "sounds.type.danger": "Suara bahaya", + "sounds.type.default": "{0} (default)", + "sounds.type.mixed": "Campuran", + "sounds.changed_count": "Suara diubah: {0}", + "sounds.changed_from": "Default: {0}", + "sounds.reset.button": "Reset semua", + "sounds.reset.title": "Reset semua suara?", + "sounds.reset.message": "Semua suara yang telah Anda ubah akan kembali ke jenis default.", + "sounds.reset.confirm": "Reset", + "sounds.reset.cancel": "Batal", + "sounds.empty": "Tidak ada suara yang cocok.", + "sounds.group.mixed_note": "Beberapa suara diubah satu per satu", + "sounds.group.changed_line": "Sekarang: {0} · Default: {1}", + "sounds.overwrite.title": "Ubah seluruh grup?", + "sounds.overwrite.message": "{1} suara di grup “{0}” akan diubah menjadi “{2}”. Suara yang Anda ubah satu per satu juga akan ditimpa.", + "sounds.overwrite.confirm": "Ubah", + "sounds.overwrite.cancel": "Batal", + "sounds.basic_warning.title": "Beralih ke mode dasar?", + "sounds.basic_warning.message": "Beberapa grup berisi suara yang Anda ubah satu per satu. Di mode dasar, jika Anda memilih jenis untuk salah satu grup tersebut, semua suara di dalamnya akan mendapat jenis itu dan perubahan satu per satu di grup itu akan ditimpa. Tidak ada yang berubah sampai Anda memilih.", + "sounds.basic_warning.confirm": "Beralih ke dasar", + "sounds.basic_warning.cancel": "Tetap di lanjutan", + "sounds.cd.sound": "{0}, jenis: {1}", + "sounds.cd.sound_changed": "{0}, jenis: {1}, default: {2}", + "sounds.cd.group": "Grup {0}, jenis: {1}, jumlah suara: {2} ({3})", + "sounds.cd.group_changed": "Grup {0}, jenis: {1}, default: {2}, jumlah suara: {3} ({4})", + "sounds.cd.group_header": "Grup {0}, jumlah suara: {1}", + + "hotkey.prompt": "Tekan tombol (Esc untuk batal)", + "hotkey.none": "Tidak ada", + "hotkey.button_name": "{0}, tombol pintasan: {1}", + + "help.start.title": "Memulai", + "help.start.1": "Pilih bahasa di bagian atas jendela ini, lalu ubah tampilan grafik di tab Setelan jika Anda mau.", + "help.start.2": "Di Beranda, klik “{0}”.", + "help.start.3": "Grafik digambar di sepanjang tepi layar, di atas jendela lain seperti game dan video. Klik mouse menembus grafik, jadi Anda tetap bisa menggunakan jendela di bawahnya.", + "help.start.4": "Untuk mematikannya, klik “{0}” di Beranda, atau klik kanan ikon SoundVisualizer di area notifikasi taskbar lalu pilih “{0}”.", + "help.modes.title": "Mode visual", + "help.mode.wave": "Seluruh tepi layar terdorong ke dalam sesuai kerasnya suara. Sisi asal suara terdorong lebih dalam.", + "help.mode.pad": "Bilah menyala di 8 titik di sekeliling tepi layar. Mode ini paling jelas menunjukkan arah.", + "help.mode.circle": "Cincin di tengah layar mengembang ke arah datangnya suara.", + "help.mode.outline": "Hanya menggambar garis tipis di sepanjang kontur ombak, sehingga tidak banyak menutupi layar game Anda.", + "help.modes.note": "Setelan seperti Ukuran dan Opasitas disimpan terpisah untuk setiap mode dan dapat diubah di tab Setelan.", + "help.sound.title": "Jenis suara", + "help.sound.kinds": "AI mengelompokkan suara yang terdengar menjadi suara latar, percakapan, dan suara bahaya. Secara default, suara latar berwarna putih, percakapan kuning, dan suara bahaya merah. Di tab Setelan, Anda dapat mengubah warna setiap jenis dan memilih apakah jenis tersebut ditampilkan.", + "help.sound.groups": "Di tab Suara, suara yang mirip dikelompokkan, dan Anda memilih jenis untuk setiap grup. Misalnya, jika grup “Bel pintu” diubah menjadi suara bahaya, saat AI mengenali bel pintu atau ketukan pintu, suara itu akan mendapat warna suara bahaya. Namun, suara yang Anda ubah sendiri menjadi suara bahaya hanya ikut dihitung dalam voting AI untuk menentukan jenisnya, jadi bukan berarti setiap bel pintu akan ditampilkan sebagai suara bahaya.", + "help.sound.advanced": "Aktifkan mode lanjutan untuk mengubah suara satu per satu.", + "help.sound.danger": "Saat suara bahaya (seperti suara tembakan) terdeteksi, warna langsung berubah tanpa memudar perlahan.", + "help.pc.title": "Setelan PC dan tombol pintas", + "help.sound_mode.title": "Mode suara", + "help.sound_mode.body": "Agar suara dari depan dan belakang juga dapat dibedakan, bukan hanya kiri dan kanan, setel perangkat output ke surround 5.1 (6 saluran) atau surround 7.1 (8 saluran) di setelan suara Windows. Dengan stereo biasa (2 saluran), aplikasi dapat membedakan kiri dan kanan, tetapi tidak dapat membedakan depan dan belakang. “Mode suara” di tab Setelan akan menyesuaikan dengan perangkat output saat mulai dijalankan atau saat perangkat berubah, dan Anda tetap dapat mengubahnya sendiri.", + "help.sound_mode.tip": "💡 Jika tidak punya perangkat output 7.1, Anda dapat menginstal {0} lalu menyetel perangkat output ke 7.1 saluran untuk menampilkan overlay dengan suara surround 7.1.", + "help.sound_mode.tip_link": "driver virtual VB-CABLE", + "help.hotkeys.title": "Tombol pintasan kustom real-time", + "help.hotkeys.body": "Di tab Setelan, klik tombol pintasan, lalu tekan satu tombol keyboard atau kombinasi tombol (misalnya Ctrl + Shift + A) untuk mengubah pintasan sesuai keinginan Anda. Menekan tombol pintasan akan langsung mengganti mode, bahkan saat aplikasi berjalan di latar belakang.", + "help.hotkeys.defaults": "Secara default, F2 mengganti mode suara, F3 mengganti mode visual, dan F4 membuka setelan overlay.", + "help.size_lock.title": "Mode kunci ukuran", + "help.size_lock.body": "Mode kunci ukuran menjaga ukuran grafik tetap dengan menggunakan opasitas yang sebanding dengan kerasnya suara. Mode ini berguna jika Anda ingin ukuran grafik tetap sama sambil tetap melihatnya bereaksi terhadap suara.", + "help.editor.title": "Mode edit overlay real-time", + "help.editor.body": "Tekan tombol pintasan yang ditetapkan (default F4) untuk menyalakan mode edit overlay. Anda dapat menyeret mouse di layar untuk mengatur batas ukuran grafik, dan menyempurnakan detailnya di panel setelan yang muncul.", + "help.developer.title": "Mode pengembang", + "help.developer.body": "Menyalakan Mode pengembang akan menampilkan apa yang didengar AI (nama suara, seberapa yakin, dan berapa lama prosesnya) di sudut kiri atas overlay, serta FPS di sudut kanan atas. Jika Anda juga menyalakan “{0}”, setiap hasil disimpan ke file CSV. Ini berguna jika Anda ingin memeriksa apakah sistem berfungsi dengan baik.", + "help.notes.title": "Perlu diketahui", + "help.note.direction_surround": "Dengan output 5.1 atau 7.1 saluran, bagian atas layar menunjukkan suara dari depan, dan bagian bawah menunjukkan suara dari belakang. Sisi kiri dan kanan menunjukkan suara dari kiri dan kanan, sedangkan grafik yang muncul dari semua sisi layar sekaligus adalah suara latar atau suara yang tidak berarah.", + "help.note.direction_stereo": "Dengan output 2 saluran (stereo), aplikasi dapat membedakan kiri dan kanan, tetapi tidak dapat membedakan depan dan belakang. Dalam hal ini, grafik di bagian atas dan bawah layar menunjukkan suara yang terdengar sama kuat di kedua sisi, serta memberi kesan ruang.", + "help.note.sources": "Aplikasi menampilkan suara semua aplikasi yang diputar melalui perangkat pemutaran default Windows. Suara yang dikirim aplikasi ke perangkat lain, misalnya headset yang hanya dipakai untuk obrolan suara, tidak ditampilkan.", + "help.note.volume": "Jika volume sebuah aplikasi diturunkan sangat rendah atau dibisukan, baik di aplikasi itu sendiri maupun di mixer volume Windows, suaranya mungkin hampir tidak terlihat atau tidak muncul sama sekali. Jika grafik kurang merespons, naikkan sedikit suara tersebut atau naikkan Kepekaan di tab Setelan.", + "help.note.ai": "Klasifikasi AI bisa salah. Jangan mengandalkan aplikasi ini sebagai satu-satunya pengaman dalam situasi yang menyangkut keselamatan.", + "help.faq.title": "Pertanyaan umum", + "help.faq.stopped_q": "Visualisasi mati padahal saya tidak menghentikannya.", + "help.faq.stopped_a": "Visualisasi mati saat penangkapan suara berhenti dan tidak pulih dalam beberapa detik, misalnya saat perangkat pemutaran dilepas dan tidak ada perangkat lain yang tersedia. Beranda akan menampilkan alasannya. Sambungkan speaker atau headphone, atau setel perangkat pemutaran default di setelan suara Windows, lalu mulai lagi.", + "help.faq.no_graphic_q": "Grafik tidak muncul.", + "help.faq.no_graphic_a": "Pastikan suara diputar melalui perangkat pemutaran default Windows dan tidak dibisukan, serta periksa apakah Beranda menampilkan info. Jika game berjalan dalam mode layar penuh eksklusif, coba mode jendela tanpa bingkai atau mode jendela. Pastikan juga Opasitas di tab Setelan tidak terlalu rendah, dan opsi tampilan untuk jenis suara yang sedang terdengar tidak dimatikan.", + "help.faq.performance_q": "Apakah aplikasi ini banyak memakai CPU atau GPU?", + "help.faq.performance_a": "Agar lebih ringan, turunkan “{0}” di tab Setelan atau matikan “{1}”.", + "help.faq.language_q": "Bagaimana cara mengubah bahasa aplikasi?", + "help.faq.language_a": "Pilih dari daftar “{0}” di bagian atas jendela ini. Saat berjalan, Anda juga dapat mengubahnya di setelan overlay. “{1}” mengikuti bahasa tampilan Windows.", + "help.report.title": "Laporkan masalah", + "help.report.desc": "Beri tahu kami tentang bug, atau suara yang ditampilkan sebagai jenis suara yang salah. GitHub akan terbuka di browser Anda dengan info sistem di bawah yang sudah terisi, jadi Anda memerlukan akun GitHub. Jika tidak punya akun, kirim email kepada kami saja. Audio tidak pernah dikirim ke mana pun.", + "help.report.github": "Laporkan di GitHub", + "help.report.mail": "Laporkan melalui email", + "help.report.copy": "Salin info sistem", + "help.report.copied": "Info sistem disalin.", + "help.report.copy_failed": "Tidak dapat menyalin ke papan klip. Coba lagi.", + "help.report.no_browser": "Browser tidak dapat dibuka. Sebagai gantinya, salin info sistem dan kirimkan kepada kami.", + "help.report.no_mail": "Tidak ada aplikasi email untuk mengirim. Salin info sistem, lalu kirimkan dengan cara lain.", + "help.report.issue_title": "Laporan dari aplikasi", + "help.report.issue_body": "Apa yang terjadi?\n\nApa yang sedang Anda dengarkan (aplikasi apa, jenis suara apa)?\n\nMenurut Anda, apa yang seharusnya terjadi?", + "help.about.title": "Tentang", + "help.about.version": "Versi {0}", + "help.about.license": "Aplikasi ini didistribusikan berdasarkan GNU Affero General Public License v3.0 (AGPL-3.0). Nama dan grup suara di tab Suara didasarkan pada ontologi AudioSet dan didistribusikan berdasarkan CC BY-SA 4.0 (lihat “Lisensi open source” di bawah).", + "help.licenses.button": "Lisensi open source", + "help.licenses.title": "Lisensi open source", + "help.licenses.close": "Tutup", + + "notice.ai_unavailable": "Deteksi jenis suara tidak berfungsi, sehingga semua suara ditampilkan dengan warna suara latar. Coba matikan lalu nyalakan lagi visualisasinya.", + "notice.ai_unavailable_hidden": "Deteksi jenis suara tidak berfungsi, sehingga semua suara dianggap suara latar, tetapi tampilan suara latar dimatikan, jadi tidak ada yang ditampilkan. Aktifkan “{0}” di tab Setelan, atau coba matikan lalu nyalakan lagi visualisasinya.", + "notice.stopped.title": "Visualisasi suara dimatikan", + "notice.stopped.no_device": "Tidak ada perangkat pemutaran yang dapat dibuka, sehingga suara tidak dapat ditangkap. Sambungkan speaker atau headphone, atau setel perangkat pemutaran default di setelan suara Windows, lalu mulai lagi.", + "notice.stopped.capture_error": "Penangkapan suara berhenti karena terjadi kesalahan, sehingga grafik dimatikan.", + "notice.dismiss": "Tutup info", + + "tray.open": "Buka peluncur", + "tray.exit": "Keluar", + + "more_info.title": "Repositori GitHub dan kontribusi", + "more_info.body": "SoundVisualizer adalah proyek open source, dan kode sumbernya terbuka untuk umum di GitHub. Anda dapat ikut serta dengan melaporkan bug, menyarankan fitur, atau berkontribusi dalam pengembangan. Klik tombol di bawah untuk mengunjungi repositori GitHub resmi! Anda juga dapat memeriksa dan mengunduh versi terbaru di tab Releases di GitHub.", + "more_info.github_button": "Kunjungi repositori GitHub", + + "dialog.notice_title": "Pemberitahuan", + "dialog.already_running": "Overlay sudah berjalan.", + "dialog.reset_title": "Reset setelan", + "dialog.reset_wave": "Reset setelan mode Ombak ke default?", + "dialog.reset_pad": "Reset setelan mode Pad ke default?", + "dialog.reset_circle": "Reset setelan mode Lingkar ke default?", + "dialog.reset_outline": "Reset setelan mode Kontur ke default?", + "dialog.reset_sound_types": "Reset setelan jenis suara ke default?", + "dialog.reset_all_title": "Reset ke default", + "dialog.reset_all": "Yakin ingin mengembalikan semua setelan ke default?", + "dialog.link_error": "Tidak dapat membuka tautan: {0}", + + "editor.title": "Setelan overlay", + "editor.drag_tip": "Seret garis panduan di layar dengan mouse untuk mengatur batas tinggi dan ukuran tengah ombak overlay secara real-time!", + "editor.glow": "Efek cahaya", + "editor.glow_strength": "Kuat cahaya", + "editor.color": "Warna", + "editor.hotkeys": "Setelan tombol pintasan", + "editor.developer_mode_desc": "Menampilkan apa yang didengar AI (kiri atas) dan FPS (kanan atas)", + "editor.save_close_hint": "untuk menyimpan setelan dan menutup", + "editor.close_overlay": "Keluar dari overlay", + "editor.guide.limit": "Batas {0}", + "editor.guide.size": "Ukuran {0}: {1}%", + "editor.guide.base_radius": "Radius dasar: {0}", + "editor.guide.circle_size": "Ukuran lingkar: {0}%", + + "hud.visual_mode": "Mode visual: [{0}] {1}", + "hud.sound_mode": "Mode suara: [{0}] {1}", + "hud.overlay_settings": "Setelan overlay: [{0}]", + "hud.status.ready": "Sistem siap", + "hud.status.stereo": "Audio PC: {0}ch (stereo)\nSumber: menunggu...", + "hud.status.surround": "Audio PC: 8ch (7.1)\nSumber: menunggu...", + + "color_picker.title": "Pilih warna", + "color_picker.hex": "Kode HEX", + "color_picker.cancel": "Batal", + "color_picker.apply": "Terapkan", + "color_picker.area": "Saturasi dan kecerahan", + "color_picker.area_value": "Saturasi {0}%, kecerahan {1}%", + "color_picker.hue": "Rona", + "color_picker.keys_help": "Gunakan tombol panah untuk mengubahnya. Tahan Shift untuk mengubah lebih banyak sekaligus.", + "color_picker.presets": "Warna umum", + "color_picker.preset.white": "Putih", + "color_picker.preset.red": "Merah", + "color_picker.preset.orange": "Oranye", + "color_picker.preset.yellow": "Kuning", + "color_picker.preset.green": "Hijau", + "color_picker.preset.cyan": "Sian", + "color_picker.preset.blue": "Biru", + "color_picker.preset.magenta": "Magenta", + + "audio.error.no_device": "Perangkat output tidak ditemukan.", + "audio.error.capture_stopped": "Penangkapan audio berhenti tiba-tiba: {0}", + "audio.error.capture_start_failed": "Tidak dapat memulai mesin penangkap audio: {0}", + + "tutorial.window_title": "Tutorial", + "tutorial.skip": "Lewati", + "tutorial.previous": "Halaman sebelumnya", + "tutorial.next": "Berikutnya", + "tutorial.done": "Mengerti", + "tutorial.page_status": "Halaman {0} dari {1}", + "tutorial.pause": "Jeda animasi", + "tutorial.play": "Putar animasi", + "tutorial.sound_on": "Nyalakan suara", + "tutorial.sound_off": "Matikan suara", + "tutorial.sound.title": "Lihat suara di layar Anda", + "tutorial.sound.body": "Suara dari game atau video Anda digambar di sepanjang tepi layar. Makin keras suaranya, makin besar gambarnya. Hanya suara yang diputar di PC ini yang ditampilkan. Aplikasi ini tidak mendengarkan suara di sekitar Anda.", + "tutorial.direction.title": "Menunjukkan arah datangnya suara", + "tutorial.direction.body": "Dengan output 2 saluran (stereo), suara dari kiri digambar lebih besar di sisi kiri, dan suara dari kanan di sisi kanan. Dengan output 5.1 atau 7.1 saluran, aplikasi juga dapat membedakan depan dan belakang: suara dari depan digambar di bagian atas layar, dan suara dari belakang di bagian bawah.", + "tutorial.types.title": "Setiap jenis suara punya warnanya sendiri", + "tutorial.types.body": "AI mengelompokkan suara menjadi tiga jenis dan menggambar setiap jenis dengan warna yang berbeda. Anda dapat mengubah warnanya di tab Setelan.", + "tutorial.type.ambient_examples": "Musik, hujan, dan suara lainnya", + "tutorial.type.speech_examples": "Suara manusia", + "tutorial.type.danger_examples": "Tembakan, ledakan, sirene", + "tutorial.start.title": "Cara menyalakan dan mematikannya", + "tutorial.start.hotkeys": "Saat berjalan, tekan {0} untuk mengganti mode suara, {1} untuk mengganti mode visual, dan {2} untuk membuka setelan overlay." +} diff --git a/SoundVisualizer.Core/Localization/Strings/it.json b/SoundVisualizer.Core/Localization/Strings/it.json new file mode 100644 index 0000000..bd45d48 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/it.json @@ -0,0 +1,294 @@ +{ + "common.language": "Lingua", + "common.reset_defaults": "Ripristina valori predefiniti", + "common.minimize": "Riduci a icona", + "common.close": "Chiudi", + "language.system": "Predefinita di sistema", + + "launcher.window_title": "Launcher di SoundVisualizer", + "launcher.tab.home": "Home", + "launcher.tab.settings": "Impostazioni", + "launcher.tab.sounds": "Suoni", + "launcher.tab.help": "Guida", + "launcher.tab.more_info": "Scopri di più", + "launcher.home.desc": "Disegna sullo schermo i suoni che non puoi vedere.\nDai giochi ai film, prova un'esperienza tutta nuova.", + "launcher.home.start": "Avvia", + "launcher.home.stop": "Interrompi", + "launcher.home.tutorial": "Guarda il tutorial", + "launcher.status.running": "Stato: in esecuzione", + "launcher.status.idle": "Stato: pronto", + + "settings.section.mode": "Impostazioni modalità", + "settings.visual_mode.title": "Modalità visiva", + "settings.visual_mode.desc": "Scegli la forma di base della grafica disegnata sullo schermo.", + "settings.mode.wave": "Onda", + "settings.mode.pad": "Pad", + "settings.mode.circle": "Cerchio", + "settings.mode.outline": "Contorno", + "settings.sound_mode.title": "Modalità audio", + "settings.sound_mode.desc": "Scegli i canali audio adatti ai tuoi altoparlanti.\nCon altoparlanti a 2 canali, scegli “2 canali”. Con altoparlanti surround, scegli “5.1 canali” o “7.1 canali”.", + "settings.channels.2": "2 canali", + "settings.channels.5_1": "5.1 canali", + "settings.channels.7_1": "7.1 canali", + "settings.max_fps.title": "Frequenza fotogrammi max", + "settings.max_fps.desc": "Imposta la frequenza fotogrammi massima con cui viene disegnata la grafica.", + "settings.section.mode_detail": "Opzioni per modalità", + "settings.mode_card.wave": "Modalità Onda", + "settings.mode_card.pad": "Modalità Pad", + "settings.mode_card.circle": "Modalità Cerchio", + "settings.mode_card.outline": "Modalità Contorno", + "settings.size_lock.title": "Blocca dimensione", + "settings.size_lock.desc": "Mantiene la grafica a una dimensione fissa e mostra il volume tramite la trasparenza.", + "settings.fixed_size.wave": "Dimensione onda", + "settings.fixed_size.pad": "Dimensione pad", + "settings.fixed_size.circle": "Dimensione cerchio", + "settings.fixed_size.outline": "Spessore contorno", + "settings.fixed_size.desc": "Imposta la dimensione fissa che la grafica mantiene mentre cambia la trasparenza.", + "settings.max_opacity.title": "Opacità max", + "settings.max_opacity.desc": "Quanto diventa opaca la grafica quando il suono è al massimo.", + "settings.size.title": "Dimensione", + "settings.size.desc.wave": "Regola l'altezza dell'onda e quanto appare corposa nel complesso.", + "settings.size.desc.pad": "Regola lo spessore e la larghezza dei pad quando reagiscono al suono.", + "settings.size.desc.circle": "Regola quanto si gonfia l'equalizzatore circolare quando c'è un suono.", + "settings.size.desc.outline": "Regola l'altezza dell'onda di contorno e quanto appare corposa nel complesso.", + "settings.opacity.title": "Opacità", + "settings.opacity.desc.wave": "Con valori alti la grafica è più marcata; con valori bassi è più tenue e trasparente.", + "settings.opacity.desc.pad": "Con valori alti la grafica è più marcata; con valori bassi è più tenue e trasparente.", + "settings.opacity.desc.circle": "Con valori alti la grafica è più marcata; con valori bassi è più tenue e trasparente.", + "settings.opacity.desc.outline": "Con valori alti la grafica è più marcata; con valori bassi è più tenue e trasparente.", + "settings.speed.title": "Velocità", + "settings.speed.desc.wave": "Regola la velocità con cui l'onda scorre e si increspa.", + "settings.speed.desc.pad": "Regola la velocità con cui i pad ruotano verso la direzione da cui arriva un suono.", + "settings.speed.desc.circle": "Regola la velocità con cui l'equalizzatore circolare si increspa e ruota in risposta al suono.", + "settings.speed.desc.outline": "Regola la velocità con cui l'onda di contorno scorre e si increspa.", + "settings.sensitivity.title": "Sensibilità", + "settings.sensitivity.desc.wave": "Regola la sensibilità con cui l'onda reagisce e si agita, anche con i suoni deboli.", + "settings.sensitivity.desc.pad": "Regola i pad in modo che captino meglio il suono e risaltino di più.", + "settings.sensitivity.desc.circle": "Regola con quanta finezza l'ampiezza del cerchio si frammenta reagendo al suono.", + "settings.sensitivity.desc.outline": "Regola la sensibilità con cui l'onda di contorno reagisce e si agita, anche con i suoni deboli.", + "settings.glow.title": "Bagliore", + "settings.glow.desc.wave": "Aggiunge un morbido bagliore al neon lungo il bordo dell'onda e ne regola l'intensità.\nNota: consuma più risorse della GPU.", + "settings.glow.desc.pad": "Aggiunge un morbido bagliore, simile a un'aura, attorno ai pad.\nNota: consuma più risorse della GPU.", + "settings.glow.desc.circle": "Aggiunge un alone scintillante lungo il bordo dell'equalizzatore circolare.\nNota: consuma più risorse della GPU.", + "settings.glow.desc.outline": "Aggiunge al contorno un morbido bagliore al neon e ne regola l'intensità.\nNota: consuma più risorse della GPU.", + "settings.radius.title": "Raggio", + "settings.radius.desc": "Imposta il raggio dell'anello centrale nella modalità Cerchio.", + "settings.section.sound_types": "Tipi di suono (IA)", + "settings.show_ambient": "Suoni ambientali", + "settings.show_speech": "Voci", + "settings.show_danger": "Suoni di pericolo", + "settings.set_color": "Scegli colore", + "settings.set_color_for": "Scegli colore: {0}", + "settings.section.hotkeys": "Scorciatoie da tastiera", + "settings.hotkey.visual.title": "Cambia modalità visiva", + "settings.hotkey.visual.desc": "Scorciatoia che cambia la forma in tempo reale durante l'esecuzione.", + "settings.hotkey.sound.title": "Cambia modalità audio", + "settings.hotkey.sound.desc": "Scorciatoia che cambia la modalità audio in tempo reale durante l'esecuzione.", + "settings.hotkey.edit.title": "Impostazioni overlay", + "settings.hotkey.edit.desc": "Scorciatoia che apre o chiude l'editor dell'overlay.", + "settings.hotkey.set": "Imposta scorciatoia", + "settings.section.advanced": "Impostazioni avanzate", + "settings.developer_mode.title": "Modalità sviluppatore", + "settings.developer_mode.desc": "Mostra in alto a sinistra dell'overlay cosa ha sentito l'IA (il nome del suono indicato, quanto era sicura e quanto tempo ha impiegato) e in alto a destra gli FPS. Le informazioni sono in inglese e non sono pensate per l'uso quotidiano.", + "settings.developer_record.title": "Salva i risultati in un file", + "settings.developer_record.desc": "Salva ogni risultato dell'IA (i nomi dei suoni con il punteggio più alto, quanto era sicura e i tempi) in un nuovo file CSV nella cartella ai-log accanto a SoundVisualizer.exe, così puoi rivedere in seguito cosa ha sentito. Se non è possibile scrivere in quella cartella, il file va in %LOCALAPPDATA%\\SoundVisualizer\\ai-log. I file possono diventare grandi, quindi disattivalo quando hai finito.", + "settings.auto_minimize.title": "Riduci a icona il launcher all'avvio", + "settings.auto_minimize.desc": "Riduce automaticamente a icona questa finestra del launcher all'avvio dell'overlay.", + + "sounds.title": "Scegli i tipi di suono", + "sounds.desc": "I suoni che l'IA riconosce sono raggruppati con suoni simili, in base al tipo a cui appartengono per impostazione predefinita. Se cambi il tipo di un gruppo, il colore dei suoi suoni segue subito il nuovo tipo.", + "sounds.sibling_note": "L'IA spesso riconosce uno stesso suono con più nomi insieme. Una sirena, per esempio, viene riconosciuta anche come “Allarme”, che si trova in un altro gruppo. Se dopo una modifica un suono appare ancora con il tipo precedente, cambia anche i gruppi o i suoni collegati.", + "sounds.advanced.title": "Modalità avanzata", + "sounds.advanced.desc": "Mostra ogni suono nel suo gruppo, così puoi cambiare i suoni uno per uno.", + "sounds.search.hint": "Cerca suoni", + "sounds.search.clear": "Cancella ricerca", + "sounds.filter.title": "Filtro", + "sounds.filter.all": "Tutti", + "sounds.filter.changed": "Modificati", + "sounds.type.ambient": "Suoni ambientali", + "sounds.type.speech": "Voci", + "sounds.type.danger": "Suoni di pericolo", + "sounds.type.default": "{0} (predefinito)", + "sounds.type.mixed": "Misto", + "sounds.changed_count": "Suoni modificati: {0}", + "sounds.changed_from": "Predefinito: {0}", + "sounds.reset.button": "Ripristina tutto", + "sounds.reset.title": "Ripristinare tutti i suoni?", + "sounds.reset.message": "Tutti i suoni che hai modificato torneranno al loro tipo predefinito.", + "sounds.reset.confirm": "Ripristina", + "sounds.reset.cancel": "Annulla", + "sounds.empty": "Nessun suono corrispondente.", + "sounds.group.mixed_note": "Alcuni suoni modificati uno per uno", + "sounds.group.changed_line": "Attuale: {0} · Predefinito: {1}", + "sounds.overwrite.title": "Cambiare tutto il gruppo?", + "sounds.overwrite.message": "Suoni del gruppo “{0}” che passeranno al tipo “{2}”: {1}. Verranno sovrascritti anche i suoni modificati uno per uno.", + "sounds.overwrite.confirm": "Cambia", + "sounds.overwrite.cancel": "Annulla", + "sounds.basic_warning.title": "Passare alla modalità base?", + "sounds.basic_warning.message": "Alcuni gruppi contengono suoni che hai modificato uno per uno. In modalità base, se scegli un tipo per uno di questi gruppi, tutti i suoi suoni prendono quel tipo e le modifiche fatte uno per uno in quel gruppo vengono sovrascritte. Non cambia nulla finché non scegli.", + "sounds.basic_warning.confirm": "Usa la modalità base", + "sounds.basic_warning.cancel": "Resta in modalità avanzata", + "sounds.cd.sound": "{0}, tipo: {1}", + "sounds.cd.sound_changed": "{0}, tipo: {1}, predefinito: {2}", + "sounds.cd.group": "Gruppo {0}, tipo: {1}, suoni: {2} ({3})", + "sounds.cd.group_changed": "Gruppo {0}, tipo: {1}, predefinito: {2}, suoni: {3} ({4})", + "sounds.cd.group_header": "Gruppo {0}, suoni: {1}", + + "hotkey.prompt": "Premi un tasto (Esc per annullare)", + "hotkey.none": "Nessuna", + "hotkey.button_name": "{0}, scorciatoia: {1}", + + "help.start.title": "Per iniziare", + "help.start.1": "Scegli la lingua in alto in questa finestra e, se vuoi, cambia l'aspetto della grafica nella scheda Impostazioni.", + "help.start.2": "Nella scheda Home, fai clic su “{0}”.", + "help.start.3": "La grafica viene disegnata lungo i bordi dello schermo, sopra le altre finestre come giochi e video. I clic la attraversano, così puoi continuare a usare la finestra sottostante.", + "help.start.4": "Per disattivare la visualizzazione, fai clic su “{0}” nella scheda Home, oppure fai clic con il pulsante destro sull'icona di SoundVisualizer nell'area di notifica della barra delle applicazioni e scegli “{0}”.", + "help.modes.title": "Modalità visive", + "help.mode.wave": "L'intero bordo dello schermo avanza verso l'interno in base al volume. Il lato da cui proviene il suono avanza di più.", + "help.mode.pad": "Lungo il bordo dello schermo si illuminano barre in 8 punti. È la modalità che mostra la direzione in modo più chiaro.", + "help.mode.circle": "Un anello al centro dello schermo si gonfia in direzione del suono.", + "help.mode.outline": "Disegna solo un sottile contorno dell'onda, così copre meno il gioco.", + "help.modes.note": "Opzioni come Dimensione e Opacità vengono salvate separatamente per ogni modalità e si possono modificare nella scheda Impostazioni.", + "help.sound.title": "Tipi di suono", + "help.sound.kinds": "L'IA classifica l'audio in suoni ambientali, voci e suoni di pericolo. Per impostazione predefinita, i suoni ambientali sono bianchi, le voci gialle e i suoni di pericolo rossi. Nella scheda Impostazioni puoi cambiare il colore di ogni tipo e scegliere se mostrarlo.", + "help.sound.groups": "Nella scheda Suoni i suoni simili sono raggruppati e scegli il tipo di ogni gruppo. Per esempio, se sposti il gruppo “Campanello della porta” tra i suoni di pericolo, quando l'IA riconosce un campanello della porta o il bussare, il suono prende il colore dei suoni di pericolo. Un suono che sposti tu tra i suoni di pericolo, però, partecipa solo al voto dell'IA sul tipo, quindi non significa che ogni campanello della porta verrà mostrato come suono di pericolo.", + "help.sound.advanced": "Per cambiare i suoni uno per uno, attiva la modalità avanzata.", + "help.sound.danger": "Quando viene rilevato un suono di pericolo (come uno sparo), il colore cambia all'istante invece di sfumare gradualmente.", + "help.pc.title": "Impostazioni PC e scorciatoie da tastiera", + "help.sound_mode.title": "Modalità audio", + "help.sound_mode.body": "Per distinguere anche il davanti dal dietro, oltre alla sinistra e alla destra, configura il dispositivo di uscita come “Surround 5.1” (6 canali) o “Surround 7.1” (8 canali) nelle impostazioni audio di Windows. Con un normale stereo (2 canali), l'app distingue la sinistra dalla destra, ma non il davanti dal dietro. La “Modalità audio” nella scheda Impostazioni si adatta al dispositivo di uscita all'avvio e quando il dispositivo cambia, e puoi comunque modificarla tu.", + "help.sound_mode.tip": "💡 Se non hai un dispositivo di uscita 7.1, puoi installare il {0} e impostare il dispositivo di uscita su 7.1 canali per mostrare l'overlay con l'audio surround 7.1.", + "help.sound_mode.tip_link": "driver virtuale VB-CABLE", + "help.hotkeys.title": "Scorciatoie personalizzate in tempo reale", + "help.hotkeys.body": "Nella scheda Impostazioni, fai clic su un pulsante di scorciatoia e premi un tasto o una combinazione di tasti (ad es. Ctrl + Shift + A) per cambiare la scorciatoia come preferisci. Premendo la scorciatoia, la modalità cambia subito, anche quando l'app è in background.", + "help.hotkeys.defaults": "Per impostazione predefinita, F2 cambia la modalità audio, F3 cambia la modalità visiva e F4 apre le impostazioni dell'overlay.", + "help.size_lock.title": "Modalità dimensione bloccata", + "help.size_lock.body": "La modalità dimensione bloccata mantiene la grafica a una dimensione fissa usando una trasparenza proporzionale al volume del suono. È utile se vuoi mantenere la grafica sempre della stessa dimensione continuando a vederla reagire al suono.", + "help.editor.title": "Modalità di modifica dell'overlay in tempo reale", + "help.editor.body": "Premi la scorciatoia assegnata (F4 per impostazione predefinita) per attivare la modalità di modifica dell'overlay. Puoi trascinare con il mouse sullo schermo per regolare il limite di dimensione della grafica e rifinire i dettagli nel pannello delle impostazioni che compare.", + "help.developer.title": "Modalità sviluppatore", + "help.developer.body": "Attivando la modalità sviluppatore, l'overlay mostra in alto a sinistra cosa ha sentito l'IA (il nome del suono, quanto era sicura e quanto tempo ha impiegato) e in alto a destra gli FPS. Se attivi anche “{0}”, ogni risultato viene salvato in un file CSV. È utile per verificare che il sistema funzioni correttamente.", + "help.notes.title": "Buono a sapersi", + "help.note.direction_surround": "Con un'uscita a 5.1 o 7.1 canali, la parte alta dello schermo mostra i suoni che arrivano da davanti e quella bassa i suoni che arrivano da dietro. I lati sinistro e destro mostrano i suoni da sinistra e da destra, mentre la grafica che compare contemporaneamente da tutti i lati dello schermo indica suoni ambientali o senza una direzione.", + "help.note.direction_stereo": "Con un'uscita a 2 canali (stereo), l'app distingue la sinistra dalla destra, ma non il davanti dal dietro. In questo caso la grafica in alto e in basso sullo schermo rappresenta i suoni che arrivano in modo simile da entrambi i lati e il senso di spazio.", + "help.note.sources": "Viene mostrato il suono di tutte le app riprodotte dal dispositivo di riproduzione predefinito di Windows. Il suono che un'app invia a un altro dispositivo, come delle cuffie usate solo per la chat vocale, non viene mostrato.", + "help.note.volume": "Se il volume di un'app è molto basso o disattivato, nell'app stessa o nel mixer volume di Windows, il suo suono potrebbe vedersi appena o non vedersi affatto. Se la grafica reagisce poco, alza un po' quel suono o aumenta la Sensibilità nella scheda Impostazioni.", + "help.note.ai": "La classificazione dell'IA può essere errata. Non affidarti a questa app come unica protezione quando è in gioco la sicurezza.", + "help.faq.title": "Domande frequenti", + "help.faq.stopped_q": "La visualizzazione si è disattivata senza che l'abbia interrotta.", + "help.faq.stopped_a": "Si disattiva quando l'acquisizione dell'audio si interrompe e non riprende entro pochi secondi, per esempio quando il dispositivo di riproduzione viene scollegato e non ce n'è un altro disponibile. In quel caso la scheda Home ne indica il motivo. Collega altoparlanti o cuffie, oppure imposta un dispositivo di riproduzione predefinito nelle impostazioni audio di Windows, poi avviala di nuovo.", + "help.faq.no_graphic_q": "Non vedo nessuna grafica.", + "help.faq.no_graphic_a": "Verifica che il suono venga riprodotto dal dispositivo di riproduzione predefinito di Windows e non sia disattivato, e se la scheda Home mostra un avviso. Se un gioco è in esecuzione a schermo intero esclusivo, prova la modalità finestra senza bordi o in finestra. Nella scheda Impostazioni controlla anche che l'Opacità non sia troppo bassa e che la visualizzazione del tipo di suono in riproduzione non sia disattivata.", + "help.faq.performance_q": "Usa molta CPU o GPU?", + "help.faq.performance_a": "Per alleggerirla, abbassa “{0}” nella scheda Impostazioni o disattiva “{1}”.", + "help.faq.language_q": "Come cambio la lingua dell'app?", + "help.faq.language_a": "Sceglila dall'elenco “{0}” in alto in questa finestra. Durante la visualizzazione puoi cambiarla anche nelle impostazioni dell'overlay. “{1}” segue la lingua di visualizzazione di Windows.", + "help.report.title": "Segnala un problema", + "help.report.desc": "Segnalaci un bug o un suono mostrato con il tipo di suono sbagliato. GitHub si apre nel browser con le informazioni di sistema qui sotto già inserite, quindi serve un account GitHub. Se non ce l'hai, inviaci invece un'email. Non viene mai inviato alcun audio.", + "help.report.github": "Segnala su GitHub", + "help.report.mail": "Segnala via email", + "help.report.copy": "Copia le informazioni di sistema", + "help.report.copied": "Informazioni di sistema copiate.", + "help.report.copy_failed": "Impossibile copiare negli appunti. Riprova.", + "help.report.no_browser": "Impossibile aprire un browser. Copia le informazioni di sistema e inviacele in un altro modo.", + "help.report.no_mail": "Nessuna app di posta per l'invio. Copia le informazioni di sistema e inviale in un altro modo.", + "help.report.issue_title": "Segnalazione dall'app", + "help.report.issue_body": "Che cosa è successo?\n\nChe cosa stavi ascoltando (app, tipo di suono)?\n\nChe cosa ti aspettavi invece?", + "help.about.title": "Informazioni", + "help.about.version": "Versione {0}", + "help.about.license": "Questa app è distribuita ai sensi della GNU Affero General Public License v3.0 (AGPL-3.0). I nomi e i gruppi dei suoni nella scheda Suoni si basano sull'ontologia AudioSet e sono distribuiti ai sensi della licenza CC BY-SA 4.0 (vedi “Licenze open source” qui sotto).", + "help.licenses.button": "Licenze open source", + "help.licenses.title": "Licenze open source", + "help.licenses.close": "Chiudi", + + "notice.ai_unavailable": "Il riconoscimento del tipo di suono non funziona: tutti i suoni vengono mostrati con il colore dei suoni ambientali. Prova a disattivare e riattivare la visualizzazione.", + "notice.ai_unavailable_hidden": "Il riconoscimento del tipo di suono non funziona: tutti i suoni contano come suoni ambientali, ma la visualizzazione dei suoni ambientali è disattivata, quindi non si vede niente. Attiva “{0}” nella scheda Impostazioni oppure prova a disattivare e riattivare la visualizzazione.", + "notice.stopped.title": "Visualizzazione suoni disattivata", + "notice.stopped.no_device": "Non è stato possibile aprire nessun dispositivo di riproduzione, quindi l'audio non può essere acquisito. Collega altoparlanti o cuffie, oppure imposta un dispositivo di riproduzione predefinito nelle impostazioni audio di Windows, poi avvia di nuovo.", + "notice.stopped.capture_error": "L'acquisizione dell'audio si è interrotta per un errore, quindi la grafica è disattivata.", + "notice.dismiss": "Chiudi avviso", + + "tray.open": "Apri il launcher", + "tray.exit": "Esci", + + "more_info.title": "Repository GitHub e contributi", + "more_info.body": "SoundVisualizer è un progetto open source e il suo codice sorgente è pubblico su GitHub. Puoi partecipare segnalando bug, suggerendo funzionalità o contribuendo allo sviluppo. Fai clic sul pulsante qui sotto per visitare il repository ufficiale su GitHub! Nella scheda Releases di GitHub puoi anche controllare e scaricare l'ultima versione.", + "more_info.github_button": "Visita il repository GitHub", + + "dialog.notice_title": "Avviso", + "dialog.already_running": "L'overlay è già in esecuzione.", + "dialog.reset_title": "Ripristina impostazioni", + "dialog.reset_wave": "Ripristinare le impostazioni predefinite della modalità Onda?", + "dialog.reset_pad": "Ripristinare le impostazioni predefinite della modalità Pad?", + "dialog.reset_circle": "Ripristinare le impostazioni predefinite della modalità Cerchio?", + "dialog.reset_outline": "Ripristinare le impostazioni predefinite della modalità Contorno?", + "dialog.reset_sound_types": "Ripristinare le impostazioni predefinite dei tipi di suono?", + "dialog.reset_all_title": "Ripristina valori predefiniti", + "dialog.reset_all": "Vuoi davvero ripristinare tutte le impostazioni ai valori predefiniti?", + "dialog.link_error": "Impossibile aprire il link: {0}", + + "editor.title": "Impostazioni overlay", + "editor.drag_tip": "Trascina con il mouse le linee guida sullo schermo per regolare in tempo reale il limite di altezza e la dimensione del centro dell'onda dell'overlay!", + "editor.glow": "Effetto bagliore", + "editor.glow_strength": "Intensità", + "editor.color": "Colore", + "editor.hotkeys": "Impostazioni scorciatoie", + "editor.developer_mode_desc": "Cosa ha sentito l'IA (in alto a sinistra) e gli FPS (in alto a destra)", + "editor.save_close_hint": "per salvare e chiudere", + "editor.close_overlay": "Chiudi l'overlay", + "editor.guide.limit": "Limite in modalità {0}", + "editor.guide.size": "Dimensione in modalità {0}: {1}%", + "editor.guide.base_radius": "Raggio base: {0}", + "editor.guide.circle_size": "Dimensione cerchio: {0}%", + + "hud.visual_mode": "Modalità visiva: [{0}] {1}", + "hud.sound_mode": "Modalità audio: [{0}] {1}", + "hud.overlay_settings": "Impostazioni overlay: [{0}]", + "hud.status.ready": "Sistema pronto", + "hud.status.stereo": "Audio del PC: {0}ch (stereo)\nSorgente: in attesa...", + "hud.status.surround": "Audio del PC: 8ch (7.1)\nSorgente: in attesa...", + + "color_picker.title": "Scegli un colore", + "color_picker.hex": "Codice HEX", + "color_picker.cancel": "Annulla", + "color_picker.apply": "Applica", + "color_picker.area": "Saturazione e luminosità", + "color_picker.area_value": "Saturazione {0}%, luminosità {1}%", + "color_picker.hue": "Tonalità", + "color_picker.keys_help": "Usa i tasti freccia per cambiarlo. Tieni premuto Maiusc per cambiarlo a passi più grandi.", + "color_picker.presets": "Colori comuni", + "color_picker.preset.white": "Bianco", + "color_picker.preset.red": "Rosso", + "color_picker.preset.orange": "Arancione", + "color_picker.preset.yellow": "Giallo", + "color_picker.preset.green": "Verde", + "color_picker.preset.cyan": "Turchese", + "color_picker.preset.blue": "Blu", + "color_picker.preset.magenta": "Magenta", + + "audio.error.no_device": "Nessun dispositivo di uscita trovato.", + "audio.error.capture_stopped": "L'acquisizione si è interrotta in modo imprevisto: {0}", + "audio.error.capture_start_failed": "Impossibile avviare il motore di acquisizione: {0}", + + "tutorial.window_title": "Tutorial", + "tutorial.skip": "Salta", + "tutorial.previous": "Pagina precedente", + "tutorial.next": "Avanti", + "tutorial.done": "Ho capito", + "tutorial.page_status": "Pagina {0} di {1}", + "tutorial.pause": "Metti in pausa l'animazione", + "tutorial.play": "Riproduci l'animazione", + "tutorial.sound_on": "Attiva l'audio", + "tutorial.sound_off": "Disattiva l'audio", + "tutorial.sound.title": "Vedi i suoni sullo schermo", + "tutorial.sound.body": "Il suono del gioco o del video viene disegnato lungo i bordi dello schermo. Più il suono è forte, più il disegno è grande. Viene mostrato solo l'audio riprodotto su questo PC. I suoni intorno a te non vengono captati.", + "tutorial.direction.title": "Mostra da dove arriva il suono", + "tutorial.direction.body": "Con un'uscita a 2 canali (stereo), un suono a sinistra viene disegnato più grande sul lato sinistro, e un suono a destra sul lato destro. Con un'uscita a 5.1 o 7.1 canali, l'app distingue anche il davanti dal dietro: i suoni che arrivano da davanti vengono disegnati in alto sullo schermo e quelli che arrivano da dietro in basso.", + "tutorial.types.title": "Ogni tipo di suono ha il suo colore", + "tutorial.types.body": "L'IA classifica i suoni in tre tipi e disegna ogni tipo con un colore diverso. Puoi cambiare i colori nella scheda Impostazioni.", + "tutorial.type.ambient_examples": "Musica, pioggia e tutto il resto", + "tutorial.type.speech_examples": "Persone che parlano", + "tutorial.type.danger_examples": "Spari, esplosioni, sirene", + "tutorial.start.title": "Come si attiva e si disattiva", + "tutorial.start.hotkeys": "Durante la visualizzazione, premi {0} per cambiare la modalità audio, {1} per cambiare la modalità visiva e {2} per aprire le impostazioni dell'overlay." +} diff --git a/SoundVisualizer.Core/Localization/Strings/ja.json b/SoundVisualizer.Core/Localization/Strings/ja.json new file mode 100644 index 0000000..ac0e2f1 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/ja.json @@ -0,0 +1,294 @@ +{ + "common.language": "言語", + "common.reset_defaults": "初期設定に戻す", + "common.minimize": "最小化", + "common.close": "閉じる", + "language.system": "システムのデフォルト", + + "launcher.window_title": "SoundVisualizer ランチャー", + "launcher.tab.home": "ホーム", + "launcher.tab.settings": "設定", + "launcher.tab.sounds": "分類", + "launcher.tab.help": "ヘルプ", + "launcher.tab.more_info": "詳細情報", + "launcher.home.desc": "目に見えない音を画面に描き出します。\nゲームも映画も、新しい体験をどうぞ。", + "launcher.home.start": "開始", + "launcher.home.stop": "停止", + "launcher.home.tutorial": "チュートリアルを見る", + "launcher.status.running": "状態:実行中", + "launcher.status.idle": "状態:待機中", + + "settings.section.mode": "モード設定", + "settings.visual_mode.title": "表示モード", + "settings.visual_mode.desc": "画面に描くグラフィックの基本的な形を選びます。", + "settings.mode.wave": "波", + "settings.mode.pad": "パッド", + "settings.mode.circle": "円形", + "settings.mode.outline": "輪郭線", + "settings.sound_mode.title": "サウンドモード", + "settings.sound_mode.desc": "スピーカーに合ったサウンドチャンネルを選びます。\n2チャンネルのスピーカーなら「2チャンネル」、サラウンドスピーカーなら「5.1チャンネル」か「7.1チャンネル」を選んでください。", + "settings.channels.2": "2チャンネル", + "settings.channels.5_1": "5.1チャンネル", + "settings.channels.7_1": "7.1チャンネル", + "settings.max_fps.title": "最大フレームレート", + "settings.max_fps.desc": "グラフィックを描画するフレームレートの上限を設定します。", + "settings.section.mode_detail": "モード別の詳細設定", + "settings.mode_card.wave": "波モード", + "settings.mode_card.pad": "パッドモード", + "settings.mode_card.circle": "円形モード", + "settings.mode_card.outline": "輪郭線モード", + "settings.size_lock.title": "サイズを固定", + "settings.size_lock.desc": "グラフィックのサイズを固定し、音の大きさを濃さで表します。", + "settings.fixed_size.wave": "波のサイズ", + "settings.fixed_size.pad": "パッドのサイズ", + "settings.fixed_size.circle": "円形のサイズ", + "settings.fixed_size.outline": "輪郭線の太さ", + "settings.fixed_size.desc": "濃さが変わってもグラフィックが保つ大きさを設定します。", + "settings.max_opacity.title": "最大の濃さ", + "settings.max_opacity.desc": "音が最も大きいときのグラフィックの濃さです。", + "settings.size.title": "サイズ", + "settings.size.desc.wave": "波の上下の高さと全体のボリューム感を調整します。", + "settings.size.desc.pad": "音に反応したときのパッドの太さと横幅を調整します。", + "settings.size.desc.circle": "音が鳴ったときに、円形イコライザーがどれだけ外へふくらむかを調整します。", + "settings.size.desc.outline": "輪郭線の波の上下の高さと全体のボリューム感を調整します。", + "settings.opacity.title": "濃さ", + "settings.opacity.desc.wave": "値が大きいほど濃く、小さいほど薄く透けて見えます。", + "settings.opacity.desc.pad": "値が大きいほど濃く、小さいほど薄く透けて見えます。", + "settings.opacity.desc.circle": "値が大きいほど濃く、小さいほど薄く透けて見えます。", + "settings.opacity.desc.outline": "値が大きいほど濃く、小さいほど薄く透けて見えます。", + "settings.speed.title": "速度", + "settings.speed.desc.wave": "波が流れて揺らめく速さを調整します。", + "settings.speed.desc.pad": "音のする方向へパッドが向きを変える速さを調整します。", + "settings.speed.desc.circle": "音に反応して円形イコライザーが波打ったり回転したりする速さを調整します。", + "settings.speed.desc.outline": "輪郭線の波が流れて揺らめく速さを調整します。", + "settings.sensitivity.title": "感度", + "settings.sensitivity.desc.wave": "小さな音にも波がどれだけ敏感に反応して揺れるかを調整します。", + "settings.sensitivity.desc.pad": "パッドが音をよく捉え、よりはっきり目立つように調整します。", + "settings.sensitivity.desc.circle": "音に反応するとき、円形の振幅をどれだけ細かく分けて動かすかを調整します。", + "settings.sensitivity.desc.outline": "小さな音にも輪郭線の波がどれだけ敏感に反応して揺れるかを調整します。", + "settings.glow.title": "発光", + "settings.glow.desc.wave": "波の縁に柔らかなネオン風の光を加え、その強さを調整します。\n注意:GPU の負荷が増えます。", + "settings.glow.desc.pad": "パッドの周りに、オーラのような柔らかな光を加えます。\n注意:GPU の負荷が増えます。", + "settings.glow.desc.circle": "円形イコライザーの縁に沿って、きらめく光の輪を加えます。\n注意:GPU の負荷が増えます。", + "settings.glow.desc.outline": "輪郭線に柔らかなネオン風の光を加え、その強さを調整します。\n注意:GPU の負荷が増えます。", + "settings.radius.title": "半径", + "settings.radius.desc": "円形モードで、中央のリングの半径を調整します。", + "settings.section.sound_types": "音の種類(AI)", + "settings.show_ambient": "環境音を表示", + "settings.show_speech": "話し声を表示", + "settings.show_danger": "危険音を表示", + "settings.set_color": "色を設定", + "settings.set_color_for": "色を設定:{0}", + "settings.section.hotkeys": "ショートカットキー", + "settings.hotkey.visual.title": "表示モードの切り替え", + "settings.hotkey.visual.desc": "実行中に形をリアルタイムで切り替えるショートカットキーです。", + "settings.hotkey.sound.title": "サウンドモードの切り替え", + "settings.hotkey.sound.desc": "実行中にサウンドモードをリアルタイムで切り替えるショートカットキーです。", + "settings.hotkey.edit.title": "オーバーレイ設定", + "settings.hotkey.edit.desc": "オーバーレイの編集画面を開いたり閉じたりするショートカットキーです。", + "settings.hotkey.set": "キーを設定", + "settings.section.advanced": "詳細設定", + "settings.developer_mode.title": "開発者モード", + "settings.developer_mode.desc": "AI が聞き取った内容(報告した音の名前、確信度、かかった時間)をオーバーレイの左上に、FPS を右上に表示します。表示は英語で、普段使うためのものではありません。", + "settings.developer_record.title": "結果をファイルに記録", + "settings.developer_record.desc": "AI の結果を毎回、SoundVisualizer.exe と同じ場所にある ai-log フォルダーの新しい CSV ファイルに書き出します。いちばん高く評価した音の名前、確信度、かかった時間も記録されるので、何の音と判断したかを後から振り返れます。そのフォルダーに書き込めない場合は %LOCALAPPDATA%\\SoundVisualizer\\ai-log に保存します。ファイルが大きくなることがあるので、確認が終わったらオフにしてください。", + "settings.auto_minimize.title": "開始時にランチャーを最小化", + "settings.auto_minimize.desc": "オーバーレイの開始時に、このランチャーウィンドウを自動で最小化します。", + + "sounds.title": "音の種類を選ぶ", + "sounds.desc": "AI が認識する音を、似た音どうしでグループにまとめ、初期設定の種類ごとに表示します。グループの種類を変えると、そのグループの音の色はすぐに新しい種類に合わせて変わります。", + "sounds.sibling_note": "AI は一つの音を複数の名前で同時に聞き取ることがよくあります。たとえばサイレンは「警報音」としても聞き取られますが、警報音は別のグループにあります。変更後も前の種類で表示される場合は、関連するグループや音も変更してください。", + "sounds.advanced.title": "詳細モード", + "sounds.advanced.desc": "すべての音をグループごとに表示し、個別に変更できます。", + "sounds.search.hint": "音の名前で検索", + "sounds.search.clear": "検索語を消去", + "sounds.filter.title": "絞り込み", + "sounds.filter.all": "すべて", + "sounds.filter.changed": "変更した音", + "sounds.type.ambient": "環境音", + "sounds.type.speech": "話し声", + "sounds.type.danger": "危険音", + "sounds.type.default": "{0}(初期設定)", + "sounds.type.mixed": "混在", + "sounds.changed_count": "変更した音:{0}件", + "sounds.changed_from": "初期設定:{0}", + "sounds.reset.button": "すべてリセット", + "sounds.reset.title": "すべての音を初期設定に戻しますか?", + "sounds.reset.message": "変更した音はすべて、初期設定の種類に戻ります。", + "sounds.reset.confirm": "リセット", + "sounds.reset.cancel": "キャンセル", + "sounds.empty": "該当する音がありません。", + "sounds.group.mixed_note": "一部の音を個別に変更済み", + "sounds.group.changed_line": "現在:{0} · 初期設定:{1}", + "sounds.overwrite.title": "グループ全体を変更しますか?", + "sounds.overwrite.message": "「{0}」グループの音{1}件が「{2}」に変わります。個別に変更した音も上書きされます。", + "sounds.overwrite.confirm": "変更", + "sounds.overwrite.cancel": "キャンセル", + "sounds.basic_warning.title": "基本モードに切り替えますか?", + "sounds.basic_warning.message": "個別に変更した音を含むグループがあります。基本モードでこうしたグループの種類を選ぶと、グループ内のすべての音がその種類になり、個別に変更した内容は上書きされます。種類を選ぶまでは何も変わりません。", + "sounds.basic_warning.confirm": "基本モードにする", + "sounds.basic_warning.cancel": "詳細モードのまま", + "sounds.cd.sound": "{0}、種類:{1}", + "sounds.cd.sound_changed": "{0}、種類:{1}、初期設定:{2}", + "sounds.cd.group": "グループ:{0}、種類:{1}、音:{2}件({3})", + "sounds.cd.group_changed": "グループ:{0}、種類:{1}、初期設定:{2}、音:{3}件({4})", + "sounds.cd.group_header": "グループ:{0}、音:{1}件", + + "hotkey.prompt": "キーを押してください(Esc でキャンセル)", + "hotkey.none": "なし", + "hotkey.button_name": "{0}、ショートカットキー:{1}", + + "help.start.title": "はじめに", + "help.start.1": "このウィンドウの上で言語を選び、必要に応じて「設定」タブでグラフィックの見た目を変更します。", + "help.start.2": "「ホーム」タブで「{0}」をクリックします。", + "help.start.3": "グラフィックは、ゲームや動画などほかのウィンドウの上に、画面の端に沿って描かれます。クリックはグラフィックを通り抜けるので、下にあるウィンドウをそのまま操作できます。", + "help.start.4": "オフにするには、「ホーム」タブで「{0}」をクリックするか、タスクバーの通知領域にある SoundVisualizer のアイコンを右クリックして「{0}」を選びます。", + "help.modes.title": "表示モード", + "help.mode.wave": "画面の端全体が、音の大きさに合わせて内側へ押し寄せます。音のする側は、さらに深く押し寄せます。", + "help.mode.pad": "画面の端の8か所にバーが浮かび上がります。方向が最もはっきり分かるモードです。", + "help.mode.circle": "画面中央のリングが、音のする方向へふくらみます。", + "help.mode.outline": "波の輪郭線だけを細く描くので、ゲーム画面が隠れにくくなります。", + "help.modes.note": "「サイズ」や「濃さ」などの設定はモードごとに保存され、「設定」タブで変更できます。", + "help.sound.title": "音の種類", + "help.sound.kinds": "聞こえてくる音を、AI が環境音・話し声・危険音に分類します。初期設定の色は、環境音が白、話し声が黄色、危険音が赤です。種類ごとの色と表示の有無は「設定」タブで変更できます。", + "help.sound.groups": "「分類」タブでは、似た音をグループにまとめ、グループごとにどの種類として扱うかを選びます。たとえば「玄関チャイム」グループを危険音に変えると、AI が玄関チャイムやノックとして聞き取ったときに危険音の色が使われます。ただし、新しく危険音にした音は AI が種類を決める投票に加わるだけなので、玄関チャイムが鳴るたびに危険音として表示されるわけではありません。", + "help.sound.advanced": "音を個別に変更するには、詳細モードをオンにします。", + "help.sound.danger": "危険音(銃声など)を検出すると、色が徐々にではなく、すぐに切り替わります。", + "help.pc.title": "PC の設定とショートカットキー", + "help.sound_mode.title": "サウンドモード", + "help.sound_mode.body": "前後の音まで区別して表示するには、Windows のサウンド設定で出力デバイスを「5.1 サラウンド」(6チャンネル)または「7.1 サラウンド」(8チャンネル)に設定してください。通常のステレオ(2チャンネル)では左右は区別できますが、前後は区別できません。「設定」タブの「サウンドモード」は、起動時と出力デバイスが変わったときにそのデバイスに合わせて切り替わり、自分で変更することもできます。", + "help.sound_mode.tip": "💡 7.1の出力デバイスがない場合は、{0}をインストールして出力デバイスを7.1チャンネルに設定すると、7.1チャンネルのサラウンドでオーバーレイを表示できます。", + "help.sound_mode.tip_link": "VB-CABLE 仮想ドライバー", + "help.hotkeys.title": "リアルタイムのカスタムショートカットキー", + "help.hotkeys.body": "「設定」タブでショートカットキーのボタンをクリックし、単独のキーまたはキーの組み合わせ(例:Ctrl + Shift + A)を押すと、ショートカットキーを好きなように変更できます。アプリがバックグラウンドにあるときでも、ショートカットキーを押すとすぐにモードが切り替わります。", + "help.hotkeys.defaults": "初期設定では、F2 でサウンドモード、F3 で表示モードを切り替え、F4 でオーバーレイ設定を開きます。", + "help.size_lock.title": "サイズ固定モード", + "help.size_lock.body": "サイズ固定モードは、音の大きさをサイズではなく濃さで表し、グラフィックのサイズを一定に保つ設定です。グラフィックの大きさを変えずに、音への反応を見たいときに便利です。", + "help.editor.title": "オーバーレイのリアルタイム編集モード", + "help.editor.body": "割り当てたショートカットキー(初期設定は F4)を押すと、オーバーレイの編集モードがオンになります。画面上でマウスをドラッグしてグラフィックのサイズの上限を調整でき、表示される設定パネルで細かな調整ができます。", + "help.developer.title": "開発者モード", + "help.developer.body": "開発者モードをオンにすると、AI が聞き取った内容(音の名前、確信度、かかった時間)がオーバーレイの左上に、FPS が右上に表示されます。「{0}」もオンにすると、すべての結果が CSV ファイルに保存されます。システムが正しく動作しているか確認したいときに便利です。", + "help.notes.title": "知っておきたいこと", + "help.note.direction_surround": "5.1 または 7.1 チャンネルで出力すると、画面の上側は前方からの音、下側は後方からの音を表します。左右はそれぞれ左と右からの音を表し、画面の四方すべてから同時に現れるグラフィックは、環境音や方向のない音です。", + "help.note.direction_stereo": "2 チャンネル(ステレオ)で出力すると、左右は区別できますが、前後は区別できません。このとき画面の上下に表示されるグラフィックは、左右から同じくらい聞こえる音と、空間の広がりを表します。", + "help.note.sources": "Windows の既定の再生デバイスから出ている、すべてのアプリの音を表示します。アプリがほかのデバイスに送っている音(ボイスチャット専用のヘッドセットなど)は表示されません。", + "help.note.volume": "アプリ内や Windows の音量ミキサーで、そのアプリの音量をかなり下げたりミュートしたりすると、音がほとんど、またはまったく表示されないことがあります。反応が弱いときは、その音を少し大きくするか、「設定」タブで「感度」を上げてください。", + "help.note.ai": "AI による分類は間違うことがあります。安全に関わる場面では、このアプリだけに頼らないでください。", + "help.faq.title": "よくある質問", + "help.faq.stopped_q": "自分でオフにしていないのにオフになりました。", + "help.faq.stopped_a": "音の取得が止まり、数秒以内に戻らないとオフになります。たとえば、再生デバイスが外れて、ほかに使えるデバイスがない場合です。そのときは「ホーム」タブに理由が表示されます。スピーカーやヘッドホンを接続するか、Windows のサウンド設定で既定の再生デバイスを設定してから、もう一度開始してください。", + "help.faq.no_graphic_q": "グラフィックが表示されません。", + "help.faq.no_graphic_a": "音が Windows の既定の再生デバイスから出ていて、ミュートになっていないか、「ホーム」タブにお知らせが出ていないかを確認してください。ゲームを排他的フルスクリーンで実行している場合は、ボーダーレスウィンドウやウィンドウモードを試してください。また、「設定」タブで「濃さ」が低すぎないか、今鳴っている音の種類の表示がオフになっていないかも確認してください。", + "help.faq.performance_q": "CPU や GPU を多く使いますか?", + "help.faq.performance_a": "負荷を軽くするには、「設定」タブで「{0}」を下げるか、「{1}」をオフにしてください。", + "help.faq.language_q": "アプリの言語はどうやって変更しますか?", + "help.faq.language_a": "このウィンドウの上にある「{0}」の一覧から選びます。実行中はオーバーレイ設定でも変更できます。「{1}」を選ぶと、Windows の表示言語に合わせて表示されます。", + "help.report.title": "問題を報告", + "help.report.desc": "バグを見つけたときや、音が間違った種類として表示されたときは、お知らせください。ブラウザで GitHub が開き、下の環境情報があらかじめ入力されます。GitHub のアカウントが必要なため、アカウントがない場合はメールで送ってください。音声は一切送信されません。", + "help.report.github": "GitHub で報告", + "help.report.mail": "メールで報告", + "help.report.copy": "環境情報をコピー", + "help.report.copied": "環境情報をコピーしました。", + "help.report.copy_failed": "クリップボードにコピーできませんでした。もう一度お試しください。", + "help.report.no_browser": "ブラウザを開けませんでした。代わりに、環境情報をコピーして送ってください。", + "help.report.no_mail": "メールを送信できるアプリがありません。環境情報をコピーして、別の方法で送ってください。", + "help.report.issue_title": "アプリからの報告", + "help.report.issue_body": "何が起きましたか?\n\n何を聞いていましたか?(アプリ、音の種類)\n\n本来はどうなることを期待していましたか?", + "help.about.title": "このアプリについて", + "help.about.version": "バージョン {0}", + "help.about.license": "このアプリは GNU Affero General Public License v3.0(AGPL-3.0)のもとで配布されています。「分類」タブの音の名前とグループは AudioSet オントロジーをもとにしており、CC BY-SA 4.0 のもとで配布されています(詳しくは下の「オープンソース ライセンス」をご覧ください)。", + "help.licenses.button": "オープンソース ライセンス", + "help.licenses.title": "オープンソース ライセンス", + "help.licenses.close": "閉じる", + + "notice.ai_unavailable": "音の種類を判別できないため、すべての音が環境音の色で表示されています。可視化を一度オフにして、もう一度オンにしてみてください。", + "notice.ai_unavailable_hidden": "音の種類を判別できないため、すべての音が環境音として扱われますが、環境音の表示がオフなので何も表示されません。「設定」タブで「{0}」をオンにするか、可視化を一度オフにして、もう一度オンにしてみてください。", + "notice.stopped.title": "音の可視化がオフになりました", + "notice.stopped.no_device": "開ける再生デバイスがないため、音を取得できません。スピーカーやヘッドホンを接続するか、Windows のサウンド設定で既定の再生デバイスを設定してから、もう一度開始してください。", + "notice.stopped.capture_error": "エラーで音の取得が止まったため、グラフィックがオフになりました。", + "notice.dismiss": "お知らせを閉じる", + + "tray.open": "ランチャーを開く", + "tray.exit": "終了", + + "more_info.title": "GitHub リポジトリとプロジェクトへの貢献", + "more_info.body": "SoundVisualizer はオープンソースのプロジェクトで、ソースコードは GitHub で公開されています。バグの報告や機能の提案、開発への貢献を通じてプロジェクトに参加できます。下のボタンをクリックして、公式の GitHub リポジトリにアクセスしてみてください!GitHub の Releases タブでは、最新バージョンを確認してダウンロードすることもできます。", + "more_info.github_button": "GitHub リポジトリにアクセス", + + "dialog.notice_title": "お知らせ", + "dialog.already_running": "オーバーレイはすでに実行中です。", + "dialog.reset_title": "設定のリセット", + "dialog.reset_wave": "波モードの設定を初期設定に戻しますか?", + "dialog.reset_pad": "パッドモードの設定を初期設定に戻しますか?", + "dialog.reset_circle": "円形モードの設定を初期設定に戻しますか?", + "dialog.reset_outline": "輪郭線モードの設定を初期設定に戻しますか?", + "dialog.reset_sound_types": "音の種類の設定を初期設定に戻しますか?", + "dialog.reset_all_title": "初期設定に戻す", + "dialog.reset_all": "すべての設定を初期設定に戻してもよろしいですか?", + "dialog.link_error": "リンクを開けませんでした:{0}", + + "editor.title": "オーバーレイ設定", + "editor.drag_tip": "画面のガイドラインをマウスでドラッグすると、オーバーレイの波の高さの上限と中央のサイズをリアルタイムで調整できます!", + "editor.glow": "発光効果", + "editor.glow_strength": "発光の強さ", + "editor.color": "色", + "editor.hotkeys": "ショートカットキー設定", + "editor.developer_mode_desc": "AI が聞き取った内容(左上)と FPS(右上)を表示", + "editor.save_close_hint": "キーで設定を保存して閉じる", + "editor.close_overlay": "オーバーレイを終了", + "editor.guide.limit": "{0}の上限ライン", + "editor.guide.size": "{0}のサイズ:{1}%", + "editor.guide.base_radius": "基本半径:{0}", + "editor.guide.circle_size": "円形のサイズ:{0}%", + + "hud.visual_mode": "表示モード:[{0}] {1}", + "hud.sound_mode": "サウンドモード:[{0}] {1}", + "hud.overlay_settings": "オーバーレイ設定:[{0}]", + "hud.status.ready": "システム準備完了", + "hud.status.stereo": "PC のオーディオ:{0}ch(ステレオ)\nソース:待機中…", + "hud.status.surround": "PC のオーディオ:8ch(7.1)\nソース:待機中…", + + "color_picker.title": "色を選択", + "color_picker.hex": "HEX コード", + "color_picker.cancel": "キャンセル", + "color_picker.apply": "適用", + "color_picker.area": "彩度と明るさ", + "color_picker.area_value": "彩度 {0}%、明るさ {1}%", + "color_picker.hue": "色相", + "color_picker.keys_help": "矢印キーで変更します。Shift キーを押しながら押すと大きく変わります。", + "color_picker.presets": "よく使う色", + "color_picker.preset.white": "白", + "color_picker.preset.red": "赤", + "color_picker.preset.orange": "オレンジ", + "color_picker.preset.yellow": "黄色", + "color_picker.preset.green": "緑", + "color_picker.preset.cyan": "水色", + "color_picker.preset.blue": "青", + "color_picker.preset.magenta": "マゼンタ", + + "audio.error.no_device": "出力デバイスが見つかりません。", + "audio.error.capture_stopped": "キャプチャが予期せず停止しました:{0}", + "audio.error.capture_start_failed": "キャプチャエンジンを開始できませんでした:{0}", + + "tutorial.window_title": "チュートリアル", + "tutorial.skip": "スキップ", + "tutorial.previous": "前のページ", + "tutorial.next": "次へ", + "tutorial.done": "OK", + "tutorial.page_status": "{1}ページ中{0}ページ目", + "tutorial.pause": "アニメーションを一時停止", + "tutorial.play": "アニメーションを再生", + "tutorial.sound_on": "音を出す", + "tutorial.sound_off": "音を消す", + "tutorial.sound.title": "音を画面で見られます", + "tutorial.sound.body": "ゲームや動画の音が、画面の端に沿って描かれます。音が大きいほど、大きく描かれます。表示するのは、この PC で再生中の音だけです。周囲の音は拾いません。", + "tutorial.direction.title": "音の方向がわかります", + "tutorial.direction.body": "2 チャンネル(ステレオ)で出力すると、左からの音は画面の左側が、右からの音は右側が大きく描かれます。5.1 または 7.1 チャンネルで出力すると前後も区別し、前方からの音は画面の上側に、後方からの音は下側に描かれます。", + "tutorial.types.title": "音の種類を色で区別します", + "tutorial.types.body": "AI が音を3種類に分類し、それぞれ違う色で描きます。色は「設定」タブで変更できます。", + "tutorial.type.ambient_examples": "音楽・雨音など、その他の音", + "tutorial.type.speech_examples": "人の声", + "tutorial.type.danger_examples": "銃声・爆発音・サイレン", + "tutorial.start.title": "オン/オフの方法", + "tutorial.start.hotkeys": "実行中は、{0} でサウンドモード、{1} で表示モードを切り替え、{2} でオーバーレイ設定を開きます。" +} diff --git a/SoundVisualizer.Core/Localization/Strings/ko.json b/SoundVisualizer.Core/Localization/Strings/ko.json new file mode 100644 index 0000000..c4687a1 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/ko.json @@ -0,0 +1,294 @@ +{ + "common.language": "언어", + "common.reset_defaults": "기본값으로 되돌리기", + "common.minimize": "최소화", + "common.close": "닫기", + "language.system": "시스템 기본값", + + "launcher.window_title": "SoundVisualizer 런처", + "launcher.tab.home": "홈", + "launcher.tab.settings": "설정", + "launcher.tab.sounds": "분류", + "launcher.tab.help": "도움말", + "launcher.tab.more_info": "더 알아보기", + "launcher.home.desc": "보이지 않던 소리를 화면에 그려냅니다.\n게이밍부터 영화 감상까지 새로운 경험을 시작하세요.", + "launcher.home.start": "실행", + "launcher.home.stop": "실행 종료", + "launcher.home.tutorial": "튜토리얼 보기", + "launcher.status.running": "상태: 실행 중", + "launcher.status.idle": "상태: 실행 대기 중", + + "settings.section.mode": "모드 설정", + "settings.visual_mode.title": "표현 모드", + "settings.visual_mode.desc": "화면에 그려질 그래픽의 기본 형태를 선택합니다.", + "settings.mode.wave": "파도", + "settings.mode.pad": "패드", + "settings.mode.circle": "원형", + "settings.mode.outline": "외곽선", + "settings.sound_mode.title": "사운드 모드", + "settings.sound_mode.desc": "스피커 환경에 맞는 사운드 채널을 선택합니다.\n2채널 환경은 '2 채널', 서라운드 환경은 '5.1 채널' 또는 '7.1 채널'을 선택하세요.", + "settings.channels.2": "2 채널", + "settings.channels.5_1": "5.1 채널", + "settings.channels.7_1": "7.1 채널", + "settings.max_fps.title": "최대 프레임", + "settings.max_fps.desc": "그래픽이 그려지는 최대 프레임 속도를 설정합니다.", + "settings.section.mode_detail": "모드별 상세 설정", + "settings.mode_card.wave": "파도 모드", + "settings.mode_card.pad": "패드 모드", + "settings.mode_card.circle": "원형 모드", + "settings.mode_card.outline": "외곽선 모드", + "settings.size_lock.title": "크기 고정", + "settings.size_lock.desc": "세기에 비례하는 투명도를 활용하여 고정된 크기의 그래픽을 보여줍니다.", + "settings.fixed_size.wave": "파도 크기", + "settings.fixed_size.pad": "패드 크기", + "settings.fixed_size.circle": "원형 크기", + "settings.fixed_size.outline": "외곽선 두께", + "settings.fixed_size.desc": "투명도가 변할 기준이 되는 고정 크기를 설정합니다.", + "settings.max_opacity.title": "최대 진하기", + "settings.max_opacity.desc": "소리가 가장 클 때 도달할 진하기입니다.", + "settings.size.title": "크기", + "settings.size.desc.wave": "파도의 위아래 높이와 전체적인 볼륨감을 조절합니다.", + "settings.size.desc.pad": "사운드 반응 시 패드의 두께와 가로 영역 넓이를 조절합니다.", + "settings.size.desc.circle": "사운드가 울릴 때 원형 이퀄라이저의 돌출 진폭 스케일을 조절합니다.", + "settings.size.desc.outline": "외곽선 파도의 위아래 높이와 전체적인 볼륨감을 조절합니다.", + "settings.opacity.title": "진하기", + "settings.opacity.desc.wave": "값이 클수록 파도가 진하고, 작을수록 옅어져 뒤의 게임이나 화면이 더 비칩니다.", + "settings.opacity.desc.pad": "값이 클수록 패드가 진하고, 작을수록 옅고 투명해집니다.", + "settings.opacity.desc.circle": "값이 클수록 원형 그래픽이 진하고, 작을수록 옅고 투명해집니다.", + "settings.opacity.desc.outline": "값이 클수록 외곽선 파도가 진하고, 작을수록 옅고 투명해집니다.", + "settings.speed.title": "속도", + "settings.speed.desc.wave": "파도가 흘러가며 일렁이는 속도를 조절합니다.", + "settings.speed.desc.pad": "패드가 특정 방향의 사운드로 회전하며 반응하는 반응 속도를 조절합니다.", + "settings.speed.desc.circle": "원형 이퀄라이저의 물결이나 회전 반응 속도를 조절합니다.", + "settings.speed.desc.outline": "외곽선 파도가 흘러가며 일렁이는 속도를 조절합니다.", + "settings.sensitivity.title": "민감도", + "settings.sensitivity.desc.wave": "작은 데시벨 소리에도 파도가 얼마나 민감하게 반응하여 출렁일지 조절합니다.", + "settings.sensitivity.desc.pad": "패드가 소리를 더 잘 포착하여 더 큰 폭으로 도드라지게 조절합니다.", + "settings.sensitivity.desc.circle": "원형 진폭이 소리에 얼마나 섬세하게 쪼개지며 반응할지 조절합니다.", + "settings.sensitivity.desc.outline": "작은 데시벨 소리에도 외곽선 파도가 얼마나 민감하게 반응하여 출렁일지 조절합니다.", + "settings.glow.title": "광원", + "settings.glow.desc.wave": "파도 외곽선에 은은하게 빛나는 네온 광원을 입히고 그 강도를 조절합니다.\n주의: GPU 리소스를 추가로 사용합니다.", + "settings.glow.desc.pad": "패드 주변에 부드러운 아우라 형식의 광원 효과를 부여합니다.\n주의: GPU 리소스를 추가로 사용합니다.", + "settings.glow.desc.circle": "원형 이퀄라이저의 테두리를 따라 반짝이는 글로우 후광을 만듭니다.\n주의: GPU 리소스를 추가로 사용합니다.", + "settings.glow.desc.outline": "외곽선에 은은하게 빛나는 네온 광원을 입히고 그 강도를 조절합니다.\n주의: GPU 리소스를 추가로 사용합니다.", + "settings.radius.title": "반지름", + "settings.radius.desc": "원형 모드에서 가운데 고리의 반지름을 정합니다.", + "settings.section.sound_types": "소리 분류 표시 (AI)", + "settings.show_ambient": "환경음 표시", + "settings.show_speech": "대화음 표시", + "settings.show_danger": "위협음 표시", + "settings.set_color": "색상 설정", + "settings.set_color_for": "색상 설정: {0}", + "settings.section.hotkeys": "단축키", + "settings.hotkey.visual.title": "표현 모드 전환", + "settings.hotkey.visual.desc": "실행 중 형태를 실시간으로 변경할 단축키입니다.", + "settings.hotkey.sound.title": "사운드 모드 전환", + "settings.hotkey.sound.desc": "실행 중 사운드 모드를 실시간으로 변경할 단축키입니다.", + "settings.hotkey.edit.title": "오버레이 설정", + "settings.hotkey.edit.desc": "오버레이 편집 화면을 표시하거나 닫는 단축키입니다.", + "settings.hotkey.set": "핫키 설정", + "settings.section.advanced": "고급 설정", + "settings.developer_mode.title": "개발자 모드", + "settings.developer_mode.desc": "AI가 무슨 소리로 들었는지, 얼마나 확신했는지, 얼마나 걸렸는지를 오버레이 왼쪽 위에, FPS를 오른쪽 위에 띄웁니다. 내용은 영어로 나오고, 평소에 쓰라고 만든 것이 아닙니다.", + "settings.developer_record.title": "결과를 파일로 기록", + "settings.developer_record.desc": "AI가 내놓은 결과를 매번 SoundVisualizer.exe 옆의 ai-log 폴더에 새 CSV 파일로 남깁니다. 가장 높게 본 소리 이름들과 확신도, 걸린 시간이 함께 적혀서 무슨 소리로 들었는지 나중에 되짚어 볼 수 있습니다. 그 폴더에 쓸 수 없으면 %LOCALAPPDATA%\\SoundVisualizer\\ai-log 폴더에 남깁니다. 파일이 커질 수 있으니 다 보고 나면 꺼 주세요.", + "settings.auto_minimize.title": "실행 시 런처 최소화", + "settings.auto_minimize.desc": "오버레이 실행 시 이 런처 창을 자동으로 최소화합니다.", + + "sounds.title": "소리 종류 선택", + "sounds.desc": "AI가 알아듣는 소리를 비슷한 것끼리 묶어, 기본 종류별로 보여 줍니다. 묶음의 종류를 바꾸면 그 소리들의 색이 바로 새 종류를 따릅니다.", + "sounds.sibling_note": "AI는 한 소리를 여러 이름으로 함께 들을 때가 많습니다. 예를 들어 사이렌은 ‘경보음’으로도 들리는데, 경보음은 다른 묶음에 있습니다. 바꾼 뒤에도 예전 종류로 보이면 관련된 묶음이나 소리도 함께 바꾸세요.", + "sounds.advanced.title": "고급 모드", + "sounds.advanced.desc": "모든 소리를 묶음별로 보여 주고, 하나씩 바꿀 수 있습니다.", + "sounds.search.hint": "소리 이름으로 찾기", + "sounds.search.clear": "검색어 지우기", + "sounds.filter.title": "필터", + "sounds.filter.all": "전체", + "sounds.filter.changed": "바꾼 소리", + "sounds.type.ambient": "환경음", + "sounds.type.speech": "대화음", + "sounds.type.danger": "위협음", + "sounds.type.default": "{0} (기본)", + "sounds.type.mixed": "일부 다름", + "sounds.changed_count": "바꾼 소리: {0}개", + "sounds.changed_from": "기본: {0}", + "sounds.reset.button": "모두 기본값으로", + "sounds.reset.title": "모든 소리를 기본값으로 되돌릴까요?", + "sounds.reset.message": "바꾼 소리가 모두 기본 종류로 돌아갑니다.", + "sounds.reset.confirm": "되돌리기", + "sounds.reset.cancel": "취소", + "sounds.empty": "맞는 소리가 없습니다.", + "sounds.group.mixed_note": "일부 소리를 하나씩 바꿈", + "sounds.group.changed_line": "지금: {0} · 기본: {1}", + "sounds.overwrite.title": "묶음 전체를 바꿀까요?", + "sounds.overwrite.message": "‘{0}’ 묶음의 소리 {1}개가 ‘{2}’ 종류로 바뀝니다. 하나씩 바꿔 둔 소리도 덮어씁니다.", + "sounds.overwrite.confirm": "바꾸기", + "sounds.overwrite.cancel": "취소", + "sounds.basic_warning.title": "기본 모드로 바꿀까요?", + "sounds.basic_warning.message": "하나씩 바꾼 소리가 들어 있는 묶음이 있습니다. 기본 모드에서 이런 묶음의 종류를 선택하면 묶음의 모든 소리가 그 종류가 되어, 하나씩 바꾼 것이 덮어써집니다. 종류를 선택하기 전에는 아무것도 바뀌지 않습니다.", + "sounds.basic_warning.confirm": "기본 모드로", + "sounds.basic_warning.cancel": "고급 모드 유지", + "sounds.cd.sound": "{0}, 종류: {1}", + "sounds.cd.sound_changed": "{0}, 종류: {1}, 기본: {2}", + "sounds.cd.group": "{0} 묶음, 종류: {1}, 소리 {2}개 ({3})", + "sounds.cd.group_changed": "{0} 묶음, 종류: {1}, 기본: {2}, 소리 {3}개 ({4})", + "sounds.cd.group_header": "{0} 묶음, 소리 {1}개", + + "hotkey.prompt": "키를 누르세요 (ESC 취소)", + "hotkey.none": "없음", + "hotkey.button_name": "{0}, 단축키: {1}", + + "help.start.title": "시작하기", + "help.start.1": "이 창 위쪽에서 언어를 선택하고, 원하면 설정 탭에서 그래픽 모양을 바꿉니다.", + "help.start.2": "홈에서 ‘{0}’ 버튼을 누릅니다.", + "help.start.3": "그래픽은 게임이나 영상 같은 다른 창 위에 화면 테두리를 따라 그려집니다. 마우스 클릭은 그래픽을 지나가므로 아래 창을 그대로 쓸 수 있습니다.", + "help.start.4": "끌 때는 홈에서 ‘{0}’ 버튼을 누르거나, 작업 표시줄 알림 영역의 SoundVisualizer 아이콘을 마우스 오른쪽 버튼으로 눌러 ‘{0}’ 메뉴를 선택합니다.", + "help.modes.title": "표현 모드", + "help.mode.wave": "화면 테두리 전체가 소리 세기만큼 안쪽으로 밀려듭니다. 소리가 나는 쪽이 더 깊게 밀려옵니다.", + "help.mode.pad": "화면 가장자리 8곳에 막대가 떠오릅니다. 방향을 가장 또렷하게 볼 수 있습니다.", + "help.mode.circle": "화면 가운데의 고리가 소리 나는 쪽으로 부풀어 오릅니다.", + "help.mode.outline": "파도의 윤곽선만 가늘게 그려 게임 화면을 덜 가립니다.", + "help.modes.note": "크기·진하기 같은 설정은 모드마다 따로 저장되고, 설정 탭에서 바꿀 수 있습니다.", + "help.sound.title": "소리 종류", + "help.sound.kinds": "AI가 들리는 소리를 환경음·대화음·위협음으로 나눕니다. 기본색은 환경음 흰색, 대화음 노란색, 위협음 빨간색이고, 설정 탭에서 종류마다 색과 표시 여부를 바꿀 수 있습니다.", + "help.sound.groups": "분류 탭에서는 비슷한 소리를 묶음으로 모아, 묶음마다 어느 종류로 볼지 선택합니다. 예를 들어 ‘초인종’ 묶음을 위협음으로 바꾸면 AI가 초인종이나 노크 소리로 알아들을 때 위협음 색이 나옵니다. 다만 새로 위협음으로 정한 소리는 AI가 종류를 가리는 투표에만 들어가므로, 초인종이 울릴 때마다 위협음으로 알린다는 뜻은 아닙니다.", + "help.sound.advanced": "소리를 하나씩 바꾸려면 고급 모드를 켭니다.", + "help.sound.danger": "위협음(총소리 등)이 감지되면 색이 서서히 바뀌지 않고 즉시 바뀝니다.", + "help.pc.title": "PC 설정과 단축키", + "help.sound_mode.title": "사운드 모드", + "help.sound_mode.body": "앞뒤 소리까지 구분해서 보려면 Windows 소리 설정에서 출력 장치를 '5.1 서라운드'(6채널)나 '7.1 서라운드'(8채널)로 설정하세요. 일반 스테레오(2채널)에서는 좌우는 구분하지만 앞뒤는 구분하지 못합니다. 설정 탭의 '사운드 모드'는 실행할 때와 출력 장치가 바뀔 때 그 장치에 맞춰지며, 직접 바꿀 수도 있습니다.", + "help.sound_mode.tip": "💡 만약 7.1 출력 장치가 없다면 {0}를 설치하고 출력장치를 7.1채널로 설정한다면 7.1 서라운드 사운드를 사용하여 오버레이를 표현할 수 있습니다.", + "help.sound_mode.tip_link": "VB-CABLE 가상 드라이버", + "help.hotkeys.title": "실시간 커스텀 단축키 제어", + "help.hotkeys.body": "설정 탭에서 핫키 버튼을 클릭한 뒤, 단일 키 또는 복합 키 조합(예: Ctrl + Shift + A)을 눌러 자유롭게 단축키를 변경할 수 있습니다. 백그라운드에서 지정된 단축키를 누르면 실시간으로 모드가 즉시 전환됩니다.", + "help.hotkeys.defaults": "기본 단축키는 F2가 사운드 모드 전환, F3가 표현 모드 전환, F4가 오버레이 설정입니다.", + "help.size_lock.title": "크기 고정 모드", + "help.size_lock.body": "크기 고정 모드는 소리의 크기에 비례하는 투명도를 활용하여 그래픽 크기를 고정시키는 설정입니다. 시각화 그래픽의 크기를 일정하게 유지하면서도 소리에 반응하는 모습을 보고 싶을 때 유용합니다.", + "help.editor.title": "오버레이 실시간 편집 모드", + "help.editor.body": "지정된 단축키(기본 F4)를 누르면 오버레이 편집 모드가 활성화됩니다. 화면에서 직접 마우스를 드래그하여 그래픽의 크기 한계선을 조절할 수 있으며, 팝업된 설정 패널에서 세부 조작이 가능합니다.", + "help.developer.title": "개발자 모드", + "help.developer.body": "개발자 모드를 켜면 AI가 무슨 소리로 들었는지, 얼마나 확신했는지, 얼마나 걸렸는지를 오버레이 왼쪽 위에, FPS를 오른쪽 위에 표시합니다. ‘{0}’ 스위치도 켜면 모든 결과가 CSV 파일로 남습니다. 시스템의 정상 작동 여부를 확인하고 싶을 때 유용합니다.", + "help.notes.title": "알아두세요", + "help.note.direction_surround": "5.1채널이나 7.1채널로 출력하면 화면 위쪽은 앞에서, 아래쪽은 뒤에서 나는 소리를 나타냅니다. 왼쪽과 오른쪽은 각각 왼쪽, 오른쪽 소리이고, 화면 모든 방향에서 한꺼번에 나오는 그래픽은 환경음이나 방향성이 없는 소리입니다.", + "help.note.direction_stereo": "2채널(스테레오)로 출력하면 좌우는 구분하지만 앞뒤는 구분하지 못합니다. 이때 화면 위·아래쪽 그래픽은 양쪽에서 비슷하게 들리는 소리와 공간감 효과를 나타냅니다.", + "help.note.sources": "Windows 기본 출력 장치로 나오는 모든 앱의 소리를 보여 줍니다. 앱이 다른 장치로 보내는 소리(예: 음성 채팅에만 쓰는 헤드셋)는 보이지 않습니다.", + "help.note.volume": "앱 안이나 Windows 볼륨 믹서에서 그 앱의 볼륨을 아주 낮추거나 음소거하면 소리가 거의 보이지 않거나 아예 보이지 않을 수 있습니다. 그래픽이 잘 반응하지 않으면 그 소리를 조금 키우거나 설정 탭에서 민감도를 올려 보세요.", + "help.note.ai": "AI 분류는 틀릴 수 있습니다. 안전이 걸린 상황에서 이 앱을 유일한 수단으로 쓰지 마세요.", + "help.faq.title": "자주 묻는 질문", + "help.faq.stopped_q": "직접 끄지 않았는데 꺼졌어요.", + "help.faq.stopped_a": "소리 받기가 멈춘 뒤 몇 초 안에 되살아나지 않으면 꺼집니다. 출력 장치가 뽑혔는데 쓸 수 있는 다른 장치가 없을 때가 그렇습니다. 그러면 홈에 이유가 나옵니다. 스피커나 헤드폰을 연결하거나 Windows 소리 설정에서 기본 출력 장치를 정한 뒤 다시 실행해 주세요.", + "help.faq.no_graphic_q": "그래픽이 보이지 않아요.", + "help.faq.no_graphic_a": "소리가 Windows 기본 출력 장치로 나오고 있는지, 음소거되어 있지 않은지, 홈에 안내가 떠 있지 않은지 확인해 주세요. 게임을 독점 전체 화면 모드로 하고 있다면 테두리 없는 창 모드나 창 모드로 바꿔 보세요. 설정에서 진하기가 너무 낮지 않은지, 지금 나는 소리 종류의 표시가 꺼져 있지 않은지도 확인해 주세요.", + "help.faq.performance_q": "CPU나 GPU를 많이 쓰나요?", + "help.faq.performance_a": "더 가볍게 하려면 설정 탭에서 ‘{0}’ 값을 낮추거나 ‘{1}’ 스위치를 끄세요.", + "help.faq.language_q": "앱 언어는 어떻게 바꾸나요?", + "help.faq.language_a": "이 창 위쪽의 ‘{0}’ 목록에서 선택합니다. 실행 중에는 오버레이 설정에서도 바꿀 수 있습니다. ‘{1}’ 항목은 Windows 표시 언어를 따릅니다.", + "help.report.title": "제보하기", + "help.report.desc": "버그나, 소리가 다른 종류로 표시된 경우를 알려 주세요. 브라우저에서 GitHub이 열리고 아래 환경 정보가 미리 채워집니다. GitHub 계정이 필요하니, 계정이 없으면 메일로 보내 주세요. 소리는 보내지 않습니다.", + "help.report.github": "GitHub에 제보하기", + "help.report.mail": "메일로 제보하기", + "help.report.copy": "환경 정보 복사", + "help.report.copied": "환경 정보를 복사했습니다.", + "help.report.copy_failed": "클립보드에 복사하지 못했습니다. 다시 시도해 주세요.", + "help.report.no_browser": "브라우저를 열지 못했습니다. 환경 정보를 복사해 보내 주세요.", + "help.report.no_mail": "메일을 보낼 앱이 없습니다. 환경 정보를 복사해 다른 방법으로 보내 주세요.", + "help.report.issue_title": "앱에서 보낸 제보", + "help.report.issue_body": "무슨 일이 있었나요?\n\n무엇을 듣고 있었나요? (앱, 소리 종류)\n\n어떻게 표시되길 기대하셨나요?", + "help.about.title": "앱 정보", + "help.about.version": "버전 {0}", + "help.about.license": "이 앱은 GNU Affero General Public License v3.0(AGPL-3.0)으로 배포됩니다. 분류 탭의 소리 이름과 묶음은 AudioSet 온톨로지를 바탕으로 해 CC BY-SA 4.0을 따르며, 자세한 내용은 아래 ‘오픈소스 라이선스’에서 볼 수 있습니다.", + "help.licenses.button": "오픈소스 라이선스", + "help.licenses.title": "오픈소스 라이선스", + "help.licenses.close": "닫기", + + "notice.ai_unavailable": "소리 종류를 구분하지 못하고 있어 모든 소리가 환경음 색으로 표시됩니다. 실행 종료 후 다시 실행해 보세요.", + "notice.ai_unavailable_hidden": "소리 종류를 구분하지 못해 모든 소리를 환경음으로 보는데, 환경음 표시가 꺼져 있어 아무것도 보이지 않습니다. 설정 탭에서 ‘{0}’ 스위치를 켜거나, 실행 종료 후 다시 실행해 보세요.", + "notice.stopped.title": "소리 시각화가 꺼졌습니다", + "notice.stopped.no_device": "열 수 있는 출력 장치가 없어 소리를 받을 수 없습니다. 스피커나 헤드폰을 연결하거나 Windows 소리 설정에서 기본 출력 장치를 정한 뒤 다시 실행해 주세요.", + "notice.stopped.capture_error": "오류로 소리 받기가 멈춰 그래픽이 꺼졌습니다.", + "notice.dismiss": "안내 닫기", + + "tray.open": "런처 열기", + "tray.exit": "프로그램 종료", + + "more_info.title": "GitHub 저장소 및 프로젝트 기여", + "more_info.body": "SoundVisualizer는 오픈소스 프로젝트로, GitHub를 통해 소스 코드가 공개되어 있습니다. 버그 제보, 기능 건의 및 개발 기여를 통해 프로젝트에 동참하실 수 있습니다. 아래 버튼을 클릭하여 공식 GitHub 저장소로 이동해 보세요! 또한, GitHub의 Releases 탭에서 최신 버전 업데이트를 확인하고 다운로드할 수 있습니다.", + "more_info.github_button": "GitHub 저장소 방문하기", + + "dialog.notice_title": "알림", + "dialog.already_running": "오버레이가 이미 실행 중입니다.", + "dialog.reset_title": "설정 초기화", + "dialog.reset_wave": "파도 모드의 설정을 기본값으로 되돌리시겠습니까?", + "dialog.reset_pad": "패드 모드의 설정을 기본값으로 되돌리시겠습니까?", + "dialog.reset_circle": "원형 모드의 설정을 기본값으로 되돌리시겠습니까?", + "dialog.reset_outline": "외곽선 모드의 설정을 기본값으로 되돌리시겠습니까?", + "dialog.reset_sound_types": "소리 분류 표시 설정을 기본값으로 되돌리시겠습니까?", + "dialog.reset_all_title": "기본값 초기화", + "dialog.reset_all": "정말로 모든 설정을 기본값으로 되돌리시겠습니까?", + "dialog.link_error": "링크를 열 수 없습니다: {0}", + + "editor.title": "오버레이 설정", + "editor.drag_tip": "화면에 표시된 가이드 라인을 마우스 드래그하여 오버레이 파도의 높이 한계선 및 중앙 크기를 실시간 조절할 수 있습니다!", + "editor.glow": "광원 효과", + "editor.glow_strength": "광원 강도", + "editor.color": "색상", + "editor.hotkeys": "단축키 설정", + "editor.developer_mode_desc": "AI가 들은 소리(왼쪽 위)와 FPS(오른쪽 위) 표시", + "editor.save_close_hint": "키를 누르면 설정 저장 후 닫힘", + "editor.close_overlay": "오버레이 실행 종료", + "editor.guide.limit": "{0} 한계선", + "editor.guide.size": "{0} 크기: {1}%", + "editor.guide.base_radius": "기본 반지름: {0}", + "editor.guide.circle_size": "원형 크기: {0}%", + + "hud.visual_mode": "표현 모드: [{0}] {1}", + "hud.sound_mode": "사운드 모드: [{0}] {1}", + "hud.overlay_settings": "오버레이 설정: [{0}]", + "hud.status.ready": "시스템 준비 완료", + "hud.status.stereo": "PC 오디오 설정: {0}ch (스테레오)\n소스: 대기 중...", + "hud.status.surround": "PC 오디오 설정: 8ch (7.1)\n소스: 대기 중...", + + "color_picker.title": "색상 선택", + "color_picker.hex": "HEX 코드", + "color_picker.cancel": "취소", + "color_picker.apply": "적용", + "color_picker.area": "채도와 밝기", + "color_picker.area_value": "채도 {0}%, 밝기 {1}%", + "color_picker.hue": "색조", + "color_picker.keys_help": "화살표 키로 바꿉니다. Shift 키를 누른 채 누르면 크게 바뀝니다.", + "color_picker.presets": "자주 쓰는 색", + "color_picker.preset.white": "흰색", + "color_picker.preset.red": "빨간색", + "color_picker.preset.orange": "주황색", + "color_picker.preset.yellow": "노란색", + "color_picker.preset.green": "초록색", + "color_picker.preset.cyan": "청록색", + "color_picker.preset.blue": "파란색", + "color_picker.preset.magenta": "자홍색", + + "audio.error.no_device": "출력 장치를 찾을 수 없습니다.", + "audio.error.capture_stopped": "캡처 비정상 종료: {0}", + "audio.error.capture_start_failed": "캡처 엔진 시작 실패: {0}", + + "tutorial.window_title": "튜토리얼", + "tutorial.skip": "건너뛰기", + "tutorial.previous": "이전 쪽", + "tutorial.next": "다음", + "tutorial.done": "확인", + "tutorial.page_status": "{1}쪽 중 {0}쪽", + "tutorial.pause": "그림 멈추기", + "tutorial.play": "그림 움직이기", + "tutorial.sound_on": "소리 켜기", + "tutorial.sound_off": "소리 끄기", + "tutorial.sound.title": "소리를 눈으로 보여 드립니다", + "tutorial.sound.body": "게임이나 영상의 소리를 화면 테두리에 그립니다. 소리가 클수록 크게 그려집니다. 이 PC에서 재생되는 소리만 보여 드리고, 주변에서 나는 소리는 듣지 않습니다.", + "tutorial.direction.title": "소리가 나는 방향을 보여 드립니다", + "tutorial.direction.body": "2채널(스테레오)로 출력하면 왼쪽에서 나는 소리는 왼쪽이, 오른쪽에서 나는 소리는 오른쪽이 더 크게 그려집니다. 5.1채널이나 7.1채널로 출력하면 앞뒤도 구분해, 앞에서 나는 소리는 화면 위쪽에, 뒤에서 나는 소리는 아래쪽에 그려집니다.", + "tutorial.types.title": "소리 종류를 색으로 구분합니다", + "tutorial.types.body": "AI가 소리를 세 종류로 나눠 서로 다른 색으로 그립니다. 색은 설정 탭에서 바꿀 수 있습니다.", + "tutorial.type.ambient_examples": "음악·빗소리 등 그 밖의 소리", + "tutorial.type.speech_examples": "사람 목소리", + "tutorial.type.danger_examples": "총소리·폭발음·사이렌", + "tutorial.start.title": "켜고 끄는 방법", + "tutorial.start.hotkeys": "실행 중에는 {0} 키로 사운드 모드를, {1} 키로 표현 모드를 바꾸고, {2} 키로 오버레이 설정을 엽니다." +} diff --git a/SoundVisualizer.Core/Localization/Strings/pl.json b/SoundVisualizer.Core/Localization/Strings/pl.json new file mode 100644 index 0000000..a195734 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/pl.json @@ -0,0 +1,294 @@ +{ + "common.language": "Język", + "common.reset_defaults": "Przywróć domyślne", + "common.minimize": "Minimalizuj", + "common.close": "Zamknij", + "language.system": "Domyślny język systemu", + + "launcher.window_title": "Launcher SoundVisualizer", + "launcher.tab.home": "Główna", + "launcher.tab.settings": "Ustawienia", + "launcher.tab.sounds": "Dźwięki", + "launcher.tab.help": "Pomoc", + "launcher.tab.more_info": "Więcej informacji", + "launcher.home.desc": "Pokazuje na ekranie dźwięki, których nie widać.\nOd gier po filmy – odkryj zupełnie nowe wrażenia.", + "launcher.home.start": "Uruchom", + "launcher.home.stop": "Zatrzymaj", + "launcher.home.tutorial": "Zobacz samouczek", + "launcher.status.running": "Stan: działa", + "launcher.status.idle": "Stan: w gotowości", + + "settings.section.mode": "Ustawienia trybu", + "settings.visual_mode.title": "Tryb wizualizacji", + "settings.visual_mode.desc": "Wybierz podstawowy kształt grafiki rysowanej na ekranie.", + "settings.mode.wave": "Fala", + "settings.mode.pad": "Pad", + "settings.mode.circle": "Okrąg", + "settings.mode.outline": "Kontur", + "settings.sound_mode.title": "Tryb dźwięku", + "settings.sound_mode.desc": "Wybierz kanały dźwięku pasujące do Twoich głośników.\nDla głośników dwukanałowych wybierz „2 kanały”, a dla zestawów dźwięku przestrzennego – „5.1 kanałów” lub „7.1 kanałów”.", + "settings.channels.2": "2 kanały", + "settings.channels.5_1": "5.1 kanałów", + "settings.channels.7_1": "7.1 kanałów", + "settings.max_fps.title": "Maks. liczba klatek na sekundę", + "settings.max_fps.desc": "Ustawia maksymalną liczbę klatek na sekundę, z jaką rysowana jest grafika.", + "settings.section.mode_detail": "Ustawienia każdego trybu", + "settings.mode_card.wave": "Tryb Fala", + "settings.mode_card.pad": "Tryb Pad", + "settings.mode_card.circle": "Tryb Okrąg", + "settings.mode_card.outline": "Tryb Kontur", + "settings.size_lock.title": "Zablokuj rozmiar", + "settings.size_lock.desc": "Utrzymuje stały rozmiar grafiki, a głośność pokazuje za pomocą przezroczystości.", + "settings.fixed_size.wave": "Rozmiar fali", + "settings.fixed_size.pad": "Rozmiar padów", + "settings.fixed_size.circle": "Rozmiar okręgu", + "settings.fixed_size.outline": "Grubość konturu", + "settings.fixed_size.desc": "Ustawia stały rozmiar grafiki, przy którym zmienia się tylko jej przezroczystość.", + "settings.max_opacity.title": "Maks. krycie", + "settings.max_opacity.desc": "Określa krycie grafiki przy najgłośniejszym dźwięku.", + "settings.size.title": "Rozmiar", + "settings.size.desc.wave": "Reguluje wysokość fali i jej ogólną objętość.", + "settings.size.desc.pad": "Reguluje, jak grube i szerokie stają się pady, gdy reagują na dźwięk.", + "settings.size.desc.circle": "Reguluje, jak mocno okrągły korektor wybrzusza się na zewnątrz, gdy słychać dźwięk.", + "settings.size.desc.outline": "Reguluje wysokość fali konturu i jej ogólną objętość.", + "settings.opacity.title": "Krycie", + "settings.opacity.desc.wave": "Im wyższa wartość, tym grafika jest wyraźniejsza; im niższa, tym bledsza i bardziej przezroczysta.", + "settings.opacity.desc.pad": "Im wyższa wartość, tym grafika jest wyraźniejsza; im niższa, tym bledsza i bardziej przezroczysta.", + "settings.opacity.desc.circle": "Im wyższa wartość, tym grafika jest wyraźniejsza; im niższa, tym bledsza i bardziej przezroczysta.", + "settings.opacity.desc.outline": "Im wyższa wartość, tym grafika jest wyraźniejsza; im niższa, tym bledsza i bardziej przezroczysta.", + "settings.speed.title": "Szybkość", + "settings.speed.desc.wave": "Reguluje, jak szybko fala płynie i się kołysze.", + "settings.speed.desc.pad": "Reguluje, jak szybko pady obracają się w stronę, z której dochodzi dźwięk.", + "settings.speed.desc.circle": "Reguluje, jak szybko okrągły korektor faluje i obraca się w odpowiedzi na dźwięk.", + "settings.speed.desc.outline": "Reguluje, jak szybko fala konturu płynie i się kołysze.", + "settings.sensitivity.title": "Czułość", + "settings.sensitivity.desc.wave": "Reguluje, jak czule fala reaguje nawet na ciche dźwięki i jak mocno wtedy wzbiera.", + "settings.sensitivity.desc.pad": "Reguluje pady tak, aby lepiej wychwytywały dźwięk i wyraźniej się wyróżniały.", + "settings.sensitivity.desc.circle": "Reguluje, jak drobno rozdziela się amplituda okręgu, gdy reaguje on na dźwięk.", + "settings.sensitivity.desc.outline": "Reguluje, jak czule fala konturu reaguje nawet na ciche dźwięki i jak mocno wtedy wzbiera.", + "settings.glow.title": "Poświata", + "settings.glow.desc.wave": "Dodaje delikatną neonową poświatę wzdłuż krawędzi fali i reguluje jej moc.\nUwaga: zużywa więcej zasobów GPU.", + "settings.glow.desc.pad": "Dodaje wokół padów delikatną poświatę przypominającą aurę.\nUwaga: zużywa więcej zasobów GPU.", + "settings.glow.desc.circle": "Dodaje migoczącą aureolę wzdłuż krawędzi okrągłego korektora.\nUwaga: zużywa więcej zasobów GPU.", + "settings.glow.desc.outline": "Dodaje do konturu delikatną neonową poświatę i reguluje jej moc.\nUwaga: zużywa więcej zasobów GPU.", + "settings.radius.title": "Promień", + "settings.radius.desc": "Ustawia promień pierścienia na środku ekranu w trybie Okrąg.", + "settings.section.sound_types": "Rodzaje dźwięków (AI)", + "settings.show_ambient": "Pokaż dźwięki otoczenia", + "settings.show_speech": "Pokaż mowę", + "settings.show_danger": "Pokaż dźwięki zagrożenia", + "settings.set_color": "Ustaw kolor", + "settings.set_color_for": "Ustaw kolor: {0}", + "settings.section.hotkeys": "Skróty klawiszowe", + "settings.hotkey.visual.title": "Zmiana trybu wizualizacji", + "settings.hotkey.visual.desc": "Skrót, który zmienia kształt grafiki na bieżąco podczas działania.", + "settings.hotkey.sound.title": "Zmiana trybu dźwięku", + "settings.hotkey.sound.desc": "Skrót, który zmienia tryb dźwięku na bieżąco podczas działania.", + "settings.hotkey.edit.title": "Ustawienia nakładki", + "settings.hotkey.edit.desc": "Skrót, który otwiera lub zamyka edytor nakładki.", + "settings.hotkey.set": "Ustaw skrót", + "settings.section.advanced": "Ustawienia zaawansowane", + "settings.developer_mode.title": "Tryb programisty", + "settings.developer_mode.desc": "Pokazuje w lewym górnym rogu nakładki, co usłyszała AI (zgłoszoną nazwę dźwięku, pewność i czas przetwarzania), a w prawym górnym rogu FPS. Informacje są po angielsku i nie są przeznaczone do codziennego użytku.", + "settings.developer_record.title": "Zapisuj wyniki do pliku", + "settings.developer_record.desc": "Zapisuje każdy wynik AI (najwyżej ocenione nazwy dźwięków, pewność i czasy przetwarzania) do nowego pliku CSV w folderze ai-log obok SoundVisualizer.exe, aby można było później sprawdzić, co usłyszała. Jeśli do tego folderu nie da się zapisywać, plik trafia do %LOCALAPPDATA%\\SoundVisualizer\\ai-log. Pliki mogą być duże, więc wyłącz zapis, gdy skończysz.", + "settings.auto_minimize.title": "Minimalizuj launcher po uruchomieniu", + "settings.auto_minimize.desc": "Automatycznie minimalizuje okno launchera, gdy uruchamia się nakładka.", + + "sounds.title": "Wybierz rodzaje dźwięków", + "sounds.desc": "Dźwięki rozpoznawane przez AI są zebrane w grupy podobnych dźwięków według rodzaju, do którego domyślnie należą. Gdy zmienisz rodzaj grupy, kolor jej dźwięków od razu dostosuje się do nowego rodzaju.", + "sounds.sibling_note": "AI często rozpoznaje ten sam dźwięk jednocześnie pod kilkoma nazwami. Syrena na przykład jest rozpoznawana także jako „Alarm”, który jest w innej grupie. Jeśli po zmianie dźwięk nadal jest pokazywany jako poprzedni rodzaj, zmień też powiązane grupy lub dźwięki.", + "sounds.advanced.title": "Tryb zaawansowany", + "sounds.advanced.desc": "Pokazuje każdy dźwięk w jego grupie, aby można było zmieniać dźwięki pojedynczo.", + "sounds.search.hint": "Szukaj dźwięków", + "sounds.search.clear": "Wyczyść wyszukiwanie", + "sounds.filter.title": "Filtr", + "sounds.filter.all": "Wszystkie", + "sounds.filter.changed": "Zmienione", + "sounds.type.ambient": "Dźwięki otoczenia", + "sounds.type.speech": "Mowa", + "sounds.type.danger": "Dźwięki zagrożenia", + "sounds.type.default": "{0} (domyślnie)", + "sounds.type.mixed": "Mieszany", + "sounds.changed_count": "Zmienione dźwięki: {0}", + "sounds.changed_from": "Domyślnie: {0}", + "sounds.reset.button": "Przywróć domyślne", + "sounds.reset.title": "Przywrócić domyślne rodzaje dźwięków?", + "sounds.reset.message": "Każdy zmieniony przez Ciebie dźwięk wróci do swojego domyślnego rodzaju.", + "sounds.reset.confirm": "Przywróć", + "sounds.reset.cancel": "Anuluj", + "sounds.empty": "Brak pasujących dźwięków.", + "sounds.group.mixed_note": "Niektóre dźwięki zmienione pojedynczo", + "sounds.group.changed_line": "Teraz: {0} · Domyślnie: {1}", + "sounds.overwrite.title": "Zmienić całą grupę?", + "sounds.overwrite.message": "Dźwięki z grupy „{0}”, które zmienią się na „{2}”: {1}. Dźwięki zmienione pojedynczo też zostaną nadpisane.", + "sounds.overwrite.confirm": "Zmień", + "sounds.overwrite.cancel": "Anuluj", + "sounds.basic_warning.title": "Przełączyć na tryb podstawowy?", + "sounds.basic_warning.message": "W niektórych grupach są dźwięki zmienione przez Ciebie pojedynczo. Jeśli w trybie podstawowym wybierzesz rodzaj dla takiej grupy, wszystkie jej dźwięki otrzymają ten rodzaj, a Twoje pojedyncze zmiany w tej grupie zostaną nadpisane. Nic się nie zmieni, dopóki nie wybierzesz.", + "sounds.basic_warning.confirm": "Przełącz na podstawowy", + "sounds.basic_warning.cancel": "Zostań w zaawansowanym", + "sounds.cd.sound": "{0}, rodzaj: {1}", + "sounds.cd.sound_changed": "{0}, rodzaj: {1}, domyślnie: {2}", + "sounds.cd.group": "Grupa {0}, rodzaj: {1}, liczba dźwięków: {2} ({3})", + "sounds.cd.group_changed": "Grupa {0}, rodzaj: {1}, domyślnie: {2}, liczba dźwięków: {3} ({4})", + "sounds.cd.group_header": "Grupa {0}, liczba dźwięków: {1}", + + "hotkey.prompt": "Naciśnij klawisz (Esc – anuluj)", + "hotkey.none": "Brak", + "hotkey.button_name": "{0}, skrót: {1}", + + "help.start.title": "Pierwsze kroki", + "help.start.1": "Wybierz język u góry tego okna i w razie potrzeby zmień wygląd grafiki na karcie „Ustawienia”.", + "help.start.2": "Na karcie „Główna” kliknij „{0}”.", + "help.start.3": "Grafika jest rysowana wzdłuż krawędzi ekranu, nad innymi oknami, takimi jak gry i filmy. Kliknięcia przechodzą przez nią, więc możesz dalej korzystać z okna pod spodem.", + "help.start.4": "Aby wyłączyć, kliknij „{0}” na karcie „Główna” albo kliknij prawym przyciskiem myszy ikonę SoundVisualizer w obszarze powiadomień na pasku zadań i wybierz „{0}”.", + "help.modes.title": "Tryby wizualizacji", + "help.mode.wave": "Cała krawędź ekranu przesuwa się do środka – tym dalej, im głośniejszy dźwięk. Po stronie, z której dochodzi dźwięk, sięga głębiej.", + "help.mode.pad": "W 8 miejscach wzdłuż krawędzi ekranu podświetlają się paski. Ten tryb najwyraźniej pokazuje kierunek.", + "help.mode.circle": "Pierścień na środku ekranu wybrzusza się w stronę, z której dochodzi dźwięk.", + "help.mode.outline": "Rysuje tylko cienki kontur fali, więc mniej zasłania grę.", + "help.modes.note": "Ustawienia takie jak „Rozmiar” i „Krycie” są zapisywane osobno dla każdego trybu. Możesz je zmienić na karcie „Ustawienia”.", + "help.sound.title": "Rodzaje dźwięków", + "help.sound.kinds": "AI dzieli to, co słychać, na dźwięki otoczenia, mowę i dźwięki zagrożenia. Domyślnie dźwięki otoczenia są oznaczane na biało, mowa na żółto, a dźwięki zagrożenia na czerwono. Na karcie „Ustawienia” możesz zmienić kolor każdego rodzaju i to, czy jest pokazywany.", + "help.sound.groups": "Na karcie „Dźwięki” podobne dźwięki są zebrane w grupy, a Ty wybierasz, do jakiego rodzaju należy każda grupa. Jeśli na przykład zmienisz grupę „Dzwonek do drzwi” na dźwięki zagrożenia, to gdy AI rozpozna dzwonek do drzwi albo pukanie, dźwięk dostanie kolor dźwięków zagrożenia. Dźwięk, który ustawisz jako dźwięk zagrożenia, bierze jednak udział tylko w głosowaniu AI nad rodzajem, więc nie oznacza to, że każdy dzwonek do drzwi zostanie pokazany jako dźwięk zagrożenia.", + "help.sound.advanced": "Aby zmieniać dźwięki pojedynczo, włącz tryb zaawansowany.", + "help.sound.danger": "Gdy zostanie wykryty dźwięk zagrożenia (np. strzał), kolor zmienia się natychmiast, a nie stopniowo.", + "help.pc.title": "Ustawienia PC i skróty klawiszowe", + "help.sound_mode.title": "Tryb dźwięku", + "help.sound_mode.body": "Aby rozróżniać także dźwięki z przodu i z tyłu, a nie tylko z lewej i prawej, w ustawieniach dźwięku systemu Windows skonfiguruj urządzenie wyjściowe jako dźwięk przestrzenny 5.1 (6 kanałów) lub 7.1 (8 kanałów). Przy zwykłym stereo (2 kanały) aplikacja odróżnia lewą stronę od prawej, ale nie przód od tyłu. „Tryb dźwięku” na karcie „Ustawienia” dopasowuje się do urządzenia wyjściowego przy uruchomieniu i po zmianie urządzenia, ale możesz go też zmienić samodzielnie.", + "help.sound_mode.tip": "💡 Jeśli nie masz urządzenia wyjściowego 7.1, zainstaluj {0} i skonfiguruj urządzenie wyjściowe jako 7.1 – wtedy nakładka będzie pokazywać dźwięk przestrzenny 7.1.", + "help.sound_mode.tip_link": "wirtualny sterownik VB-CABLE", + "help.hotkeys.title": "Własne skróty klawiszowe", + "help.hotkeys.body": "Na karcie „Ustawienia” kliknij przycisk skrótu i naciśnij pojedynczy klawisz lub kombinację klawiszy (np. Ctrl + Shift + A), aby dowolnie zmienić skrót. Naciśnięcie skrótu natychmiast przełącza tryb, nawet gdy aplikacja działa w tle.", + "help.hotkeys.defaults": "Domyślnie F2 zmienia tryb dźwięku, F3 zmienia tryb wizualizacji, a F4 otwiera ustawienia nakładki.", + "help.size_lock.title": "Tryb blokady rozmiaru", + "help.size_lock.body": "Tryb blokady rozmiaru utrzymuje stały rozmiar grafiki, a głośność dźwięku pokazuje za pomocą przezroczystości. Przydaje się, gdy chcesz zachować stały rozmiar grafiki, a jednocześnie widzieć, jak reaguje ona na dźwięk.", + "help.editor.title": "Edycja nakładki na żywo", + "help.editor.body": "Naciśnij przypisany skrót (domyślnie F4), aby włączyć tryb edycji nakładki. Przeciągając myszą po ekranie, możesz ustawić limit rozmiaru grafiki, a szczegóły dopracować w wyświetlonym panelu ustawień.", + "help.developer.title": "Tryb programisty", + "help.developer.body": "Po włączeniu trybu programisty w lewym górnym rogu nakładki pojawia się to, co usłyszała AI (nazwa dźwięku, pewność i czas przetwarzania), a w prawym górnym rogu FPS. Jeśli włączysz też „{0}”, każdy wynik zostanie zapisany w pliku CSV. Przydaje się to, gdy chcesz sprawdzić, czy system działa prawidłowo.", + "help.notes.title": "Warto wiedzieć", + "help.note.direction_surround": "Przy wyjściu 5.1 lub 7.1 góra ekranu pokazuje dźwięk z przodu, a dół – dźwięk z tyłu. Lewa i prawa krawędź pokazują dźwięk z lewej i prawej strony, a grafika wychodząca jednocześnie ze wszystkich stron ekranu to dźwięki otoczenia lub dźwięki bez określonego kierunku.", + "help.note.direction_stereo": "Przy wyjściu 2-kanałowym (stereo) aplikacja odróżnia lewą stronę od prawej, ale nie przód od tyłu. Grafika u góry i u dołu ekranu pokazuje wtedy dźwięki słyszalne jednakowo z obu stron oraz wrażenie przestrzeni.", + "help.note.sources": "Pokazywany jest dźwięk wszystkich aplikacji odtwarzany przez domyślne urządzenie odtwarzania systemu Windows. Dźwięk, który aplikacja wysyła na inne urządzenie, na przykład na zestaw słuchawkowy używany tylko do czatu głosowego, nie jest pokazywany.", + "help.note.volume": "Jeśli głośność aplikacji jest bardzo niska lub wyciszona – w samej aplikacji albo w mikserze głośności systemu Windows – jej dźwięk może być ledwie widoczny albo wcale. Jeśli grafika słabo reaguje, trochę podgłośnij ten dźwięk albo zwiększ „Czułość” na karcie „Ustawienia”.", + "help.note.ai": "Klasyfikacja AI może być błędna. Gdy chodzi o bezpieczeństwo, nie polegaj na tej aplikacji jako jedynym zabezpieczeniu.", + "help.faq.title": "Najczęstsze pytania", + "help.faq.stopped_q": "Wizualizacja wyłączyła się bez mojego udziału.", + "help.faq.stopped_a": "Wizualizacja wyłącza się, gdy przechwytywanie dźwięku zatrzyma się i nie wznowi w ciągu kilku sekund, na przykład gdy urządzenie odtwarzania zostanie odłączone, a żadne inne nie jest dostępne. Karta „Główna” pokaże wtedy przyczynę. Podłącz głośniki lub słuchawki albo ustaw domyślne urządzenie odtwarzania w ustawieniach dźwięku systemu Windows, a potem uruchom ją ponownie.", + "help.faq.no_graphic_q": "Nie widzę żadnej grafiki.", + "help.faq.no_graphic_a": "Sprawdź, czy dźwięk jest odtwarzany przez domyślne urządzenie odtwarzania systemu Windows i nie jest wyciszony oraz czy na karcie „Główna” nie ma komunikatu. Jeśli gra działa w trybie wyłącznego pełnego ekranu, spróbuj trybu okna bez obramowania lub trybu okna. Upewnij się też, że na karcie „Ustawienia” wartość „Krycie” nie jest zbyt niska, a pokazywanie rodzaju dźwięku, który właśnie słychać, nie jest wyłączone.", + "help.faq.performance_q": "Czy aplikacja mocno obciąża procesor lub kartę graficzną?", + "help.faq.performance_a": "Aby ją odciążyć, zmniejsz „{0}” na karcie „Ustawienia” lub wyłącz „{1}”.", + "help.faq.language_q": "Jak zmienić język aplikacji?", + "help.faq.language_a": "Wybierz go z listy „{0}” u góry tego okna. Podczas działania możesz go też zmienić w ustawieniach nakładki. Opcja „{1}” sprawia, że aplikacja używa języka wyświetlania systemu Windows.", + "help.report.title": "Zgłoś problem", + "help.report.desc": "Daj nam znać o błędzie albo o dźwięku, który został pokazany jako dźwięk niewłaściwego rodzaju. GitHub otworzy się w przeglądarce z uzupełnionymi informacjami o systemie widocznymi poniżej, dlatego potrzebne jest konto na GitHubie. Jeśli nie masz konta, wyślij nam e-mail. Żaden dźwięk nie jest nigdy wysyłany.", + "help.report.github": "Zgłoś na GitHubie", + "help.report.mail": "Zgłoś przez e-mail", + "help.report.copy": "Skopiuj informacje o systemie", + "help.report.copied": "Skopiowano informacje o systemie.", + "help.report.copy_failed": "Nie udało się skopiować do schowka. Spróbuj ponownie.", + "help.report.no_browser": "Nie udało się otworzyć przeglądarki. Skopiuj informacje o systemie i wyślij je nam w inny sposób.", + "help.report.no_mail": "Brak aplikacji do wysyłania e-maili. Skopiuj informacje o systemie i wyślij je w inny sposób.", + "help.report.issue_title": "Zgłoszenie z aplikacji", + "help.report.issue_body": "Co się stało?\n\nCo było wtedy odtwarzane (aplikacja, rodzaj dźwięku)?\n\nCo powinno się wydarzyć zamiast tego?", + "help.about.title": "O aplikacji", + "help.about.version": "Wersja {0}", + "help.about.license": "Ta aplikacja jest udostępniana na warunkach GNU Affero General Public License v3.0 (AGPL-3.0). Nazwy i grupy dźwięków na karcie „Dźwięki” są oparte na ontologii AudioSet i udostępniane na warunkach licencji CC BY-SA 4.0 (zobacz „Licencje open source” poniżej).", + "help.licenses.button": "Licencje open source", + "help.licenses.title": "Licencje open source", + "help.licenses.close": "Zamknij", + + "notice.ai_unavailable": "Rozpoznawanie rodzaju dźwięku nie działa, więc wszystkie dźwięki są pokazywane w kolorze dźwięków otoczenia. Spróbuj wyłączyć i ponownie włączyć wizualizację.", + "notice.ai_unavailable_hidden": "Rozpoznawanie rodzaju dźwięku nie działa, więc wszystkie dźwięki są traktowane jako dźwięki otoczenia, a ich pokazywanie jest wyłączone, dlatego nic nie jest widoczne. Włącz „{0}” na karcie „Ustawienia” albo spróbuj wyłączyć i ponownie włączyć wizualizację.", + "notice.stopped.title": "Wizualizacja dźwięku wyłączona", + "notice.stopped.no_device": "Nie udało się otworzyć żadnego urządzenia odtwarzania, więc nie można przechwytywać dźwięku. Podłącz głośniki lub słuchawki albo ustaw domyślne urządzenie odtwarzania w ustawieniach dźwięku systemu Windows, a potem uruchom ponownie.", + "notice.stopped.capture_error": "Przechwytywanie dźwięku zostało przerwane przez błąd, więc grafika jest wyłączona.", + "notice.dismiss": "Zamknij komunikat", + + "tray.open": "Otwórz launcher", + "tray.exit": "Zakończ", + + "more_info.title": "Repozytorium GitHub i współtworzenie", + "more_info.body": "SoundVisualizer to projekt open source, którego kod źródłowy jest publicznie dostępny na GitHubie. Możesz wziąć w nim udział, zgłaszając błędy, proponując nowe funkcje lub pomagając w rozwoju. Kliknij przycisk poniżej, aby przejść do oficjalnego repozytorium na GitHubie! Na karcie Releases na GitHubie możesz też sprawdzić i pobrać najnowszą wersję.", + "more_info.github_button": "Odwiedź repozytorium GitHub", + + "dialog.notice_title": "Informacja", + "dialog.already_running": "Nakładka już działa.", + "dialog.reset_title": "Resetowanie ustawień", + "dialog.reset_wave": "Przywrócić domyślne ustawienia trybu Fala?", + "dialog.reset_pad": "Przywrócić domyślne ustawienia trybu Pad?", + "dialog.reset_circle": "Przywrócić domyślne ustawienia trybu Okrąg?", + "dialog.reset_outline": "Przywrócić domyślne ustawienia trybu Kontur?", + "dialog.reset_sound_types": "Przywrócić domyślne ustawienia rodzajów dźwięków?", + "dialog.reset_all_title": "Przywracanie ustawień domyślnych", + "dialog.reset_all": "Czy na pewno chcesz przywrócić wszystkie ustawienia domyślne?", + "dialog.link_error": "Nie udało się otworzyć linku: {0}", + + "editor.title": "Ustawienia nakładki", + "editor.drag_tip": "Przeciągaj myszą linie pomocnicze na ekranie, aby na bieżąco ustawiać limit wysokości i rozmiar środka fali nakładki!", + "editor.glow": "Efekt poświaty", + "editor.glow_strength": "Moc poświaty", + "editor.color": "Kolor", + "editor.hotkeys": "Ustawienia skrótów", + "editor.developer_mode_desc": "Pokazuje, co usłyszała AI (u góry po lewej), i FPS (u góry po prawej)", + "editor.save_close_hint": "– zapisz i zamknij", + "editor.close_overlay": "Zamknij nakładkę", + "editor.guide.limit": "{0}: granica", + "editor.guide.size": "{0}: rozmiar {1}%", + "editor.guide.base_radius": "Promień bazowy: {0}", + "editor.guide.circle_size": "Rozmiar okręgu: {0}%", + + "hud.visual_mode": "Tryb wizualizacji: [{0}] {1}", + "hud.sound_mode": "Tryb dźwięku: [{0}] {1}", + "hud.overlay_settings": "Ustawienia nakładki: [{0}]", + "hud.status.ready": "System gotowy", + "hud.status.stereo": "Dźwięk PC: {0} kan. (stereo)\nŹródło: oczekiwanie...", + "hud.status.surround": "Dźwięk PC: 8 kan. (7.1)\nŹródło: oczekiwanie...", + + "color_picker.title": "Wybierz kolor", + "color_picker.hex": "Kod HEX", + "color_picker.cancel": "Anuluj", + "color_picker.apply": "Zastosuj", + "color_picker.area": "Nasycenie i jasność", + "color_picker.area_value": "Nasycenie {0}%, jasność {1}%", + "color_picker.hue": "Odcień", + "color_picker.keys_help": "Zmieniaj klawiszami strzałek. Przytrzymaj Shift, aby zmieniać większymi krokami.", + "color_picker.presets": "Popularne kolory", + "color_picker.preset.white": "Biały", + "color_picker.preset.red": "Czerwony", + "color_picker.preset.orange": "Pomarańczowy", + "color_picker.preset.yellow": "Żółty", + "color_picker.preset.green": "Zielony", + "color_picker.preset.cyan": "Turkusowy", + "color_picker.preset.blue": "Niebieski", + "color_picker.preset.magenta": "Magenta", + + "audio.error.no_device": "Nie znaleziono urządzenia wyjściowego.", + "audio.error.capture_stopped": "Przechwytywanie dźwięku nieoczekiwanie się zatrzymało: {0}", + "audio.error.capture_start_failed": "Nie udało się uruchomić mechanizmu przechwytywania dźwięku: {0}", + + "tutorial.window_title": "Samouczek", + "tutorial.skip": "Pomiń", + "tutorial.previous": "Poprzednia strona", + "tutorial.next": "Dalej", + "tutorial.done": "Rozumiem", + "tutorial.page_status": "Strona {0} z {1}", + "tutorial.pause": "Wstrzymaj animację", + "tutorial.play": "Odtwórz animację", + "tutorial.sound_on": "Włącz dźwięk", + "tutorial.sound_off": "Wyłącz dźwięk", + "tutorial.sound.title": "Zobacz dźwięk na ekranie", + "tutorial.sound.body": "Dźwięk z gry lub filmu jest rysowany wzdłuż krawędzi ekranu. Im głośniejszy dźwięk, tym większa grafika. Pokazywany jest tylko dźwięk odtwarzany na tym komputerze. Dźwięki wokół Ciebie nie są wychwytywane.", + "tutorial.direction.title": "Pokazuje, skąd dochodzi dźwięk", + "tutorial.direction.body": "Przy wyjściu 2-kanałowym (stereo) dźwięk z lewej strony powiększa grafikę po lewej, a dźwięk z prawej – po prawej. Przy wyjściu 5.1 lub 7.1 aplikacja odróżnia też przód od tyłu: dźwięk z przodu jest rysowany u góry ekranu, a dźwięk z tyłu – u dołu.", + "tutorial.types.title": "Każdy rodzaj dźwięku ma swój kolor", + "tutorial.types.body": "AI dzieli dźwięki na trzy rodzaje i rysuje każdy w innym kolorze. Kolory możesz zmienić na karcie „Ustawienia”.", + "tutorial.type.ambient_examples": "Muzyka, deszcz i cała reszta", + "tutorial.type.speech_examples": "Głosy ludzi", + "tutorial.type.danger_examples": "Strzały, wybuchy, syreny", + "tutorial.start.title": "Jak włączyć i wyłączyć", + "tutorial.start.hotkeys": "Podczas działania naciśnij {0}, aby zmienić tryb dźwięku, {1}, aby zmienić tryb wizualizacji, i {2}, aby otworzyć ustawienia nakładki." +} diff --git a/SoundVisualizer.Core/Localization/Strings/pt-BR.json b/SoundVisualizer.Core/Localization/Strings/pt-BR.json new file mode 100644 index 0000000..09d4043 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/pt-BR.json @@ -0,0 +1,294 @@ +{ + "common.language": "Idioma", + "common.reset_defaults": "Redefinir para o padrão", + "common.minimize": "Minimizar", + "common.close": "Fechar", + "language.system": "Padrão do sistema", + + "launcher.window_title": "Inicializador do SoundVisualizer", + "launcher.tab.home": "Início", + "launcher.tab.settings": "Ajustes", + "launcher.tab.sounds": "Sons", + "launcher.tab.help": "Ajuda", + "launcher.tab.more_info": "Saiba mais", + "launcher.home.desc": "Desenha na tela os sons que você não consegue ver.\nDos jogos aos filmes, viva uma experiência totalmente nova.", + "launcher.home.start": "Iniciar", + "launcher.home.stop": "Parar", + "launcher.home.tutorial": "Ver tutorial", + "launcher.status.running": "Status: Em execução", + "launcher.status.idle": "Status: Pronto", + + "settings.section.mode": "Ajustes de modo", + "settings.visual_mode.title": "Modo visual", + "settings.visual_mode.desc": "Escolha a forma básica dos gráficos desenhados na tela.", + "settings.mode.wave": "Onda", + "settings.mode.pad": "Pad", + "settings.mode.circle": "Círculo", + "settings.mode.outline": "Contorno", + "settings.sound_mode.title": "Modo de som", + "settings.sound_mode.desc": "Escolha os canais de som que correspondem aos seus alto-falantes.\nPara alto-falantes de 2 canais, escolha “2 canais”. Para alto-falantes surround, escolha “5.1 canais” ou “7.1 canais”.", + "settings.channels.2": "2 canais", + "settings.channels.5_1": "5.1 canais", + "settings.channels.7_1": "7.1 canais", + "settings.max_fps.title": "Taxa de quadros máxima", + "settings.max_fps.desc": "Define a taxa de quadros máxima em que os gráficos são desenhados.", + "settings.section.mode_detail": "Ajustes de cada modo", + "settings.mode_card.wave": "Modo Onda", + "settings.mode_card.pad": "Modo Pad", + "settings.mode_card.circle": "Modo Círculo", + "settings.mode_card.outline": "Modo Contorno", + "settings.size_lock.title": "Fixar tamanho", + "settings.size_lock.desc": "Mantém os gráficos em um tamanho fixo e mostra o volume do som pela transparência.", + "settings.fixed_size.wave": "Tamanho da onda", + "settings.fixed_size.pad": "Tamanho do pad", + "settings.fixed_size.circle": "Tamanho do círculo", + "settings.fixed_size.outline": "Espessura do traço", + "settings.fixed_size.desc": "Define o tamanho fixo que os gráficos mantêm enquanto a transparência muda.", + "settings.max_opacity.title": "Opacidade máxima", + "settings.max_opacity.desc": "O quanto os gráficos ficam opacos quando o som está no volume mais alto.", + "settings.size.title": "Tamanho", + "settings.size.desc.wave": "Ajusta a altura da onda e o quanto ela parece volumosa no geral.", + "settings.size.desc.pad": "Ajusta a espessura e a largura que os pads atingem ao reagir ao som.", + "settings.size.desc.circle": "Ajusta o quanto o equalizador circular se projeta para fora quando há som.", + "settings.size.desc.outline": "Ajusta a altura da onda de contorno e o quanto ela parece volumosa no geral.", + "settings.opacity.title": "Opacidade", + "settings.opacity.desc.wave": "Valores mais altos deixam os gráficos mais fortes; valores mais baixos, mais fracos e transparentes.", + "settings.opacity.desc.pad": "Valores mais altos deixam os gráficos mais fortes; valores mais baixos, mais fracos e transparentes.", + "settings.opacity.desc.circle": "Valores mais altos deixam os gráficos mais fortes; valores mais baixos, mais fracos e transparentes.", + "settings.opacity.desc.outline": "Valores mais altos deixam os gráficos mais fortes; valores mais baixos, mais fracos e transparentes.", + "settings.speed.title": "Velocidade", + "settings.speed.desc.wave": "Ajusta a velocidade com que a onda flui e ondula.", + "settings.speed.desc.pad": "Ajusta a rapidez com que os pads giram na direção de onde vem um som.", + "settings.speed.desc.circle": "Ajusta a rapidez com que o equalizador circular ondula e gira em resposta ao som.", + "settings.speed.desc.outline": "Ajusta a velocidade com que a onda de contorno flui e ondula.", + "settings.sensitivity.title": "Sensibilidade", + "settings.sensitivity.desc.wave": "Ajusta com que sensibilidade a onda reage e se agita, mesmo com sons baixos.", + "settings.sensitivity.desc.pad": "Ajusta os pads para que captem melhor o som e se destaquem mais.", + "settings.sensitivity.desc.circle": "Ajusta com que nível de detalhe a amplitude do círculo se fragmenta ao reagir ao som.", + "settings.sensitivity.desc.outline": "Ajusta com que sensibilidade a onda de contorno reage e se agita, mesmo com sons baixos.", + "settings.glow.title": "Brilho neon", + "settings.glow.desc.wave": "Adiciona um brilho neon suave à borda da onda e ajusta a intensidade.\nAtenção: consome mais recursos da GPU.", + "settings.glow.desc.pad": "Adiciona um brilho suave, como uma aura, ao redor dos pads.\nAtenção: consome mais recursos da GPU.", + "settings.glow.desc.circle": "Adiciona um halo cintilante ao longo da borda do equalizador circular.\nAtenção: consome mais recursos da GPU.", + "settings.glow.desc.outline": "Adiciona um brilho neon suave ao contorno e ajusta a intensidade.\nAtenção: consome mais recursos da GPU.", + "settings.radius.title": "Raio", + "settings.radius.desc": "Define o raio do anel central no modo Círculo.", + "settings.section.sound_types": "Tipos de som (IA)", + "settings.show_ambient": "Mostrar sons ambientes", + "settings.show_speech": "Mostrar vozes", + "settings.show_danger": "Mostrar sons de perigo", + "settings.set_color": "Escolher cor", + "settings.set_color_for": "Escolher cor: {0}", + "settings.section.hotkeys": "Teclas de atalho", + "settings.hotkey.visual.title": "Alternar modo visual", + "settings.hotkey.visual.desc": "Atalho que muda a forma em tempo real durante a execução.", + "settings.hotkey.sound.title": "Alternar modo de som", + "settings.hotkey.sound.desc": "Atalho que muda o modo de som em tempo real durante a execução.", + "settings.hotkey.edit.title": "Ajustes da sobreposição", + "settings.hotkey.edit.desc": "Atalho que abre ou fecha o editor da sobreposição.", + "settings.hotkey.set": "Definir atalho", + "settings.section.advanced": "Ajustes avançados", + "settings.developer_mode.title": "Modo de desenvolvedor", + "settings.developer_mode.desc": "Mostra no canto superior esquerdo da sobreposição o que a IA ouviu (o nome do som informado, o grau de certeza e quanto tempo levou) e, no canto superior direito, o FPS. As informações aparecem em inglês e não são para o uso diário.", + "settings.developer_record.title": "Salvar os resultados em um arquivo", + "settings.developer_record.desc": "Salva cada resultado da IA (os nomes de som com maior pontuação, o grau de certeza e os tempos) em um novo arquivo CSV na pasta ai-log, ao lado do SoundVisualizer.exe, para você rever depois o que ela ouviu. Se não for possível gravar nessa pasta, o arquivo vai para %LOCALAPPDATA%\\SoundVisualizer\\ai-log. Os arquivos podem ficar grandes, então desative quando terminar.", + "settings.auto_minimize.title": "Minimizar o inicializador ao iniciar", + "settings.auto_minimize.desc": "Minimiza automaticamente esta janela do inicializador quando a sobreposição é iniciada.", + + "sounds.title": "Escolha os tipos de som", + "sounds.desc": "Os sons que a IA reconhece ficam agrupados com sons parecidos, de acordo com o tipo padrão de cada um. Se você mudar o tipo de um grupo, a cor dos sons dele passa a seguir o novo tipo na hora.", + "sounds.sibling_note": "A IA muitas vezes reconhece um mesmo som com vários nomes ao mesmo tempo. Uma sirene, por exemplo, também é reconhecida como “Alarme”, que fica em outro grupo. Se, depois de uma mudança, um som continuar aparecendo com o tipo antigo, mude também os grupos ou sons relacionados.", + "sounds.advanced.title": "Modo avançado", + "sounds.advanced.desc": "Mostra cada som dentro do grupo para você mudar os sons um a um.", + "sounds.search.hint": "Pesquisar sons", + "sounds.search.clear": "Limpar pesquisa", + "sounds.filter.title": "Filtro", + "sounds.filter.all": "Todos", + "sounds.filter.changed": "Alterados", + "sounds.type.ambient": "Som ambiente", + "sounds.type.speech": "Voz", + "sounds.type.danger": "Som de perigo", + "sounds.type.default": "{0} (padrão)", + "sounds.type.mixed": "Misto", + "sounds.changed_count": "Sons alterados: {0}", + "sounds.changed_from": "Padrão: {0}", + "sounds.reset.button": "Redefinir tudo", + "sounds.reset.title": "Redefinir todos os sons?", + "sounds.reset.message": "Todos os sons que você alterou voltam ao tipo padrão.", + "sounds.reset.confirm": "Redefinir", + "sounds.reset.cancel": "Cancelar", + "sounds.empty": "Nenhum som encontrado.", + "sounds.group.mixed_note": "Alguns sons alterados um a um", + "sounds.group.changed_line": "Atual: {0} · Padrão: {1}", + "sounds.overwrite.title": "Alterar o grupo inteiro?", + "sounds.overwrite.message": "Sons do grupo “{0}” que vão mudar para “{2}”: {1}. Os sons que você alterou um a um também serão substituídos.", + "sounds.overwrite.confirm": "Alterar", + "sounds.overwrite.cancel": "Cancelar", + "sounds.basic_warning.title": "Mudar para o modo básico?", + "sounds.basic_warning.message": "Alguns grupos têm sons que você alterou um a um. No modo básico, se você escolher um tipo para um desses grupos, todos os sons dele passam a ter esse tipo e as alterações feitas um a um nesse grupo são substituídas. Nada muda até você escolher.", + "sounds.basic_warning.confirm": "Mudar para básico", + "sounds.basic_warning.cancel": "Ficar no avançado", + "sounds.cd.sound": "{0}, tipo: {1}", + "sounds.cd.sound_changed": "{0}, tipo: {1}, padrão: {2}", + "sounds.cd.group": "Grupo {0}, tipo: {1}, sons: {2} ({3})", + "sounds.cd.group_changed": "Grupo {0}, tipo: {1}, padrão: {2}, sons: {3} ({4})", + "sounds.cd.group_header": "Grupo {0}, sons: {1}", + + "hotkey.prompt": "Pressione uma tecla (Esc para cancelar)", + "hotkey.none": "Nenhum", + "hotkey.button_name": "{0}, atalho: {1}", + + "help.start.title": "Primeiros passos", + "help.start.1": "Escolha o idioma no topo desta janela e, se quiser, mude a aparência dos gráficos na aba Ajustes.", + "help.start.2": "Na aba Início, clique em “{0}”.", + "help.start.3": "Os gráficos são desenhados nas bordas da tela, por cima de outras janelas, como jogos e vídeos. Os cliques atravessam os gráficos, então você pode continuar usando a janela que está embaixo.", + "help.start.4": "Para desligar, clique em “{0}” na aba Início ou clique com o botão direito no ícone do SoundVisualizer na área de notificação da barra de tarefas e escolha “{0}”.", + "help.modes.title": "Modos visuais", + "help.mode.wave": "Toda a borda da tela avança para dentro de acordo com o volume do som. O lado de onde vem o som avança ainda mais.", + "help.mode.pad": "Barras se acendem em 8 pontos ao redor da borda da tela. É o modo que mostra a direção com mais clareza.", + "help.mode.circle": "Um anel no centro da tela se expande na direção do som.", + "help.mode.outline": "Desenha só um contorno fino da onda, então cobre menos a tela do jogo.", + "help.modes.note": "Ajustes como Tamanho e Opacidade são salvos separadamente para cada modo e podem ser alterados na aba Ajustes.", + "help.sound.title": "Tipos de som", + "help.sound.kinds": "A IA separa os sons em três tipos: sons ambientes, vozes e sons de perigo. Por padrão, os sons ambientes aparecem em branco, as vozes em amarelo e os sons de perigo em vermelho. Na aba Ajustes, você pode mudar a cor de cada tipo e escolher se ele é mostrado.", + "help.sound.groups": "Na aba Sons, os sons parecidos ficam agrupados e você escolhe o tipo de cada grupo. Por exemplo, se mudar o grupo “Campainha” para som de perigo, quando a IA reconhecer uma campainha ou uma batida na porta, o som passa a ter a cor dos sons de perigo. Mas um som que você mesmo muda para som de perigo só participa da votação da IA sobre o tipo, então isso não quer dizer que toda campainha vai aparecer como som de perigo.", + "help.sound.advanced": "Ative o modo avançado para mudar os sons um a um.", + "help.sound.danger": "Quando um som de perigo (como um tiro) é detectado, a cor muda na hora, sem transição gradual.", + "help.pc.title": "Ajustes do PC e teclas de atalho", + "help.sound_mode.title": "Modo de som", + "help.sound_mode.body": "Para diferenciar também os sons da frente e de trás, além dos da esquerda e da direita, configure o dispositivo de saída como “Surround 5.1” (6 canais) ou “Surround 7.1” (8 canais) nas configurações de som do Windows. Com um estéreo comum (2 canais), o app diferencia esquerda e direita, mas não frente e trás. O “Modo de som” na aba Ajustes se ajusta ao dispositivo de saída ao iniciar e quando o dispositivo muda, e você também pode alterá-lo.", + "help.sound_mode.tip": "💡 Se você não tiver um dispositivo de saída 7.1, pode instalar o {0} e configurar o dispositivo de saída em 7.1 canais para mostrar a sobreposição com som surround 7.1.", + "help.sound_mode.tip_link": "driver virtual VB-CABLE", + "help.hotkeys.title": "Atalhos personalizados em tempo real", + "help.hotkeys.body": "Na aba Ajustes, clique em um botão de atalho e pressione uma tecla ou uma combinação de teclas (por exemplo, Ctrl + Shift + A) para mudar o atalho como quiser. Ao pressionar o atalho, o modo muda na hora, mesmo com o app em segundo plano.", + "help.hotkeys.defaults": "Por padrão, F2 alterna o modo de som, F3 alterna o modo visual e F4 abre os ajustes da sobreposição.", + "help.size_lock.title": "Modo de tamanho fixo", + "help.size_lock.body": "O modo de tamanho fixo mantém os gráficos sempre do mesmo tamanho e usa uma transparência proporcional ao volume do som. É útil quando você quer que o tamanho dos gráficos não mude, mas ainda quer ver como eles reagem ao som.", + "help.editor.title": "Modo de edição da sobreposição em tempo real", + "help.editor.body": "Pressione o atalho definido (F4 por padrão) para ativar o modo de edição da sobreposição. Você pode arrastar o mouse na tela para ajustar o limite de tamanho dos gráficos e refinar os detalhes no painel de ajustes que aparece.", + "help.developer.title": "Modo de desenvolvedor", + "help.developer.body": "Ao ativar o modo de desenvolvedor, a sobreposição mostra no canto superior esquerdo o que a IA ouviu (o nome do som, o grau de certeza e quanto tempo levou) e, no canto superior direito, o FPS. Se você também ativar “{0}”, cada resultado é salvo em um arquivo CSV. É útil para verificar se o sistema está funcionando corretamente.", + "help.notes.title": "Bom saber", + "help.note.direction_surround": "Com saída de 5.1 ou 7.1 canais, a parte de cima da tela mostra o som que vem da frente, e a de baixo, o som que vem de trás. As laterais esquerda e direita mostram o som que vem da esquerda e da direita, e os gráficos que surgem de todos os lados da tela ao mesmo tempo representam sons ambientes ou sem direção.", + "help.note.direction_stereo": "Com saída de 2 canais (estéreo), o app diferencia esquerda e direita, mas não frente e trás. Nesse caso, os gráficos nas partes de cima e de baixo da tela representam sons que chegam igualmente dos dois lados e a sensação de espaço.", + "help.note.sources": "O app mostra o som de todos os apps que tocam pelo dispositivo de reprodução padrão do Windows. O som que um app envia para outro dispositivo, como um headset usado só para chat de voz, não é mostrado.", + "help.note.volume": "Se o volume de um app estiver muito baixo ou no mudo, no próprio app ou no mixer de volume do Windows, o som dele pode quase não aparecer ou nem aparecer. Se os gráficos não reagirem bem, aumente um pouco esse som ou aumente a Sensibilidade na aba Ajustes.", + "help.note.ai": "A IA pode errar na classificação. Não use este app como sua única proteção em situações que envolvam segurança.", + "help.faq.title": "Perguntas frequentes", + "help.faq.stopped_q": "Desligou sozinha, sem que eu tenha parado.", + "help.faq.stopped_a": "Ela desliga quando a captura de som para e não volta em alguns segundos, por exemplo, quando o dispositivo de reprodução é desconectado e não há outro disponível. A aba Início mostra o motivo. Conecte alto-falantes ou fones de ouvido, ou defina um dispositivo de reprodução padrão nas configurações de som do Windows, e inicie de novo.", + "help.faq.no_graphic_q": "Não vejo nenhum gráfico.", + "help.faq.no_graphic_a": "Verifique se o som está tocando pelo dispositivo de reprodução padrão do Windows e não está no mudo, e se a aba Início mostra algum aviso. Se um jogo estiver em tela cheia exclusiva, experimente o modo sem bordas ou em janela. Confira também, na aba Ajustes, se a Opacidade não está baixa demais e se a exibição do tipo de som atual não está desativada.", + "help.faq.performance_q": "Usa muita CPU ou GPU?", + "help.faq.performance_a": "Para deixar mais leve, diminua “{0}” na aba Ajustes ou desative “{1}”.", + "help.faq.language_q": "Como mudo o idioma do app?", + "help.faq.language_a": "Escolha um na lista “{0}” no topo desta janela. Enquanto estiver em execução, você também pode mudar nos ajustes da sobreposição. “{1}” segue o idioma de exibição do Windows.", + "help.report.title": "Relatar um problema", + "help.report.desc": "Conte para nós sobre um bug ou sobre um som que foi mostrado com o tipo errado. O GitHub é aberto no seu navegador com as informações do sistema abaixo já preenchidas, por isso é preciso ter uma conta no GitHub. Se não tiver uma, envie um e-mail para nós. Nenhum áudio é enviado em momento algum.", + "help.report.github": "Relatar no GitHub", + "help.report.mail": "Relatar por e-mail", + "help.report.copy": "Copiar informações do sistema", + "help.report.copied": "Informações do sistema copiadas.", + "help.report.copy_failed": "Não foi possível copiar para a área de transferência. Tente de novo.", + "help.report.no_browser": "Não foi possível abrir um navegador. Copie as informações do sistema e envie para nós de outra forma.", + "help.report.no_mail": "Não há um app de e-mail para enviar. Copie as informações do sistema e envie de outra forma.", + "help.report.issue_title": "Relato enviado pelo app", + "help.report.issue_body": "O que aconteceu?\n\nO que você estava ouvindo (app, tipo de som)?\n\nO que você esperava em vez disso?", + "help.about.title": "Sobre", + "help.about.version": "Versão {0}", + "help.about.license": "Este app é distribuído sob a GNU Affero General Public License v3.0 (AGPL-3.0). Os nomes e os grupos de sons da aba Sons são baseados na ontologia AudioSet e distribuídos sob a licença CC BY-SA 4.0 (veja “Licenças de código aberto” abaixo).", + "help.licenses.button": "Licenças de código aberto", + "help.licenses.title": "Licenças de código aberto", + "help.licenses.close": "Fechar", + + "notice.ai_unavailable": "A detecção do tipo de som não está funcionando, então todos os sons aparecem na cor dos sons ambientes. Tente desligar e ligar a visualização de novo.", + "notice.ai_unavailable_hidden": "A detecção do tipo de som não está funcionando, então todos os sons contam como sons ambientes, mas a exibição dos sons ambientes está desligada, por isso nada aparece. Ative “{0}” na aba Ajustes ou tente desligar e ligar a visualização de novo.", + "notice.stopped.title": "Visualização de sons desligada", + "notice.stopped.no_device": "Não foi possível abrir nenhum dispositivo de reprodução, então o som não pode ser capturado. Conecte alto-falantes ou fones de ouvido, ou defina um dispositivo de reprodução padrão nas configurações de som do Windows, e inicie de novo.", + "notice.stopped.capture_error": "A captura de som parou por causa de um erro, então os gráficos estão desligados.", + "notice.dismiss": "Fechar aviso", + + "tray.open": "Abrir o inicializador", + "tray.exit": "Sair", + + "more_info.title": "Repositório no GitHub e contribuições", + "more_info.body": "O SoundVisualizer é um projeto de código aberto, e o código-fonte é público no GitHub. Você pode participar relatando bugs, sugerindo recursos ou contribuindo com o desenvolvimento. Clique no botão abaixo para visitar o repositório oficial no GitHub! Você também pode conferir e baixar a versão mais recente na aba Releases do GitHub.", + "more_info.github_button": "Visitar o repositório no GitHub", + + "dialog.notice_title": "Aviso", + "dialog.already_running": "A sobreposição já está em execução.", + "dialog.reset_title": "Redefinir ajustes", + "dialog.reset_wave": "Redefinir os ajustes do modo Onda para o padrão?", + "dialog.reset_pad": "Redefinir os ajustes do modo Pad para o padrão?", + "dialog.reset_circle": "Redefinir os ajustes do modo Círculo para o padrão?", + "dialog.reset_outline": "Redefinir os ajustes do modo Contorno para o padrão?", + "dialog.reset_sound_types": "Redefinir os ajustes dos tipos de som para o padrão?", + "dialog.reset_all_title": "Redefinir para o padrão", + "dialog.reset_all": "Tem certeza de que quer redefinir todos os ajustes para o padrão?", + "dialog.link_error": "Não foi possível abrir o link: {0}", + + "editor.title": "Ajustes da sobreposição", + "editor.drag_tip": "Arraste com o mouse as linhas-guia na tela para ajustar em tempo real o limite de altura e o tamanho do centro da onda da sobreposição!", + "editor.glow": "Efeito de brilho neon", + "editor.glow_strength": "Intensidade", + "editor.color": "Cor", + "editor.hotkeys": "Ajustes de atalhos", + "editor.developer_mode_desc": "O que a IA ouviu (no alto à esquerda) e o FPS (no alto à direita)", + "editor.save_close_hint": "para salvar e fechar", + "editor.close_overlay": "Fechar a sobreposição", + "editor.guide.limit": "Limite no modo {0}", + "editor.guide.size": "Tamanho no modo {0}: {1}%", + "editor.guide.base_radius": "Raio base: {0}", + "editor.guide.circle_size": "Tamanho do círculo: {0}%", + + "hud.visual_mode": "Modo visual: [{0}] {1}", + "hud.sound_mode": "Modo de som: [{0}] {1}", + "hud.overlay_settings": "Ajustes da sobreposição: [{0}]", + "hud.status.ready": "Sistema pronto", + "hud.status.stereo": "Áudio do PC: {0}ch (estéreo)\nFonte: aguardando...", + "hud.status.surround": "Áudio do PC: 8ch (7.1)\nFonte: aguardando...", + + "color_picker.title": "Escolha uma cor", + "color_picker.hex": "Código HEX", + "color_picker.cancel": "Cancelar", + "color_picker.apply": "Aplicar", + "color_picker.area": "Saturação e brilho", + "color_picker.area_value": "Saturação {0}%, brilho {1}%", + "color_picker.hue": "Matiz", + "color_picker.keys_help": "Use as teclas de seta para alterar. Mantenha a tecla Shift pressionada para avançar em passos maiores.", + "color_picker.presets": "Cores comuns", + "color_picker.preset.white": "Branco", + "color_picker.preset.red": "Vermelho", + "color_picker.preset.orange": "Laranja", + "color_picker.preset.yellow": "Amarelo", + "color_picker.preset.green": "Verde", + "color_picker.preset.cyan": "Turquesa", + "color_picker.preset.blue": "Azul", + "color_picker.preset.magenta": "Magenta", + + "audio.error.no_device": "Nenhum dispositivo de saída foi encontrado.", + "audio.error.capture_stopped": "A captura parou inesperadamente: {0}", + "audio.error.capture_start_failed": "Não foi possível iniciar o mecanismo de captura: {0}", + + "tutorial.window_title": "Tutorial", + "tutorial.skip": "Pular", + "tutorial.previous": "Página anterior", + "tutorial.next": "Próximo", + "tutorial.done": "Entendi", + "tutorial.page_status": "Página {0} de {1}", + "tutorial.pause": "Pausar animação", + "tutorial.play": "Reproduzir animação", + "tutorial.sound_on": "Ativar som", + "tutorial.sound_off": "Desativar som", + "tutorial.sound.title": "Veja o som na sua tela", + "tutorial.sound.body": "O som do seu jogo ou vídeo é desenhado nas bordas da tela. Quanto mais alto o som, maior o desenho. Só aparece o som que toca neste PC. Os sons ao seu redor não são captados.", + "tutorial.direction.title": "Mostra de onde vem o som", + "tutorial.direction.body": "Com saída de 2 canais (estéreo), um som da esquerda é desenhado maior do lado esquerdo, e um som da direita, do lado direito. Com saída de 5.1 ou 7.1 canais, o app também diferencia frente e trás: o som que vem da frente é desenhado na parte de cima da tela, e o que vem de trás, na parte de baixo.", + "tutorial.types.title": "Cada tipo de som tem sua própria cor", + "tutorial.types.body": "A IA separa os sons em três tipos e desenha cada um com uma cor diferente. Você pode mudar as cores na aba Ajustes.", + "tutorial.type.ambient_examples": "Música, chuva e todo o resto", + "tutorial.type.speech_examples": "Pessoas falando", + "tutorial.type.danger_examples": "Tiros, explosões, sirenes", + "tutorial.start.title": "Como ligar e desligar", + "tutorial.start.hotkeys": "Enquanto estiver em execução, pressione {0} para alternar o modo de som, {1} para alternar o modo visual e {2} para abrir os ajustes da sobreposição." +} diff --git a/SoundVisualizer.Core/Localization/Strings/ru.json b/SoundVisualizer.Core/Localization/Strings/ru.json new file mode 100644 index 0000000..e01c9e3 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/ru.json @@ -0,0 +1,294 @@ +{ + "common.language": "Язык", + "common.reset_defaults": "Сбросить настройки", + "common.minimize": "Свернуть", + "common.close": "Закрыть", + "language.system": "Язык системы", + + "launcher.window_title": "Лаунчер SoundVisualizer", + "launcher.tab.home": "Главная", + "launcher.tab.settings": "Настройки", + "launcher.tab.sounds": "Звуки", + "launcher.tab.help": "Справка", + "launcher.tab.more_info": "Подробнее", + "launcher.home.desc": "Показывает на экране звуки, которые нельзя увидеть.\nОт игр до фильмов — откройте для себя новые впечатления.", + "launcher.home.start": "Запустить", + "launcher.home.stop": "Остановить", + "launcher.home.tutorial": "Показать обучение", + "launcher.status.running": "Статус: работает", + "launcher.status.idle": "Статус: ожидает запуска", + + "settings.section.mode": "Настройки режима", + "settings.visual_mode.title": "Режим отображения", + "settings.visual_mode.desc": "Выберите основную форму графики, которая рисуется на экране.", + "settings.mode.wave": "Волна", + "settings.mode.pad": "Панель", + "settings.mode.circle": "Круг", + "settings.mode.outline": "Контур", + "settings.sound_mode.title": "Звуковой режим", + "settings.sound_mode.desc": "Выберите звуковые каналы, которые подходят вашим динамикам.\nДля двухканальных динамиков выберите «2 канала», а для систем объёмного звука — «5.1 каналов» или «7.1 каналов».", + "settings.channels.2": "2 канала", + "settings.channels.5_1": "5.1 каналов", + "settings.channels.7_1": "7.1 каналов", + "settings.max_fps.title": "Макс. частота кадров", + "settings.max_fps.desc": "Задаёт максимальную частоту кадров, с которой рисуется графика.", + "settings.section.mode_detail": "Параметры по режимам", + "settings.mode_card.wave": "Режим «Волна»", + "settings.mode_card.pad": "Режим «Панель»", + "settings.mode_card.circle": "Режим «Круг»", + "settings.mode_card.outline": "Режим «Контур»", + "settings.size_lock.title": "Фиксировать размер", + "settings.size_lock.desc": "Размер графики не меняется, а громкость передаётся её прозрачностью.", + "settings.fixed_size.wave": "Размер волны", + "settings.fixed_size.pad": "Размер панелей", + "settings.fixed_size.circle": "Размер круга", + "settings.fixed_size.outline": "Толщина контура", + "settings.fixed_size.desc": "Задаёт постоянный размер графики, при котором меняется только её прозрачность.", + "settings.max_opacity.title": "Макс. плотность", + "settings.max_opacity.desc": "Насколько плотной становится графика при самом громком звуке.", + "settings.size.title": "Размер", + "settings.size.desc.wave": "Регулирует высоту волны и то, насколько объёмной она выглядит.", + "settings.size.desc.pad": "Регулирует, насколько толстыми и широкими становятся панели, когда реагируют на звук.", + "settings.size.desc.circle": "Регулирует, насколько далеко круговой эквалайзер выступает наружу при воспроизведении звука.", + "settings.size.desc.outline": "Регулирует высоту контурной волны и то, насколько объёмной она выглядит.", + "settings.opacity.title": "Плотность", + "settings.opacity.desc.wave": "Чем выше значение, тем насыщеннее графика; чем ниже, тем она бледнее и прозрачнее.", + "settings.opacity.desc.pad": "Чем выше значение, тем насыщеннее графика; чем ниже, тем она бледнее и прозрачнее.", + "settings.opacity.desc.circle": "Чем выше значение, тем насыщеннее графика; чем ниже, тем она бледнее и прозрачнее.", + "settings.opacity.desc.outline": "Чем выше значение, тем насыщеннее графика; чем ниже, тем она бледнее и прозрачнее.", + "settings.speed.title": "Скорость", + "settings.speed.desc.wave": "Регулирует, как быстро волна течёт и колышется.", + "settings.speed.desc.pad": "Регулирует, как быстро панели поворачиваются в сторону, откуда идёт звук.", + "settings.speed.desc.circle": "Регулирует, как быстро круговой эквалайзер колышется и вращается в ответ на звук.", + "settings.speed.desc.outline": "Регулирует, как быстро контурная волна течёт и колышется.", + "settings.sensitivity.title": "Отклик", + "settings.sensitivity.desc.wave": "Регулирует, насколько чутко волна откликается даже на тихие звуки и как сильно при этом вздымается.", + "settings.sensitivity.desc.pad": "Настраивает панели так, чтобы они лучше улавливали звук и заметнее выделялись.", + "settings.sensitivity.desc.circle": "Регулирует, насколько дробно распадается амплитуда круга, когда он реагирует на звук.", + "settings.sensitivity.desc.outline": "Регулирует, насколько чутко контурная волна откликается даже на тихие звуки и как сильно при этом вздымается.", + "settings.glow.title": "Сияние", + "settings.glow.desc.wave": "Добавляет мягкое неоновое сияние по краю волны и регулирует его силу.\nВнимание: требует дополнительных ресурсов GPU.", + "settings.glow.desc.pad": "Добавляет вокруг панелей мягкое сияние, похожее на ауру.\nВнимание: требует дополнительных ресурсов GPU.", + "settings.glow.desc.circle": "Добавляет мерцающий ореол по краю кругового эквалайзера.\nВнимание: требует дополнительных ресурсов GPU.", + "settings.glow.desc.outline": "Добавляет контуру мягкое неоновое сияние и регулирует его силу.\nВнимание: требует дополнительных ресурсов GPU.", + "settings.radius.title": "Радиус", + "settings.radius.desc": "Задаёт радиус центрального кольца в режиме «Круг».", + "settings.section.sound_types": "Типы звуков (ИИ)", + "settings.show_ambient": "Показывать фоновые звуки", + "settings.show_speech": "Показывать речь", + "settings.show_danger": "Показывать опасные звуки", + "settings.set_color": "Задать цвет", + "settings.set_color_for": "Задать цвет: {0}", + "settings.section.hotkeys": "Горячие клавиши", + "settings.hotkey.visual.title": "Смена режима отображения", + "settings.hotkey.visual.desc": "Горячая клавиша, которая меняет форму графики прямо во время работы.", + "settings.hotkey.sound.title": "Смена звукового режима", + "settings.hotkey.sound.desc": "Горячая клавиша, которая меняет звуковой режим прямо во время работы.", + "settings.hotkey.edit.title": "Настройки оверлея", + "settings.hotkey.edit.desc": "Горячая клавиша, которая открывает и закрывает редактор оверлея.", + "settings.hotkey.set": "Назначить клавишу", + "settings.section.advanced": "Дополнительные настройки", + "settings.developer_mode.title": "Режим разработчика", + "settings.developer_mode.desc": "Показывает в левом верхнем углу оверлея, что услышал ИИ (название звука, уверенность и время обработки), а в правом верхнем — FPS. Эти сведения выводятся на английском и не предназначены для повседневного использования.", + "settings.developer_record.title": "Записывать результаты в файл", + "settings.developer_record.desc": "Записывает каждый результат ИИ (названия звуков с самой высокой оценкой, уверенность и время обработки) в новый CSV-файл в папке ai-log рядом с SoundVisualizer.exe, чтобы потом можно было посмотреть, что он услышал. Если в эту папку нельзя записывать, файл сохраняется в %LOCALAPPDATA%\\SoundVisualizer\\ai-log. Файлы могут быть большими, поэтому выключите запись, когда закончите.", + "settings.auto_minimize.title": "Сворачивать лаунчер при запуске", + "settings.auto_minimize.desc": "Автоматически сворачивает окно лаунчера, когда запускается оверлей.", + + "sounds.title": "Выберите типы звуков", + "sounds.desc": "Звуки, которые распознаёт ИИ, собраны в группы похожих звуков по типам, к которым они относятся по умолчанию. Если изменить тип группы, цвет её звуков сразу станет таким же, как у нового типа.", + "sounds.sibling_note": "ИИ часто распознаёт один и тот же звук сразу под несколькими названиями. Сирена, например, распознаётся ещё и как «Сигнал тревоги», а этот звук в другой группе. Если после изменения звук всё ещё отображается со старым типом, измените и связанные группы или звуки.", + "sounds.advanced.title": "Расширенный режим", + "sounds.advanced.desc": "Показывает каждый звук в его группе, чтобы можно было менять звуки по одному.", + "sounds.search.hint": "Поиск звуков", + "sounds.search.clear": "Очистить поиск", + "sounds.filter.title": "Фильтр", + "sounds.filter.all": "Все", + "sounds.filter.changed": "Изменённые", + "sounds.type.ambient": "Фоновые звуки", + "sounds.type.speech": "Речь", + "sounds.type.danger": "Опасные звуки", + "sounds.type.default": "{0} (по умолчанию)", + "sounds.type.mixed": "Смешанный", + "sounds.changed_count": "Изменено звуков: {0}", + "sounds.changed_from": "По умолчанию: {0}", + "sounds.reset.button": "Сбросить все", + "sounds.reset.title": "Сбросить типы всех звуков?", + "sounds.reset.message": "Каждый звук, который вы изменили, вернётся к своему типу по умолчанию.", + "sounds.reset.confirm": "Сбросить", + "sounds.reset.cancel": "Отмена", + "sounds.empty": "Подходящих звуков нет.", + "sounds.group.mixed_note": "Некоторые звуки изменены по отдельности", + "sounds.group.changed_line": "Сейчас: {0} · По умолчанию: {1}", + "sounds.overwrite.title": "Изменить всю группу?", + "sounds.overwrite.message": "Звуков в группе «{0}», которые получат тип «{2}»: {1}. Звуки, изменённые по отдельности, тоже будут перезаписаны.", + "sounds.overwrite.confirm": "Изменить", + "sounds.overwrite.cancel": "Отмена", + "sounds.basic_warning.title": "Перейти в базовый режим?", + "sounds.basic_warning.message": "В некоторых группах есть звуки, которые вы изменили по отдельности. Если в базовом режиме выбрать тип для такой группы, все её звуки получат этот тип, а изменения, сделанные по отдельности в этой группе, будут перезаписаны. Пока вы ничего не выберете, ничего не изменится.", + "sounds.basic_warning.confirm": "В базовый режим", + "sounds.basic_warning.cancel": "Остаться в расширенном", + "sounds.cd.sound": "{0}, тип: {1}", + "sounds.cd.sound_changed": "{0}, тип: {1}, по умолчанию: {2}", + "sounds.cd.group": "Группа {0}, тип: {1}, звуков: {2} ({3})", + "sounds.cd.group_changed": "Группа {0}, тип: {1}, по умолчанию: {2}, звуков: {3} ({4})", + "sounds.cd.group_header": "Группа {0}, звуков: {1}", + + "hotkey.prompt": "Нажмите клавишу (Esc — отмена)", + "hotkey.none": "Нет", + "hotkey.button_name": "{0}, горячая клавиша: {1}", + + "help.start.title": "Начало работы", + "help.start.1": "Выберите язык в верхней части этого окна и при желании измените вид графики на вкладке «Настройки».", + "help.start.2": "На вкладке «Главная» нажмите «{0}».", + "help.start.3": "Графика рисуется по краям экрана поверх других окон, например игр и видео. Щелчки мыши проходят сквозь неё, поэтому окном под ней можно пользоваться как обычно.", + "help.start.4": "Чтобы выключить, нажмите «{0}» на вкладке «Главная» или щёлкните правой кнопкой мыши значок SoundVisualizer в области уведомлений на панели задач и выберите «{0}».", + "help.modes.title": "Режимы отображения", + "help.mode.wave": "Края экрана по всему периметру надвигаются внутрь — тем сильнее, чем громче звук. Со стороны, откуда идёт звук, волна заходит глубже.", + "help.mode.pad": "В 8 точках по краю экрана загораются полоски. В этом режиме направление видно лучше всего.", + "help.mode.circle": "Кольцо в центре экрана вытягивается в сторону звука.", + "help.mode.outline": "Рисуется только тонкий контур волны, поэтому графика меньше загораживает игру.", + "help.modes.note": "Такие настройки, как «Размер» и «Плотность», сохраняются отдельно для каждого режима. Изменить их можно на вкладке «Настройки».", + "help.sound.title": "Типы звуков", + "help.sound.kinds": "ИИ делит всё, что слышно, на фоновые звуки, речь и опасные звуки. По умолчанию фоновые звуки отображаются белым, речь — жёлтым, а опасные звуки — красным. На вкладке «Настройки» можно изменить цвет каждого типа и выбрать, показывать ли его.", + "help.sound.groups": "На вкладке «Звуки» похожие звуки собраны в группы, и вы выбираете, к какому типу относится каждая группа. Например, если перевести группу «Дверной звонок» в опасные звуки, то когда ИИ распознаёт дверной звонок или стук в дверь, звук получит цвет опасных звуков. Однако звук, который вы сами перевели в опасные звуки, лишь участвует в голосовании ИИ о типе, поэтому это не значит, что каждый дверной звонок будет показан как опасный звук.", + "help.sound.advanced": "Чтобы менять звуки по одному, включите расширенный режим.", + "help.sound.danger": "Когда обнаружен опасный звук (например, выстрел), цвет меняется сразу, а не плавно.", + "help.pc.title": "Настройки ПК и горячие клавиши", + "help.sound_mode.title": "Звуковой режим", + "help.sound_mode.body": "Чтобы различать звуки не только слева и справа, но и спереди и сзади, выберите для устройства вывода в параметрах звука Windows конфигурацию объёмного звука 5.1 (6 каналов) или 7.1 (8 каналов). При обычном стерео (2 канала) приложение различает звуки слева и справа, но не спереди и сзади. «Звуковой режим» на вкладке «Настройки» подстраивается под устройство вывода при запуске и при смене устройства, но его можно изменить и вручную.", + "help.sound_mode.tip": "💡 Если у вас нет устройства вывода 7.1, установите {0} и выберите для устройства вывода конфигурацию 7.1 — тогда оверлей сможет показывать объёмный звук 7.1.", + "help.sound_mode.tip_link": "виртуальный драйвер VB-CABLE", + "help.hotkeys.title": "Настраиваемые горячие клавиши", + "help.hotkeys.body": "На вкладке «Настройки» нажмите кнопку горячей клавиши, а затем нажмите одну клавишу или сочетание клавиш (например, Ctrl + Shift + A), чтобы изменить горячую клавишу по своему усмотрению. Горячая клавиша мгновенно переключает режим, даже когда приложение работает в фоне.", + "help.hotkeys.defaults": "По умолчанию F2 переключает звуковой режим, F3 — режим отображения, а F4 открывает настройки оверлея.", + "help.size_lock.title": "Режим фиксации размера", + "help.size_lock.body": "В режиме фиксации размера графика сохраняет постоянный размер, а громкость звука передаётся прозрачностью. Это удобно, если вы хотите, чтобы графика не меняла размер, но при этом было видно, как она реагирует на звук.", + "help.editor.title": "Редактирование оверлея в реальном времени", + "help.editor.body": "Нажмите назначенную горячую клавишу (по умолчанию F4), чтобы включить режим редактирования оверлея. Перетаскивая мышью прямо на экране, можно задать предельный размер графики, а в появившейся панели настроек — точно настроить детали.", + "help.developer.title": "Режим разработчика", + "help.developer.body": "Если включить режим разработчика, в левом верхнем углу оверлея появится то, что услышал ИИ (название звука, уверенность и время обработки), а в правом верхнем — FPS. Если также включить «{0}», каждый результат будет сохраняться в CSV-файл. Это полезно, когда нужно проверить, правильно ли работает система.", + "help.notes.title": "Полезно знать", + "help.note.direction_surround": "При выводе звука 5.1 или 7.1 верх экрана показывает звук спереди, а низ — звук сзади. Левый и правый края показывают звук слева и справа, а графика, появляющаяся одновременно со всех сторон экрана, — это фоновые или ненаправленные звуки.", + "help.note.direction_stereo": "При двухканальном (стерео) выводе приложение различает звуки слева и справа, но не спереди и сзади. Графика вверху и внизу экрана в этом случае обозначает звуки, одинаково слышные с обеих сторон, а также передаёт ощущение пространства.", + "help.note.sources": "Показывается звук всех приложений, который идёт через устройство воспроизведения Windows по умолчанию. Звук, который приложение отправляет на другое устройство, например на гарнитуру только для голосового чата, не показывается.", + "help.note.volume": "Если громкость приложения сильно убавлена или выключена — в самом приложении или в микшере громкости Windows, — его звук может почти или совсем не отображаться. Если графика реагирует слабо, немного прибавьте этот звук или увеличьте «Отклик» на вкладке «Настройки».", + "help.note.ai": "ИИ может ошибаться при определении типа звука. В ситуациях, где важна безопасность, не полагайтесь только на это приложение.", + "help.faq.title": "Часто задаваемые вопросы", + "help.faq.stopped_q": "Визуализатор выключился сам, без моей команды.", + "help.faq.stopped_a": "Он выключается, если захват звука прекращается и не восстанавливается за несколько секунд, например когда устройство воспроизведения отключено, а другого нет. Причина отобразится на вкладке «Главная». Подключите колонки или наушники либо выберите устройство воспроизведения по умолчанию в параметрах звука Windows, а затем запустите снова.", + "help.faq.no_graphic_q": "Графика не отображается.", + "help.faq.no_graphic_a": "Проверьте, что звук идёт через устройство воспроизведения Windows по умолчанию и не выключен, а также нет ли сообщения на вкладке «Главная». Если игра работает в монопольном полноэкранном режиме, попробуйте оконный режим без рамки или оконный режим. Также убедитесь, что на вкладке «Настройки» значение «Плотность» не слишком низкое и что показ текущего типа звука не выключен.", + "help.faq.performance_q": "Приложение сильно нагружает процессор или видеокарту?", + "help.faq.performance_a": "Чтобы снизить нагрузку, уменьшите «{0}» на вкладке «Настройки» или выключите «{1}».", + "help.faq.language_q": "Как изменить язык приложения?", + "help.faq.language_a": "Выберите его в списке «{0}» в верхней части этого окна. Во время работы язык можно сменить и в настройках оверлея. При выборе варианта «{1}» используется язык интерфейса Windows.", + "help.report.title": "Сообщить о проблеме", + "help.report.desc": "Сообщите нам об ошибке или о звуке, для которого был показан неверный тип. В браузере откроется GitHub, где уже будут заполнены сведения о системе, показанные ниже, поэтому нужен аккаунт GitHub. Если аккаунта нет, напишите нам по электронной почте. Звук никогда никуда не отправляется.", + "help.report.github": "Сообщить через GitHub", + "help.report.mail": "Сообщить по эл. почте", + "help.report.copy": "Скопировать сведения о системе", + "help.report.copied": "Сведения о системе скопированы.", + "help.report.copy_failed": "Не удалось скопировать в буфер обмена. Попробуйте ещё раз.", + "help.report.no_browser": "Не удалось открыть браузер. Скопируйте сведения о системе и отправьте их нам другим способом.", + "help.report.no_mail": "Нет приложения для отправки почты. Скопируйте сведения о системе и отправьте их другим способом.", + "help.report.issue_title": "Сообщение из приложения", + "help.report.issue_body": "Что произошло?\n\nЧто вы слушали (приложение, тип звука)?\n\nЧего вы ожидали вместо этого?", + "help.about.title": "О приложении", + "help.about.version": "Версия {0}", + "help.about.license": "Это приложение распространяется на условиях GNU Affero General Public License v3.0 (AGPL-3.0). Названия и группы звуков на вкладке «Звуки» основаны на онтологии AudioSet и распространяются на условиях лицензии CC BY-SA 4.0 (см. «Лицензии открытого ПО» ниже).", + "help.licenses.button": "Лицензии открытого ПО", + "help.licenses.title": "Лицензии открытого ПО", + "help.licenses.close": "Закрыть", + + "notice.ai_unavailable": "Определение типа звука не работает, поэтому все звуки показываются цветом фоновых звуков. Попробуйте выключить и снова включить визуализатор.", + "notice.ai_unavailable_hidden": "Определение типа звука не работает, поэтому все звуки считаются фоновыми, а показ фоновых звуков выключен, и ничего не отображается. Включите «{0}» на вкладке «Настройки» или попробуйте выключить и снова включить визуализатор.", + "notice.stopped.title": "Визуализатор звука выключен", + "notice.stopped.no_device": "Не удалось открыть ни одно устройство воспроизведения, поэтому захватить звук невозможно. Подключите колонки или наушники либо выберите устройство воспроизведения по умолчанию в параметрах звука Windows, а затем запустите снова.", + "notice.stopped.capture_error": "Захват звука прервался из-за ошибки, поэтому графика отключена.", + "notice.dismiss": "Скрыть", + + "tray.open": "Открыть лаунчер", + "tray.exit": "Выход", + + "more_info.title": "Репозиторий GitHub и участие в проекте", + "more_info.body": "SoundVisualizer — проект с открытым исходным кодом, и его код опубликован на GitHub. Вы можете принять участие в проекте: сообщать об ошибках, предлагать новые функции или помогать с разработкой. Нажмите кнопку ниже, чтобы перейти в официальный репозиторий на GitHub! Там же на вкладке Releases можно проверить, вышла ли новая версия, и скачать её.", + "more_info.github_button": "Перейти в репозиторий GitHub", + + "dialog.notice_title": "Уведомление", + "dialog.already_running": "Оверлей уже запущен.", + "dialog.reset_title": "Сброс настроек", + "dialog.reset_wave": "Вернуть настройки режима «Волна» к значениям по умолчанию?", + "dialog.reset_pad": "Вернуть настройки режима «Панель» к значениям по умолчанию?", + "dialog.reset_circle": "Вернуть настройки режима «Круг» к значениям по умолчанию?", + "dialog.reset_outline": "Вернуть настройки режима «Контур» к значениям по умолчанию?", + "dialog.reset_sound_types": "Вернуть настройки типов звуков к значениям по умолчанию?", + "dialog.reset_all_title": "Сброс всех настроек", + "dialog.reset_all": "Вы действительно хотите вернуть все настройки к значениям по умолчанию?", + "dialog.link_error": "Не удалось открыть ссылку: {0}", + + "editor.title": "Настройки оверлея", + "editor.drag_tip": "Перетаскивайте мышью направляющие на экране, чтобы в реальном времени менять предельную высоту и размер центра волны оверлея!", + "editor.glow": "Эффект сияния", + "editor.glow_strength": "Сила сияния", + "editor.color": "Цвет", + "editor.hotkeys": "Настройка горячих клавиш", + "editor.developer_mode_desc": "Показывает, что услышал ИИ (слева вверху), и FPS (справа вверху)", + "editor.save_close_hint": "— сохранить и закрыть", + "editor.close_overlay": "Закрыть оверлей", + "editor.guide.limit": "{0}: граница", + "editor.guide.size": "{0}: размер {1} %", + "editor.guide.base_radius": "Базовый радиус: {0}", + "editor.guide.circle_size": "Размер круга: {0} %", + + "hud.visual_mode": "Режим отображения: [{0}] {1}", + "hud.sound_mode": "Звуковой режим: [{0}] {1}", + "hud.overlay_settings": "Настройки оверлея: [{0}]", + "hud.status.ready": "Система готова", + "hud.status.stereo": "Звук ПК: {0} кан. (стерео)\nИсточник: ожидание...", + "hud.status.surround": "Звук ПК: 8 кан. (7.1)\nИсточник: ожидание...", + + "color_picker.title": "Выбор цвета", + "color_picker.hex": "HEX-код", + "color_picker.cancel": "Отмена", + "color_picker.apply": "Применить", + "color_picker.area": "Насыщенность и яркость", + "color_picker.area_value": "Насыщенность {0} %, яркость {1} %", + "color_picker.hue": "Оттенок", + "color_picker.keys_help": "Меняйте значение клавишами со стрелками. С нажатой клавишей Shift шаг больше.", + "color_picker.presets": "Основные цвета", + "color_picker.preset.white": "Белый", + "color_picker.preset.red": "Красный", + "color_picker.preset.orange": "Оранжевый", + "color_picker.preset.yellow": "Жёлтый", + "color_picker.preset.green": "Зелёный", + "color_picker.preset.cyan": "Голубой", + "color_picker.preset.blue": "Синий", + "color_picker.preset.magenta": "Пурпурный", + + "audio.error.no_device": "Устройство вывода не найдено.", + "audio.error.capture_stopped": "Захват звука неожиданно остановился: {0}", + "audio.error.capture_start_failed": "Не удалось запустить движок захвата звука: {0}", + + "tutorial.window_title": "Обучение", + "tutorial.skip": "Пропустить", + "tutorial.previous": "Предыдущая страница", + "tutorial.next": "Далее", + "tutorial.done": "Понятно", + "tutorial.page_status": "Страница {0} из {1}", + "tutorial.pause": "Приостановить анимацию", + "tutorial.play": "Воспроизвести анимацию", + "tutorial.sound_on": "Включить звук", + "tutorial.sound_off": "Выключить звук", + "tutorial.sound.title": "Показываем звук на экране", + "tutorial.sound.body": "Звук из игры или видео рисуется по краям экрана. Чем громче звук, тем крупнее рисунок. Показывается только звук, который воспроизводится на этом ПК. Приложение не слышит звуки вокруг вас.", + "tutorial.direction.title": "Показывает, откуда идёт звук", + "tutorial.direction.body": "При двухканальном (стерео) выводе звук слева рисуется крупнее у левого края экрана, а звук справа — у правого. При выводе звука 5.1 или 7.1 приложение различает и звуки спереди и сзади: звук спереди рисуется вверху экрана, а звук сзади — внизу.", + "tutorial.types.title": "У каждого типа звука свой цвет", + "tutorial.types.body": "ИИ делит звуки на три типа и рисует каждый своим цветом. Цвета можно изменить на вкладке «Настройки».", + "tutorial.type.ambient_examples": "Музыка, дождь и всё остальное", + "tutorial.type.speech_examples": "Голоса людей", + "tutorial.type.danger_examples": "Выстрелы, взрывы, сирены", + "tutorial.start.title": "Как включить и выключить", + "tutorial.start.hotkeys": "Во время работы нажмите {0}, чтобы переключить звуковой режим, {1} — чтобы переключить режим отображения, и {2} — чтобы открыть настройки оверлея." +} diff --git a/SoundVisualizer.Core/Localization/Strings/th.json b/SoundVisualizer.Core/Localization/Strings/th.json new file mode 100644 index 0000000..79908cd --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/th.json @@ -0,0 +1,294 @@ +{ + "common.language": "ภาษา", + "common.reset_defaults": "รีเซ็ตเป็นค่าเริ่มต้น", + "common.minimize": "ย่อเล็กสุด", + "common.close": "ปิด", + "language.system": "ค่าเริ่มต้นของระบบ", + + "launcher.window_title": "ตัวเปิดใช้ SoundVisualizer", + "launcher.tab.home": "หน้าแรก", + "launcher.tab.settings": "การตั้งค่า", + "launcher.tab.sounds": "เสียง", + "launcher.tab.help": "ความช่วยเหลือ", + "launcher.tab.more_info": "ข้อมูลเพิ่มเติม", + "launcher.home.desc": "เปลี่ยนเสียงที่มองไม่เห็นให้เป็นภาพบนหน้าจอ\nตั้งแต่เล่นเกมไปจนถึงดูภาพยนตร์ มาสัมผัสประสบการณ์ใหม่กันเลย", + "launcher.home.start": "เริ่ม", + "launcher.home.stop": "หยุด", + "launcher.home.tutorial": "ดูบทแนะนำ", + "launcher.status.running": "สถานะ: กำลังทำงาน", + "launcher.status.idle": "สถานะ: พร้อมใช้งาน", + + "settings.section.mode": "การตั้งค่าโหมด", + "settings.visual_mode.title": "โหมดการแสดงผล", + "settings.visual_mode.desc": "เลือกรูปแบบพื้นฐานของกราฟิกที่จะวาดบนหน้าจอ", + "settings.mode.wave": "คลื่น", + "settings.mode.pad": "แพด", + "settings.mode.circle": "วงกลม", + "settings.mode.outline": "เส้นขอบ", + "settings.sound_mode.title": "โหมดเสียง", + "settings.sound_mode.desc": "เลือกจำนวนแชนแนลเสียงให้ตรงกับลำโพงของคุณ\nหากใช้ลำโพง 2 แชนแนล ให้เลือก “2 แชนแนล” หากใช้ลำโพงเซอร์ราวด์ ให้เลือก “5.1 แชนแนล” หรือ “7.1 แชนแนล”", + "settings.channels.2": "2 แชนแนล", + "settings.channels.5_1": "5.1 แชนแนล", + "settings.channels.7_1": "7.1 แชนแนล", + "settings.max_fps.title": "อัตราเฟรมสูงสุด", + "settings.max_fps.desc": "ตั้งค่าอัตราเฟรมสูงสุดที่ใช้วาดกราฟิก", + "settings.section.mode_detail": "การตั้งค่าแต่ละโหมด", + "settings.mode_card.wave": "โหมดคลื่น", + "settings.mode_card.pad": "โหมดแพด", + "settings.mode_card.circle": "โหมดวงกลม", + "settings.mode_card.outline": "โหมดเส้นขอบ", + "settings.size_lock.title": "ล็อกขนาด", + "settings.size_lock.desc": "ให้กราฟิกมีขนาดคงที่ และแสดงความดังของเสียงเป็นความเข้มแทน", + "settings.fixed_size.wave": "ขนาดคลื่น", + "settings.fixed_size.pad": "ขนาดแพด", + "settings.fixed_size.circle": "ขนาดวงกลม", + "settings.fixed_size.outline": "ความหนาเส้นขอบ", + "settings.fixed_size.desc": "ตั้งขนาดคงที่ที่กราฟิกจะคงไว้ขณะที่ความเข้มเปลี่ยนไป", + "settings.max_opacity.title": "ความเข้มสูงสุด", + "settings.max_opacity.desc": "ความเข้มของกราฟิกเมื่อเสียงดังที่สุด", + "settings.size.title": "ขนาด", + "settings.size.desc.wave": "ปรับความสูงของคลื่นและความเต็มของภาพโดยรวม", + "settings.size.desc.pad": "ปรับความหนาและความกว้างของแพดเมื่อตอบสนองต่อเสียง", + "settings.size.desc.circle": "ปรับว่าอีควอไลเซอร์วงกลมจะพองออกมากแค่ไหนเมื่อมีเสียง", + "settings.size.desc.outline": "ปรับความสูงของคลื่นเส้นขอบและความเต็มของภาพโดยรวม", + "settings.opacity.title": "ความเข้ม", + "settings.opacity.desc.wave": "ค่ายิ่งสูงจะยิ่งดูเข้มชัด ค่ายิ่งต่ำจะยิ่งดูจางและโปร่งใส", + "settings.opacity.desc.pad": "ค่ายิ่งสูงจะยิ่งดูเข้มชัด ค่ายิ่งต่ำจะยิ่งดูจางและโปร่งใส", + "settings.opacity.desc.circle": "ค่ายิ่งสูงจะยิ่งดูเข้มชัด ค่ายิ่งต่ำจะยิ่งดูจางและโปร่งใส", + "settings.opacity.desc.outline": "ค่ายิ่งสูงจะยิ่งดูเข้มชัด ค่ายิ่งต่ำจะยิ่งดูจางและโปร่งใส", + "settings.speed.title": "ความเร็ว", + "settings.speed.desc.wave": "ปรับความเร็วที่คลื่นไหลและกระเพื่อม", + "settings.speed.desc.pad": "ปรับความเร็วที่แพดหันไปทางทิศที่เสียงมา", + "settings.speed.desc.circle": "ปรับความเร็วที่อีควอไลเซอร์วงกลมกระเพื่อมและหมุนตามเสียง", + "settings.speed.desc.outline": "ปรับความเร็วที่คลื่นเส้นขอบไหลและกระเพื่อม", + "settings.sensitivity.title": "ความไว", + "settings.sensitivity.desc.wave": "ปรับว่าคลื่นจะตอบสนองและซัดขึ้นไวแค่ไหน แม้กับเสียงเบา", + "settings.sensitivity.desc.pad": "ปรับให้แพดจับเสียงได้ดีขึ้นและเด่นชัดขึ้น", + "settings.sensitivity.desc.circle": "ปรับว่าแอมพลิจูดของวงกลมจะแบ่งย่อยละเอียดแค่ไหนเมื่อตอบสนองต่อเสียง", + "settings.sensitivity.desc.outline": "ปรับว่าคลื่นเส้นขอบจะตอบสนองและซัดขึ้นไวแค่ไหน แม้กับเสียงเบา", + "settings.glow.title": "แสงเรือง", + "settings.glow.desc.wave": "เพิ่มแสงเรืองแบบนีออนที่นุ่มนวลตามขอบคลื่น และปรับระดับความแรง\nหมายเหตุ: ใช้ทรัพยากร GPU เพิ่มขึ้น", + "settings.glow.desc.pad": "เพิ่มแสงเรืองนุ่มนวลคล้ายออร่ารอบแพด\nหมายเหตุ: ใช้ทรัพยากร GPU เพิ่มขึ้น", + "settings.glow.desc.circle": "เพิ่มวงแสงระยิบระยับตามขอบของอีควอไลเซอร์วงกลม\nหมายเหตุ: ใช้ทรัพยากร GPU เพิ่มขึ้น", + "settings.glow.desc.outline": "เพิ่มแสงเรืองแบบนีออนที่นุ่มนวลให้เส้นขอบ และปรับระดับความแรง\nหมายเหตุ: ใช้ทรัพยากร GPU เพิ่มขึ้น", + "settings.radius.title": "รัศมี", + "settings.radius.desc": "ปรับรัศมีของวงแหวนตรงกลางในโหมดวงกลม", + "settings.section.sound_types": "ประเภทเสียง (AI)", + "settings.show_ambient": "แสดงเสียงแวดล้อม", + "settings.show_speech": "แสดงเสียงพูด", + "settings.show_danger": "แสดงเสียงอันตราย", + "settings.set_color": "ตั้งค่าสี", + "settings.set_color_for": "ตั้งค่าสี: {0}", + "settings.section.hotkeys": "คีย์ลัด", + "settings.hotkey.visual.title": "สลับโหมดการแสดงผล", + "settings.hotkey.visual.desc": "คีย์ลัดสำหรับเปลี่ยนรูปแบบกราฟิกแบบเรียลไทม์ขณะทำงาน", + "settings.hotkey.sound.title": "สลับโหมดเสียง", + "settings.hotkey.sound.desc": "คีย์ลัดสำหรับเปลี่ยนโหมดเสียงแบบเรียลไทม์ขณะทำงาน", + "settings.hotkey.edit.title": "การตั้งค่าโอเวอร์เลย์", + "settings.hotkey.edit.desc": "คีย์ลัดสำหรับเปิดหรือปิดหน้าแก้ไขโอเวอร์เลย์", + "settings.hotkey.set": "ตั้งคีย์ลัด", + "settings.section.advanced": "การตั้งค่าขั้นสูง", + "settings.developer_mode.title": "โหมดนักพัฒนา", + "settings.developer_mode.desc": "แสดงสิ่งที่ AI ได้ยิน (ชื่อเสียงที่ AI รายงาน ความมั่นใจ และเวลาที่ใช้) ที่มุมซ้ายบนของโอเวอร์เลย์ และแสดง FPS ที่มุมขวาบน ข้อมูลที่แสดงเป็นภาษาอังกฤษและไม่ได้มีไว้สำหรับการใช้งานทั่วไป", + "settings.developer_record.title": "บันทึกผลลัพธ์ลงไฟล์", + "settings.developer_record.desc": "บันทึกผลลัพธ์ทุกครั้งของ AI (ชื่อเสียงที่ AI ให้คะแนนสูงสุด ความมั่นใจ และเวลาที่ใช้) ลงในไฟล์ CSV ใหม่ในโฟลเดอร์ ai-log ข้าง SoundVisualizer.exe เพื่อให้คุณย้อนดูได้ว่า AI ได้ยินอะไร หากเขียนลงโฟลเดอร์นั้นไม่ได้ ไฟล์จะถูกบันทึกไว้ที่ %LOCALAPPDATA%\\SoundVisualizer\\ai-log แทน ไฟล์อาจมีขนาดใหญ่ เมื่อดูเสร็จแล้วโปรดปิด", + "settings.auto_minimize.title": "ย่อตัวเปิดใช้เมื่อเริ่ม", + "settings.auto_minimize.desc": "ย่อหน้าต่างตัวเปิดใช้นี้โดยอัตโนมัติเมื่อโอเวอร์เลย์เริ่มทำงาน", + + "sounds.title": "เลือกประเภทเสียง", + "sounds.desc": "เสียงที่ AI รู้จักจะจัดกลุ่มรวมกับเสียงที่คล้ายกัน และแสดงตามประเภทเริ่มต้นของแต่ละเสียง หากเปลี่ยนประเภทของกลุ่ม สีของเสียงในกลุ่มจะเปลี่ยนตามประเภทใหม่ทันที", + "sounds.sibling_note": "AI มักได้ยินเสียงเดียวเป็นหลายชื่อพร้อมกัน เช่น ไซเรนยังได้ยินเป็น “สัญญาณเตือนภัย” ด้วย ซึ่งอยู่ในอีกกลุ่มหนึ่ง หากเปลี่ยนแล้วเสียงยังแสดงเป็นประเภทเดิม ให้เปลี่ยนกลุ่มหรือเสียงที่เกี่ยวข้องด้วย", + "sounds.advanced.title": "โหมดขั้นสูง", + "sounds.advanced.desc": "แสดงทุกเสียงตามกลุ่ม เพื่อให้คุณเปลี่ยนเสียงได้ทีละเสียง", + "sounds.search.hint": "ค้นหาเสียง", + "sounds.search.clear": "ล้างคำค้นหา", + "sounds.filter.title": "ตัวกรอง", + "sounds.filter.all": "ทั้งหมด", + "sounds.filter.changed": "เปลี่ยนแล้ว", + "sounds.type.ambient": "เสียงแวดล้อม", + "sounds.type.speech": "เสียงพูด", + "sounds.type.danger": "เสียงอันตราย", + "sounds.type.default": "{0} (ค่าเริ่มต้น)", + "sounds.type.mixed": "ผสม", + "sounds.changed_count": "เปลี่ยนแล้ว {0} เสียง", + "sounds.changed_from": "ค่าเริ่มต้น: {0}", + "sounds.reset.button": "รีเซ็ตทั้งหมด", + "sounds.reset.title": "รีเซ็ตเสียงทั้งหมดหรือไม่", + "sounds.reset.message": "เสียงทุกเสียงที่คุณเปลี่ยนไว้จะกลับไปเป็นประเภทเริ่มต้น", + "sounds.reset.confirm": "รีเซ็ต", + "sounds.reset.cancel": "ยกเลิก", + "sounds.empty": "ไม่พบเสียงที่ตรงกัน", + "sounds.group.mixed_note": "มีบางเสียงที่เปลี่ยนทีละเสียง", + "sounds.group.changed_line": "ตอนนี้: {0} · ค่าเริ่มต้น: {1}", + "sounds.overwrite.title": "เปลี่ยนทั้งกลุ่มหรือไม่", + "sounds.overwrite.message": "เสียง {1} เสียงในกลุ่ม “{0}” จะเปลี่ยนเป็น “{2}” เสียงที่คุณเปลี่ยนทีละเสียงไว้จะถูกเขียนทับด้วย", + "sounds.overwrite.confirm": "เปลี่ยน", + "sounds.overwrite.cancel": "ยกเลิก", + "sounds.basic_warning.title": "เปลี่ยนเป็นโหมดพื้นฐานหรือไม่", + "sounds.basic_warning.message": "บางกลุ่มมีเสียงที่คุณเปลี่ยนทีละเสียงไว้ ในโหมดพื้นฐาน หากเลือกประเภทให้กลุ่มเหล่านี้ เสียงทั้งหมดในกลุ่มจะเป็นประเภทนั้น และสิ่งที่เปลี่ยนทีละเสียงไว้ในกลุ่มนั้นจะถูกเขียนทับ จะไม่มีอะไรเปลี่ยนจนกว่าคุณจะเลือก", + "sounds.basic_warning.confirm": "ใช้โหมดพื้นฐาน", + "sounds.basic_warning.cancel": "อยู่ในโหมดขั้นสูงต่อ", + "sounds.cd.sound": "{0}, ประเภท: {1}", + "sounds.cd.sound_changed": "{0}, ประเภท: {1}, ค่าเริ่มต้น: {2}", + "sounds.cd.group": "กลุ่ม {0}, ประเภท: {1}, จำนวนเสียง: {2} ({3})", + "sounds.cd.group_changed": "กลุ่ม {0}, ประเภท: {1}, ค่าเริ่มต้น: {2}, จำนวนเสียง: {3} ({4})", + "sounds.cd.group_header": "กลุ่ม {0}, จำนวนเสียง: {1}", + + "hotkey.prompt": "กดคีย์ (Esc เพื่อยกเลิก)", + "hotkey.none": "ไม่มี", + "hotkey.button_name": "{0}, คีย์ลัด: {1}", + + "help.start.title": "เริ่มต้นใช้งาน", + "help.start.1": "เลือกภาษาที่ด้านบนของหน้าต่างนี้ และหากต้องการ ให้เปลี่ยนรูปแบบกราฟิกในแท็บการตั้งค่า", + "help.start.2": "ในแท็บหน้าแรก ให้คลิก “{0}”", + "help.start.3": "กราฟิกจะวาดตามขอบหน้าจอ ซ้อนอยู่บนหน้าต่างอื่นๆ เช่น เกมและวิดีโอ การคลิกเมาส์จะผ่านกราฟิกไป คุณจึงใช้หน้าต่างด้านล่างได้ตามปกติ", + "help.start.4": "หากต้องการปิด ให้คลิก “{0}” ในแท็บหน้าแรก หรือคลิกขวาที่ไอคอน SoundVisualizer ในพื้นที่แจ้งเตือนของแถบงาน แล้วเลือก “{0}”", + "help.modes.title": "โหมดการแสดงผล", + "help.mode.wave": "ขอบหน้าจอทั้งหมดจะดันเข้าด้านในตามความดังของเสียง และด้านที่เสียงมาจะดันเข้าไปลึกกว่า", + "help.mode.pad": "แถบจะสว่างขึ้นที่ 8 จุดรอบขอบหน้าจอ แสดงทิศทางได้ชัดเจนที่สุด", + "help.mode.circle": "วงแหวนตรงกลางหน้าจอจะพองออกไปทางทิศทางที่เสียงมา", + "help.mode.outline": "วาดเฉพาะเส้นขอบบางของคลื่น จึงบังหน้าจอเกมน้อยกว่า", + "help.modes.note": "การตั้งค่าต่างๆ เช่น ขนาดและความเข้ม จะบันทึกแยกกันในแต่ละโหมด และเปลี่ยนได้ในแท็บการตั้งค่า", + "help.sound.title": "ประเภทเสียง", + "help.sound.kinds": "AI จะแยกเสียงที่ได้ยินออกเป็นเสียงแวดล้อม เสียงพูด และเสียงอันตราย สีเริ่มต้นคือ เสียงแวดล้อมสีขาว เสียงพูดสีเหลือง และเสียงอันตรายสีแดง ในแท็บการตั้งค่า คุณสามารถเปลี่ยนสีของแต่ละประเภทและเลือกได้ว่าจะแสดงหรือไม่", + "help.sound.groups": "ในแท็บเสียง เสียงที่คล้ายกันจะอยู่รวมเป็นกลุ่ม และคุณเลือกได้ว่าแต่ละกลุ่มจะนับเป็นประเภทใด เช่น หากเปลี่ยนกลุ่ม “กริ่งประตู” เป็นเสียงอันตราย เมื่อ AI ได้ยินเป็นกริ่งประตูหรือเคาะประตู เสียงนั้นจะใช้สีของเสียงอันตราย อย่างไรก็ตาม เสียงที่คุณตั้งเป็นเสียงอันตรายเองจะเข้าร่วมแค่การโหวตของ AI เพื่อเลือกประเภทเท่านั้น จึงไม่ได้หมายความว่ากริ่งประตูจะแสดงเป็นเสียงอันตรายทุกครั้ง", + "help.sound.advanced": "หากต้องการเปลี่ยนเสียงทีละเสียง ให้เปิดโหมดขั้นสูง", + "help.sound.danger": "เมื่อตรวจพบเสียงอันตราย (เช่น เสียงปืน) สีจะเปลี่ยนทันทีแทนที่จะค่อยๆ เปลี่ยน", + "help.pc.title": "การตั้งค่า PC และปุ่มลัด", + "help.sound_mode.title": "โหมดเสียง", + "help.sound_mode.body": "หากต้องการแยกเสียงด้านหน้าและด้านหลังได้ด้วย นอกเหนือจากซ้ายและขวา ให้ตั้งค่าอุปกรณ์เอาต์พุตในการตั้งค่าเสียงของ Windows เป็นเซอร์ราวด์ 5.1 (6 แชนแนล) หรือเซอร์ราวด์ 7.1 (8 แชนแนล) หากใช้สเตอริโอทั่วไป (2 แชนแนล) จะแยกซ้ายกับขวาได้ แต่แยกด้านหน้ากับด้านหลังไม่ได้ “โหมดเสียง” ในแท็บการตั้งค่าจะปรับให้ตรงกับอุปกรณ์เอาต์พุตเมื่อเริ่มทำงานหรือเมื่ออุปกรณ์เปลี่ยน และคุณยังเปลี่ยนเองได้", + "help.sound_mode.tip": "💡 หากไม่มีอุปกรณ์เอาต์พุต 7.1 คุณสามารถติดตั้ง{0} แล้วตั้งค่าอุปกรณ์เอาต์พุตเป็น 7.1 แชนแนล เพื่อแสดงโอเวอร์เลย์ด้วยเสียงเซอร์ราวด์ 7.1", + "help.sound_mode.tip_link": "ไดรเวอร์เสมือน VB-CABLE", + "help.hotkeys.title": "คีย์ลัดที่กำหนดเองได้แบบเรียลไทม์", + "help.hotkeys.body": "ในแท็บการตั้งค่า ให้คลิกปุ่มคีย์ลัด แล้วกดคีย์เดียวหรือคีย์ผสม (เช่น Ctrl + Shift + A) เพื่อเปลี่ยนคีย์ลัดได้ตามต้องการ เมื่อกดคีย์ลัด โหมดจะสลับทันทีแม้แอปจะทำงานอยู่เบื้องหลัง", + "help.hotkeys.defaults": "ค่าเริ่มต้นคือ F2 สลับโหมดเสียง F3 สลับโหมดการแสดงผล และ F4 เปิดการตั้งค่าโอเวอร์เลย์", + "help.size_lock.title": "โหมดล็อกขนาด", + "help.size_lock.body": "โหมดล็อกขนาดจะคงขนาดของกราฟิกไว้ และใช้ความเข้มที่แปรผันตามความดังของเสียงแทน เหมาะเมื่อคุณต้องการให้กราฟิกมีขนาดเท่าเดิม แต่ยังเห็นการตอบสนองต่อเสียง", + "help.editor.title": "โหมดแก้ไขโอเวอร์เลย์แบบเรียลไทม์", + "help.editor.body": "กดคีย์ลัดที่กำหนดไว้ (ค่าเริ่มต้นคือ F4) เพื่อเปิดโหมดแก้ไขโอเวอร์เลย์ คุณสามารถใช้เมาส์ลากบนหน้าจอเพื่อปรับขีดจำกัดขนาดของกราฟิก และปรับรายละเอียดในแผงการตั้งค่าที่แสดงขึ้นมา", + "help.developer.title": "โหมดนักพัฒนา", + "help.developer.body": "เมื่อเปิดโหมดนักพัฒนา มุมซ้ายบนของโอเวอร์เลย์จะแสดงสิ่งที่ AI ได้ยิน (ชื่อเสียง ความมั่นใจ และเวลาที่ใช้) และมุมขวาบนจะแสดง FPS หากเปิด “{0}” ด้วย ผลลัพธ์ทุกรายการจะถูกบันทึกลงไฟล์ CSV เหมาะเมื่อคุณต้องการตรวจสอบว่าระบบทำงานได้ตามปกติหรือไม่", + "help.notes.title": "ข้อควรทราบ", + "help.note.direction_surround": "เมื่อส่งเสียงออกแบบ 5.1 หรือ 7.1 แชนแนล ด้านบนของหน้าจอแสดงเสียงจากด้านหน้า และด้านล่างแสดงเสียงจากด้านหลัง ด้านซ้ายและขวาแสดงเสียงจากทางซ้ายและทางขวา ส่วนกราฟิกที่ปรากฏจากทุกด้านของหน้าจอพร้อมกันคือเสียงแวดล้อมหรือเสียงที่ไม่มีทิศทาง", + "help.note.direction_stereo": "เมื่อส่งเสียงออกแบบ 2 แชนแนล (สเตอริโอ) แอปแยกเสียงซ้ายกับขวาได้ แต่แยกด้านหน้ากับด้านหลังไม่ได้ ในกรณีนี้ กราฟิกที่ด้านบนและด้านล่างของหน้าจอแสดงถึงเสียงที่ได้ยินเท่าๆ กันทั้งสองข้าง รวมถึงมิติเชิงพื้นที่", + "help.note.sources": "แอปจะแสดงเสียงของทุกแอปที่เล่นผ่านอุปกรณ์เล่นเสียงเริ่มต้นของ Windows เสียงที่แอปส่งไปยังอุปกรณ์อื่น เช่น หูฟังที่ใช้สำหรับแชทเสียงเท่านั้น จะไม่แสดง", + "help.note.volume": "หากลดระดับเสียงของแอปใดลงมากหรือปิดเสียงไว้ ไม่ว่าจะในแอปนั้นเองหรือในตัวผสมเสียงของ Windows เสียงของแอปนั้นอาจแทบไม่แสดงหรือไม่แสดงเลย หากกราฟิกตอบสนองไม่ดี ให้เพิ่มเสียงนั้นขึ้นเล็กน้อย หรือเพิ่มความไวในแท็บการตั้งค่า", + "help.note.ai": "AI อาจจำแนกเสียงผิดพลาดได้ โปรดอย่าพึ่งพาแอปนี้เพียงอย่างเดียวในสถานการณ์ที่เกี่ยวข้องกับความปลอดภัย", + "help.faq.title": "คำถามที่พบบ่อย", + "help.faq.stopped_q": "การแสดงภาพเสียงถูกปิดเองโดยที่ฉันไม่ได้สั่งหยุด", + "help.faq.stopped_a": "แอปจะปิดเมื่อการจับเสียงหยุดลงและไม่กลับมาทำงานภายในไม่กี่วินาที เช่น เมื่ออุปกรณ์เล่นเสียงถูกถอดออกและไม่มีอุปกรณ์อื่นให้ใช้ แท็บหน้าแรกจะแสดงสาเหตุ โปรดเชื่อมต่อลำโพงหรือหูฟัง หรือตั้งค่าอุปกรณ์เล่นเสียงเริ่มต้นในการตั้งค่าเสียงของ Windows แล้วเริ่มอีกครั้ง", + "help.faq.no_graphic_q": "ไม่เห็นกราฟิกเลย", + "help.faq.no_graphic_a": "โปรดตรวจสอบว่าเสียงเล่นผ่านอุปกรณ์เล่นเสียงเริ่มต้นของ Windows และไม่ได้ปิดเสียงไว้ รวมถึงดูว่าแท็บหน้าแรกแสดงข้อความแจ้งหรือไม่ หากเกมทำงานในโหมดเต็มหน้าจอแบบเอกสิทธิ์ ให้ลองใช้โหมดหน้าต่างแบบไม่มีขอบหรือโหมดหน้าต่าง นอกจากนี้ โปรดตรวจสอบในแท็บการตั้งค่าด้วยว่าไม่ได้ตั้งความเข้มไว้ต่ำเกินไป รวมถึงไม่ได้ปิดการแสดงเสียงประเภทที่กำลังดังอยู่", + "help.faq.performance_q": "ใช้ CPU หรือ GPU มากไหม", + "help.faq.performance_a": "หากต้องการให้เบาลง ให้ลด “{0}” ในแท็บการตั้งค่า หรือปิด “{1}”", + "help.faq.language_q": "เปลี่ยนภาษาของแอปได้อย่างไร", + "help.faq.language_a": "เลือกจากรายการ “{0}” ที่ด้านบนของหน้าต่างนี้ ขณะทำงานอยู่ คุณยังเปลี่ยนได้ในการตั้งค่าโอเวอร์เลย์ หากเลือก “{1}” แอปจะใช้ภาษาที่แสดงของ Windows", + "help.report.title": "รายงานปัญหา", + "help.report.desc": "แจ้งให้เราทราบเกี่ยวกับข้อบกพร่อง หรือเสียงที่ถูกแสดงผิดประเภท GitHub จะเปิดขึ้นในเบราว์เซอร์ของคุณ โดยกรอกข้อมูลระบบด้านล่างไว้ให้แล้ว จึงต้องมีบัญชี GitHub หากไม่มีบัญชี โปรดส่งอีเมลถึงเราแทน จะไม่มีการส่งข้อมูลเสียงใดๆ ออกไปเลย", + "help.report.github": "รายงานผ่าน GitHub", + "help.report.mail": "รายงานทางอีเมล", + "help.report.copy": "คัดลอกข้อมูลระบบ", + "help.report.copied": "คัดลอกข้อมูลระบบแล้ว", + "help.report.copy_failed": "คัดลอกไปยังคลิปบอร์ดไม่สำเร็จ โปรดลองอีกครั้ง", + "help.report.no_browser": "เปิดเบราว์เซอร์ไม่ได้ โปรดคัดลอกข้อมูลระบบแล้วส่งถึงเราแทน", + "help.report.no_mail": "ไม่มีแอปอีเมลสำหรับส่ง โปรดคัดลอกข้อมูลระบบแล้วส่งด้วยวิธีอื่น", + "help.report.issue_title": "รายงานจากแอป", + "help.report.issue_body": "เกิดอะไรขึ้น\n\nคุณกำลังฟังอะไรอยู่ (แอปใด เสียงประเภทใด)\n\nคุณคาดว่าควรจะเป็นอย่างไร", + "help.about.title": "เกี่ยวกับแอป", + "help.about.version": "เวอร์ชัน {0}", + "help.about.license": "แอปนี้เผยแพร่ภายใต้ GNU Affero General Public License v3.0 (AGPL-3.0) ชื่อและกลุ่มของเสียงในแท็บเสียงอิงจาก AudioSet Ontology และเผยแพร่ภายใต้ CC BY-SA 4.0 (ดู “ใบอนุญาตโอเพนซอร์ส” ด้านล่าง)", + "help.licenses.button": "ใบอนุญาตโอเพนซอร์ส", + "help.licenses.title": "ใบอนุญาตโอเพนซอร์ส", + "help.licenses.close": "ปิด", + + "notice.ai_unavailable": "ระบบแยกประเภทเสียงไม่ทำงาน จึงแสดงทุกเสียงด้วยสีของเสียงแวดล้อม ลองปิดแล้วเปิดการแสดงภาพเสียงอีกครั้ง", + "notice.ai_unavailable_hidden": "ระบบแยกประเภทเสียงไม่ทำงาน จึงนับทุกเสียงเป็นเสียงแวดล้อม แต่การแสดงเสียงแวดล้อมถูกปิดอยู่ จึงไม่มีอะไรแสดง โปรดเปิด “{0}” ในแท็บการตั้งค่า หรือลองปิดแล้วเปิดการแสดงภาพเสียงอีกครั้ง", + "notice.stopped.title": "การแสดงภาพเสียงถูกปิดแล้ว", + "notice.stopped.no_device": "เปิดอุปกรณ์เล่นเสียงไม่ได้ จึงจับเสียงไม่ได้ โปรดเชื่อมต่อลำโพงหรือหูฟัง หรือตั้งค่าอุปกรณ์เล่นเสียงเริ่มต้นในการตั้งค่าเสียงของ Windows แล้วเริ่มอีกครั้ง", + "notice.stopped.capture_error": "การจับเสียงหยุดลงเพราะเกิดข้อผิดพลาด จึงปิดกราฟิก", + "notice.dismiss": "ปิดข้อความแจ้ง", + + "tray.open": "แสดงตัวเปิดใช้", + "tray.exit": "ออก", + + "more_info.title": "ที่เก็บโค้ดบน GitHub และการมีส่วนร่วม", + "more_info.body": "SoundVisualizer เป็นโปรเจกต์โอเพนซอร์ส และเปิดเผยซอร์สโค้ดไว้บน GitHub คุณสามารถมีส่วนร่วมได้ด้วยการรายงานข้อบกพร่อง เสนอฟีเจอร์ หรือร่วมพัฒนา คลิกปุ่มด้านล่างเพื่อไปยังที่เก็บโค้ดทางการบน GitHub ได้เลย นอกจากนี้ คุณยังตรวจสอบและดาวน์โหลดเวอร์ชันล่าสุดได้ที่แท็บ Releases บน GitHub", + "more_info.github_button": "เปิดที่เก็บโค้ดบน GitHub", + + "dialog.notice_title": "ข้อความแจ้ง", + "dialog.already_running": "โอเวอร์เลย์กำลังทำงานอยู่แล้ว", + "dialog.reset_title": "รีเซ็ตการตั้งค่า", + "dialog.reset_wave": "รีเซ็ตการตั้งค่าโหมดคลื่นเป็นค่าเริ่มต้นหรือไม่", + "dialog.reset_pad": "รีเซ็ตการตั้งค่าโหมดแพดเป็นค่าเริ่มต้นหรือไม่", + "dialog.reset_circle": "รีเซ็ตการตั้งค่าโหมดวงกลมเป็นค่าเริ่มต้นหรือไม่", + "dialog.reset_outline": "รีเซ็ตการตั้งค่าโหมดเส้นขอบเป็นค่าเริ่มต้นหรือไม่", + "dialog.reset_sound_types": "รีเซ็ตการตั้งค่าประเภทเสียงเป็นค่าเริ่มต้นหรือไม่", + "dialog.reset_all_title": "รีเซ็ตเป็นค่าเริ่มต้น", + "dialog.reset_all": "ต้องการรีเซ็ตการตั้งค่าทั้งหมดเป็นค่าเริ่มต้นใช่หรือไม่", + "dialog.link_error": "เปิดลิงก์ไม่ได้: {0}", + + "editor.title": "การตั้งค่าโอเวอร์เลย์", + "editor.drag_tip": "ใช้เมาส์ลากเส้นนำบนหน้าจอเพื่อปรับขีดจำกัดความสูงและขนาดตรงกลางของคลื่นโอเวอร์เลย์ได้แบบเรียลไทม์", + "editor.glow": "เอฟเฟกต์แสงเรือง", + "editor.glow_strength": "ระดับแสงเรือง", + "editor.color": "สี", + "editor.hotkeys": "การตั้งค่าคีย์ลัด", + "editor.developer_mode_desc": "แสดงสิ่งที่ AI ได้ยิน (ซ้ายบน) และ FPS (ขวาบน)", + "editor.save_close_hint": "เพื่อบันทึกการตั้งค่าและปิด", + "editor.close_overlay": "ปิดโอเวอร์เลย์", + "editor.guide.limit": "ขีดจำกัด{0}", + "editor.guide.size": "ขนาด{0}: {1}%", + "editor.guide.base_radius": "รัศมีพื้นฐาน: {0}", + "editor.guide.circle_size": "ขนาดวงกลม: {0}%", + + "hud.visual_mode": "โหมดการแสดงผล: [{0}] {1}", + "hud.sound_mode": "โหมดเสียง: [{0}] {1}", + "hud.overlay_settings": "การตั้งค่าโอเวอร์เลย์: [{0}]", + "hud.status.ready": "ระบบพร้อมแล้ว", + "hud.status.stereo": "เสียง PC: {0}ch (สเตอริโอ)\nแหล่งเสียง: กำลังรอ...", + "hud.status.surround": "เสียง PC: 8ch (7.1)\nแหล่งเสียง: กำลังรอ...", + + "color_picker.title": "เลือกสี", + "color_picker.hex": "รหัส HEX", + "color_picker.cancel": "ยกเลิก", + "color_picker.apply": "นำไปใช้", + "color_picker.area": "ความอิ่มตัวและความสว่าง", + "color_picker.area_value": "ความอิ่มตัว {0}%, ความสว่าง {1}%", + "color_picker.hue": "เฉดสี", + "color_picker.keys_help": "ใช้ปุ่มลูกศรเพื่อเปลี่ยน กด Shift ค้างไว้เพื่อเปลี่ยนทีละมากขึ้น", + "color_picker.presets": "สีที่ใช้บ่อย", + "color_picker.preset.white": "สีขาว", + "color_picker.preset.red": "สีแดง", + "color_picker.preset.orange": "สีส้ม", + "color_picker.preset.yellow": "สีเหลือง", + "color_picker.preset.green": "สีเขียว", + "color_picker.preset.cyan": "สีฟ้า", + "color_picker.preset.blue": "สีน้ำเงิน", + "color_picker.preset.magenta": "สีบานเย็น", + + "audio.error.no_device": "ไม่พบอุปกรณ์เอาต์พุต", + "audio.error.capture_stopped": "การจับเสียงหยุดลงโดยไม่คาดคิด: {0}", + "audio.error.capture_start_failed": "เริ่มเอนจินจับเสียงไม่ได้: {0}", + + "tutorial.window_title": "บทแนะนำ", + "tutorial.skip": "ข้าม", + "tutorial.previous": "หน้าก่อนหน้า", + "tutorial.next": "ถัดไป", + "tutorial.done": "เข้าใจแล้ว", + "tutorial.page_status": "หน้า {0} จาก {1}", + "tutorial.pause": "หยุดภาพเคลื่อนไหวชั่วคราว", + "tutorial.play": "เล่นภาพเคลื่อนไหว", + "tutorial.sound_on": "เปิดเสียง", + "tutorial.sound_off": "ปิดเสียง", + "tutorial.sound.title": "มองเห็นเสียงบนหน้าจอ", + "tutorial.sound.body": "เสียงจากเกมหรือวิดีโอจะแสดงเป็นภาพตามขอบหน้าจอ ยิ่งเสียงดัง ภาพก็ยิ่งใหญ่ แสดงเฉพาะเสียงที่เล่นอยู่ใน PC เครื่องนี้เท่านั้น แอปจะไม่จับเสียงรอบตัวคุณ", + "tutorial.direction.title": "บอกได้ว่าเสียงมาจากทางไหน", + "tutorial.direction.body": "เมื่อส่งเสียงออกแบบ 2 แชนแนล (สเตอริโอ) หากเสียงมาจากทางซ้าย ภาพฝั่งซ้ายของหน้าจอจะใหญ่กว่า หากมาจากทางขวา ภาพฝั่งขวาจะใหญ่กว่า เมื่อส่งเสียงออกแบบ 5.1 หรือ 7.1 แชนแนล แอปจะแยกด้านหน้ากับด้านหลังได้ด้วย โดยเสียงจากด้านหน้าจะแสดงที่ด้านบนของหน้าจอ และเสียงจากด้านหลังจะแสดงที่ด้านล่าง", + "tutorial.types.title": "เสียงแต่ละประเภทมีสีของตัวเอง", + "tutorial.types.body": "AI จะแยกเสียงออกเป็น 3 ประเภท และวาดแต่ละประเภทด้วยสีของตัวเอง คุณเปลี่ยนสีได้ในแท็บการตั้งค่า", + "tutorial.type.ambient_examples": "เพลง เสียงฝน และเสียงอื่นทั้งหมด", + "tutorial.type.speech_examples": "เสียงคน", + "tutorial.type.danger_examples": "เสียงปืน เสียงระเบิด เสียงไซเรน", + "tutorial.start.title": "วิธีเปิดและปิด", + "tutorial.start.hotkeys": "ขณะทำงานอยู่ กด {0} เพื่อสลับโหมดเสียง กด {1} เพื่อสลับโหมดการแสดงผล และกด {2} เพื่อเปิดการตั้งค่าโอเวอร์เลย์" +} diff --git a/SoundVisualizer.Core/Localization/Strings/tr.json b/SoundVisualizer.Core/Localization/Strings/tr.json new file mode 100644 index 0000000..eeac03c --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/tr.json @@ -0,0 +1,294 @@ +{ + "common.language": "Dil", + "common.reset_defaults": "Varsayılanlara sıfırla", + "common.minimize": "Simge durumuna küçült", + "common.close": "Kapat", + "language.system": "Sistem varsayılanı", + + "launcher.window_title": "SoundVisualizer Başlatıcısı", + "launcher.tab.home": "Ana sayfa", + "launcher.tab.settings": "Ayarlar", + "launcher.tab.sounds": "Sesler", + "launcher.tab.help": "Yardım", + "launcher.tab.more_info": "Daha fazla bilgi", + "launcher.home.desc": "Göremediğiniz sesleri doğrudan ekranınıza çizer.\nOyunlardan filmlere, yepyeni bir deneyim yaşayın.", + "launcher.home.start": "Başlat", + "launcher.home.stop": "Durdur", + "launcher.home.tutorial": "Tanıtımı göster", + "launcher.status.running": "Durum: Çalışıyor", + "launcher.status.idle": "Durum: Hazır", + + "settings.section.mode": "Mod ayarları", + "settings.visual_mode.title": "Görsel mod", + "settings.visual_mode.desc": "Ekranda çizilecek grafiklerin temel şeklini seçin.", + "settings.mode.wave": "Dalga", + "settings.mode.pad": "Pad", + "settings.mode.circle": "Daire", + "settings.mode.outline": "Kontur", + "settings.sound_mode.title": "Ses modu", + "settings.sound_mode.desc": "Hoparlörlerinize uygun ses kanallarını seçin.\n2 kanallı hoparlörler için “2 kanal”, surround hoparlörler için “5.1 kanal” veya “7.1 kanal” seçeneğini belirleyin.", + "settings.channels.2": "2 kanal", + "settings.channels.5_1": "5.1 kanal", + "settings.channels.7_1": "7.1 kanal", + "settings.max_fps.title": "Maks. kare hızı", + "settings.max_fps.desc": "Grafiklerin çizileceği en yüksek kare hızını ayarlar.", + "settings.section.mode_detail": "Modlara özel ayarlar", + "settings.mode_card.wave": "Dalga modu", + "settings.mode_card.pad": "Pad modu", + "settings.mode_card.circle": "Daire modu", + "settings.mode_card.outline": "Kontur modu", + "settings.size_lock.title": "Boyutu sabitle", + "settings.size_lock.desc": "Grafikleri sabit bir boyutta tutar; ses yüksekliğini boyut yerine saydamlıkla gösterir.", + "settings.fixed_size.wave": "Dalga boyutu", + "settings.fixed_size.pad": "Pad boyutu", + "settings.fixed_size.circle": "Daire boyutu", + "settings.fixed_size.outline": "Kontur kalınlığı", + "settings.fixed_size.desc": "Saydamlık değişirken grafiklerin sabit kalacağı boyutu ayarlar.", + "settings.max_opacity.title": "Maks. opaklık", + "settings.max_opacity.desc": "Ses en yüksek olduğunda grafiklerin ulaşacağı opaklık.", + "settings.size.title": "Boyut", + "settings.size.desc.wave": "Dalganın yüksekliğini ve genel olarak ne kadar dolgun görüneceğini ayarlar.", + "settings.size.desc.pad": "Pad çubuklarının sese tepki verirken ne kadar kalın ve geniş olacağını ayarlar.", + "settings.size.desc.circle": "Ses çaldığında dairesel ekolayzerin dışa doğru ne kadar şişeceğini ayarlar.", + "settings.size.desc.outline": "Kontur dalgasının yüksekliğini ve genel olarak ne kadar dolgun görüneceğini ayarlar.", + "settings.opacity.title": "Opaklık", + "settings.opacity.desc.wave": "Değer yükseldikçe grafikler daha belirgin, düştükçe daha soluk ve saydam görünür.", + "settings.opacity.desc.pad": "Değer yükseldikçe grafikler daha belirgin, düştükçe daha soluk ve saydam görünür.", + "settings.opacity.desc.circle": "Değer yükseldikçe grafikler daha belirgin, düştükçe daha soluk ve saydam görünür.", + "settings.opacity.desc.outline": "Değer yükseldikçe grafikler daha belirgin, düştükçe daha soluk ve saydam görünür.", + "settings.speed.title": "Hız", + "settings.speed.desc.wave": "Dalganın ne kadar hızlı akıp dalgalanacağını ayarlar.", + "settings.speed.desc.pad": "Pad çubuklarının sesin geldiği yöne ne kadar hızlı döneceğini ayarlar.", + "settings.speed.desc.circle": "Dairesel ekolayzerin sese tepki olarak ne kadar hızlı dalgalanıp döneceğini ayarlar.", + "settings.speed.desc.outline": "Kontur dalgasının ne kadar hızlı akıp dalgalanacağını ayarlar.", + "settings.sensitivity.title": "Hassasiyet", + "settings.sensitivity.desc.wave": "Dalganın kısık seslere bile ne kadar hassas tepki verip kabaracağını ayarlar.", + "settings.sensitivity.desc.pad": "Pad çubuklarının sesi ne kadar iyi yakalayıp ne kadar belirgin görüneceğini ayarlar.", + "settings.sensitivity.desc.circle": "Dairenin genliğinin sese tepki verirken ne kadar ince parçalara bölüneceğini ayarlar.", + "settings.sensitivity.desc.outline": "Kontur dalgasının kısık seslere bile ne kadar hassas tepki verip kabaracağını ayarlar.", + "settings.glow.title": "Parıltı", + "settings.glow.desc.wave": "Dalganın kenarı boyunca yumuşak bir neon parıltısı ekler ve gücünü ayarlar.\nDikkat: Ek GPU kaynağı kullanır.", + "settings.glow.desc.pad": "Pad çubuklarının çevresine yumuşak, aura benzeri bir parıltı ekler.\nDikkat: Ek GPU kaynağı kullanır.", + "settings.glow.desc.circle": "Dairesel ekolayzerin kenarı boyunca ışıldayan bir hâle ekler.\nDikkat: Ek GPU kaynağı kullanır.", + "settings.glow.desc.outline": "Kontura yumuşak bir neon parıltısı ekler ve gücünü ayarlar.\nDikkat: Ek GPU kaynağı kullanır.", + "settings.radius.title": "Yarıçap", + "settings.radius.desc": "Daire modunda ortadaki halkanın yarıçapını ayarlar.", + "settings.section.sound_types": "Ses türleri (yapay zeka)", + "settings.show_ambient": "Ortam seslerini göster", + "settings.show_speech": "Konuşmayı göster", + "settings.show_danger": "Tehlike seslerini göster", + "settings.set_color": "Renk ayarla", + "settings.set_color_for": "Renk ayarla: {0}", + "settings.section.hotkeys": "Kısayol tuşları", + "settings.hotkey.visual.title": "Görsel modu değiştir", + "settings.hotkey.visual.desc": "Çalışırken şekli anında değiştiren kısayol tuşu.", + "settings.hotkey.sound.title": "Ses modunu değiştir", + "settings.hotkey.sound.desc": "Çalışırken ses modunu anında değiştiren kısayol tuşu.", + "settings.hotkey.edit.title": "Yer paylaşımı ayarları", + "settings.hotkey.edit.desc": "Yer paylaşımı düzenleyicisini açan veya kapatan kısayol tuşu.", + "settings.hotkey.set": "Kısayol ata", + "settings.section.advanced": "Gelişmiş ayarlar", + "settings.developer_mode.title": "Geliştirici modu", + "settings.developer_mode.desc": "Yapay zekanın ne duyduğunu (bildirdiği ses adı, ne kadar emin olduğu ve ne kadar sürdüğü) yer paylaşımının sol üst köşesinde, FPS'yi de sağ üst köşesinde gösterir. Bilgiler İngilizce gösterilir ve günlük kullanım için tasarlanmamıştır.", + "settings.developer_record.title": "Sonuçları dosyaya kaydet", + "settings.developer_record.desc": "Yapay zekanın her sonucunu (en yüksek puan verdiği ses adları, ne kadar emin olduğu ve süreler) SoundVisualizer.exe ile aynı yerdeki ai-log klasöründe yeni bir CSV dosyasına yazar, böylece ne duyduğuna sonradan bakabilirsiniz. Bu klasöre yazılamıyorsa dosya %LOCALAPPDATA%\\SoundVisualizer\\ai-log klasörüne kaydedilir. Dosyalar büyüyebilir, işiniz bitince kapatın.", + "settings.auto_minimize.title": "Başlatınca başlatıcıyı küçült", + "settings.auto_minimize.desc": "Yer paylaşımı başladığında bu başlatıcı penceresini otomatik olarak simge durumuna küçültür.", + + "sounds.title": "Ses türlerini seçin", + "sounds.desc": "Yapay zekanın tanıdığı sesler benzer seslerle gruplanır ve varsayılan olarak sayıldıkları türe göre gösterilir. Bir grubun türünü değiştirdiğinizde o gruptaki seslerin rengi hemen yeni türe göre değişir.", + "sounds.sibling_note": "Yapay zeka tek bir sesi çoğu zaman aynı anda birkaç adla tanır. Örneğin siren “Alarm” olarak da tanınır ve “Alarm” başka bir gruptadır. Bir ses değişiklikten sonra hâlâ eski türüyle görünüyorsa ilgili grupları veya sesleri de değiştirin.", + "sounds.advanced.title": "Gelişmiş mod", + "sounds.advanced.desc": "Tüm sesleri gruplarıyla birlikte gösterir; böylece her sesi ayrı olarak değiştirebilirsiniz.", + "sounds.search.hint": "Seslerde ara", + "sounds.search.clear": "Aramayı temizle", + "sounds.filter.title": "Filtre", + "sounds.filter.all": "Tümü", + "sounds.filter.changed": "Değiştirilenler", + "sounds.type.ambient": "Ortam sesi", + "sounds.type.speech": "Konuşma", + "sounds.type.danger": "Tehlike sesi", + "sounds.type.default": "{0} (varsayılan)", + "sounds.type.mixed": "Karışık", + "sounds.changed_count": "Değiştirilen sesler: {0}", + "sounds.changed_from": "Varsayılan: {0}", + "sounds.reset.button": "Tümünü sıfırla", + "sounds.reset.title": "Tüm sesler sıfırlansın mı?", + "sounds.reset.message": "Değiştirdiğiniz tüm sesler varsayılan türlerine döner.", + "sounds.reset.confirm": "Sıfırla", + "sounds.reset.cancel": "İptal", + "sounds.empty": "Eşleşen ses yok.", + "sounds.group.mixed_note": "Bazı sesler ayrı olarak değiştirildi", + "sounds.group.changed_line": "Şu an: {0} · Varsayılan: {1}", + "sounds.overwrite.title": "Grubun tamamı değiştirilsin mi?", + "sounds.overwrite.message": "“{0}” grubundaki {1} ses “{2}” türüne geçecek. Ayrı olarak değiştirdiğiniz seslerin de üzerine yazılacak.", + "sounds.overwrite.confirm": "Değiştir", + "sounds.overwrite.cancel": "İptal", + "sounds.basic_warning.title": "Temel moda geçilsin mi?", + "sounds.basic_warning.message": "Bazı gruplarda ayrı olarak değiştirdiğiniz sesler var. Temel modda bu gruplardan biri için tür seçerseniz gruptaki tüm sesler o türü alır ve o grupta ayrı olarak yaptığınız değişikliklerin üzerine yazılır. Siz seçene kadar hiçbir şey değişmez.", + "sounds.basic_warning.confirm": "Temel moda geç", + "sounds.basic_warning.cancel": "Gelişmiş modda kal", + "sounds.cd.sound": "{0}, tür: {1}", + "sounds.cd.sound_changed": "{0}, tür: {1}, varsayılan: {2}", + "sounds.cd.group": "{0} grubu, tür: {1}, ses sayısı: {2} ({3})", + "sounds.cd.group_changed": "{0} grubu, tür: {1}, varsayılan: {2}, ses sayısı: {3} ({4})", + "sounds.cd.group_header": "{0} grubu, ses sayısı: {1}", + + "hotkey.prompt": "Bir tuşa basın (iptal için Esc)", + "hotkey.none": "Yok", + "hotkey.button_name": "{0}, kısayol: {1}", + + "help.start.title": "Başlarken", + "help.start.1": "Bu pencerenin üst kısmından dili seçin ve isterseniz “Ayarlar” sekmesinde grafiklerin görünümünü değiştirin.", + "help.start.2": "“Ana sayfa” sekmesinde “{0}” düğmesine tıklayın.", + "help.start.3": "Grafikler, oyun ve video gibi diğer pencerelerin üzerinde, ekranın kenarları boyunca çizilir. Tıklamalar grafiklerin içinden geçer; böylece alttaki pencereyi kullanmaya devam edebilirsiniz.", + "help.start.4": "Kapatmak için “Ana sayfa” sekmesindeki “{0}” düğmesine tıklayın ya da görev çubuğunun bildirim alanındaki SoundVisualizer simgesine sağ tıklayıp “{0}” seçeneğini seçin.", + "help.modes.title": "Görsel modlar", + "help.mode.wave": "Ekranın tüm kenarları ses yüksekliğine göre içeri doğru ilerler. Sesin geldiği taraf daha derine uzanır.", + "help.mode.pad": "Ekran kenarındaki 8 noktada çubuklar yanar. Yönü en net gösteren moddur.", + "help.mode.circle": "Ekranın ortasındaki halka, sesin geldiği yöne doğru genişler.", + "help.mode.outline": "Dalganın yalnızca ince dış çizgisini çizer; böylece oyun ekranınızı daha az örter.", + "help.modes.note": "“Boyut” ve “Opaklık” gibi ayarlar her mod için ayrı kaydedilir ve “Ayarlar” sekmesinden değiştirilebilir.", + "help.sound.title": "Ses türleri", + "help.sound.kinds": "Yapay zeka, duyduğunuz sesleri ortam sesleri, konuşma ve tehlike sesleri olarak ayırır. Varsayılan olarak ortam sesleri beyaz, konuşma sarı, tehlike sesleri ise kırmızı renkte gösterilir. Her türün rengini ve gösterilip gösterilmeyeceğini “Ayarlar” sekmesinden değiştirebilirsiniz.", + "help.sound.groups": "“Sesler” sekmesinde benzer sesler gruplanır ve her grubun hangi türden sayılacağını siz seçersiniz. Örneğin “Kapı zili” grubunu tehlike sesi yaparsanız yapay zeka bir kapı zili veya kapıya vurma sesi tanıdığında bu ses tehlike sesi rengini alır. Ancak yeni tehlike sesi yaptığınız bir ses, yalnızca yapay zekanın türü belirlemek için yaptığı oylamaya katılır; bu yüzden her kapı zilinin tehlike sesi olarak gösterileceği anlamına gelmez.", + "help.sound.advanced": "Her sesi ayrı olarak değiştirmek için gelişmiş modu açın.", + "help.sound.danger": "Bir tehlike sesi (ör. silah sesi) algılandığında renk yavaşça geçiş yapmak yerine anında değişir.", + "help.pc.title": "PC ayarları ve kısayol tuşları", + "help.sound_mode.title": "Ses modu", + "help.sound_mode.body": "Sağ ve solun yanı sıra önü ve arkayı da ayırt etmek için Windows ses ayarlarında çıkış cihazınızı “5.1 Surround” (6 kanal) veya “7.1 Surround” (8 kanal) olarak ayarlayın. Normal stereoda (2 kanal) uygulama sağı ve solu ayırt edebilir, ancak önü ve arkayı ayırt edemez. “Ayarlar” sekmesindeki “Ses modu”, başlatıldığında ve çıkış cihazı değiştiğinde o cihaza göre ayarlanır; dilerseniz kendiniz de değiştirebilirsiniz.", + "help.sound_mode.tip": "💡 7.1 çıkış cihazınız yoksa {0} yükleyip çıkış cihazını 7.1 kanala ayarlayarak yer paylaşımını 7.1 surround sesle gösterebilirsiniz.", + "help.sound_mode.tip_link": "VB-CABLE sanal sürücüsünü", + "help.hotkeys.title": "Anlık özel kısayol tuşları", + "help.hotkeys.body": "“Ayarlar” sekmesinde bir kısayol düğmesine tıklayın ve tek bir tuşa ya da bir tuş birleşimine (ör. Ctrl + Shift + A) basarak kısayolu dilediğiniz gibi değiştirin. Uygulama arka planda çalışırken bile kısayol tuşuna bastığınızda mod anında değişir.", + "help.hotkeys.defaults": "Varsayılan olarak F2 ses modunu, F3 görsel modu değiştirir; F4 ise yer paylaşımı ayarlarını açar.", + "help.size_lock.title": "Boyut sabitleme modu", + "help.size_lock.body": "Boyut sabitleme modu, ses yüksekliğiyle orantılı saydamlık kullanarak grafikleri sabit bir boyutta tutar. Grafiklerin boyutunu aynı tutup sese nasıl tepki verdiklerini yine de görmek istediğinizde işe yarar.", + "help.editor.title": "Anlık yer paylaşımı düzenleme modu", + "help.editor.body": "Yer paylaşımı düzenleme modunu açmak için atanmış kısayol tuşuna (varsayılan: F4) basın. Ekranda fareyle sürükleyerek grafiklerin boyut sınırını ayarlayabilir, açılan ayarlar panelinde ayrıntılara ince ayar yapabilirsiniz.", + "help.developer.title": "Geliştirici modu", + "help.developer.body": "Geliştirici modunu açtığınızda yer paylaşımının sol üst köşesinde yapay zekanın ne duyduğu (ses adı, ne kadar emin olduğu ve ne kadar sürdüğü), sağ üst köşesinde de FPS gösterilir. “{0}” seçeneğini de açarsanız her sonuç bir CSV dosyasına kaydedilir. Sistemin düzgün çalışıp çalışmadığını kontrol etmek istediğinizde işe yarar.", + "help.notes.title": "Bilmeniz gerekenler", + "help.note.direction_surround": "5.1 veya 7.1 kanal çıkışta ekranın üst kısmı önden, alt kısmı arkadan gelen sesi gösterir. Sol ve sağ kenarlar soldan ve sağdan gelen sesi gösterir; ekranın her yanından aynı anda gelen grafikler ise ortam sesleri veya yönü olmayan seslerdir.", + "help.note.direction_stereo": "2 kanal (stereo) çıkışta uygulama sağı ve solu ayırt edebilir, ancak önü ve arkayı ayırt edemez. Bu durumda ekranın üst ve alt kısmındaki grafikler, her iki tarafta eşit duyulan sesleri ve uzamsal etkiyi gösterir.", + "help.note.sources": "Windows'un varsayılan oynatma cihazından çalan tüm uygulamaların sesi gösterilir. Bir uygulamanın başka bir cihaza, örneğin yalnızca sesli sohbet için kullanılan bir kulaklığa gönderdiği ses gösterilmez.", + "help.note.volume": "Bir uygulamanın ses düzeyi, uygulamanın kendisinde veya Windows ses karıştırıcısında çok kısıksa ya da kapalıysa sesi neredeyse hiç veya hiç görünmeyebilir. Grafikler iyi tepki vermiyorsa o sesi biraz açın veya “Ayarlar” sekmesinde “Hassasiyet” değerini artırın.", + "help.note.ai": "Yapay zeka sınıflandırması hatalı olabilir. Güvenliğin söz konusu olduğu durumlarda yalnızca bu uygulamaya güvenmeyin.", + "help.faq.title": "Sık sorulan sorular", + "help.faq.stopped_q": "Ben durdurmadığım hâlde kapandı.", + "help.faq.stopped_a": "Ses yakalama durur ve birkaç saniye içinde yeniden başlamazsa kapanır; örneğin oynatma cihazının bağlantısı kesildiğinde ve başka bir cihaz olmadığında. Bu durumda nedeni “Ana sayfa” sekmesinde gösterilir. Hoparlör veya kulaklık bağlayın ya da Windows ses ayarlarında varsayılan bir oynatma cihazı belirleyin, sonra yeniden başlatın.", + "help.faq.no_graphic_q": "Hiç grafik göremiyorum.", + "help.faq.no_graphic_a": "Sesin Windows'un varsayılan oynatma cihazından çaldığını ve kapalı olmadığını, ayrıca “Ana sayfa” sekmesinde bir uyarı olup olmadığını kontrol edin. Bir oyun özel tam ekran modunda çalışıyorsa çerçevesiz pencere veya pencere modunu deneyin. Ayrıca “Ayarlar” sekmesinde “Opaklık” değerinin çok düşük olmadığından ve o an çalan ses türünün gösteriminin kapalı olmadığından emin olun.", + "help.faq.performance_q": "Çok fazla CPU veya GPU kullanır mı?", + "help.faq.performance_a": "Yükü azaltmak için “Ayarlar” sekmesinde “{0}” değerini düşürün veya “{1}” özelliğini kapatın.", + "help.faq.language_q": "Uygulama dilini nasıl değiştiririm?", + "help.faq.language_a": "Bu pencerenin üst kısmındaki “{0}” listesinden seçin. Çalışırken yer paylaşımı ayarlarından da değiştirebilirsiniz. “{1}” seçeneği Windows'un görüntüleme dilini kullanır.", + "help.report.title": "Sorun bildir", + "help.report.desc": "Bir hatayı ya da yanlış türde gösterilen bir sesi bize bildirin. Tarayıcınızda GitHub açılır ve aşağıdaki sistem bilgileri önceden doldurulur; bu yüzden bir GitHub hesabı gerekir. Hesabınız yoksa bize e-posta gönderin. Ses hiçbir zaman gönderilmez.", + "help.report.github": "GitHub'da bildir", + "help.report.mail": "E-postayla bildir", + "help.report.copy": "Sistem bilgilerini kopyala", + "help.report.copied": "Sistem bilgileri kopyalandı.", + "help.report.copy_failed": "Panoya kopyalanamadı. Yeniden deneyin.", + "help.report.no_browser": "Tarayıcı açılamadı. Sistem bilgilerini kopyalayıp bize başka bir yolla gönderin.", + "help.report.no_mail": "E-posta gönderebilecek bir uygulama yok. Sistem bilgilerini kopyalayıp başka bir yolla gönderin.", + "help.report.issue_title": "Uygulamadan gönderilen rapor", + "help.report.issue_body": "Ne oldu?\n\nNe dinliyordunuz (uygulama, ses türü)?\n\nBunun yerine ne olmasını bekliyordunuz?", + "help.about.title": "Hakkında", + "help.about.version": "Sürüm {0}", + "help.about.license": "Bu uygulama GNU Affero General Public License v3.0 (AGPL-3.0) kapsamında dağıtılmaktadır. “Sesler” sekmesindeki ses adları ve ses grupları AudioSet ontolojisine dayanır ve CC BY-SA 4.0 kapsamında dağıtılır (ayrıntılar için aşağıdaki “Açık kaynak lisansları” düğmesine tıklayın).", + "help.licenses.button": "Açık kaynak lisansları", + "help.licenses.title": "Açık kaynak lisansları", + "help.licenses.close": "Kapat", + + "notice.ai_unavailable": "Ses türü algılama çalışmıyor; bu yüzden tüm sesler ortam sesi renginde gösteriliyor. Görselleştirmeyi kapatıp yeniden açmayı deneyin.", + "notice.ai_unavailable_hidden": "Ses türü algılama çalışmıyor; bu yüzden tüm sesler ortam sesi sayılıyor, ancak ortam seslerinin gösterimi kapalı olduğundan hiçbir şey görünmüyor. “Ayarlar” sekmesinde “{0}” seçeneğini açın veya görselleştirmeyi kapatıp yeniden açmayı deneyin.", + "notice.stopped.title": "Ses görselleştirme kapandı", + "notice.stopped.no_device": "Hiçbir oynatma cihazı açılamadığı için ses yakalanamıyor. Hoparlör veya kulaklık bağlayın ya da Windows ses ayarlarında varsayılan bir oynatma cihazı belirleyin, sonra yeniden başlatın.", + "notice.stopped.capture_error": "Bir hata nedeniyle ses yakalama durdu; grafikler kapalı.", + "notice.dismiss": "Uyarıyı kapat", + + "tray.open": "Başlatıcıyı aç", + "tray.exit": "Çıkış", + + "more_info.title": "GitHub deposu ve katkıda bulunma", + "more_info.body": "SoundVisualizer açık kaynaklı bir projedir ve kaynak kodu GitHub'da herkese açıktır. Hata bildirerek, özellik önererek veya geliştirmeye katkıda bulunarak projeye dahil olabilirsiniz. Resmî GitHub deposunu ziyaret etmek için aşağıdaki düğmeye tıklayın! Ayrıca GitHub'daki Releases sekmesinden en son sürümü kontrol edip indirebilirsiniz.", + "more_info.github_button": "GitHub deposunu ziyaret et", + + "dialog.notice_title": "Bilgi", + "dialog.already_running": "Yer paylaşımı zaten çalışıyor.", + "dialog.reset_title": "Ayarları sıfırla", + "dialog.reset_wave": "Dalga modu ayarları varsayılanlara sıfırlansın mı?", + "dialog.reset_pad": "Pad modu ayarları varsayılanlara sıfırlansın mı?", + "dialog.reset_circle": "Daire modu ayarları varsayılanlara sıfırlansın mı?", + "dialog.reset_outline": "Kontur modu ayarları varsayılanlara sıfırlansın mı?", + "dialog.reset_sound_types": "Ses türü ayarları varsayılanlara sıfırlansın mı?", + "dialog.reset_all_title": "Varsayılanlara sıfırla", + "dialog.reset_all": "Tüm ayarları varsayılanlara sıfırlamak istediğinizden emin misiniz?", + "dialog.link_error": "Bağlantı açılamadı: {0}", + + "editor.title": "Yer paylaşımı ayarları", + "editor.drag_tip": "Yer paylaşımındaki dalganın yükseklik sınırını ve merkez boyutunu anında ayarlamak için ekrandaki kılavuz çizgilerini fareyle sürükleyin!", + "editor.glow": "Parıltı efekti", + "editor.glow_strength": "Parıltı gücü", + "editor.color": "Renk", + "editor.hotkeys": "Kısayol tuşu ayarları", + "editor.developer_mode_desc": "Yapay zekanın ne duyduğunu (sol üst) ve FPS'yi (sağ üst) gösterir", + "editor.save_close_hint": "tuşuyla kaydedip kapatın", + "editor.close_overlay": "Yer paylaşımını kapat", + "editor.guide.limit": "{0} sınırı", + "editor.guide.size": "{0} boyutu: %{1}", + "editor.guide.base_radius": "Temel yarıçap: {0}", + "editor.guide.circle_size": "Daire boyutu: %{0}", + + "hud.visual_mode": "Görsel mod: [{0}] {1}", + "hud.sound_mode": "Ses modu: [{0}] {1}", + "hud.overlay_settings": "Yer paylaşımı ayarları: [{0}]", + "hud.status.ready": "Sistem hazır", + "hud.status.stereo": "PC sesi: {0} kanal (stereo)\nKaynak: bekleniyor...", + "hud.status.surround": "PC sesi: 8 kanal (7.1)\nKaynak: bekleniyor...", + + "color_picker.title": "Renk seçin", + "color_picker.hex": "HEX kodu", + "color_picker.cancel": "İptal", + "color_picker.apply": "Uygula", + "color_picker.area": "Doygunluk ve parlaklık", + "color_picker.area_value": "Doygunluk %{0}, parlaklık %{1}", + "color_picker.hue": "Ton", + "color_picker.keys_help": "Ok tuşlarıyla değiştirin. Daha büyük adımlarla değiştirmek için Shift tuşunu basılı tutun.", + "color_picker.presets": "Sık kullanılan renkler", + "color_picker.preset.white": "Beyaz", + "color_picker.preset.red": "Kırmızı", + "color_picker.preset.orange": "Turuncu", + "color_picker.preset.yellow": "Sarı", + "color_picker.preset.green": "Yeşil", + "color_picker.preset.cyan": "Turkuaz", + "color_picker.preset.blue": "Mavi", + "color_picker.preset.magenta": "Macenta", + + "audio.error.no_device": "Çıkış cihazı bulunamadı.", + "audio.error.capture_stopped": "Ses yakalama beklenmedik şekilde durdu: {0}", + "audio.error.capture_start_failed": "Ses yakalama motoru başlatılamadı: {0}", + + "tutorial.window_title": "Tanıtım", + "tutorial.skip": "Atla", + "tutorial.previous": "Önceki sayfa", + "tutorial.next": "İleri", + "tutorial.done": "Anladım", + "tutorial.page_status": "Sayfa {0}, toplam {1}", + "tutorial.pause": "Animasyonu duraklat", + "tutorial.play": "Animasyonu oynat", + "tutorial.sound_on": "Sesi aç", + "tutorial.sound_off": "Sesi kapat", + "tutorial.sound.title": "Sesleri ekranınızda görün", + "tutorial.sound.body": "Oyununuzun veya videonuzun sesi ekranın kenarlarında çizilir. Ses ne kadar yüksekse o kadar büyük çizilir. Yalnızca bu bilgisayarda çalan ses gösterilir. Uygulama çevrenizdeki sesleri dinlemez.", + "tutorial.direction.title": "Sesin nereden geldiğini gösterir", + "tutorial.direction.body": "2 kanal (stereo) çıkışta soldan gelen ses solda, sağdan gelen ses sağda daha büyük çizilir. 5.1 veya 7.1 kanal çıkışta uygulama önü ve arkayı da ayırt eder: önden gelen ses ekranın üst kısmında, arkadan gelen ses alt kısmında çizilir.", + "tutorial.types.title": "Her ses türünün kendi rengi var", + "tutorial.types.body": "Yapay zeka, sesleri üç türe ayırır ve her türü farklı bir renkte çizer. Renkleri “Ayarlar” sekmesinden değiştirebilirsiniz.", + "tutorial.type.ambient_examples": "Müzik, yağmur ve diğer tüm sesler", + "tutorial.type.speech_examples": "İnsan sesleri", + "tutorial.type.danger_examples": "Silah sesi, patlama, siren", + "tutorial.start.title": "Nasıl açılıp kapatılır", + "tutorial.start.hotkeys": "Çalışırken ses modunu değiştirmek için {0} tuşuna, görsel modu değiştirmek için {1} tuşuna, yer paylaşımı ayarlarını açmak için {2} tuşuna basın." +} diff --git a/SoundVisualizer.Core/Localization/Strings/vi.json b/SoundVisualizer.Core/Localization/Strings/vi.json new file mode 100644 index 0000000..d74dd03 --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/vi.json @@ -0,0 +1,294 @@ +{ + "common.language": "Ngôn ngữ", + "common.reset_defaults": "Đặt lại về mặc định", + "common.minimize": "Thu nhỏ", + "common.close": "Đóng", + "language.system": "Mặc định của hệ thống", + + "launcher.window_title": "Trình khởi chạy SoundVisualizer", + "launcher.tab.home": "Trang chủ", + "launcher.tab.settings": "Cài đặt", + "launcher.tab.sounds": "Phân loại", + "launcher.tab.help": "Trợ giúp", + "launcher.tab.more_info": "Tìm hiểu thêm", + "launcher.home.desc": "Vẽ lên màn hình những âm thanh mà bạn không nhìn thấy.\nTừ trò chơi đến phim ảnh, hãy khám phá một trải nghiệm hoàn toàn mới.", + "launcher.home.start": "Bắt đầu", + "launcher.home.stop": "Dừng", + "launcher.home.tutorial": "Xem hướng dẫn", + "launcher.status.running": "Trạng thái: Đang chạy", + "launcher.status.idle": "Trạng thái: Sẵn sàng", + + "settings.section.mode": "Cài đặt chế độ", + "settings.visual_mode.title": "Chế độ hiển thị", + "settings.visual_mode.desc": "Chọn hình dạng cơ bản của đồ họa được vẽ trên màn hình.", + "settings.mode.wave": "Sóng", + "settings.mode.pad": "Vạch", + "settings.mode.circle": "Vòng", + "settings.mode.outline": "Viền", + "settings.sound_mode.title": "Chế độ âm thanh", + "settings.sound_mode.desc": "Chọn số kênh âm thanh phù hợp với loa của bạn.\nVới loa 2 kênh, hãy chọn “2 kênh”. Với loa vòm, hãy chọn “5.1 kênh” hoặc “7.1 kênh”.", + "settings.channels.2": "2 kênh", + "settings.channels.5_1": "5.1 kênh", + "settings.channels.7_1": "7.1 kênh", + "settings.max_fps.title": "Tốc độ khung hình tối đa", + "settings.max_fps.desc": "Đặt tốc độ khung hình cao nhất để vẽ đồ họa.", + "settings.section.mode_detail": "Cài đặt theo chế độ", + "settings.mode_card.wave": "Chế độ Sóng", + "settings.mode_card.pad": "Chế độ Vạch", + "settings.mode_card.circle": "Chế độ Vòng", + "settings.mode_card.outline": "Chế độ Viền", + "settings.size_lock.title": "Khóa kích thước", + "settings.size_lock.desc": "Giữ đồ họa ở kích thước cố định và thể hiện độ lớn âm thanh bằng độ đậm.", + "settings.fixed_size.wave": "Kích thước sóng", + "settings.fixed_size.pad": "Kích thước vạch", + "settings.fixed_size.circle": "Kích thước vòng", + "settings.fixed_size.outline": "Độ dày viền", + "settings.fixed_size.desc": "Đặt kích thước cố định mà đồ họa giữ nguyên trong khi độ đậm thay đổi.", + "settings.max_opacity.title": "Đậm tối đa", + "settings.max_opacity.desc": "Độ đậm của đồ họa khi âm thanh lớn nhất.", + "settings.size.title": "Kích thước", + "settings.size.desc.wave": "Điều chỉnh chiều cao của sóng và độ đầy đặn tổng thể.", + "settings.size.desc.pad": "Điều chỉnh độ dày và độ rộng của các vạch khi phản ứng với âm thanh.", + "settings.size.desc.circle": "Điều chỉnh mức equalizer hình tròn phồng ra khi có âm thanh.", + "settings.size.desc.outline": "Điều chỉnh chiều cao của sóng viền và độ đầy đặn tổng thể.", + "settings.opacity.title": "Độ đậm", + "settings.opacity.desc.wave": "Giá trị càng cao thì càng đậm; càng thấp thì càng nhạt và trong suốt.", + "settings.opacity.desc.pad": "Giá trị càng cao thì càng đậm; càng thấp thì càng nhạt và trong suốt.", + "settings.opacity.desc.circle": "Giá trị càng cao thì càng đậm; càng thấp thì càng nhạt và trong suốt.", + "settings.opacity.desc.outline": "Giá trị càng cao thì càng đậm; càng thấp thì càng nhạt và trong suốt.", + "settings.speed.title": "Tốc độ", + "settings.speed.desc.wave": "Điều chỉnh tốc độ sóng trôi và gợn lên.", + "settings.speed.desc.pad": "Điều chỉnh tốc độ các vạch xoay về phía âm thanh phát ra.", + "settings.speed.desc.circle": "Điều chỉnh tốc độ equalizer hình tròn gợn sóng và xoay theo âm thanh.", + "settings.speed.desc.outline": "Điều chỉnh tốc độ sóng viền trôi và gợn lên.", + "settings.sensitivity.title": "Độ nhạy", + "settings.sensitivity.desc.wave": "Điều chỉnh mức độ sóng phản ứng nhạy và dâng lên, kể cả với âm thanh nhỏ.", + "settings.sensitivity.desc.pad": "Điều chỉnh để các vạch bắt âm thanh tốt hơn và nổi bật hơn.", + "settings.sensitivity.desc.circle": "Điều chỉnh mức độ chi tiết mà biên độ của vòng được chia nhỏ khi phản ứng với âm thanh.", + "settings.sensitivity.desc.outline": "Điều chỉnh mức độ sóng viền phản ứng nhạy và dâng lên, kể cả với âm thanh nhỏ.", + "settings.glow.title": "Phát sáng", + "settings.glow.desc.wave": "Thêm ánh sáng neon dịu nhẹ dọc theo mép sóng và điều chỉnh cường độ.\nLưu ý: tốn thêm tài nguyên GPU.", + "settings.glow.desc.pad": "Thêm quầng sáng dịu nhẹ như hào quang quanh các vạch.\nLưu ý: tốn thêm tài nguyên GPU.", + "settings.glow.desc.circle": "Thêm vầng sáng lấp lánh dọc theo mép equalizer hình tròn.\nLưu ý: tốn thêm tài nguyên GPU.", + "settings.glow.desc.outline": "Thêm ánh sáng neon dịu nhẹ cho viền và điều chỉnh cường độ.\nLưu ý: tốn thêm tài nguyên GPU.", + "settings.radius.title": "Bán kính", + "settings.radius.desc": "Chỉnh bán kính của vòng tròn ở giữa màn hình trong chế độ Vòng.", + "settings.section.sound_types": "Loại âm thanh (AI)", + "settings.show_ambient": "Hiện âm thanh môi trường", + "settings.show_speech": "Hiện giọng nói", + "settings.show_danger": "Hiện âm thanh nguy hiểm", + "settings.set_color": "Đặt màu", + "settings.set_color_for": "Đặt màu: {0}", + "settings.section.hotkeys": "Phím tắt", + "settings.hotkey.visual.title": "Chuyển chế độ hiển thị", + "settings.hotkey.visual.desc": "Phím tắt để đổi hình dạng theo thời gian thực khi đang chạy.", + "settings.hotkey.sound.title": "Chuyển chế độ âm thanh", + "settings.hotkey.sound.desc": "Phím tắt để đổi chế độ âm thanh theo thời gian thực khi đang chạy.", + "settings.hotkey.edit.title": "Cài đặt lớp phủ", + "settings.hotkey.edit.desc": "Phím tắt để mở hoặc đóng trình chỉnh sửa lớp phủ.", + "settings.hotkey.set": "Đặt phím tắt", + "settings.section.advanced": "Cài đặt nâng cao", + "settings.developer_mode.title": "Chế độ nhà phát triển", + "settings.developer_mode.desc": "Hiển thị nội dung AI nghe được (tên âm thanh AI báo, mức độ chắc chắn và thời gian xử lý) ở góc trên bên trái lớp phủ, cùng FPS ở góc trên bên phải. Nội dung hiển thị bằng tiếng Anh và không dành cho việc sử dụng hằng ngày.", + "settings.developer_record.title": "Ghi kết quả vào tệp", + "settings.developer_record.desc": "Ghi mọi kết quả của AI (các tên âm thanh được đánh giá cao nhất, mức độ chắc chắn và thời gian xử lý) vào một tệp CSV mới trong thư mục ai-log cạnh SoundVisualizer.exe, để bạn có thể xem lại AI đã nghe thấy gì. Nếu không ghi được vào thư mục đó, tệp sẽ được lưu vào %LOCALAPPDATA%\\SoundVisualizer\\ai-log. Tệp có thể rất lớn, hãy tắt khi bạn xem xong.", + "settings.auto_minimize.title": "Thu nhỏ trình khởi chạy khi bắt đầu", + "settings.auto_minimize.desc": "Tự động thu nhỏ cửa sổ trình khởi chạy này khi lớp phủ bắt đầu chạy.", + + "sounds.title": "Chọn loại âm thanh", + "sounds.desc": "Các âm thanh AI nhận diện được nằm chung nhóm với những âm thanh tương tự, xếp theo loại mặc định của từng âm thanh. Khi bạn đổi loại của một nhóm, màu của các âm thanh trong nhóm sẽ đổi theo loại mới ngay lập tức.", + "sounds.sibling_note": "AI thường nhận ra một âm thanh bằng nhiều tên cùng lúc. Ví dụ, còi hụ cũng được nhận ra là “Báo động”, vốn nằm ở nhóm khác. Nếu sau khi đổi mà một âm thanh vẫn hiện theo loại cũ, hãy đổi cả các nhóm hoặc âm thanh liên quan.", + "sounds.advanced.title": "Chế độ nâng cao", + "sounds.advanced.desc": "Hiện mọi âm thanh theo nhóm để bạn đổi riêng từng âm thanh.", + "sounds.search.hint": "Tìm âm thanh", + "sounds.search.clear": "Xóa từ khóa tìm kiếm", + "sounds.filter.title": "Bộ lọc", + "sounds.filter.all": "Tất cả", + "sounds.filter.changed": "Đã đổi", + "sounds.type.ambient": "Âm thanh môi trường", + "sounds.type.speech": "Giọng nói", + "sounds.type.danger": "Âm thanh nguy hiểm", + "sounds.type.default": "{0} (mặc định)", + "sounds.type.mixed": "Hỗn hợp", + "sounds.changed_count": "Âm thanh đã đổi: {0}", + "sounds.changed_from": "Mặc định: {0}", + "sounds.reset.button": "Đặt lại tất cả", + "sounds.reset.title": "Đặt lại tất cả âm thanh về mặc định?", + "sounds.reset.message": "Mọi âm thanh bạn đã đổi sẽ trở về loại mặc định.", + "sounds.reset.confirm": "Đặt lại", + "sounds.reset.cancel": "Hủy", + "sounds.empty": "Không có âm thanh nào phù hợp.", + "sounds.group.mixed_note": "Một số âm thanh đã được đổi riêng", + "sounds.group.changed_line": "Hiện tại: {0} · Mặc định: {1}", + "sounds.overwrite.title": "Đổi cả nhóm?", + "sounds.overwrite.message": "{1} âm thanh trong nhóm “{0}” sẽ chuyển thành “{2}”. Những âm thanh bạn đã đổi riêng cũng sẽ bị ghi đè.", + "sounds.overwrite.confirm": "Đổi", + "sounds.overwrite.cancel": "Hủy", + "sounds.basic_warning.title": "Chuyển sang chế độ cơ bản?", + "sounds.basic_warning.message": "Một số nhóm có âm thanh bạn đã đổi riêng. Ở chế độ cơ bản, nếu bạn chọn loại cho một nhóm như vậy, mọi âm thanh trong nhóm sẽ nhận loại đó và những thay đổi riêng trong nhóm sẽ bị ghi đè. Không có gì thay đổi cho đến khi bạn chọn.", + "sounds.basic_warning.confirm": "Chuyển sang cơ bản", + "sounds.basic_warning.cancel": "Giữ chế độ nâng cao", + "sounds.cd.sound": "{0}, loại: {1}", + "sounds.cd.sound_changed": "{0}, loại: {1}, mặc định: {2}", + "sounds.cd.group": "Nhóm {0}, loại: {1}, số âm thanh: {2} ({3})", + "sounds.cd.group_changed": "Nhóm {0}, loại: {1}, mặc định: {2}, số âm thanh: {3} ({4})", + "sounds.cd.group_header": "Nhóm {0}, số âm thanh: {1}", + + "hotkey.prompt": "Nhấn một phím (Esc để hủy)", + "hotkey.none": "Không có", + "hotkey.button_name": "{0}, phím tắt: {1}", + + "help.start.title": "Bắt đầu sử dụng", + "help.start.1": "Chọn ngôn ngữ ở đầu cửa sổ này, và nếu muốn, hãy đổi giao diện đồ họa trong thẻ Cài đặt.", + "help.start.2": "Ở thẻ Trang chủ, nhấp “{0}”.", + "help.start.3": "Đồ họa được vẽ dọc theo các cạnh màn hình, phía trên các cửa sổ khác như trò chơi và video. Thao tác nhấp chuột đi xuyên qua đồ họa, nên bạn vẫn dùng được cửa sổ bên dưới.", + "help.start.4": "Để tắt, hãy nhấp “{0}” ở thẻ Trang chủ, hoặc nhấp chuột phải vào biểu tượng SoundVisualizer trong vùng thông báo của thanh tác vụ rồi chọn “{0}”.", + "help.modes.title": "Chế độ hiển thị", + "help.mode.wave": "Toàn bộ cạnh màn hình dâng vào trong theo độ lớn âm thanh. Phía có âm thanh phát ra sẽ dâng vào sâu hơn.", + "help.mode.pad": "Các vạch sáng lên ở 8 vị trí quanh cạnh màn hình. Thể hiện hướng âm thanh rõ ràng nhất.", + "help.mode.circle": "Một vòng tròn ở giữa màn hình phồng lên về phía có âm thanh.", + "help.mode.outline": "Chỉ vẽ đường viền mảnh của sóng, nên ít che màn hình trò chơi hơn.", + "help.modes.note": "Các cài đặt như Kích thước và Độ đậm được lưu riêng cho từng chế độ và có thể thay đổi trong thẻ Cài đặt.", + "help.sound.title": "Loại âm thanh", + "help.sound.kinds": "AI phân loại âm thanh bạn nghe thấy thành âm thanh môi trường, giọng nói và âm thanh nguy hiểm. Theo mặc định, âm thanh môi trường có màu trắng, giọng nói màu vàng và âm thanh nguy hiểm màu đỏ. Bạn có thể đổi màu và chọn có hiện hay không cho từng loại trong thẻ Cài đặt.", + "help.sound.groups": "Trong thẻ Phân loại, các âm thanh tương tự được gom thành nhóm và bạn chọn mỗi nhóm được tính là loại nào. Ví dụ, nếu đổi nhóm “Chuông cửa” thành âm thanh nguy hiểm, khi AI nhận ra chuông cửa hoặc gõ cửa, âm thanh đó sẽ có màu của âm thanh nguy hiểm. Tuy nhiên, âm thanh do bạn tự đổi thành âm thanh nguy hiểm chỉ tham gia vào việc bỏ phiếu chọn loại của AI, nên không có nghĩa là chuông cửa lúc nào cũng sẽ hiện là âm thanh nguy hiểm.", + "help.sound.advanced": "Để đổi riêng từng âm thanh, hãy bật chế độ nâng cao.", + "help.sound.danger": "Khi phát hiện âm thanh nguy hiểm (như tiếng súng), màu sẽ đổi ngay lập tức thay vì chuyển dần.", + "help.pc.title": "Cài đặt PC và phím tắt", + "help.sound_mode.title": "Chế độ âm thanh", + "help.sound_mode.body": "Để phân biệt cả âm thanh phía trước và phía sau, ngoài trái và phải, hãy đặt thiết bị đầu ra thành âm thanh vòm 5.1 (6 kênh) hoặc 7.1 (8 kênh) trong cài đặt âm thanh của Windows. Với âm thanh nổi thông thường (2 kênh), ứng dụng phân biệt được trái và phải nhưng không phân biệt được trước và sau. “Chế độ âm thanh” trong thẻ Cài đặt sẽ tự khớp với thiết bị đầu ra khi bắt đầu chạy hoặc khi thiết bị thay đổi, và bạn vẫn có thể tự thay đổi.", + "help.sound_mode.tip": "💡 Nếu không có thiết bị đầu ra 7.1, bạn có thể cài {0} rồi đặt thiết bị đầu ra thành 7.1 kênh để hiển thị lớp phủ bằng âm thanh vòm 7.1.", + "help.sound_mode.tip_link": "trình điều khiển ảo VB-CABLE", + "help.hotkeys.title": "Phím tắt tùy chỉnh theo thời gian thực", + "help.hotkeys.body": "Trong thẻ Cài đặt, hãy nhấp vào nút phím tắt rồi nhấn một phím hoặc tổ hợp phím (ví dụ: Ctrl + Shift + A) để đổi phím tắt theo ý bạn. Khi nhấn phím tắt, chế độ sẽ chuyển ngay lập tức, kể cả khi ứng dụng đang chạy nền.", + "help.hotkeys.defaults": "Theo mặc định, F2 chuyển chế độ âm thanh, F3 chuyển chế độ hiển thị và F4 mở cài đặt lớp phủ.", + "help.size_lock.title": "Chế độ khóa kích thước", + "help.size_lock.body": "Chế độ khóa kích thước giữ đồ họa ở kích thước cố định và dùng độ đậm tương ứng với độ lớn âm thanh để thể hiện âm thanh. Chế độ này hữu ích khi bạn muốn giữ nguyên kích thước đồ họa mà vẫn thấy đồ họa phản ứng với âm thanh.", + "help.editor.title": "Chế độ chỉnh sửa lớp phủ theo thời gian thực", + "help.editor.body": "Nhấn phím tắt đã gán (mặc định là F4) để bật chế độ chỉnh sửa lớp phủ. Bạn có thể kéo chuột trên màn hình để điều chỉnh giới hạn kích thước của đồ họa, rồi tinh chỉnh chi tiết trong bảng cài đặt hiện ra.", + "help.developer.title": "Chế độ nhà phát triển", + "help.developer.body": "Khi bật Chế độ nhà phát triển, góc trên bên trái lớp phủ sẽ hiện nội dung AI nghe được (tên âm thanh, mức độ chắc chắn và thời gian xử lý), còn góc trên bên phải hiện FPS. Nếu bật thêm “{0}”, mọi kết quả sẽ được lưu vào tệp CSV. Chế độ này hữu ích khi bạn muốn kiểm tra xem hệ thống có hoạt động bình thường không.", + "help.notes.title": "Lưu ý", + "help.note.direction_surround": "Khi xuất âm thanh 5.1 hoặc 7.1 kênh, cạnh trên màn hình thể hiện âm thanh từ phía trước và cạnh dưới thể hiện âm thanh từ phía sau. Hai bên trái và phải thể hiện âm thanh từ bên trái và bên phải, còn đồ họa xuất hiện cùng lúc từ mọi phía của màn hình là âm thanh môi trường hoặc âm thanh không có hướng.", + "help.note.direction_stereo": "Khi xuất âm thanh 2 kênh (âm thanh nổi), ứng dụng phân biệt được trái và phải, nhưng không phân biệt được phía trước và phía sau. Khi đó, đồ họa ở cạnh trên và cạnh dưới màn hình biểu thị âm thanh nghe như nhau ở cả hai bên, cũng như cảm giác không gian.", + "help.note.sources": "Ứng dụng hiển thị âm thanh của mọi ứng dụng phát qua thiết bị phát mặc định của Windows. Âm thanh mà một ứng dụng gửi đến thiết bị khác, chẳng hạn tai nghe chỉ dùng để trò chuyện thoại, sẽ không được hiển thị.", + "help.note.volume": "Nếu âm lượng của một ứng dụng bị giảm rất thấp hoặc tắt tiếng, trong chính ứng dụng đó hoặc trong bộ trộn âm lượng của Windows, âm thanh của nó có thể gần như không hiện hoặc hoàn toàn không hiện. Nếu đồ họa phản hồi yếu, hãy tăng âm thanh đó lên một chút hoặc tăng Độ nhạy trong thẻ Cài đặt.", + "help.note.ai": "AI có thể phân loại sai. Khi liên quan đến an toàn, đừng coi ứng dụng này là biện pháp bảo vệ duy nhất.", + "help.faq.title": "Câu hỏi thường gặp", + "help.faq.stopped_q": "Ứng dụng tự tắt dù tôi không tắt.", + "help.faq.stopped_a": "Ứng dụng sẽ tắt khi việc thu âm thanh dừng lại và không phục hồi trong vài giây, chẳng hạn khi thiết bị phát bị ngắt kết nối mà không còn thiết bị nào khác. Khi đó, thẻ Trang chủ sẽ cho biết lý do. Hãy kết nối loa hoặc tai nghe, hoặc đặt thiết bị phát mặc định trong cài đặt âm thanh của Windows, rồi bắt đầu lại.", + "help.faq.no_graphic_q": "Tôi không thấy đồ họa nào.", + "help.faq.no_graphic_a": "Hãy kiểm tra xem âm thanh có đang phát qua thiết bị phát mặc định của Windows và không bị tắt tiếng hay không, và thẻ Trang chủ có hiện thông báo nào không. Nếu trò chơi chạy ở chế độ toàn màn hình độc quyền, hãy thử chế độ cửa sổ không viền hoặc chế độ cửa sổ. Ngoài ra, hãy đảm bảo Độ đậm trong thẻ Cài đặt không quá thấp và tùy chọn hiện loại âm thanh đang phát vẫn đang bật.", + "help.faq.performance_q": "Ứng dụng có dùng nhiều CPU hoặc GPU không?", + "help.faq.performance_a": "Để ứng dụng nhẹ hơn, hãy giảm “{0}” trong thẻ Cài đặt hoặc tắt “{1}”.", + "help.faq.language_q": "Làm cách nào để đổi ngôn ngữ ứng dụng?", + "help.faq.language_a": "Chọn trong danh sách “{0}” ở đầu cửa sổ này. Khi đang chạy, bạn cũng có thể đổi trong cài đặt lớp phủ. “{1}” sẽ dùng ngôn ngữ hiển thị của Windows.", + "help.report.title": "Báo cáo sự cố", + "help.report.desc": "Hãy cho chúng tôi biết khi bạn gặp lỗi, hoặc khi một âm thanh bị hiện sai loại. GitHub sẽ mở trong trình duyệt với thông tin hệ thống bên dưới đã được điền sẵn, nên bạn cần có tài khoản GitHub. Nếu không có tài khoản, hãy gửi email cho chúng tôi. Ứng dụng không bao giờ gửi đi bất kỳ âm thanh nào.", + "help.report.github": "Báo cáo trên GitHub", + "help.report.mail": "Báo cáo qua email", + "help.report.copy": "Sao chép thông tin hệ thống", + "help.report.copied": "Đã sao chép thông tin hệ thống.", + "help.report.copy_failed": "Không thể sao chép vào bộ nhớ tạm. Hãy thử lại.", + "help.report.no_browser": "Không mở được trình duyệt. Thay vào đó, hãy sao chép thông tin hệ thống và gửi cho chúng tôi.", + "help.report.no_mail": "Không có ứng dụng email nào để gửi. Hãy sao chép thông tin hệ thống và gửi bằng cách khác.", + "help.report.issue_title": "Báo cáo từ ứng dụng", + "help.report.issue_body": "Chuyện gì đã xảy ra?\n\nLúc đó bạn đang nghe gì (ứng dụng nào, loại âm thanh nào)?\n\nBạn đã mong đợi kết quả như thế nào?", + "help.about.title": "Giới thiệu", + "help.about.version": "Phiên bản {0}", + "help.about.license": "Ứng dụng này được phân phối theo GNU Affero General Public License v3.0 (AGPL-3.0). Tên và nhóm âm thanh trong thẻ Phân loại dựa trên AudioSet Ontology và được phân phối theo giấy phép CC BY-SA 4.0 (xem “Giấy phép nguồn mở” bên dưới).", + "help.licenses.button": "Giấy phép nguồn mở", + "help.licenses.title": "Giấy phép nguồn mở", + "help.licenses.close": "Đóng", + + "notice.ai_unavailable": "Ứng dụng không nhận diện được loại âm thanh nên mọi âm thanh đều hiện bằng màu của âm thanh môi trường. Hãy thử tắt âm thanh trực quan rồi bật lại.", + "notice.ai_unavailable_hidden": "Ứng dụng không nhận diện được loại âm thanh nên mọi âm thanh đều được tính là âm thanh môi trường, nhưng âm thanh môi trường đang bị ẩn nên không có gì hiện ra. Hãy bật “{0}” trong thẻ Cài đặt, hoặc thử tắt âm thanh trực quan rồi bật lại.", + "notice.stopped.title": "Âm thanh trực quan đã tắt", + "notice.stopped.no_device": "Không mở được thiết bị phát nào nên không thể thu âm thanh. Hãy kết nối loa hoặc tai nghe, hoặc đặt thiết bị phát mặc định trong cài đặt âm thanh của Windows, rồi bắt đầu lại.", + "notice.stopped.capture_error": "Việc thu âm thanh đã dừng do lỗi nên đồ họa đã tắt.", + "notice.dismiss": "Đóng thông báo", + + "tray.open": "Mở trình khởi chạy", + "tray.exit": "Thoát", + + "more_info.title": "Kho lưu trữ GitHub và đóng góp", + "more_info.body": "SoundVisualizer là dự án nguồn mở, mã nguồn được công khai trên GitHub. Bạn có thể tham gia bằng cách báo lỗi, đề xuất tính năng hoặc đóng góp vào việc phát triển. Hãy nhấp vào nút bên dưới để truy cập kho lưu trữ GitHub chính thức! Bạn cũng có thể xem và tải phiên bản mới nhất trong thẻ Releases trên GitHub.", + "more_info.github_button": "Truy cập kho lưu trữ GitHub", + + "dialog.notice_title": "Thông báo", + "dialog.already_running": "Lớp phủ hiện đang chạy.", + "dialog.reset_title": "Đặt lại cài đặt", + "dialog.reset_wave": "Đặt lại cài đặt của chế độ Sóng về mặc định?", + "dialog.reset_pad": "Đặt lại cài đặt của chế độ Vạch về mặc định?", + "dialog.reset_circle": "Đặt lại cài đặt của chế độ Vòng về mặc định?", + "dialog.reset_outline": "Đặt lại cài đặt của chế độ Viền về mặc định?", + "dialog.reset_sound_types": "Đặt lại cài đặt loại âm thanh về mặc định?", + "dialog.reset_all_title": "Đặt lại về mặc định", + "dialog.reset_all": "Bạn có chắc muốn đặt lại tất cả cài đặt về mặc định không?", + "dialog.link_error": "Không thể mở liên kết: {0}", + + "editor.title": "Cài đặt lớp phủ", + "editor.drag_tip": "Dùng chuột kéo các đường hướng dẫn trên màn hình để điều chỉnh giới hạn chiều cao và kích thước ở giữa của sóng lớp phủ theo thời gian thực!", + "editor.glow": "Hiệu ứng phát sáng", + "editor.glow_strength": "Độ phát sáng", + "editor.color": "Màu", + "editor.hotkeys": "Cài đặt phím tắt", + "editor.developer_mode_desc": "Hiện nội dung AI nghe được (trên trái) và FPS (trên phải)", + "editor.save_close_hint": "để lưu cài đặt và đóng", + "editor.close_overlay": "Thoát lớp phủ", + "editor.guide.limit": "Giới hạn {0}", + "editor.guide.size": "Kích thước {0}: {1}%", + "editor.guide.base_radius": "Bán kính cơ bản: {0}", + "editor.guide.circle_size": "Kích thước vòng: {0}%", + + "hud.visual_mode": "Chế độ hiển thị: [{0}] {1}", + "hud.sound_mode": "Chế độ âm thanh: [{0}] {1}", + "hud.overlay_settings": "Cài đặt lớp phủ: [{0}]", + "hud.status.ready": "Hệ thống đã sẵn sàng", + "hud.status.stereo": "Âm thanh PC: {0}ch (âm thanh nổi)\nNguồn: đang chờ...", + "hud.status.surround": "Âm thanh PC: 8ch (7.1)\nNguồn: đang chờ...", + + "color_picker.title": "Chọn màu", + "color_picker.hex": "Mã HEX", + "color_picker.cancel": "Hủy", + "color_picker.apply": "Áp dụng", + "color_picker.area": "Độ bão hòa và độ sáng", + "color_picker.area_value": "Độ bão hòa {0}%, độ sáng {1}%", + "color_picker.hue": "Sắc độ", + "color_picker.keys_help": "Dùng các phím mũi tên để thay đổi. Giữ Shift để thay đổi nhiều hơn mỗi lần.", + "color_picker.presets": "Màu thường dùng", + "color_picker.preset.white": "Trắng", + "color_picker.preset.red": "Đỏ", + "color_picker.preset.orange": "Cam", + "color_picker.preset.yellow": "Vàng", + "color_picker.preset.green": "Xanh lá cây", + "color_picker.preset.cyan": "Xanh lơ", + "color_picker.preset.blue": "Xanh dương", + "color_picker.preset.magenta": "Hồng cánh sen", + + "audio.error.no_device": "Không tìm thấy thiết bị đầu ra.", + "audio.error.capture_stopped": "Việc thu âm thanh đã dừng đột ngột: {0}", + "audio.error.capture_start_failed": "Không thể khởi động bộ thu âm thanh: {0}", + + "tutorial.window_title": "Hướng dẫn", + "tutorial.skip": "Bỏ qua", + "tutorial.previous": "Trang trước", + "tutorial.next": "Tiếp theo", + "tutorial.done": "Đã hiểu", + "tutorial.page_status": "Trang {0} trên {1}", + "tutorial.pause": "Tạm dừng hoạt ảnh", + "tutorial.play": "Phát hoạt ảnh", + "tutorial.sound_on": "Bật âm thanh", + "tutorial.sound_off": "Tắt âm thanh", + "tutorial.sound.title": "Nhìn thấy âm thanh trên màn hình", + "tutorial.sound.body": "Âm thanh từ trò chơi hoặc video được vẽ dọc theo các cạnh màn hình. Âm thanh càng lớn thì đồ họa càng lớn. Chỉ âm thanh đang phát trên PC này mới được hiển thị. Ứng dụng không thu âm thanh xung quanh bạn.", + "tutorial.direction.title": "Cho biết âm thanh đến từ hướng nào", + "tutorial.direction.body": "Khi xuất âm thanh 2 kênh (âm thanh nổi), âm thanh từ bên trái được vẽ lớn hơn ở bên trái, âm thanh từ bên phải được vẽ lớn hơn ở bên phải. Khi xuất âm thanh 5.1 hoặc 7.1 kênh, ứng dụng còn phân biệt được phía trước và phía sau: âm thanh từ phía trước được vẽ ở cạnh trên màn hình, âm thanh từ phía sau được vẽ ở cạnh dưới.", + "tutorial.types.title": "Mỗi loại âm thanh có một màu riêng", + "tutorial.types.body": "AI phân loại âm thanh thành ba loại và vẽ mỗi loại bằng một màu riêng. Bạn có thể đổi màu trong thẻ Cài đặt.", + "tutorial.type.ambient_examples": "Nhạc, tiếng mưa và mọi âm thanh khác", + "tutorial.type.speech_examples": "Tiếng người nói", + "tutorial.type.danger_examples": "Tiếng súng, tiếng nổ, tiếng còi báo động", + "tutorial.start.title": "Cách bật và tắt", + "tutorial.start.hotkeys": "Khi đang chạy, nhấn {0} để chuyển chế độ âm thanh, {1} để chuyển chế độ hiển thị và {2} để mở cài đặt lớp phủ." +} diff --git a/SoundVisualizer.Core/Localization/Strings/zh-Hans.json b/SoundVisualizer.Core/Localization/Strings/zh-Hans.json new file mode 100644 index 0000000..75761ee --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/zh-Hans.json @@ -0,0 +1,294 @@ +{ + "common.language": "语言", + "common.reset_defaults": "恢复默认设置", + "common.minimize": "最小化", + "common.close": "关闭", + "language.system": "系统默认", + + "launcher.window_title": "SoundVisualizer 启动器", + "launcher.tab.home": "首页", + "launcher.tab.settings": "设置", + "launcher.tab.sounds": "分类", + "launcher.tab.help": "帮助", + "launcher.tab.more_info": "了解更多", + "launcher.home.desc": "将看不见的声音呈现在屏幕上。\n从游戏到观影,开启全新体验。", + "launcher.home.start": "启动", + "launcher.home.stop": "停止", + "launcher.home.tutorial": "查看教程", + "launcher.status.running": "状态:运行中", + "launcher.status.idle": "状态:待启动", + + "settings.section.mode": "模式设置", + "settings.visual_mode.title": "显示模式", + "settings.visual_mode.desc": "选择屏幕上所绘图形的基本形状。", + "settings.mode.wave": "波浪", + "settings.mode.pad": "光条", + "settings.mode.circle": "圆环", + "settings.mode.outline": "轮廓", + "settings.sound_mode.title": "声音模式", + "settings.sound_mode.desc": "选择与扬声器相符的声道。\n2 声道扬声器请选择“2 声道”,环绕声扬声器请选择“5.1 声道”或“7.1 声道”。", + "settings.channels.2": "2 声道", + "settings.channels.5_1": "5.1 声道", + "settings.channels.7_1": "7.1 声道", + "settings.max_fps.title": "最大帧率", + "settings.max_fps.desc": "设置绘制图形时的最高帧率。", + "settings.section.mode_detail": "各模式详细设置", + "settings.mode_card.wave": "波浪模式", + "settings.mode_card.pad": "光条模式", + "settings.mode_card.circle": "圆环模式", + "settings.mode_card.outline": "轮廓模式", + "settings.size_lock.title": "锁定大小", + "settings.size_lock.desc": "将图形保持在固定大小,改用不透明度来表示声音强弱。", + "settings.fixed_size.wave": "波浪大小", + "settings.fixed_size.pad": "光条大小", + "settings.fixed_size.circle": "圆环大小", + "settings.fixed_size.outline": "轮廓粗细", + "settings.fixed_size.desc": "设置不透明度变化时图形保持的固定大小。", + "settings.max_opacity.title": "最大不透明度", + "settings.max_opacity.desc": "声音最大时图形达到的不透明度。", + "settings.size.title": "大小", + "settings.size.desc.wave": "调整波浪的上下高度和整体饱满感。", + "settings.size.desc.pad": "调整光条响应声音时的粗细和宽度。", + "settings.size.desc.circle": "调整声音响起时圆环均衡器向外鼓起的幅度。", + "settings.size.desc.outline": "调整轮廓波浪的上下高度和整体饱满感。", + "settings.opacity.title": "不透明度", + "settings.opacity.desc.wave": "数值越大,图形越浓重;数值越小,图形越淡、越透明。", + "settings.opacity.desc.pad": "数值越大,图形越浓重;数值越小,图形越淡、越透明。", + "settings.opacity.desc.circle": "数值越大,图形越浓重;数值越小,图形越淡、越透明。", + "settings.opacity.desc.outline": "数值越大,图形越浓重;数值越小,图形越淡、越透明。", + "settings.speed.title": "速度", + "settings.speed.desc.wave": "调整波浪流动和起伏的速度。", + "settings.speed.desc.pad": "调整光条转向声音来源方向的速度。", + "settings.speed.desc.circle": "调整圆环均衡器随声音起伏和转动的速度。", + "settings.speed.desc.outline": "调整轮廓波浪流动和起伏的速度。", + "settings.sensitivity.title": "灵敏度", + "settings.sensitivity.desc.wave": "调整波浪对较小声音的反应灵敏度和起伏程度。", + "settings.sensitivity.desc.pad": "调整光条捕捉声音的能力,使其更加醒目。", + "settings.sensitivity.desc.circle": "调整圆环振幅响应声音时分割的精细程度。", + "settings.sensitivity.desc.outline": "调整轮廓波浪对较小声音的反应灵敏度和起伏程度。", + "settings.glow.title": "发光", + "settings.glow.desc.wave": "在波浪边缘添加柔和的霓虹光晕,并调整其强度。\n注意:会占用额外的 GPU 资源。", + "settings.glow.desc.pad": "在光条周围添加光环般柔和的光晕。\n注意:会占用额外的 GPU 资源。", + "settings.glow.desc.circle": "沿圆环均衡器的边缘添加闪烁的光晕。\n注意:会占用额外的 GPU 资源。", + "settings.glow.desc.outline": "为轮廓添加柔和的霓虹光晕,并调整其强度。\n注意:会占用额外的 GPU 资源。", + "settings.radius.title": "半径", + "settings.radius.desc": "调整圆环模式下中央圆环的半径。", + "settings.section.sound_types": "声音类型(AI)", + "settings.show_ambient": "显示环境音", + "settings.show_speech": "显示人声", + "settings.show_danger": "显示危险音", + "settings.set_color": "设置颜色", + "settings.set_color_for": "设置颜色:{0}", + "settings.section.hotkeys": "快捷键", + "settings.hotkey.visual.title": "切换显示模式", + "settings.hotkey.visual.desc": "运行时实时切换图形形状的快捷键。", + "settings.hotkey.sound.title": "切换声音模式", + "settings.hotkey.sound.desc": "运行时实时切换声音模式的快捷键。", + "settings.hotkey.edit.title": "覆盖层设置", + "settings.hotkey.edit.desc": "打开或关闭覆盖层编辑界面的快捷键。", + "settings.hotkey.set": "设置快捷键", + "settings.section.advanced": "高级设置", + "settings.developer_mode.title": "开发者模式", + "settings.developer_mode.desc": "在覆盖层左上角显示 AI 听到的内容(它报告的声音名称、把握程度和耗时),并在右上角显示 FPS。显示内容为英文,不适合日常使用。", + "settings.developer_record.title": "将结果记录到文件", + "settings.developer_record.desc": "将 AI 的每个结果(它评分最高的声音名称、把握程度和耗时)写入 SoundVisualizer.exe 旁边 ai-log 文件夹中的新 CSV 文件,方便日后回看它听到了什么。如果无法写入该文件夹,文件会改存到 %LOCALAPPDATA%\\SoundVisualizer\\ai-log。文件可能会变得很大,用完后请关闭。", + "settings.auto_minimize.title": "启动时最小化启动器", + "settings.auto_minimize.desc": "启动覆盖层时,自动最小化此启动器窗口。", + + "sounds.title": "选择声音类型", + "sounds.desc": "AI 能识别的声音与相似的声音归为一组,并按各自的默认类型显示。更改一组的类型后,组内声音的颜色会立即随新类型改变。", + "sounds.sibling_note": "AI 常常会把同一个声音同时识别为多个名称。例如警笛也会被识别为“警报”,而“警报”在另一组中。如果更改后某个声音仍按原来的类型显示,请一并更改相关的组或声音。", + "sounds.advanced.title": "高级模式", + "sounds.advanced.desc": "按组显示所有声音,可以逐个更改。", + "sounds.search.hint": "搜索声音", + "sounds.search.clear": "清除搜索内容", + "sounds.filter.title": "筛选", + "sounds.filter.all": "全部", + "sounds.filter.changed": "已更改", + "sounds.type.ambient": "环境音", + "sounds.type.speech": "人声", + "sounds.type.danger": "危险音", + "sounds.type.default": "{0}(默认)", + "sounds.type.mixed": "混合", + "sounds.changed_count": "已更改的声音:{0} 个", + "sounds.changed_from": "默认:{0}", + "sounds.reset.button": "全部恢复默认", + "sounds.reset.title": "要将所有声音恢复默认吗?", + "sounds.reset.message": "您更改过的所有声音都将恢复为默认类型。", + "sounds.reset.confirm": "恢复默认", + "sounds.reset.cancel": "取消", + "sounds.empty": "没有符合条件的声音。", + "sounds.group.mixed_note": "部分声音已单独更改", + "sounds.group.changed_line": "当前:{0} · 默认:{1}", + "sounds.overwrite.title": "要更改整组吗?", + "sounds.overwrite.message": "“{0}”组中的 {1} 个声音将改为“{2}”。您单独更改过的声音也会被覆盖。", + "sounds.overwrite.confirm": "更改", + "sounds.overwrite.cancel": "取消", + "sounds.basic_warning.title": "要切换到基本模式吗?", + "sounds.basic_warning.message": "有些组中含有您单独更改过的声音。在基本模式下为这类组选择类型时,组内所有声音都会变为该类型,您在该组中单独做的更改会被覆盖。在您选择之前,不会有任何更改。", + "sounds.basic_warning.confirm": "切换到基本模式", + "sounds.basic_warning.cancel": "保留高级模式", + "sounds.cd.sound": "{0},类型:{1}", + "sounds.cd.sound_changed": "{0},类型:{1},默认:{2}", + "sounds.cd.group": "{0}组,类型:{1},声音:{2} 个({3})", + "sounds.cd.group_changed": "{0}组,类型:{1},默认:{2},声音:{3} 个({4})", + "sounds.cd.group_header": "{0}组,声音:{1} 个", + + "hotkey.prompt": "请按下按键(按 Esc 取消)", + "hotkey.none": "无", + "hotkey.button_name": "{0},快捷键:{1}", + + "help.start.title": "开始使用", + "help.start.1": "在此窗口顶部选择语言,如有需要,可在“设置”标签页中更改图形的外观。", + "help.start.2": "在“首页”点击“{0}”。", + "help.start.3": "图形会沿着屏幕边缘,绘制在游戏、视频等其他窗口之上。鼠标点击会穿过图形,因此可以继续操作下方的窗口。", + "help.start.4": "如需关闭,请在“首页”点击“{0}”,或右键点击任务栏通知区域中的 SoundVisualizer 图标,然后选择“{0}”。", + "help.modes.title": "显示模式", + "help.mode.wave": "整个屏幕边缘会随声音大小向内推进,声音传来的一侧推进得更深。", + "help.mode.pad": "屏幕边缘的 8 个位置会亮起光条,方向显示最为清晰。", + "help.mode.circle": "屏幕中央的圆环会朝声音传来的方向鼓起。", + "help.mode.outline": "只绘制细细的波浪轮廓线,对游戏画面的遮挡更少。", + "help.modes.note": "“大小”“不透明度”等设置会按模式分别保存,可在“设置”标签页中更改。", + "help.sound.title": "声音类型", + "help.sound.kinds": "AI 会将听到的声音分为环境音、人声和危险音。默认情况下,环境音为白色,人声为黄色,危险音为红色。您可以在“设置”标签页中更改每种类型的颜色以及是否显示。", + "help.sound.groups": "在“分类”标签页中,相似的声音会归为一组,您可以为每组选择归入哪一类。例如,将“门铃”组改为危险音后,AI 识别到门铃或敲门声时,会使用危险音的颜色。不过,新设为危险音的声音只会参与 AI 判断类型的投票,因此并不代表每次门铃响都会显示为危险音。", + "help.sound.advanced": "要逐个更改声音,请开启高级模式。", + "help.sound.danger": "检测到危险音(如枪声)时,颜色会立即改变,而不是逐渐过渡。", + "help.pc.title": "PC 设置与快捷键", + "help.sound_mode.title": "声音模式", + "help.sound_mode.body": "若要同时分辨前后和左右的声音,请在 Windows 声音设置中将输出设备配置为 5.1 环绕声(6 声道)或 7.1 环绕声(8 声道)。使用普通立体声(2 声道)时,可以分辨左右,但无法分辨前后。“设置”标签页中的“声音模式”会在启动时以及输出设备更改时自动与该设备匹配,你也可以自行更改。", + "help.sound_mode.tip": "💡 如果没有 7.1 输出设备,可以安装 {0},并将输出设备设为 7.1 声道,即可用 7.1 环绕声显示覆盖层。", + "help.sound_mode.tip_link": "VB-CABLE 虚拟驱动程序", + "help.hotkeys.title": "实时自定义快捷键", + "help.hotkeys.body": "在“设置”标签页中点击快捷键按钮,然后按下单个按键或组合键(例如 Ctrl + Shift + A),即可随意更改快捷键。即使应用在后台运行,按下快捷键也会立即切换模式。", + "help.hotkeys.defaults": "默认情况下,F2 切换声音模式,F3 切换显示模式,F4 打开覆盖层设置。", + "help.size_lock.title": "锁定大小模式", + "help.size_lock.body": "锁定大小模式用与声音大小成正比的不透明度来表示声音,从而让图形保持固定大小。如果想在保持图形大小不变的同时看到它对声音的反应,这个模式会很有用。", + "help.editor.title": "覆盖层实时编辑模式", + "help.editor.body": "按下指定的快捷键(默认为 F4)即可开启覆盖层编辑模式。您可以直接在屏幕上用鼠标拖动来调整图形的大小上限,并在弹出的设置面板中微调细节。", + "help.developer.title": "开发者模式", + "help.developer.body": "开启开发者模式后,覆盖层左上角会显示 AI 听到的内容(声音名称、把握程度和耗时),右上角会显示 FPS。如果同时开启“{0}”,每个结果都会保存到 CSV 文件中。需要确认系统是否正常运行时,这个模式会很有用。", + "help.notes.title": "注意事项", + "help.note.direction_surround": "以 5.1 或 7.1 声道输出时,屏幕顶部表示来自前方的声音,底部表示来自后方的声音。左右两侧分别表示来自左边和右边的声音,而从屏幕四周同时出现的图形表示环境音或没有方向的声音。", + "help.note.direction_stereo": "以 2 声道(立体声)输出时,可以分辨左右,但无法分辨前后。此时屏幕顶部和底部的图形表示左右两侧听起来相近的声音以及空间感。", + "help.note.sources": "会显示通过 Windows 默认播放设备播放的所有应用的声音。应用发送到其他设备的声音(例如只用于语音聊天的耳机)不会显示。", + "help.note.volume": "如果在应用内或 Windows 音量合成器中把某个应用的音量调得很低或静音,它的声音可能几乎不显示或完全不显示。如果图形反应不明显,请把该声音调大一些,或在“设置”标签页中调高“灵敏度”。", + "help.note.ai": "AI 分类可能出错。在涉及安全的情况下,请勿将本应用作为唯一的保障手段。", + "help.faq.title": "常见问题", + "help.faq.stopped_q": "我没有手动停止,它却自己关闭了。", + "help.faq.stopped_a": "声音捕获停止且几秒内没有恢复时,它就会关闭,例如播放设备被断开、又没有其他可用设备时。此时“首页”会显示原因。请连接扬声器或耳机,或在 Windows 声音设置中设置默认播放设备,然后重新启动。", + "help.faq.no_graphic_q": "看不到任何图形。", + "help.faq.no_graphic_a": "请确认声音正通过 Windows 默认播放设备播放且没有静音,并查看“首页”是否显示了提示。如果游戏以独占全屏模式运行,请尝试改用无边框窗口或窗口模式。另外,请确认“设置”标签页中的“不透明度”没有调得太低,并且当前声音类型的显示没有被关闭。", + "help.faq.performance_q": "会占用很多 CPU 或 GPU 吗?", + "help.faq.performance_a": "如需减轻负担,请在“设置”标签页中调低“{0}”,或关闭“{1}”。", + "help.faq.language_q": "如何更改应用语言?", + "help.faq.language_a": "在此窗口顶部的“{0}”列表中选择。运行时也可以在覆盖层设置中更改。选择“{1}”将跟随 Windows 的显示语言。", + "help.report.title": "报告问题", + "help.report.desc": "如果遇到程序错误,或者某个声音被显示成了错误的类型,请告诉我们。浏览器中会打开 GitHub,并预先填入下方的环境信息,因此需要 GitHub 账号。如果没有账号,请改用电子邮件发送给我们。本应用绝不会发送任何音频。", + "help.report.github": "在 GitHub 上报告", + "help.report.mail": "通过电子邮件报告", + "help.report.copy": "复制环境信息", + "help.report.copied": "已复制环境信息。", + "help.report.copy_failed": "无法复制到剪贴板。请重试。", + "help.report.no_browser": "无法打开浏览器。请改为复制环境信息并发送给我们。", + "help.report.no_mail": "没有可用于发送邮件的应用。请复制环境信息,并通过其他方式发送。", + "help.report.issue_title": "来自应用的报告", + "help.report.issue_body": "发生了什么?\n\n您当时在听什么(哪个应用、哪种声音)?\n\n您原本期望的结果是什么?", + "help.about.title": "关于", + "help.about.version": "版本 {0}", + "help.about.license": "本应用基于 GNU Affero General Public License v3.0(AGPL-3.0)发布。“分类”标签页中的声音名称和分组基于 AudioSet 本体,并以 CC BY-SA 4.0 许可发布(详见下方的“开源许可”)。", + "help.licenses.button": "开源许可", + "help.licenses.title": "开源许可", + "help.licenses.close": "关闭", + + "notice.ai_unavailable": "当前无法识别声音类型,所有声音都以环境音的颜色显示。请尝试将可视化关闭后重新开启。", + "notice.ai_unavailable_hidden": "当前无法识别声音类型,所有声音都会被视为环境音,但环境音的显示已关闭,因此不会显示任何内容。请在“设置”标签页中开启“{0}”,或尝试将可视化关闭后重新开启。", + "notice.stopped.title": "声音可视化已关闭", + "notice.stopped.no_device": "没有可打开的播放设备,因此无法捕获声音。请连接扬声器或耳机,或在 Windows 声音设置中设置默认播放设备,然后重新启动。", + "notice.stopped.capture_error": "声音捕获因出错而停止,图形已关闭。", + "notice.dismiss": "关闭提示", + + "tray.open": "打开启动器", + "tray.exit": "退出", + + "more_info.title": "GitHub 仓库与项目贡献", + "more_info.body": "SoundVisualizer 是一个开源项目,源代码公开在 GitHub 上。您可以通过报告错误、提出功能建议或参与开发来加入这个项目。点击下方按钮即可访问官方 GitHub 仓库!您还可以在 GitHub 的 Releases 页面查看并下载最新版本。", + "more_info.github_button": "访问 GitHub 仓库", + + "dialog.notice_title": "提示", + "dialog.already_running": "覆盖层已在运行。", + "dialog.reset_title": "重置设置", + "dialog.reset_wave": "要将波浪模式的设置恢复为默认值吗?", + "dialog.reset_pad": "要将光条模式的设置恢复为默认值吗?", + "dialog.reset_circle": "要将圆环模式的设置恢复为默认值吗?", + "dialog.reset_outline": "要将轮廓模式的设置恢复为默认值吗?", + "dialog.reset_sound_types": "要将声音类型的设置恢复为默认值吗?", + "dialog.reset_all_title": "恢复默认设置", + "dialog.reset_all": "确定要将所有设置恢复为默认值吗?", + "dialog.link_error": "无法打开链接:{0}", + + "editor.title": "覆盖层设置", + "editor.drag_tip": "用鼠标拖动屏幕上的辅助线,即可实时调整覆盖层波浪的高度上限和中央大小!", + "editor.glow": "发光效果", + "editor.glow_strength": "发光强度", + "editor.color": "颜色", + "editor.hotkeys": "快捷键设置", + "editor.developer_mode_desc": "显示 AI 听到的内容(左上角)和 FPS(右上角)", + "editor.save_close_hint": "键保存设置并关闭", + "editor.close_overlay": "退出覆盖层", + "editor.guide.limit": "{0}上限线", + "editor.guide.size": "{0}大小:{1}%", + "editor.guide.base_radius": "基础半径:{0}", + "editor.guide.circle_size": "圆环大小:{0}%", + + "hud.visual_mode": "显示模式:[{0}] {1}", + "hud.sound_mode": "声音模式:[{0}] {1}", + "hud.overlay_settings": "覆盖层设置:[{0}]", + "hud.status.ready": "系统就绪", + "hud.status.stereo": "PC 音频:{0}ch(立体声)\n音源:等待中……", + "hud.status.surround": "PC 音频:8ch(7.1)\n音源:等待中……", + + "color_picker.title": "选择颜色", + "color_picker.hex": "HEX 代码", + "color_picker.cancel": "取消", + "color_picker.apply": "应用", + "color_picker.area": "饱和度和亮度", + "color_picker.area_value": "饱和度 {0}%,亮度 {1}%", + "color_picker.hue": "色相", + "color_picker.keys_help": "用方向键调整。按住 Shift 键可大幅调整。", + "color_picker.presets": "常用颜色", + "color_picker.preset.white": "白色", + "color_picker.preset.red": "红色", + "color_picker.preset.orange": "橙色", + "color_picker.preset.yellow": "黄色", + "color_picker.preset.green": "绿色", + "color_picker.preset.cyan": "青色", + "color_picker.preset.blue": "蓝色", + "color_picker.preset.magenta": "洋红色", + + "audio.error.no_device": "未找到输出设备。", + "audio.error.capture_stopped": "音频捕获意外停止:{0}", + "audio.error.capture_start_failed": "无法启动音频捕获引擎:{0}", + + "tutorial.window_title": "教程", + "tutorial.skip": "跳过", + "tutorial.previous": "上一页", + "tutorial.next": "下一步", + "tutorial.done": "知道了", + "tutorial.page_status": "第 {0} 页,共 {1} 页", + "tutorial.pause": "暂停动画", + "tutorial.play": "播放动画", + "tutorial.sound_on": "打开声音", + "tutorial.sound_off": "关闭声音", + "tutorial.sound.title": "在屏幕上看见声音", + "tutorial.sound.body": "游戏或视频中的声音会以图形显示在屏幕边缘。声音越大,图形就越大。只显示这台电脑上播放的声音。本应用不会捕获您周围的声音。", + "tutorial.direction.title": "显示声音来自哪个方向", + "tutorial.direction.body": "以 2 声道(立体声)输出时,来自左边的声音会在左侧显示得更大,来自右边的声音会在右侧显示得更大。以 5.1 或 7.1 声道输出时,还能分辨前后:来自前方的声音显示在屏幕顶部,来自后方的声音显示在底部。", + "tutorial.types.title": "用颜色区分声音类型", + "tutorial.types.body": "AI 会将声音分为三种类型,并用不同的颜色显示。您可以在“设置”标签页中更改颜色。", + "tutorial.type.ambient_examples": "音乐、雨声等其他声音", + "tutorial.type.speech_examples": "说话声", + "tutorial.type.danger_examples": "枪声、爆炸声、警报声", + "tutorial.start.title": "如何开启和关闭", + "tutorial.start.hotkeys": "运行时,按 {0} 切换声音模式,按 {1} 切换显示模式,按 {2} 打开覆盖层设置。" +} diff --git a/SoundVisualizer.Core/Localization/Strings/zh-Hant.json b/SoundVisualizer.Core/Localization/Strings/zh-Hant.json new file mode 100644 index 0000000..3b8088b --- /dev/null +++ b/SoundVisualizer.Core/Localization/Strings/zh-Hant.json @@ -0,0 +1,294 @@ +{ + "common.language": "語言", + "common.reset_defaults": "重設為預設值", + "common.minimize": "最小化", + "common.close": "關閉", + "language.system": "系統預設", + + "launcher.window_title": "SoundVisualizer 啟動器", + "launcher.tab.home": "首頁", + "launcher.tab.settings": "設定", + "launcher.tab.sounds": "分類", + "launcher.tab.help": "說明", + "launcher.tab.more_info": "深入了解", + "launcher.home.desc": "將看不見的聲音直接描繪在螢幕上。\n從遊戲到電影,開啟全新的體驗。", + "launcher.home.start": "開始", + "launcher.home.stop": "停止", + "launcher.home.tutorial": "查看教學", + "launcher.status.running": "狀態:執行中", + "launcher.status.idle": "狀態:準備就緒", + + "settings.section.mode": "模式設定", + "settings.visual_mode.title": "視覺模式", + "settings.visual_mode.desc": "選擇螢幕上所繪製圖形的基本形狀。", + "settings.mode.wave": "波浪", + "settings.mode.pad": "光條", + "settings.mode.circle": "圓形", + "settings.mode.outline": "輪廓", + "settings.sound_mode.title": "聲音模式", + "settings.sound_mode.desc": "選擇符合喇叭配置的聲道。\n2 聲道喇叭請選擇「2 聲道」,環繞音效喇叭請選擇「5.1 聲道」或「7.1 聲道」。", + "settings.channels.2": "2 聲道", + "settings.channels.5_1": "5.1 聲道", + "settings.channels.7_1": "7.1 聲道", + "settings.max_fps.title": "最大畫面播放速率", + "settings.max_fps.desc": "設定繪製圖形時的最高畫面播放速率。", + "settings.section.mode_detail": "各模式詳細設定", + "settings.mode_card.wave": "波浪模式", + "settings.mode_card.pad": "光條模式", + "settings.mode_card.circle": "圓形模式", + "settings.mode_card.outline": "輪廓模式", + "settings.size_lock.title": "鎖定大小", + "settings.size_lock.desc": "讓圖形維持固定大小,改以不透明度呈現音量大小。", + "settings.fixed_size.wave": "波浪大小", + "settings.fixed_size.pad": "光條大小", + "settings.fixed_size.circle": "圓形大小", + "settings.fixed_size.outline": "輪廓粗細", + "settings.fixed_size.desc": "設定不透明度變化時,圖形維持的固定大小。", + "settings.max_opacity.title": "最大不透明度", + "settings.max_opacity.desc": "聲音最大時圖形達到的不透明度。", + "settings.size.title": "大小", + "settings.size.desc.wave": "調整波浪的上下高度與整體飽滿感。", + "settings.size.desc.pad": "調整光條回應聲音時的粗細與寬度。", + "settings.size.desc.circle": "調整聲音響起時,圓形等化器向外膨脹的幅度。", + "settings.size.desc.outline": "調整輪廓波浪的上下高度與整體飽滿感。", + "settings.opacity.title": "不透明度", + "settings.opacity.desc.wave": "數值越高越鮮明;數值越低越淡、越透明。", + "settings.opacity.desc.pad": "數值越高越鮮明;數值越低越淡、越透明。", + "settings.opacity.desc.circle": "數值越高越鮮明;數值越低越淡、越透明。", + "settings.opacity.desc.outline": "數值越高越鮮明;數值越低越淡、越透明。", + "settings.speed.title": "速度", + "settings.speed.desc.wave": "調整波浪流動與起伏的速度。", + "settings.speed.desc.pad": "調整光條轉向聲音來源方向的速度。", + "settings.speed.desc.circle": "調整圓形等化器隨聲音起伏與旋轉的速度。", + "settings.speed.desc.outline": "調整輪廓波浪流動與起伏的速度。", + "settings.sensitivity.title": "靈敏度", + "settings.sensitivity.desc.wave": "調整波浪對較小聲音的反應靈敏度與起伏程度。", + "settings.sensitivity.desc.pad": "調整光條捕捉聲音的能力,讓它更加醒目。", + "settings.sensitivity.desc.circle": "調整圓形振幅回應聲音時分割的細緻程度。", + "settings.sensitivity.desc.outline": "調整輪廓波浪對較小聲音的反應靈敏度與起伏程度。", + "settings.glow.title": "光暈", + "settings.glow.desc.wave": "在波浪邊緣加上柔和的霓虹光暈,並調整其強度。\n注意:會使用額外的 GPU 資源。", + "settings.glow.desc.pad": "在光條周圍加上如光環般柔和的光暈。\n注意:會使用額外的 GPU 資源。", + "settings.glow.desc.circle": "沿著圓形等化器的邊緣加上閃爍的光環。\n注意:會使用額外的 GPU 資源。", + "settings.glow.desc.outline": "為輪廓加上柔和的霓虹光暈,並調整其強度。\n注意:會使用額外的 GPU 資源。", + "settings.radius.title": "半徑", + "settings.radius.desc": "調整圓形模式中央圓環的半徑。", + "settings.section.sound_types": "聲音類型 (AI)", + "settings.show_ambient": "顯示環境音", + "settings.show_speech": "顯示人聲", + "settings.show_danger": "顯示危險音", + "settings.set_color": "設定顏色", + "settings.set_color_for": "設定顏色:{0}", + "settings.section.hotkeys": "快速鍵", + "settings.hotkey.visual.title": "切換視覺模式", + "settings.hotkey.visual.desc": "執行時即時切換圖形形狀的快速鍵。", + "settings.hotkey.sound.title": "切換聲音模式", + "settings.hotkey.sound.desc": "執行時即時切換聲音模式的快速鍵。", + "settings.hotkey.edit.title": "覆疊畫面設定", + "settings.hotkey.edit.desc": "開啟或關閉覆疊畫面編輯器的快速鍵。", + "settings.hotkey.set": "設定快速鍵", + "settings.section.advanced": "進階設定", + "settings.developer_mode.title": "開發人員模式", + "settings.developer_mode.desc": "在覆疊畫面左上角顯示 AI 聽到的內容(它回報的聲音名稱、把握程度和耗時),並在右上角顯示 FPS。顯示內容為英文,不適合日常使用。", + "settings.developer_record.title": "將結果記錄到檔案", + "settings.developer_record.desc": "將 AI 的每個結果(它評分最高的聲音名稱、把握程度和耗時)寫入 SoundVisualizer.exe 旁邊 ai-log 資料夾中的新 CSV 檔案,方便日後回顧它聽到了什麼。如果無法寫入該資料夾,檔案會改存到 %LOCALAPPDATA%\\SoundVisualizer\\ai-log。檔案可能會變得很大,用完後請關閉。", + "settings.auto_minimize.title": "開始時將啟動器最小化", + "settings.auto_minimize.desc": "覆疊畫面開始執行時,自動將這個啟動器視窗最小化。", + + "sounds.title": "選擇聲音類型", + "sounds.desc": "AI 能辨識的聲音會和相似的聲音歸為同一群組,並依各自的預設類型顯示。變更群組的類型後,其中聲音的顏色會立即跟著新類型改變。", + "sounds.sibling_note": "AI 常常會把同一個聲音同時辨識為多個名稱。例如警笛也會被辨識為「警報」,而「警報」在另一個群組。如果變更後某個聲音仍以原本的類型顯示,請一併變更相關的群組或聲音。", + "sounds.advanced.title": "進階模式", + "sounds.advanced.desc": "依群組顯示所有聲音,可以逐一變更。", + "sounds.search.hint": "搜尋聲音", + "sounds.search.clear": "清除搜尋內容", + "sounds.filter.title": "篩選", + "sounds.filter.all": "全部", + "sounds.filter.changed": "已變更", + "sounds.type.ambient": "環境音", + "sounds.type.speech": "人聲", + "sounds.type.danger": "危險音", + "sounds.type.default": "{0} (預設)", + "sounds.type.mixed": "混合", + "sounds.changed_count": "已變更的聲音:{0} 個", + "sounds.changed_from": "預設:{0}", + "sounds.reset.button": "全部重設", + "sounds.reset.title": "要將所有聲音重設為預設值嗎?", + "sounds.reset.message": "你變更過的所有聲音,都會恢復為預設類型。", + "sounds.reset.confirm": "重設", + "sounds.reset.cancel": "取消", + "sounds.empty": "沒有符合的聲音。", + "sounds.group.mixed_note": "部分聲音已個別變更", + "sounds.group.changed_line": "目前:{0} · 預設:{1}", + "sounds.overwrite.title": "要變更整個群組嗎?", + "sounds.overwrite.message": "「{0}」群組中的 {1} 個聲音將改為「{2}」。你個別變更過的聲音也會被覆寫。", + "sounds.overwrite.confirm": "變更", + "sounds.overwrite.cancel": "取消", + "sounds.basic_warning.title": "要切換到基本模式嗎?", + "sounds.basic_warning.message": "有些群組含有你個別變更過的聲音。在基本模式下為這類群組選擇類型時,群組內所有聲音都會變成該類型,你在該群組中個別做的變更會被覆寫。在你選擇之前,不會有任何變更。", + "sounds.basic_warning.confirm": "切換到基本模式", + "sounds.basic_warning.cancel": "保留進階模式", + "sounds.cd.sound": "{0},類型:{1}", + "sounds.cd.sound_changed": "{0},類型:{1},預設:{2}", + "sounds.cd.group": "{0}群組,類型:{1},聲音:{2} 個 ({3})", + "sounds.cd.group_changed": "{0}群組,類型:{1},預設:{2},聲音:{3} 個 ({4})", + "sounds.cd.group_header": "{0}群組,聲音:{1} 個", + + "hotkey.prompt": "請按下按鍵 (按 Esc 取消)", + "hotkey.none": "無", + "hotkey.button_name": "{0},快速鍵:{1}", + + "help.start.title": "開始使用", + "help.start.1": "在此視窗頂端選擇語言,如有需要,可以在「設定」分頁中變更圖形的外觀。", + "help.start.2": "在「首頁」按一下「{0}」。", + "help.start.3": "圖形會沿著螢幕邊緣,繪製在遊戲、影片等其他視窗之上。滑鼠點按會穿過圖形,因此可以繼續操作下方的視窗。", + "help.start.4": "如要關閉,請在「首頁」按一下「{0}」,或以滑鼠右鍵按一下工作列通知區域中的 SoundVisualizer 圖示,然後選擇「{0}」。", + "help.modes.title": "視覺模式", + "help.mode.wave": "整個螢幕邊緣會隨著音量向內推進,聲音來源那一側會推進得更深。", + "help.mode.pad": "螢幕邊緣的 8 個位置會亮起光條,最容易看出聲音的方向。", + "help.mode.circle": "螢幕中央的圓環會朝聲音來源的方向膨脹。", + "help.mode.outline": "只以細線描繪波浪的輪廓,較不會遮住遊戲畫面。", + "help.modes.note": "「大小」、「不透明度」等設定會依模式分別儲存,可以在「設定」分頁中變更。", + "help.sound.title": "聲音類型", + "help.sound.kinds": "AI 會將聽到的聲音分為環境音、人聲和危險音。預設情況下,環境音為白色、人聲為黃色、危險音為紅色。你可以在「設定」分頁中變更各類型的顏色,以及是否顯示。", + "help.sound.groups": "在「分類」分頁中,相似的聲音會歸為同一群組,你可以為每個群組選擇歸入哪一類。例如將「門鈴」群組改為危險音後,AI 辨識到門鈴或敲門聲時,就會使用危險音的顏色。不過,新設為危險音的聲音只會參與 AI 判斷類型的投票,因此並不代表每次門鈴響都會顯示為危險音。", + "help.sound.advanced": "如要逐一變更聲音,請開啟進階模式。", + "help.sound.danger": "偵測到危險音 (例如槍聲) 時,顏色會立即改變,而不是逐漸轉變。", + "help.pc.title": "PC 設定與快速鍵", + "help.sound_mode.title": "聲音模式", + "help.sound_mode.body": "若要同時分辨前後和左右的聲音,請在 Windows 音效設定中將輸出裝置設為 5.1 環繞音效 (6 聲道) 或 7.1 環繞音效 (8 聲道)。使用一般的立體聲 (2 聲道) 時,可以分辨左右,但無法分辨前後。「設定」分頁中的「聲音模式」會在啟動時以及輸出裝置變更時自動配合該裝置,你也可以自行變更。", + "help.sound_mode.tip": "💡 如果沒有 7.1 輸出裝置,可以安裝 {0},並將輸出裝置設為 7.1 聲道,就能以 7.1 環繞音效顯示覆疊畫面。", + "help.sound_mode.tip_link": "VB-CABLE 虛擬驅動程式", + "help.hotkeys.title": "即時自訂快速鍵", + "help.hotkeys.body": "在「設定」分頁中按一下快速鍵按鈕,然後按下單一按鍵或組合鍵 (例如 Ctrl + Shift + A),就能隨意變更快速鍵。即使應用程式在背景執行,按下快速鍵也會立即切換模式。", + "help.hotkeys.defaults": "預設情況下,F2 切換聲音模式,F3 切換視覺模式,F4 開啟覆疊畫面設定。", + "help.size_lock.title": "鎖定大小模式", + "help.size_lock.body": "鎖定大小模式會以與音量成正比的不透明度呈現聲音,讓圖形維持固定大小。如果想讓圖形大小保持不變,同時看到它對聲音的反應,這個模式就很實用。", + "help.editor.title": "覆疊畫面即時編輯模式", + "help.editor.body": "按下指定的快速鍵 (預設為 F4) 即可開啟覆疊畫面編輯模式。你可以直接在螢幕上用滑鼠拖曳來調整圖形的大小上限,並在跳出的設定面板中微調細節。", + "help.developer.title": "開發人員模式", + "help.developer.body": "開啟開發人員模式後,覆疊畫面左上角會顯示 AI 聽到的內容(聲音名稱、把握程度和耗時),右上角會顯示 FPS。如果同時開啟「{0}」,每個結果都會儲存到 CSV 檔案中。想確認系統是否正常運作時,這個模式就很實用。", + "help.notes.title": "注意事項", + "help.note.direction_surround": "以 5.1 或 7.1 聲道輸出時,螢幕上方代表來自前方的聲音,下方代表來自後方的聲音。左右兩側分別代表來自左邊和右邊的聲音,而從螢幕四周同時出現的圖形,則是環境音或沒有方向性的聲音。", + "help.note.direction_stereo": "以 2 聲道 (立體聲) 輸出時,可以分辨左右,但無法分辨前後。這時螢幕上方和下方的圖形代表兩側聽起來差不多的聲音,以及空間感效果。", + "help.note.sources": "會顯示透過 Windows 預設播放裝置播放的所有應用程式的聲音。應用程式傳送到其他裝置的聲音 (例如只用於語音聊天的耳機) 不會顯示。", + "help.note.volume": "如果在應用程式內或 Windows 音量混音程式中,把某個應用程式的音量調得很低或靜音,它的聲音可能幾乎不顯示或完全不顯示。如果圖形反應不明顯,請把該聲音調大一些,或在「設定」分頁中調高「靈敏度」。", + "help.note.ai": "AI 分類可能會出錯。在攸關安全的情況下,請勿將這個應用程式當作唯一的依靠。", + "help.faq.title": "常見問題", + "help.faq.stopped_q": "我沒有關閉它,它卻自己關掉了。", + "help.faq.stopped_a": "聲音擷取停止且幾秒內沒有恢復時,它就會關閉,例如播放裝置被拔除,又沒有其他可用的裝置時。此時「首頁」會顯示原因。請連接喇叭或耳機,或在 Windows 音效設定中設定預設播放裝置,然後重新開始。", + "help.faq.no_graphic_q": "看不到任何圖形。", + "help.faq.no_graphic_a": "請確認聲音正透過 Windows 預設播放裝置播放且沒有靜音,並查看「首頁」是否顯示了提示。如果遊戲以獨佔全螢幕模式執行,請試著改用無邊框視窗或視窗模式。另外,也請確認「設定」分頁中的「不透明度」沒有調得太低,以及目前這種聲音類型的顯示沒有關閉。", + "help.faq.performance_q": "會占用很多 CPU 或 GPU 嗎?", + "help.faq.performance_a": "如要減輕負擔,請在「設定」分頁中調低「{0}」,或關閉「{1}」。", + "help.faq.language_q": "如何變更應用程式語言?", + "help.faq.language_a": "從此視窗頂端的「{0}」清單中選擇。執行時也可以在覆疊畫面設定中變更。選擇「{1}」會跟隨 Windows 的顯示語言。", + "help.report.title": "回報問題", + "help.report.desc": "如果遇到程式錯誤,或是聲音被顯示成錯誤的類型,請告訴我們。瀏覽器會開啟 GitHub,並預先填入下方的環境資訊,因此需要 GitHub 帳號。如果沒有帳號,請改用電子郵件寄給我們。這個應用程式絕不會傳送任何音訊。", + "help.report.github": "在 GitHub 上回報", + "help.report.mail": "透過電子郵件回報", + "help.report.copy": "複製環境資訊", + "help.report.copied": "已複製環境資訊。", + "help.report.copy_failed": "無法複製到剪貼簿。請再試一次。", + "help.report.no_browser": "無法開啟瀏覽器。請改為複製環境資訊並傳送給我們。", + "help.report.no_mail": "找不到可以寄送電子郵件的應用程式。請複製環境資訊,再透過其他方式傳送。", + "help.report.issue_title": "來自應用程式的回報", + "help.report.issue_body": "發生了什麼事?\n\n你當時在聽什麼 (哪個應用程式、哪種聲音)?\n\n你原本預期會是什麼結果?", + "help.about.title": "關於", + "help.about.version": "版本 {0}", + "help.about.license": "本應用程式以 GNU Affero General Public License v3.0 (AGPL-3.0) 授權發布。「分類」分頁中的聲音名稱和群組以 AudioSet 本體為基礎,並以 CC BY-SA 4.0 授權發布 (詳見下方的「開放原始碼授權」)。", + "help.licenses.button": "開放原始碼授權", + "help.licenses.title": "開放原始碼授權", + "help.licenses.close": "關閉", + + "notice.ai_unavailable": "目前無法辨識聲音類型,所有聲音都會以環境音的顏色顯示。請試著關閉聲音視覺化後再重新開啟。", + "notice.ai_unavailable_hidden": "目前無法辨識聲音類型,所有聲音都會被視為環境音,但環境音的顯示已關閉,因此不會顯示任何內容。請在「設定」分頁中開啟「{0}」,或試著關閉聲音視覺化後再重新開啟。", + "notice.stopped.title": "聲音視覺化已關閉", + "notice.stopped.no_device": "沒有可開啟的播放裝置,因此無法擷取聲音。請連接喇叭或耳機,或在 Windows 音效設定中設定預設播放裝置,然後重新開始。", + "notice.stopped.capture_error": "聲音擷取因發生錯誤而停止,因此圖形已關閉。", + "notice.dismiss": "關閉提示", + + "tray.open": "開啟啟動器", + "tray.exit": "結束", + + "more_info.title": "GitHub 存放庫與專案貢獻", + "more_info.body": "SoundVisualizer 是開放原始碼專案,原始碼公開在 GitHub 上。你可以透過回報錯誤、建議功能或參與開發來加入這個專案。按一下下方按鈕,前往官方 GitHub 存放庫吧!你也可以在 GitHub 的 Releases 頁面查看並下載最新版本。", + "more_info.github_button": "前往 GitHub 存放庫", + + "dialog.notice_title": "提示", + "dialog.already_running": "覆疊畫面已在執行中。", + "dialog.reset_title": "重設設定", + "dialog.reset_wave": "要將波浪模式的設定重設為預設值嗎?", + "dialog.reset_pad": "要將光條模式的設定重設為預設值嗎?", + "dialog.reset_circle": "要將圓形模式的設定重設為預設值嗎?", + "dialog.reset_outline": "要將輪廓模式的設定重設為預設值嗎?", + "dialog.reset_sound_types": "要將聲音類型的設定重設為預設值嗎?", + "dialog.reset_all_title": "重設為預設值", + "dialog.reset_all": "確定要將所有設定重設為預設值嗎?", + "dialog.link_error": "無法開啟連結:{0}", + + "editor.title": "覆疊畫面設定", + "editor.drag_tip": "用滑鼠拖曳螢幕上的輔助線,就能即時調整覆疊畫面波浪的高度上限和中央大小!", + "editor.glow": "光暈效果", + "editor.glow_strength": "光暈強度", + "editor.color": "顏色", + "editor.hotkeys": "快速鍵設定", + "editor.developer_mode_desc": "顯示 AI 聽到的內容(左上角)和 FPS(右上角)", + "editor.save_close_hint": "鍵儲存設定並關閉", + "editor.close_overlay": "結束覆疊畫面", + "editor.guide.limit": "{0}上限線", + "editor.guide.size": "{0}大小:{1}%", + "editor.guide.base_radius": "基本半徑:{0}", + "editor.guide.circle_size": "圓形大小:{0}%", + + "hud.visual_mode": "視覺模式:[{0}] {1}", + "hud.sound_mode": "聲音模式:[{0}] {1}", + "hud.overlay_settings": "覆疊畫面設定:[{0}]", + "hud.status.ready": "系統已就緒", + "hud.status.stereo": "PC 音訊:{0}ch (立體聲)\n音源:等待中……", + "hud.status.surround": "PC 音訊:8ch (7.1)\n音源:等待中……", + + "color_picker.title": "選擇顏色", + "color_picker.hex": "HEX 色碼", + "color_picker.cancel": "取消", + "color_picker.apply": "套用", + "color_picker.area": "飽和度和亮度", + "color_picker.area_value": "飽和度 {0}%,亮度 {1}%", + "color_picker.hue": "色相", + "color_picker.keys_help": "用方向鍵調整。按住 Shift 鍵可大幅調整。", + "color_picker.presets": "常用顏色", + "color_picker.preset.white": "白色", + "color_picker.preset.red": "紅色", + "color_picker.preset.orange": "橘色", + "color_picker.preset.yellow": "黃色", + "color_picker.preset.green": "綠色", + "color_picker.preset.cyan": "青色", + "color_picker.preset.blue": "藍色", + "color_picker.preset.magenta": "洋紅色", + + "audio.error.no_device": "找不到輸出裝置。", + "audio.error.capture_stopped": "音訊擷取意外停止:{0}", + "audio.error.capture_start_failed": "無法啟動音訊擷取引擎:{0}", + + "tutorial.window_title": "教學", + "tutorial.skip": "略過", + "tutorial.previous": "上一頁", + "tutorial.next": "下一步", + "tutorial.done": "我知道了", + "tutorial.page_status": "第 {0} 頁,共 {1} 頁", + "tutorial.pause": "暫停動畫", + "tutorial.play": "播放動畫", + "tutorial.sound_on": "開啟聲音", + "tutorial.sound_off": "關閉聲音", + "tutorial.sound.title": "在螢幕上看見聲音", + "tutorial.sound.body": "遊戲或影片的聲音會沿著螢幕邊緣描繪出來。聲音越大,圖形就越大。只會顯示這台電腦上播放的聲音。這個應用程式不會擷取你周遭的聲音。", + "tutorial.direction.title": "顯示聲音來自哪個方向", + "tutorial.direction.body": "以 2 聲道 (立體聲) 輸出時,來自左邊的聲音會在左側顯示得更大,來自右邊的聲音會在右側顯示得更大。以 5.1 或 7.1 聲道輸出時,還能分辨前後:來自前方的聲音顯示在螢幕上方,來自後方的聲音顯示在下方。", + "tutorial.types.title": "用顏色區分聲音類型", + "tutorial.types.body": "AI 會將聲音分為三種類型,並以不同顏色顯示。你可以在「設定」分頁中變更顏色。", + "tutorial.type.ambient_examples": "音樂、雨聲等其他聲音", + "tutorial.type.speech_examples": "說話聲", + "tutorial.type.danger_examples": "槍聲、爆炸聲、警報聲", + "tutorial.start.title": "如何開啟和關閉", + "tutorial.start.hotkeys": "執行時,按 {0} 切換聲音模式,按 {1} 切換視覺模式,按 {2} 開啟覆疊畫面設定。" +} diff --git a/SoundVisualizer.Core/Localization/UiLanguage.cs b/SoundVisualizer.Core/Localization/UiLanguage.cs new file mode 100644 index 0000000..6c5d18d --- /dev/null +++ b/SoundVisualizer.Core/Localization/UiLanguage.cs @@ -0,0 +1,12 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Localization +{ + /// + /// 화면 언어 하나. 는 BCP-47 꼴 코드로, 설정 파일의 Language 값과 문자열 표 파일 이름 + /// (Localization/Strings/{Code}.json)에 그대로 쓴다. 은 그 언어로 쓴 이름이라, + /// 지금 화면 언어를 읽지 못하는 사람도 언어 목록에서 자기 언어를 찾을 수 있다. + /// + public sealed record UiLanguage(string Code, string NativeName, bool IsRightToLeft = false); +} diff --git a/SoundVisualizer.Core/Localization/UiLanguages.cs b/SoundVisualizer.Core/Localization/UiLanguages.cs new file mode 100644 index 0000000..e2a7b3b --- /dev/null +++ b/SoundVisualizer.Core/Localization/UiLanguages.cs @@ -0,0 +1,136 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using System.Text; + +namespace SoundVisualizer.Localization +{ + /// + /// 고를 수 있는 화면 언어 18개와, 설정값( 또는 언어 코드)을 실제 화면 언어로 푸는 규칙. + /// 목록 순서는 언어 콤보에 그대로 나온다. 안드로이드 판과 같은 언어들이다. + /// + public static class UiLanguages + { + /// Windows 화면 언어를 따르는 선택. 새로 설치하면 이것으로 시작한다. + public const string SystemCode = "system"; + + /// 지원하지 않는 언어와, 표에 없는 문자열이 기대는 언어. + public const string DefaultCode = "en"; + + public static IReadOnlyList All { get; } = new[] + { + new UiLanguage("en", "English"), + new UiLanguage("ko", "한국어"), + new UiLanguage("ja", "日本語"), + new UiLanguage("zh-Hans", "简体中文"), + new UiLanguage("zh-Hant", "繁體中文"), + new UiLanguage("es", "Español"), + new UiLanguage("pt-BR", "Português (Brasil)"), + new UiLanguage("fr", "Français"), + new UiLanguage("de", "Deutsch"), + new UiLanguage("ru", "Русский"), + new UiLanguage("it", "Italiano"), + new UiLanguage("vi", "Tiếng Việt"), + new UiLanguage("th", "ไทย"), + new UiLanguage("id", "Bahasa Indonesia"), + new UiLanguage("tr", "Türkçe"), + new UiLanguage("pl", "Polski"), + new UiLanguage("hi", "हिन्दी"), + new UiLanguage("ar", "العربية", IsRightToLeft: true), + }; + + // 예전 판이 설정 파일에 남긴 언어 값(런처 콤보에 보이던 이름과 약어). 읽을 때 한 번 코드로 바꾸고, 저장은 코드로 한다. + // 첫 실행 기본값이던 "ENG" 도 영어다. + private static readonly Dictionary LegacySettingValues = new(StringComparer.OrdinalIgnoreCase) + { + ["KOR"] = "ko", + ["English"] = "en", + ["ENG"] = "en", + ["日本語"] = "ja", + ["中文"] = "zh-Hans", + ["Español"] = "es", + ["Français"] = "fr", + ["Deutsch"] = "de", + ["Русский"] = "ru", + }; + + /// 코드로 언어를 찾는다. 대소문자는 가리지 않는다("zh-hans" 도 간체 중국어). 없으면 null. + public static UiLanguage? Find(string? code) + { + if (string.IsNullOrWhiteSpace(code)) return null; + string trimmed = code.Trim(); + foreach (var language in All) + { + if (string.Equals(language.Code, trimmed, StringComparison.OrdinalIgnoreCase)) return language; + } + return null; + } + + public static bool IsRightToLeft(string? code) => Find(code)?.IsRightToLeft ?? false; + + /// + /// 설정 파일에서 읽은 값을 저장할 값으로 맞춘다. 결과는 이거나 의 코드다. + /// 예전 판의 값("KOR", "日本語" 등)은 코드로 옮긴다. 비었거나 모르는 값(다음 판의 새 언어 등)은 시스템 언어를 따르게 한다. + /// + public static string NormalizeSetting(string? stored) + { + if (string.IsNullOrWhiteSpace(stored)) return SystemCode; + + // 예전 값은 XAML 에 적힌 글자 그대로라 보통 조합형(NFC)이지만, 다른 편집기를 거쳤을 수도 있어 맞춰서 찾는다. + string value = stored.Trim().Normalize(NormalizationForm.FormC); + if (string.Equals(value, SystemCode, StringComparison.OrdinalIgnoreCase)) return SystemCode; + if (LegacySettingValues.TryGetValue(value, out var migrated)) return migrated; + return Find(value)?.Code ?? SystemCode; + } + + /// 설정값을 실제 화면 언어 코드로 푼다. 이면 (Windows 화면 언어)를 따른다. + public static string Resolve(string? setting, CultureInfo uiCulture) + { + string normalized = NormalizeSetting(setting); + return normalized == SystemCode ? FromCultureName(uiCulture.Name) : normalized; + } + + /// + /// Windows 문화권 이름("ko-KR", "zh-TW", "pt-PT" 등)을 지원하는 언어로 바꾼다. + /// 중국어는 번체를 쓰는 대만·홍콩·마카오(또는 Hant 문자 표기)만 번체, 나머지는 간체다. 포르투갈어는 모두 브라질 포르투갈어, + /// 인도네시아어는 옛 코드 "in" 도 받는다. 그 밖에는 언어 부분이 지원 목록에 있으면 그 언어, 없으면 영어다. + /// + public static string FromCultureName(string? cultureName) + { + if (string.IsNullOrWhiteSpace(cultureName)) return DefaultCode; + + string[] parts = cultureName.Trim().Replace('_', '-').Split('-', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return DefaultCode; + string[] subtags = parts[1..]; + + switch (parts[0].ToLowerInvariant()) + { + case "zh": + // 문자 표기가 있으면 그것을 따른다. 옛 .NET 의 "zh-CHT"/"zh-CHS" 도 같은 뜻이다. + if (HasSubtag(subtags, "Hant", "CHT")) return "zh-Hant"; + if (HasSubtag(subtags, "Hans", "CHS")) return "zh-Hans"; + return HasSubtag(subtags, "TW", "HK", "MO") ? "zh-Hant" : "zh-Hans"; + case "pt": + return "pt-BR"; + case "in": + case "id": + return "id"; + default: + return Find(parts[0])?.Code ?? DefaultCode; + } + } + + private static bool HasSubtag(string[] subtags, params string[] wanted) + { + foreach (var subtag in subtags) + { + foreach (var w in wanted) + { + if (string.Equals(subtag, w, StringComparison.OrdinalIgnoreCase)) return true; + } + } + return false; + } + } +} diff --git a/SoundVisualizer.Core/Rendering/FrameStep.cs b/SoundVisualizer.Core/Rendering/FrameStep.cs new file mode 100644 index 0000000..e229451 --- /dev/null +++ b/SoundVisualizer.Core/Rendering/FrameStep.cs @@ -0,0 +1,63 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Rendering +{ + /// + /// 프레임마다 곱하던 감쇠·따라붙기 계수를 60fps 한 프레임을 기준으로 시간 정규화한다(Android VisualizerEngine 의 k 와 norm). + /// + /// 계수를 그린 프레임마다 그대로 곱하면 최대 프레임(30~모니터 주사율)에 따라 움직임이 달라진다. 144fps 에서는 60fps 보다 + /// 2.4배 빨리 줄고 빨리 따라붙었다. 지난 프레임 뒤로 흐른 시간을 60fps 프레임 수(step)로 재서, 감쇠는 decay^step, + /// 따라붙기는 1-(1-c)^step 으로 바꾼다. 60fps 에서는 step 이 1 이라 예전 값 그대로다. + /// + public static class FrameStep + { + /// 기준 프레임 길이(60fps). 예전 계수는 모두 이 간격으로 그릴 때의 값이다. + public const double ReferenceFrameMs = 1000.0 / 60.0; + + /// + /// 한 번에 진행하는 가장 긴 시간(60fps 프레임 4개). 창이 잠깐 멈췄다 돌아와도 한 프레임에 크게 건너뛰지 않는다. + /// Android 는 아래로도 0.25(240fps)에서 자르지만 PC 는 최대 프레임을 모니터 주사율(240Hz 넘는 모니터도 있다)까지 + /// 올릴 수 있어 아래로는 자르지 않는다. 자르면 그보다 빠른 화면에서 다시 빨라진다. + /// + public const double MaxStep = 4.0; + + /// 지난 프레임 뒤로 흐른 시간을 60fps 프레임 수로 바꾼다. 0 과 사이다. + public static double FromElapsed(double elapsedMs) + { + if (!(elapsedMs > 0)) return 0; // 같은 시각에 두 번 불렸거나 시계가 뒤로 갔다. 진행하지 않는다. + return Math.Min(elapsedMs / ReferenceFrameMs, MaxStep); + } + + /// 60fps 한 프레임에 을 곱하던 감쇠를 프레임 분량으로 바꾼다. + public static float Decay(float perFrame, double step) => + step == 1.0 ? perFrame : (float)Math.Pow(perFrame, step); + + /// + /// 60fps 한 프레임에 남은 거리의 만큼 따라붙던 계수를 프레임 분량으로 바꾼다. + /// 1 이면 바로 붙고, 0 이면 움직이지 않는다. + /// + public static double Approach(double perFrame, double step) => + step == 1.0 ? perFrame : 1.0 - Math.Pow(1.0 - perFrame, step); + } + + /// + /// 그린 프레임 사이의 시간을 재서 의 step 으로 준다. 첫 프레임과 직후는 1 이다 + /// (쉬다가 깨어난 첫 프레임이 쉰 시간만큼 한꺼번에 진행하지 않게). 렌더 스레드에서만 쓴다. + /// + public sealed class FrameClock + { + private double _lastMs = double.NaN; + + /// 이번 프레임의 시각(밀리초, 단조 증가)을 받아 지난 프레임 뒤로 흐른 60fps 프레임 수를 돌려준다. + public double Next(double nowMs) + { + double last = _lastMs; + _lastMs = nowMs; + return double.IsNaN(last) ? 1.0 : FrameStep.FromElapsed(nowMs - last); + } + + /// 다음 프레임을 첫 프레임처럼 다룬다. + public void Reset() => _lastMs = double.NaN; + } +} diff --git a/SoundVisualizer.Core/Rendering/LateDangerHold.cs b/SoundVisualizer.Core/Rendering/LateDangerHold.cs new file mode 100644 index 0000000..f416c28 --- /dev/null +++ b/SoundVisualizer.Core/Rendering/LateDangerHold.cs @@ -0,0 +1,99 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Audio; + +namespace SoundVisualizer.Rendering +{ + /// + /// 표시를 꺼 둔 종류라 그리지 못한 피크를 약 1초 보관하는 링 버퍼(Android VisualizerEngine 의 holdL/holdR). + /// + /// 위협음만 보이게 해 두면 총성 같은 짧은 소리는 먼저 숨긴 라벨(환경음 등)로 들어오고, 위협음 판정은 다음 분류(250ms 주기)와 + /// 추론 시간만큼 늦게 온다. 그때 피크를 이미 버렸다면 남은 잔향으로 0 에서 자라 거의 보이지 않는다. 그래서 칸마다 가장 큰 + /// 프레임 하나를 남겨 두고, 늦은 판정이 오면 엔진이 이것으로 크기와 방향을 다시 잡는다(). + /// + /// 칸 하나는 60fps 두 프레임(약 33ms)이라 30칸이면 약 1초다. 분류기는 최근 약 1초 창을 250ms 마다 판정하므로, 판정이 몇 번 + /// 늦어져도 그 판정을 부른 소리는 이 안에 있다. 방향이 섞이지 않게 채널들을 한 프레임으로 묶어 다룬다. + /// 렌더 스레드에서만 쓴다. 할당은 없다. + /// + public sealed class LateDangerHold + { + public const int Slots = 30; + public const int Channels = PeakAccumulator.ChannelCount; + + /// 칸 하나의 길이(60fps 프레임 수). + public const double SlotFrames = 2; + + private readonly float[] _ring = new float[Slots * Channels]; + private readonly float[] _ringLevels = new float[Slots]; + private readonly float[] _current = new float[Channels]; // 채우는 중인 칸 + private float _currentLevel; + private int _index; // 다음에 덮어쓸(가장 오래된) 칸 + private double _frames; // 채우는 중인 칸에 쌓인 시간(60fps 프레임 수) + + /// 마지막으로 큰 피크를 넣은 뒤 그 피크가 밀려나기까지 남은 칸 수. 0 이면 보관한 것이 없어 링을 훑지 않는다. + private int _liveSlots; + + /// 보관한 피크가 있는지. + public bool HasData => _liveSlots > 0; + + /// + /// 그리지 못한 프레임의 피크를 지금 칸에 모은다. 은 그 프레임의 크기(지금 사운드 모드가 그리는 채널의 + /// 최댓값)이고, 이하면 깨울 일도 없으니 넣지 않는다. 칸마다 가장 큰 프레임 하나만 남는다. + /// + public void Keep(ReadOnlySpan peaks, float level, float threshold) + { + if (!(level > threshold)) return; + if (level > _currentLevel) + { + peaks[..Channels].CopyTo(_current); + _currentLevel = level; + } + _liveSlots = Slots + 1; // 지금 칸 + 링 한 바퀴 + } + + /// + /// 보관 시간을 (60fps 프레임 수)만큼 흘린다. 칸이 차면 링에 넣고 가장 오래된 칸을 덮어쓴다. + /// 큰 피크가 모두 밀려나면 링은 전부 0 이 되므로 그다음부터는 아무것도 하지 않는다. + /// + public void Advance(double frames) + { + if (_liveSlots == 0) return; + _frames += frames; + while (_frames >= SlotFrames) + { + _frames -= SlotFrames; + _current.AsSpan().CopyTo(_ring.AsSpan(_index * Channels, Channels)); + _ringLevels[_index] = _currentLevel; + _index = (_index + 1) % Slots; + Array.Clear(_current); + _currentLevel = 0; + if (--_liveSlots == 0) + { + _frames = 0; + return; + } + } + } + + /// + /// 오래된 칸부터 번째 칸(0..). 마지막()은 채우는 중인 칸이다. + /// + public ReadOnlySpan Slot(int n) => + n < Slots ? _ring.AsSpan((_index + n) % Slots * Channels, Channels) : _current; + + /// 과 같은 순서로 그 칸의 크기. + public float SlotLevel(int n) => n < Slots ? _ringLevels[(_index + n) % Slots] : _currentLevel; + + /// 보관한 것을 모두 버린다. 한 번 쓴 소리로 두 번 번쩍이지 않게, 쓰고 나면 부른다. + public void Clear() + { + Array.Clear(_ring); + Array.Clear(_ringLevels); + Array.Clear(_current); + _currentLevel = 0; + _frames = 0; + _liveSlots = 0; + } + } +} diff --git a/SoundVisualizer.Core/Rendering/OverlayInput.cs b/SoundVisualizer.Core/Rendering/OverlayInput.cs new file mode 100644 index 0000000..a4b40fd --- /dev/null +++ b/SoundVisualizer.Core/Rendering/OverlayInput.cs @@ -0,0 +1,18 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Rendering +{ + /// + /// 프레임마다 엔진()에 넘기는 바깥 값(Android VisualizerInputs 가 읽어 오던 것). + /// 설정과 AI 결과는 부르는 쪽이 프레임마다 한 번만 읽어 이것으로 넘긴다. 그래야 한 프레임의 판단이 모두 같은 결과로 된다. + /// + /// 지금 표현 모드의 설정. + /// 표현 모드( 등). + /// 사운드 모드(0: 2.0, 1: 5.1, 2: 7.1). + /// AI 가 확정한 소리 종류. 결과가 없으면 환경음이다. + /// 그 종류를 화면에 보이게 해 두었는지. + public readonly record struct OverlayInput(VisualModeSettings Settings, int VisualMode, int SoundMode, SoundType Label, bool Shown); +} diff --git a/SoundVisualizer.Core/Rendering/SoundWakeSignal.cs b/SoundVisualizer.Core/Rendering/SoundWakeSignal.cs new file mode 100644 index 0000000..c8197c6 --- /dev/null +++ b/SoundVisualizer.Core/Rendering/SoundWakeSignal.cs @@ -0,0 +1,51 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Rendering +{ + /// + /// 쉬는(idle) 오버레이를 소리가 나는 순간 깨운다(Android SoundWakeSignal, #170). + /// + /// 쉬는 동안 오버레이는 33ms 마다 단축키와 소리를 확인한다. 캡처 스레드는 어차피 버퍼마다 소리 크기를 알고 있으므로, + /// 조용히 쉬는 동안에는 큰 소리가 든 버퍼에서 바로 확인을 앞당기게 한다. 그러면 다음 33ms 를 기다리지 않고 그리기 시작한다. + /// + /// 순서는 이렇다. 오버레이(UI 스레드)가 확인하기 **직전에** 을 부르고 소리를 확인한다. 그 뒤 캡처 스레드가 + /// 에서 큰 소리를 보면 한 번만 true 를 돌려주고, 캡처 스레드는 확인을 UI 스레드에 넘긴다. + /// ** 뒤에 들어온 버퍼만 센다.** 마지막 소리 크기만 보고 깨우면, 캡처가 멈춰 그 값이 크게 남아 있을 때 쉬지 못한다. + /// 표시를 꺼 둔 종류의 소리가 계속 나는 동안에는 오버레이가 해 둔다. 그러지 않으면 버퍼마다(약 10ms) 깨어 + /// 33ms 확인보다 오히려 잦아진다. + /// + /// 캡처 스레드가 버퍼마다 더 하는 일은 원자 변수 읽기 한 번이다. 할당은 없다. + /// + public sealed class SoundWakeSignal + { + private readonly float _threshold; + private int _armed; + + /// 소리 크기가 이 값보다 크면 깨운다. + public SoundWakeSignal(float threshold) + { + _threshold = threshold; + } + + /// 지금 큰 소리를 기다리는지. 캡처 스레드가 소리 크기를 잴지 먼저 보는 데 쓴다. + public bool IsArmed => Volatile.Read(ref _armed) != 0; + + /// 지금부터 들어오는 큰 소리 한 번을 알린다. 오버레이가 소리를 확인하기 직전에 부른다. + public void Arm() => Volatile.Write(ref _armed, 1); + + /// 큰 소리를 기다리지 않는다. 깨어났거나 33ms 확인만 할 때 부른다. + public void Disarm() => Volatile.Write(ref _armed, 0); + + /// + /// 캡처 스레드가 버퍼를 넘긴 직후 그 버퍼의 소리 크기로 부른다. 기다리는 중이고 크기가 문턱을 넘으면 true 를 한 번만 + /// 돌려준다(여러 스레드가 동시에 불러도 하나만). 그때 부르는 쪽이 오버레이의 확인을 앞당긴다. + /// + public bool OnBuffer(float level) + { + if (Volatile.Read(ref _armed) == 0) return false; // 깨어 있거나 이미 알렸다. 그리는 동안은 대부분 여기서 끝난다. + if (!(level > _threshold)) return false; + return Interlocked.Exchange(ref _armed, 0) == 1; + } + } +} diff --git a/SoundVisualizer.Core/Rendering/StereoUpmix.cs b/SoundVisualizer.Core/Rendering/StereoUpmix.cs new file mode 100644 index 0000000..5b99b24 --- /dev/null +++ b/SoundVisualizer.Core/Rendering/StereoUpmix.cs @@ -0,0 +1,95 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Rendering +{ + /// + /// 사운드 모드 2.0: 앞 좌우 두 채널을 화면 둘레 여덟 자리로 펼친다(Android VisualizerEngine 의 upmix, Mid/Side). + /// + /// 좌우에 함께 있는 몫(Mid = 작은 쪽)은 앞(상단 가운데·양쪽 위 모서리)에, 한쪽이 더 큰 몫(Side = 차이)만 그쪽 옆에 그린다. + /// 그래서 가운데 소리는 5.1·7.1 의 앞 가운데(FC)처럼 위에 서고, 한쪽으로 치우친 소리는 그쪽 옆만 깊어진다. 예전처럼 좌우를 + /// 옆·뒤로 그대로 복사하면 가운데 소리가 양옆에서 똑같이 그려져 "양쪽에서 나는 소리" 와 구별되지 않았다. + /// + /// 뒤쪽 자리(좌하단·하단 가운데·우하단)는 같은 계산을 조금 늦은 좌우로 한다. 소리가 앞에서 뒤로 퍼지는 것처럼 보이는 + /// Android 의 공간 리플(기본값 켜짐)이다. Android 는 5칸 히스토리의 가장 오래된 값(60fps 네 프레임 전)을 쓰지만 틱 수로 세면 + /// 최대 프레임에 따라 지연이 달라지므로, 여기서는 라는 시간으로 센다. + /// 렌더 스레드에서만 쓴다. 할당은 없다. + /// + public sealed class StereoUpmix + { + /// 뒤쪽 자리가 따라오는 지연. Android 의 60fps 네 프레임과 같다. + public const double RearDelayMs = 4 * FrameStep.ReferenceFrameMs; + + // 프레임 시각이 조금 흔들려도 60fps 네 프레임 전 값을 고르게 1ms 를 봐준다. + private const double DelayToleranceMs = 1; + + // 지연만큼의 프레임을 담는 링. 960fps 까지 지연 시간만큼을 담는다. + private const int Capacity = 64; + private readonly double[] _times = new double[Capacity]; + private readonly float[] _left = new float[Capacity]; + private readonly float[] _right = new float[Capacity]; + private int _head; // 다음에 쓸 칸 + private int _count; + + /// + /// 이번 프레임의 앞 좌우를 넣고, 뒤쪽 자리에 쓸 좌우( 전의 값)를 돌려준다. + /// 그만큼 오래된 프레임이 아직 없으면 0 이다(Android 도 히스토리를 0 으로 채워 시작한다). + /// + public void Push(double nowMs, float left, float right, out float rearLeft, out float rearRight) + { + _times[_head] = nowMs; + _left[_head] = left; + _right[_head] = right; + _head = (_head + 1) % Capacity; + if (_count < Capacity) _count++; + + rearLeft = 0; + rearRight = 0; + // 가장 최근 것부터 거슬러 올라가, 지연만큼 지난 첫 프레임을 쓴다. 링이 다 차도 그만큼 지난 것이 없으면 가장 오래된 것을 쓴다. + for (int n = 1; n <= _count; n++) + { + int i = (_head - n + Capacity) % Capacity; + if (nowMs - _times[i] >= RearDelayMs - DelayToleranceMs || n == Capacity) + { + rearLeft = _left[i]; + rearRight = _right[i]; + return; + } + } + } + + /// 지난 프레임을 모두 버린다. 쉬기로 내려갈 때와 사운드 모드를 바꿀 때 부른다. + public void Clear() + { + _head = 0; + _count = 0; + } + + /// + /// 앞 좌우와 뒤쪽 좌우를 둘레 여덟 자리(0:FC 1:FR 2:SR 3:BR 4:BC 5:BL 6:SL 7:FL)로 펼치고 그 합을 돌려준다. + /// 계수는 Android 와 같다. + /// + public static float Spread(float left, float right, float rearLeft, float rearRight, Span positions) + { + float mid = Math.Min(left, right); + float sideL = Math.Max(0f, left - right); + float sideR = Math.Max(0f, right - left); + positions[0] = mid * 1.2f; // FC + positions[1] = right * 0.9f; // FR + positions[7] = left * 0.9f; // FL + positions[2] = sideR * 1.5f; // SR + positions[6] = sideL * 1.5f; // SL + + float rearMid = Math.Min(rearLeft, rearRight); + float rearSideL = Math.Max(0f, rearLeft - rearRight); + float rearSideR = Math.Max(0f, rearRight - rearLeft); + positions[3] = rearSideR * 1.2f; // BR + positions[4] = rearMid * 0.8f; // BC + positions[5] = rearSideL * 1.2f; // BL + + float total = 0; + for (int i = 0; i < 8; i++) total += positions[i]; + return total; + } + } +} diff --git a/SoundVisualizer.Core/Rendering/VisualizerEngine.cs b/SoundVisualizer.Core/Rendering/VisualizerEngine.cs new file mode 100644 index 0000000..7d6c74f --- /dev/null +++ b/SoundVisualizer.Core/Rendering/VisualizerEngine.cs @@ -0,0 +1,576 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Audio; + +namespace SoundVisualizer.Rendering +{ + /// + /// 오버레이가 그린 프레임마다 하는 계산: 채널 피크 → 전체 크기와 방향 분포의 스무딩 → 화면 둘레 여덟 자리의 깊이 → 패드·원형의 움직임. + /// Android VisualizerEngine.kt 에서 그리기를 뺀 계산부다. 도형은 앱의 Visualizers 가 이 결과로 만든다. + /// + /// 감쇠와 따라붙기는 모두 60fps 한 프레임 기준으로 시간 정규화한다(). 그래서 최대 프레임을 30 으로 두든 + /// 모니터 주사율로 두든 같은 시간 뒤에 같은 모양이 되고, 60fps 에서는 예전과 같다. + /// + /// 표시를 꺼 둔 종류라 그리지 못한 피크는 약 1초 보관해(), 위협음 판정이 늦게 와도 표시 중이었을 때의 + /// 크기로 그린다. + /// + /// 아무것도 보이지 않은 지 1초가 지나고 조용하면(또는 지금 설정과 라벨로는 그릴 수 없으면) 쉰다(). + /// 쉬는 동안 오버레이는 프레임마다 그리지 않고 마다 로 확인하며, + /// 그릴 수 있는 소리가 나면 깨어난다. + /// + /// 사운드 모드 2.0 은 앞 좌우를 Mid/Side 로 둘레 여덟 자리에 펼쳐 그린다(). 5.1·7.1 은 채널마다 제자리에 그린다. + /// + /// 렌더 스레드(UI 스레드)에서만 쓴다. 프레임마다 힙 할당이 없다. + /// 채널은 캡처 순서 FL, FR, FC, BL, BR, SL, SR, LFE 다( 와 같다). + /// 둘레 자리는 0:FC(상단 중앙) 1:FR(우상단) 2:SR(우측 중앙) 3:BR(우하단) 4:BC(하단 중앙) 5:BL(좌하단) 6:SL(좌측 중앙) 7:FL(좌상단)이다. + /// 스무딩에 넣는 값()은 5.1·7.1 이면 채널 순서, 2.0 이면 둘레 자리 순서다. + /// + public sealed class VisualizerEngine + { + public const int Channels = PeakAccumulator.ChannelCount; + public const int Positions = 8; + + // 캡처 채널 번호 + private const int FL = 0, FR = 1, FC = 2, BL = 3, BR = 4, SL = 5, SR = 6, LFE = 7; + + // 표현 모드(AppSettings.VisualMode) + public const int ModeWave = 0, ModePad = 1, ModeCircle = 2, ModeOutline = 3; + + /// 버퍼가 오지 않은 프레임마다 채널 목표값에 곱하는 감쇠(60fps 기준). + public const float TargetDecay = 0.87f; + + /// 패드 두께·원형 깊이가 프레임마다 목표를 따라붙는 비율(60fps 기준). + public const double PadLerp = 0.3; + public const double CircleLerp = 0.25; + + /// 프레임마다 애니메이션 시각에 더하는 값(60fps 기준). + public const double AnimationStep = 0.035; + + /// 크기 100% 가 닿는 한계선은 화면 가운데에서 이만큼(DIP) 바깥이다. 한계선이 이보다 가깝지는 않다. + public const double EdgeMargin = 10; + + /// 패드는 파도 깊이의 이 비율을 두께로 쓰고, 에서 자른다. + public const double PadDepthScale = 0.25; + public const double PadMaxThickness = 55; + + /// 원형은 파도 깊이의 이 비율로 부풀고, 화면 높이의 에서 자른다. + public const double CircleDepthScale = 0.35; + public const double CircleMaxHeightRatio = 0.4; + + /// + /// 이보다 옅으면 보이지 않는 것으로 본다(진하기 0..1). 크기 고정 모드는 진하기가 전체 크기를 따라 0 으로 다가가기만 해서, + /// 이 문턱이 없으면 소리가 그친 뒤에도 몇 분 동안 보이지 않는 도형을 화면 전체 크기로 계속 그린다. + /// + public const double MinVisibleAlpha = 0.002; + + /// 도형이 보이기 시작하는 문턱(DIP). 파도·외곽선은 깊이, 패드는 두께, 원형은 깊이로 본다(Visualizers 와 같다). + public const double WaveActiveDepth = 3; + public const double PadMinThickness = 0.5; + public const double CircleMinDepth = 1; + + /// 아무것도 보이지 않은 채 이만큼(밀리초) 지나면 쉰다. + public const double IdleAfterMs = 1000; + + /// 쉬는 동안 단축키와 소리를 확인하는 간격(밀리초). + public const int IdlePollMs = 33; + + /// 그리는 채널 가운데 가장 큰 값이 이 이하면 조용한 것으로 본다. AI 무음 게이트()와 같다. + public const float WakeThreshold = 0.01f; + + /// + /// 늦게 온 위협음 판정으로 그릴 때 가장 센 자리가 적어도 닿는 깊이(DIP). + /// 파도·외곽선이 보이기 시작하는 3 의 두 배 넘게 잡아 민감도 기본값에서 0.4초쯤 보이고, + /// 패드(깊이 ×0.25, 0.5)와 원형(깊이 ×0.35, 1)도 보이는 문턱을 넘는다. + /// + public const double LateDangerMinDepth = 8; + + private readonly FrameClock _clock = new(); + private readonly LateDangerHold _hold = new(); + private readonly StereoUpmix _upmix = new(); + private bool _stereoLayout; // 지난 프레임이 2.0 배치(둘레 자리 순서)였는지 + + // 채널별 목표(최근 피크). 버퍼가 오면 그 피크로 바꾸고, 안 오면 줄인다. + private readonly float[] _targets = new float[Channels]; + // 이번 프레임에 스무딩에 넣은 값. 5.1·7.1 은 채널 순서, 2.0 은 둘레 자리 순서다. + private readonly float[] _sources = new float[Channels]; + private float _totalTarget; + // 늦은 위협음 판정 때 보관한 소리를 채널 값으로 펼쳐 보는 자리 + private readonly float[] _heldSources = new float[Channels]; + + // 전체 크기(민감도로 따라붙음)와 방향 분포(속도로 따라붙음) + private double _smoothTotal; + private readonly double[] _dist = { 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125 }; + + private readonly double[] _depths = new double[Positions]; + private readonly double[] _padThickness = new double[Positions]; + private readonly double[] _circleDepths = new double[Positions]; + private double _baseDepth; + private double _animationTime; + private double _alpha; + private bool _visible; + + // 쉬기 + private double _invisibleSinceMs = -1; // 보이지 않기 시작한 프레임 시각. 보이는 동안은 -1. + private double _lastHoldMs = double.NaN; // 보관 시간을 마지막으로 흘린 시각 + private bool _quietAtLastPoll; + + private double _width, _height; + + /// 그릴 면의 크기(DIP). 프레임마다 알려 준다. + public void SetSurfaceSize(double width, double height) + { + _width = width; + _height = height; + } + + /// + /// 그린 프레임 하나를 계산한다. + /// + /// 이번 프레임 시각(밀리초, 단조 증가). + /// 지난 프레임 뒤로 들어온 버퍼들의 채널별 최고값. 버퍼가 하나도 오지 않았으면 비운다. + /// 이번 프레임의 설정과 AI 라벨. + /// 보이는 것이 없어도 쉬지 않는다(오버레이 편집 중). + public void Tick(double nowMs, ReadOnlySpan peaks, in OverlayInput input, bool keepAwake = false) + { + double step = _clock.Next(nowMs); + var settings = input.Settings; + int soundMode = input.SoundMode; + _lastHoldMs = nowMs; + + // 1. 목표값. 버퍼가 왔으면 그 피크, 안 왔으면 60fps 기준 프레임마다 0.87 씩 줄인 값. + if (!peaks.IsEmpty) + { + peaks[..Channels].CopyTo(_targets); + } + else + { + float decay = FrameStep.Decay(TargetDecay, step); + for (int i = 0; i < Channels; i++) _targets[i] *= decay; + } + + // 1-1. 라벨. 숨긴 종류면 이번 피크를 보관하고, 위협음 판정이 새로 왔으면 보관한 크기로 시작한다. + // 그릴 수 없는 설정이면 판정이 와도 보일 것이 없으니 보관하지 않는다. + // 깨어 있는 동안 받은 소리도 판정이 오기 전에 쉬기로 내려가면 사라지므로 여기서도 똑같이 모은다(PollWake 참고). + _hold.Advance(step); + bool drawable = CanDraw(settings); + if (drawable) + { + if (!input.Shown) + { + if (!peaks.IsEmpty) _hold.Keep(peaks, Loudness(peaks, soundMode), WakeThreshold); + } + else if (input.Label == SoundType.Danger) + { + ReleaseHold(settings, soundMode); + } + } + + // 2. 스무딩. 전체 크기(민감도·떨림)와 소리가 어디서 나는지의 분포(위치 변화 속도)를 따로 따라간다. + // 2.0 과 5.1·7.1 은 분포의 배치가 달라, 사운드 모드를 바꾸면 분포를 고르게 놓고 새로 따라간다. + // 그대로 두면 바꾼 직후 잠깐 엉뚱한 방향이 그려진다. + bool stereo = soundMode == 0; + if (stereo != _stereoLayout) + { + _stereoLayout = stereo; + Array.Fill(_dist, 1.0 / Channels); + _upmix.Clear(); + } + float total = MapSources(nowMs, soundMode); + _totalTarget = total; + + _smoothTotal += (total - _smoothTotal) * FrameStep.Approach(TremorCoefficient(settings), step); + + double sfPosition = FrameStep.Approach(PositionCoefficient(settings), step); + if (total > 0.0001f) + { + for (int i = 0; i < Channels; i++) _dist[i] += ((_sources[i] / total) - _dist[i]) * sfPosition; + } + + // 3. 깊이. 크기 100% 가 화면 가운데 한계선에 닿게 맞춘다. + _baseDepth = BaseDepthFor(settings, _width, _height); + FillDepths(settings.IntensityAsOpacity, soundMode); + + // 4. 패드·원형은 깊이를 다시 한 번 부드럽게 따라간다. 그리는 모드만 움직인다. + bool anyShape = input.VisualMode switch + { + ModePad => StepPad(step), + ModeCircle => StepCircle(step), + _ => AnyDepthAbove(WaveActiveDepth), + }; + + _animationTime += AnimationStep * step; + + // 5. 진하기와 보이는지. 크기 고정 모드는 진하기가 소리 크기를 따른다. + _alpha = AlphaFor(settings, _smoothTotal); + _visible = input.Shown && _alpha > MinVisibleAlpha && anyShape; + + // 6. 쉬기. 보이지 않은 지 1초가 지났고 조용하거나, 소리는 나도 지금 라벨과 설정으로는 그릴 수 없으면 쉰다. + // 소리 조건만 보면 표시를 끈 종류의 음악이 계속 나올 때 그릴 것도 없이 최대 프레임으로 돈다. + if (_visible || keepAwake) + { + _invisibleSinceMs = -1; + } + else if (_invisibleSinceMs < 0) + { + _invisibleSinceMs = nowMs; + } + else if (nowMs - _invisibleSinceMs > IdleAfterMs && + (Loudness(_targets, soundMode) < WakeThreshold || !(input.Shown && drawable))) + { + EnterIdle(); + } + } + + /// + /// 쉬는 동안 마다(또는 캡처 스레드가 큰 소리로 앞당겨) 부른다. 소리가 나고 지금 설정과 라벨로 그릴 수 + /// 있을 때만 깨어나 true 를 돌려준다. 그러면 부르는 쪽은 다시 프레임마다 을 부른다. + /// + /// 표시를 끈 종류의 소리가 계속 나는 동안에는 깨어나지 않는다. 다만 그 피크는 버리지 않고 약 1초 보관한다. 위협음만 보이게 + /// 해 두면 총성 같은 짧은 소리는 먼저 숨긴 라벨로 들어오고, 위협음 판정은 다음 분류와 추론 시간만큼 늦게 온다. + /// 위협음 판정이 오면 소리가 이미 끝났어도 보관한 크기로 깨어난다. + /// + /// 확인 시각(밀리초, 과 같은 시계). + /// 지난 확인 뒤로 들어온 버퍼들의 채널별 최고값. 버퍼가 없었으면 비운다. + /// 지금 설정과 AI 라벨. + public bool PollWake(double nowMs, ReadOnlySpan peaks, in OverlayInput input) + { + if (!IsIdle) return false; + + // 보관 시간은 실제로 흐른 시간만큼 흘린다. 확인 간격은 타이머 해상도와 앞당긴 확인 때문에 고르지 않다. + double frames = double.IsNaN(_lastHoldMs) ? 0 : Math.Max(0, nowMs - _lastHoldMs) / FrameStep.ReferenceFrameMs; + _lastHoldMs = nowMs; + _hold.Advance(frames); + + int soundMode = input.SoundMode; + float level = peaks.IsEmpty ? 0 : Loudness(peaks, soundMode); + bool loud = level > WakeThreshold; + // 조용하고 보관한 소리도 없으면 설정·라벨을 볼 필요가 없다. 쉬는 동안의 확인은 대부분 여기서 끝나고, + // 다음 확인은 소리가 날 때까지 미룰 수 있다(CanSleepUntilLoud). + _quietAtLastPoll = !loud && !_hold.HasData; + if (_quietAtLastPoll) return false; + + var settings = input.Settings; + if (!CanDraw(settings)) return false; + if (!input.Shown) + { + if (!peaks.IsEmpty) _hold.Keep(peaks, level, WakeThreshold); + return false; + } + + // 위협음 판정이 새로 왔으면 보관한 크기로 시작한다. 소리가 이미 끝났어도 깨어난다. + // 그 밖의 깨어남은 0 에서 민감도 속도로 자란다. + bool released = input.Label == SoundType.Danger && ReleaseHold(settings, soundMode); + if (!loud && !released) return false; + + if (!peaks.IsEmpty) peaks[..Channels].CopyTo(_targets); + Wake(); + return true; + } + + /// + /// 소리와 상관없이 깨운다(오버레이 편집을 시작할 때). 이미 깨어 있으면 아무것도 하지 않는다. + /// + public void Resume() + { + if (IsIdle) Wake(); + } + + private void Wake() + { + IsIdle = false; + _invisibleSinceMs = -1; + // 쉰 시간만큼 한꺼번에 진행하지 않게, 깨어난 첫 프레임은 한 프레임으로 센다. + _clock.Reset(); + } + + private void EnterIdle() + { + IsIdle = true; + _visible = false; + // 깨어나는 길은 조용하지 않은 확인뿐이다. 쉬기로 내려오기 직전에 보관한 소리가 있을 수 있으니 한 번은 확인하고 잠든다. + _quietAtLastPoll = false; + Array.Clear(_targets); + Array.Clear(_sources); + _smoothTotal = 0; + Array.Clear(_padThickness); + Array.Clear(_circleDepths); + _upmix.Clear(); + _clock.Reset(); + // 보관한 피크는 비우지 않는다. 쉬기로 내려간 뒤에 위협음 판정이 와도 쓸 수 있어야 한다. + } + + /// 진하기(0..1). 크기 고정이면 최대 진하기 × 소리 크기(2.5 에서 가득), 아니면 진하기 설정 그대로다. + public static double AlphaFor(VisualModeSettings s, double smoothTotal) + { + if (s.IntensityAsOpacity) + { + double maxOpacity = Math.Max(0.0, s.OpacityFixedMaxOpacity) / 100.0; + return maxOpacity * Math.Max(0.0, Math.Min(1.0, smoothTotal / 2.5)); + } + return Math.Max(0.0, s.VisualOpacity) / 100.0; + } + + private bool AnyDepthAbove(double threshold) + { + for (int i = 0; i < Positions; i++) + { + if (_depths[i] > threshold) return true; + } + return false; + } + + /// + /// 소리 크기와 라벨에 상관없이, 지금 설정으로 무언가 보일 수 있는지. 진하기나 크기가 0 이면 소리가 아무리 커도 + /// 그릴 것이 없다(Android canDraw). 라벨의 표시 여부는 따로 본다. + /// + public static bool CanDraw(VisualModeSettings s) + { + double potentialAlpha = (s.IntensityAsOpacity ? s.OpacityFixedMaxOpacity : s.VisualOpacity) / 100.0; + double size = s.IntensityAsOpacity ? s.OpacityFixedSize : s.Intensity; + return potentialAlpha > MinVisibleAlpha && size > 0; + } + + /// + /// 채널 피크 한 프레임의 크기: 지금 사운드 모드가 그리는 채널 가운데 가장 큰 값. 2.0 은 앞 좌우만 보고, + /// 5.1·7.1 은 자리가 없는 LFE 를 뺀다. 그리지 못할 채널만 울리는 소리로 깨어나거나 보관하지 않게 한다. + /// 캡처 스레드에서도 부른다(할당·상태 없음). + /// + public static float Loudness(ReadOnlySpan peaks, int soundMode) + { + int count = soundMode == 0 ? 2 : LFE; + float max = 0; + for (int i = 0; i < count; i++) + { + if (peaks[i] > max) max = peaks[i]; + } + return max; + } + + /// + /// 이번 프레임의 채널 목표값을 스무딩에 넣을 값()으로 펼치고 그 합을 돌려준다. + /// 2.0 은 앞 좌우를 둘레 여덟 자리로 펼치고 뒤쪽 자리는 조금 늦은 좌우로 그린다. 5.1·7.1 은 채널 그대로다. + /// + private float MapSources(double nowMs, int soundMode) + { + if (soundMode == 0) + { + _upmix.Push(nowMs, _targets[FL], _targets[FR], out float rearL, out float rearR); + return StereoUpmix.Spread(_targets[FL], _targets[FR], rearL, rearR, _sources); + } + return CopyChannels(_targets, _sources); + } + + /// + /// 보관한 프레임 하나를 스무딩에 넣을 값으로 펼치고 그 합을 돌려준다. 판정이 올 즈음엔 뒤쪽 지연도 따라잡았다고 보고 + /// 앞뒤에 같은 좌우를 넣는다(Android upmixTotal). + /// + private static float MapHeld(ReadOnlySpan frame, int soundMode, Span sources) + { + if (soundMode == 0) return StereoUpmix.Spread(frame[FL], frame[FR], frame[FL], frame[FR], sources); + return CopyChannels(frame, sources); + } + + private static float CopyChannels(ReadOnlySpan frame, Span sources) + { + frame[..Channels].CopyTo(sources); + float total = 0; + for (int i = 0; i < Channels; i++) total += sources[i]; + return total; + } + + /// + /// 펼친 값에서 가장 깊이 그려질 자리의 몫. 2.0 은 여덟 자리가 모두 그려지고, 5.1·7.1 은 LFE 를 뺀 채널이 그려진다 + /// (하단 가운데는 뒤 좌우의 평균이라 그 둘보다 깊지 않다). + /// + private static double MaxDrawnShare(ReadOnlySpan sources, double total, int soundMode) + { + int count = soundMode == 0 ? Positions : LFE; + double max = 0; + for (int i = 0; i < count; i++) + { + double share = sources[i] / total; + if (share > max) max = share; + } + return max; + } + + /// + /// 보관한 소리로 전체 크기와 방향을 바로 잡고 보관을 비운다(Android releaseHold). 보관한 것이 있었으면 true. + /// + /// 판정이 늦게 온 소리를 민감도 스무딩으로 0 에서 키우면 이미 끝난 뒤라 거의 보이지 않으므로 미리 키워 두고, + /// 줄어드는 것은 이후 프레임에서 평소처럼 민감도 속도를 따른다. 한 번 쓰면 비우므로 같은 소리로 두 번 번쩍이지 않는다. + /// + /// 크기는 그 종류를 표시 중이었다면 평소 경로가 그렸을 크기다. 보관한 피크를 그대로 전체 크기로 쓰면 소리가 1초 넘게 + /// 이어질 때의 크기라서, 짧은 총성마다 크기 한도까지 뛰고 같은 소리가 직전 라벨에 따라 열 배 넘게 다르게 그려진다. + /// + private bool ReleaseHold(VisualModeSettings s, int soundMode) + { + if (!_hold.HasData) return false; + + // 1) 오래된 칸부터 칸 하나(2프레임)씩 평소 스무딩에 다시 흘려, 도달했을 가장 큰 전체 크기를 구한다. + // 칸마다 가장 큰 피크만 남아 있으니 실제보다 조금 크게 나온다. 방향은 가장 큰 칸으로 잡는다. + double sf = FrameStep.Approach(TremorCoefficient(s), LateDangerHold.SlotFrames); + double ramp = 0, rampPeak = 0; + int loudest = -1; + float loudestLevel = 0; + for (int n = 0; n <= LateDangerHold.Slots; n++) + { + ramp += (MapHeld(_hold.Slot(n), soundMode, _heldSources) - ramp) * sf; + if (ramp > rampPeak) rampPeak = ramp; + float level = _hold.SlotLevel(n); + if (level > loudestLevel) + { + loudestLevel = level; + loudest = n; + } + } + + // 이 소리가 계속 날 때 다다르는 크기. _heldSources 에 방향이 남는다. + double full = loudest < 0 ? 0 : MapHeld(_hold.Slot(loudest), soundMode, _heldSources); + _hold.Clear(); + if (full <= 0) return true; + + double seed = rampPeak; + // 2) 그래도 가장 센 자리가 LateDangerMinDepth 에 못 미치면 거기까지 올린다. 판정까지 난 위협음이 안 보이면 안 된다. + // 크기 고정 모드는 전체 크기가 깊이가 아니라 진하기라서 올리지 않는다. + double depth = BaseDepthFor(s, _width, _height); + if (!s.IntensityAsOpacity && depth > 0) + { + double maxShare = MaxDrawnShare(_heldSources, full, soundMode); + if (maxShare > 0) seed = Math.Max(seed, LateDangerMinDepth / (depth * maxShare)); + } + // 같은 소리가 계속 날 때보다 크게 그리지는 않는다. + seed = Math.Min(seed, full); + + // 이미 더 크게 그리는 중이면(지금도 큰 소리가 이어짐) 건드리지 않는다. + if (seed > _smoothTotal) + { + _smoothTotal = seed; + for (int i = 0; i < Channels; i++) _dist[i] = _heldSources[i] / full; + } + return true; + } + + /// 민감도(떨림) → 60fps 한 프레임에 전체 크기가 소리를 따라붙는 비율. + internal static float TremorCoefficient(VisualModeSettings s) + { + float c = (float)(Math.Max(0.1, s.Sensitivity) / 100.0); + return c > 1.0f ? 1.0f : c; + } + + /// 위치 변화 속도 → 60fps 한 프레임에 방향 분포가 새 위치를 따라붙는 비율. + internal static float PositionCoefficient(VisualModeSettings s) + { + float c = (float)(Math.Max(0.1, s.PositionSpeed) / 100.0); + return c > 1.0f ? 1.0f : c; + } + + /// 크기 100% 가 화면 가운데 한계선에 닿는 깊이(DIP). 크기 고정 모드는 고정 크기의 절반을 쓴다. + public static double BaseDepthFor(VisualModeSettings s, double width, double height) + { + double maxBase = Math.Min(width, height) / 2.0 - EdgeMargin; + if (maxBase < EdgeMargin) maxBase = EdgeMargin; + double size = s.IntensityAsOpacity ? s.OpacityFixedSize / 2.0 : s.Intensity; + return maxBase * (Math.Max(0.0, size) / 100.0); + } + + private void FillDepths(bool sizeLocked, int soundMode) + { + double bd = _baseDepth; + double total = _smoothTotal; + if (soundMode == 0) + { + // 2.0: 분포가 이미 둘레 자리 순서다. + for (int p = 0; p < Positions; p++) _depths[p] = Depth(bd, sizeLocked, total, _dist[p]); + return; + } + + // 5.1·7.1: 채널마다 제자리에 그린다. 하단 가운데는 뒤 좌우의 가운데(팬텀), LFE 는 자리가 없다. + _depths[0] = Depth(bd, sizeLocked, total, _dist[FC]); + _depths[1] = Depth(bd, sizeLocked, total, _dist[FR]); + _depths[2] = Depth(bd, sizeLocked, total, _dist[SR]); + _depths[3] = Depth(bd, sizeLocked, total, _dist[BR]); + _depths[4] = sizeLocked + ? Math.Min(bd, bd * ((_dist[BL] + _dist[BR]) / 2.0 * 4.0)) + : Math.Min(bd, bd * ((total * _dist[BL] + total * _dist[BR]) / 2.0)); + _depths[5] = Depth(bd, sizeLocked, total, _dist[BL]); + _depths[6] = Depth(bd, sizeLocked, total, _dist[SL]); + _depths[7] = Depth(bd, sizeLocked, total, _dist[FL]); + } + + /// 한 자리의 깊이. 크기 고정이면 분포만으로(4배), 아니면 전체 크기 × 분포로 정하고 기준 깊이에서 자른다. + private static double Depth(double baseDepth, bool sizeLocked, double smoothTotal, double share) => + Math.Min(baseDepth, baseDepth * (sizeLocked ? share * 4.0 : smoothTotal * share)); + + /// 패드 두께를 따라붙이고, 보이는 막대가 있는지 돌려준다. + private bool StepPad(double step) + { + double lerp = FrameStep.Approach(PadLerp, step); + bool any = false; + for (int c = 0; c < Positions; c++) + { + double target = _depths[c] * PadDepthScale; + if (target > PadMaxThickness) target = PadMaxThickness; + _padThickness[c] += (target - _padThickness[c]) * lerp; + if (_padThickness[c] >= PadMinThickness) any = true; + } + return any; + } + + /// 원형 깊이를 따라붙이고, 보이게 부푼 자리가 있는지 돌려준다. + private bool StepCircle(double step) + { + double lerp = FrameStep.Approach(CircleLerp, step); + double cap = _height * CircleMaxHeightRatio; + bool any = false; + for (int i = 0; i < Positions; i++) + { + double target = _depths[i] * CircleDepthScale; + if (target > cap) target = cap; + _circleDepths[i] += (target - _circleDepths[i]) * lerp; + if (_circleDepths[i] > CircleMinDepth) any = true; + } + return any; + } + + /// 이번 프레임에 그릴 것이 보이는지: 라벨을 보이게 해 두었고, 진하기가 문턱을 넘고, 보이는 도형이 있다. + public bool Visible => _visible; + + /// 이번 프레임의 진하기(0..1). + public double Alpha => _alpha; + + /// 쉬는 중인지. 쉬는 동안은 대신 를 부른다. + public bool IsIdle { get; private set; } + + /// + /// 쉬는 동안 다음 확인을 큰 소리가 날 때까지 미뤄도 되는지(). 직전 확인이 조용했고 보관한 소리도 + /// 없을 때만 true 다. 표시를 꺼 둔 종류의 소리가 나는 동안이나, 늦게 올 위협음 판정을 위해 소리를 보관하는 동안에는 false 다. + /// + public bool CanSleepUntilLoud => IsIdle && _quietAtLastPoll; + + /// 스무딩한 전체 크기. 크기 고정 모드의 진하기도 이것으로 정한다. + public double SmoothTotal => _smoothTotal; + + /// 이번 프레임 채널 값의 합. + public float TotalTarget => _totalTarget; + + /// 크기 100% 의 깊이(DIP). + public double BaseDepth => _baseDepth; + + public double AnimationTime => _animationTime; + + /// 둘레 여덟 자리의 깊이(DIP). 파도·외곽선은 이것으로 그린다. + public ReadOnlySpan Depths => _depths; + + /// 채널별 방향 분포(캡처 순서, 합이 대체로 1). + public ReadOnlySpan Distribution => _dist; + + /// 둘레 여덟 자리의 패드 두께(DIP). 패드 모드에서만 움직인다. + public ReadOnlySpan PadThickness => _padThickness; + + /// 둘레 여덟 자리의 원형 깊이(DIP, 반지름에 더한다). 원형 모드에서만 움직인다. + public ReadOnlySpan CircleDepths => _circleDepths; + } +} diff --git a/SoundVisualizer.Core/Rendering/WaveShape.cs b/SoundVisualizer.Core/Rendering/WaveShape.cs new file mode 100644 index 0000000..bed9385 --- /dev/null +++ b/SoundVisualizer.Core/Rendering/WaveShape.cs @@ -0,0 +1,176 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Rendering +{ + /// + /// 파도·외곽선 모드의 안쪽 곡선이 지나는 점들(Android VisualizerEngine 의 buildWavePoints·roundedInnerPoint). + /// + /// 화면 둘레를 상단 가운데부터 시계 방향으로 고르게 번 짚고, 자리 여덟 곳의 깊이를 Catmull-Rom 으로 이어 + /// 그만큼 안쪽으로 들인 점을 만든다. 모서리는 그 모서리 깊이의 두 배를 반경으로 2차 베지어로 둥글린다. + /// 앱의 WaveVisualizer 와 OutlineVisualizer 가 같은 점으로 곡선을 그린다. 렌더 스레드에서만 쓴다. 할당은 없다. + /// + public sealed class WaveShape + { + /// 둘레를 짚는 점 수. + public const int Samples = 150; + + private const int Positions = 8; + private readonly double[] _positions = new double[Positions]; + + /// 점의 좌표(DIP). 가 채운다. + public double[] X { get; } = new double[Samples]; + public double[] Y { get; } = new double[Samples]; + + /// + /// 크기 × 화면에서 둘레 여덟 자리의 깊이 + /// (0:상단 가운데부터 시계 방향)로 점을 만든다. 깊이는 에서 자른다. + /// + public void Build(double w, double h, ReadOnlySpan depths, double maxDepth) + { + double P = 2 * (w + h); + + // 자리마다 둘레에서의 위치(0..1). 상단 가운데가 0 이다. + _positions[0] = 0; + _positions[1] = (w / 2.0) / P; + _positions[2] = (w / 2.0 + h / 2.0) / P; + _positions[3] = (w / 2.0 + h) / P; + _positions[4] = (w / 2.0 + h + w / 2.0) / P; + _positions[5] = (w / 2.0 + h + w) / P; + _positions[6] = (w / 2.0 + h + w + h / 2.0) / P; + _positions[7] = (w / 2.0 + h + w + h) / P; + + double d_tr = WaveDepth(_positions[1], depths, maxDepth); + double d_br = WaveDepth(_positions[3], depths, maxDepth); + double d_bl = WaveDepth(_positions[5], depths, maxDepth); + double d_tl = WaveDepth(_positions[7], depths, maxDepth); + + for (int i = 0; i < Samples; i++) + { + double dist = (P * i) / Samples; + double d = WaveDepth(dist / P, depths, maxDepth); + RoundedInnerPoint(dist, w, h, P, d, d_tr, d_br, d_bl, d_tl, out X[i], out Y[i]); + } + } + + /// + /// 둘레 거리 (상단 가운데부터 시계 방향)에서 깊이 만큼 들어온 점. 모서리 근처는 둥글린다. + /// + /// 둥글리는 반경이 0 인 모서리(깊이 0)는 건너뛴다(Android 의 r > 0 조건). 짚는 점이 모서리에 정확히 놓이면 예전에는 0/0 으로 + /// 점이 NaN 이 되어 도형 전체가 그려지지 않았다. 16:9 화면은 150 점 가운데 넷이 네 모서리에 정확히 놓여, 모서리 자리 하나만 + /// 조용해도(2.0 의 가운데 소리처럼) 파도와 외곽선이 통째로 사라졌다. + /// + private static void RoundedInnerPoint(double dist, double w, double h, double P, double d, + double d_tr, double d_br, double d_bl, double d_tl, out double x, out double y) + { + double c_tr = w / 2.0; + double c_br = w / 2.0 + h; + double c_bl = c_br + w; + double c_tl = c_bl + h; + + // 라운딩 반경 (파도 깊이에 비례하도록 설정하여 깊이가 0일 때는 깎이지 않도록 함) + double R_tr = Math.Min(d_tr * 2.0, Math.Min(w / 2.0, h / 2.0)); + double R_br = Math.Min(d_br * 2.0, Math.Min(w / 2.0, h / 2.0)); + double R_bl = Math.Min(d_bl * 2.0, Math.Min(w / 2.0, h / 2.0)); + double R_tl = Math.Min(d_tl * 2.0, Math.Min(w / 2.0, h / 2.0)); + + double distToTL = dist - c_tl; + if (dist < w / 2.0) distToTL = dist + P - c_tl; + + // 1) Top-Right 코너: 상단 가로선 끝 → 구석의 꺾임(제어점) → 우측 세로선 시작 + if (Math.Abs(dist - c_tr) <= R_tr && R_tr > 0) + { + double t = (dist - c_tr + R_tr) / (2 * R_tr); + QuadBezier(t, w - R_tr, d, w - d, d, w - d, R_tr, out x, out y); + return; + } + // 2) Bottom-Right 코너 + if (Math.Abs(dist - c_br) <= R_br && R_br > 0) + { + double t = (dist - c_br + R_br) / (2 * R_br); + QuadBezier(t, w - d, h - R_br, w - d, h - d, w - R_br, h - d, out x, out y); + return; + } + // 3) Bottom-Left 코너 + if (Math.Abs(dist - c_bl) <= R_bl && R_bl > 0) + { + double t = (dist - c_bl + R_bl) / (2 * R_bl); + QuadBezier(t, R_bl, h - d, d, h - d, d, h - R_bl, out x, out y); + return; + } + // 4) Top-Left 코너 + if (Math.Abs(distToTL) <= R_tl && R_tl > 0) + { + double t = (distToTL + R_tl) / (2 * R_tl); + QuadBezier(t, d, R_tl, d, d, R_tl, d, out x, out y); + return; + } + + // 모서리가 아닐 경우 (제한된 직선 구간) + EdgePosition(dist, w, h, P, out double ex, out double ey); + if (dist <= c_tr || dist > c_tl) { x = Math.Max(d, Math.Min(w - d, ex)); y = d; return; } // Top + if (dist <= c_br) { x = w - d; y = Math.Max(d, Math.Min(h - d, ey)); return; } // Right + if (dist <= c_bl) { x = Math.Max(d, Math.Min(w - d, ex)); y = h - d; return; } // Bottom + x = d; y = Math.Max(d, Math.Min(h - d, ey)); // Left + } + + /// 2차 베지어로 안쪽으로 부드럽게 감싼다. + private static void QuadBezier(double t, double p0x, double p0y, double p1x, double p1y, double p2x, double p2y, out double x, out double y) + { + x = (1 - t) * (1 - t) * p0x + 2 * (1 - t) * t * p1x + t * t * p2x; + y = (1 - t) * (1 - t) * p0y + 2 * (1 - t) * t * p1y + t * t * p2y; + } + + /// 둘레 거리(상단 가운데부터 시계 방향) → 화면 테두리 위의 점. + private static void EdgePosition(double dist, double w, double h, double P, out double x, out double y) + { + dist = ((dist % P) + P) % P; + if (dist <= w / 2) { x = w / 2 + dist; y = 0; return; } + if (dist <= w / 2 + h) { x = w; y = dist - w / 2; return; } + if (dist <= w / 2 + h + w) { x = w - (dist - (w / 2 + h)); y = h; return; } + if (dist <= w / 2 + h + w + h) { x = 0; y = h - (dist - (w / 2 + h + w)); return; } + x = dist - (w / 2 + h + w + h); + y = 0; + } + + private double WaveDepth(double t, ReadOnlySpan depths, double maxDepth) => + Math.Min(maxDepth, Math.Max(0, InterpolateDepthCatmullRom(t, depths))); + + /// 둘레 위치 t(0..1)의 깊이. 자리 여덟 곳의 깊이를 지나는 Catmull-Rom 곡선이다. + private double InterpolateDepthCatmullRom(double t, ReadOnlySpan depths) + { + t = ((t % 1.0) + 1.0) % 1.0; + int n = Positions; + + int idx1 = 0; + for (int i = 0; i < n; i++) + { + if (_positions[i] <= t) idx1 = i; + } + int idx0 = (idx1 - 1 + n) % n; + int idx2 = (idx1 + 1) % n; + int idx3 = (idx1 + 2) % n; + + double p1 = _positions[idx1]; + double p2 = _positions[idx2]; + if (p2 <= p1) p2 += 1.0; + + double at = t; + if (at < p1) at += 1.0; + + double segLen = p2 - p1; + double lt = (segLen > 0) ? (at - p1) / segLen : 0; + lt = Math.Max(0, Math.Min(1, lt)); + + double d0 = depths[idx0], d1 = depths[idx1]; + double d2 = depths[idx2], d3 = depths[idx3]; + + double a = -0.5 * d0 + 1.5 * d1 - 1.5 * d2 + 0.5 * d3; + double b = d0 - 2.5 * d1 + 2.0 * d2 - 0.5 * d3; + double c = -0.5 * d0 + 0.5 * d2; + double dv = d1; + + return Math.Max(0, a * lt * lt * lt + b * lt * lt + c * lt + dv); + } + } +} diff --git a/SoundVisualizer.Core/SoundVisualizer.Core.csproj b/SoundVisualizer.Core/SoundVisualizer.Core.csproj new file mode 100644 index 0000000..aeeb1da --- /dev/null +++ b/SoundVisualizer.Core/SoundVisualizer.Core.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/ar.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/ar.json new file mode 100644 index 0000000..77edde9 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/ar.json @@ -0,0 +1,17 @@ +{ + "horns": "أبواق السيارات وإنذاراتها", + "vehicle_warnings": "تنبيهات المركبات", + "breaking": "تحطّم الزجاج والانكسار", + "talking": "التحدث والهمس", + "distress": "الصياح والصراخ والبكاء", + "signals": "المنبّهات والصفير والصفارات", + "voices": "التصفيق والدندنة والصفير بالفم", + "water": "الصنابير والماء الجاري", + "kitchen": "المطبخ والطهي", + "appliances": "الأجهزة المنزلية والساعات", + "tools": "الأدوات والآلات", + "nature": "الطبيعة والطقس", + "body": "السعال والتنفّس وأصوات الجسم", + "objects": "الأشياء والأصوات القصيرة", + "background": "التلفزيون والراديو وأصوات الخلفية" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/de.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/de.json new file mode 100644 index 0000000..9c9e333 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/de.json @@ -0,0 +1,17 @@ +{ + "horns": "Hupen und Autoalarme", + "vehicle_warnings": "Warnsignale von Fahrzeugen", + "breaking": "Glasbruch und Krachen", + "talking": "Sprechen und Flüstern", + "distress": "Rufen, Schreien und Weinen", + "signals": "Wecker, Pieptöne und Trillerpfeifen", + "voices": "Klatschen, Summen und Pfeifen", + "water": "Wasserhähne und fließendes Wasser", + "kitchen": "Küche und Kochen", + "appliances": "Haushaltsgeräte und Uhren", + "tools": "Werkzeuge und Maschinen", + "nature": "Natur und Wetter", + "body": "Husten, Atmen und Körpergeräusche", + "objects": "Gegenstände und kurze Geräusche", + "background": "Fernseher, Radio und Hintergrundgeräusche" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/en.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/en.json new file mode 100644 index 0000000..174a886 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/en.json @@ -0,0 +1,17 @@ +{ + "horns": "Car horns and car alarms", + "vehicle_warnings": "Vehicle warnings", + "breaking": "Breaking glass and crashes", + "talking": "Talking and whispering", + "distress": "Shouting, screaming and crying", + "signals": "Alarm clocks, beeps and whistles", + "voices": "Clapping, humming and whistling", + "water": "Taps and running water", + "kitchen": "Kitchen and cooking", + "appliances": "Home appliances and clocks", + "tools": "Tools and machines", + "nature": "Nature and weather", + "body": "Coughs, breathing and body sounds", + "objects": "Objects and short sounds", + "background": "TV, radio and background sound" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/es.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/es.json new file mode 100644 index 0000000..a1f467a --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/es.json @@ -0,0 +1,17 @@ +{ + "horns": "Bocinas y alarmas de automóvil", + "vehicle_warnings": "Avisos de vehículos", + "breaking": "Vidrios rotos y estampidos", + "talking": "Habla y susurros", + "distress": "Gritos y llanto", + "signals": "Despertadores, pitidos y silbatos", + "voices": "Aplausos, tarareo y silbidos", + "water": "Grifos y agua corriente", + "kitchen": "Cocina y comida", + "appliances": "Electrodomésticos y relojes", + "tools": "Herramientas y máquinas", + "nature": "Naturaleza y clima", + "body": "Tos, respiración y sonidos del cuerpo", + "objects": "Objetos y sonidos breves", + "background": "TV, radio y sonido de fondo" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/fr.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/fr.json new file mode 100644 index 0000000..56410ca --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/fr.json @@ -0,0 +1,17 @@ +{ + "horns": "Klaxons et alarmes de voiture", + "vehicle_warnings": "Signaux d'alerte des véhicules", + "breaking": "Bris de verre et fracas", + "talking": "Parole et chuchotements", + "distress": "Cris et pleurs", + "signals": "Réveils, bips et sifflets", + "voices": "Applaudissements, fredonnements et sifflements", + "water": "Robinets et eau qui coule", + "kitchen": "Cuisine et cuisson", + "appliances": "Électroménager et horloges", + "tools": "Outils et machines", + "nature": "Nature et météo", + "body": "Toux, respiration et bruits du corps", + "objects": "Objets et sons brefs", + "background": "Télé, radio et fond sonore" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/hi.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/hi.json new file mode 100644 index 0000000..ad6fcef --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/hi.json @@ -0,0 +1,17 @@ +{ + "horns": "हॉर्न और कार के अलार्म", + "vehicle_warnings": "वाहनों के चेतावनी संकेत", + "breaking": "कांच का टूटना और धड़ाम की आवाज़ें", + "talking": "बोलना और फुसफुसाना", + "distress": "चिल्लाना, चीखना और रोना", + "signals": "अलार्म घड़ी, बीप और सीटी", + "voices": "ताली बजाना, गुनगुनाना और सीटी बजाना", + "water": "नल और बहता पानी", + "kitchen": "रसोई और खाना पकाना", + "appliances": "घरेलू उपकरण और घड़ियां", + "tools": "औज़ार और मशीनें", + "nature": "प्रकृति और मौसम", + "body": "खांसी, सांस और शरीर की आवाज़ें", + "objects": "चीज़ें और छोटी आवाज़ें", + "background": "टीवी, रेडियो और पृष्ठभूमि की आवाज़" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/id.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/id.json new file mode 100644 index 0000000..80f2910 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/id.json @@ -0,0 +1,17 @@ +{ + "horns": "Klakson dan alarm mobil", + "vehicle_warnings": "Peringatan kendaraan", + "breaking": "Kaca pecah dan benturan", + "talking": "Obrolan dan bisikan", + "distress": "Teriakan, jeritan, dan tangisan", + "signals": "Jam weker, bip, dan peluit", + "voices": "Tepuk tangan, senandung, dan siulan", + "water": "Keran dan air mengalir", + "kitchen": "Dapur dan memasak", + "appliances": "Peralatan rumah tangga dan jam", + "tools": "Perkakas dan mesin", + "nature": "Alam dan cuaca", + "body": "Batuk, napas, dan suara tubuh", + "objects": "Benda dan suara singkat", + "background": "TV, radio, dan bising sekitar" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/it.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/it.json new file mode 100644 index 0000000..fdf6f6a --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/it.json @@ -0,0 +1,17 @@ +{ + "horns": "Clacson e allarmi dell'auto", + "vehicle_warnings": "Segnali di avviso dei veicoli", + "breaking": "Vetri rotti e schianti", + "talking": "Parlato e sussurri", + "distress": "Grida, strilli e pianto", + "signals": "Sveglie, bip e fischietti", + "voices": "Applausi, canticchiare e fischiettare", + "water": "Rubinetti e acqua corrente", + "kitchen": "Cucina e cottura", + "appliances": "Elettrodomestici e orologi", + "tools": "Attrezzi e macchinari", + "nature": "Natura e meteo", + "body": "Tosse, respiro e suoni del corpo", + "objects": "Oggetti e suoni brevi", + "background": "TV, radio e suoni di sottofondo" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/ja.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/ja.json new file mode 100644 index 0000000..63ccc9c --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/ja.json @@ -0,0 +1,17 @@ +{ + "horns": "クラクション・車の警報音", + "vehicle_warnings": "乗り物の警告音", + "breaking": "ガラスが割れる音・物が壊れる音", + "talking": "会話・ささやき声", + "distress": "大声・悲鳴・泣き声", + "signals": "目覚まし時計・電子音・ホイッスル", + "voices": "拍手・鼻歌・口笛", + "water": "蛇口・流れる水", + "kitchen": "台所・料理", + "appliances": "家電・時計", + "tools": "工具・機械", + "nature": "自然・天気", + "body": "せき・呼吸・体の音", + "objects": "物の音・短い音", + "background": "テレビ・ラジオ・背景音" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/ko.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/ko.json new file mode 100644 index 0000000..42049b9 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/ko.json @@ -0,0 +1,17 @@ +{ + "horns": "경적·자동차 경보음", + "vehicle_warnings": "차량 경고음", + "breaking": "유리 깨짐·충돌", + "talking": "말소리·속삭임", + "distress": "외침·비명·울음", + "signals": "알람·삐 소리·호루라기", + "voices": "박수·콧노래·휘파람", + "water": "수돗물·물 흐르는 소리", + "kitchen": "부엌·요리", + "appliances": "가전제품·시계", + "tools": "공구·기계", + "nature": "자연·날씨", + "body": "기침·숨소리·몸 소리", + "objects": "물건 소리·짧은 소리", + "background": "TV·라디오·배경 소리" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/pl.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/pl.json new file mode 100644 index 0000000..336bc94 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/pl.json @@ -0,0 +1,17 @@ +{ + "horns": "Klaksony i alarmy samochodowe", + "vehicle_warnings": "Sygnały ostrzegawcze pojazdów", + "breaking": "Tłuczenie szkła i łoskot", + "talking": "Mówienie i szept", + "distress": "Krzyki i płacz", + "signals": "Budziki, piknięcia i gwizdki", + "voices": "Oklaski, nucenie i gwizdanie", + "water": "Krany i płynąca woda", + "kitchen": "Kuchnia i gotowanie", + "appliances": "Sprzęty domowe i zegary", + "tools": "Narzędzia i maszyny", + "nature": "Przyroda i pogoda", + "body": "Kaszel, oddech i odgłosy ciała", + "objects": "Przedmioty i krótkie dźwięki", + "background": "Telewizor, radio i dźwięki w tle" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/pt-BR.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/pt-BR.json new file mode 100644 index 0000000..cf7a445 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/pt-BR.json @@ -0,0 +1,17 @@ +{ + "horns": "Buzinas e alarmes de carro", + "vehicle_warnings": "Sinais de alerta de veículos", + "breaking": "Vidro quebrado e pancadas", + "talking": "Fala e sussurros", + "distress": "Gritos e choro", + "signals": "Despertadores, bipes e apitos", + "voices": "Aplausos, cantarolar e assobios", + "water": "Torneiras e água corrente", + "kitchen": "Cozinha e comida", + "appliances": "Eletrodomésticos e relógios", + "tools": "Ferramentas e máquinas", + "nature": "Natureza e clima", + "body": "Tosse, respiração e sons do corpo", + "objects": "Objetos e sons curtos", + "background": "TV, rádio e som de fundo" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/ru.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/ru.json new file mode 100644 index 0000000..42ff960 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/ru.json @@ -0,0 +1,17 @@ +{ + "horns": "Гудки и автосигнализации", + "vehicle_warnings": "Предупреждающие сигналы транспорта", + "breaking": "Бьющееся стекло и громкие удары", + "talking": "Разговоры и шёпот", + "distress": "Крики и плач", + "signals": "Будильники, пиканье и свистки", + "voices": "Аплодисменты, напевание и свист", + "water": "Краны и текущая вода", + "kitchen": "Кухня и готовка", + "appliances": "Бытовая техника и часы", + "tools": "Инструменты и механизмы", + "nature": "Природа и погода", + "body": "Кашель, дыхание и звуки тела", + "objects": "Предметы и короткие звуки", + "background": "Телевизор, радио и шумы" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/th.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/th.json new file mode 100644 index 0000000..77c6aba --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/th.json @@ -0,0 +1,17 @@ +{ + "horns": "แตรรถและสัญญาณกันขโมยรถ", + "vehicle_warnings": "เสียงเตือนจากยานพาหนะ", + "breaking": "กระจกแตกและเสียงพังโครม", + "talking": "การพูดคุยและเสียงกระซิบ", + "distress": "เสียงตะโกน กรีดร้อง และร้องไห้", + "signals": "นาฬิกาปลุก เสียงบี๊บ และนกหวีด", + "voices": "เสียงปรบมือ ฮัมเพลง และผิวปาก", + "water": "ก๊อกน้ำและน้ำไหล", + "kitchen": "ห้องครัวและการทำอาหาร", + "appliances": "เครื่องใช้ไฟฟ้าในบ้านและนาฬิกา", + "tools": "เครื่องมือช่างและเครื่องจักร", + "nature": "ธรรมชาติและสภาพอากาศ", + "body": "เสียงไอ หายใจ และเสียงจากร่างกาย", + "objects": "สิ่งของและเสียงสั้นๆ", + "background": "ทีวี วิทยุ และเสียงพื้นหลัง" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/tr.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/tr.json new file mode 100644 index 0000000..1e98628 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/tr.json @@ -0,0 +1,17 @@ +{ + "horns": "Kornalar ve araba alarmları", + "vehicle_warnings": "Araç uyarı sesleri", + "breaking": "Cam kırılması ve parçalanma", + "talking": "Sohbet ve fısıltı", + "distress": "Bağırma, çığlık ve ağlama", + "signals": "Çalar saatler, bip sesleri ve düdükler", + "voices": "Alkış, mırıldanma ve ıslık", + "water": "Musluklar ve akan su", + "kitchen": "Mutfak ve yemek pişirme", + "appliances": "Ev aletleri ve saatler", + "tools": "Aletler ve makineler", + "nature": "Doğa ve hava durumu", + "body": "Öksürük, nefes ve vücut sesleri", + "objects": "Eşyalar ve kısa sesler", + "background": "TV, radyo ve arka plan sesi" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/vi.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/vi.json new file mode 100644 index 0000000..3cc8bf8 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/vi.json @@ -0,0 +1,17 @@ +{ + "horns": "Còi xe và báo động ô tô", + "vehicle_warnings": "Tín hiệu cảnh báo của xe cộ", + "breaking": "Kính vỡ và tiếng đổ vỡ", + "talking": "Nói chuyện và thì thầm", + "distress": "Tiếng kêu, tiếng thét và tiếng khóc", + "signals": "Đồng hồ báo thức, tiếng bíp và tiếng còi", + "voices": "Vỗ tay, ngân nga và huýt sáo", + "water": "Vòi nước và nước chảy", + "kitchen": "Nhà bếp và nấu ăn", + "appliances": "Đồ gia dụng và đồng hồ", + "tools": "Dụng cụ và máy móc", + "nature": "Thiên nhiên và thời tiết", + "body": "Ho, hơi thở và âm thanh cơ thể", + "objects": "Đồ vật và âm thanh ngắn", + "background": "Tivi, radio và âm thanh nền" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/zh-Hans.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/zh-Hans.json new file mode 100644 index 0000000..5a68a5e --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "horns": "汽车喇叭与汽车警报", + "vehicle_warnings": "车辆警示音", + "breaking": "玻璃破碎与撞击声", + "talking": "说话声与耳语", + "distress": "喊叫、尖叫与哭声", + "signals": "闹钟、哔哔声与哨声", + "voices": "拍手、哼歌与吹口哨", + "water": "水龙头与流水", + "kitchen": "厨房与烹饪", + "appliances": "家电与钟表", + "tools": "工具与机器", + "nature": "自然与天气", + "body": "咳嗽、呼吸与身体声音", + "objects": "物品与短促声音", + "background": "电视、收音机与背景声" +} diff --git a/SoundVisualizer.Core/Sounds/Data/GroupTitles/zh-Hant.json b/SoundVisualizer.Core/Sounds/Data/GroupTitles/zh-Hant.json new file mode 100644 index 0000000..c25ea74 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/GroupTitles/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "horns": "汽車喇叭與防盜警報", + "vehicle_warnings": "車輛警示音", + "breaking": "玻璃碎裂與撞擊聲", + "talking": "說話聲與悄悄話", + "distress": "大喊、尖叫與哭泣聲", + "signals": "鬧鐘、嗶聲與哨子聲", + "voices": "拍手聲、哼歌與吹口哨", + "water": "水龍頭與流水", + "kitchen": "廚房與烹飪", + "appliances": "家電與時鐘", + "tools": "工具與機器", + "nature": "自然與天氣", + "body": "咳嗽、呼吸與身體聲音", + "objects": "物品與短促聲音", + "background": "電視、收音機與背景聲" +} diff --git a/SoundVisualizer.Core/Sounds/Data/NOTICE.txt b/SoundVisualizer.Core/Sounds/Data/NOTICE.txt new file mode 100644 index 0000000..9d4f64b --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/NOTICE.txt @@ -0,0 +1,34 @@ +================================================================================ +AudioSet 온톨로지 (분류 탭의 소리 묶음·소리 이름 번역) +================================================================================ +SoundVisualizer.Core/Sounds/Data/sound_groups.tsv +SoundVisualizer.Core/Sounds/Data/SoundNames/*.json + +분류 탭의 소리 묶음 표(sound_groups.tsv)와 소리 이름(SoundNames/*.json)은 +AudioSet 온톨로지를 바탕으로 만든 각색물입니다. 소리 이름은 온톨로지의 +클래스 이름과 그 번역이고, 묶음은 온톨로지의 갈래를 읽고 사람이 정했습니다. +소리 이름을 그대로 쓴 묶음 이름(사이렌, 초인종 등)도 여기에 해당합니다. +이 자료는 원본과 같은 CC BY-SA 4.0 으로 배포하며, 표 파일 맨 위에도 같은 +고지를 적어 두었습니다. JSON 은 주석을 달 수 없어 이 파일로 고지합니다. +앱의 코드와 새로 지은 묶음 이름(GroupTitles/*.json)은 이 저장소의 라이선스 +(GNU AGPL v3)를 따릅니다. + +"AudioSet Ontology" by Google Inc. +https://github.com/audioset/ontology (commit d417d32) +Licensed under the Creative Commons Attribution-ShareAlike 4.0 +International License (CC BY-SA 4.0). +https://creativecommons.org/licenses/by-sa/4.0/ + +Changes: the 521 YAMNet sound names were regrouped into everyday groups by +SoundVisualizer contributors (sound_groups.tsv), and the class names were +translated into the app's languages (SoundNames/*.json). + +이 자료는 있는 그대로 제공되며 어떠한 보증도 하지 않습니다. 자세한 내용은 +CC BY-SA 4.0 의 5조(보증의 부인과 책임의 제한)를 따릅니다. + +-------------------------------------------------------------------------------- +모바일 판(app/src/main/assets/sound_groups.tsv, app/src/main/res/values*/ +sound_names.xml, strings.xml 의 sound_group_* 문구)에서 옮겼습니다. 표는 글자 +그대로이고, 이름은 안드로이드 이스케이프(\' 와 &)를 푼 뒤 언어마다 YAMNet +번호 순서의 배열로 적었습니다. 언어 코드는 PC 판의 것입니다(values-in → id, +values-zh-rCN → zh-Hans, values-zh-rTW → zh-Hant, values-pt-rBR → pt-BR). diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/ar.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/ar.json new file mode 100644 index 0000000..1a73fcd --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/ar.json @@ -0,0 +1,523 @@ +[ + "كلام", + "كلام طفل", + "محادثة", + "سرد أو حديث فردي", + "مناغاة", + "كلام اصطناعي", + "صياح", + "صيحة مدوية", + "صيحة فرح", + "زعيق", + "صياح الأطفال", + "صراخ", + "همس", + "ضحك", + "ضحك رضيع", + "ضحكة خفيفة", + "ضحكة ساخرة", + "قهقهة", + "ضحكة مكتومة", + "بكاء", + "بكاء رضيع", + "بكاء خافت", + "أنين", + "تنهّد", + "غناء", + "جوقة", + "غناء اليودل", + "ترتيل", + "ترتيل مانترا", + "غناء طفل", + "غناء اصطناعي", + "غناء الراب", + "دندنة", + "تأوّه", + "نخير", + "صفير بالفم", + "تنفّس", + "أزيز الصدر", + "شخير", + "شهقة", + "لهاث", + "نفخة من الأنف", + "سعال", + "تنحنح", + "عطس", + "استنشاق بالأنف", + "ركض", + "جرّ القدمين", + "وقع الأقدام", + "مضغ", + "عضّ", + "غرغرة", + "قرقرة البطن", + "تجشّؤ", + "فواق (حازوقة)", + "إطلاق الريح", + "أصوات اليدين", + "فرقعة الأصابع", + "تصفيق", + "دقات القلب", + "نفخة قلبية", + "هتاف", + "تصفيق الجمهور", + "ثرثرة", + "حشد من الناس", + "ضجيج كلام متداخل", + "أطفال يلعبون", + "حيوانات", + "حيوانات أليفة", + "كلب", + "نباح", + "نباح كلب صغير", + "عواء", + "نباح ممدود", + "زمجرة", + "أنين الكلب", + "قطة", + "خرخرة", + "مواء", + "هسهسة", + "مواء التزاوج", + "حيوانات المزرعة", + "حصان", + "وقع الحوافر", + "صهيل", + "أبقار", + "خوار", + "جرس الأبقار", + "خنزير", + "نخير الخنزير", + "ماعز", + "ثغاء", + "خروف", + "دواجن", + "دجاج", + "قوقأة الدجاج", + "صياح الديك", + "ديك رومي", + "قرقرة الديك الرومي", + "بط", + "بطبطة", + "إوز", + "صياح الإوز", + "حيوانات برية", + "أسود ونمور", + "زئير", + "طيور", + "تغريد الطيور", + "زقزقة", + "صياح الطيور", + "حمام ويمام", + "هديل", + "غراب", + "نعيق الغراب", + "بومة", + "نعيق البوم", + "رفرفة الأجنحة", + "ذئاب وكلاب برية", + "قوارض", + "فأر", + "دبيب", + "حشرات", + "صرصور الليل", + "بعوضة", + "ذبابة", + "طنين", + "نحل ودبابير", + "ضفدع", + "نقيق", + "أفعى", + "خشخشة", + "أصوات الحيتان", + "موسيقى", + "آلة موسيقية", + "آلة وترية بالنقر", + "غيتار", + "غيتار كهربائي", + "غيتار باص", + "غيتار أكوستيك", + "غيتار سلايد", + "تقنية التابينغ (غيتار)", + "ضرب أوتار الغيتار", + "بانجو", + "سيتار", + "ماندولين", + "آلة القانون", + "يوكوليلي", + "لوحة مفاتيح موسيقية", + "بيانو", + "بيانو كهربائي", + "أرغن", + "أرغن إلكتروني", + "أرغن هاموند", + "سينثسايزر", + "سامبلر", + "هاربسيكورد", + "آلة إيقاع", + "طقم طبول", + "آلة طبول إلكترونية", + "طبل", + "طبل السنير", + "ضربة على حافة الطبل", + "قرع طبل متواصل", + "طبل جهير", + "طبول التيمباني", + "تابلا (طبلة هندية)", + "صنج", + "صنج هاي هات", + "قالب خشبي إيقاعي", + "دفّ", + "خشخيشة", + "ماراكاس", + "غونغ", + "أجراس أنبوبية", + "آلة إيقاع لحنية", + "زيلوفون", + "زيلوفون معدني", + "فيبرافون", + "طبل فولاذي", + "أوركسترا", + "آلة نفخ نحاسية", + "بوق فرنسي", + "ترومبيت", + "ترومبون", + "آلة وترية بالقوس", + "مجموعة الوتريات", + "كمان", + "نقر أوتار الكمان", + "تشيلو", + "كونترباص", + "آلة نفخ", + "فلوت", + "ساكسفون", + "كلارينيت", + "قيثارة (هارب)", + "جرس", + "جرس الكنيسة", + "جلاجل (أجراس صغيرة)", + "جرس الدراجة", + "شوكة رنانة", + "رنين أجراس", + "أجراس الرياح", + "فن قرع الأجراس", + "هارمونيكا", + "أكورديون", + "مزمار القربة", + "ديدجيريدو", + "بوق الشوفار", + "ثيرمين", + "وعاء الغناء التبتي", + "سكراتش (دي جي)", + "موسيقى البوب", + "موسيقى الهيب هوب", + "بيت بوكس (إيقاع بالفم)", + "موسيقى الروك", + "موسيقى الهيفي ميتال", + "موسيقى البانك روك", + "موسيقى الغرانج", + "موسيقى الروك التقدمي", + "موسيقى الروك أند رول", + "موسيقى الروك السايكيديلي", + "موسيقى الآر أند بي", + "موسيقى السول", + "موسيقى الريغي", + "موسيقى الكانتري", + "موسيقى السوينغ", + "موسيقى البلوغراس", + "موسيقى الفانك", + "موسيقى شعبية (فولك)", + "موسيقى الشرق الأوسط", + "موسيقى الجاز", + "موسيقى الديسكو", + "موسيقى كلاسيكية", + "أوبرا", + "موسيقى إلكترونية", + "موسيقى الهاوس", + "موسيقى التكنو", + "موسيقى الدبستب", + "موسيقى الدرام أند بيس", + "موسيقى الإلكترونيكا", + "موسيقى رقص إلكترونية", + "موسيقى الأمبينت", + "موسيقى الترانس", + "موسيقى أمريكا اللاتينية", + "موسيقى السالسا", + "موسيقى الفلامنكو", + "موسيقى البلوز", + "موسيقى للأطفال", + "موسيقى العصر الجديد", + "موسيقى غنائية", + "غناء بلا آلات (أكابيلا)", + "موسيقى أفريقية", + "موسيقى الأفروبيت", + "موسيقى مسيحية", + "موسيقى الغوسبل", + "موسيقى آسيوية", + "موسيقى كارناتيك (هندية)", + "موسيقى بوليوود", + "موسيقى السكا", + "موسيقى تقليدية", + "موسيقى مستقلة (إندي)", + "أغنية", + "موسيقى خلفية", + "موسيقى الشارة", + "لحن إعلاني", + "موسيقى تصويرية", + "تهويدة", + "موسيقى ألعاب الفيديو", + "موسيقى عيد الميلاد المجيد", + "موسيقى رقص", + "موسيقى الأعراس", + "موسيقى مبهجة", + "موسيقى حزينة", + "موسيقى رقيقة", + "موسيقى حماسية", + "موسيقى غاضبة", + "موسيقى مخيفة", + "رياح", + "حفيف الأوراق", + "ضجيج الرياح (ميكروفون)", + "عاصفة رعدية", + "رعد", + "ماء", + "مطر", + "قطرة مطر", + "مطر على سطح", + "جدول ماء", + "شلال", + "بحر", + "أمواج", + "بخار", + "بقبقة", + "نار", + "طقطقة", + "مركبة", + "قارب", + "قارب شراعي", + "قارب تجديف", + "قارب بمحرك", + "سفينة", + "مركبة آلية", + "سيارة", + "بوق السيارة", + "نفخة بوق قصيرة", + "إنذار السيارة", + "زجاج السيارة الكهربائي", + "انزلاق السيارة", + "صرير الإطارات", + "مرور سيارة", + "سباق سيارات", + "شاحنة", + "مكابح هوائية", + "بوق الشاحنة", + "صفير الرجوع للخلف", + "عربة الآيس كريم", + "حافلة", + "مركبة طوارئ", + "سيارة شرطة (صفارة إنذار)", + "سيارة إسعاف (صفارة إنذار)", + "سيارة إطفاء (صفارة إنذار)", + "دراجة نارية", + "ضجيج حركة المرور", + "السكك الحديدية", + "قطار", + "صفارة القطار", + "بوق القطار", + "عربة قطار", + "صرير عجلات القطار", + "مترو الأنفاق", + "مركبة جوية", + "محرك طائرة", + "محرك نفاث", + "مروحة طائرة", + "مروحية", + "طائرة", + "دراجة هوائية", + "لوح تزلج", + "محرك", + "محرك خفيف (تردد مرتفع)", + "مثقاب الأسنان", + "جزازة العشب", + "منشار سلسلي", + "محرك متوسط (تردد متوسط)", + "محرك ثقيل (تردد منخفض)", + "طرق المحرك", + "بدء تشغيل المحرك", + "محرك دائر دون حركة", + "تسارع المحرك", + "باب", + "جرس الباب", + "رنين جرس الباب", + "باب منزلق", + "إغلاق الباب بقوة", + "طرق الباب", + "نقر خفيف", + "صرير قصير", + "فتح الخزانة وإغلاقها", + "فتح الدرج وإغلاقه", + "أطباق وقدور", + "أدوات المائدة", + "تقطيع الطعام", + "قلي الطعام", + "فرن ميكروويف", + "خلاط كهربائي", + "صنبور الماء", + "ماء في حوض الغسيل", + "ماء في حوض الاستحمام", + "مجفف الشعر", + "سيفون المرحاض", + "فرشاة أسنان", + "فرشاة أسنان كهربائية", + "مكنسة كهربائية", + "سحّاب الملابس", + "خشخشة المفاتيح", + "سقوط عملة معدنية", + "مقص", + "آلة حلاقة كهربائية", + "خلط أوراق اللعب", + "كتابة على المفاتيح", + "آلة كاتبة", + "لوحة مفاتيح الكمبيوتر", + "كتابة بالقلم", + "إنذار", + "هاتف", + "رنين جرس الهاتف", + "نغمة رنين", + "نغمات طلب الرقم", + "نغمة الخط", + "نغمة انشغال الخط", + "منبّه", + "صفارة إنذار", + "صفارة الدفاع المدني", + "جرس طنّان", + "كاشف الدخان", + "إنذار الحريق", + "بوق الضباب", + "صفارة", + "صفارة بخارية", + "آليات ميكانيكية", + "سقّاطة ميكانيكية", + "ساعة", + "تكّة معدنية", + "تكتكة الساعة", + "تروس", + "بكرات", + "آلة خياطة", + "مروحة", + "مكيّف هواء", + "آلة تسجيل النقد", + "طابعة", + "كاميرا", + "كاميرا عاكسة (SLR)", + "أدوات", + "مطرقة", + "مطرقة هوائية", + "نشر بالمنشار", + "حكّ بالمبرد", + "صنفرة", + "أداة كهربائية", + "مثقاب", + "انفجار", + "طلق ناري", + "مدفع رشاش", + "وابل من الرصاص", + "قصف مدفعي", + "مسدس ألعاب", + "ألعاب نارية", + "مفرقعات", + "فرقعة", + "ثوران بركاني", + "دويّ", + "خشب", + "تقطيع بالفأس", + "تشظّي الخشب", + "تشقّق", + "زجاج", + "رنين الكؤوس", + "تحطّم الزجاج", + "سائل", + "تناثر سائل", + "خضخضة سائل", + "عصر شيء مبلل", + "قطرات متساقطة", + "سكب سائل", + "سيلان خفيف", + "تدفق قوي", + "ملء وعاء بسائل", + "رشّ بالرذاذ", + "ضخّ سائل", + "تقليب سائل", + "غليان", + "سونار", + "سهم", + "شقّ الهواء", + "ارتطام مكتوم", + "ارتطام أجوف", + "جهاز دوزنة إلكتروني", + "وحدة مؤثرات صوتية", + "مؤثر الكورس", + "ارتداد كرة السلة", + "صوت قوي مفاجئ", + "صفعة", + "ضربة قوية", + "تحطّم", + "انكسار", + "ارتداد", + "فرقعة سوط", + "رفرفة", + "خدش", + "كشط", + "فرك", + "تدحرج", + "سحق", + "تجعيد الورق", + "تمزيق", + "صفير إلكتروني قصير", + "رنين حاد", + "رنّة معدنية", + "قرقعة معدنية", + "صرير حاد", + "صرير", + "حفيف", + "أزيز", + "قعقعة", + "أزيز الزيت الساخن", + "نقرات", + "طقطقة إيقاعية", + "هدير", + "سقوط شيء في الماء", + "جلجلة", + "طنين متواصل", + "طنين حاد قصير", + "صوت زنبركي (بوينغ)", + "قرمشة", + "صمت", + "نغمة نقية (موجة جيبية)", + "نغمة توافقية", + "نغمة صاعدة أو هابطة", + "مؤثر صوتي", + "نبضات صوتية منتظمة", + "في الداخل (غرفة صغيرة)", + "في الداخل (قاعة كبيرة)", + "في الداخل (مكان عام)", + "في الخارج (بيئة حضرية)", + "في الخارج (بيئة طبيعية)", + "صدى المكان", + "صدى", + "ضوضاء", + "ضوضاء بيئية", + "تشويش كهربائي", + "طنين الكهرباء", + "تشوّه الصوت", + "ارتجاع الصوت في الهاتف", + "صخب فوضوي", + "ضوضاء بيضاء", + "ضوضاء وردية", + "هدير نابض", + "اهتزاز ميكانيكي", + "تلفزيون", + "راديو", + "تسجيل ميداني" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/de.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/de.json new file mode 100644 index 0000000..d69b5fb --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/de.json @@ -0,0 +1,523 @@ +[ + "Sprechen", + "Sprechendes Kind", + "Gespräch", + "Erzählung, Monolog", + "Brabbeln", + "Computerstimme", + "Rufen", + "Grölen", + "Jubelschrei", + "Brüllen", + "Kindergeschrei", + "Schreien", + "Flüstern", + "Lachen", + "Babylachen", + "Kichern", + "Hämisches Kichern", + "Schallendes Lachen", + "Leises Lachen", + "Weinen", + "Babyweinen", + "Wimmern", + "Stöhnen", + "Seufzen", + "Gesang", + "Chor", + "Jodeln", + "Sprechgesang", + "Mantra", + "Singendes Kind", + "Synthetischer Gesang", + "Rap", + "Summen", + "Ächzen", + "Grunzen", + "Pfeifen", + "Atmen", + "Pfeifendes Atmen", + "Schnarchen", + "Nach Luft schnappen", + "Keuchen", + "Schnauben", + "Husten", + "Räuspern", + "Niesen", + "Schniefen", + "Rennende Person", + "Schlurfen", + "Schritte", + "Kauen", + "Beißen", + "Gurgeln", + "Magenknurren", + "Rülpsen", + "Schluckauf", + "Pupsen", + "Hände", + "Fingerschnippen", + "Klatschen", + "Herzschlag", + "Herzgeräusch", + "Jubel", + "Applaus", + "Geplauder", + "Menschenmenge", + "Stimmengewirr", + "Spielende Kinder", + "Tier", + "Haustiere", + "Hund", + "Bellen", + "Kläffen", + "Heulen (Hund, Wolf)", + "Wau-wau", + "Knurren", + "Winseln", + "Katze", + "Schnurren", + "Miauen", + "Zischen", + "Jaulen (Katze)", + "Nutztiere", + "Pferd", + "Hufgeklapper", + "Wiehern", + "Rinder", + "Muhen", + "Kuhglocke", + "Schwein", + "Grunzen (Schwein)", + "Ziege", + "Meckern, Blöken", + "Schaf", + "Geflügel", + "Huhn", + "Gackern", + "Hahnenschrei", + "Truthahn", + "Kollern (Truthahn)", + "Ente", + "Quaken (Ente)", + "Gans", + "Schnattern (Gans)", + "Wildtiere", + "Großkatzen (Löwe, Tiger)", + "Brüllen (Großkatze)", + "Vogel", + "Vogelgesang", + "Zwitschern", + "Kreischen (Vogel)", + "Taube", + "Gurren", + "Krähe", + "Krächzen", + "Eule", + "Eulenruf", + "Flügelschlagen", + "Wölfe, Wildhunde", + "Nagetiere", + "Maus", + "Trippeln", + "Insekt", + "Grille", + "Mücke", + "Fliege", + "Summen (Insekt)", + "Biene, Wespe", + "Frosch", + "Quaken (Frosch)", + "Schlange", + "Rasseln", + "Walgesang", + "Musik", + "Musikinstrument", + "Zupfinstrument", + "Gitarre", + "E-Gitarre", + "E-Bass", + "Akustikgitarre", + "Slide-Gitarre", + "Tapping (Gitarre)", + "Schrammeln (Gitarre)", + "Banjo", + "Sitar", + "Mandoline", + "Zither", + "Ukulele", + "Tasteninstrument", + "Klavier", + "E-Piano", + "Orgel", + "Elektronische Orgel", + "Hammondorgel", + "Synthesizer", + "Sampler", + "Cembalo", + "Schlaginstrument", + "Schlagzeug", + "Drumcomputer", + "Trommel", + "Kleine Trommel", + "Rimshot", + "Trommelwirbel", + "Große Trommel", + "Pauke", + "Tabla", + "Becken (Instrument)", + "Hi-Hat", + "Holzblocktrommel", + "Tamburin", + "Rassel (Instrument)", + "Maracas", + "Gong", + "Röhrenglocken", + "Stabspiele", + "Xylophon, Marimba", + "Glockenspiel", + "Vibraphon", + "Steeldrum", + "Orchester", + "Blechblasinstrument", + "Waldhorn", + "Trompete", + "Posaune", + "Streichinstrument", + "Streicher", + "Geige", + "Pizzicato", + "Cello", + "Kontrabass", + "Holzblasinstrument", + "Flöte", + "Saxophon", + "Klarinette", + "Harfe", + "Glocke", + "Kirchenglocke", + "Schellen", + "Fahrradklingel", + "Stimmgabel", + "Glockenklang", + "Windspiel", + "Glockengeläut", + "Mundharmonika", + "Akkordeon", + "Dudelsack", + "Didgeridoo", + "Schofar", + "Theremin", + "Klangschale", + "Scratching (DJ)", + "Popmusik", + "Hip-Hop", + "Beatboxing", + "Rockmusik", + "Heavy Metal", + "Punkrock", + "Grunge", + "Progressive Rock", + "Rock ’n’ Roll", + "Psychedelic Rock", + "Rhythm and Blues", + "Soul", + "Reggae", + "Country", + "Swing", + "Bluegrass", + "Funk (Musik)", + "Folk", + "Orientalische Musik", + "Jazz", + "Disco", + "Klassische Musik", + "Oper", + "Elektronische Musik", + "House-Musik", + "Techno", + "Dubstep", + "Drum and Bass", + "Electronica", + "Elektronische Tanzmusik", + "Ambient-Musik", + "Trance", + "Lateinamerikanische Musik", + "Salsa", + "Flamenco", + "Blues", + "Kindermusik", + "New Age", + "Vokalmusik", + "A cappella", + "Afrikanische Musik", + "Afrobeat", + "Christliche Musik", + "Gospel", + "Asiatische Musik", + "Karnatische Musik", + "Bollywood-Musik", + "Ska", + "Traditionelle Musik", + "Indie", + "Lied", + "Hintergrundmusik", + "Titelmusik", + "Werbejingle", + "Soundtrack", + "Schlaflied", + "Videospielmusik", + "Weihnachtsmusik", + "Tanzmusik", + "Hochzeitsmusik", + "Fröhliche Musik", + "Traurige Musik", + "Zärtliche Musik", + "Aufregende Musik", + "Wütende Musik", + "Gruselige Musik", + "Wind", + "Blätterrascheln", + "Windgeräusch (Mikrofon)", + "Gewitter", + "Donner", + "Wasser", + "Regen", + "Regentropfen", + "Regenprasseln", + "Bach", + "Wasserfall", + "Meer", + "Wellenrauschen", + "Dampf", + "Gluckern", + "Feuer", + "Knistern", + "Fahrzeug", + "Boot", + "Segelboot", + "Ruderboot, Kanu", + "Motorboot", + "Schiff", + "Kraftfahrzeug", + "Auto", + "Hupen", + "Kurzes Hupen", + "Autoalarm", + "Elektrische Fensterheber", + "Schleudern (Auto)", + "Reifenquietschen", + "Vorbeifahrendes Auto", + "Autorennen", + "Lastwagen", + "Druckluftbremse", + "Lastwagenhupe", + "Rückfahrwarnton", + "Eiswagen", + "Bus", + "Einsatzfahrzeug", + "Polizeiauto (Sirene)", + "Krankenwagen (Sirene)", + "Feuerwehrauto (Sirene)", + "Motorrad", + "Verkehrslärm", + "Schienenverkehr", + "Zug", + "Zugpfeife", + "Zughupe", + "Eisenbahnwaggon", + "Quietschende Zugräder", + "U-Bahn", + "Luftfahrzeug", + "Flugzeugtriebwerk", + "Düsentriebwerk", + "Propeller", + "Hubschrauber", + "Flugzeug", + "Fahrrad", + "Skateboard", + "Motor", + "Kleiner Motor", + "Zahnarztbohrer", + "Rasenmäher", + "Kettensäge", + "Mittelgroßer Motor", + "Großer Motor", + "Motorklopfen", + "Motorstart", + "Motor im Leerlauf", + "Aufheulender Motor", + "Tür", + "Türklingel", + "Türgong", + "Schiebetür", + "Türenknallen", + "Klopfen", + "Leichtes Klopfen", + "Quietschen", + "Schranktür", + "Schublade", + "Geschirrklappern", + "Besteck", + "Schneiden (Küche)", + "Braten in der Pfanne", + "Mikrowelle", + "Mixer", + "Wasserhahn", + "Wasser im Waschbecken", + "Wasser in der Badewanne", + "Föhn", + "Toilettenspülung", + "Zahnbürste", + "Elektrische Zahnbürste", + "Staubsauger", + "Reißverschluss", + "Klimpernde Schlüssel", + "Fallende Münze", + "Schere", + "Elektrorasierer", + "Kartenmischen", + "Tippen", + "Schreibmaschine", + "Computertastatur", + "Schreiben", + "Alarm", + "Telefon", + "Telefonklingeln", + "Klingelton", + "Wähltastentöne", + "Wählton", + "Besetztzeichen", + "Wecker", + "Sirene", + "Zivilschutzsirene", + "Summer", + "Rauchmelder", + "Feueralarm", + "Nebelhorn", + "Trillerpfeife", + "Dampfpfeife", + "Mechanik", + "Ratsche", + "Uhr", + "Ticken", + "Ticktack", + "Zahnräder", + "Seilrollen", + "Nähmaschine", + "Ventilator", + "Klimaanlage", + "Registrierkasse", + "Drucker", + "Kamera", + "Spiegelreflexkamera", + "Werkzeug", + "Hammer", + "Presslufthammer", + "Sägen", + "Feilen", + "Schleifen", + "Elektrowerkzeug", + "Bohrmaschine", + "Explosion", + "Schuss", + "Maschinengewehr", + "Gewehrsalve", + "Artilleriefeuer", + "Spielzeugpistole", + "Feuerwerk", + "Böller", + "Platzen", + "Vulkanausbruch", + "Dröhnen", + "Holz", + "Holzhacken", + "Splitterndes Holz", + "Knacken", + "Glas", + "Klirren", + "Zersplittern", + "Flüssigkeit", + "Platschen", + "Schwappen", + "Nasses Quetschen", + "Tropfen", + "Gießen", + "Rinnsal", + "Schwall", + "Befüllen", + "Sprühen", + "Pumpen", + "Umrühren", + "Kochendes Wasser", + "Sonar", + "Pfeil", + "Sausen", + "Dumpfer Schlag", + "Hohler Schlag", + "Stimmgerät", + "Effektgerät", + "Chorus-Effekt", + "Dribbeln (Basketball)", + "Knall", + "Ohrfeige, Klaps", + "Kräftiger Schlag", + "Zertrümmern", + "Zerbrechen", + "Abprallen", + "Peitschenknall", + "Flattern", + "Kratzen", + "Schaben", + "Reiben", + "Rollen", + "Zerdrücken", + "Zerknüllen", + "Zerreißen", + "Piepton", + "Ping", + "Ding (heller Klang)", + "Scheppern", + "Quieken", + "Knarren", + "Rascheln", + "Surren", + "Klappern", + "Brutzeln", + "Klicken", + "Klackern", + "Grollen", + "Plopp (ins Wasser)", + "Klimpern", + "Brummen", + "Sirren", + "Boing (Sprungfeder)", + "Knirschen", + "Stille", + "Sinuston", + "Oberton", + "Gleitender Ton", + "Soundeffekt", + "Pulsierender Ton", + "Drinnen, kleiner Raum", + "Drinnen, großer Raum", + "Drinnen, öffentlicher Ort", + "Draußen, Stadt", + "Draußen, Natur", + "Nachhall", + "Echo", + "Rauschen", + "Umweltlärm", + "Statisches Rauschen", + "Brummen (Stromnetz)", + "Verzerrung", + "Mithörton (Telefon)", + "Lautes Durcheinander", + "Weißes Rauschen", + "Rosa Rauschen", + "Wummern", + "Vibrationsgeräusch", + "Fernseher", + "Radio", + "Tonaufnahme vor Ort" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/en.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/en.json new file mode 100644 index 0000000..f11e96c --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/en.json @@ -0,0 +1,523 @@ +[ + "Speech", + "Child speech, kid speaking", + "Conversation", + "Narration, monologue", + "Babbling", + "Speech synthesizer", + "Shout", + "Bellow", + "Whoop", + "Yell", + "Children shouting", + "Screaming", + "Whispering", + "Laughter", + "Baby laughter", + "Giggle", + "Snicker", + "Belly laugh", + "Chuckle, chortle", + "Crying, sobbing", + "Baby cry, infant cry", + "Whimper", + "Wail, moan", + "Sigh", + "Singing", + "Choir", + "Yodeling", + "Chant", + "Mantra", + "Child singing", + "Synthetic singing", + "Rapping", + "Humming", + "Groan", + "Grunt", + "Whistling", + "Breathing", + "Wheeze", + "Snoring", + "Gasp", + "Pant", + "Snort", + "Cough", + "Throat clearing", + "Sneeze", + "Sniff", + "Run", + "Shuffle", + "Walk, footsteps", + "Chewing, mastication", + "Biting", + "Gargling", + "Stomach rumble", + "Burping, eructation", + "Hiccup", + "Fart", + "Hands", + "Finger snapping", + "Clapping", + "Heart sounds, heartbeat", + "Heart murmur", + "Cheering", + "Applause", + "Chatter", + "Crowd", + "Hubbub, speech noise, speech babble", + "Children playing", + "Animal", + "Domestic animals, pets", + "Dog", + "Bark", + "Yip", + "Howl", + "Bow-wow", + "Growling", + "Whimper (dog)", + "Cat", + "Purr", + "Meow", + "Hiss", + "Caterwaul", + "Livestock, farm animals, working animals", + "Horse", + "Clip-clop", + "Neigh, whinny", + "Cattle, bovinae", + "Moo", + "Cowbell", + "Pig", + "Oink", + "Goat", + "Bleat", + "Sheep", + "Fowl", + "Chicken, rooster", + "Cluck", + "Crowing, cock-a-doodle-doo", + "Turkey", + "Gobble", + "Duck", + "Quack", + "Goose", + "Honk", + "Wild animals", + "Roaring cats (lions, tigers)", + "Roar", + "Bird", + "Bird vocalization, bird call, bird song", + "Chirp, tweet", + "Squawk", + "Pigeon, dove", + "Coo", + "Crow", + "Caw", + "Owl", + "Hoot", + "Bird flight, flapping wings", + "Canidae, dogs, wolves", + "Rodents, rats, mice", + "Mouse", + "Patter", + "Insect", + "Cricket", + "Mosquito", + "Fly, housefly", + "Buzz", + "Bee, wasp, etc.", + "Frog", + "Croak", + "Snake", + "Rattle", + "Whale vocalization", + "Music", + "Musical instrument", + "Plucked string instrument", + "Guitar", + "Electric guitar", + "Bass guitar", + "Acoustic guitar", + "Steel guitar, slide guitar", + "Tapping (guitar technique)", + "Strum", + "Banjo", + "Sitar", + "Mandolin", + "Zither", + "Ukulele", + "Keyboard (musical)", + "Piano", + "Electric piano", + "Organ", + "Electronic organ", + "Hammond organ", + "Synthesizer", + "Sampler", + "Harpsichord", + "Percussion", + "Drum kit", + "Drum machine", + "Drum", + "Snare drum", + "Rimshot", + "Drum roll", + "Bass drum", + "Timpani", + "Tabla", + "Cymbal", + "Hi-hat", + "Wood block", + "Tambourine", + "Rattle (instrument)", + "Maraca", + "Gong", + "Tubular bells", + "Mallet percussion", + "Marimba, xylophone", + "Glockenspiel", + "Vibraphone", + "Steelpan", + "Orchestra", + "Brass instrument", + "French horn", + "Trumpet", + "Trombone", + "Bowed string instrument", + "String section", + "Violin, fiddle", + "Pizzicato", + "Cello", + "Double bass", + "Wind instrument, woodwind instrument", + "Flute", + "Saxophone", + "Clarinet", + "Harp", + "Bell", + "Church bell", + "Jingle bell", + "Bicycle bell", + "Tuning fork", + "Chime", + "Wind chime", + "Change ringing (campanology)", + "Harmonica", + "Accordion", + "Bagpipes", + "Didgeridoo", + "Shofar", + "Theremin", + "Singing bowl", + "Scratching (performance technique)", + "Pop music", + "Hip hop music", + "Beatboxing", + "Rock music", + "Heavy metal", + "Punk rock", + "Grunge", + "Progressive rock", + "Rock and roll", + "Psychedelic rock", + "Rhythm and blues", + "Soul music", + "Reggae", + "Country", + "Swing music", + "Bluegrass", + "Funk", + "Folk music", + "Middle Eastern music", + "Jazz", + "Disco", + "Classical music", + "Opera", + "Electronic music", + "House music", + "Techno", + "Dubstep", + "Drum and bass", + "Electronica", + "Electronic dance music", + "Ambient music", + "Trance music", + "Music of Latin America", + "Salsa music", + "Flamenco", + "Blues", + "Music for children", + "New-age music", + "Vocal music", + "A capella", + "Music of Africa", + "Afrobeat", + "Christian music", + "Gospel music", + "Music of Asia", + "Carnatic music", + "Music of Bollywood", + "Ska", + "Traditional music", + "Independent music", + "Song", + "Background music", + "Theme music", + "Jingle (music)", + "Soundtrack music", + "Lullaby", + "Video game music", + "Christmas music", + "Dance music", + "Wedding music", + "Happy music", + "Sad music", + "Tender music", + "Exciting music", + "Angry music", + "Scary music", + "Wind", + "Rustling leaves", + "Wind noise (microphone)", + "Thunderstorm", + "Thunder", + "Water", + "Rain", + "Raindrop", + "Rain on surface", + "Stream", + "Waterfall", + "Ocean", + "Waves, surf", + "Steam", + "Gurgling", + "Fire", + "Crackle", + "Vehicle", + "Boat, Water vehicle", + "Sailboat, sailing ship", + "Rowboat, canoe, kayak", + "Motorboat, speedboat", + "Ship", + "Motor vehicle (road)", + "Car", + "Vehicle horn, car horn, honking", + "Toot", + "Car alarm", + "Power windows, electric windows", + "Skidding", + "Tire squeal", + "Car passing by", + "Race car, auto racing", + "Truck", + "Air brake", + "Air horn, truck horn", + "Reversing beeps", + "Ice cream truck, ice cream van", + "Bus", + "Emergency vehicle", + "Police car (siren)", + "Ambulance (siren)", + "Fire engine, fire truck (siren)", + "Motorcycle", + "Traffic noise, roadway noise", + "Rail transport", + "Train", + "Train whistle", + "Train horn", + "Railroad car, train wagon", + "Train wheels squealing", + "Subway, metro, underground", + "Aircraft", + "Aircraft engine", + "Jet engine", + "Propeller, airscrew", + "Helicopter", + "Fixed-wing aircraft, airplane", + "Bicycle", + "Skateboard", + "Engine", + "Light engine (high frequency)", + "Dental drill, dentist's drill", + "Lawn mower", + "Chainsaw", + "Medium engine (mid frequency)", + "Heavy engine (low frequency)", + "Engine knocking", + "Engine starting", + "Idling", + "Accelerating, revving, vroom", + "Door", + "Doorbell", + "Ding-dong", + "Sliding door", + "Slam", + "Knock", + "Tap", + "Squeak", + "Cupboard open or close", + "Drawer open or close", + "Dishes, pots, and pans", + "Cutlery, silverware", + "Chopping (food)", + "Frying (food)", + "Microwave oven", + "Blender", + "Water tap, faucet", + "Sink (filling or washing)", + "Bathtub (filling or washing)", + "Hair dryer", + "Toilet flush", + "Toothbrush", + "Electric toothbrush", + "Vacuum cleaner", + "Zipper (clothing)", + "Keys jangling", + "Coin (dropping)", + "Scissors", + "Electric shaver, electric razor", + "Shuffling cards", + "Typing", + "Typewriter", + "Computer keyboard", + "Writing", + "Alarm", + "Telephone", + "Telephone bell ringing", + "Ringtone", + "Telephone dialing, DTMF", + "Dial tone", + "Busy signal", + "Alarm clock", + "Siren", + "Civil defense siren", + "Buzzer", + "Smoke detector, smoke alarm", + "Fire alarm", + "Foghorn", + "Whistle", + "Steam whistle", + "Mechanisms", + "Ratchet, pawl", + "Clock", + "Tick", + "Tick-tock", + "Gears", + "Pulleys", + "Sewing machine", + "Mechanical fan", + "Air conditioning", + "Cash register", + "Printer", + "Camera", + "Single-lens reflex camera", + "Tools", + "Hammer", + "Jackhammer", + "Sawing", + "Filing (rasp)", + "Sanding", + "Power tool", + "Drill", + "Explosion", + "Gunshot, gunfire", + "Machine gun", + "Fusillade", + "Artillery fire", + "Cap gun", + "Fireworks", + "Firecracker", + "Burst, pop", + "Eruption", + "Boom", + "Wood", + "Chop", + "Splinter", + "Crack", + "Glass", + "Chink, clink", + "Shatter", + "Liquid", + "Splash, splatter", + "Slosh", + "Squish", + "Drip", + "Pour", + "Trickle, dribble", + "Gush", + "Fill (with liquid)", + "Spray", + "Pump (liquid)", + "Stir", + "Boiling", + "Sonar", + "Arrow", + "Whoosh, swoosh, swish", + "Thump, thud", + "Thunk", + "Electronic tuner", + "Effects unit", + "Chorus effect", + "Basketball bounce", + "Bang", + "Slap, smack", + "Whack, thwack", + "Smash, crash", + "Breaking", + "Bouncing", + "Whip", + "Flap", + "Scratch", + "Scrape", + "Rub", + "Roll", + "Crushing", + "Crumpling, crinkling", + "Tearing", + "Beep, bleep", + "Ping", + "Ding", + "Clang", + "Squeal", + "Creak", + "Rustle", + "Whir", + "Clatter", + "Sizzle", + "Clicking", + "Clickety-clack", + "Rumble", + "Plop", + "Jingle, tinkle", + "Hum", + "Zing", + "Boing", + "Crunch", + "Silence", + "Sine wave", + "Harmonic", + "Chirp tone", + "Sound effect", + "Pulse", + "Inside, small room", + "Inside, large room or hall", + "Inside, public space", + "Outside, urban or manmade", + "Outside, rural or natural", + "Reverberation", + "Echo", + "Noise", + "Environmental noise", + "Static", + "Mains hum", + "Distortion", + "Sidetone", + "Cacophony", + "White noise", + "Pink noise", + "Throbbing", + "Vibration", + "Television", + "Radio", + "Field recording" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/es.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/es.json new file mode 100644 index 0000000..3b85a69 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/es.json @@ -0,0 +1,523 @@ +[ + "Habla", + "Niño hablando", + "Conversación", + "Narración", + "Balbuceo", + "Voz sintética", + "Grito", + "Bramido", + "Grito de júbilo", + "Alarido", + "Niños gritando", + "Grito agudo", + "Susurro", + "Risa", + "Risa de bebé", + "Risita", + "Risa burlona", + "Carcajada", + "Risa contenida", + "Llanto", + "Llanto de bebé", + "Gimoteo", + "Gemido", + "Suspiro", + "Canto", + "Coro", + "Canto tirolés", + "Cántico", + "Mantra", + "Niño cantando", + "Canto sintético", + "Rap", + "Tarareo", + "Quejido", + "Gruñido de persona", + "Silbido", + "Respiración", + "Silbido al respirar", + "Ronquido", + "Inhalación brusca", + "Jadeo", + "Resoplido", + "Tos", + "Carraspeo", + "Estornudo", + "Sorber por la nariz", + "Correr", + "Arrastrar los pies", + "Pasos", + "Masticación", + "Mordisco", + "Gárgaras", + "Ruido de estómago", + "Eructo", + "Hipo", + "Flatulencia", + "Manos", + "Chasquido de dedos", + "Palmadas", + "Latido del corazón", + "Soplo cardíaco", + "Vítores", + "Aplausos", + "Gente charlando", + "Multitud", + "Vocerío", + "Niños jugando", + "Animal", + "Animales domésticos", + "Perro", + "Ladrido", + "Ladrido agudo", + "Aullido", + "Guau guau", + "Gruñido", + "Gimoteo de perro", + "Gato", + "Ronroneo", + "Maullido", + "Siseo", + "Maullido de celo", + "Animales de granja", + "Caballo", + "Pisadas de caballo", + "Relincho", + "Vaca", + "Mugido", + "Cencerro", + "Cerdo", + "Gruñido de cerdo", + "Cabra", + "Balido", + "Oveja", + "Aves de corral", + "Gallina y gallo", + "Cacareo", + "Canto del gallo", + "Pavo", + "Gluglú de pavo", + "Pato", + "Graznido de pato", + "Ganso", + "Graznido de ganso", + "Animales salvajes", + "Grandes felinos", + "Rugido", + "Pájaro", + "Canto de pájaros", + "Piar", + "Graznido", + "Paloma", + "Arrullo", + "Cuervo", + "Graznido de cuervo", + "Búho", + "Ululato de búho", + "Aleteo de pájaro", + "Lobos y otros cánidos", + "Roedores", + "Ratón", + "Correteo de patitas", + "Insecto", + "Grillo", + "Mosquito", + "Mosca", + "Zumbido", + "Abeja o avispa", + "Rana", + "Croar", + "Serpiente", + "Cascabel de serpiente", + "Canto de ballena", + "Música", + "Instrumento musical", + "Instrumento de cuerda pulsada", + "Guitarra", + "Guitarra eléctrica", + "Bajo eléctrico", + "Guitarra acústica", + "Guitarra slide", + "Tapping (guitarra)", + "Rasgueo", + "Banjo", + "Sitar", + "Mandolina", + "Cítara", + "Ukelele", + "Teclado musical", + "Piano", + "Piano eléctrico", + "Órgano", + "Órgano electrónico", + "Órgano Hammond", + "Sintetizador", + "Sampler", + "Clavecín", + "Percusión", + "Batería (instrumento)", + "Caja de ritmos", + "Tambor", + "Redoblante", + "Golpe de aro", + "Redoble", + "Bombo", + "Timbales de orquesta", + "Tabla (tambor indio)", + "Platillo", + "Hi-hat (platillos)", + "Bloque de madera", + "Pandereta", + "Sonajero", + "Maracas", + "Gong", + "Campanas tubulares", + "Percusión melódica", + "Marimba o xilófono", + "Metalófono", + "Vibráfono", + "Tambor de acero", + "Orquesta", + "Instrumento de viento metal", + "Trompa (corno francés)", + "Trompeta", + "Trombón", + "Instrumento de cuerda frotada", + "Sección de cuerdas", + "Violín", + "Pizzicato", + "Violonchelo", + "Contrabajo", + "Instrumento de viento madera", + "Flauta", + "Saxofón", + "Clarinete", + "Arpa", + "Campana", + "Campana de iglesia", + "Cascabel", + "Timbre de bicicleta", + "Diapasón", + "Carillón", + "Campanas de viento", + "Repique de campanas", + "Armónica", + "Acordeón", + "Gaita", + "Didgeridoo", + "Shofar", + "Theremín", + "Cuenco tibetano", + "Scratch de DJ", + "Música pop", + "Música hip hop", + "Beatbox", + "Música rock", + "Heavy metal", + "Punk rock", + "Grunge", + "Rock progresivo", + "Rock and roll", + "Rock psicodélico", + "Rhythm and blues", + "Música soul", + "Reggae", + "Música country", + "Música swing", + "Bluegrass", + "Funk", + "Música folk", + "Música de Oriente Medio", + "Jazz", + "Música disco", + "Música clásica", + "Ópera", + "Música electrónica", + "Música house", + "Techno", + "Dubstep", + "Drum and bass", + "Electrónica", + "Música electrónica de baile", + "Música ambient", + "Música trance", + "Música latinoamericana", + "Música salsa", + "Flamenco", + "Blues", + "Música infantil", + "Música new age", + "Música vocal", + "A capela", + "Música africana", + "Afrobeat", + "Música cristiana", + "Música góspel", + "Música asiática", + "Música carnática", + "Música de Bollywood", + "Ska", + "Música tradicional", + "Música independiente", + "Canción", + "Música de fondo", + "Tema principal", + "Jingle publicitario", + "Banda sonora", + "Canción de cuna", + "Música de videojuegos", + "Música navideña", + "Música de baile", + "Música de boda", + "Música alegre", + "Música triste", + "Música tierna", + "Música emocionante", + "Música furiosa", + "Música aterradora", + "Viento", + "Roce de hojas", + "Viento en el micrófono", + "Tormenta", + "Trueno", + "Agua", + "Lluvia", + "Gota de lluvia", + "Repiqueteo de lluvia", + "Arroyo", + "Cascada", + "Océano", + "Olas", + "Vapor", + "Gorgoteo", + "Fuego", + "Crepitar", + "Vehículo", + "Embarcación", + "Velero", + "Bote de remos o canoa", + "Lancha motora", + "Barco", + "Vehículo de motor", + "Automóvil", + "Bocina o claxon", + "Toque de bocina", + "Alarma de automóvil", + "Ventanillas eléctricas", + "Derrape", + "Chirrido de neumáticos", + "Automóvil que pasa", + "Carreras de automóviles", + "Camión", + "Freno de aire", + "Bocina de camión", + "Pitidos de marcha atrás", + "Camión de helados", + "Autobús", + "Vehículo de emergencia", + "Sirena de policía", + "Sirena de ambulancia", + "Sirena de bomberos", + "Motocicleta", + "Ruido de tráfico", + "Ferrocarril", + "Tren", + "Silbato de tren", + "Bocina de tren", + "Vagón de tren", + "Chirrido de ruedas de tren", + "Metro", + "Aeronave", + "Motor de aeronave", + "Motor a reacción", + "Hélice", + "Helicóptero", + "Avión", + "Bicicleta", + "Monopatín (skate)", + "Motor", + "Motor pequeño (tono agudo)", + "Taladro de dentista", + "Cortacésped", + "Motosierra", + "Motor mediano (tono medio)", + "Motor grande (tono grave)", + "Golpeteo del motor", + "Arranque del motor", + "Motor al ralentí", + "Motor acelerando", + "Puerta", + "Timbre de la puerta", + "Din don del timbre", + "Puerta deslizante", + "Portazo", + "Golpes en la puerta", + "Golpecito", + "Chirrido", + "Abrir o cerrar un armario", + "Abrir o cerrar un cajón", + "Platos y ollas", + "Cubiertos", + "Picar alimentos", + "Freír alimentos", + "Microondas", + "Licuadora o batidora", + "Grifo de agua", + "Lavabo o fregadero", + "Bañera o tina", + "Secador de pelo", + "Descarga del inodoro", + "Cepillo de dientes", + "Cepillo de dientes eléctrico", + "Aspiradora", + "Cremallera", + "Tintineo de llaves", + "Moneda cayendo", + "Tijeras", + "Afeitadora eléctrica", + "Barajar cartas", + "Tecleo", + "Máquina de escribir", + "Teclado de computadora", + "Escritura a mano", + "Alarma", + "Teléfono", + "Timbre de teléfono", + "Tono de llamada", + "Marcación telefónica", + "Tono de marcado", + "Tono de ocupado", + "Despertador", + "Sirena", + "Sirena de defensa civil", + "Zumbador", + "Detector de humo", + "Alarma de incendios", + "Sirena de niebla", + "Silbato", + "Silbato de vapor", + "Mecanismos", + "Trinquete", + "Reloj", + "Tic metálico", + "Tictac", + "Engranajes", + "Poleas", + "Máquina de coser", + "Ventilador", + "Aire acondicionado", + "Caja registradora", + "Impresora", + "Cámara", + "Cámara réflex", + "Herramientas", + "Martillo", + "Martillo neumático", + "Cortar con sierra", + "Limar", + "Lijar", + "Herramienta eléctrica", + "Taladro", + "Explosión", + "Disparo", + "Ametralladora", + "Descarga de fusilería", + "Fuego de artillería", + "Pistola de juguete", + "Fuegos artificiales", + "Petardo", + "Estallido", + "Erupción volcánica", + "Estruendo", + "Madera", + "Hachazo", + "Madera astillándose", + "Chasquido", + "Vidrio", + "Tintín de copas", + "Vidrio rompiéndose", + "Líquido", + "Salpicadura", + "Líquido agitándose", + "Estrujar algo mojado", + "Goteo", + "Verter líquido", + "Chorrito", + "Chorro fuerte", + "Llenado de líquido", + "Rociado", + "Bombeo de líquido", + "Revolver un líquido", + "Hervor", + "Señal de sonar", + "Flecha", + "Silbido en el aire", + "Golpe sordo", + "Golpe hueco", + "Afinador electrónico", + "Unidad de efectos", + "Efecto chorus", + "Balón de baloncesto rebotando", + "Estampido", + "Bofetada", + "Golpe seco", + "Rotura violenta", + "Rotura", + "Rebote", + "Latigazo", + "Tela ondeando", + "Arañazo", + "Raspado", + "Frotamiento", + "Objeto rodando", + "Aplastamiento", + "Arrugar papel", + "Desgarro", + "Pitido", + "Ping", + "Tilín", + "Golpe metálico", + "Chillido", + "Crujido de madera", + "Roce", + "Zumbido mecánico", + "Objetos entrechocando", + "Chisporroteo", + "Clic", + "Traqueteo rítmico", + "Retumbo", + "Caída suave en el agua", + "Tintineo", + "Zumbido sordo", + "Zumbido breve y agudo", + "Boing (resorte)", + "Crujido", + "Silencio", + "Tono puro", + "Tono armónico", + "Tono que sube o baja", + "Efecto de sonido", + "Pulsos regulares", + "Interior, sala pequeña", + "Interior, sala grande", + "Interior, espacio público", + "Exterior, entorno urbano", + "Exterior, entorno natural", + "Reverberación", + "Eco", + "Ruido", + "Ruido ambiental", + "Ruido de estática", + "Zumbido eléctrico", + "Distorsión", + "Retorno de voz (teléfono)", + "Cacofonía", + "Ruido blanco", + "Ruido rosa", + "Sonido palpitante", + "Vibración mecánica", + "Televisión", + "Radio", + "Grabación de campo" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/fr.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/fr.json new file mode 100644 index 0000000..7bcca2b --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/fr.json @@ -0,0 +1,523 @@ +[ + "Parole", + "Parole d'enfant", + "Conversation", + "Narration", + "Babillage", + "Voix de synthèse", + "Cri", + "Cri bestial", + "Cri de joie", + "Hurlement", + "Cris d'enfants", + "Cri perçant", + "Chuchotement", + "Rire", + "Rire de bébé", + "Petit rire nerveux", + "Ricanement", + "Éclat de rire", + "Rire étouffé", + "Pleurs", + "Pleurs de bébé", + "Pleurnichement", + "Gémissement", + "Soupir", + "Chant", + "Chœur", + "Yodel", + "Psalmodie", + "Mantra", + "Chant d'enfant", + "Chant de synthèse", + "Rap", + "Fredonnement", + "Gémissement de douleur", + "Grognement", + "Sifflement", + "Respiration", + "Respiration sifflante", + "Ronflement", + "Inspiration brusque", + "Halètement", + "Souffle par le nez", + "Toux", + "Raclement de gorge", + "Éternuement", + "Reniflement", + "Course à pied", + "Pas traînants", + "Bruits de pas", + "Mastication", + "Morsure", + "Gargarisme", + "Ventre qui gargouille", + "Rot", + "Hoquet", + "Pet", + "Bruits de mains", + "Claquement de doigts", + "Claquement de mains", + "Battements de cœur", + "Souffle cardiaque", + "Acclamations", + "Applaudissements", + "Bavardages", + "Foule", + "Brouhaha", + "Enfants qui jouent", + "Animal", + "Animaux domestiques", + "Chien", + "Aboiement", + "Jappement", + "Hurlement (chien, loup)", + "Ouaf ouaf", + "Grognement animal", + "Gémissement de chien", + "Chat", + "Ronronnement", + "Miaulement", + "Sifflement (chat, serpent)", + "Chat en chaleur", + "Animaux de ferme", + "Cheval", + "Bruits de sabots", + "Hennissement", + "Vache", + "Meuglement", + "Cloche de vache", + "Cochon", + "Grognement de cochon", + "Chèvre", + "Bêlement", + "Mouton", + "Volaille", + "Poule, coq", + "Caquètement", + "Chant du coq", + "Dindon", + "Glouglou de dindon", + "Canard", + "Cri de canard", + "Oie", + "Cri d'oie", + "Animaux sauvages", + "Fauves (lions, tigres)", + "Rugissement", + "Oiseau", + "Chant d'oiseau", + "Pépiement", + "Criaillement", + "Pigeon, tourterelle", + "Roucoulement", + "Corbeau", + "Croassement", + "Hibou, chouette", + "Hululement", + "Battements d'ailes", + "Loups, chiens sauvages", + "Rongeurs", + "Souris", + "Trottinement", + "Insecte", + "Grillon", + "Moustique", + "Mouche", + "Bourdonnement", + "Abeille, guêpe", + "Grenouille", + "Coassement", + "Serpent", + "Bruit de crécelle", + "Chant de baleine", + "Musique", + "Instrument de musique", + "Cordes pincées", + "Guitare", + "Guitare électrique", + "Guitare basse", + "Guitare acoustique", + "Guitare slide", + "Tapping (guitare)", + "Guitare grattée", + "Banjo", + "Sitar", + "Mandoline", + "Cithare", + "Ukulélé", + "Instrument à clavier", + "Piano", + "Piano électrique", + "Orgue", + "Orgue électronique", + "Orgue Hammond", + "Synthétiseur", + "Échantillonneur", + "Clavecin", + "Percussions", + "Batterie (musique)", + "Boîte à rythmes", + "Tambour", + "Caisse claire", + "Rimshot", + "Roulement de tambour", + "Grosse caisse", + "Timbales", + "Tabla", + "Cymbale", + "Charleston (cymbale)", + "Wood-block", + "Tambourin", + "Hochet (instrument)", + "Maracas", + "Gong", + "Cloches tubulaires", + "Percussions mélodiques", + "Xylophone, marimba", + "Glockenspiel", + "Vibraphone", + "Steel drum", + "Orchestre", + "Cuivres", + "Cor d'harmonie", + "Trompette", + "Trombone", + "Cordes frottées", + "Ensemble à cordes", + "Violon", + "Pizzicato", + "Violoncelle", + "Contrebasse", + "Instrument à vent", + "Flûte", + "Saxophone", + "Clarinette", + "Harpe", + "Cloche", + "Cloche d'église", + "Grelot", + "Sonnette de vélo", + "Diapason", + "Carillon", + "Carillon à vent", + "Volée de cloches", + "Harmonica", + "Accordéon", + "Cornemuse", + "Didgeridoo", + "Chofar", + "Thérémine", + "Bol tibétain", + "Scratch (DJ)", + "Musique pop", + "Musique hip-hop", + "Beatbox", + "Musique rock", + "Heavy metal", + "Punk rock", + "Grunge", + "Rock progressif", + "Rock and roll", + "Rock psychédélique", + "Rhythm and blues", + "Musique soul", + "Reggae", + "Country", + "Musique swing", + "Bluegrass", + "Funk", + "Musique folk", + "Musique du Moyen-Orient", + "Jazz", + "Disco", + "Musique classique", + "Opéra", + "Musique électronique", + "Musique house", + "Techno", + "Dubstep", + "Drum and bass", + "Electronica", + "Électro dansante (EDM)", + "Musique ambient", + "Musique trance", + "Musique latino-américaine", + "Musique salsa", + "Flamenco", + "Blues", + "Musique pour enfants", + "Musique new age", + "Musique vocale", + "A cappella", + "Musique africaine", + "Afrobeat", + "Musique chrétienne", + "Musique gospel", + "Musique asiatique", + "Musique carnatique", + "Musique de Bollywood", + "Ska", + "Musique traditionnelle", + "Musique indépendante", + "Chanson", + "Musique de fond", + "Musique de générique", + "Jingle publicitaire", + "Bande originale", + "Berceuse", + "Musique de jeu vidéo", + "Musique de Noël", + "Musique de danse", + "Musique de mariage", + "Musique joyeuse", + "Musique triste", + "Musique tendre", + "Musique palpitante", + "Musique rageuse", + "Musique effrayante", + "Vent", + "Bruissement de feuilles", + "Vent dans le micro", + "Orage", + "Tonnerre", + "Eau", + "Pluie", + "Goutte de pluie", + "Pluie qui tambourine", + "Ruisseau", + "Cascade", + "Océan", + "Vagues", + "Vapeur", + "Gargouillis", + "Feu", + "Crépitement", + "Véhicule", + "Bateau", + "Voilier", + "Barque, canoë, kayak", + "Bateau à moteur", + "Navire", + "Véhicule à moteur", + "Voiture", + "Klaxon", + "Coup de klaxon", + "Alarme de voiture", + "Vitres électriques", + "Dérapage", + "Crissement de pneus", + "Voiture qui passe", + "Voiture de course", + "Camion", + "Frein pneumatique", + "Klaxon de camion", + "Bips de recul", + "Camion de glaces", + "Bus", + "Véhicule d'urgence", + "Voiture de police (sirène)", + "Ambulance (sirène)", + "Camion de pompiers (sirène)", + "Moto", + "Bruit de circulation", + "Chemin de fer", + "Train", + "Sifflet de train", + "Klaxon de train", + "Wagon", + "Crissement des rails", + "Métro", + "Aéronef", + "Moteur d'avion", + "Réacteur d'avion", + "Hélice", + "Hélicoptère", + "Avion", + "Vélo", + "Skateboard", + "Moteur", + "Petit moteur (aigu)", + "Fraise de dentiste", + "Tondeuse à gazon", + "Tronçonneuse", + "Moteur moyen", + "Gros moteur (grave)", + "Cliquetis de moteur", + "Démarrage de moteur", + "Moteur au ralenti", + "Moteur qui accélère", + "Porte", + "Sonnette", + "Ding-dong", + "Porte coulissante", + "Claquement de porte", + "Coups à la porte", + "Tapotement", + "Couinement", + "Porte de placard", + "Tiroir", + "Vaisselle", + "Couverts", + "Découpe d'aliments", + "Friture", + "Micro-ondes", + "Mixeur", + "Robinet", + "Évier, lavabo", + "Baignoire", + "Sèche-cheveux", + "Chasse d'eau", + "Brosse à dents", + "Brosse à dents électrique", + "Aspirateur", + "Fermeture éclair", + "Cliquetis de clés", + "Pièce qui tombe", + "Ciseaux", + "Rasoir électrique", + "Mélange de cartes", + "Frappe au clavier", + "Machine à écrire", + "Clavier d'ordinateur", + "Écriture à la main", + "Alarme", + "Téléphone", + "Sonnerie de téléphone", + "Sonnerie de portable", + "Composition d'un numéro", + "Tonalité téléphonique", + "Signal occupé", + "Réveil", + "Sirène", + "Sirène d'alerte", + "Buzzer", + "Détecteur de fumée", + "Alarme incendie", + "Corne de brume", + "Sifflet", + "Sifflet à vapeur", + "Mécanismes", + "Cliquet", + "Horloge", + "Tic métallique", + "Tic-tac", + "Engrenages", + "Poulies", + "Machine à coudre", + "Ventilateur", + "Climatisation", + "Caisse enregistreuse", + "Imprimante", + "Appareil photo", + "Appareil photo reflex", + "Outils", + "Marteau", + "Marteau-piqueur", + "Scie", + "Lime (outil)", + "Ponçage", + "Outil électrique", + "Perceuse", + "Explosion", + "Coup de feu", + "Mitrailleuse", + "Fusillade", + "Tir d'artillerie", + "Pistolet à amorces", + "Feux d'artifice", + "Pétard", + "Éclatement", + "Éruption volcanique", + "Boum", + "Bois", + "Coups de hache", + "Bois qui éclate", + "Craquement", + "Verre", + "Tintement", + "Bris de verre", + "Liquide", + "Éclaboussure", + "Clapotis", + "Écrasement mouillé", + "Goutte-à-goutte", + "Liquide versé", + "Filet d'eau", + "Jaillissement", + "Remplissage (liquide)", + "Pulvérisation", + "Pompe (liquide)", + "Liquide remué", + "Ébullition", + "Sonar", + "Flèche", + "Objet qui fend l'air", + "Choc sourd", + "Choc creux", + "Accordeur électronique", + "Unité d'effets", + "Effet chorus", + "Rebond de ballon (basket)", + "Bang", + "Claque", + "Coup sec", + "Fracas", + "Objet qui se casse", + "Rebond", + "Coup de fouet", + "Claquement au vent", + "Grattement", + "Raclement", + "Frottement", + "Roulement", + "Écrasement", + "Froissement", + "Déchirement", + "Bip", + "Ping", + "Ding", + "Choc métallique", + "Crissement", + "Grincement", + "Bruissement", + "Ronronnement mécanique", + "Entrechoquement", + "Grésillement", + "Clics", + "Cliquetis rythmé", + "Grondement", + "Petit plouf", + "Cliquetis", + "Bourdonnement sourd", + "Bref bourdonnement aigu", + "Boing (ressort)", + "Bruit croustillant", + "Silence", + "Son pur", + "Harmonique", + "Son montant ou descendant", + "Effet sonore", + "Impulsions régulières", + "Intérieur, petite pièce", + "Intérieur, grande salle", + "Intérieur, lieu public", + "Extérieur, milieu urbain", + "Extérieur, milieu naturel", + "Réverbération", + "Écho", + "Bruit", + "Bruit ambiant", + "Parasites", + "Bourdonnement électrique", + "Distorsion", + "Retour de voix (téléphone)", + "Cacophonie", + "Bruit blanc", + "Bruit rose", + "Pulsation sourde", + "Bruit de vibration", + "Télévision", + "Radio", + "Enregistrement de terrain" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/hi.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/hi.json new file mode 100644 index 0000000..ee1fd8f --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/hi.json @@ -0,0 +1,523 @@ +[ + "बोलना", + "बच्चे का बोलना", + "लोगों की बातचीत", + "एक व्यक्ति का बोलना", + "शिशु का बड़बड़ाना", + "मशीनी आवाज़ में बोलना", + "चिल्लाना", + "इंसान की दहाड़", + "खुशी से चिल्लाना", + "गला फाड़कर चिल्लाना", + "बच्चों का चिल्लाना", + "चीखना", + "फुसफुसाना", + "हंसी", + "शिशु की हंसी", + "खिलखिलाना", + "मज़ाक उड़ाती हंसी", + "ठहाका", + "दबी हंसी", + "रोना", + "शिशु का रोना", + "सुबकना", + "कराहना", + "आह भरना", + "गायन", + "गायक मंडली", + "योडलिंग", + "लयबद्ध पाठ", + "मंत्र जाप", + "बच्चे का गाना", + "मशीनी आवाज़ में गाना", + "रैप", + "गुनगुनाना", + "गहरी कराह", + "इंसान का घुरघुराना", + "मुंह से सीटी बजाना", + "सांस लेना", + "सांस की घरघराहट", + "खर्राटे", + "अचानक सांस खींचना", + "हांफना", + "नाक से तेज़ सांस छोड़ना", + "खांसी", + "गला साफ़ करना", + "छींक", + "नाक सुड़कना", + "दौड़ना", + "पैर घसीटकर चलना", + "कदमों की आवाज़", + "चबाना", + "दांत से काटना", + "गरारे करना", + "पेट में गुड़गुड़", + "डकार", + "हिचकी", + "पाद", + "हाथों की आवाज़", + "चुटकी बजाना", + "ताली बजाना", + "दिल की धड़कन", + "दिल की असामान्य आवाज़", + "जयकार", + "तालियां", + "लोगों की गपशप", + "भीड़", + "लोगों का शोरगुल", + "बच्चों का खेलना", + "जानवर", + "पालतू जानवर", + "कुत्ता", + "भौंकना", + "छोटे कुत्ते का भौंकना", + "हुआं-हुआं", + "भौं-भौं", + "गुर्राना", + "कुत्ते की कूं-कूं", + "बिल्ली", + "बिल्ली का घुरघुराना", + "म्याऊं", + "फुफकारना", + "बिल्ली का रोना", + "खेत के जानवर", + "घोड़ा", + "घोड़े की टापें", + "हिनहिनाना", + "गाय-बैल", + "गाय का रंभाना", + "गाय की घंटी", + "सूअर", + "सूअर का घुरघुराना", + "बकरी", + "मिमियाना", + "भेड़", + "मुर्गी-बत्तख जैसे पक्षी", + "मुर्गा-मुर्गी", + "मुर्गी का कुड़कुड़ाना", + "मुर्गे की बांग", + "टर्की", + "टर्की की बोली", + "बत्तख", + "बत्तख की क्वैक-क्वैक", + "हंस", + "हंस की बोली", + "जंगली जानवर", + "शेर और बाघ", + "दहाड़", + "पक्षी", + "पक्षियों की चहचहाहट", + "चिड़िया की चीं-चीं", + "पक्षी की कर्कश चीख", + "कबूतर", + "कबूतर की गुटरगूं", + "कौआ", + "कांव-कांव", + "उल्लू", + "उल्लू की घू-घू", + "पक्षी के पंख फड़फड़ाना", + "भेड़िये और सियार", + "चूहे जैसे जीव", + "चूहा", + "नन्हे कदमों की आहट", + "कीड़े-मकोड़े", + "झींगुर", + "मच्छर", + "मक्खी", + "भिनभिनाना", + "मधुमक्खी और ततैया", + "मेंढक", + "टर्र-टर्र", + "सांप", + "खड़खड़ाहट", + "व्हेल की आवाज़", + "संगीत", + "वाद्य यंत्र", + "छेड़कर बजने वाले तार वाद्य", + "गिटार", + "इलेक्ट्रिक गिटार", + "बेस गिटार", + "अकूस्टिक गिटार", + "स्टील गिटार", + "गिटार पर टैपिंग", + "गिटार की झनकार", + "बैंजो", + "सितार", + "मैंडोलिन", + "ज़िथर", + "यूकुलेले", + "कीबोर्ड (वाद्य)", + "पियानो", + "इलेक्ट्रिक पियानो", + "ऑर्गन", + "इलेक्ट्रॉनिक ऑर्गन", + "हैमंड ऑर्गन", + "सिंथेसाइज़र", + "सैंपलर", + "हार्पसीकॉर्ड", + "ताल वाद्य", + "ड्रम सेट", + "ड्रम मशीन", + "ड्रम", + "स्नेयर ड्रम", + "ड्रम के किनारे पर थाप", + "ड्रम रोल", + "बेस ड्रम", + "टिंपनी", + "तबला", + "झांझ", + "हाई-हैट", + "लकड़ी का ब्लॉक (वाद्य)", + "डफली", + "झुनझुना", + "मराका", + "गोंग", + "नली वाली घंटियां", + "ज़ाइलोफ़ोन जैसे वाद्य", + "ज़ाइलोफ़ोन", + "ग्लॉकेनस्पील", + "वाइब्राफ़ोन", + "स्टीलपैन", + "ऑर्केस्ट्रा", + "पीतल के वाद्य", + "फ़्रेंच हॉर्न", + "ट्रंपेट", + "ट्रॉम्बोन", + "गज़ से बजने वाले तार वाद्य", + "तार वाद्यों का समूह", + "वायलिन", + "वायलिन के तार छेड़ना", + "चेलो", + "डबल बेस", + "फूंककर बजने वाले वाद्य", + "बांसुरी", + "सैक्सोफ़ोन", + "क्लैरिनेट", + "हार्प", + "घंटी", + "चर्च की घंटी", + "घुंघरू", + "साइकिल की घंटी", + "ट्यूनिंग फ़ोर्क", + "घंटियों की धुन", + "हवा से बजने वाली घंटियां", + "घंटियों का क्रमवार बजना", + "माउथ ऑर्गन", + "अकॉर्डियन", + "बैगपाइप", + "डिजेरिडू", + "शोफ़ार (सींग का बाजा)", + "थेरेमिन", + "सिंगिंग बाउल", + "डीजे स्क्रैचिंग", + "पॉप संगीत", + "हिप-हॉप संगीत", + "बीटबॉक्सिंग", + "रॉक संगीत", + "हेवी मेटल", + "पंक रॉक", + "ग्रंज", + "प्रोग्रेसिव रॉक", + "रॉक एंड रोल", + "साइकेडेलिक रॉक", + "रिदम एंड ब्लूज़", + "सोल संगीत", + "रेगे", + "कंट्री संगीत", + "स्विंग संगीत", + "ब्लूग्रास", + "फ़ंक", + "लोक संगीत", + "मध्य-पूर्वी संगीत", + "जैज़", + "डिस्को", + "पश्चिमी शास्त्रीय संगीत", + "ओपेरा", + "इलेक्ट्रॉनिक संगीत", + "हाउस संगीत", + "टेक्नो", + "डबस्टेप", + "ड्रम एंड बेस", + "इलेक्ट्रॉनिका", + "इलेक्ट्रॉनिक डांस संगीत", + "एंबिएंट संगीत", + "ट्रांस संगीत", + "लैटिन अमेरिकी संगीत", + "साल्सा संगीत", + "फ़्लामेंको", + "ब्लूज़", + "बच्चों के लिए संगीत", + "न्यू एज संगीत", + "गायन संगीत", + "बिना वाद्य के गायन", + "अफ़्रीकी संगीत", + "एफ़्रोबीट", + "ईसाई संगीत", + "गॉस्पेल संगीत", + "एशियाई संगीत", + "कर्नाटक संगीत", + "बॉलीवुड संगीत", + "स्का", + "पारंपरिक संगीत", + "इंडी संगीत", + "गीत", + "पृष्ठभूमि संगीत", + "थीम संगीत", + "विज्ञापन की धुन", + "साउंडट्रैक संगीत", + "लोरी", + "वीडियो गेम संगीत", + "क्रिसमस संगीत", + "डांस संगीत", + "शादी का संगीत", + "खुशी भरा संगीत", + "दुख भरा संगीत", + "कोमल संगीत", + "जोशीला संगीत", + "गुस्से भरा संगीत", + "डरावना संगीत", + "हवा", + "पत्तों की सरसराहट", + "माइक पर हवा का शोर", + "गरज के साथ तूफ़ान", + "बादलों की गरज", + "पानी", + "बारिश", + "बारिश की बूंद", + "छत या खिड़की पर बारिश", + "बहती नदी", + "झरना", + "समुद्र", + "समुद्र की लहरें", + "भाप", + "पानी की गुड़गुड़", + "आग", + "चटचटाहट", + "वाहन", + "नाव", + "पाल वाली नाव", + "चप्पू वाली नाव", + "मोटरबोट", + "पानी का जहाज़", + "मोटर वाहन", + "कार", + "वाहन का हॉर्न", + "हॉर्न की छोटी आवाज़", + "कार का अलार्म", + "कार की पावर विंडो", + "वाहन का फिसलना", + "टायरों की चीख", + "पास से गुज़रती कार", + "रेसिंग कार", + "ट्रक", + "ट्रक का एयर ब्रेक", + "ट्रक का प्रेशर हॉर्न", + "रिवर्स गियर की बीप", + "आइसक्रीम वैन", + "बस", + "आपातकालीन वाहन", + "पुलिस कार (सायरन)", + "एंबुलेंस (सायरन)", + "दमकल (सायरन)", + "मोटरसाइकिल", + "ट्रैफ़िक का शोर", + "रेल यातायात", + "ट्रेन", + "ट्रेन की सीटी", + "ट्रेन का हॉर्न", + "ट्रेन का डिब्बा", + "ट्रेन के पहियों की चीख", + "मेट्रो", + "विमान", + "विमान का इंजन", + "जेट इंजन", + "प्रोपेलर", + "हेलीकॉप्टर", + "हवाई जहाज़", + "साइकिल", + "स्केटबोर्ड", + "इंजन", + "हल्का इंजन", + "डेंटिस्ट की ड्रिल", + "घास काटने की मशीन", + "चेन वाली आरी", + "मध्यम इंजन", + "भारी इंजन", + "इंजन की खटखट", + "इंजन स्टार्ट होना", + "खड़े वाहन का चालू इंजन", + "इंजन को रेस देना", + "दरवाज़ा", + "दरवाज़े की घंटी", + "घंटी की टिंग-टोंग", + "स्लाइडिंग दरवाज़ा", + "दरवाज़ा ज़ोर से बंद होना", + "दरवाज़ा खटखटाना", + "हल्की ठक-ठक", + "चूं-चूं की आवाज़", + "अलमारी खुलना-बंद होना", + "दराज़ खुलना-बंद होना", + "बर्तनों की खटपट", + "छुरी-कांटे और चम्मच", + "सब्ज़ी काटना", + "तेल में तलना", + "माइक्रोवेव ओवन", + "मिक्सी", + "पानी का नल", + "सिंक (भरना या धोना)", + "बाथटब (भरना या नहाना)", + "हेयर ड्रायर", + "टॉयलेट फ़्लश", + "टूथब्रश", + "इलेक्ट्रिक टूथब्रश", + "वैक्यूम क्लीनर", + "ज़िप खींचना", + "चाबियों की खनखनाहट", + "सिक्का गिरना", + "कैंची", + "इलेक्ट्रिक शेवर", + "ताश फेंटना", + "टाइपिंग", + "टाइपराइटर", + "कंप्यूटर कीबोर्ड", + "कागज़ पर लिखना", + "अलार्म", + "फ़ोन", + "फ़ोन की घंटी", + "रिंगटोन", + "नंबर डायल करने की टोन", + "डायल टोन", + "बिज़ी टोन", + "अलार्म घड़ी", + "सायरन", + "आपदा चेतावनी सायरन", + "बज़र", + "धुएं का अलार्म", + "आग का अलार्म", + "कोहरे का हॉर्न", + "सीटी", + "भाप की सीटी", + "मशीनें और उपकरण", + "रैचेट की किट-किट", + "घड़ी", + "धातु की टिक", + "घड़ी की टिक-टिक", + "मशीन के गियर", + "घिरनी", + "सिलाई मशीन", + "पंखा", + "एसी", + "कैश रजिस्टर", + "प्रिंटर", + "कैमरा", + "एसएलआर कैमरा", + "औज़ार", + "हथौड़ा", + "कंक्रीट तोड़ने की मशीन", + "आरी चलाना", + "रेती से घिसना", + "रेगमाल से घिसना", + "बिजली के औज़ार", + "ड्रिल मशीन", + "विस्फोट", + "गोली चलने की आवाज़", + "मशीनगन", + "एक साथ गोलीबारी", + "तोपों की गोलाबारी", + "खिलौना बंदूक", + "आतिशबाज़ी", + "पटाखा", + "फट से फूटना", + "ज्वालामुखी फटना", + "गूंजता धमाका", + "लकड़ी", + "कुल्हाड़ी से काटना", + "लकड़ी चिरना", + "चटकने की आवाज़", + "कांच", + "खनक", + "चकनाचूर होना", + "तरल पदार्थ", + "छपाक", + "बर्तन में पानी छलकना", + "गीली चीज़ की पच-पच", + "टपकना", + "पानी उड़ेलना", + "पतली धार में बहना", + "तेज़ धार में बहना", + "बर्तन में तरल भरना", + "छिड़काव", + "पंप से तरल निकालना", + "चम्मच से हिलाना", + "उबलना", + "पनडुब्बी का सोनार", + "तीर", + "हवा चीरने की आवाज़", + "धम्म की आवाज़", + "खोखली ठक", + "सुर मिलाने वाला ट्यूनर", + "ऑडियो इफ़ेक्ट उपकरण", + "कोरस इफ़ेक्ट", + "बास्केटबॉल का टप्पा", + "अचानक तेज़ आवाज़", + "थप्पड़", + "ज़ोरदार प्रहार", + "धड़ाम से टूटना", + "टूटना", + "टप्पे खाना", + "चाबुक की फटकार", + "झंडे की फड़फड़ाहट", + "खरोंचना", + "खुरचना", + "रगड़ना", + "लुढ़कना", + "कुचलना", + "कागज़ मसलना", + "फाड़ना", + "बीप", + "पिंग", + "टन्न", + "धातु की झनझनाहट", + "चीं की तीखी आवाज़", + "चरमराहट", + "सरसराहट", + "हल्की घर्र-घर्र", + "खटर-पटर", + "तेल की छनछनाहट", + "क्लिक की आवाज़", + "लयबद्ध खटखट", + "गड़गड़ाहट", + "पानी में डुबुक", + "खनखनाहट", + "धीमी भनभनाहट", + "तीखी सनसनाहट", + "स्प्रिंग जैसी आवाज़", + "कुरकुराहट", + "सन्नाटा", + "शुद्ध टोन", + "हार्मोनिक टोन", + "चढ़ती या उतरती टोन", + "साउंड इफ़ेक्ट", + "रुक-रुककर आती आवाज़", + "छोटे कमरे के अंदर", + "बड़े कमरे या हॉल के अंदर", + "सार्वजनिक जगह के अंदर", + "बाहर शहरी इलाके में", + "बाहर गांव या प्रकृति में", + "गूंज", + "प्रतिध्वनि", + "शोर", + "आस-पास का शोर", + "सिग्नल की खरखराहट", + "बिजली की भनभनाहट", + "बिगड़ी हुई आवाज़", + "फ़ोन में अपनी आवाज़", + "कर्कश शोर-शराबा", + "व्हाइट नॉइज़", + "पिंक नॉइज़", + "धड़कती आवाज़", + "कंपन की आवाज़", + "टीवी", + "रेडियो", + "किसी जगह की रिकॉर्डिंग" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/id.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/id.json new file mode 100644 index 0000000..8b3ff60 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/id.json @@ -0,0 +1,523 @@ +[ + "Orang berbicara", + "Anak berbicara", + "Percakapan", + "Narasi", + "Celoteh bayi", + "Suara bicara sintetis", + "Teriakan", + "Teriakan menggelegar", + "Pekik kegirangan", + "Teriakan keras", + "Anak-anak berteriak", + "Jeritan", + "Bisikan", + "Tawa", + "Tawa bayi", + "Cekikikan", + "Tawa mengejek", + "Tawa terbahak-bahak", + "Tawa kecil", + "Tangisan", + "Tangisan bayi", + "Rengekan", + "Rintihan", + "Helaan napas", + "Nyanyian", + "Paduan suara", + "Nyanyian yodel", + "Lantunan", + "Mantra", + "Anak bernyanyi", + "Nyanyian sintetis", + "Rap", + "Senandung", + "Erangan", + "Erangan pendek", + "Siulan", + "Napas", + "Mengi", + "Dengkuran", + "Tarikan napas kaget", + "Napas terengah-engah", + "Dengusan", + "Batuk", + "Berdeham", + "Bersin", + "Mengendus", + "Berlari", + "Langkah terseret", + "Langkah kaki", + "Mengunyah", + "Menggigit", + "Berkumur", + "Perut keroncongan", + "Sendawa", + "Cegukan", + "Kentut", + "Suara tangan", + "Jentikan jari", + "Tepukan tangan", + "Detak jantung", + "Murmur jantung", + "Sorak-sorai", + "Tepuk tangan penonton", + "Obrolan banyak orang", + "Kerumunan", + "Riuh suara orang", + "Anak-anak bermain", + "Hewan", + "Hewan peliharaan", + "Anjing", + "Gonggongan", + "Gonggongan anjing kecil", + "Lolongan", + "Guk-guk", + "Geraman", + "Rengekan anjing", + "Kucing", + "Dengkuran kucing", + "Meong", + "Desisan", + "Raungan kucing", + "Hewan ternak", + "Kuda", + "Derap kaki kuda", + "Ringkikan kuda", + "Sapi", + "Lenguhan sapi", + "Lonceng sapi", + "Babi", + "Dengusan babi", + "Kambing", + "Embikan", + "Domba", + "Unggas", + "Ayam", + "Ayam berkotek", + "Ayam jago berkokok", + "Kalkun", + "Suara kalkun jantan", + "Bebek", + "Suara bebek", + "Angsa", + "Suara angsa", + "Hewan liar", + "Singa dan harimau", + "Auman", + "Burung", + "Kicauan burung", + "Cicit burung", + "Pekik burung", + "Burung merpati", + "Suara merpati", + "Burung gagak", + "Suara gagak", + "Burung hantu", + "Suara burung hantu", + "Kepakan sayap", + "Serigala dan anjing liar", + "Hewan pengerat", + "Tikus", + "Derap langkah kecil", + "Serangga", + "Jangkrik", + "Nyamuk", + "Lalat", + "Dengung serangga", + "Lebah dan tawon", + "Katak", + "Suara katak", + "Ular", + "Derik ular", + "Suara paus", + "Musik", + "Alat musik", + "Alat musik petik", + "Gitar", + "Gitar listrik", + "Gitar bas", + "Gitar akustik", + "Gitar slide", + "Teknik tapping gitar", + "Genjrengan gitar", + "Banjo", + "Sitar", + "Mandolin", + "Siter", + "Ukulele", + "Keyboard (musik)", + "Piano", + "Piano listrik", + "Organ", + "Organ elektronik", + "Organ Hammond", + "Synthesizer", + "Sampler", + "Harpsikord", + "Perkusi", + "Drum set", + "Mesin drum", + "Drum", + "Snare drum", + "Rimshot", + "Pukulan drum beruntun", + "Drum bas", + "Timpani", + "Tabla", + "Simbal", + "Hi-hat", + "Balok kayu (perkusi)", + "Tamborin", + "Alat musik kocok", + "Marakas", + "Gong", + "Lonceng tabung", + "Perkusi bernada", + "Marimba dan xilofon", + "Glockenspiel", + "Vibrafon", + "Steelpan", + "Orkestra", + "Alat musik tiup logam", + "French horn", + "Terompet", + "Trombon", + "Alat musik gesek", + "Kelompok alat musik gesek", + "Biola", + "Petikan biola", + "Cello", + "Kontrabas", + "Alat musik tiup kayu", + "Seruling", + "Saksofon", + "Klarinet", + "Harpa", + "Lonceng", + "Lonceng gereja", + "Giring-giring", + "Bel sepeda", + "Garpu tala", + "Melodi lonceng", + "Lonceng angin", + "Lonceng bersahutan", + "Harmonika", + "Akordeon", + "Bagpipe", + "Didgeridoo", + "Shofar (terompet tanduk)", + "Theremin", + "Mangkuk bernyanyi", + "Scratch DJ", + "Musik pop", + "Musik hip hop", + "Beatbox", + "Musik rock", + "Heavy metal", + "Punk rock", + "Grunge", + "Rock progresif", + "Rock and roll", + "Rock psikedelik", + "Rhythm and blues", + "Musik soul", + "Reggae", + "Musik country", + "Musik swing", + "Bluegrass", + "Funk", + "Musik folk", + "Musik Timur Tengah", + "Jazz", + "Disko", + "Musik klasik", + "Opera", + "Musik elektronik", + "Musik house", + "Techno", + "Dubstep", + "Drum and bass", + "Electronica", + "Musik dansa elektronik", + "Musik ambient", + "Musik trance", + "Musik Amerika Latin", + "Musik salsa", + "Flamenco", + "Blues", + "Musik anak-anak", + "Musik new age", + "Musik vokal", + "Akapela", + "Musik Afrika", + "Afrobeat", + "Musik Kristen", + "Musik gospel", + "Musik Asia", + "Musik Karnatik", + "Musik Bollywood", + "Ska", + "Musik tradisional", + "Musik indie", + "Lagu", + "Musik latar", + "Musik tema", + "Jingle iklan", + "Musik pengiring film", + "Lagu nina bobo", + "Musik game", + "Musik Natal", + "Musik dansa", + "Musik pernikahan", + "Musik ceria", + "Musik sedih", + "Musik lembut", + "Musik bersemangat", + "Musik bernada marah", + "Musik seram", + "Angin", + "Gemeresik daun", + "Bising angin (mikrofon)", + "Badai petir", + "Guntur", + "Air", + "Hujan", + "Tetes hujan", + "Hujan di atap/jendela", + "Aliran sungai", + "Air terjun", + "Laut", + "Deburan ombak", + "Uap air", + "Air berdeguk", + "Api", + "Gemeretak api", + "Kendaraan", + "Kendaraan air", + "Perahu layar", + "Perahu dayung", + "Perahu motor", + "Kapal", + "Kendaraan bermotor", + "Mobil", + "Klakson", + "Klakson singkat", + "Alarm mobil", + "Power window mobil", + "Mobil tergelincir", + "Decit ban", + "Mobil melintas", + "Mobil balap", + "Truk", + "Rem angin", + "Klakson truk", + "Alarm mundur", + "Mobil es krim", + "Bus", + "Kendaraan darurat", + "Mobil polisi (sirene)", + "Ambulans (sirene)", + "Mobil pemadam (sirene)", + "Sepeda motor", + "Bising lalu lintas", + "Transportasi rel", + "Kereta api", + "Peluit kereta api", + "Klakson kereta api", + "Gerbong kereta", + "Decit roda kereta", + "Kereta bawah tanah", + "Pesawat udara", + "Mesin pesawat", + "Mesin jet", + "Baling-baling pesawat", + "Helikopter", + "Pesawat terbang", + "Sepeda", + "Skateboard", + "Mesin", + "Mesin kecil (nada tinggi)", + "Bor gigi", + "Mesin pemotong rumput", + "Gergaji mesin", + "Mesin sedang (nada sedang)", + "Mesin besar (nada rendah)", + "Ketukan mesin", + "Mesin dinyalakan", + "Mesin langsam", + "Mesin berakselerasi", + "Pintu", + "Bel pintu", + "Ding-dong (bel pintu)", + "Pintu geser", + "Pintu dibanting", + "Ketukan pintu", + "Ketukan ringan", + "Decit", + "Lemari dibuka/ditutup", + "Laci dibuka/ditutup", + "Piring dan panci", + "Sendok dan garpu", + "Mencincang makanan", + "Menggoreng", + "Microwave", + "Blender", + "Keran air", + "Air di wastafel", + "Air di bak mandi", + "Pengering rambut", + "Siraman toilet", + "Sikat gigi", + "Sikat gigi listrik", + "Penyedot debu", + "Ritsleting", + "Gemerincing kunci", + "Koin jatuh", + "Gunting", + "Alat cukur listrik", + "Mengocok kartu", + "Mengetik", + "Mesin tik", + "Keyboard komputer", + "Menulis", + "Alarm", + "Telepon", + "Dering bel telepon", + "Nada dering", + "Nada tombol telepon", + "Nada pilih telepon", + "Nada sibuk", + "Jam weker", + "Sirene", + "Sirene peringatan bahaya", + "Buzzer", + "Alarm asap", + "Alarm kebakaran", + "Klakson kapal (kabut)", + "Peluit", + "Peluit uap", + "Suara mekanis", + "Ratchet", + "Jam", + "Detak jam", + "Tik-tak jam", + "Roda gigi", + "Katrol", + "Mesin jahit", + "Kipas angin", + "AC (pendingin ruangan)", + "Mesin kasir", + "Printer", + "Kamera", + "Kamera SLR", + "Perkakas", + "Palu", + "Mesin pemecah beton", + "Menggergaji", + "Mengikir", + "Mengamplas", + "Perkakas listrik", + "Bor", + "Ledakan", + "Tembakan", + "Senapan mesin", + "Tembakan serentak", + "Tembakan artileri", + "Pistol mainan", + "Kembang api", + "Petasan", + "Letupan", + "Letusan gunung berapi", + "Dentuman", + "Kayu", + "Membelah kayu", + "Kayu menyerpih", + "Retakan", + "Kaca", + "Denting gelas", + "Kaca pecah", + "Cairan", + "Cipratan", + "Cairan terguncang", + "Benda basah diremas", + "Tetesan", + "Menuang cairan", + "Gemericik", + "Air mengucur deras", + "Wadah diisi cairan", + "Semprotan", + "Pompa cairan", + "Mengaduk", + "Air mendidih", + "Sonar", + "Anak panah", + "Benda melesat", + "Gedebuk", + "Ketukan tumpul", + "Tuner elektronik", + "Unit efek suara", + "Efek chorus", + "Pantulan bola basket", + "Suara keras mendadak", + "Tamparan", + "Pukulan keras", + "Pecah berantakan", + "Benda pecah", + "Benda memantul", + "Lecutan cambuk", + "Kibaran kain", + "Goresan", + "Gesekan benda keras", + "Gosokan", + "Benda menggelinding", + "Benda remuk", + "Kertas diremas", + "Merobek", + "Suara bip", + "Suara ting", + "Denting", + "Dentang logam", + "Decit melengking", + "Derit", + "Gemeresik", + "Deru halus", + "Benda berkelontangan", + "Desis minyak panas", + "Suara klik", + "Suara klik berirama", + "Gemuruh", + "Benda kecil tercebur", + "Gemerincing", + "Dengung", + "Desing", + "Suara pegas", + "Suara kriuk", + "Hening", + "Nada murni (sinus)", + "Nada harmonik", + "Nada naik/turun", + "Efek suara", + "Suara berdenyut", + "Dalam ruangan kecil", + "Dalam ruangan besar/aula", + "Dalam ruang publik", + "Luar ruangan (perkotaan)", + "Luar ruangan (alam)", + "Gaung ruangan", + "Gema", + "Bising", + "Bising lingkungan", + "Suara statis", + "Dengung listrik", + "Distorsi", + "Suara sendiri di telepon", + "Hiruk-pikuk", + "White noise", + "Pink noise", + "Dengung berdenyut", + "Suara getaran", + "Televisi", + "Radio", + "Rekaman suasana" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/it.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/it.json new file mode 100644 index 0000000..6f6d37e --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/it.json @@ -0,0 +1,523 @@ +[ + "Parlato", + "Bambino che parla", + "Conversazione", + "Narrazione", + "Balbettio di bambino", + "Voce sintetica", + "Grido", + "Urlo animalesco", + "Grido di gioia", + "Urlo", + "Bambini che gridano", + "Strillo", + "Sussurro", + "Risata", + "Risata di neonato", + "Risatina", + "Risolino beffardo", + "Risata fragorosa", + "Risata sommessa", + "Pianto", + "Pianto di neonato", + "Piagnucolio", + "Lamento", + "Sospiro", + "Canto", + "Coro", + "Canto jodel", + "Cantilena", + "Mantra", + "Bambino che canta", + "Canto sintetico", + "Rap", + "Canto a bocca chiusa", + "Gemito", + "Grugnito", + "Fischio con la bocca", + "Respiro", + "Respiro sibilante", + "Russare", + "Respiro mozzato", + "Respiro affannoso", + "Sbuffo dal naso", + "Tosse", + "Schiarirsi la gola", + "Starnuto", + "Tirare su col naso", + "Passi di corsa", + "Passi strascicati", + "Passi", + "Masticazione", + "Morso", + "Gargarismi", + "Stomaco che brontola", + "Rutto", + "Singhiozzo", + "Peto", + "Suoni con le mani", + "Schiocco di dita", + "Battito di mani", + "Battito cardiaco", + "Soffio cardiaco", + "Acclamazioni", + "Applausi", + "Chiacchiericcio", + "Folla", + "Vocio confuso", + "Bambini che giocano", + "Animali", + "Animali domestici", + "Cane", + "Abbaio", + "Abbaio acuto", + "Ululato", + "Bau bau", + "Ringhio", + "Guaito", + "Gatto", + "Fusa", + "Miagolio", + "Sibilo", + "Gatto in calore", + "Animali da fattoria", + "Cavallo", + "Zoccoli di cavallo", + "Nitrito", + "Bovini", + "Muggito", + "Campanaccio", + "Maiale", + "Grugnito di maiale", + "Capra", + "Belato", + "Pecora", + "Pollame", + "Gallo e gallina", + "Coccodè", + "Chicchirichì", + "Tacchino", + "Glu glu del tacchino", + "Anatra", + "Qua qua", + "Oca", + "Verso dell'oca", + "Animali selvatici", + "Grandi felini", + "Ruggito", + "Uccelli", + "Canto degli uccelli", + "Cinguettio", + "Strido di uccello", + "Piccione", + "Tubare", + "Corvo", + "Gracchiare", + "Gufo", + "Verso del gufo", + "Battito d'ali", + "Lupi e canidi selvatici", + "Roditori", + "Topo", + "Zampettio", + "Insetti", + "Grillo", + "Zanzara", + "Mosca", + "Ronzio", + "Api e vespe", + "Rana", + "Gracidio", + "Serpente", + "Rumore di sonaglio", + "Canto delle balene", + "Musica", + "Strumento musicale", + "Strumento a corde pizzicate", + "Chitarra", + "Chitarra elettrica", + "Basso elettrico", + "Chitarra acustica", + "Chitarra slide", + "Tapping (chitarra)", + "Accordi di chitarra", + "Banjo", + "Sitar", + "Mandolino", + "Cetra", + "Ukulele", + "Tastiera musicale", + "Pianoforte", + "Piano elettrico", + "Organo", + "Organo elettronico", + "Organo Hammond", + "Sintetizzatore", + "Campionatore", + "Clavicembalo", + "Percussioni", + "Batteria (strumento)", + "Batteria elettronica", + "Tamburo", + "Rullante", + "Rimshot", + "Rullo di tamburo", + "Grancassa", + "Timpani", + "Tabla", + "Piatto (percussione)", + "Charleston (hi-hat)", + "Blocco di legno", + "Tamburello", + "Sonaglio (strumento)", + "Maracas", + "Gong", + "Campane tubolari", + "Percussioni melodiche", + "Xilofono", + "Glockenspiel", + "Vibrafono", + "Steel drum", + "Orchestra", + "Ottoni", + "Corno francese", + "Tromba", + "Trombone", + "Strumento ad arco", + "Sezione d'archi", + "Violino", + "Pizzicato", + "Violoncello", + "Contrabbasso", + "Strumento a fiato", + "Flauto", + "Sassofono", + "Clarinetto", + "Arpa", + "Campana", + "Campana di chiesa", + "Campanellini", + "Campanello della bicicletta", + "Diapason", + "Carillon", + "Campane a vento", + "Concerto di campane", + "Armonica a bocca", + "Fisarmonica", + "Cornamusa", + "Didgeridoo", + "Shofar", + "Theremin", + "Campana tibetana", + "Scratch da DJ", + "Musica pop", + "Musica hip hop", + "Beatbox", + "Musica rock", + "Heavy metal", + "Punk rock", + "Grunge", + "Rock progressivo", + "Rock and roll", + "Rock psichedelico", + "Rhythm and blues", + "Musica soul", + "Reggae", + "Musica country", + "Musica swing", + "Bluegrass", + "Funk", + "Musica folk", + "Musica mediorientale", + "Jazz", + "Musica disco", + "Musica classica", + "Opera lirica", + "Musica elettronica", + "Musica house", + "Techno", + "Dubstep", + "Drum and bass", + "Electronica", + "Musica dance elettronica", + "Musica ambient", + "Musica trance", + "Musica latinoamericana", + "Musica salsa", + "Flamenco", + "Blues", + "Musica per bambini", + "Musica new age", + "Musica vocale", + "A cappella", + "Musica africana", + "Afrobeat", + "Musica cristiana", + "Musica gospel", + "Musica asiatica", + "Musica carnatica", + "Musica di Bollywood", + "Ska", + "Musica tradizionale", + "Musica indie", + "Canzone", + "Musica di sottofondo", + "Sigla musicale", + "Jingle pubblicitario", + "Colonna sonora", + "Ninna nanna", + "Musica da videogioco", + "Musica natalizia", + "Musica da ballo", + "Musica nuziale", + "Musica allegra", + "Musica triste", + "Musica dolce", + "Musica emozionante", + "Musica rabbiosa", + "Musica che fa paura", + "Vento", + "Fruscio di foglie", + "Vento sul microfono", + "Temporale", + "Tuono", + "Acqua", + "Pioggia", + "Goccia di pioggia", + "Pioggia che batte", + "Ruscello", + "Cascata", + "Mare", + "Onde", + "Vapore", + "Gorgoglio", + "Fuoco", + "Crepitio", + "Veicolo", + "Barca", + "Barca a vela", + "Barca a remi", + "Motoscafo", + "Nave", + "Veicolo a motore", + "Auto", + "Clacson", + "Colpo di clacson", + "Allarme dell'auto", + "Finestrini elettrici", + "Sbandata", + "Stridio di gomme", + "Auto che passa", + "Auto da corsa", + "Camion", + "Freno ad aria compressa", + "Clacson del camion", + "Bip di retromarcia", + "Furgone dei gelati", + "Autobus", + "Veicolo di emergenza", + "Sirena della polizia", + "Sirena dell'ambulanza", + "Sirena dei pompieri", + "Moto", + "Rumore del traffico", + "Trasporto ferroviario", + "Treno", + "Fischio del treno", + "Tromba del treno", + "Vagone ferroviario", + "Stridio sui binari", + "Metropolitana", + "Velivolo", + "Motore d'aereo", + "Motore a reazione", + "Elica", + "Elicottero", + "Aereo", + "Bicicletta", + "Skateboard", + "Motore", + "Motore piccolo", + "Trapano del dentista", + "Tosaerba", + "Motosega", + "Motore medio", + "Motore grande", + "Motore che batte in testa", + "Avviamento del motore", + "Motore al minimo", + "Motore che accelera", + "Porta", + "Campanello della porta", + "Din don del campanello", + "Porta scorrevole", + "Porta sbattuta", + "Bussare", + "Colpetto", + "Cigolio", + "Anta dell'armadietto", + "Cassetto", + "Piatti e pentole", + "Posate", + "Coltello sul tagliere", + "Frittura", + "Forno a microonde", + "Frullatore", + "Rubinetto", + "Lavandino", + "Vasca da bagno", + "Asciugacapelli", + "Sciacquone", + "Spazzolino", + "Spazzolino elettrico", + "Aspirapolvere", + "Cerniera lampo", + "Tintinnio di chiavi", + "Moneta che cade", + "Forbici", + "Rasoio elettrico", + "Mescolare le carte", + "Digitazione", + "Macchina da scrivere", + "Tastiera del computer", + "Scrittura a mano", + "Allarme", + "Telefono", + "Squillo del telefono", + "Suoneria", + "Composizione del numero", + "Segnale di linea", + "Segnale di occupato", + "Sveglia", + "Sirena", + "Sirena di protezione civile", + "Cicalino", + "Rilevatore di fumo", + "Allarme antincendio", + "Sirena da nebbia", + "Fischietto", + "Fischio a vapore", + "Meccanismi", + "Cricchetto", + "Orologio", + "Tic metallico", + "Tic tac", + "Ingranaggi", + "Carrucole", + "Macchina da cucire", + "Ventilatore", + "Condizionatore", + "Registratore di cassa", + "Stampante", + "Fotocamera", + "Fotocamera reflex", + "Attrezzi", + "Martello", + "Martello pneumatico", + "Taglio con la sega", + "Limatura", + "Levigatura", + "Utensile elettrico", + "Trapano", + "Esplosione", + "Sparo", + "Mitragliatrice", + "Scarica di fucileria", + "Fuoco d'artiglieria", + "Pistola giocattolo", + "Fuochi d'artificio", + "Petardo", + "Scoppio", + "Eruzione", + "Boato", + "Legno", + "Colpi d'ascia", + "Legno che si scheggia", + "Schiocco", + "Vetro", + "Tintinnio di bicchieri", + "Vetri in frantumi", + "Liquido", + "Schizzi", + "Sciabordio", + "Spugna strizzata", + "Gocciolio", + "Versare un liquido", + "Liquido che cola", + "Liquido che sgorga", + "Riempire un contenitore", + "Spruzzo", + "Pompa per liquidi", + "Mescolare un liquido", + "Ebollizione", + "Sonar", + "Freccia", + "Sibilo nell'aria", + "Tonfo", + "Colpo cupo", + "Accordatore elettronico", + "Processore di effetti", + "Effetto chorus", + "Palleggio (basket)", + "Botto", + "Schiaffo", + "Colpo secco", + "Schianto", + "Rottura", + "Rimbalzo", + "Schiocco di frusta", + "Sventolio", + "Graffio", + "Raschiare", + "Sfregamento", + "Rotolamento", + "Schiacciamento", + "Accartocciamento", + "Strappo", + "Bip", + "Ping", + "Din", + "Fragore metallico", + "Stridio", + "Scricchiolio", + "Fruscio", + "Frullio", + "Oggetti che sbattono", + "Sfrigolio", + "Clic", + "Ticchettio", + "Rombo", + "Oggetto che cade in acqua", + "Tintinnio", + "Ronzio cupo", + "Breve ronzio acuto", + "Boing (molla)", + "Rumore croccante", + "Silenzio", + "Tono puro", + "Tono armonico", + "Tono che sale o scende", + "Effetto sonoro", + "Impulsi regolari", + "In una stanza piccola", + "In una sala grande", + "In un luogo pubblico", + "All'aperto, in città", + "All'aperto, in natura", + "Riverbero", + "Eco", + "Rumore", + "Rumore ambientale", + "Disturbo elettrico", + "Ronzio elettrico", + "Distorsione", + "Autoascolto al telefono", + "Frastuono", + "Rumore bianco", + "Rumore rosa", + "Rumore pulsante", + "Vibrazione meccanica", + "Televisione", + "Radio", + "Registrazione sul campo" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/ja.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/ja.json new file mode 100644 index 0000000..d8946ba --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/ja.json @@ -0,0 +1,523 @@ +[ + "人の話し声", + "子どもの話し声", + "会話", + "ナレーション", + "赤ちゃんの喃語", + "合成音声", + "大声", + "雄たけび", + "喜びの叫び声", + "叫び声", + "子どもたちの大声", + "悲鳴", + "ささやき声", + "笑い声", + "赤ちゃんの笑い声", + "くすくす笑い", + "せせら笑い", + "大笑い", + "含み笑い", + "泣き声", + "赤ちゃんの泣き声", + "すすり泣き", + "うめき声", + "ため息", + "歌声", + "合唱", + "ヨーデル", + "詠唱", + "マントラ", + "子どもの歌声", + "合成された歌声", + "ラップ", + "鼻歌", + "うなり声", + "短いうなり声", + "口笛", + "呼吸音", + "ゼーゼーいう呼吸", + "いびき", + "息をのむ音", + "荒い息づかい", + "鼻を鳴らす音", + "せき", + "せき払い", + "くしゃみ", + "鼻をすする音", + "走る足音", + "足を引きずる音", + "足音", + "食べ物をかむ音", + "かじる音", + "うがい", + "おなかが鳴る音", + "げっぷ", + "しゃっくり", + "おなら", + "手や指の音", + "指を鳴らす音", + "手をたたく音", + "心臓の鼓動", + "心臓の雑音", + "歓声", + "拍手", + "大勢のおしゃべり", + "群衆", + "ざわめき", + "子どもが遊ぶ声", + "動物", + "ペット", + "犬", + "犬のほえ声", + "犬の甲高い鳴き声", + "遠吠え", + "犬の鳴き声(ワンワン)", + "動物のうなり声", + "犬の弱々しい鳴き声", + "猫", + "猫が喉を鳴らす音", + "猫の鳴き声", + "シャーという威嚇音", + "発情期の猫の鳴き声", + "家畜", + "馬", + "馬のひづめの音", + "馬のいななき", + "牛", + "牛の鳴き声", + "カウベル", + "豚", + "豚の鳴き声", + "ヤギ", + "ヤギや羊の鳴き声", + "羊", + "家禽(ニワトリなど)", + "ニワトリ", + "めんどりの鳴き声", + "おんどりの鳴き声", + "七面鳥", + "七面鳥の鳴き声", + "アヒル", + "アヒルの鳴き声", + "ガチョウ", + "ガチョウの鳴き声", + "野生動物", + "ライオン・トラなど", + "猛獣のほえ声", + "鳥", + "鳥の鳴き声", + "小鳥のさえずり", + "鳥のけたたましい声", + "ハト", + "ハトの鳴き声", + "カラス", + "カラスの鳴き声", + "フクロウ", + "フクロウの鳴き声", + "鳥の羽ばたき", + "野生のイヌ科動物", + "げっ歯類(ネズミなど)", + "ネズミ", + "小動物の足音", + "虫", + "コオロギ", + "蚊", + "ハエ", + "虫の羽音", + "ハチ", + "カエル", + "カエルの鳴き声", + "ヘビ", + "ガラガラヘビの音", + "クジラの鳴き声", + "音楽", + "楽器", + "弦をはじく楽器", + "ギター", + "エレキギター", + "ベースギター", + "アコースティックギター", + "スチールギター", + "ギターのタッピング", + "ギターのストローク", + "バンジョー", + "シタール", + "マンドリン", + "琴・ツィター", + "ウクレレ", + "鍵盤楽器", + "ピアノ", + "エレクトリックピアノ", + "オルガン", + "電子オルガン", + "ハモンドオルガン", + "シンセサイザー", + "サンプラー", + "チェンバロ", + "打楽器", + "ドラムセット", + "ドラムマシン", + "ドラム・太鼓", + "スネアドラム", + "リムショット", + "ドラムロール", + "バスドラム", + "ティンパニ", + "タブラ", + "シンバル", + "ハイハット", + "ウッドブロック", + "タンバリン", + "シェイカー(楽器)", + "マラカス", + "ゴング", + "チューブラーベル", + "鍵盤打楽器", + "マリンバ・木琴", + "鉄琴", + "ビブラフォン", + "スチールパン", + "オーケストラ", + "金管楽器", + "ホルン", + "トランペット", + "トロンボーン", + "弓で弾く弦楽器", + "弦楽合奏", + "バイオリン", + "ピチカート奏法", + "チェロ", + "コントラバス", + "木管楽器", + "フルート", + "サックス", + "クラリネット", + "ハープ", + "鐘・ベル", + "教会の鐘", + "鈴", + "自転車のベル", + "音叉", + "チャイム", + "風鈴", + "鐘を順に鳴らす音", + "ハーモニカ", + "アコーディオン", + "バグパイプ", + "ディジュリドゥ", + "ショファル(角笛)", + "テルミン", + "シンギングボウル", + "スクラッチ(DJ)", + "ポップス", + "ヒップホップ", + "ビートボックス", + "ロック", + "ヘビーメタル", + "パンク・ロック", + "グランジ", + "プログレッシブ・ロック", + "ロックンロール", + "サイケデリック・ロック", + "リズム・アンド・ブルース", + "ソウル・ミュージック", + "レゲエ", + "カントリー・ミュージック", + "スウィング・ジャズ", + "ブルーグラス", + "ファンク", + "フォークソング", + "中東の音楽", + "ジャズ", + "ディスコ・ミュージック", + "クラシック音楽", + "オペラ", + "電子音楽", + "ハウス・ミュージック", + "テクノ", + "ダブステップ", + "ドラムンベース", + "エレクトロニカ", + "EDM", + "アンビエント・ミュージック", + "トランス・ミュージック", + "ラテン音楽", + "サルサ", + "フラメンコ", + "ブルース", + "子ども向けの音楽", + "ニューエイジ・ミュージック", + "声楽", + "アカペラ", + "アフリカの音楽", + "アフロビート", + "キリスト教音楽", + "ゴスペル", + "アジアの音楽", + "カルナータカ音楽", + "ボリウッド音楽", + "スカ", + "伝統音楽", + "インディーズ音楽", + "歌", + "BGM", + "テーマ曲", + "CMソング", + "サウンドトラック", + "子守唄", + "ゲーム音楽", + "クリスマス音楽", + "ダンス音楽", + "結婚式の音楽", + "楽しい音楽", + "悲しい音楽", + "優しい音楽", + "わくわくする音楽", + "怒りの音楽", + "怖い音楽", + "風", + "木の葉のざわめき", + "マイクの風切り音", + "雷雨", + "雷", + "水", + "雨", + "雨粒", + "屋根や窓を打つ雨", + "川の流れ", + "滝", + "海", + "波", + "蒸気", + "ゴボゴボという水音", + "火が燃える音", + "パチパチという音", + "乗り物", + "船・ボート", + "帆船・ヨット", + "手こぎボート・カヌー", + "モーターボート", + "大型船", + "自動車・バイク", + "乗用車", + "車のクラクション", + "短いクラクション", + "車の警報音", + "パワーウィンドウ", + "車のスリップ音", + "タイヤのきしむ音", + "車が通り過ぎる音", + "レーシングカー", + "トラック", + "エアブレーキ", + "トラックのエアホーン", + "バック時の警告音", + "アイスクリーム販売車", + "バス", + "緊急車両", + "パトカー(サイレン)", + "救急車(サイレン)", + "消防車(サイレン)", + "バイク", + "交通騒音", + "鉄道", + "列車", + "機関車の汽笛", + "列車の警笛", + "鉄道車両", + "列車の車輪のきしむ音", + "地下鉄", + "航空機", + "航空機のエンジン", + "ジェットエンジン", + "プロペラ", + "ヘリコプター", + "飛行機", + "自転車", + "スケートボード", + "エンジン", + "小型エンジン(高音)", + "歯医者のドリル", + "芝刈り機", + "チェーンソー", + "中型エンジン(中音)", + "大型エンジン(低音)", + "エンジンのノッキング", + "エンジン始動", + "アイドリング", + "エンジンの加速音", + "ドア", + "玄関チャイム", + "ピンポーン(チャイム)", + "引き戸", + "ドアを強く閉める音", + "ノック", + "軽くたたく音", + "キュッという音", + "戸棚の開け閉め", + "引き出しの開け閉め", + "食器や鍋の音", + "ナイフやフォーク", + "包丁で刻む音", + "揚げ物・炒め物の音", + "電子レンジ", + "ミキサー(調理用)", + "蛇口の水音", + "シンクの水音", + "浴槽の水音", + "ドライヤー", + "トイレを流す音", + "歯ブラシ", + "電動歯ブラシ", + "掃除機", + "ファスナー", + "鍵のジャラジャラ", + "コインが落ちる音", + "はさみ", + "電気シェーバー", + "トランプを切る音", + "タイピング", + "タイプライター", + "パソコンのキーボード", + "ペンで書く音", + "警報音", + "電話", + "電話のベル", + "着信音", + "電話のプッシュ音", + "電話の発信音", + "電話の話し中音", + "目覚まし時計", + "サイレン", + "防災サイレン", + "ブザー", + "煙感知器の警報音", + "火災報知器", + "霧笛", + "ホイッスル", + "汽笛", + "機械", + "ラチェット", + "時計", + "カチッという金属音", + "時計のチクタク音", + "歯車", + "滑車", + "ミシン", + "扇風機・ファン", + "エアコン", + "レジ", + "プリンター", + "カメラ", + "一眼レフカメラ", + "工具", + "ハンマー", + "削岩機", + "のこぎり", + "やすりがけ", + "紙やすりがけ", + "電動工具", + "電動ドリル", + "爆発", + "銃声", + "機関銃", + "一斉射撃", + "砲撃", + "おもちゃの銃", + "花火", + "爆竹", + "破裂音", + "噴火", + "ドーンという音", + "木材", + "おので木を割る音", + "木が裂ける音", + "ピシッと割れる音", + "ガラス", + "カチンという音", + "ガラスが割れる音", + "液体", + "水がはねる音", + "液体が揺れる音", + "ぬれた物をつぶす音", + "しずくが落ちる音", + "液体を注ぐ音", + "ちょろちょろ流れる音", + "どっと流れ出る音", + "容器に液体を満たす音", + "スプレーの噴射音", + "液体のポンプ音", + "かき混ぜる音", + "沸騰する音", + "ソナー", + "矢", + "ヒュッと空を切る音", + "ドスンという音", + "ゴトンという音", + "楽器用チューナー", + "エフェクター", + "コーラス効果", + "バスケのドリブル音", + "バンという音", + "平手でたたく音", + "バシッと打つ音", + "ガシャンと壊れる音", + "物が割れる音", + "物が弾む音", + "むちの音", + "はためく音", + "ひっかく音", + "ガリガリこする音", + "こすれる音", + "転がる音", + "押しつぶす音", + "紙などを丸める音", + "引き裂く音", + "ピッという電子音", + "ピーンと響く音", + "チーンという音", + "ガーンという金属音", + "キーッという音", + "ギシギシきしむ音", + "カサカサという音", + "ウィーンという音", + "ガタガタという音", + "ジュージューという音", + "クリック音", + "カタカタという音", + "ゴーッという低い音", + "ポチャンという音", + "金属が触れ合う音", + "ブーンという音", + "ピュンという音", + "ビヨーンという音", + "バリッと砕ける音", + "無音", + "サイン波", + "倍音", + "音程が変わる電子音", + "効果音", + "規則的な断続音", + "室内(小さな部屋)", + "室内(広間・ホール)", + "室内(公共の場)", + "屋外(街中)", + "屋外(田舎・自然)", + "残響", + "こだま", + "ノイズ", + "環境騒音", + "ザーッという電気ノイズ", + "電源のハムノイズ", + "音の歪み", + "受話器に返る自分の声", + "やかましい騒音", + "ホワイトノイズ", + "ピンクノイズ", + "脈打つような音", + "振動音", + "テレビ", + "ラジオ", + "フィールド録音" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/ko.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/ko.json new file mode 100644 index 0000000..e5b0eda --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/ko.json @@ -0,0 +1,523 @@ +[ + "말소리", + "아이 말소리", + "대화", + "내레이션", + "옹알이", + "합성 음성", + "외침", + "우렁찬 고함", + "환호성", + "고함", + "아이들 함성", + "비명", + "속삭임", + "웃음소리", + "아기 웃음소리", + "키득키득 웃음", + "비웃음", + "폭소", + "조용한 웃음", + "울음소리", + "아기 울음소리", + "흐느낌", + "신음", + "한숨", + "노랫소리", + "합창", + "요들", + "읊조림", + "만트라", + "아이 노랫소리", + "합성 노랫소리", + "랩", + "콧노래", + "앓는 소리", + "끙 소리", + "휘파람", + "숨소리", + "쌕쌕거림", + "코골이", + "헉 하는 소리", + "헐떡임", + "콧방귀", + "기침", + "헛기침", + "재채기", + "코 훌쩍임", + "달리는 발소리", + "발 끄는 소리", + "발소리", + "씹는 소리", + "깨무는 소리", + "가글하는 소리", + "배 꼬르륵 소리", + "트림", + "딸꾹질", + "방귀", + "손으로 내는 소리", + "손가락 튕기는 소리", + "손뼉 치는 소리", + "심장 박동", + "심장 잡음", + "응원 함성", + "박수갈채", + "사람들 잡담", + "군중", + "왁자지껄한 소리", + "아이들 노는 소리", + "동물", + "반려동물", + "개", + "개 짖는 소리", + "작은 개 짖는 소리", + "울부짖음 (개·늑대)", + "멍멍", + "으르렁거림", + "개 낑낑거림", + "고양이", + "고양이 골골거림", + "고양이 울음소리", + "쉬익 소리 (하악질)", + "고양이 발정 울음", + "가축", + "말 (동물)", + "말발굽 소리", + "말 울음소리", + "소", + "소 울음소리", + "카우벨", + "돼지", + "돼지 울음소리", + "염소", + "양·염소 울음소리", + "양", + "가금류", + "닭", + "닭 꼬꼬 소리", + "수탉 울음소리", + "칠면조", + "칠면조 울음소리", + "오리", + "오리 울음소리", + "거위", + "거위 울음소리", + "야생동물", + "사자·호랑이", + "맹수 포효", + "새", + "새 울음소리", + "새 지저귐", + "날카로운 새 울음소리", + "비둘기", + "비둘기 울음소리", + "까마귀", + "까마귀 울음소리", + "부엉이", + "부엉이 울음소리", + "새 날갯짓", + "늑대·들개", + "쥐 (설치류)", + "생쥐", + "작은 동물 발소리", + "곤충", + "귀뚜라미", + "모기", + "파리", + "윙윙 소리", + "벌", + "개구리", + "개구리 울음소리", + "뱀", + "덜거덕거리는 소리", + "고래 울음소리", + "음악", + "악기", + "뜯는 현악기", + "기타 (악기)", + "일렉트릭 기타", + "베이스 기타", + "어쿠스틱 기타", + "슬라이드 기타", + "기타 태핑", + "기타 스트로크", + "밴조", + "시타르", + "만돌린", + "치터", + "우쿨렐레", + "건반악기", + "피아노", + "일렉트릭 피아노", + "오르간", + "전자 오르간", + "해먼드 오르간", + "신시사이저", + "샘플러", + "하프시코드", + "타악기", + "드럼 세트", + "드럼 머신", + "북·드럼", + "스네어 드럼", + "림샷", + "드럼 롤", + "베이스 드럼", + "팀파니", + "타블라", + "심벌즈", + "하이햇", + "우드블록", + "탬버린", + "셰이커", + "마라카스", + "징", + "튜블러 벨", + "건반 타악기", + "실로폰·마림바", + "글로켄슈필", + "비브라폰", + "스틸팬", + "오케스트라", + "금관악기", + "호른", + "트럼펫", + "트롬본", + "켜는 현악기", + "현악 합주", + "바이올린", + "피치카토", + "첼로", + "콘트라베이스", + "목관악기", + "플루트", + "색소폰", + "클라리넷", + "하프", + "종소리", + "교회 종소리", + "방울 소리", + "자전거 벨", + "소리굽쇠", + "차임벨", + "윈드차임", + "교회 종 연주", + "하모니카", + "아코디언", + "백파이프", + "디저리두", + "쇼파르 (뿔나팔)", + "테레민", + "싱잉볼", + "DJ 스크래치", + "팝 음악", + "힙합", + "비트박스", + "록 음악", + "헤비메탈", + "펑크 록", + "그런지 록", + "프로그레시브 록", + "로큰롤", + "사이키델릭 록", + "R&B", + "소울 음악", + "레게", + "컨트리 음악", + "스윙 음악", + "블루그래스", + "펑크 (Funk)", + "포크 음악", + "중동 음악", + "재즈", + "디스코", + "클래식 음악", + "오페라", + "전자 음악", + "하우스 음악", + "테크노", + "덥스텝", + "드럼 앤 베이스", + "일렉트로니카", + "EDM", + "앰비언트 음악", + "트랜스 음악", + "라틴 음악", + "살사 음악", + "플라멩코", + "블루스", + "동요", + "뉴에이지 음악", + "보컬 음악", + "아카펠라", + "아프리카 음악", + "아프로비트", + "기독교 음악", + "가스펠", + "아시아 음악", + "남인도 고전 음악", + "발리우드 음악", + "스카 음악", + "전통 음악", + "인디 음악", + "노래", + "배경 음악", + "주제곡", + "광고 음악", + "OST", + "자장가", + "게임 음악", + "크리스마스 음악", + "댄스 음악", + "결혼식 음악", + "밝은 음악", + "슬픈 음악", + "부드러운 음악", + "신나는 음악", + "분노에 찬 음악", + "무서운 음악", + "바람", + "나뭇잎 바스락 소리", + "마이크 바람 잡음", + "천둥 번개", + "천둥", + "물", + "비", + "빗방울", + "빗소리 (지붕·창문)", + "시냇물", + "폭포", + "바다", + "파도", + "증기 뿜는 소리", + "꼬르륵 물소리", + "불타는 소리", + "타닥타닥 소리", + "탈것", + "배·보트", + "돛단배", + "노 젓는 배", + "모터보트", + "선박", + "도로 차량", + "자동차", + "자동차 경적", + "짧은 경적", + "자동차 경보음", + "자동차 전동 창문", + "차 미끄러지는 소리", + "타이어 끼익 소리", + "지나가는 자동차", + "자동차 경주", + "트럭", + "에어 브레이크", + "트럭 경적", + "후진 경고음", + "아이스크림 트럭", + "버스", + "긴급 차량", + "경찰차 사이렌", + "구급차 사이렌", + "소방차 사이렌", + "오토바이", + "교통 소음", + "철도", + "기차", + "기차 기적 소리", + "기차 경적", + "객차·화차", + "기차 바퀴 끼익 소리", + "지하철", + "항공기", + "항공기 엔진", + "제트 엔진", + "프로펠러", + "헬리콥터", + "비행기", + "자전거", + "스케이트보드", + "엔진", + "소형 엔진 (고음)", + "치과 드릴", + "잔디깎이", + "전기톱", + "중형 엔진 (중음)", + "대형 엔진 (저음)", + "엔진 노킹", + "엔진 시동", + "엔진 공회전", + "엔진 가속", + "문", + "초인종", + "딩동", + "미닫이문", + "문 쾅 닫는 소리", + "노크 소리", + "톡톡 두드리는 소리", + "끽 소리", + "찬장 여닫는 소리", + "서랍 여닫는 소리", + "그릇·냄비 소리", + "수저·포크 소리", + "칼질 소리", + "기름에 튀기는 소리", + "전자레인지", + "믹서기", + "수도꼭지 물소리", + "싱크대 물소리", + "욕조 물소리", + "헤어드라이어", + "변기 물 내리는 소리", + "칫솔질", + "전동 칫솔", + "진공청소기", + "지퍼", + "열쇠 짤랑이는 소리", + "동전 떨어지는 소리", + "가위", + "전기면도기", + "카드 섞는 소리", + "타자 치는 소리", + "타자기", + "컴퓨터 키보드", + "글씨 쓰는 소리", + "경보음", + "전화기", + "따르릉 전화벨", + "휴대폰 벨소리", + "전화 버튼음", + "전화 발신음", + "통화 중 신호음", + "알람 시계", + "사이렌", + "민방위 사이렌", + "버저", + "연기 감지기 경보음", + "화재 경보음", + "안개 경적", + "호루라기", + "증기 기적 소리", + "기계 장치", + "라쳇 드르륵 소리", + "시계", + "째깍 소리", + "시계 째깍째깍", + "톱니바퀴", + "도르래", + "재봉틀", + "선풍기", + "에어컨", + "금전 등록기", + "프린터", + "카메라", + "DSLR 카메라", + "공구", + "망치", + "착암기", + "톱질", + "줄칼질", + "사포질", + "전동 공구", + "드릴", + "폭발음", + "총소리", + "기관총", + "일제 사격", + "포격", + "장난감 총", + "불꽃놀이", + "폭죽", + "펑 터지는 소리", + "화산 폭발", + "굉음", + "나무", + "도끼질", + "나무 쪼개지는 소리", + "쩍 갈라지는 소리", + "유리", + "유리잔 부딪치는 소리", + "유리 깨지는 소리", + "액체", + "물 튀는 소리", + "물 출렁이는 소리", + "질퍽거리는 소리", + "물방울 소리", + "물 따르는 소리", + "졸졸 흐르는 소리", + "콸콸 쏟아지는 소리", + "물 받는 소리", + "칙칙 뿌리는 소리", + "물 펌프 소리", + "휘젓는 소리", + "끓는 소리", + "음파 탐지기", + "화살", + "휙 소리", + "쿵 소리", + "퉁 소리", + "악기 튜너", + "이펙터", + "코러스 효과", + "농구공 튀는 소리", + "쾅 소리", + "찰싹 소리", + "퍽 치는 소리", + "와장창 소리", + "깨지는 소리", + "통통 튀는 소리", + "채찍 소리", + "펄럭이는 소리", + "긁는 소리", + "긁어내는 소리", + "문지르는 소리", + "굴러가는 소리", + "으스러지는 소리", + "종이 구기는 소리", + "찢어지는 소리", + "삐 소리", + "핑 소리", + "띵 소리", + "요란한 쇳소리", + "끼익 소리", + "삐걱 소리", + "바스락 소리", + "윙 돌아가는 소리", + "달그락 소리", + "지글지글 소리", + "딸깍 소리", + "철컥철컥 소리", + "우르릉 소리", + "퐁당 소리", + "짤랑짤랑 소리", + "웅 하는 소리", + "짧고 높은 윙 소리", + "용수철 튕기는 소리", + "바삭 소리", + "무음", + "사인파", + "배음", + "음높이 변하는 신호음", + "효과음", + "규칙적인 펄스음", + "실내 (작은 방)", + "실내 (큰 방·홀)", + "실내 (공공장소)", + "실외 (도시)", + "실외 (자연)", + "잔향 (울림)", + "메아리", + "잡음", + "주변 소음", + "지지직 잡음", + "전기 웅웅 잡음", + "소리 왜곡", + "통화 되울림 (측음)", + "시끄러운 불협화음", + "백색 소음", + "핑크 노이즈", + "둥둥 울리는 소리", + "진동 소리", + "텔레비전", + "라디오", + "현장 녹음" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/pl.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/pl.json new file mode 100644 index 0000000..2e6accb --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/pl.json @@ -0,0 +1,523 @@ +[ + "Mowa", + "Mowa dziecka", + "Rozmowa", + "Narracja, monolog", + "Gaworzenie", + "Syntezator mowy", + "Krzyk", + "Ryk człowieka", + "Okrzyk radości", + "Wrzask", + "Krzyki dzieci", + "Przeraźliwy krzyk", + "Szept", + "Śmiech", + "Śmiech niemowlęcia", + "Chichot", + "Złośliwy chichot", + "Gromki śmiech", + "Cichy śmiech", + "Płacz", + "Płacz niemowlęcia", + "Pochlipywanie", + "Jęk, zawodzenie", + "Westchnienie", + "Śpiew", + "Chór", + "Jodłowanie", + "Chorał, skandowanie", + "Mantra", + "Śpiew dziecka", + "Śpiew syntetyczny", + "Rapowanie", + "Nucenie", + "Stękanie", + "Mruknięcie", + "Gwizdanie", + "Oddech", + "Świszczący oddech", + "Chrapanie", + "Gwałtowny wdech", + "Dyszenie", + "Parsknięcie", + "Kaszel", + "Odchrząkiwanie", + "Kichanie", + "Pociąganie nosem", + "Bieganie", + "Szuranie nogami", + "Kroki", + "Żucie", + "Gryzienie", + "Płukanie gardła", + "Burczenie w brzuchu", + "Bekanie", + "Czkawka", + "Puszczanie bąków", + "Odgłosy rąk", + "Pstrykanie palcami", + "Klaskanie", + "Bicie serca", + "Szmer serca", + "Wiwaty", + "Oklaski", + "Gwar rozmów", + "Tłum", + "Zgiełk głosów", + "Bawiące się dzieci", + "Zwierzęta", + "Zwierzęta domowe", + "Pies", + "Szczekanie", + "Ujadanie", + "Wycie", + "Przeciągłe szczekanie", + "Warczenie", + "Skomlenie psa", + "Kot", + "Mruczenie", + "Miauczenie", + "Syczenie", + "Kocie zawodzenie", + "Zwierzęta gospodarskie", + "Koń", + "Stukot kopyt", + "Rżenie", + "Bydło", + "Muczenie", + "Krowi dzwonek", + "Świnia", + "Chrząkanie świni", + "Koza", + "Beczenie", + "Owca", + "Drób", + "Kura, kogut", + "Gdakanie", + "Pianie koguta", + "Indyk", + "Gulgotanie indyka", + "Kaczka", + "Kwakanie", + "Gęś", + "Gęganie", + "Dzikie zwierzęta", + "Lwy, tygrysy", + "Ryk", + "Ptak", + "Śpiew ptaków", + "Ćwierkanie", + "Skrzeczenie", + "Gołąb", + "Gruchanie", + "Wrona", + "Krakanie", + "Sowa", + "Pohukiwanie", + "Trzepot skrzydeł", + "Wilki i dzikie psy", + "Gryzonie", + "Mysz", + "Tupot łapek", + "Owad", + "Świerszcz", + "Komar", + "Mucha", + "Bzyczenie", + "Pszczoła, osa", + "Żaba", + "Kumkanie", + "Wąż", + "Grzechotanie", + "Głosy wielorybów", + "Muzyka", + "Instrument muzyczny", + "Instrument szarpany", + "Gitara", + "Gitara elektryczna", + "Gitara basowa", + "Gitara akustyczna", + "Gitara hawajska (slide)", + "Tapping na gitarze", + "Brzdąkanie na gitarze", + "Banjo", + "Sitar", + "Mandolina", + "Cytra", + "Ukulele", + "Instrument klawiszowy", + "Fortepian", + "Pianino elektryczne", + "Organy", + "Organy elektroniczne", + "Organy Hammonda", + "Syntezator", + "Sampler", + "Klawesyn", + "Instrument perkusyjny", + "Perkusja", + "Automat perkusyjny", + "Bęben", + "Werbel", + "Rimshot na werblu", + "Werble (tremolo)", + "Bęben basowy", + "Kotły (tympany)", + "Tabla", + "Talerz perkusyjny", + "Hi-hat", + "Drewniany klocek", + "Tamburyn", + "Grzechotka", + "Marakasy", + "Gong", + "Dzwony rurowe", + "Instrument sztabkowy", + "Ksylofon, marimba", + "Dzwonki (cymbałki)", + "Wibrafon", + "Steelpan (bęben stalowy)", + "Orkiestra", + "Instrument dęty blaszany", + "Waltornia", + "Trąbka", + "Puzon", + "Instrument smyczkowy", + "Sekcja smyczkowa", + "Skrzypce", + "Pizzicato", + "Wiolonczela", + "Kontrabas", + "Instrument dęty drewniany", + "Flet", + "Saksofon", + "Klarnet", + "Harfa", + "Dzwon", + "Dzwon kościelny", + "Dzwoneczki", + "Dzwonek rowerowy", + "Kamerton", + "Kuranty", + "Dzwonki wietrzne", + "Bicie w dzwony", + "Harmonijka ustna", + "Akordeon", + "Dudy", + "Didgeridoo", + "Szofar", + "Theremin", + "Misa tybetańska", + "Skreczowanie (DJ)", + "Muzyka pop", + "Muzyka hip-hopowa", + "Beatbox", + "Muzyka rockowa", + "Heavy metal", + "Punk rock", + "Grunge", + "Rock progresywny", + "Rock and roll", + "Rock psychodeliczny", + "Rhythm and blues", + "Muzyka soul", + "Reggae", + "Muzyka country", + "Muzyka swingowa", + "Bluegrass", + "Funk", + "Muzyka folkowa", + "Muzyka Bliskiego Wschodu", + "Jazz", + "Disco", + "Muzyka klasyczna", + "Opera", + "Muzyka elektroniczna", + "Muzyka house", + "Techno", + "Dubstep", + "Drum and bass", + "Electronica", + "Muzyka klubowa (EDM)", + "Muzyka ambient", + "Muzyka trance", + "Muzyka latynoamerykańska", + "Muzyka salsa", + "Flamenco", + "Blues", + "Muzyka dla dzieci", + "Muzyka new age", + "Muzyka wokalna", + "A cappella", + "Muzyka afrykańska", + "Afrobeat", + "Muzyka chrześcijańska", + "Muzyka gospel", + "Muzyka azjatycka", + "Muzyka karnatacka", + "Muzyka bollywoodzka", + "Ska", + "Muzyka tradycyjna", + "Muzyka niezależna", + "Piosenka", + "Muzyka w tle", + "Muzyka z czołówki", + "Dżingiel reklamowy", + "Muzyka filmowa", + "Kołysanka", + "Muzyka z gier", + "Muzyka świąteczna", + "Muzyka taneczna", + "Muzyka weselna", + "Wesoła muzyka", + "Smutna muzyka", + "Łagodna muzyka", + "Ekscytująca muzyka", + "Agresywna muzyka", + "Muzyka grozy", + "Wiatr", + "Szelest liści", + "Szum wiatru w mikrofonie", + "Burza", + "Grzmot", + "Woda", + "Deszcz", + "Kropla deszczu", + "Bębnienie deszczu", + "Strumień", + "Wodospad", + "Morze, ocean", + "Fale morskie", + "Para wodna", + "Bulgotanie", + "Ogień", + "Trzaskanie ognia", + "Pojazd", + "Łodzie i statki", + "Żaglówka, żaglowiec", + "Łódź wiosłowa, kajak", + "Motorówka", + "Statek", + "Pojazd silnikowy", + "Samochód", + "Klakson", + "Krótkie trąbnięcie", + "Alarm samochodowy", + "Elektryczne szyby", + "Poślizg", + "Pisk opon", + "Przejeżdżający samochód", + "Wyścigi samochodowe", + "Ciężarówka", + "Hamulec pneumatyczny", + "Klakson ciężarówki", + "Sygnał cofania", + "Samochód z lodami", + "Autobus", + "Pojazd uprzywilejowany", + "Radiowóz (syrena)", + "Karetka (syrena)", + "Wóz strażacki (syrena)", + "Motocykl", + "Szum ruchu ulicznego", + "Kolej", + "Pociąg", + "Gwizd pociągu", + "Syrena pociągu", + "Wagon kolejowy", + "Pisk kół pociągu", + "Metro", + "Statek powietrzny", + "Silnik lotniczy", + "Silnik odrzutowy", + "Śmigło", + "Helikopter", + "Samolot", + "Rower", + "Deskorolka", + "Silnik", + "Mały silnik", + "Wiertarka dentystyczna", + "Kosiarka", + "Piła łańcuchowa", + "Średni silnik", + "Duży silnik", + "Stukanie silnika", + "Uruchamianie silnika", + "Silnik na biegu jałowym", + "Przyspieszający pojazd", + "Drzwi", + "Dzwonek do drzwi", + "Ding-dong dzwonka", + "Drzwi przesuwne", + "Trzaśnięcie drzwiami", + "Pukanie", + "Postukiwanie", + "Piszczenie", + "Drzwiczki szafki", + "Szuflada", + "Stukot naczyń", + "Sztućce", + "Siekanie", + "Smażenie", + "Mikrofalówka", + "Blender", + "Kran", + "Woda w zlewie", + "Woda w wannie", + "Suszarka do włosów", + "Spłukiwanie toalety", + "Szczoteczka do zębów", + "Szczoteczka elektryczna", + "Odkurzacz", + "Zamek błyskawiczny", + "Brzęk kluczy", + "Upadająca moneta", + "Nożyczki", + "Golarka elektryczna", + "Tasowanie kart", + "Stukanie w klawisze", + "Maszyna do pisania", + "Klawiatura komputera", + "Pisanie odręczne", + "Alarm", + "Telefon", + "Dzwonienie telefonu", + "Dzwonek komórki", + "Wybieranie numeru", + "Sygnał zgłoszenia centrali", + "Sygnał zajętości", + "Budzik", + "Syrena", + "Syrena obrony cywilnej", + "Brzęczyk", + "Czujnik dymu", + "Alarm pożarowy", + "Syrena mgłowa", + "Gwizdek", + "Gwizdek parowy", + "Mechanizmy", + "Mechanizm zapadkowy", + "Zegar", + "Tyknięcie", + "Tykanie zegara", + "Koła zębate", + "Bloczki linowe", + "Maszyna do szycia", + "Wentylator", + "Klimatyzacja", + "Kasa fiskalna", + "Drukarka", + "Aparat fotograficzny", + "Lustrzanka", + "Narzędzia", + "Młotek", + "Młot pneumatyczny", + "Cięcie piłą", + "Piłowanie pilnikiem", + "Szlifowanie", + "Elektronarzędzie", + "Wiertarka", + "Wybuch", + "Strzał", + "Karabin maszynowy", + "Salwa karabinowa", + "Ostrzał artyleryjski", + "Pistolet na kapiszony", + "Fajerwerki", + "Petarda", + "Pęknięcie (np. balonu)", + "Erupcja wulkanu", + "Głęboki huk", + "Drewno", + "Rąbanie drewna", + "Łamanie drewna", + "Trzask", + "Szkło", + "Brzęk szkła", + "Tłuczenie szkła", + "Ciecz", + "Plusk", + "Chlupotanie", + "Ściskanie czegoś mokrego", + "Kapanie", + "Nalewanie", + "Ciurkanie", + "Chlustanie", + "Napełnianie naczynia", + "Rozpylanie", + "Pompowanie cieczy", + "Mieszanie", + "Wrzenie", + "Sonar", + "Strzała z łuku", + "Świst", + "Głuche uderzenie", + "Głuchy stuk", + "Stroik elektroniczny", + "Procesor efektów", + "Efekt chorus", + "Kozłowanie piłki", + "Huk", + "Plaśnięcie", + "Mocne uderzenie", + "Roztrzaskanie", + "Łamanie", + "Odbijanie się", + "Trzask bicza", + "Łopotanie", + "Drapanie", + "Skrobanie", + "Pocieranie", + "Toczenie się", + "Miażdżenie", + "Gniecenie papieru, folii", + "Rozdzieranie", + "Piknięcie", + "Ping", + "Dzyń", + "Szczęk metalu", + "Pisk", + "Skrzypienie", + "Szelest", + "Furkot", + "Klekot", + "Skwierczenie", + "Klikanie", + "Rytmiczny stukot", + "Dudnienie", + "Wpadnięcie do wody", + "Pobrzękiwanie", + "Buczenie", + "Krótkie brzęczenie", + "Dźwięk sprężyny", + "Chrzęst", + "Cisza", + "Czysty ton (sinusoida)", + "Ton harmoniczny", + "Ton o zmiennej wysokości", + "Efekt dźwiękowy", + "Pulsowanie", + "W małym pomieszczeniu", + "W dużej sali", + "W budynku publicznym", + "Na zewnątrz, w mieście", + "Na zewnątrz, poza miastem", + "Pogłos", + "Echo", + "Szum", + "Szum otoczenia", + "Trzaski zakłóceń", + "Buczenie elektryczne", + "Zniekształcenie", + "Własny głos w słuchawce", + "Kakofonia", + "Biały szum", + "Różowy szum", + "Rytmiczne dudnienie", + "Drgania", + "Telewizor", + "Radio", + "Nagranie terenowe" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/pt-BR.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/pt-BR.json new file mode 100644 index 0000000..b9ed1a1 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/pt-BR.json @@ -0,0 +1,523 @@ +[ + "Fala", + "Criança falando", + "Conversa", + "Narração", + "Balbucio", + "Voz sintetizada", + "Grito", + "Urro", + "Grito de euforia", + "Berro", + "Crianças gritando", + "Grito agudo", + "Sussurro", + "Risada", + "Risada de bebê", + "Risadinha", + "Risinho de deboche", + "Gargalhada", + "Riso contido", + "Choro", + "Choro de bebê", + "Choramingo", + "Lamento", + "Suspiro", + "Canto", + "Coral", + "Canto tirolês", + "Cântico", + "Mantra", + "Criança cantando", + "Canto sintetizado", + "Rap", + "Cantarolar", + "Gemido", + "Grunhido", + "Assobio", + "Respiração", + "Chiado no peito", + "Ronco", + "Suspiro de susto", + "Respiração ofegante", + "Bufada", + "Tosse", + "Pigarro", + "Espirro", + "Fungada", + "Passos de corrida", + "Passos arrastados", + "Passos", + "Mastigação", + "Mordida", + "Gargarejo", + "Barriga roncando", + "Arroto", + "Soluço", + "Pum", + "Mãos", + "Estalo de dedos", + "Palmas", + "Batimentos cardíacos", + "Sopro cardíaco", + "Torcida", + "Aplausos", + "Burburinho", + "Multidão", + "Falatório", + "Crianças brincando", + "Animal", + "Animais de estimação", + "Cachorro", + "Latido", + "Latido agudo", + "Uivo", + "Au-au", + "Rosnado", + "Ganido", + "Gato", + "Ronronar", + "Miado", + "Chiado", + "Miado de gato no cio", + "Animais de fazenda", + "Cavalo", + "Cascos de cavalo", + "Relincho", + "Gado", + "Mugido", + "Sino de vaca", + "Porco", + "Grunhido de porco", + "Cabra", + "Balido", + "Ovelha", + "Aves domésticas", + "Galinha e galo", + "Cacarejo", + "Canto do galo", + "Peru", + "Glu-glu de peru", + "Pato", + "Grasnado de pato", + "Ganso", + "Grasnado de ganso", + "Animais selvagens", + "Grandes felinos", + "Rugido", + "Pássaro", + "Canto de pássaro", + "Piado", + "Grasnado", + "Pombo", + "Arrulho de pombo", + "Corvo", + "Grasnado de corvo", + "Coruja", + "Piado de coruja", + "Bater de asas", + "Lobos e cães selvagens", + "Roedores", + "Camundongo", + "Passinhos de animal", + "Inseto", + "Grilo", + "Mosquito", + "Mosca", + "Zumbido de inseto", + "Abelha ou vespa", + "Sapo", + "Coaxar", + "Cobra", + "Chocalho de cascavel", + "Canto de baleia", + "Música", + "Instrumento musical", + "Cordas dedilhadas", + "Violão ou guitarra", + "Guitarra elétrica", + "Baixo elétrico", + "Violão", + "Guitarra havaiana (slide)", + "Tapping na guitarra", + "Batida de violão", + "Banjo", + "Sitar", + "Bandolim", + "Cítara", + "Ukulele", + "Teclado musical", + "Piano", + "Piano elétrico", + "Órgão", + "Órgão eletrônico", + "Órgão Hammond", + "Sintetizador", + "Sampler", + "Cravo (instrumento)", + "Percussão", + "Bateria", + "Bateria eletrônica", + "Tambor", + "Caixa-clara", + "Batida no aro", + "Rufar de tambor", + "Bumbo", + "Tímpano (instrumento)", + "Tabla", + "Prato de bateria", + "Chimbal", + "Bloco de madeira", + "Pandeiro", + "Chocalho", + "Maraca", + "Gongo", + "Sinos tubulares", + "Percussão melódica", + "Xilofone ou marimba", + "Glockenspiel", + "Vibrafone", + "Tambor de aço", + "Orquestra", + "Instrumento de sopro de metal", + "Trompa", + "Trompete", + "Trombone", + "Cordas com arco", + "Seção de cordas", + "Violino", + "Pizzicato", + "Violoncelo", + "Contrabaixo acústico", + "Instrumento de sopro", + "Flauta", + "Saxofone", + "Clarinete", + "Harpa", + "Sino", + "Sino de igreja", + "Guizo", + "Campainha de bicicleta", + "Diapasão", + "Carrilhão", + "Mensageiro dos ventos", + "Repique de sinos", + "Gaita de boca", + "Acordeão (sanfona)", + "Gaita de foles", + "Didgeridoo", + "Shofar", + "Teremim", + "Tigela tibetana", + "Scratch de DJ", + "Música pop", + "Hip-hop", + "Beatbox", + "Rock", + "Heavy metal", + "Punk rock", + "Grunge", + "Rock progressivo", + "Rock and roll", + "Rock psicodélico", + "Rhythm and blues", + "Soul", + "Reggae", + "Música country", + "Swing", + "Bluegrass", + "Funk", + "Música folk", + "Música do Oriente Médio", + "Jazz", + "Música disco", + "Música clássica", + "Ópera", + "Música eletrônica", + "Música house", + "Techno", + "Dubstep", + "Drum and bass", + "Electronica", + "Música eletrônica dançante", + "Música ambiente (gênero)", + "Trance", + "Música latino-americana", + "Música salsa", + "Flamenco", + "Blues", + "Música infantil", + "Música new age", + "Música vocal", + "A cappella", + "Música africana", + "Afrobeat", + "Música cristã", + "Gospel", + "Música asiática", + "Música carnática", + "Música de Bollywood", + "Ska", + "Música tradicional", + "Música independente", + "Canção", + "Música de fundo", + "Música-tema", + "Jingle", + "Trilha sonora", + "Canção de ninar", + "Música de videogame", + "Música de Natal", + "Música para dançar", + "Música de casamento", + "Música alegre", + "Música triste", + "Música terna", + "Música empolgante", + "Música raivosa", + "Música assustadora", + "Vento", + "Farfalhar de folhas", + "Vento no microfone", + "Tempestade com trovões", + "Trovão", + "Água", + "Chuva", + "Gota de chuva", + "Chuva batendo", + "Riacho", + "Cachoeira", + "Mar", + "Ondas", + "Vapor", + "Gorgolejo", + "Fogo", + "Crepitar", + "Veículo", + "Barco", + "Veleiro", + "Barco a remo", + "Lancha", + "Navio", + "Veículo motorizado", + "Carro", + "Buzina", + "Buzinada", + "Alarme de carro", + "Vidro elétrico", + "Derrapagem", + "Pneu cantando", + "Carro passando", + "Carro de corrida", + "Caminhão", + "Freio a ar", + "Buzina de caminhão", + "Bipe de ré", + "Caminhão de sorvete", + "Ônibus", + "Veículo de emergência", + "Carro de polícia (sirene)", + "Ambulância (sirene)", + "Carro de bombeiros (sirene)", + "Moto", + "Ruído de trânsito", + "Transporte ferroviário", + "Trem", + "Apito de trem", + "Buzina de trem", + "Vagão de trem", + "Rodas de trem rangendo", + "Metrô", + "Aeronave", + "Motor de aeronave", + "Motor a jato", + "Hélice", + "Helicóptero", + "Avião", + "Bicicleta", + "Skate", + "Motor", + "Motor leve (agudo)", + "Broca de dentista", + "Cortador de grama", + "Motosserra", + "Motor médio", + "Motor pesado (grave)", + "Motor batendo pino", + "Partida de motor", + "Motor em marcha lenta", + "Motor acelerando", + "Porta", + "Campainha", + "Dim-dom da campainha", + "Porta de correr", + "Porta batendo com força", + "Batida na porta", + "Batidinha", + "Rangido", + "Porta de armário", + "Gaveta", + "Louça e panelas", + "Talheres", + "Picando alimentos", + "Fritura", + "Micro-ondas", + "Liquidificador", + "Torneira", + "Água na pia", + "Água na banheira", + "Secador de cabelo", + "Descarga do vaso", + "Escova de dentes", + "Escova de dentes elétrica", + "Aspirador de pó", + "Zíper", + "Chaves tilintando", + "Moeda caindo", + "Tesoura", + "Barbeador elétrico", + "Embaralhando cartas", + "Digitação", + "Máquina de escrever", + "Teclado de computador", + "Escrita à mão", + "Alarme", + "Telefone", + "Campainha de telefone", + "Toque de celular", + "Discagem telefônica", + "Tom de discagem", + "Sinal de ocupado", + "Despertador", + "Sirene", + "Sirene da Defesa Civil", + "Campainha elétrica", + "Detector de fumaça", + "Alarme de incêndio", + "Buzina de nevoeiro", + "Apito", + "Apito a vapor", + "Mecanismos", + "Catraca", + "Relógio", + "Tique de relógio", + "Tique-taque", + "Engrenagens", + "Roldanas", + "Máquina de costura", + "Ventilador", + "Ar-condicionado", + "Caixa registradora", + "Impressora", + "Câmera", + "Câmera reflex", + "Ferramentas", + "Martelo", + "Britadeira", + "Serrando", + "Limando", + "Lixando", + "Ferramenta elétrica", + "Furadeira", + "Explosão", + "Tiro", + "Metralhadora", + "Tiroteio", + "Fogo de artilharia", + "Revólver de espoleta", + "Fogos de artifício", + "Bombinha", + "Estouro", + "Erupção vulcânica", + "Estrondo", + "Madeira", + "Golpe de machado", + "Madeira lascando", + "Estalo", + "Vidro", + "Tilintar de vidro", + "Vidro estilhaçando", + "Líquido", + "Respingo", + "Líquido balançando", + "Espremendo algo molhado", + "Gotejamento", + "Despejando líquido", + "Líquido escorrendo", + "Jorro", + "Enchendo um recipiente", + "Borrifo", + "Bombeando líquido", + "Mexendo líquido", + "Fervura", + "Sonar", + "Flecha", + "Algo cortando o ar", + "Baque", + "Baque oco", + "Afinador eletrônico", + "Pedal de efeitos", + "Efeito chorus", + "Bola de basquete quicando", + "Pancada", + "Tapa", + "Golpe forte", + "Algo se espatifando", + "Algo quebrando", + "Algo quicando", + "Estalo de chicote", + "Pano batendo ao vento", + "Arranhão", + "Raspagem", + "Fricção", + "Algo rolando", + "Esmagamento", + "Amassando papel", + "Algo rasgando", + "Bipe", + "Ping", + "Toque metálico agudo", + "Estrondo metálico", + "Guincho", + "Rangido de madeira", + "Farfalhar", + "Zumbido mecânico", + "Objetos chacoalhando", + "Chiado de fritura", + "Cliques", + "Cliques rítmicos", + "Ruído grave contínuo", + "Algo caindo na água", + "Tilintar", + "Zumbido contínuo", + "Zumbido curto e agudo", + "Som de mola", + "Som crocante", + "Silêncio", + "Tom puro", + "Harmônico", + "Tom deslizante", + "Efeito sonoro", + "Pulsos regulares", + "Dentro de sala pequena", + "Dentro de salão grande", + "Dentro de local público", + "Ao ar livre, na cidade", + "Ao ar livre, na natureza", + "Reverberação", + "Eco", + "Ruído", + "Ruído ambiental", + "Estática", + "Zumbido elétrico", + "Distorção", + "Retorno de voz (telefone)", + "Barulheira", + "Ruído branco", + "Ruído rosa", + "Ruído pulsante", + "Vibração mecânica", + "Televisão", + "Rádio", + "Gravação de campo" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/ru.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/ru.json new file mode 100644 index 0000000..08712b4 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/ru.json @@ -0,0 +1,523 @@ +[ + "Речь", + "Детская речь", + "Разговор", + "Монолог, рассказ", + "Детский лепет", + "Синтезированная речь", + "Крик", + "Рёв человека", + "Радостный возглас", + "Вопль", + "Крики детей", + "Пронзительный крик", + "Шёпот", + "Смех", + "Смех младенца", + "Хихиканье", + "Ехидный смешок", + "Хохот", + "Тихий смех", + "Плач", + "Плач младенца", + "Хныканье", + "Стон", + "Вздох", + "Пение", + "Хор", + "Йодль", + "Песнопение", + "Мантра", + "Детское пение", + "Синтезированное пение", + "Рэп", + "Напевание без слов", + "Оханье", + "Кряхтение", + "Свист", + "Дыхание", + "Свистящее дыхание", + "Храп", + "Резкий вдох", + "Одышка", + "Фырканье", + "Кашель", + "Откашливание", + "Чихание", + "Шмыганье носом", + "Бег", + "Шарканье", + "Шаги", + "Жевание", + "Откусывание", + "Полоскание горла", + "Урчание в животе", + "Отрыжка", + "Икота", + "Кишечные газы", + "Звуки рук", + "Щелчок пальцами", + "Хлопки в ладоши", + "Сердцебиение", + "Шум в сердце", + "Ликование", + "Аплодисменты", + "Болтовня", + "Толпа", + "Гул голосов", + "Играющие дети", + "Животные", + "Домашние животные", + "Собака", + "Лай", + "Тявканье", + "Вой", + "Протяжный лай", + "Рычание", + "Скуление собаки", + "Кошка", + "Мурлыканье", + "Мяуканье", + "Шипение", + "Кошачий вой", + "Животные на ферме", + "Лошадь", + "Цокот копыт", + "Ржание", + "Корова, бык", + "Мычание", + "Коровий колокольчик", + "Свинья", + "Хрюканье", + "Коза", + "Блеяние", + "Овца", + "Домашняя птица", + "Курица, петух", + "Кудахтанье", + "Кукареканье", + "Индейка", + "Крик индюка", + "Утка", + "Кряканье", + "Гусь", + "Гоготание", + "Дикие животные", + "Львы, тигры", + "Рык", + "Птица", + "Пение птиц", + "Чириканье", + "Резкий птичий крик", + "Голубь", + "Воркование", + "Ворона", + "Карканье", + "Сова", + "Уханье совы", + "Хлопанье крыльев", + "Волки, дикие собаки", + "Грызуны", + "Мышь", + "Топот лапок", + "Насекомые", + "Сверчок", + "Комар", + "Муха", + "Жужжание", + "Пчела, оса", + "Лягушка", + "Кваканье", + "Змея", + "Тарахтение", + "Пение китов", + "Музыка", + "Музыкальный инструмент", + "Щипковый инструмент", + "Гитара", + "Электрогитара", + "Бас-гитара", + "Акустическая гитара", + "Слайд-гитара", + "Тэппинг на гитаре", + "Бой по струнам", + "Банджо", + "Ситар", + "Мандолина", + "Цитра", + "Укулеле", + "Клавишный инструмент", + "Фортепиано", + "Электропиано", + "Орган", + "Электронный орган", + "Орган Хаммонда", + "Синтезатор", + "Сэмплер", + "Клавесин", + "Ударный инструмент", + "Ударная установка", + "Драм-машина", + "Барабан", + "Малый барабан", + "Удар по ободу барабана", + "Барабанная дробь", + "Большой барабан", + "Литавры", + "Табла", + "Тарелки (ударные)", + "Хай-хэт", + "Вуд-блок", + "Бубен", + "Погремушка", + "Маракасы", + "Гонг", + "Трубчатые колокола", + "Мелодические ударные", + "Ксилофон, маримба", + "Металлофон", + "Вибрафон", + "Стальной барабан", + "Оркестр", + "Медный духовой инструмент", + "Валторна", + "Труба", + "Тромбон", + "Смычковый инструмент", + "Струнная группа оркестра", + "Скрипка", + "Пиццикато", + "Виолончель", + "Контрабас", + "Духовой инструмент", + "Флейта", + "Саксофон", + "Кларнет", + "Арфа", + "Колокол", + "Церковный колокол", + "Бубенчики", + "Велосипедный звонок", + "Камертон", + "Перезвон", + "Музыка ветра", + "Колокольный звон", + "Губная гармошка", + "Аккордеон", + "Волынка", + "Диджериду", + "Шофар", + "Терменвокс", + "Поющая чаша", + "Диджейский скретч", + "Поп-музыка", + "Хип-хоп", + "Битбокс", + "Рок-музыка", + "Хеви-метал", + "Панк-рок", + "Гранж", + "Прогрессивный рок", + "Рок-н-ролл", + "Психоделический рок", + "Ритм-н-блюз", + "Соул", + "Регги", + "Кантри", + "Свинг", + "Блюграсс", + "Фанк", + "Фолк-музыка", + "Ближневосточная музыка", + "Джаз", + "Диско", + "Классическая музыка", + "Опера", + "Электронная музыка", + "Хаус-музыка", + "Техно", + "Дабстеп", + "Драм-н-бейс", + "Электроника (музыка)", + "Танцевальная электроника", + "Эмбиент", + "Транс-музыка", + "Латиноамериканская музыка", + "Сальса", + "Фламенко", + "Блюз", + "Детская музыка", + "Нью-эйдж", + "Вокальная музыка", + "А капелла", + "Африканская музыка", + "Афробит", + "Христианская музыка", + "Госпел", + "Азиатская музыка", + "Карнатическая музыка", + "Музыка Болливуда", + "Ска", + "Народная музыка", + "Инди-музыка", + "Песня", + "Фоновая музыка", + "Музыкальная заставка", + "Рекламный джингл", + "Саундтрек", + "Колыбельная", + "Музыка из видеоигр", + "Рождественская музыка", + "Танцевальная музыка", + "Свадебная музыка", + "Весёлая музыка", + "Грустная музыка", + "Нежная музыка", + "Захватывающая музыка", + "Агрессивная музыка", + "Пугающая музыка", + "Ветер", + "Шелест листьев", + "Ветер в микрофоне", + "Гроза", + "Гром", + "Вода", + "Дождь", + "Капля дождя", + "Стук дождя", + "Ручей", + "Водопад", + "Океан", + "Волны, прибой", + "Шипение пара", + "Бульканье", + "Огонь", + "Потрескивание", + "Транспорт", + "Водный транспорт", + "Парусник", + "Гребная лодка", + "Моторная лодка, катер", + "Корабль", + "Автотранспорт", + "Автомобиль", + "Автомобильный гудок", + "Короткий гудок", + "Автосигнализация", + "Стеклоподъёмник", + "Занос автомобиля", + "Визг шин", + "Проезжающий автомобиль", + "Автогонки", + "Грузовик", + "Пневматический тормоз", + "Гудок грузовика", + "Сигнал заднего хода", + "Фургон с мороженым", + "Автобус", + "Машина экстренных служб", + "Полицейская машина (сирена)", + "Скорая помощь (сирена)", + "Пожарная машина (сирена)", + "Мотоцикл", + "Шум дорожного движения", + "Рельсовый транспорт", + "Поезд", + "Свисток паровоза", + "Гудок поезда", + "Вагон поезда", + "Визг колёс поезда", + "Метро", + "Воздушный транспорт", + "Двигатель самолёта", + "Реактивный двигатель", + "Пропеллер", + "Вертолёт", + "Самолёт", + "Велосипед", + "Скейтборд", + "Двигатель", + "Малый двигатель", + "Бормашина", + "Газонокосилка", + "Бензопила", + "Средний двигатель", + "Большой двигатель", + "Стук в двигателе", + "Запуск двигателя", + "Холостой ход", + "Рёв двигателя при разгоне", + "Дверь", + "Дверной звонок", + "Динь-дон (дверной звонок)", + "Раздвижная дверь", + "Хлопок двери", + "Стук в дверь", + "Постукивание", + "Писк", + "Дверца шкафа", + "Выдвижной ящик", + "Посуда", + "Столовые приборы", + "Нарезка продуктов", + "Жарка на сковороде", + "Микроволновая печь", + "Блендер", + "Вода из крана", + "Вода в раковине", + "Вода в ванне", + "Фен", + "Смыв унитаза", + "Зубная щётка", + "Электрическая зубная щётка", + "Пылесос", + "Застёжка-молния", + "Звон ключей", + "Падение монеты", + "Ножницы", + "Электробритва", + "Тасование карт", + "Набор текста", + "Пишущая машинка", + "Компьютерная клавиатура", + "Письмо от руки", + "Сигнал тревоги", + "Телефон", + "Звонок телефона", + "Мелодия звонка", + "Тональный набор номера", + "Гудок в трубке", + "Сигнал занятой линии", + "Будильник", + "Сирена", + "Сирена гражданской обороны", + "Зуммер", + "Датчик дыма", + "Пожарная сигнализация", + "Туманный горн", + "Свисток", + "Паровой свисток", + "Механизмы", + "Трещотка (храповик)", + "Часы", + "Тиканье", + "Ход часов (тик-так)", + "Шестерёнки", + "Шкивы", + "Швейная машинка", + "Вентилятор", + "Кондиционер", + "Кассовый аппарат", + "Принтер", + "Фотоаппарат", + "Зеркальный фотоаппарат", + "Рабочие инструменты", + "Молоток", + "Отбойный молоток", + "Пила", + "Напильник", + "Шлифовка", + "Электроинструмент", + "Дрель", + "Взрыв", + "Выстрел", + "Пулемёт", + "Ружейный залп", + "Артиллерийский огонь", + "Игрушечный пистолет", + "Фейерверк", + "Петарда", + "Хлопок", + "Извержение вулкана", + "Грохот", + "Древесина", + "Рубка дров", + "Расщепление древесины", + "Треск", + "Стекло", + "Звяканье", + "Бьющееся стекло", + "Жидкость", + "Всплеск", + "Бултыхание", + "Хлюпанье", + "Капанье", + "Наливание жидкости", + "Тонкая струйка", + "Мощная струя", + "Наполнение ёмкости", + "Распыление", + "Перекачка жидкости", + "Помешивание", + "Кипение", + "Сонар", + "Стрела", + "Рассекание воздуха", + "Глухой удар", + "Глухой стук", + "Электронный тюнер", + "Процессор эффектов", + "Эффект хорус", + "Стук баскетбольного мяча", + "Громкий удар", + "Шлепок", + "Хлёсткий удар", + "Разбивание вдребезги", + "Разламывание", + "Отскок", + "Щелчок кнута", + "Хлопанье на ветру", + "Царапание", + "Скрежет", + "Трение", + "Катящийся предмет", + "Раздавливание", + "Сминание", + "Разрывание", + "Пиканье", + "Звонкий затухающий звук", + "Звон", + "Лязг", + "Визг", + "Скрип", + "Шорох", + "Механическое жужжание", + "Дребезжание", + "Шкворчание", + "Щёлканье", + "Перестук", + "Низкий гул", + "Бульк (падение в воду)", + "Позвякивание", + "Гудение", + "Короткое жужжание", + "Пружинящий звук", + "Хруст", + "Тишина", + "Чистый тон", + "Обертон", + "Скользящий тон", + "Звуковой эффект", + "Ритмичные импульсы", + "В маленькой комнате", + "В большом помещении", + "В общественном помещении", + "На улице в городе", + "На природе", + "Гулкое помещение", + "Эхо", + "Шум", + "Шум окружающей среды", + "Помехи", + "Гудение электросети", + "Искажение звука", + "Свой голос в трубке", + "Какофония", + "Белый шум", + "Розовый шум", + "Пульсирующий гул", + "Звук вибрации", + "Телевизор", + "Радио", + "Полевая запись" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/th.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/th.json new file mode 100644 index 0000000..af46896 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/th.json @@ -0,0 +1,523 @@ +[ + "เสียงพูด", + "เสียงเด็กพูด", + "เสียงสนทนา", + "เสียงบรรยาย", + "เสียงเด็กอ้อแอ้", + "เสียงพูดสังเคราะห์", + "เสียงตะโกน", + "เสียงคนคำราม", + "เสียงโห่ร้องดีใจ", + "เสียงตะโกนลั่น", + "เสียงเด็กตะโกน", + "เสียงกรีดร้อง", + "เสียงกระซิบ", + "เสียงหัวเราะ", + "เสียงทารกหัวเราะ", + "เสียงหัวเราะคิกคัก", + "เสียงหัวเราะเยาะ", + "เสียงหัวเราะลั่น", + "เสียงหัวเราะหึๆ", + "เสียงร้องไห้", + "เสียงทารกร้องไห้", + "เสียงร้องครางหงิงๆ", + "เสียงครวญคราง", + "เสียงถอนหายใจ", + "เสียงร้องเพลง", + "คณะประสานเสียง", + "เสียงร้องโยเดล", + "เสียงสวด", + "เสียงสวดคาถา", + "เสียงเด็กร้องเพลง", + "เสียงร้องเพลงสังเคราะห์", + "เสียงแร็ป", + "เสียงฮัมเพลง", + "เสียงโอดโอย", + "เสียงฮึดฮัด", + "เสียงผิวปาก", + "เสียงหายใจ", + "เสียงหายใจหวีด", + "เสียงกรน", + "เสียงสูดหายใจเฮือก", + "เสียงหายใจหอบ", + "เสียงพ่นลมทางจมูก", + "เสียงไอ", + "เสียงกระแอม", + "เสียงจาม", + "เสียงสูดจมูก", + "เสียงวิ่ง", + "เสียงเดินลากเท้า", + "เสียงฝีเท้า", + "เสียงเคี้ยว", + "เสียงกัด", + "เสียงกลั้วคอ", + "เสียงท้องร้อง", + "เสียงเรอ", + "เสียงสะอึก", + "เสียงผายลม", + "เสียงจากมือ", + "เสียงดีดนิ้ว", + "เสียงตบมือ", + "เสียงหัวใจเต้น", + "เสียงหัวใจผิดปกติ", + "เสียงเชียร์", + "เสียงปรบมือ (หลายคน)", + "เสียงพูดคุยจอแจ", + "เสียงฝูงชน", + "เสียงพูดอื้ออึง", + "เสียงเด็กเล่น", + "สัตว์", + "สัตว์เลี้ยง", + "สุนัข", + "สุนัขเห่า", + "สุนัขเล็กเห่าแหลม", + "สุนัขหอน", + "สุนัขเห่าโฮ่งๆ", + "สัตว์ขู่คำราม", + "สุนัขร้องหงิงๆ", + "แมว", + "แมวครางครืดๆ", + "แมวร้องเหมียว", + "เสียงขู่ฟ่อ", + "แมวร้องหาคู่", + "สัตว์ในฟาร์ม", + "ม้า", + "เสียงกีบม้า", + "ม้าร้อง", + "วัว", + "วัวร้อง", + "กระดิ่งวัว", + "หมู", + "หมูร้อง", + "แพะ", + "แพะแกะร้อง", + "แกะ", + "สัตว์ปีก", + "ไก่", + "ไก่ร้องกุ๊กๆ", + "ไก่ขัน", + "ไก่งวง", + "ไก่งวงร้อง", + "เป็ด", + "เป็ดร้อง", + "ห่าน", + "ห่านร้อง", + "สัตว์ป่า", + "สิงโตและเสือ", + "สิงโตคำราม", + "นก", + "นกร้อง", + "นกร้องจิ๊บๆ", + "นกร้องแว้กๆ", + "นกพิราบและนกเขา", + "นกพิราบร้องกุ๊กกู", + "อีกา", + "อีการ้อง", + "นกฮูก", + "นกฮูกร้อง", + "นกกระพือปีก", + "สุนัขป่า", + "สัตว์ฟันแทะ", + "หนู", + "สัตว์เล็กวิ่งกุกกัก", + "แมลง", + "จิ้งหรีด", + "ยุง", + "แมลงวัน", + "แมลงบินหึ่งๆ", + "ผึ้งและตัวต่อ", + "กบ", + "กบร้อง", + "งู", + "เสียงเขย่า (งูหางกระดิ่ง)", + "วาฬร้อง", + "ดนตรี", + "เครื่องดนตรี", + "เครื่องสายแบบดีด", + "กีตาร์", + "กีตาร์ไฟฟ้า", + "กีตาร์เบส", + "กีตาร์โปร่ง", + "กีตาร์สไลด์", + "เคาะสายกีตาร์ (แท็ปปิ้ง)", + "ตีคอร์ดกีตาร์", + "แบนโจ", + "ซีตาร์", + "แมนโดลิน", + "พิณซิเธอร์", + "อูคูเลเล่", + "คีย์บอร์ดดนตรี", + "เปียโน", + "เปียโนไฟฟ้า", + "ออร์แกน", + "ออร์แกนอิเล็กทรอนิกส์", + "ออร์แกนแฮมมอนด์", + "ซินธิไซเซอร์", + "แซมเพลอร์ (ดนตรี)", + "ฮาร์ปซิคอร์ด", + "เครื่องเคาะจังหวะ", + "กลองชุด", + "ดรัมแมชชีน", + "กลอง", + "กลองสแนร์", + "ริมช็อต (ตีขอบกลอง)", + "รัวกลอง", + "กลองเบส", + "กลองทิมปานี", + "กลองตับลา", + "ฉาบ", + "ไฮแฮต", + "วู้ดบล็อก", + "แทมบูรีน", + "ลูกเขย่า", + "ลูกแซ็ก (มาราคัส)", + "ฆ้อง", + "ระฆังราว", + "เครื่องตีแบบระนาด", + "ระนาดฝรั่ง (มาริมบา)", + "กล็อกเกนสปีล", + "ไวบราโฟน", + "สตีลแพน", + "วงออร์เคสตรา", + "เครื่องเป่าทองเหลือง", + "เฟรนช์ฮอร์น", + "ทรัมเป็ต", + "ทรอมโบน", + "เครื่องสายแบบสี", + "กลุ่มเครื่องสาย", + "ไวโอลิน", + "พิซซิคาโต (ดีดสาย)", + "เชลโล", + "ดับเบิลเบส", + "เครื่องเป่าลมไม้", + "ฟลุต", + "แซกโซโฟน", + "คลาริเน็ต", + "ฮาร์ป", + "ระฆัง", + "ระฆังโบสถ์", + "กระพรวน", + "กริ่งจักรยาน", + "ส้อมเสียง", + "ระฆังเป็นทำนอง", + "กระดิ่งลม", + "ตีระฆังสลับลำดับ", + "หีบเพลงปาก", + "หีบเพลงชัก", + "ปี่สก๊อต", + "ดิดเจอรีดู", + "แตรเขาแกะ (โชฟาร์)", + "เทอร์มิน", + "ขันทิเบต", + "ดีเจสแครชแผ่นเสียง", + "เพลงป๊อป", + "เพลงฮิปฮอป", + "บีทบ็อกซ์", + "เพลงร็อก", + "เพลงเฮฟวีเมทัล", + "เพลงพังก์ร็อก", + "เพลงกรันจ์", + "เพลงโปรเกรสซีฟร็อก", + "เพลงร็อกแอนด์โรล", + "เพลงไซคีเดลิกร็อก", + "เพลงอาร์แอนด์บี", + "เพลงโซล", + "เพลงเร็กเก้", + "เพลงคันทรี", + "เพลงสวิง", + "เพลงบลูแกรส", + "เพลงฟังก์", + "เพลงโฟล์ก", + "เพลงตะวันออกกลาง", + "เพลงแจ๊ส", + "เพลงดิสโก้", + "เพลงคลาสสิก", + "โอเปร่า", + "เพลงอิเล็กทรอนิกส์", + "เพลงเฮาส์", + "เพลงเทคโน", + "เพลงดับสเต็ป", + "เพลงดรัมแอนด์เบส", + "เพลงอิเล็กทรอนิกา", + "เพลง EDM", + "เพลงแอมเบียนต์", + "เพลงทรานซ์", + "เพลงละตินอเมริกา", + "เพลงซัลซ่า", + "เพลงฟลาเมงโก", + "เพลงบลูส์", + "เพลงเด็ก", + "เพลงนิวเอจ", + "เพลงขับร้อง", + "เพลงอะแคปเปลลา", + "เพลงแอฟริกา", + "เพลงแอโฟรบีท", + "เพลงคริสเตียน", + "เพลงกอสเปล", + "เพลงเอเชีย", + "เพลงคาร์นาติก", + "เพลงบอลลีวูด", + "เพลงสกา", + "เพลงพื้นเมือง", + "เพลงอินดี้", + "เพลง", + "เพลงพื้นหลัง", + "เพลงธีม", + "เพลงโฆษณา", + "เพลงประกอบภาพยนตร์", + "เพลงกล่อมเด็ก", + "เพลงประกอบเกม", + "เพลงคริสต์มาส", + "เพลงเต้นรำ", + "เพลงงานแต่งงาน", + "เพลงร่าเริง", + "เพลงเศร้า", + "เพลงอ่อนโยน", + "เพลงเร้าใจ", + "เพลงเกรี้ยวกราด", + "เพลงน่ากลัว", + "ลมพัด", + "ใบไม้เสียดสี", + "เสียงลมเข้าไมโครโฟน", + "พายุฝนฟ้าคะนอง", + "ฟ้าร้อง", + "เสียงน้ำ", + "ฝนตก", + "หยดน้ำฝน", + "ฝนตกกระทบพื้นผิว", + "ลำธาร", + "น้ำตก", + "ทะเล", + "คลื่นซัดฝั่ง", + "ไอน้ำ", + "น้ำไหลโครกๆ", + "ไฟลุกไหม้", + "เสียงปะทุเปรี๊ยะๆ", + "ยานพาหนะ", + "เรือ", + "เรือใบ", + "เรือพาย", + "เรือยนต์", + "เรือขนาดใหญ่", + "ยานยนต์บนถนน", + "รถยนต์", + "แตรรถ", + "บีบแตรปี๊น", + "สัญญาณกันขโมยรถ", + "กระจกไฟฟ้ารถยนต์", + "รถลื่นไถล", + "ยางรถดังเอี๊ยด", + "รถวิ่งผ่าน", + "รถแข่ง", + "รถบรรทุก", + "เบรกลม", + "แตรลมรถบรรทุก", + "เสียงเตือนรถถอยหลัง", + "รถขายไอศกรีม", + "รถบัส", + "รถฉุกเฉิน", + "ไซเรนรถตำรวจ", + "ไซเรนรถพยาบาล", + "ไซเรนรถดับเพลิง", + "รถจักรยานยนต์", + "เสียงจราจร", + "ยานพาหนะบนราง", + "รถไฟ", + "หวูดรถไฟ", + "แตรรถไฟ", + "ตู้รถไฟ", + "ล้อรถไฟเสียดราง", + "รถไฟใต้ดิน", + "อากาศยาน", + "เครื่องยนต์เครื่องบิน", + "เครื่องยนต์ไอพ่น", + "ใบพัดเครื่องบิน", + "เฮลิคอปเตอร์", + "เครื่องบิน", + "จักรยาน", + "สเก็ตบอร์ด", + "เครื่องยนต์", + "เครื่องยนต์ขนาดเล็ก", + "เครื่องกรอฟัน", + "เครื่องตัดหญ้า", + "เลื่อยยนต์", + "เครื่องยนต์ขนาดกลาง", + "เครื่องยนต์ขนาดใหญ่", + "เครื่องยนต์น็อก", + "สตาร์ทเครื่องยนต์", + "เครื่องยนต์เดินเบา", + "เร่งเครื่องยนต์", + "ประตู", + "กริ่งประตู", + "กริ่งดังติ๊งต่อง", + "ประตูบานเลื่อน", + "ปิดประตูดังปัง", + "เคาะประตู", + "เคาะเบาๆ", + "เสียงเอี๊ยด", + "เปิดปิดตู้", + "เปิดปิดลิ้นชัก", + "จานชามหม้อกระทะ", + "ช้อนส้อม", + "หั่นอาหาร", + "ทอดอาหาร", + "เตาไมโครเวฟ", + "เครื่องปั่น", + "ก๊อกน้ำ", + "อ่างล้างมือหรือล้างจาน", + "อ่างอาบน้ำ", + "ไดร์เป่าผม", + "กดชักโครก", + "แปรงฟัน", + "แปรงสีฟันไฟฟ้า", + "เครื่องดูดฝุ่น", + "รูดซิป", + "พวงกุญแจกระทบกัน", + "เหรียญตก", + "กรรไกร", + "เครื่องโกนหนวดไฟฟ้า", + "สับไพ่", + "กดแป้นพิมพ์", + "เครื่องพิมพ์ดีด", + "คีย์บอร์ดคอมพิวเตอร์", + "เขียนหนังสือ", + "สัญญาณเตือนภัย", + "โทรศัพท์", + "กริ่งโทรศัพท์", + "เสียงเรียกเข้า", + "เสียงกดเบอร์โทรศัพท์", + "สัญญาณพร้อมโทรออก", + "สัญญาณสายไม่ว่าง", + "นาฬิกาปลุก", + "ไซเรน", + "ไซเรนเตือนภัยสาธารณะ", + "ออดไฟฟ้า", + "เครื่องตรวจจับควัน", + "สัญญาณเตือนไฟไหม้", + "แตรหมอก", + "นกหวีด", + "หวูดไอน้ำ", + "เครื่องกลไก", + "เฟืองกรอกแกรก (แรทเชท)", + "นาฬิกา", + "เสียงติ๊ก", + "นาฬิกาเดินติ๊กต็อก", + "เฟืองหมุน", + "รอก", + "จักรเย็บผ้า", + "พัดลม", + "เครื่องปรับอากาศ", + "เครื่องคิดเงิน", + "เครื่องพิมพ์", + "กล้องถ่ายรูป", + "กล้อง SLR", + "เครื่องมือช่าง", + "ตอกด้วยค้อน", + "เครื่องเจาะถนน", + "เลื่อยไม้", + "ตะไบ", + "ขัดกระดาษทราย", + "เครื่องมือไฟฟ้า", + "สว่าน", + "เสียงระเบิด", + "เสียงปืน", + "ปืนกล", + "ระดมยิง", + "ยิงปืนใหญ่", + "ปืนแก๊ป (ของเล่น)", + "ดอกไม้ไฟ", + "ประทัด", + "เสียงแตกโพละ", + "ภูเขาไฟระเบิด", + "เสียงดังตูม", + "เสียงไม้", + "ผ่าไม้", + "ไม้แตกเป็นเสี่ยง", + "เสียงแตกร้าว", + "แก้วและกระจก", + "แก้วกระทบกัน", + "กระจกแตก", + "ของเหลว", + "น้ำกระเซ็น", + "น้ำกระฉอก", + "เสียงบีบของเปียก", + "น้ำหยด", + "เทน้ำ", + "น้ำไหลริน", + "น้ำพุ่ง", + "เติมน้ำใส่ภาชนะ", + "ฉีดพ่น", + "ปั๊มน้ำ", + "กวนของเหลว", + "น้ำเดือด", + "โซนาร์", + "ลูกธนู", + "เสียงหวือ", + "เสียงตุบ", + "เสียงโป๊ก", + "เครื่องจูนเสียง", + "เครื่องเอฟเฟกต์", + "เอฟเฟกต์คอรัส", + "ลูกบาสเด้ง", + "เสียงดังปัง", + "เสียงตบฉาด", + "เสียงฟาดผัวะ", + "เสียงพังโครม", + "เสียงแตกหัก", + "เสียงเด้งกระดอน", + "เสียงแส้ฟาด", + "เสียงสะบัดพึ่บพั่บ", + "เสียงขีดข่วน", + "เสียงขูดครูด", + "เสียงถู", + "เสียงกลิ้ง", + "เสียงบดขยี้", + "เสียงขยำกรอบแกรบ", + "เสียงฉีก", + "เสียงบี๊บ", + "เสียงปิ๊ง", + "เสียงติ๊ง", + "เสียงเคร้ง", + "เสียงแหลมวี้ด", + "เสียงเอี๊ยดอ๊าด", + "เสียงสวบสาบ", + "เสียงหมุนหวิวๆ", + "เสียงโครมคราม", + "เสียงฉ่า", + "เสียงคลิก", + "เสียงกึกกักเป็นจังหวะ", + "เสียงครืนๆ", + "เสียงจ๋อม", + "เสียงกรุ๊งกริ๊ง", + "เสียงหึ่งต่อเนื่อง", + "เสียงวิ้ง", + "เสียงดึ๋ง (สปริง)", + "เสียงกรุบกรับ", + "ความเงียบ", + "คลื่นไซน์", + "เสียงฮาร์มอนิก", + "เสียงโทนเลื่อนระดับ", + "เสียงเอฟเฟกต์", + "เสียงดังเป็นห้วงๆ", + "ในห้องขนาดเล็ก", + "ในห้องโถงหรือห้องใหญ่", + "ในอาคารสาธารณะ", + "กลางแจ้งในเมือง", + "กลางแจ้งในธรรมชาติ", + "เสียงก้อง", + "เสียงสะท้อน", + "เสียงรบกวน", + "เสียงรบกวนรอบข้าง", + "เสียงซ่าจากคลื่นรบกวน", + "เสียงหึ่งจากไฟบ้าน", + "เสียงแตกพร่า", + "เสียงตัวเองในหูโทรศัพท์", + "เสียงอึกทึกครึกโครม", + "ไวท์นอยส์", + "พิงก์นอยส์", + "เสียงทุ้มเต้นเป็นจังหวะ", + "เสียงสั่นสะเทือน", + "โทรทัศน์", + "วิทยุ", + "บันทึกเสียงภาคสนาม" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/tr.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/tr.json new file mode 100644 index 0000000..29d3582 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/tr.json @@ -0,0 +1,523 @@ +[ + "Konuşma", + "Çocuk konuşması", + "Sohbet", + "Anlatım, monolog", + "Bebek agulaması", + "Sentetik konuşma", + "Bağırma", + "Gür sesle bağırma", + "Sevinç çığlığı", + "Haykırma", + "Bağıran çocuklar", + "Çığlık", + "Fısıltı", + "Gülüş", + "Bebek gülüşü", + "Kıkırdama", + "Alaycı gülüş", + "Kahkaha", + "Kıs kıs gülme", + "Ağlama", + "Bebek ağlaması", + "Sızlanma", + "İnleme", + "İç çekme", + "Şarkı söyleme", + "Koro", + "Yodel", + "İlahi, ezgili okuma", + "Mantra", + "Şarkı söyleyen çocuk", + "Sentetik şarkı söyleme", + "Rap yapma", + "Mırıldanma", + "İnilti", + "Homurtu", + "Islık çalma", + "Nefes alıp verme", + "Hırıltılı nefes", + "Horlama", + "Ani nefes çekme", + "Nefes nefese kalma", + "Burundan sert nefes", + "Öksürük", + "Boğaz temizleme", + "Hapşırık", + "Burun çekme", + "Koşu", + "Ayak sürüyerek yürüme", + "Ayak sesleri", + "Çiğneme", + "Isırma", + "Gargara", + "Mide guruldaması", + "Geğirme", + "Hıçkırık", + "Osuruk", + "El sesleri", + "Parmak şıklatma", + "El çırpma", + "Kalp atışı", + "Kalp üfürümü", + "Tezahürat", + "Alkış", + "Karışık konuşmalar", + "Kalabalık", + "Konuşma uğultusu", + "Oynayan çocuklar", + "Hayvan", + "Evcil hayvanlar", + "Köpek", + "Havlama", + "Küçük köpek havlaması", + "Uluma", + "Hav hav (köpek)", + "Hırlama", + "Sızlanma (köpek)", + "Kedi", + "Mırlama", + "Miyavlama", + "Tıslama", + "Kedi uluması", + "Çiftlik hayvanları", + "At", + "Nal sesleri", + "Kişneme", + "Sığır", + "İnek böğürmesi", + "İnek çanı", + "Domuz", + "Domuz homurtusu", + "Keçi", + "Meleme", + "Koyun", + "Kümes hayvanları", + "Tavuk, horoz", + "Gıdaklama", + "Horoz ötüşü", + "Hindi", + "Glu glu (hindi)", + "Ördek", + "Vaklama", + "Kaz", + "Kaz ötüşü", + "Yabani hayvanlar", + "Aslan, kaplan", + "Kükreme", + "Kuş", + "Kuş ötüşü", + "Cıvıltı", + "Kuş ciyaklaması", + "Güvercin", + "Güvercin guruldaması", + "Karga", + "Karga gaklaması", + "Baykuş", + "Baykuş ötüşü", + "Kuş kanat çırpışı", + "Kurt, yabani köpek", + "Kemirgenler", + "Fare", + "Tıpırtı", + "Böcek", + "Cırcır böceği", + "Sivrisinek", + "Sinek", + "Vızıltı", + "Arı, eşek arısı", + "Kurbağa", + "Vıraklama", + "Yılan", + "Şıkırtı", + "Balina sesi", + "Müzik", + "Müzik aleti", + "Telli çalgı", + "Gitar", + "Elektro gitar", + "Bas gitar", + "Akustik gitar", + "Slide gitar", + "Tapping (gitar tekniği)", + "Gitar tıngırdatma", + "Banjo", + "Sitar", + "Mandolin", + "Kanun, zither", + "Ukulele", + "Klavyeli çalgı", + "Piyano", + "Elektrikli piyano", + "Org", + "Elektronik org", + "Hammond org", + "Synthesizer", + "Sampler", + "Klavsen", + "Vurmalı çalgı", + "Bateri", + "Davul makinesi", + "Davul", + "Trampet", + "Rimshot (trampet)", + "Trampet rulosu", + "Bas davul", + "Timpani", + "Tabla", + "Zil (çalgı)", + "Hi-hat", + "Tahta blok", + "Tef", + "Sallamalı çalgı", + "Marakas", + "Gong", + "Borulu çanlar", + "Melodik vurmalı çalgı", + "Marimba, ksilofon", + "Metalofon", + "Vibrafon", + "Çelik davul", + "Orkestra", + "Bakır üflemeli çalgı", + "Korno", + "Trompet", + "Trombon", + "Yaylı çalgı", + "Yaylı çalgılar grubu", + "Keman", + "Pizzicato (parmakla çalma)", + "Viyolonsel", + "Kontrbas", + "Üflemeli çalgı", + "Flüt", + "Saksofon", + "Klarnet", + "Arp", + "Çan", + "Kilise çanı", + "Çıngırak", + "Bisiklet zili", + "Diyapazon", + "Çan melodisi", + "Rüzgâr çanı", + "Sıralı çan çalma", + "Mızıka", + "Akordeon", + "Gayda, tulum", + "Didgeridoo", + "Şofar", + "Theremin", + "Tibet çanağı", + "Scratch (DJ tekniği)", + "Pop müzik", + "Hip hop müzik", + "Beatbox", + "Rock müzik", + "Heavy metal", + "Punk rock", + "Grunge", + "Progresif rock", + "Rock and roll", + "Psikedelik rock", + "Rhythm and blues", + "Soul müzik", + "Reggae", + "Country müzik", + "Swing müzik", + "Bluegrass", + "Funk", + "Folk müzik", + "Orta Doğu müziği", + "Caz", + "Disko", + "Klasik müzik", + "Opera", + "Elektronik müzik", + "House müzik", + "Tekno", + "Dubstep", + "Drum and bass", + "Electronica", + "Elektronik dans müziği", + "Ambient müzik", + "Trance müzik", + "Latin Amerika müziği", + "Salsa müzik", + "Flamenko", + "Blues", + "Çocuk şarkıları", + "New age müzik", + "Vokal müzik", + "A cappella", + "Afrika müziği", + "Afrobeat", + "Hristiyan müziği", + "Gospel müzik", + "Asya müziği", + "Karnatik müzik", + "Bollywood müziği", + "Ska", + "Geleneksel müzik", + "Bağımsız müzik", + "Şarkı", + "Fon müziği", + "Jenerik müziği", + "Reklam müziği", + "Film müziği", + "Ninni", + "Video oyunu müziği", + "Noel müziği", + "Dans müziği", + "Düğün müziği", + "Neşeli müzik", + "Hüzünlü müzik", + "Yumuşak müzik", + "Heyecanlı müzik", + "Öfkeli müzik", + "Korkutucu müzik", + "Rüzgâr", + "Yaprak hışırtısı", + "Rüzgâr gürültüsü (mikrofon)", + "Gök gürültülü fırtına", + "Gök gürültüsü", + "Su", + "Yağmur", + "Yağmur damlası", + "Yüzeye vuran yağmur", + "Dere", + "Şelale", + "Okyanus", + "Deniz dalgaları", + "Buhar", + "Lıkırtı", + "Ateş", + "Çıtırtı", + "Araç", + "Tekne, deniz aracı", + "Yelkenli", + "Kürekli tekne, kano", + "Motorbot", + "Gemi", + "Motorlu kara aracı", + "Araba", + "Korna", + "Kısa korna", + "Araba alarmı", + "Elektrikli cam (araba)", + "Patinaj", + "Lastik gıcırtısı", + "Geçen araba", + "Yarış arabası", + "Kamyon", + "Havalı fren", + "Havalı korna", + "Geri vites bip sesi", + "Dondurma arabası", + "Otobüs", + "Acil durum aracı", + "Polis arabası (siren)", + "Ambulans (siren)", + "İtfaiye aracı (siren)", + "Motosiklet", + "Trafik gürültüsü", + "Raylı ulaşım", + "Tren", + "Tren düdüğü", + "Tren kornası", + "Tren vagonu", + "Tren tekerleği gıcırtısı", + "Metro", + "Hava aracı", + "Uçak motoru", + "Jet motoru", + "Pervane", + "Helikopter", + "Uçak", + "Bisiklet", + "Kaykay", + "Motor", + "Küçük motor", + "Dişçi matkabı", + "Çim biçme makinesi", + "Motorlu testere", + "Orta boy motor", + "Büyük motor", + "Motor vuruntusu", + "Motor çalıştırma", + "Rölanti", + "Hızlanan motor", + "Kapı", + "Kapı zili", + "Ding dong (kapı zili)", + "Sürgülü kapı", + "Kapı çarpması", + "Kapıya vurma", + "Hafif vuruş", + "Gıcırtı", + "Dolap açıp kapama", + "Çekmece açıp kapama", + "Tabak, tencere, tava", + "Çatal bıçak", + "Yiyecek doğrama", + "Yağda kızartma", + "Mikrodalga fırın", + "Blender", + "Musluk", + "Lavabo (doldurma, yıkama)", + "Küvet (doldurma, yıkama)", + "Saç kurutma makinesi", + "Tuvalet sifonu", + "Diş fırçası", + "Elektrikli diş fırçası", + "Elektrikli süpürge", + "Fermuar", + "Anahtar şıngırtısı", + "Düşen bozuk para", + "Makas", + "Tıraş makinesi", + "İskambil karıştırma", + "Tuşlarla yazma", + "Daktilo", + "Bilgisayar klavyesi", + "Kalemle yazma", + "Alarm", + "Telefon", + "Klasik telefon zili", + "Telefon zil sesi", + "Telefon tuş sesleri", + "Çevir sesi", + "Meşgul sesi", + "Çalar saat", + "Siren", + "Sivil savunma sireni", + "Buzzer (uyarı sesi)", + "Duman alarmı", + "Yangın alarmı", + "Sis düdüğü", + "Düdük", + "Buhar düdüğü", + "Mekanizmalar", + "Cırcır mekanizması", + "Saat", + "Tıkırtı", + "Tik tak", + "Dişliler", + "Makaralar", + "Dikiş makinesi", + "Vantilatör", + "Klima", + "Yazar kasa", + "Yazıcı", + "Fotoğraf makinesi", + "SLR fotoğraf makinesi", + "Aletler", + "Çekiç", + "Beton kırıcı", + "Testereyle kesme", + "Eğeleme", + "Zımparalama", + "Elektrikli alet", + "Matkap", + "Patlama", + "Silah sesi", + "Makineli tüfek", + "Yaylım ateşi", + "Top atışı", + "Oyuncak tabanca", + "Havai fişek", + "Kestane fişeği", + "Pat sesi (balon vb.)", + "Yanardağ patlaması", + "Gümleme", + "Ahşap", + "Baltayla kesme", + "Ahşap kırılması", + "Çatırtı", + "Cam", + "Tıngırtı", + "Cam kırılması", + "Sıvı", + "Sıvı sıçraması", + "Çalkalanma", + "Islak ezilme", + "Damlama", + "Sıvı dökme", + "İnce akış", + "Fışkırma", + "Sıvı doldurma", + "Püskürtme", + "Sıvı pompalama", + "Kaşıkla karıştırma", + "Kaynama", + "Sonar", + "Ok", + "Vınlama", + "Boğuk vuruş", + "Kof vuruş", + "Akort cihazı", + "Efekt cihazı", + "Chorus efekti", + "Basketbol topu sekmesi", + "Ani gürültü", + "Tokat", + "Sert vuruş", + "Parçalanma", + "Kırılma", + "Sekme (top vb.)", + "Kırbaç şaklaması", + "Çırpınma (bayrak vb.)", + "Yüzeyi çizme", + "Kazıma", + "Sürtme", + "Yuvarlanma", + "Ezilme", + "Buruşturma", + "Yırtma", + "Bip", + "Çınlama", + "Ding", + "Tangırtı", + "Ciyaklama", + "Gıcırdama (ahşap)", + "Hışırtı", + "Hızla dönme sesi", + "Takırtı", + "Cızırtı", + "Tıklama", + "Ritmik takırtı", + "Gümbürtü", + "Suya düşme", + "Şıngırtı", + "Uğultu", + "Kısa tiz vızıltı", + "Yay sesi (boing)", + "Kıtırtı", + "Sessizlik", + "Sinüs dalgası (saf ton)", + "Harmonik ton", + "Perdesi değişen ton", + "Ses efekti", + "Düzenli aralıklı ses", + "İç mekân, küçük oda", + "İç mekân, büyük salon", + "İç mekân, kamusal alan", + "Dış mekân, şehir", + "Dış mekân, kır ve doğa", + "Yankılanma", + "Yankı", + "Gürültü", + "Çevre gürültüsü", + "Parazit", + "Elektrik uğultusu", + "Ses bozulması", + "Telefonda kendi sesi", + "Ses karmaşası", + "Beyaz gürültü", + "Pembe gürültü", + "Zonklayan uğultu", + "Titreşim sesi", + "Televizyon", + "Radyo", + "Ortam kaydı" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/vi.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/vi.json new file mode 100644 index 0000000..104dd2d --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/vi.json @@ -0,0 +1,523 @@ +[ + "Giọng nói", + "Trẻ em nói", + "Trò chuyện", + "Lời dẫn, độc thoại", + "Trẻ bập bẹ", + "Giọng nói tổng hợp", + "Nói to", + "Gào rống", + "Reo mừng", + "La hét", + "Trẻ em la hét", + "Tiếng thét", + "Thì thầm", + "Tiếng cười", + "Em bé cười", + "Cười khúc khích", + "Cười khẩy", + "Cười ha hả", + "Cười khẽ", + "Tiếng khóc", + "Em bé khóc", + "Khóc thút thít", + "Rên rỉ", + "Thở dài", + "Tiếng hát", + "Hợp xướng", + "Hát yodel", + "Ngâm tụng", + "Tụng thần chú", + "Trẻ em hát", + "Giọng hát tổng hợp", + "Đọc rap", + "Ngân nga", + "Kêu rên", + "Tiếng hự", + "Huýt sáo", + "Tiếng thở", + "Thở khò khè", + "Ngáy", + "Hít hơi gấp", + "Thở hổn hển", + "Khịt mũi", + "Ho", + "Hắng giọng", + "Hắt hơi", + "Hít mũi", + "Tiếng chân chạy", + "Tiếng lê chân", + "Tiếng bước chân", + "Nhai thức ăn", + "Tiếng cắn", + "Súc họng", + "Bụng sôi", + "Ợ hơi", + "Nấc cụt", + "Đánh rắm", + "Âm thanh bàn tay", + "Búng tay", + "Vỗ tay", + "Tiếng tim đập", + "Tiếng thổi tim", + "Reo hò cổ vũ", + "Tràng pháo tay", + "Nhiều người trò chuyện", + "Đám đông", + "Tiếng người ồn ào", + "Trẻ em nô đùa", + "Động vật", + "Thú cưng", + "Chó", + "Chó sủa", + "Chó sủa ăng ẳng", + "Chó tru", + "Chó sủa gâu gâu", + "Chó gầm gừ", + "Chó rên ư ử", + "Mèo", + "Mèo kêu rừ rừ", + "Mèo kêu meo meo", + "Mèo khè, tiếng xì", + "Mèo gào", + "Vật nuôi trang trại", + "Ngựa", + "Tiếng vó ngựa", + "Ngựa hí", + "Trâu bò", + "Bò rống", + "Chuông bò", + "Lợn", + "Lợn kêu ụt ịt", + "Dê", + "Dê cừu kêu be be", + "Cừu", + "Gia cầm", + "Gà", + "Gà cục tác", + "Gà trống gáy", + "Gà tây", + "Gà tây kêu", + "Vịt", + "Vịt kêu quạc quạc", + "Ngỗng", + "Ngỗng kêu", + "Động vật hoang dã", + "Sư tử, hổ", + "Sư tử, hổ gầm", + "Chim", + "Chim hót", + "Chim kêu chíp chíp", + "Chim kêu quang quác", + "Chim bồ câu", + "Bồ câu gù", + "Quạ", + "Quạ kêu", + "Cú mèo", + "Cú mèo kêu", + "Chim vỗ cánh", + "Sói, chó hoang", + "Loài gặm nhấm", + "Chuột", + "Tiếng chân thú nhỏ", + "Côn trùng", + "Dế", + "Muỗi", + "Ruồi", + "Tiếng vo ve", + "Ong", + "Ếch", + "Ếch kêu ộp ộp", + "Rắn", + "Tiếng lắc lọc xọc", + "Cá voi kêu", + "Âm nhạc", + "Nhạc cụ", + "Đàn dây gảy", + "Đàn guitar", + "Đàn guitar điện", + "Đàn guitar bass", + "Đàn guitar thùng", + "Đàn guitar slide", + "Tapping (kỹ thuật guitar)", + "Quạt dây đàn guitar", + "Đàn banjo", + "Đàn sitar", + "Đàn mandolin", + "Đàn tranh", + "Đàn ukulele", + "Đàn phím", + "Đàn piano", + "Đàn piano điện", + "Đàn organ ống", + "Đàn organ điện tử", + "Đàn organ Hammond", + "Đàn synthesizer", + "Máy sampler", + "Đàn harpsichord", + "Nhạc cụ gõ", + "Dàn trống", + "Máy tạo nhịp trống", + "Trống", + "Trống snare", + "Gõ vành trống", + "Trống dồn", + "Trống bass", + "Trống định âm", + "Trống tabla", + "Chũm chọe", + "Chũm chọe hi-hat", + "Mõ gỗ", + "Trống lắc tay", + "Nhạc cụ lắc", + "Nhạc cụ lắc maracas", + "Cồng chiêng", + "Chuông ống", + "Nhạc cụ gõ giai điệu", + "Đàn xylophone", + "Đàn glockenspiel", + "Đàn vibraphone", + "Trống thép", + "Dàn nhạc giao hưởng", + "Kèn đồng", + "Kèn cor", + "Kèn trumpet", + "Kèn trombone", + "Đàn dây kéo vĩ", + "Bộ dây dàn nhạc", + "Đàn violin", + "Gảy dây (pizzicato)", + "Đàn cello", + "Đàn contrabass", + "Nhạc cụ hơi", + "Sáo", + "Kèn saxophone", + "Kèn clarinet", + "Đàn hạc", + "Chuông", + "Chuông nhà thờ", + "Chuông lục lạc", + "Chuông xe đạp", + "Âm thoa", + "Chuông giai điệu", + "Chuông gió", + "Chuông đổ hồi", + "Kèn harmonica", + "Đàn accordion", + "Kèn túi", + "Kèn didgeridoo", + "Kèn sừng shofar", + "Đàn theremin", + "Chuông xoay", + "Scratch đĩa than (DJ)", + "Nhạc pop", + "Nhạc hip hop", + "Beatbox", + "Nhạc rock", + "Nhạc heavy metal", + "Nhạc punk rock", + "Nhạc grunge", + "Nhạc progressive rock", + "Nhạc rock and roll", + "Nhạc psychedelic rock", + "Nhạc R&B", + "Nhạc soul", + "Nhạc reggae", + "Nhạc đồng quê", + "Nhạc swing", + "Nhạc bluegrass", + "Nhạc funk", + "Nhạc dân gian", + "Nhạc Trung Đông", + "Nhạc jazz", + "Nhạc disco", + "Nhạc cổ điển", + "Nhạc opera", + "Nhạc điện tử", + "Nhạc house", + "Nhạc techno", + "Nhạc dubstep", + "Nhạc drum and bass", + "Nhạc electronica", + "Nhạc EDM", + "Nhạc ambient", + "Nhạc trance", + "Nhạc Mỹ Latinh", + "Nhạc salsa", + "Nhạc flamenco", + "Nhạc blues", + "Nhạc thiếu nhi", + "Nhạc new age", + "Thanh nhạc", + "Hát không nhạc đệm", + "Nhạc châu Phi", + "Nhạc afrobeat", + "Nhạc Cơ Đốc giáo", + "Nhạc phúc âm", + "Nhạc châu Á", + "Nhạc cổ điển Nam Ấn", + "Nhạc Bollywood", + "Nhạc ska", + "Nhạc truyền thống", + "Nhạc indie", + "Bài hát", + "Nhạc nền", + "Nhạc chủ đề", + "Nhạc quảng cáo", + "Nhạc phim", + "Hát ru", + "Nhạc trò chơi điện tử", + "Nhạc Giáng sinh", + "Nhạc khiêu vũ", + "Nhạc đám cưới", + "Nhạc vui tươi", + "Nhạc buồn", + "Nhạc dịu dàng", + "Nhạc sôi động", + "Nhạc giận dữ", + "Nhạc rùng rợn", + "Gió", + "Lá xào xạc", + "Gió thổi vào micrô", + "Mưa giông", + "Sấm", + "Nước", + "Mưa", + "Giọt mưa", + "Mưa rơi lộp độp", + "Suối chảy", + "Thác nước", + "Biển", + "Sóng vỗ", + "Hơi nước", + "Nước chảy ọc ọc", + "Lửa", + "Lửa nổ lách tách", + "Phương tiện giao thông", + "Tàu thuyền", + "Thuyền buồm", + "Thuyền chèo", + "Xuồng máy", + "Tàu thủy", + "Xe cơ giới (đường bộ)", + "Ô tô", + "Còi xe", + "Tiếng còi ngắn", + "Báo động ô tô", + "Cửa kính điện ô tô", + "Xe trượt bánh", + "Lốp xe rít", + "Ô tô chạy qua", + "Xe đua", + "Xe tải", + "Phanh hơi", + "Còi hơi xe tải", + "Còi báo lùi xe", + "Xe bán kem", + "Xe buýt", + "Xe ưu tiên", + "Xe cảnh sát (còi hụ)", + "Xe cứu thương (còi hụ)", + "Xe cứu hỏa (còi hụ)", + "Xe máy", + "Tiếng ồn giao thông", + "Phương tiện đường sắt", + "Tàu hỏa", + "Còi tàu hơi nước", + "Còi tàu hỏa", + "Toa tàu", + "Bánh tàu rít trên ray", + "Tàu điện ngầm", + "Máy bay", + "Động cơ máy bay", + "Động cơ phản lực", + "Cánh quạt máy bay", + "Trực thăng", + "Máy bay cánh cố định", + "Xe đạp", + "Ván trượt", + "Động cơ", + "Động cơ nhỏ (âm cao)", + "Máy khoan nha khoa", + "Máy cắt cỏ", + "Cưa máy", + "Động cơ vừa (âm trung)", + "Động cơ lớn (âm trầm)", + "Tiếng gõ động cơ", + "Động cơ khởi động", + "Động cơ chạy không tải", + "Rồ ga tăng tốc", + "Cửa", + "Chuông cửa", + "Chuông cửa kính coong", + "Cửa trượt", + "Đóng sầm cửa", + "Gõ cửa", + "Gõ nhẹ", + "Tiếng kin kít", + "Đóng mở tủ", + "Đóng mở ngăn kéo", + "Bát đĩa, xoong nồi", + "Dao nĩa", + "Băm thái thức ăn", + "Chiên rán thức ăn", + "Lò vi sóng", + "Máy xay sinh tố", + "Vòi nước", + "Bồn rửa (xả nước, rửa)", + "Bồn tắm (xả nước, tắm)", + "Máy sấy tóc", + "Xả bồn cầu", + "Bàn chải đánh răng", + "Bàn chải đánh răng điện", + "Máy hút bụi", + "Khóa kéo", + "Chìa khóa leng keng", + "Đồng xu rơi", + "Cắt bằng kéo", + "Máy cạo râu", + "Tráo bài", + "Gõ phím", + "Máy đánh chữ", + "Bàn phím máy tính", + "Viết trên giấy", + "Báo động", + "Điện thoại", + "Chuông điện thoại reo", + "Nhạc chuông", + "Tiếng bấm số điện thoại", + "Âm mời quay số", + "Âm báo bận", + "Đồng hồ báo thức", + "Còi hụ", + "Còi hụ cảnh báo khẩn cấp", + "Còi báo điện tử", + "Chuông báo khói", + "Chuông báo cháy", + "Còi báo sương mù", + "Thổi còi", + "Còi hơi nước", + "Máy móc", + "Cơ cấu bánh cóc", + "Đồng hồ", + "Tiếng tích", + "Đồng hồ tích tắc", + "Bánh răng", + "Ròng rọc", + "Máy khâu", + "Quạt điện", + "Máy điều hòa", + "Máy tính tiền", + "Máy in", + "Máy ảnh", + "Máy ảnh SLR", + "Dụng cụ", + "Búa", + "Máy đục bê tông", + "Cưa gỗ", + "Giũa", + "Chà nhám", + "Dụng cụ điện", + "Máy khoan", + "Tiếng nổ", + "Tiếng súng", + "Súng máy", + "Bắn đồng loạt", + "Pháo kích", + "Súng đồ chơi", + "Pháo hoa", + "Pháo nổ", + "Tiếng nổ bụp", + "Núi lửa phun trào", + "Tiếng nổ ầm", + "Gỗ", + "Chặt gỗ", + "Gỗ vỡ vụn", + "Tiếng nứt rắc", + "Thủy tinh", + "Tiếng lanh canh", + "Vỡ tan tành", + "Chất lỏng", + "Nước bắn tung tóe", + "Nước sóng sánh", + "Tiếng bóp vật ướt", + "Nước nhỏ giọt", + "Rót nước", + "Nước chảy róc rách", + "Nước tuôn xối xả", + "Đổ đầy nước", + "Phun xịt", + "Bơm nước", + "Tiếng khuấy", + "Nước sôi", + "Tiếng sonar", + "Mũi tên", + "Tiếng vút", + "Tiếng thịch", + "Tiếng cộp", + "Máy lên dây đàn", + "Bộ hiệu ứng âm thanh", + "Hiệu ứng chorus", + "Bóng rổ nảy", + "Tiếng đùng", + "Tiếng tát", + "Tiếng đập mạnh", + "Tiếng đổ vỡ", + "Tiếng gãy vỡ", + "Tiếng vật nảy", + "Tiếng roi quất", + "Tiếng phần phật", + "Tiếng cào xước", + "Tiếng cạo", + "Tiếng cọ xát", + "Tiếng lăn", + "Tiếng nghiền nát", + "Tiếng vò nhàu", + "Tiếng xé rách", + "Tiếng bíp", + "Tiếng ting", + "Tiếng keng", + "Tiếng kim loại chát chúa", + "Tiếng rít", + "Tiếng kẽo kẹt", + "Tiếng sột soạt", + "Tiếng quay vù vù", + "Tiếng loảng xoảng", + "Tiếng xèo xèo", + "Tiếng lách cách", + "Tiếng lạch cạch đều đều", + "Tiếng ì ầm", + "Tiếng tõm", + "Tiếng leng keng", + "Tiếng ù ù", + "Tiếng chíu", + "Tiếng lò xo nảy", + "Tiếng rôm rốp", + "Im lặng", + "Sóng sin (âm thuần)", + "Họa âm", + "Âm quét tần số", + "Hiệu ứng âm thanh", + "Âm xung đều nhịp", + "Trong nhà, phòng nhỏ", + "Trong nhà, phòng lớn", + "Trong nhà, nơi công cộng", + "Ngoài trời, đô thị", + "Ngoài trời, thiên nhiên", + "Âm vang", + "Tiếng vọng", + "Tiếng ồn", + "Tiếng ồn môi trường", + "Tiếng rè nhiễu", + "Tiếng ù điện lưới", + "Méo tiếng", + "Âm phản hồi điện thoại", + "Âm thanh hỗn loạn", + "Tiếng ồn trắng", + "Tiếng ồn hồng", + "Tiếng đập âm ỉ", + "Tiếng rung cơ học", + "Tivi", + "Đài radio", + "Ghi âm hiện trường" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/zh-Hans.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/zh-Hans.json new file mode 100644 index 0000000..4ad9243 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/zh-Hans.json @@ -0,0 +1,523 @@ +[ + "说话声", + "儿童说话声", + "对话", + "旁白、独白", + "婴儿咿呀声", + "合成语音", + "喊叫", + "吼叫", + "高声欢叫", + "大叫", + "儿童喊叫", + "尖叫", + "耳语", + "笑声", + "婴儿笑声", + "咯咯笑", + "窃笑", + "捧腹大笑", + "轻笑", + "哭声", + "婴儿哭声", + "呜咽", + "呻吟", + "叹气", + "歌声", + "合唱", + "约德尔唱法", + "吟诵", + "念咒", + "儿童歌声", + "合成歌声", + "说唱", + "哼歌", + "痛苦呻吟", + "闷哼", + "吹口哨", + "呼吸声", + "喘鸣", + "打鼾", + "倒吸一口气", + "喘气", + "鼻子喷气", + "咳嗽", + "清嗓子", + "打喷嚏", + "吸鼻子", + "跑步声", + "拖着脚走路", + "脚步声", + "咀嚼声", + "咬东西", + "漱口", + "肚子咕咕叫", + "打饱嗝", + "打嗝", + "放屁", + "手部声音", + "打响指", + "拍手", + "心跳声", + "心脏杂音", + "欢呼", + "掌声", + "多人闲聊", + "人群", + "嘈杂人声", + "儿童玩耍", + "动物", + "宠物", + "狗", + "狗吠", + "小狗尖吠", + "嚎叫", + "汪汪叫", + "低吼", + "狗呜咽", + "猫", + "猫打呼噜", + "猫叫声", + "嘶嘶声", + "猫叫春", + "家畜", + "马", + "马蹄声", + "马嘶鸣", + "牛", + "牛叫声", + "牛铃", + "猪", + "猪叫声", + "山羊", + "羊叫声", + "绵羊", + "家禽", + "鸡", + "母鸡咯咯叫", + "公鸡打鸣", + "火鸡", + "火鸡叫声", + "鸭子", + "鸭子叫声", + "鹅", + "鹅叫声", + "野生动物", + "狮子、老虎", + "猛兽咆哮", + "鸟", + "鸟叫声", + "小鸟啾啾叫", + "鸟的刺耳叫声", + "鸽子", + "鸽子咕咕叫", + "乌鸦", + "乌鸦叫声", + "猫头鹰", + "猫头鹰叫声", + "鸟拍打翅膀", + "狼等犬科动物", + "啮齿动物", + "老鼠", + "小动物跑动声", + "昆虫", + "蟋蟀", + "蚊子", + "苍蝇", + "嗡嗡声", + "蜜蜂、黄蜂", + "青蛙", + "青蛙叫声", + "蛇", + "响尾蛇嘎啦声", + "鲸鱼叫声", + "音乐", + "乐器", + "弹拨乐器", + "吉他", + "电吉他", + "贝斯", + "木吉他", + "滑棒吉他", + "吉他点弦", + "吉他扫弦", + "班卓琴", + "西塔琴", + "曼陀林", + "筝类乐器", + "尤克里里", + "键盘乐器", + "钢琴", + "电钢琴", + "管风琴", + "电子风琴", + "哈蒙德风琴", + "合成器", + "采样器", + "羽管键琴", + "打击乐器", + "架子鼓", + "鼓机", + "鼓", + "小军鼓", + "军鼓边击", + "军鼓滚奏", + "大鼓", + "定音鼓", + "塔布拉鼓", + "钹", + "踩镲", + "木鱼", + "铃鼓", + "摇响乐器", + "沙锤", + "锣", + "管钟", + "旋律打击乐器", + "木琴", + "钟琴", + "颤音琴", + "钢鼓", + "管弦乐队", + "铜管乐器", + "圆号", + "小号", + "长号", + "弓弦乐器", + "弦乐合奏", + "小提琴", + "提琴拨弦", + "大提琴", + "低音提琴", + "木管乐器", + "笛子", + "萨克斯", + "单簧管", + "竖琴", + "钟声", + "教堂钟声", + "铃铛", + "自行车铃", + "音叉", + "报时钟声", + "风铃", + "组钟轮鸣", + "口琴", + "手风琴", + "风笛", + "迪吉里杜管", + "羊角号", + "特雷门琴", + "颂钵", + "DJ 搓碟", + "流行音乐", + "嘻哈音乐", + "节奏口技", + "摇滚乐", + "重金属音乐", + "朋克摇滚", + "垃圾摇滚", + "前卫摇滚", + "早期摇滚乐", + "迷幻摇滚", + "节奏布鲁斯", + "灵魂乐", + "雷鬼音乐", + "乡村音乐", + "摇摆乐", + "蓝草音乐", + "放克音乐", + "民谣", + "中东音乐", + "爵士乐", + "迪斯科", + "古典音乐", + "歌剧", + "电子音乐", + "浩室音乐", + "铁克诺音乐", + "回响贝斯", + "鼓打贝斯", + "电子乐(Electronica)", + "电子舞曲", + "氛围音乐", + "迷幻舞曲", + "拉丁美洲音乐", + "萨尔萨音乐", + "弗拉门戈", + "布鲁斯", + "儿童音乐", + "新世纪音乐", + "声乐", + "无伴奏演唱", + "非洲音乐", + "非洲节拍音乐", + "基督教音乐", + "福音音乐", + "亚洲音乐", + "南印度古典音乐", + "宝莱坞音乐", + "斯卡音乐", + "传统音乐", + "独立音乐", + "歌曲", + "背景音乐", + "主题音乐", + "广告歌曲", + "配乐", + "摇篮曲", + "游戏音乐", + "圣诞音乐", + "舞曲", + "婚礼音乐", + "欢快音乐", + "悲伤音乐", + "温柔音乐", + "激昂音乐", + "愤怒音乐", + "恐怖音乐", + "风声", + "树叶沙沙声", + "麦克风风噪", + "雷雨", + "雷声", + "水声", + "雨声", + "雨滴声", + "雨点敲打声", + "溪流", + "瀑布", + "大海", + "海浪", + "蒸汽", + "咕嘟水声", + "火焰燃烧", + "噼啪声", + "交通工具", + "船只", + "帆船", + "划艇", + "快艇", + "轮船", + "机动车", + "汽车", + "汽车喇叭", + "喇叭短鸣", + "汽车警报", + "电动车窗", + "车辆打滑", + "轮胎尖啸", + "汽车驶过", + "赛车", + "卡车", + "气刹放气声", + "卡车气喇叭", + "倒车提示音", + "冰淇淋车", + "公交车", + "应急车辆", + "警车警笛", + "救护车警笛", + "消防车警笛", + "摩托车", + "交通噪音", + "轨道交通", + "火车", + "火车汽笛", + "火车喇叭", + "火车车厢", + "火车车轮尖啸", + "地铁", + "飞行器", + "飞机发动机", + "喷气发动机", + "螺旋桨", + "直升机", + "飞机", + "自行车", + "滑板", + "发动机", + "小型发动机", + "牙钻", + "割草机", + "链锯", + "中型发动机", + "大型发动机", + "发动机爆震", + "发动机启动", + "发动机怠速", + "发动机加速", + "门", + "门铃", + "门铃叮咚声", + "推拉门", + "摔门声", + "敲门声", + "轻敲声", + "吱吱声", + "开关橱柜", + "开关抽屉", + "锅碗瓢盆", + "刀叉餐具", + "切菜", + "煎炸食物", + "微波炉", + "搅拌机", + "水龙头", + "水槽放水、冲洗", + "浴缸放水、洗澡", + "吹风机", + "马桶冲水", + "刷牙", + "电动牙刷", + "吸尘器", + "拉链", + "钥匙叮当响", + "硬币掉落", + "剪刀", + "电动剃须刀", + "洗牌", + "打字", + "打字机", + "电脑键盘", + "写字", + "警报", + "电话", + "电话铃声", + "手机铃声", + "电话按键音", + "电话拨号音", + "电话忙音", + "闹钟", + "警笛", + "防空警笛", + "蜂鸣器", + "烟雾报警器", + "火灾警报", + "雾笛", + "哨声", + "蒸汽汽笛", + "机械装置", + "棘轮", + "钟表", + "嘀嗒声", + "钟表嘀嗒声", + "齿轮", + "滑轮", + "缝纫机", + "风扇", + "空调", + "收银机", + "打印机", + "相机", + "单反相机", + "工具", + "锤子", + "风镐", + "锯木头", + "锉刀", + "砂纸打磨", + "电动工具", + "电钻", + "爆炸", + "枪声", + "机枪", + "排枪齐射", + "炮火", + "玩具火药枪", + "烟花", + "鞭炮", + "爆裂声", + "火山喷发", + "轰隆巨响", + "木头", + "劈木头", + "木头碎裂", + "断裂声", + "玻璃", + "清脆碰撞声", + "玻璃破碎", + "液体", + "水花飞溅", + "液体晃荡", + "挤压湿物声", + "滴水声", + "倒水", + "涓涓细流", + "水流喷涌", + "注入液体", + "喷洒", + "水泵抽水", + "搅拌", + "沸腾", + "声呐", + "射箭", + "嗖嗖声", + "闷响", + "空洞闷响", + "电子调音器", + "效果器", + "合唱效果", + "篮球弹地", + "砰的一声", + "拍打声", + "重击声", + "砸碎声", + "破碎声", + "弹跳声", + "鞭子抽打", + "旗帜拍动", + "划刻声", + "刮擦声", + "摩擦声", + "滚动声", + "压碎声", + "揉皱声", + "撕裂声", + "哔哔声", + "铮的一声", + "叮的一声", + "金属哐当声", + "尖啸声", + "嘎吱声", + "沙沙声", + "呼呼转动声", + "杂乱碰撞声", + "滋滋声", + "咔嗒声", + "有节奏的咔嗒声", + "隆隆声", + "小物体落水", + "叮当声", + "低沉嗡鸣", + "咻的一声", + "弹簧嘣声", + "咔嚓脆响", + "寂静无声", + "正弦波", + "谐波", + "扫频音", + "音效", + "脉冲音", + "室内(小房间)", + "室内(大空间)", + "室内(公共场所)", + "户外(城市)", + "户外(乡村、野外)", + "混响", + "回声", + "噪音", + "环境噪音", + "静电噪音", + "电流嗡鸣", + "声音失真", + "通话侧音", + "刺耳嘈杂声", + "白噪音", + "粉红噪音", + "低沉脉动声", + "振动声", + "电视", + "收音机", + "实地录音" +] diff --git a/SoundVisualizer.Core/Sounds/Data/SoundNames/zh-Hant.json b/SoundVisualizer.Core/Sounds/Data/SoundNames/zh-Hant.json new file mode 100644 index 0000000..87db2ea --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/SoundNames/zh-Hant.json @@ -0,0 +1,523 @@ +[ + "說話聲", + "兒童說話聲", + "交談聲", + "旁白、獨白", + "牙牙學語", + "合成語音", + "大喊", + "咆哮", + "興奮高呼", + "大叫", + "兒童大喊", + "尖叫", + "悄悄話", + "笑聲", + "嬰兒笑聲", + "咯咯笑", + "竊笑", + "捧腹大笑", + "輕笑", + "哭泣聲", + "嬰兒哭聲", + "嗚咽", + "呻吟", + "嘆氣", + "歌聲", + "合唱團", + "約德爾唱法", + "吟誦", + "唸咒", + "兒童歌聲", + "合成歌聲", + "饒舌", + "哼歌", + "悶哼", + "哼聲", + "吹口哨", + "呼吸聲", + "喘鳴聲", + "打呼聲", + "倒抽一口氣", + "喘氣聲", + "鼻子噴氣聲", + "咳嗽聲", + "清喉嚨", + "打噴嚏", + "吸鼻子", + "跑步聲", + "拖著腳走路", + "腳步聲", + "咀嚼聲", + "啃咬聲", + "漱口聲", + "肚子咕嚕叫", + "打飽嗝", + "打嗝", + "放屁聲", + "手部聲音", + "彈手指", + "拍手聲", + "心跳聲", + "心雜音", + "歡呼聲", + "掌聲", + "眾人閒聊聲", + "人群", + "嘈雜人聲", + "兒童嬉戲聲", + "動物", + "寵物", + "狗", + "狗吠聲", + "小型犬尖吠", + "嚎叫", + "汪汪叫", + "低吼", + "狗哀鳴", + "貓", + "貓呼嚕聲", + "貓叫聲", + "嘶嘶聲", + "貓叫春", + "家畜", + "馬", + "馬蹄聲", + "馬嘶聲", + "牛", + "牛叫聲", + "牛鈴", + "豬", + "豬叫聲", + "山羊", + "羊叫聲", + "綿羊", + "家禽", + "雞", + "雞咯咯叫", + "公雞啼叫", + "火雞", + "火雞叫聲", + "鴨子", + "鴨叫聲", + "鵝", + "鵝叫聲", + "野生動物", + "獅子、老虎", + "獅虎吼叫", + "鳥類", + "鳥叫聲", + "小鳥啾啾叫", + "鳥尖叫聲", + "鴿子", + "鴿子咕咕叫", + "烏鴉", + "烏鴉叫聲", + "貓頭鷹", + "貓頭鷹叫聲", + "鳥拍翅聲", + "狼、野犬", + "鼠類", + "老鼠", + "小動物跑動聲", + "昆蟲", + "蟋蟀", + "蚊子", + "蒼蠅", + "嗡嗡聲", + "蜜蜂、黃蜂", + "青蛙", + "青蛙叫聲", + "蛇", + "喀啦喀啦聲", + "鯨魚叫聲", + "音樂", + "樂器", + "撥弦樂器", + "吉他", + "電吉他", + "電貝斯", + "木吉他", + "滑棒吉他", + "吉他點弦", + "吉他刷弦", + "斑鳩琴", + "西塔琴", + "曼陀林", + "齊特琴 (箏類)", + "烏克麗麗", + "鍵盤樂器", + "鋼琴", + "電鋼琴", + "管風琴", + "電子風琴", + "哈蒙德風琴", + "合成器", + "取樣器", + "大鍵琴", + "打擊樂器", + "爵士鼓", + "鼓機", + "鼓", + "小鼓", + "鼓邊重擊", + "擊鼓滾奏", + "大鼓", + "定音鼓", + "塔布拉鼓", + "銅鈸", + "腳踏鈸", + "木魚", + "鈴鼓", + "搖鈴 (樂器)", + "沙鈴", + "鑼", + "管鐘", + "鍵盤打擊樂器", + "木琴", + "鐵琴", + "顫音琴", + "鋼鼓", + "管弦樂團", + "銅管樂器", + "法國號", + "小號", + "長號", + "弓弦樂器", + "弦樂合奏", + "小提琴", + "弦樂撥奏", + "大提琴", + "低音大提琴", + "木管樂器", + "笛子", + "薩克斯風", + "單簧管", + "豎琴", + "鐘聲", + "教堂鐘聲", + "鈴鐺", + "腳踏車鈴", + "音叉", + "報時鐘聲", + "風鈴", + "組鐘輪鳴", + "口琴", + "手風琴", + "風笛", + "迪吉里杜管", + "羊角號", + "特雷門琴", + "頌缽", + "DJ 刷碟", + "流行音樂", + "嘻哈音樂", + "口技 (Beatbox)", + "搖滾樂", + "重金屬", + "龐克搖滾", + "油漬搖滾", + "前衛搖滾", + "早期搖滾樂", + "迷幻搖滾", + "節奏藍調", + "靈魂樂", + "雷鬼樂", + "鄉村音樂", + "搖擺樂", + "藍草音樂", + "放克", + "民謠", + "中東音樂", + "爵士樂", + "迪斯可", + "古典音樂", + "歌劇", + "電子音樂", + "浩室音樂 (House)", + "鐵克諾 (Techno)", + "回響貝斯 (Dubstep)", + "鼓打貝斯", + "電子樂 (Electronica)", + "電子舞曲", + "氛圍音樂", + "迷幻舞曲 (Trance)", + "拉丁美洲音樂", + "騷莎音樂", + "佛朗明哥", + "藍調", + "兒童音樂", + "新世紀音樂", + "聲樂", + "阿卡貝拉", + "非洲音樂", + "非洲節拍", + "基督教音樂", + "福音音樂", + "亞洲音樂", + "南印度古典音樂", + "寶萊塢音樂", + "斯卡 (Ska)", + "傳統音樂", + "獨立音樂", + "歌曲", + "背景音樂", + "主題曲", + "廣告歌曲", + "配樂", + "搖籃曲", + "遊戲音樂", + "聖誕音樂", + "舞曲", + "婚禮音樂", + "歡樂音樂", + "悲傷音樂", + "溫柔音樂", + "激昂音樂", + "憤怒音樂", + "恐怖音樂", + "風聲", + "樹葉沙沙聲", + "麥克風風切聲", + "雷雨聲", + "雷聲", + "水聲", + "雨聲", + "雨滴聲", + "雨打屋頂或窗戶", + "溪流聲", + "瀑布聲", + "海洋", + "海浪聲", + "蒸汽聲", + "咕嚕水聲", + "火焰燃燒", + "劈啪聲", + "交通工具", + "船隻", + "帆船", + "划船、獨木舟", + "快艇", + "輪船", + "道路車輛", + "汽車", + "汽車喇叭", + "喇叭短鳴", + "汽車防盜警報", + "電動車窗", + "車輛打滑", + "輪胎尖嘯聲", + "汽車駛過", + "賽車", + "卡車", + "氣壓煞車", + "卡車喇叭", + "倒車警示音", + "冰淇淋車", + "公車", + "緊急車輛", + "警車 (警笛)", + "救護車 (警笛)", + "消防車 (警笛)", + "機車", + "交通噪音", + "軌道車輛", + "火車", + "火車汽笛", + "火車喇叭", + "火車車廂", + "火車車輪尖嘯聲", + "捷運、地鐵", + "飛行器", + "飛機引擎", + "噴射引擎", + "螺旋槳", + "直升機", + "飛機", + "腳踏車", + "滑板", + "引擎", + "輕型引擎 (高頻)", + "牙醫鑽牙聲", + "割草機", + "電鋸", + "中型引擎 (中頻)", + "重型引擎 (低頻)", + "引擎爆震", + "引擎啟動", + "引擎怠速", + "引擎加速", + "門", + "門鈴", + "門鈴叮咚聲", + "拉門", + "甩門聲", + "敲門聲", + "輕敲聲", + "吱吱聲", + "櫥櫃開關聲", + "抽屜開關聲", + "碗盤鍋具碰撞", + "刀叉餐具", + "切菜聲", + "煎炸聲", + "微波爐", + "果汁機", + "水龍頭", + "水槽 (放水或清洗)", + "浴缸 (放水或清洗)", + "吹風機", + "馬桶沖水", + "刷牙聲", + "電動牙刷", + "吸塵器", + "拉鍊", + "鑰匙叮噹聲", + "硬幣掉落", + "剪刀", + "電動刮鬍刀", + "洗牌聲", + "打字聲", + "打字機", + "電腦鍵盤", + "寫字聲", + "警報", + "電話", + "電話鈴聲", + "手機鈴聲", + "撥號按鍵音", + "撥號音", + "忙線音", + "鬧鐘", + "警笛", + "民防警笛", + "蜂鳴器", + "煙霧警報", + "火警警報", + "霧笛", + "哨子聲", + "蒸汽汽笛", + "機械裝置", + "棘輪", + "時鐘", + "滴答聲", + "時鐘滴答聲", + "齒輪", + "滑輪", + "縫紉機", + "風扇", + "冷氣", + "收銀機", + "印表機", + "相機", + "單眼相機", + "工具", + "鐵鎚", + "路面破碎機", + "鋸切聲", + "銼刀聲", + "砂紙打磨", + "電動工具", + "電鑽", + "爆炸聲", + "槍聲", + "機關槍", + "排槍齊射", + "砲擊", + "玩具火藥槍", + "煙火", + "鞭炮", + "爆裂聲", + "火山爆發", + "轟隆巨響", + "木頭", + "劈柴聲", + "木頭碎裂", + "斷裂聲", + "玻璃", + "清脆碰撞聲", + "玻璃碎裂", + "液體", + "潑濺聲", + "液體晃動", + "濕軟物擠壓聲", + "滴水聲", + "倒水聲", + "細流聲", + "液體湧出", + "裝水聲", + "噴灑聲", + "幫浦抽水", + "攪拌聲", + "沸騰聲", + "聲納", + "弓箭", + "咻的破風聲", + "沉悶撞擊聲", + "空洞悶響", + "電子調音器", + "效果器", + "合唱效果", + "籃球彈跳聲", + "砰的一聲", + "拍打聲", + "重擊聲", + "砸碎聲", + "碎裂聲", + "彈跳聲", + "揮鞭聲", + "旗幟拍動聲", + "刮劃聲", + "刮擦聲", + "摩擦聲", + "滾動聲", + "壓碎聲", + "揉皺聲", + "撕裂聲", + "嗶聲", + "清脆鳴響", + "叮的一聲", + "金屬匡噹聲", + "尖嘯聲", + "嘎吱聲", + "沙沙聲", + "機件轉動聲", + "雜亂碰撞聲", + "滋滋聲", + "喀噠聲", + "連續喀噠聲", + "隆隆聲", + "小物落水聲", + "叮噹聲", + "低鳴聲", + "短促尖鳴", + "彈簧聲", + "喀滋脆響", + "安靜無聲", + "正弦波", + "泛音", + "掃頻音", + "音效", + "脈衝聲", + "室內 (小房間)", + "室內 (大廳)", + "室內 (公共場所)", + "戶外 (都市)", + "戶外 (大自然)", + "殘響", + "回音", + "噪音", + "環境噪音", + "靜電雜音", + "電流低鳴聲", + "聲音失真", + "電話側音", + "混亂嘈雜聲", + "白噪音", + "粉紅噪音", + "陣陣悶響", + "機械振動聲", + "電視", + "收音機", + "實地錄音" +] diff --git a/SoundVisualizer.Core/Sounds/Data/sound_groups.tsv b/SoundVisualizer.Core/Sounds/Data/sound_groups.tsv new file mode 100644 index 0000000..a6a6668 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/Data/sound_groups.tsv @@ -0,0 +1,534 @@ +# SPDX-License-Identifier: CC-BY-SA-4.0 +# +# SoundVisualizer sound groups: the group of each of the 521 YAMNet sound names in the Sounds tab. +# Adapted from the AudioSet Ontology by Google Inc. +# (https://github.com/audioset/ontology, commit d417d32), licensed under +# CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/). +# Changes: the 521 YAMNet sound names were regrouped into everyday groups by SoundVisualizer contributors (revised 2026-10: laughs, yells, cries and cheering moved to the speech groups). +# This file is licensed under CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/). +# Provided as is, without warranties of any kind (Section 5 of the licence: Disclaimer of Warranties +# and Limitation of Liability). +# +# Format: group_iddisplay_name, one sound per line. Groups and sounds are in display order. +# group_id is a SoundGroup entry (lower case); display_name is from assets/ai/yamnet_class_map.csv. +alarms Alarm +alarms Smoke detector, smoke alarm +alarms Fire alarm +sirens Siren +sirens Ambulance (siren) +sirens Fire engine, fire truck (siren) +sirens Police car (siren) +sirens Civil defense siren +horns Vehicle horn, car horn, honking +horns Air horn, truck horn +horns Car alarm +vehicle_warnings Reversing beeps +vehicle_warnings Bicycle bell +vehicle_warnings Tire squeal +vehicle_warnings Skidding +vehicle_warnings Emergency vehicle +vehicle_warnings Train horn +vehicle_warnings Train whistle +vehicle_warnings Foghorn +breaking Shatter +breaking Breaking +breaking Smash, crash +breaking Bang +slam Slam +gunshots Gunshot, gunfire +gunshots Machine gun +gunshots Fusillade +gunshots Artillery fire +gunshots Cap gun +explosions Explosion +explosions Boom +explosions Burst, pop +fireworks Fireworks +fireworks Firecracker +thunder Thunder +thunder Thunderstorm +chainsaw Chainsaw +talking Speech +talking Conversation +talking Child speech, kid speaking +talking Narration, monologue +talking Whispering +talking Babbling +talking Speech synthesizer +distress Shout +distress Screaming +distress Crying, sobbing +distress Children shouting +distress Yell +distress Bellow +distress Whoop +distress Wail, moan +baby Baby cry, infant cry +baby Whimper +crowd Crowd +crowd Chatter +crowd Hubbub, speech noise, speech babble +crowd Children playing +crowd Cheering +laughter Laughter +laughter Baby laughter +laughter Giggle +laughter Belly laugh +laughter Chuckle, chortle +laughter Snicker +singing Singing +singing Choir +singing Child singing +singing Rapping +singing Synthetic singing +doorbell Doorbell +doorbell Ding-dong +doorbell Knock +doorbell Tap +phone Telephone +phone Telephone bell ringing +phone Ringtone +phone Telephone dialing, DTMF +phone Dial tone +phone Busy signal +signals Alarm clock +signals Beep, bleep +signals Buzzer +signals Ding +signals Ping +signals Whistle +signals Steam whistle +fire Fire +fire Crackle +dog Dog +dog Bark +dog Bow-wow +dog Yip +dog Growling +dog Howl +dog Whimper (dog) +voices Applause +voices Clapping +voices Humming +voices Whistling +voices Yodeling +voices Chant +voices Mantra +voices Finger snapping +voices Hands +footsteps Walk, footsteps +footsteps Run +footsteps Shuffle +door Door +door Sliding door +door Squeak +door Creak +door Cupboard open or close +door Drawer open or close +water Water tap, faucet +water Toilet flush +water Drip +water Sink (filling or washing) +water Bathtub (filling or washing) +water Fill (with liquid) +water Gush +water Pour +water Trickle, dribble +water Spray +water Splash, splatter +water Slosh +water Squish +water Pump (liquid) +water Liquid +kitchen Microwave oven +kitchen Frying (food) +kitchen Dishes, pots, and pans +kitchen Blender +kitchen Boiling +kitchen Sizzle +kitchen Chopping (food) +kitchen Cutlery, silverware +kitchen Stir +kitchen Glass +kitchen Chink, clink +appliances Vacuum cleaner +appliances Hair dryer +appliances Clock +appliances Mechanical fan +appliances Air conditioning +appliances Sewing machine +appliances Electric shaver, electric razor +appliances Electric toothbrush +appliances Toothbrush +appliances Printer +appliances Camera +appliances Single-lens reflex camera +appliances Tick-tock +appliances Tick +tools Drill +tools Hammer +tools Lawn mower +tools Power tool +tools Jackhammer +tools Sawing +tools Sanding +tools Filing (rasp) +tools Tools +tools Dental drill, dentist's drill +tools Mechanisms +tools Gears +tools Pulleys +tools Ratchet, pawl +tools Whir +tools Cash register +vehicle Vehicle +vehicle Car +vehicle Train +vehicle Fixed-wing aircraft, airplane +vehicle Car passing by +vehicle Traffic noise, roadway noise +vehicle Bus +vehicle Truck +vehicle Motorcycle +vehicle Motor vehicle (road) +vehicle Air brake +vehicle Power windows, electric windows +vehicle Race car, auto racing +vehicle Ice cream truck, ice cream van +vehicle Bicycle +vehicle Skateboard +vehicle Engine +vehicle Engine starting +vehicle Idling +vehicle Accelerating, revving, vroom +vehicle Engine knocking +vehicle Light engine (high frequency) +vehicle Medium engine (mid frequency) +vehicle Heavy engine (low frequency) +vehicle Subway, metro, underground +vehicle Rail transport +vehicle Railroad car, train wagon +vehicle Train wheels squealing +vehicle Helicopter +vehicle Aircraft +vehicle Aircraft engine +vehicle Jet engine +vehicle Propeller, airscrew +vehicle Boat, Water vehicle +vehicle Ship +vehicle Motorboat, speedboat +vehicle Sailboat, sailing ship +vehicle Rowboat, canoe, kayak +vehicle Toot +bell Bell +bell Church bell +bell Chime +bell Wind chime +bell Jingle bell +bell Cowbell +bell Change ringing (campanology) +bell Tuning fork +animal Animal +animal Cat +animal Bird +animal Chicken, rooster +animal Meow +animal Purr +animal Caterwaul +animal Domestic animals, pets +animal Bird vocalization, bird call, bird song +animal Chirp, tweet +animal Squawk +animal Pigeon, dove +animal Coo +animal Crow +animal Caw +animal Owl +animal Hoot +animal Bird flight, flapping wings +animal Livestock, farm animals, working animals +animal Crowing, cock-a-doodle-doo +animal Cluck +animal Fowl +animal Duck +animal Quack +animal Goose +animal Honk +animal Turkey +animal Gobble +animal Horse +animal Neigh, whinny +animal Clip-clop +animal Cattle, bovinae +animal Moo +animal Pig +animal Oink +animal Sheep +animal Goat +animal Bleat +animal Wild animals +animal Roaring cats (lions, tigers) +animal Roar +animal Canidae, dogs, wolves +animal Rodents, rats, mice +animal Mouse +animal Patter +animal Insect +animal Mosquito +animal Fly, housefly +animal Buzz +animal Bee, wasp, etc. +animal Cricket +animal Frog +animal Croak +animal Snake +animal Whale vocalization +nature Rain +nature Wind +nature Waves, surf +nature Rain on surface +nature Raindrop +nature Rustling leaves +nature Water +nature Stream +nature Waterfall +nature Ocean +nature Steam +nature Gurgling +nature Eruption +body Cough +body Snoring +body Breathing +body Sneeze +body Gasp +body Wheeze +body Pant +body Throat clearing +body Sniff +body Snort +body Sigh +body Groan +body Grunt +body Chewing, mastication +body Biting +body Gargling +body Hiccup +body Burping, eructation +body Stomach rumble +body Fart +body Heart sounds, heartbeat +body Heart murmur +objects Thump, thud +objects Typing +objects Keys jangling +objects Thunk +objects Clatter +objects Clang +objects Slap, smack +objects Whack, thwack +objects Crunch +objects Crushing +objects Crumpling, crinkling +objects Tearing +objects Rattle +objects Hiss +objects Jingle, tinkle +objects Coin (dropping) +objects Zipper (clothing) +objects Scissors +objects Shuffling cards +objects Computer keyboard +objects Typewriter +objects Writing +objects Clicking +objects Clickety-clack +objects Squeal +objects Rustle +objects Scratch +objects Scrape +objects Rub +objects Roll +objects Bouncing +objects Basketball bounce +objects Plop +objects Whoosh, swoosh, swish +objects Whip +objects Flap +objects Boing +objects Zing +objects Arrow +objects Wood +objects Chop +objects Splinter +objects Crack +music Music +music Musical instrument +music Pop music +music Piano +music Classical music +music Guitar +music Background music +music Drum +music Song +music Theme music +music Soundtrack music +music Video game music +music Jingle (music) +music Lullaby +music Music for children +music Christmas music +music Wedding music +music Dance music +music Happy music +music Sad music +music Tender music +music Exciting music +music Angry music +music Scary music +music Rock music +music Heavy metal +music Punk rock +music Grunge +music Progressive rock +music Rock and roll +music Psychedelic rock +music Hip hop music +music Beatboxing +music Rhythm and blues +music Soul music +music Funk +music Disco +music Reggae +music Ska +music Jazz +music Blues +music Swing music +music Country +music Bluegrass +music Folk music +music Opera +music Electronic music +music House music +music Techno +music Dubstep +music Drum and bass +music Electronica +music Electronic dance music +music Ambient music +music Trance music +music New-age music +music Vocal music +music A capella +music Gospel music +music Christian music +music Independent music +music Traditional music +music Middle Eastern music +music Music of Africa +music Afrobeat +music Music of Asia +music Carnatic music +music Music of Bollywood +music Music of Latin America +music Salsa music +music Flamenco +music Electric piano +music Keyboard (musical) +music Organ +music Electronic organ +music Hammond organ +music Synthesizer +music Sampler +music Harpsichord +music Acoustic guitar +music Electric guitar +music Bass guitar +music Steel guitar, slide guitar +music Tapping (guitar technique) +music Strum +music Plucked string instrument +music Banjo +music Sitar +music Mandolin +music Zither +music Ukulele +music Harp +music Bowed string instrument +music Violin, fiddle +music Pizzicato +music Cello +music Double bass +music String section +music Orchestra +music Brass instrument +music Trumpet +music Trombone +music French horn +music Wind instrument, woodwind instrument +music Flute +music Saxophone +music Clarinet +music Harmonica +music Accordion +music Bagpipes +music Didgeridoo +music Shofar +music Theremin +music Percussion +music Drum kit +music Drum machine +music Snare drum +music Rimshot +music Drum roll +music Bass drum +music Timpani +music Tabla +music Cymbal +music Hi-hat +music Wood block +music Tambourine +music Rattle (instrument) +music Maraca +music Gong +music Tubular bells +music Mallet percussion +music Marimba, xylophone +music Glockenspiel +music Vibraphone +music Steelpan +music Scratching (performance technique) +music Electronic tuner +music Effects unit +music Chorus effect +music Singing bowl +background Television +background Radio +background Noise +background Field recording +background Environmental noise +background Hum +background Mains hum +background Rumble +background Static +background White noise +background Pink noise +background Throbbing +background Vibration +background Distortion +background Cacophony +background Sidetone +background Wind noise (microphone) +background Echo +background Reverberation +background Inside, small room +background Inside, large room or hall +background Inside, public space +background Outside, urban or manmade +background Outside, rural or natural +background Silence +background Sound effect +background Sine wave +background Harmonic +background Chirp tone +background Pulse +background Sonar diff --git a/SoundVisualizer.Core/Sounds/KeptSounds.cs b/SoundVisualizer.Core/Sounds/KeptSounds.cs new file mode 100644 index 0000000..c5ce652 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/KeptSounds.cs @@ -0,0 +1,48 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; + +namespace SoundVisualizer.Sounds +{ + /// + /// 거르기를 켠 채로 방금 바꾼 소리(모델 이름, #374). 모바일 KeptSounds 와 같다. 바꾼 소리가 거르기에서 빠져도 찾는 말이나 거르기를 + /// 바꿀 때까지 목록에 남겨, 누른 카드(고급 모드는 그 줄)가 눈앞에서 사라지지 않게 한다. + /// + /// 카드가 아니라 소리를 남긴다. 기본 모드는 남은 소리가 있는 카드가, 고급 모드는 그 줄과 그 위의 머리가 남고, "바꾼 소리" 거르기도 + /// 같은 방법으로 된다. · 는 남기기 시작할 때의 것이고, 지금 것과 다르면 아무것도 남기지 않는다. + /// 화면은 찾는 말이나 거르기를 바꿀 때와 모두 되돌리기를 할 때 로 비운다. + /// + public sealed record KeptSounds(string Query, SoundFilter Filter, IReadOnlySet Names) + { + private static readonly IReadOnlySet NoNames = FrozenSet.Empty; + + /// 남길 것이 없다. + public static KeptSounds Empty { get; } = new("", SoundFilter.All, NoNames); + + /// 지금의 찾는 말과 거르기에서 남길 소리. 남기기 시작할 때와 다르면 빈 것이다. + public IReadOnlySet NamesFor(string query, SoundFilter filter) => + string.Equals(query, Query, StringComparison.Ordinal) && filter == Filter ? Names : NoNames; + + /// + /// 를 더한 것. 찾는 말이나 거르기가 남기기 시작할 때와 다르면 앞의 것은 버리고 새로 시작한다. + /// 전체에서는 모든 소리가 거르기에 맞아 남길 것이 없으니 빈 것이다. + /// + public KeptSounds Plus(string query, SoundFilter filter, IEnumerable added) + { + if (filter == SoundFilter.All) return Empty; + var names = new HashSet(NamesFor(query, filter), StringComparer.Ordinal); + names.UnionWith(added); + return new KeptSounds(query, filter, names.ToFrozenSet(StringComparer.Ordinal)); + } + + public bool Equals(KeptSounds? other) => + other is not null && + string.Equals(Query, other.Query, StringComparison.Ordinal) && + Filter == other.Filter && + Names.SetEquals(other.Names); + + // 집합의 순서와 상관없이 같은 값이어야 해서 수만 넣는다. + public override int GetHashCode() => HashCode.Combine(Query, Filter, Names.Count); + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundCards.cs b/SoundVisualizer.Core/Sounds/SoundCards.cs new file mode 100644 index 0000000..20e1ecd --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundCards.cs @@ -0,0 +1,220 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using System.Text; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Sounds +{ + // 분류 탭의 묶음 화면이 다루는 값들. 모바일 SoundGroups.kt 의 데이터 클래스와 같다. + // 목록을 든 레코드는 같음을 목록의 내용으로 본다(모바일 data class 처럼). 레코드가 스스로 만드는 같음은 목록의 참조만 본다. + + /// 묶음 표의 한 줄. 는 머리글을 뺀 줄 순서(0부터)이고, 묶음 화면에서 소리가 보이는 순서다. + public sealed record SoundGroupRow(SoundGroup Group, int Order); + + /// + /// 분류 탭의 카드 하나. 같은 묶음이면서 기본 종류가 같은 소리들이다(카드 = (묶음, 기본 종류)). + /// + /// 지금은 묶음마다 기본 종류가 하나라 카드 하나가 곧 묶음 하나다. AI 의 매핑이 바뀌어 한 소리의 기본 종류가 달라지면, 그 소리는 + /// 표를 고치지 않아도 새 종류의 구역에 같은 묶음 이름의 카드로 따로 보인다. 그래서 한 카드의 소리는 늘 기본 종류가 같고, + /// 카드에서 고르는 "(기본)" 이 모든 소리에 맞다. 섞인 카드()도 하나씩 바꾼 소리가 있을 때만 생긴다. + /// + /// 표에 없는 소리면 null 이고, 그 소리 하나가 카드가 된다. + /// 카드의 소리들의 기본 종류. 카드가 놓이는 구역이다. + /// 표의 순서대로. + /// + /// 이름(소리 이름)이 곧 카드 이름인 소리. 새로 지은 이름을 쓰는 묶음이면 null 이다. 매핑이 바뀌어 묶음이 두 구역에 나뉘면 다른 + /// 구역의 카드에 들어 있을 수 있다. + /// + public sealed record SoundCard(SoundGroup? Group, SoundType DefaultType, IReadOnlyList Members, SoundEntry? TitleEntry) + { + /// 새로 지은 카드 이름을 쓰면 그 묶음(모바일 titleRes). 카드 이름이 소리 이름()이면 null. + public SoundGroup? TitleGroup => TitleEntry is null ? Group : null; + + /// 목록에서 카드를 가리키는 이름("sirens:danger"). 한 묶음이 두 구역에 나뉘어도 겹치지 않게 기본 종류를 붙인다. + public string Id => + (Group?.Id() ?? "sound_" + (Members.Count > 0 ? Members[0].Index : -1).ToString(CultureInfo.InvariantCulture)) + + ":" + DefaultType.ToLabel(); + + public bool Equals(SoundCard? other) => + other is not null && Group == other.Group && DefaultType == other.DefaultType && + Equals(TitleEntry, other.TitleEntry) && ValueLists.SameItems(Members, other.Members); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Group); + hash.Add(DefaultType); + hash.Add(TitleEntry); + ValueLists.AddItems(ref hash, Members); + return hash.ToHashCode(); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Id = ").Append(Id).Append(", Members = ").Append(ValueLists.Names(Members)); + builder.Append(", TitleEntry = ").Append(TitleEntry?.Name); + return true; + } + } + + /// 구역 하나. 이 기본 종류인 카드들이다. + public sealed record SoundSection(SoundType Type, IReadOnlyList Cards) + { + public bool Equals(SoundSection? other) => + other is not null && Type == other.Type && ValueLists.SameItems(Cards, other.Cards); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Type); + ValueLists.AddItems(ref hash, Cards); + return hash.ToHashCode(); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Type = ").Append(Type).Append(", Cards = [").AppendJoin(", ", Cards.Select(card => card.Id)).Append(']'); + return true; + } + } + + /// + /// 카드의 지금 종류. 저장하지 않고 소리마다의 종류에서 그때그때 정한다(). + /// + public abstract record GroupState + { + private GroupState() + { + } + + /// 모든 소리가 이다. 카드의 기본 종류와 다르면 묶음째 바꾼 것이다. + public sealed record Uniform(SoundType Type) : GroupState; + + /// 소리마다 종류가 다르다("일부 다름"). 카드의 기본 종류는 하나라 하나씩 바꾼 소리가 있다는 뜻이다. + public sealed record Mixed : GroupState + { + private Mixed() + { + } + + public static Mixed Instance { get; } = new(); + } + } + + /// + /// 카드 이름 밑의 작은 글씨. 는 개까지의 소리이고, + /// 는 못 보인 소리의 수("+N")다. + /// + public sealed record GroupPreview(IReadOnlyList Names, int More) + { + public bool Equals(GroupPreview? other) => + other is not null && More == other.More && ValueLists.SameItems(Names, other.Names); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(More); + ValueLists.AddItems(ref hash, Names); + return hash.ToHashCode(); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Names = ").Append(ValueLists.Names(Names)).Append(", More = ").Append(More); + return true; + } + } + + /// 찾는 말과 거르기에 남은 카드. + /// 남은 카드. + /// + /// 찾는 말과 거르기에 맞는 소리(표 순서). 기본 모드는 이 소리를 미리보기 앞에 두고(), + /// 고급 모드는 이 소리만 줄로 보인다. + /// + public sealed record ShownCard(SoundCard Card, IReadOnlyList Shown) + { + public bool Equals(ShownCard? other) => + other is not null && Card.Equals(other.Card) && ValueLists.SameItems(Shown, other.Shown); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Card); + ValueLists.AddItems(ref hash, Shown); + return hash.ToHashCode(); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Card = ").Append(Card.Id).Append(", Shown = ").Append(ValueLists.Names(Shown)); + return true; + } + } + + /// 남은 카드가 있는 구역. 남은 카드가 없는 구역은 목록에 넣지 않는다. + public sealed record ShownSection(SoundType Type, IReadOnlyList Cards) + { + public bool Equals(ShownSection? other) => + other is not null && Type == other.Type && ValueLists.SameItems(Cards, other.Cards); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Type); + ValueLists.AddItems(ref hash, Cards); + return hash.ToHashCode(); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Type = ").Append(Type).Append(", Cards = [").AppendJoin(", ", Cards).Append(']'); + return true; + } + } + + /// + /// 고급 모드 목록의 한 칸. 는 목록의 키로, 칸의 종류마다 앞 글자를 달리해 서로 겹치지 않는다. + /// + public abstract record AdvancedItem + { + private AdvancedItem() + { + } + + public abstract string Key { get; } + + /// 구역 머리. 그 아래 묶음들의 기본 종류다. + public sealed record SectionHeading(SoundType Type) : AdvancedItem + { + public override string Key => "s:" + Type.ToLabel(); + } + + /// 묶음 머리. 그 아래 줄들이 이 카드의 소리다. + public sealed record GroupHeading(SoundCard Card) : AdvancedItem + { + public override string Key => "g:" + Card.Id; + } + + /// 소리 한 줄. + public sealed record Row(SoundEntry Entry) : AdvancedItem + { + public override string Key => "r:" + Entry.Index.ToString(CultureInfo.InvariantCulture); + } + } + + /// 목록을 든 레코드의 같음과 해시, 표시. + internal static class ValueLists + { + public static bool SameItems(IReadOnlyList a, IReadOnlyList b) => ReferenceEquals(a, b) || a.SequenceEqual(b); + + public static void AddItems(ref HashCode hash, IReadOnlyList items) + { + hash.Add(items.Count); + foreach (T item in items) hash.Add(item); + } + + public static string Names(IEnumerable entries) => "[" + string.Join(", ", entries.Select(entry => entry.Name)) + "]"; + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundCatalog.cs b/SoundVisualizer.Core/Sounds/SoundCatalog.cs new file mode 100644 index 0000000..332f6bc --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundCatalog.cs @@ -0,0 +1,317 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; +using System.Globalization; +using System.Text; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Sounds +{ + /// + /// 분류 탭의 한 줄. AI 가 알아듣는 소리 하나와 그 소리의 기본 종류. 모바일 SoundEntry(SoundCatalog.kt)와 같다. + /// + /// YAMNet 클래스 번호. 언어마다의 소리 이름(Data/SoundNames)도 이 순서다. + /// + /// 모델이 쓰는 영어 이름. 사용자가 고른 종류를 저장하는 키이고, AI()가 받는 이름과 같아야 해서 + /// 번역된 이름이 아니라 이것을 쓴다. + /// + /// 키워드 규칙()만으로 정한 종류. + /// 이 소리가 든 묶음(Data/sound_groups.tsv, #349). 표에 없으면 null 이고, 그 소리 하나가 카드가 된다. + /// 묶음 화면에서 보이는 자리. 표의 줄 순서이고, 표에 없는 소리는 표 뒤에 번호 순서로 온다. + public sealed record SoundEntry(int Index, string Name, SoundType DefaultType, SoundGroup? Group, int Order); + + /// 분류 탭의 거르기. 종류는 사용자가 바꾼 것까지 반영한 지금 종류로 본다. + public enum SoundFilter + { + All, + Ambient, + Speech, + Danger, + + /// 바꾼 소리. 지금 종류가 기본 종류와 다른 소리다. + Changed, + } + + /// + /// 분류 탭의 소리 목록과 찾기·거르기. 모바일 SoundCatalog.kt 와 같다. 화면을 모르므로 WPF 없이 테스트한다. + /// + /// 사용자가 바꾼 종류(overrides)는 설정에 저장하는 그대로 받는다. 키는 모델의 영어 이름, 값은 의 + /// 문자열이고, 이 받는 것과 같은 맵이다. + /// + public static class SoundCatalog + { + /// + /// 모델의 소리 이름(번호 순서)과 묶음 표(Data/sound_groups.tsv)로 목록을 만든다. 기본 종류는 AI 의 매핑이다. + /// 는 AI 가 읽는 클래스 목록()을 그대로 넘긴다. + /// + public static IReadOnlyList Load(IReadOnlyList classNames) => FromNames(classNames, SoundData.GroupTable); + + /// + /// 모델의 소리 이름(번호 순서)으로 목록을 만든다. + /// + /// 모델의 소리 이름. 번호 순서다. + /// 소리 이름별 묶음(). 표에 없는 소리는 묶음이 없다. + /// 기본 종류. AI 의 매핑이 바뀐 경우를 테스트가 흉내 낼 때만 바꾼다. + public static IReadOnlyList FromNames( + IReadOnlyList names, + IReadOnlyDictionary? groups = null, + Func? defaultOf = null) + { + ArgumentNullException.ThrowIfNull(names); + defaultOf ??= YamnetThreeClassMapper.MapDisplayNameToCoarse; + int afterTable = (groups is { Count: > 0 } ? groups.Values.Max(row => row.Order) : -1) + 1; + var entries = new SoundEntry[names.Count]; + for (int index = 0; index < entries.Length; index++) + { + string name = names[index]; + SoundGroupRow? row = groups is not null && groups.TryGetValue(name, out var found) ? found : null; + entries[index] = new SoundEntry(index, name, defaultOf(name), row?.Group, row?.Order ?? afterTable + index); + } + return Array.AsReadOnly(entries); + } + + /// + /// 분류 탭의 색인을 넣어 둔 데이터로 만든다. 이름은 의 것이고, 없는 언어나 빠진 이름은 영어다. + /// 이름을 1,042번 다듬으므로 화면 스레드에서 부르지 않는다( 가 스레드 풀에서 부른다). + /// + public static SoundIndex BuildIndex(string? language, IReadOnlyList entries) + { + string code = SoundLanguages.Resolve(language); + return BuildIndex(code, entries, SoundData.Names(code), SoundData.GroupTitles(code), SoundData.GroupTitles(SoundLanguages.English)); + } + + /// + /// 색인 하나를 만든다. 시간은 거의 찾기 값을 만드는 데(소리마다 두 이름을 ) 든다. + /// + /// 색인의 언어. 캐시의 키다. + /// 소리 목록(). + /// 그 언어의 소리 이름. 클래스 번호 순서다. 모자라면 모델 이름을 쓴다. + /// 그 언어의 새로 지은 묶음 이름. + /// 영어의 새로 지은 묶음 이름. 그 이름으로도 찾게 하고, 그 언어에 빠진 이름을 채운다. + public static SoundIndex BuildIndex( + string language, + IReadOnlyList entries, + IReadOnlyList labels, + IReadOnlyDictionary titles, + IReadOnlyDictionary englishTitles) + { + ArgumentNullException.ThrowIfNull(language); + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(labels); + ArgumentNullException.ThrowIfNull(titles); + ArgumentNullException.ThrowIfNull(englishTitles); + + string English(SoundGroup group) => englishTitles.TryGetValue(group, out string? title) ? title : group.Id(); + string Local(SoundGroup group) => titles.TryGetValue(group, out string? title) ? title : English(group); + + var localTitles = new Dictionary(); + foreach (SoundGroup group in SoundGroupInfo.All) + { + if (group.HasOwnTitle()) localTitles[group] = Local(group); + } + + var searchKeys = new string[entries.Count]; + for (int i = 0; i < searchKeys.Length; i++) + { + SoundEntry entry = entries[i]; + searchKeys[i] = SearchKey(SoundIndex.LabelOf(labels, entry), entry.Name); + } + + return new SoundIndex( + language, + entries, + labels, + Array.AsReadOnly(searchKeys), + SoundGroups.Sections(entries), + localTitles.ToFrozenDictionary(), + SoundGroups.TitleKeys(Local, English)); + } + + /// 사용자가 바꾼 것까지 반영한 지금 종류. 세 라벨이 아닌 값은 AI 처럼() 없는 것으로 본다. + public static SoundType TypeOf(SoundEntry entry, IReadOnlyDictionary overrides) => + overrides.TryGetValue(entry.Name, out string? label) && SoundTypeLabels.TryParse(label, out SoundType type) + ? type + : entry.DefaultType; + + /// + /// 모델 이름의 클래스 번호. 모델 이름은 서로 겹치지 않는다. 글자 그대로(대소문자 포함) 맞춰 보고, 목록에 없으면 -1. + /// + public static int IndexOf(IReadOnlyList entries, string? name) + { + foreach (SoundEntry entry in entries) + { + if (string.Equals(entry.Name, name, StringComparison.Ordinal)) return entry.Index; + } + return -1; + } + + /// + /// 찾는 말과 거르기에 맞는 줄만 남긴다. 순서는 그대로다. + /// + /// 줄마다 로 만든 값. 입력할 때마다 521개 이름을 다시 다듬지 않게 미리 만든다. + public static IReadOnlyList Filter( + IReadOnlyList entries, + IReadOnlyList searchKeys, + IReadOnlyDictionary overrides, + string query, + SoundFilter filter) + { + string needle = Normalize(query); + var result = new List(); + for (int i = 0; i < entries.Count; i++) + { + SoundEntry entry = entries[i]; + if (Passes(entry, overrides, filter) && + (needle.Length == 0 || searchKeys[i].Contains(needle, StringComparison.Ordinal))) + { + result.Add(entry); + } + } + return result.AsReadOnly(); + } + + /// + /// "바꾼 소리" 수. 묶음이 아니라 소리를 센다(묶음 하나를 바꾸면 그 소리 수만큼 는다, #374). 저장값에는 지금 목록에 없는 + /// 이름이나 기본 종류와 같아진 값이 남아 있을 수 있어, "바꾼 소리" 거르기와 같은 기준으로 목록에서 센다. + /// + public static int CountChanged(IReadOnlyList entries, IReadOnlyDictionary overrides) + { + int count = 0; + foreach (SoundEntry entry in entries) + { + if (TypeOf(entry, overrides) != entry.DefaultType) count++; + } + return count; + } + + /// 가 거르기에 맞는지. 묶음 화면()도 소리마다 같은 기준으로 본다. + internal static bool Passes(SoundEntry entry, IReadOnlyDictionary overrides, SoundFilter filter) + { + SoundType type = TypeOf(entry, overrides); + return filter switch + { + SoundFilter.All => true, + SoundFilter.Ambient => type == SoundType.Ambient, + SoundFilter.Speech => type == SoundType.Speech, + SoundFilter.Danger => type == SoundType.Danger, + SoundFilter.Changed => type != entry.DefaultType, + _ => false, + }; + } + + /// + /// 찾기에 쓰는 값. 보이는 이름과 모델 이름 어느 쪽으로 찾아도 맞게 둘을 잇는다. 줄바꿈으로 이어서, 찾는 말(줄바꿈이 없다)이 + /// 둘이 맞닿은 곳에서 맞지 않는다. 영어 이름을 함께 넣는 것은 어느 화면 언어에서도 개발자 모드(영어)에서 본 이름으로 찾게 하려는 것이다. + /// + public static string SearchKey(string label, string name) => Normalize(label) + "\n" + Normalize(name); + + /// + /// 대소문자, 띄어쓰기, 악센트 같은 결합 부호를 가리지 않는다. "fire truck" 은 "Firetruck" 으로도, "sirène" 은 "sirene" 으로도 찾힌다. + /// + /// 문장 부호(쉼표, 가운뎃점 '·'·'・', 붙임표, 괄호)도 띄어쓰기처럼 뺀다. 묶음 이름은 낱말을 이런 부호로 잇는데("부엌·요리", + /// "TV, radio and …"), 사람은 띄어 쳐서 찾는다. 부호를 남겨 두면 "부엌 요리" 처럼 이어진 두 낱말로는 아무것도 찾지 못했다(#349). + /// + /// 한글은 낱자로 풀어 첫소리와 받침을 같은 글자로 본다. 글자를 치는 중에는 입력 칸에 "ㅅ", "상"(다음 글자의 첫소리가 받침으로 + /// 붙은 모양), "홪" 같은 모양이 잠깐 들어오는데, 음절째 비교하면 한 글자 칠 때마다 결과가 사라졌다 나타난다. 겹받침·겹모음(ㄺ, ㅘ)도 + /// 낱자 둘로 푼다. + /// + /// 모바일과 같은 차례다(NFD → 결합 부호(Mn) 빼기 → 띄어쓰기·문장 부호(P) 빼기 → 소문자 → 한글 낱자). 띄어쓰기는 유니코드의 것 + /// 모두(모바일 앱의 ICU 정규식과 같다)이고, 소문자는 문화권을 타지 않는다. + /// + internal static string Normalize(string text) + { + string decomposed = Decompose(text); + var builder = new StringBuilder(decomposed.Length); + Span buffer = stackalloc char[2]; + foreach (Rune rune in decomposed.EnumerateRunes()) + { + UnicodeCategory category = Rune.GetUnicodeCategory(rune); + if (category == UnicodeCategory.NonSpacingMark || IsPunctuation(category) || Rune.IsWhiteSpace(rune)) continue; + + Rune lower = Rune.ToLowerInvariant(rune); + if (lower.IsBmp && HangulLetters.TryGetValue((char)lower.Value, out string? letters)) + { + builder.Append(letters); + } + else + { + builder.Append(buffer[..lower.EncodeToUtf16(buffer)]); + } + } + return builder.ToString(); + } + + /// + /// NFD 로 푼다. 짝 없는 서로게이트가 든 글(붙여 넣은 글 등)은 .NET 의 정규화가 받지 않으므로 대체 문자(U+FFFD)로 바꿔 다시 한다. + /// 모바일(Java)은 그 글자를 그대로 두지만, 어느 쪽이든 실제 이름과는 맞지 않는다. + /// + private static string Decompose(string text) + { + try + { + return text.Normalize(NormalizationForm.FormD); + } + catch (ArgumentException) + { + var sanitized = new StringBuilder(text.Length); + foreach (Rune rune in text.EnumerateRunes()) sanitized.Append(rune.ToString()); + return sanitized.ToString().Normalize(NormalizationForm.FormD); + } + } + + private static bool IsPunctuation(UnicodeCategory category) => category is + UnicodeCategory.ConnectorPunctuation or + UnicodeCategory.DashPunctuation or + UnicodeCategory.OpenPunctuation or + UnicodeCategory.ClosePunctuation or + UnicodeCategory.InitialQuotePunctuation or + UnicodeCategory.FinalQuotePunctuation or + UnicodeCategory.OtherPunctuation; + + /// 한글 낱자(첫소리·가운뎃소리·받침, 겹자모)를 입력 칸에 보이는 호환용 낱자로. NFD 가 음절을 이 낱자들로 푼다. + private static readonly FrozenDictionary HangulLetters = BuildHangulLetters(); + + private static FrozenDictionary BuildHangulLetters() + { + var map = new Dictionary(); + const string initials = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; + for (int i = 0; i < initials.Length; i++) map[(char)('ᄀ' + i)] = initials[i].ToString(); + + string[] vowels = + { + "ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅗㅏ", "ㅗㅐ", "ㅗㅣ", "ㅛ", "ㅜ", "ㅜㅓ", "ㅜㅔ", "ㅜㅣ", + "ㅠ", "ㅡ", "ㅡㅣ", "ㅣ", + }; + for (int i = 0; i < vowels.Length; i++) map[(char)('ᅡ' + i)] = vowels[i]; + + string[] finals = + { + "ㄱ", "ㄲ", "ㄱㅅ", "ㄴ", "ㄴㅈ", "ㄴㅎ", "ㄷ", "ㄹ", "ㄹㄱ", "ㄹㅁ", "ㄹㅂ", "ㄹㅅ", "ㄹㅌ", "ㄹㅍ", "ㄹㅎ", + "ㅁ", "ㅂ", "ㅂㅅ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ", + }; + for (int i = 0; i < finals.Length; i++) map[(char)('ᆨ' + i)] = finals[i]; + + // 치는 중에 입력 칸에 보이는 호환용 겹자모 + map['ㄳ'] = "ㄱㅅ"; + map['ㄵ'] = "ㄴㅈ"; + map['ㄶ'] = "ㄴㅎ"; + map['ㄺ'] = "ㄹㄱ"; + map['ㄻ'] = "ㄹㅁ"; + map['ㄼ'] = "ㄹㅂ"; + map['ㄽ'] = "ㄹㅅ"; + map['ㄾ'] = "ㄹㅌ"; + map['ㄿ'] = "ㄹㅍ"; + map['ㅀ'] = "ㄹㅎ"; + map['ㅄ'] = "ㅂㅅ"; + map['ㅘ'] = "ㅗㅏ"; + map['ㅙ'] = "ㅗㅐ"; + map['ㅚ'] = "ㅗㅣ"; + map['ㅝ'] = "ㅜㅓ"; + map['ㅞ'] = "ㅜㅔ"; + map['ㅟ'] = "ㅜㅣ"; + map['ㅢ'] = "ㅡㅣ"; + return map.ToFrozenDictionary(); + } + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundData.cs b/SoundVisualizer.Core/Sounds/SoundData.cs new file mode 100644 index 0000000..13eb694 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundData.cs @@ -0,0 +1,136 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Concurrent; +using System.Collections.Frozen; +using System.Text.Json; + +namespace SoundVisualizer.Sounds +{ + /// + /// 분류 탭의 데이터. Core 에 리소스로 넣어 둔 파일(Sounds/Data)을 읽는다. 모바일 판의 res/values*/sound_names.xml, + /// strings.xml 의 sound_group_* 문구, assets/sound_groups.tsv 를 옮긴 것이다. + /// + /// - SoundNames/{언어}.json: 소리 이름 521개의 배열(YAMNet 번호 순서). 안드로이드 이스케이프(\' 와 &amp;)는 풀어 두었다. + /// AudioSet 온톨로지의 각색물이라 CC BY-SA 4.0 이다(Data/NOTICE.txt). + /// - GroupTitles/{언어}.json: 새로 지은 묶음 이름 15개(묶음 id → 이름). + /// - sound_groups.tsv: 소리마다의 묶음. CC BY-SA 4.0 머리글을 그대로 둔다. + /// + /// 언어마다 한 번 읽어 두고 같은(바꿀 수 없는) 목록을 돌려준다. 어느 스레드에서 불러도 된다. + /// + public static class SoundData + { + private const string Prefix = "SoundVisualizer.Sounds.Data."; + private const string NamesFolder = "SoundNames."; + private const string TitlesFolder = "GroupTitles."; + private const string Extension = ".json"; + + private static readonly ConcurrentDictionary> NamesByLanguage = new(StringComparer.Ordinal); + + private static readonly ConcurrentDictionary> TitlesByLanguage = + new(StringComparer.Ordinal); + + private static readonly Lazy> Table = new(() => + { + using Stream stream = OpenGroupTable(); + return SoundGroups.ParseTable(stream); + }); + + /// + /// 의 소리 이름(YAMNet 번호 순서). 데이터가 없는 언어면 영어이고(), + /// 그 언어에 빠진 이름(빈 값)은 영어 이름으로 채운다. + /// + public static IReadOnlyList Names(string? language) => + NamesByLanguage.GetOrAdd(SoundLanguages.Resolve(language), LoadNames); + + /// + /// 의 새로 지은 묶음 이름( 인 묶음 15개). 데이터가 없는 언어나 + /// 그 언어에 빠진 이름은 영어다. 나머지 묶음의 이름은 소리 이름()이다. + /// + public static IReadOnlyDictionary GroupTitles(string? language) => + TitlesByLanguage.GetOrAdd(SoundLanguages.Resolve(language), LoadGroupTitles); + + /// 묶음 표(). 키는 모델의 소리 이름이고 표의 줄 순서다. + public static IReadOnlyDictionary GroupTable => Table.Value; + + /// 묶음 표 파일. 머리글(라이선스)까지 그대로다. + internal static Stream OpenGroupTable() => Open(Prefix + "sound_groups.tsv") + ?? throw new InvalidOperationException("sound_groups.tsv 리소스가 없습니다."); + + /// 파일 그대로의 소리 이름(채우지 않은 것). 그 언어의 파일이 없으면 null. + internal static IReadOnlyList? ReadNamesFile(string code) + { + using Stream? stream = Open(Prefix + NamesFolder + code + Extension); + if (stream is null) return null; + using JsonDocument document = JsonDocument.Parse(stream); + var names = new List(); + foreach (JsonElement element in document.RootElement.EnumerateArray()) names.Add(element.GetString() ?? ""); + return names.AsReadOnly(); + } + + /// 파일 그대로의 새로 지은 묶음 이름(묶음 id → 이름, 파일의 순서). 그 언어의 파일이 없으면 null. + internal static IReadOnlyList>? ReadGroupTitlesFile(string code) + { + using Stream? stream = Open(Prefix + TitlesFolder + code + Extension); + if (stream is null) return null; + using JsonDocument document = JsonDocument.Parse(stream); + var titles = new List>(); + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + { + titles.Add(new KeyValuePair(property.Name, property.Value.GetString() ?? "")); + } + return titles.AsReadOnly(); + } + + /// 리소스로 들어 있는 소리 이름 파일과 묶음 이름 파일의 언어. 지원 언어와 맞는지 테스트가 본다. + internal static (IReadOnlyList Names, IReadOnlyList Titles) EmbeddedLanguages() + { + var names = new List(); + var titles = new List(); + foreach (string resource in typeof(SoundData).Assembly.GetManifestResourceNames()) + { + if (!resource.StartsWith(Prefix, StringComparison.Ordinal) || !resource.EndsWith(Extension, StringComparison.Ordinal)) continue; + string rest = resource[Prefix.Length..^Extension.Length]; + if (rest.StartsWith(NamesFolder, StringComparison.Ordinal)) names.Add(rest[NamesFolder.Length..]); + else if (rest.StartsWith(TitlesFolder, StringComparison.Ordinal)) titles.Add(rest[TitlesFolder.Length..]); + } + return (names, titles); + } + + private static Stream? Open(string resource) => typeof(SoundData).Assembly.GetManifestResourceStream(resource); + + private static IReadOnlyList LoadNames(string code) + { + IReadOnlyList own = ReadNamesFile(code) ?? Array.Empty(); + if (code == SoundLanguages.English) return own; + + IReadOnlyList english = Names(SoundLanguages.English); + var names = new string[Math.Max(own.Count, english.Count)]; + for (int i = 0; i < names.Length; i++) + { + string? name = i < own.Count ? own[i] : null; + names[i] = string.IsNullOrWhiteSpace(name) ? (i < english.Count ? english[i] : "") : name; + } + return Array.AsReadOnly(names); + } + + private static IReadOnlyDictionary LoadGroupTitles(string code) + { + var own = new Dictionary(StringComparer.Ordinal); + foreach (var (id, title) in ReadGroupTitlesFile(code) ?? Array.Empty>()) + { + if (!string.IsNullOrWhiteSpace(title)) own[id] = title; + } + IReadOnlyDictionary? english = code == SoundLanguages.English ? null : GroupTitles(SoundLanguages.English); + + var titles = new Dictionary(); + foreach (SoundGroup group in SoundGroupInfo.All) + { + if (!group.HasOwnTitle()) continue; + if (own.TryGetValue(group.Id(), out string? title)) titles[group] = title; + else if (english is not null && english.TryGetValue(group, out string? fallback)) titles[group] = fallback; + } + return titles.ToFrozenDictionary(); + } + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundGroup.cs b/SoundVisualizer.Core/Sounds/SoundGroup.cs new file mode 100644 index 0000000..016421e --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundGroup.cs @@ -0,0 +1,137 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; + +namespace SoundVisualizer.Sounds +{ + /// + /// 분류 탭의 소리 묶음(#349). 항목 순서가 화면에 보이는 순서다. 모바일 SoundGroup(SoundGroups.kt)과 같다. + /// + /// 어느 소리가 어느 묶음인지는 여기가 아니라 묶음 표(Data/sound_groups.tsv)에 둔다. 그 표는 AudioSet 온톨로지(CC BY-SA 4.0)의 + /// 갈래를 읽고 만든 것이라 같은 라이선스로 따로 둔다(Data/NOTICE.txt). 표의 group_id 는 다. + /// + /// 묶음이 어느 구역에 보이는지는 여기서 정하지 않는다. 소리마다의 기본 종류(AI 의 매핑)로 그때그때 나눈다 + /// (). 아래 주석의 구역은 지금 매핑에서 그렇다는 뜻이다. + /// + public enum SoundGroup + { + // 위협음 + Alarms, + Sirens, + Horns, + VehicleWarnings, + Breaking, + Slam, + Gunshots, + Explosions, + Fireworks, + Thunder, + Chainsaw, + + // 대화음. 사람의 발성(웃음·고함·울음·환호)이 대화음이 된 뒤(#369) 아기 울음은 외침·비명·울음 바로 뒤에 둔다(#370). + // 말소리 묶음은 새로 지은 이름을 쓴다(#374). 소리 이름 'Speech' 는 여러 언어에서 구역 머리(대화음)와 같은 낱말이다. + Talking, + Distress, + Baby, + Crowd, + Laughter, + Singing, + + // 환경음. 주의를 끄는 소리(초인종·전화·알람·불·개)부터 둔다. + Doorbell, + Phone, + Signals, + Fire, + Dog, + Voices, + Footsteps, + Door, + Water, + Kitchen, + Appliances, + Tools, + Vehicle, + Bell, + Animal, + Nature, + Body, + Objects, + Music, + Background, + } + + /// + /// 묶음마다 정해 둔 것. 모바일 enum 이 들고 있던 id 와 이름(titleRes·titleSound)이다. + /// + /// 묶음 이름은 둘 중 하나다. 22개는 이름이 곧 묶음 이름인 소리(사이렌, 초인종 등)의 소리 이름 번역을 그대로 써서 따로 번역하지 + /// 않는다(). 15개는 새로 지은 이름이고, 언어마다 Data/GroupTitles 에 있다(). + /// + public static class SoundGroupInfo + { + private readonly record struct Info(SoundGroup Group, string Id, string? TitleSound); + + // id 는 모바일 enum 이름의 소문자(VEHICLE_WARNINGS → vehicle_warnings)라 표의 group_id 와 같다. + private static readonly Info[] Infos = + { + new(SoundGroup.Alarms, "alarms", "Alarm"), + new(SoundGroup.Sirens, "sirens", "Siren"), + new(SoundGroup.Horns, "horns", null), + new(SoundGroup.VehicleWarnings, "vehicle_warnings", null), + new(SoundGroup.Breaking, "breaking", null), + new(SoundGroup.Slam, "slam", "Slam"), + new(SoundGroup.Gunshots, "gunshots", "Gunshot, gunfire"), + new(SoundGroup.Explosions, "explosions", "Explosion"), + new(SoundGroup.Fireworks, "fireworks", "Fireworks"), + new(SoundGroup.Thunder, "thunder", "Thunder"), + new(SoundGroup.Chainsaw, "chainsaw", "Chainsaw"), + new(SoundGroup.Talking, "talking", null), + new(SoundGroup.Distress, "distress", null), + new(SoundGroup.Baby, "baby", "Baby cry, infant cry"), + new(SoundGroup.Crowd, "crowd", "Crowd"), + new(SoundGroup.Laughter, "laughter", "Laughter"), + new(SoundGroup.Singing, "singing", "Singing"), + new(SoundGroup.Doorbell, "doorbell", "Doorbell"), + new(SoundGroup.Phone, "phone", "Telephone"), + new(SoundGroup.Signals, "signals", null), + new(SoundGroup.Fire, "fire", "Fire"), + new(SoundGroup.Dog, "dog", "Dog"), + new(SoundGroup.Voices, "voices", null), + new(SoundGroup.Footsteps, "footsteps", "Walk, footsteps"), + new(SoundGroup.Door, "door", "Door"), + new(SoundGroup.Water, "water", null), + new(SoundGroup.Kitchen, "kitchen", null), + new(SoundGroup.Appliances, "appliances", null), + new(SoundGroup.Tools, "tools", null), + new(SoundGroup.Vehicle, "vehicle", "Vehicle"), + new(SoundGroup.Bell, "bell", "Bell"), + new(SoundGroup.Animal, "animal", "Animal"), + new(SoundGroup.Nature, "nature", null), + new(SoundGroup.Body, "body", null), + new(SoundGroup.Objects, "objects", null), + new(SoundGroup.Music, "music", "Music"), + new(SoundGroup.Background, "background", null), + }; + + private static readonly FrozenDictionary ByGroup = Infos.ToFrozenDictionary(info => info.Group); + + private static readonly FrozenDictionary ById = + Infos.ToFrozenDictionary(info => info.Id, info => info.Group, StringComparer.Ordinal); + + /// 모든 묶음. 화면 순서(항목 순서)다. + public static IReadOnlyList All { get; } = Array.AsReadOnly(Enum.GetValues()); + + /// 표(Data/sound_groups.tsv)의 group_id. + public static string Id(this SoundGroup group) => ByGroup[group].Id; + + /// 이름(소리 이름)이 그대로 묶음 이름인 소리의 모델 이름. 새로 지은 이름을 쓰는 묶음이면 null. + public static string? TitleSound(this SoundGroup group) => ByGroup[group].TitleSound; + + /// 새로 지은 이름(Data/GroupTitles)을 쓰는지. 모바일의 titleRes != 0 이다. + public static bool HasOwnTitle(this SoundGroup group) => ByGroup[group].TitleSound is null; + + /// 표의 group_id 로 찾는다. 대소문자까지 같아야 하고, 모르는 이름이면 null. + public static SoundGroup? FromId(string? id) => + id is not null && ById.TryGetValue(id, out SoundGroup group) ? group : null; + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundGroups.cs b/SoundVisualizer.Core/Sounds/SoundGroups.cs new file mode 100644 index 0000000..129edd8 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundGroups.cs @@ -0,0 +1,269 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Frozen; +using System.Collections.ObjectModel; +using System.Text; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Sounds +{ + /// + /// 분류 탭의 묶음 화면에 필요한 계산. 모바일 SoundGroups.kt 와 같다. 화면을 모르므로 WPF 없이 테스트한다. + /// + /// 묶음 상태는 저장하지 않는다. 저장하는 것은 소리마다의 종류(설정의 sound_types, )뿐이고, + /// 묶음이 모두 같은지·일부 다른지는 그때그때 정한다. + /// + public static class SoundGroups + { + /// 미리보기에 이름을 보이는 소리 수. 나머지는 "+N" 으로 센다. + public const int PreviewSize = 3; + + /// 구역 순서. 작고 중요한 위협음을 맨 위에, 긴 환경음을 맨 아래에 둔다. + public static IReadOnlyList SectionOrder { get; } = + Array.AsReadOnly(new[] { SoundType.Danger, SoundType.Speech, SoundType.Ambient }); + + /// + /// 묶음 표(Data/sound_groups.tsv)를 읽는다. 키는 소리 이름(display_name)이고, 표의 줄 순서대로 늘어놓는다. + /// + /// '#' 로 시작하는 줄(라이선스 머리글)과 빈 줄은 건너뛴다. 줄 끝은 LF·CRLF 어느 쪽이든 된다. 윈도에서는 git 이 CRLF 로 바꿔 + /// 꺼내고, 그대로 DLL 에 들어간다. 모르는 묶음, 탭이 없는 줄, 이미 나온 소리는 버린다. 그 소리는 표에 없는 것이 되어 제 이름의 + /// 카드로 보인다. 탭이 깨지지 않는 쪽을 고른 것이고, 표가 맞는지는 테스트(SoundGroupTableTests)가 막는다. + /// 받은 스트림은 닫지 않는다. + /// + public static IReadOnlyDictionary ParseTable(Stream input) + { + ArgumentNullException.ThrowIfNull(input); + using var reader = new StreamReader(input, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + return ParseTable(reader); + } + + /// + public static IReadOnlyDictionary ParseTable(TextReader reader) + { + ArgumentNullException.ThrowIfNull(reader); + var rows = new OrderedDictionary(StringComparer.Ordinal); + string? line; + while ((line = reader.ReadLine()) is not null) + { + if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) continue; + int tab = line.IndexOf('\t'); + if (tab < 0) continue; + if (SoundGroupInfo.FromId(line[..tab]) is not { } group) continue; + string name = line[(tab + 1)..]; + if (name.Length == 0 || rows.ContainsKey(name)) continue; + rows.Add(name, new SoundGroupRow(group, rows.Count)); + } + return new ReadOnlyDictionary(rows); + } + + /// + /// 카드를 구역별로 나눈다. 카드는 (묶음, 소리마다의 기본 종류)다. + /// + /// 기본 종류는 그대로다. 매핑이 바뀌면 표를 고치지 않아도 그 소리가 새 구역에 같은 묶음 + /// 이름으로 따로 보인다. 구역은 순서이고 카드가 없는 구역은 뺀다. 구역 안의 카드는 + /// 순서이고, 표에 없는 소리는 그 뒤에 한 소리씩 카드가 된다. 카드 안의 소리는 표의 순서다. + /// + public static IReadOnlyList Sections(IReadOnlyList entries) + { + ArgumentNullException.ThrowIfNull(entries); + // 이름이 겹치면 뒤의 것(모바일 associateBy). 모델 이름은 겹치지 않는다. + var byName = new Dictionary(StringComparer.Ordinal); + foreach (SoundEntry entry in entries) byName[entry.Name] = entry; + + var cards = entries + .GroupBy(entry => new CardKey(entry.Group, entry.DefaultType, entry.Group is null ? entry.Index : -1)) + .Select(members => + { + SoundEntry[] sorted = members.OrderBy(entry => entry.Order).ThenBy(entry => entry.Index).ToArray(); + SoundEntry? title = members.Key.Group switch + { + null => sorted[0], + { } group when group.TitleSound() is { } sound => byName.GetValueOrDefault(sound) ?? sorted[0], + _ => null, + }; + return new SoundCard(members.Key.Group, members.Key.Type, Array.AsReadOnly(sorted), title); + }) + .ToList(); + + return Array.AsReadOnly(cards + .GroupBy(card => card.DefaultType) + .OrderBy(inSection => SectionRank(inSection.Key)) + .Select(inSection => new SoundSection( + inSection.Key, + Array.AsReadOnly(inSection + .OrderBy(card => card.Group is { } group ? (int)group : int.MaxValue) + .ThenBy(card => card.Members[0].Order) + .ToArray()))) + .ToArray()); + } + + /// 표에 없는 소리는 에 번호를 넣어 저마다 카드가 되게 한다. + private readonly record struct CardKey(SoundGroup? Group, SoundType Type, int Single); + + /// 세 종류 밖의 값은 생기지 않지만, 생기더라도 소리를 잃지 않게 맨 뒤 구역으로 둔다. + private static int SectionRank(SoundType type) + { + for (int i = 0; i < SectionOrder.Count; i++) + { + if (SectionOrder[i] == type) return i; + } + return SectionOrder.Count; + } + + /// 카드의 지금 종류. 사용자가 바꾼 것()까지 반영한다. + public static GroupState State(SoundCard card, IReadOnlyDictionary overrides) + { + SoundType type = SoundCatalog.TypeOf(card.Members[0], overrides); + foreach (SoundEntry entry in card.Members) + { + if (SoundCatalog.TypeOf(entry, overrides) != type) return GroupState.Mixed.Instance; + } + return new GroupState.Uniform(type); + } + + /// + /// 섞인 카드가 하나라도 있는지. 고급 모드를 끌 때 덮어쓰기를 먼저 알릴지 정한다. + /// 찾는 말·거르기와 상관없이 모든 카드를 보고, 지난번에 바꿔 둔 것도 저장값으로 함께 본다. + /// + public static bool AnyMixed(IReadOnlyList sections, IReadOnlyDictionary overrides) => + sections.Any(section => section.Cards.Any(card => State(card, overrides) is GroupState.Mixed)); + + /// + /// 카드 이름 밑에 보일 소리. 카드 이름인 소리()는 이미 보이므로 뺀다. 그래서 그 소리 하나뿐인 + /// 카드(전기톱 등)는 빈 미리보기다. 에 든 소리(찾는 말에 맞은 소리)를 앞에 두고, 나머지는 표의 순서다. + /// + public static GroupPreview Preview(SoundCard card, IEnumerable? first = null) + { + var firstIndexes = first is null ? new HashSet() : first.Select(entry => entry.Index).ToHashSet(); + int? titleIndex = card.TitleEntry?.Index; + var rest = card.Members.Where(entry => entry.Index != titleIndex).ToList(); + var ordered = rest.Where(entry => firstIndexes.Contains(entry.Index)) + .Concat(rest.Where(entry => !firstIndexes.Contains(entry.Index))) + .ToList(); + SoundEntry[] names = ordered.Take(PreviewSize).ToArray(); + return new GroupPreview(Array.AsReadOnly(names), ordered.Count - names.Length); + } + + /// + /// 찾는 말과 거르기에 맞는 카드와 소리. 기본 모드와 고급 모드가 함께 쓴다. + /// + /// - 찾는 말은 카드 이름과 소리 이름에서, 보이는 이름과 모델 이름 어느 쪽으로든 찾는다(). + /// 그래서 한글을 치는 중의 모양("상", "홪")에서도 결과가 끊기지 않는다. + /// - 카드 이름이 맞으면 묶음의 소리가 모두 맞은 것으로 본다. 소리 이름이 맞으면 그 소리만 맞은 것이다. + /// - 거르기는 소리마다 지금 종류(또는 바꿨는지)로 본다. 구역은 기본 종류라, 바꾼 묶음도 제 구역에 그대로 있다. + /// 에 든 소리(거르기를 켠 채로 방금 바꾼 소리, )는 거르기에 맞지 않아도 맞은 + /// 것으로 본다. 찾는 말은 그대로 본다. + /// - 둘 다 맞는 소리가 하나라도 있으면 카드가 남는다. 남은 카드가 없는 구역은 뺀다. + /// + /// 소리마다 로 만든 값. entry.Index 자리에 둔다. + /// + /// 새로 지은 묶음 이름의 찾기 값(). 소리 이름이 곧 묶음 이름이면 그 소리의 값을 쓰므로 넣지 않아도 된다. + /// + /// 거르기와 상관없이 남길 소리의 모델 이름(). + public static IReadOnlyList Filter( + IReadOnlyList sections, + IReadOnlyList searchKeys, + IReadOnlyDictionary titleKeys, + IReadOnlyDictionary overrides, + string query, + SoundFilter filter, + IReadOnlySet? kept = null) + { + string needle = SoundCatalog.Normalize(query); + var result = new List(); + foreach (SoundSection section in sections) + { + var cards = new List(); + foreach (SoundCard card in section.Cards) + { + bool titleMatched = needle.Length == 0 || + TitleKey(card, searchKeys, titleKeys).Contains(needle, StringComparison.Ordinal); + var shown = new List(); + foreach (SoundEntry entry in card.Members) + { + bool passes = SoundCatalog.Passes(entry, overrides, filter) || (kept?.Contains(entry.Name) ?? false); + if (passes && (titleMatched || KeyAt(searchKeys, entry.Index).Contains(needle, StringComparison.Ordinal))) + { + shown.Add(entry); + } + } + if (shown.Count > 0) cards.Add(new ShownCard(card, shown.AsReadOnly())); + } + if (cards.Count > 0) result.Add(new ShownSection(section.Type, cards.AsReadOnly())); + } + return result.AsReadOnly(); + } + + private static string KeyAt(IReadOnlyList searchKeys, int index) => + (uint)index < (uint)searchKeys.Count ? searchKeys[index] : ""; + + private static string TitleKey(SoundCard card, IReadOnlyList searchKeys, IReadOnlyDictionary titleKeys) + { + if (card.TitleEntry is { } entry && (uint)entry.Index < (uint)searchKeys.Count) return searchKeys[entry.Index]; + if (card.Group is { } group && titleKeys.TryGetValue(group, out string? key)) return key; + return ""; + } + + /// + /// 의 titleKeys. 새로 지은 묶음 이름을 소리 이름처럼 화면 언어의 이름과 영어 이름 어느 쪽으로도 찾게 한다. + /// 이름이 소리 이름인 묶음은 그 소리의 찾기 값을 쓰므로 넣지 않는다. + /// + /// 화면 언어의 묶음 이름. + /// 영어의 묶음 이름. + public static IReadOnlyDictionary TitleKeys(Func local, Func english) + { + var keys = new Dictionary(); + foreach (SoundGroup group in SoundGroupInfo.All) + { + if (group.HasOwnTitle()) keys[group] = SoundCatalog.SearchKey(local(group), english(group)); + } + return keys.ToFrozenDictionary(); + } + + /// + /// 고급 모드 목록. 의 결과를 구역 머리, 묶음 머리, 소리 줄 차례로 편다. 그래서 맞는 줄이 있는 묶음과 + /// 구역만 머리가 보이고, 카드 이름이 맞은 묶음은 소리가 모두 보인다. + /// + public static IReadOnlyList AdvancedItems(IReadOnlyList shown) + { + var items = new List(); + foreach (ShownSection section in shown) + { + items.Add(new AdvancedItem.SectionHeading(section.Type)); + foreach (ShownCard card in section.Cards) + { + items.Add(new AdvancedItem.GroupHeading(card.Card)); + foreach (SoundEntry entry in card.Shown) items.Add(new AdvancedItem.Row(entry)); + } + } + return items.AsReadOnly(); + } + + /// + /// 기본 모드에서 를 로 고르면 종류가 바뀌는 소리 수. 묻는 창 + /// ()에 쓴다. 하나씩 바꿔 둔 소리만이 아니라 기본 종류 그대로인 소리까지, 고르면 종류가 바뀌는 + /// 소리를 모두 센다. + /// + public static int OverwriteCount(SoundCard card, IReadOnlyDictionary overrides, SoundType picked) => + card.Members.Count(entry => SoundCatalog.TypeOf(entry, overrides) != picked); + + /// + /// 기본 모드에서 를 로 고를 때 먼저 물을지(#374). 하나씩 바꿔 둔 소리의 종류가 + /// 실제로 바뀔 때만 묻는다. + /// + /// 하나씩 바꿔 둔 소리는 섞인 카드()에서 기본 종류가 아닌 소리다. "바꾼 소리" 거르기와 수도 같은 + /// 기준이다. 그 소리와 같은 종류를 고르면 그 소리는 그대로라 묻지 않고, 기본 종류를 고르면 그 소리가 되돌려지므로 늘 묻는다. + /// 모두 같은 카드는 묶음째 바꾼 것이라 묻지 않는다. + /// + /// 묶음째 X 로 바꾼 뒤 한 소리만 기본 종류로 되돌린 카드에서 X 를 고르면 묻지 않는다. 저장값에는 기본 종류가 아닌 것만 남아 + /// '되돌린 소리' 와 '건드리지 않은 소리' 를 가릴 수 없다. + /// + public static bool OverwritesOneByOne(SoundCard card, IReadOnlyDictionary overrides, SoundType picked) => + State(card, overrides) is GroupState.Mixed && card.Members.Any(entry => + { + SoundType type = SoundCatalog.TypeOf(entry, overrides); + return type != entry.DefaultType && type != picked; + }); + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundIndex.cs b/SoundVisualizer.Core/Sounds/SoundIndex.cs new file mode 100644 index 0000000..5cd281c --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundIndex.cs @@ -0,0 +1,139 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Sounds +{ + /// + /// 분류 탭이 그리는 데 쓰는 것을 화면 언어 하나로 한 번 만들어 둔 것(#368). 모바일 SoundIndex 와 같다. + /// 가 만들고, 보통은 로 얻는다. + /// + /// 만들 때 이름 521개를 읽고 정규화를 1,042번 하므로, 탭에 들어올 때마다 화면 스레드에서 다시 만들지 않는다. 만든 뒤에는 + /// 바뀌지 않아 어느 스레드에서 읽어도 된다. 글자를 칠 때마다 하는 일은 (소리 521개를 거르기)뿐이다. + /// + public sealed class SoundIndex + { + private readonly IReadOnlyList _labels; + private readonly IReadOnlyDictionary _titles; + + internal SoundIndex( + string language, + IReadOnlyList entries, + IReadOnlyList labels, + IReadOnlyList searchKeys, + IReadOnlyList sections, + IReadOnlyDictionary titles, + IReadOnlyDictionary titleKeys) + { + Language = language; + Entries = entries; + _labels = labels; + SearchKeys = searchKeys; + Sections = sections; + _titles = titles; + TitleKeys = titleKeys; + } + + /// 만든 언어( 의 코드). 캐시의 키다. + public string Language { get; } + + /// 소리 목록(). 번호 순서다. + public IReadOnlyList Entries { get; } + + /// 소리마다의 . entry.Index 자리에 둔다. + public IReadOnlyList SearchKeys { get; } + + /// 묶음과 구역(). 언어와 상관없지만 목록이 같으면 늘 같으므로 함께 만들어 둔다. + public IReadOnlyList Sections { get; } + + /// 새로 지은 묶음 이름의 찾기 값(). 이 언어의 이름과 영어 이름을 잇는다. + public IReadOnlyDictionary TitleKeys { get; } + + /// 이 언어의 소리 이름. 이름 목록에 없는 번호(모델이 바뀐 직후 등)면 모델 이름이다. + public string LabelOf(SoundEntry entry) => LabelOf(_labels, entry); + + /// + /// 카드(묶음)의 이름. 이름이 곧 묶음 이름인 소리가 있으면 그 소리의 이름이고(사이렌, 초인종 등), 아니면 이 언어의 새로 지은 + /// 묶음 이름이다. 고급 모드의 묶음 머리도 같은 이름을 쓴다. + /// + public string CardTitle(SoundCard card) + { + if (card.TitleEntry is { } entry) return LabelOf(entry); + if (card.Group is { } group) return _titles.TryGetValue(group, out string? title) ? title : group.Id(); + return card.Members.Count > 0 ? LabelOf(card.Members[0]) : ""; + } + + /// 찾는 말과 거르기에 맞는 카드와 소리(). 글자를 칠 때마다 부른다. + public IReadOnlyList Filter( + IReadOnlyDictionary overrides, + string query, + SoundFilter filter, + IReadOnlySet? kept = null) => + SoundGroups.Filter(Sections, SearchKeys, TitleKeys, overrides, query, filter, kept); + + /// "바꾼 소리" 수(). 묶음이 아니라 소리를 센다. + public int CountChanged(IReadOnlyDictionary overrides) => SoundCatalog.CountChanged(Entries, overrides); + + internal static string LabelOf(IReadOnlyList labels, SoundEntry entry) => + (uint)entry.Index < (uint)labels.Count ? labels[entry.Index] : entry.Name; + } + + /// + /// 분류 탭의 색인을 언어 하나의 것만 들고 있는다(모바일 SoundCatalog.index·cachedIndexOrNull). 클래스 목록 하나에 하나를 둔다. + /// + /// 두는 것은 마지막으로 만든 언어의 것뿐이라, 언어가 바뀌면 새로 만들고 앞의 것은 버린다(그 언어로 돌아가면 다시 만든다). + /// 여럿이 함께 불러도 한 번만 만든다. 만드는 중에 부른 쪽이 기다리기를 그만둬도(탭을 떠남) 다 만든 색인은 캐시에 남아, + /// 곧 다시 들어온 탭은 앞의 것이 끝나기를 기다렸다가 그것을 쓴다. + /// + public sealed class SoundIndexCache + { + private readonly Lazy> _entries; + private readonly object _gate = new(); + private volatile SoundIndex? _cached; + + /// AI 가 읽는 클래스 목록(). 소리 목록은 처음 만들 때 한 번 짓는다. + public SoundIndexCache(IReadOnlyList classNames) + { + ArgumentNullException.ThrowIfNull(classNames); + _entries = new Lazy>(() => SoundCatalog.Load(classNames)); + } + + /// + /// 로 만들어 둔 색인. 아직 없거나 다른 언어로 만든 것이면 null. 아무것도 만들지 않으므로 화면 + /// 스레드에서 불러도 된다. 탭에 다시 들어올 때 이것이 있으면 바로 그린다. + /// + public SoundIndex? CachedOrNull(string? language) => Cached(SoundLanguages.Resolve(language)); + + /// + /// 의 색인. 없으면 만들어 캐시에 넣는다. 만드는 동안 부른 스레드를 붙잡으므로 화면 스레드에서는 + /// 를 쓴다. + /// + public SoundIndex GetOrBuild(string? language) => + GetOrBuild(language, code => SoundCatalog.BuildIndex(code, _entries.Value)); + + /// + /// 를 스레드 풀에서 한다. 이미 만들어 두었으면 끝난 작업을 바로 돌려준다. + /// 기다리기를 그만두려면 WaitAsync(취소 토큰)로 기다린다. 그래도 만드는 일은 끝까지 해서 캐시에 남긴다. + /// + public Task GetOrBuildAsync(string? language) => + CachedOrNull(language) is { } index ? Task.FromResult(index) : Task.Run(() => GetOrBuild(language)); + + /// + /// 만드는 일(, 맞춘 언어 코드를 받는다)을 밖에서 받는다. 그래서 테스트가 만드는 동안을 붙잡아 본다. + /// 다 만든 색인은 여기서 캐시에 넣는다. + /// + internal SoundIndex GetOrBuild(string? language, Func build) + { + string code = SoundLanguages.Resolve(language); + if (Cached(code) is { } hit) return hit; + lock (_gate) + { + if (Cached(code) is { } again) return again; + SoundIndex built = build(code); + _cached = built; + return built; + } + } + + private SoundIndex? Cached(string code) => _cached is { } index && index.Language == code ? index : null; + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundLanguages.cs b/SoundVisualizer.Core/Sounds/SoundLanguages.cs new file mode 100644 index 0000000..0c6f618 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundLanguages.cs @@ -0,0 +1,39 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Sounds +{ + /// + /// 분류 탭의 소리 이름과 묶음 이름이 있는 언어. PC 판 화면 언어의 코드로 적는다. + /// 모바일 판의 values 폴더 18개를 옮긴 것이다(values 는 en, values-in 은 id, values-zh-rCN·zh-rTW 는 zh-Hans·zh-Hant, + /// values-pt-rBR 는 pt-BR). + /// + public static class SoundLanguages + { + /// 기본 언어. 영어 소리 이름은 모델의 클래스 이름 그대로다. + public const string English = "en"; + + /// 데이터가 있는 언어. + public static IReadOnlyList All { get; } = Array.AsReadOnly(new[] + { + "en", "ko", "ja", "zh-Hans", "zh-Hant", "es", "pt-BR", "fr", "de", + "ru", "it", "vi", "th", "id", "tr", "pl", "hi", "ar", + }); + + /// + /// 를 데이터의 언어 코드로 맞춘다. 대소문자는 가리지 않고("pt-br" → "pt-BR"), 데이터가 없는 + /// 언어나 null 이면 영어다. 데이터를 읽는 곳과 색인의 캐시가 모두 이 값을 키로 써서, 같은 언어를 두 번 만들지 않는다. + /// + public static string Resolve(string? language) + { + if (language is not null) + { + foreach (string code in All) + { + if (string.Equals(code, language, StringComparison.OrdinalIgnoreCase)) return code; + } + } + return English; + } + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundList.cs b/SoundVisualizer.Core/Sounds/SoundList.cs new file mode 100644 index 0000000..8355b71 --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundList.cs @@ -0,0 +1,258 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Sounds +{ + /// + /// 소리 탭 목록의 한 줄. 모바일 ClassifyTab 의 LazyColumn 항목과 같은 차례이고 키도 같다(intro, changed, empty, 구역·카드 또는 + /// 고급 모드의 칸, end). 화면은 키가 같은 줄을 다시 만들지 않고 고쳐 쓴다. 그래야 종류를 고른 카드가 키보드 초점과 자리를 잃지 않는다. + /// + /// 모바일은 찾기·거르기를 목록 안에 붙는 머리(stickyHeader)로 두지만, PC 는 목록 위에 붙박아 두므로 목록에 없다. + /// + public abstract record SoundListItem + { + private SoundListItem() + { + } + + /// 목록의 키. 칸의 종류마다 앞 글자를 달리해 서로 겹치지 않는다. + public abstract string Key { get; } + + /// 제목·설명·고급 모드 스위치. 늘 맨 위다. + public sealed record Intro : SoundListItem + { + private Intro() + { + } + + public static Intro Instance { get; } = new(); + + public override string Key => "intro"; + } + + /// "바꾼 소리: N" 과 모두 되돌리기. 는 묶음이 아니라 소리의 수다(). + public sealed record Changed(int Count) : SoundListItem + { + public override string Key => "changed"; + } + + /// 찾는 말과 거르기에 맞는 소리가 없다. + public sealed record Empty : SoundListItem + { + private Empty() + { + } + + public static Empty Instance { get; } = new(); + + public override string Key => "empty"; + } + + /// 구역 머리. 두 모드가 같은 키를 쓴다(). + public sealed record SectionHeading(SoundType Type) : SoundListItem + { + public override string Key => "s:" + Type.ToLabel(); + } + + /// 기본 모드의 카드. + /// 카드와 찾는 말·거르기에 맞은 소리. 고르면 맞은 소리를 거르기에서 빠져도 남긴다(). + /// 카드의 지금 종류(). + /// 카드 이름 밑의 소리들. 맞은 소리가 앞에 온다(). + public sealed record GroupCard(ShownCard Shown, GroupState State, GroupPreview Preview) : SoundListItem + { + public override string Key => "c:" + Shown.Card.Id; + } + + /// 고급 모드의 묶음 머리(). + public sealed record GroupHeading(SoundCard Card) : SoundListItem + { + public override string Key => "g:" + Card.Id; + } + + /// 고급 모드의 소리 한 줄(). 은 사용자가 바꾼 것까지 반영한 지금 종류다. + public sealed record Sound(SoundEntry Entry, SoundType Type) : SoundListItem + { + public override string Key => "r:" + Entry.Index.ToString(CultureInfo.InvariantCulture); + } + + /// 목록 끝의 여백. + public sealed record End : SoundListItem + { + private End() + { + } + + public static End Instance { get; } = new(); + + public override string Key => "end"; + } + } + + /// + /// 소리 탭의 목록을 만든다. 화면은 이 목록을 그대로 그리고, 줄마다의 글은 문자열 표에서 고른다(). + /// 기본 모드와 고급 모드가 같은 찾기·거르기 결과()를 쓴다. + /// + public static class SoundList + { + /// + /// 결과가 시작하는 줄(바꾼 소리 줄)의 자리. 그 앞은 제목·설명·고급 모드 스위치()뿐이다. + /// 찾는 말이나 거르기를 바꾸면 이 줄을 찾기·거르기 바로 밑으로 올린다(). + /// + public const int ResultsIndex = 1; + + /// + /// 지금 목록. 모바일 ClassifyTab 의 LazyColumn 과 같은 차례다: 소개, 바꾼 소리 수, (맞는 것이 없으면) 빈 결과 안내, 기본 모드는 + /// 구역 머리와 카드, 고급 모드는 구역 머리·묶음 머리·소리 줄(), 끝 여백. + /// + /// 화면 언어의 색인. + /// 사용자가 바꾼 종류(설정의 소리 종류). + /// 찾는 말. 다듬지 않은 그대로 넘긴다. + /// 거르기. + /// 거르기를 켠 채로 방금 바꾼 소리. 찾는 말과 거르기가 남기기 시작할 때와 같을 때만 남긴다. + /// 고급 모드인지. + public static IReadOnlyList Build( + SoundIndex index, + IReadOnlyDictionary overrides, + string query, + SoundFilter filter, + KeptSounds kept, + bool advanced) + { + ArgumentNullException.ThrowIfNull(index); + ArgumentNullException.ThrowIfNull(overrides); + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(kept); + + IReadOnlyList shown = index.Filter(overrides, query, filter, kept.NamesFor(query, filter)); + var items = new List { SoundListItem.Intro.Instance, new SoundListItem.Changed(index.CountChanged(overrides)) }; + if (shown.Count == 0) items.Add(SoundListItem.Empty.Instance); + + if (advanced) + { + foreach (AdvancedItem item in SoundGroups.AdvancedItems(shown)) + { + items.Add(item switch + { + AdvancedItem.SectionHeading heading => new SoundListItem.SectionHeading(heading.Type), + AdvancedItem.GroupHeading group => new SoundListItem.GroupHeading(group.Card), + AdvancedItem.Row row => new SoundListItem.Sound(row.Entry, SoundCatalog.TypeOf(row.Entry, overrides)), + _ => throw new InvalidOperationException("모르는 고급 모드 칸: " + item), + }); + } + } + else + { + foreach (ShownSection section in shown) + { + items.Add(new SoundListItem.SectionHeading(section.Type)); + foreach (ShownCard card in section.Cards) + { + items.Add(new SoundListItem.GroupCard(card, SoundGroups.State(card.Card, overrides), SoundGroups.Preview(card.Card, card.Shown))); + } + } + } + + items.Add(SoundListItem.End.Instance); + return items.AsReadOnly(); + } + + /// + /// 찾는 말이나 거르기를 바꾼 뒤 결과()를 찾기·거르기 바로 밑으로 올릴지(모바일 ClassifyTab 의 + /// LaunchedEffect). 한참 내려간 채로 바꾸면 결과의 중간부터 보이므로 올린다. 찾는 말이 있으면 맨 위에 있어도 올린다. + /// 모바일에서는 찾기 칸을 누르면 자판이 화면 아래를 가려, 맨 위의 제목·안내가 남은 자리를 다 차지하고 결과가 보이지 않았다(#283). + /// 거르기만 바꿀 때는 맨 위면 그대로 둔다. 탭을 처음 그릴 때는 부르지 않는다. + /// + /// 바뀐 뒤의 찾는 말. + /// 소개 줄이 목록 위로 다 올라가 보이지 않는지(모바일의 firstVisibleItemIndex > 1). + public static bool ShouldScrollToResults(string query, bool introScrolledAway) => + !string.IsNullOrEmpty(query) || introScrolledAway; + } + + /// 화면 읽어주기에 줄을 읽힐 꼴(모바일 GroupCard·SoundTypeRow 의 description). 문구는 화면이 문자열 표에서 고른다. + public enum SoundRowSpeech + { + /// "이름, 종류: X". 소리 한 줄, 또는 미리보기가 없는(소리 하나뿐인) 카드다. + Sound, + + /// "이름, 종류: X, 기본: Y". + SoundChanged, + + /// "이름 묶음, 종류: X, 소리 N개 (미리보기)". + Group, + + /// "이름 묶음, 종류: X, 기본: Y, 소리 N개 (미리보기)". 일부만 바꾼 카드도 이것이다. + GroupChanged, + } + + /// 이름 밑의 밝은 글씨(모바일 GroupCard 의 accent, SoundTypeRow 의 "기본: Y"). + public enum SoundRowNote + { + None, + + /// 카드의 소리마다 종류가 다르다("일부 소리를 하나씩 바꿈"). + Mixed, + + /// 카드를 묶음째 바꿨다("지금: X · 기본: Y"). + GroupChanged, + + /// 고급 모드에서 소리를 바꿨다("기본: Y"). + SoundChanged, + } + + /// + /// 소리 탭 줄의 글을 어느 꼴로 적을지. 모바일 GroupCard·SoundTypeRow 의 갈래를 그대로 옮겼다. 문구는 화면 쪽 문자열 표에 있고, + /// 여기서는 어느 문구를 쓸지와 번역하지 않는 기호만 정한다. + /// + public static class SoundRowText + { + /// 미리보기의 이름 사이(모바일과 같다). + public const string ShownSeparator = " • "; + + /// 화면 읽어주기가 읽는 미리보기의 이름 사이. 화면의 " • " 는 기호라 읽히지 않게 쉼표로 바꾼다. + public const string SpokenSeparator = ", "; + + /// 미리보기에 못 보인 소리의 수("+N"). 숫자뿐이라 번역하지 않는다(모바일 classify_group_more). + public static string More(int count) => "+" + count.ToString(CultureInfo.InvariantCulture); + + /// + /// 카드를 읽힐 꼴. 미리보기가 없는 카드(전기톱처럼 소리 하나뿐인 묶음, 표에 없는 소리)는 소리 한 줄과 같은 글로 읽는다. + /// 일부만 바꾼 카드는 지금 종류가 기본 종류가 아니므로 기본 종류를 함께 읽는다. + /// + public static SoundRowSpeech SpeechOf(SoundListItem.GroupCard card) + { + ArgumentNullException.ThrowIfNull(card); + bool unchanged = card.State is GroupState.Uniform uniform && uniform.Type == card.Shown.Card.DefaultType; + if (card.Preview.Names.Count == 0) return unchanged ? SoundRowSpeech.Sound : SoundRowSpeech.SoundChanged; + return unchanged ? SoundRowSpeech.Group : SoundRowSpeech.GroupChanged; + } + + /// 소리 한 줄을 읽힐 꼴. + public static SoundRowSpeech SpeechOf(SoundListItem.Sound sound) + { + ArgumentNullException.ThrowIfNull(sound); + return sound.Type == sound.Entry.DefaultType ? SoundRowSpeech.Sound : SoundRowSpeech.SoundChanged; + } + + /// 카드 이름 밑의 밝은 글씨. 묶음째 바꿨으면 지금 종류와 기본 종류를, 일부만 바꿨으면 그렇다고 적는다. + public static SoundRowNote NoteOf(SoundListItem.GroupCard card) + { + ArgumentNullException.ThrowIfNull(card); + return card.State switch + { + GroupState.Mixed => SoundRowNote.Mixed, + GroupState.Uniform uniform when uniform.Type != card.Shown.Card.DefaultType => SoundRowNote.GroupChanged, + _ => SoundRowNote.None, + }; + } + + /// 소리 한 줄 밑의 밝은 글씨. 바꾼 소리는 기본 종류를 함께 보인다. 되돌리려면 목록에서 "(기본)" 을 고른다. + public static SoundRowNote NoteOf(SoundListItem.Sound sound) + { + ArgumentNullException.ThrowIfNull(sound); + return sound.Type == sound.Entry.DefaultType ? SoundRowNote.None : SoundRowNote.SoundChanged; + } + } +} diff --git a/SoundVisualizer.Core/Sounds/SoundTypeOverrides.cs b/SoundVisualizer.Core/Sounds/SoundTypeOverrides.cs new file mode 100644 index 0000000..9eb138b --- /dev/null +++ b/SoundVisualizer.Core/Sounds/SoundTypeOverrides.cs @@ -0,0 +1,80 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Sounds +{ + /// + /// 분류 탭에서 사용자가 바꾼 소리 종류(설정의 sound_types)를 고치는 계산. 모바일 SettingsManager 의 setSoundTypes·loadSoundTypes 와 같다. + /// + /// 맵의 키는 모델의 영어 이름, 값은 의 문자열이다. 기본 종류와 같은 값은 두지 않으므로, 맵에 있으면 + /// 바꾼 소리다. AI 는 같은 맵으로 을 만든다. 모두 되돌리기는 빈 맵을 저장하는 것이다. + /// + public static class SoundTypeOverrides + { + /// + /// 를 모두 으로 본 새 맵. 소리마다 기본 종류와 같으면 바꾼 것을 지우고, 다르면 + /// 그 라벨을 적는다. 다른 소리의 값은 그대로 둔다. 그래서 기본 종류와 같은 값이 새로 들어가는 일은 없다. + /// + /// 기본 모드에서 카드를 고르면 찾는 말·거르기와 상관없이 카드의 소리()를 모두 한 번에 넘긴다(#349). + /// AI 는 맵이 바뀌면 판정을 새로 시작하므로, 소리마다 따로 바꾸면 묶음(음악은 140개)을 바꾸는 동안 반쯤 바뀐 맵을 읽어 거듭 다시 + /// 시작하고 저장도 소리 수만큼 한다. 고급 모드는 소리 하나를 넘긴다. + /// + /// 바뀌는 것이 없으면 를 그대로(같은 인스턴스) 돌려준다. 그때는 저장하지 않아도 된다. + /// + public static IReadOnlyDictionary WithType( + IReadOnlyDictionary current, + IEnumerable sounds, + SoundType type) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(sounds); + string label = type.ToLabel(); + Dictionary? updated = null; + foreach (SoundEntry entry in sounds) + { + IReadOnlyDictionary now = updated ?? current; + if (type == entry.DefaultType) + { + if (!now.ContainsKey(entry.Name)) continue; + updated ??= Copy(current); + updated.Remove(entry.Name); + } + else + { + if (now.TryGetValue(entry.Name, out string? old) && old == label) continue; + updated ??= Copy(current); + updated[entry.Name] = label; + } + } + return updated is null ? current : updated.AsReadOnly(); + } + + /// + /// 저장값에서 남길 것. 세 라벨이 아닌 값과 지금 기본 종류(AI 의 매핑)와 같은 값은 버린다. + /// 기본 종류는 AI 의 매핑이 바뀌면 달라질 수 있는데, 그때 사용자가 고른 것과 같아진 소리는 "바꾼 소리" 로 남아 있을 까닭이 없다. + /// 모르는 이름은 모바일처럼 남긴다(모델의 이름이 바뀐 뒤 등). 세는 쪽()은 목록에서 세고, + /// AI 는 에서 따로 거른다. + /// + public static IReadOnlyDictionary Sanitize(IEnumerable> stored) + { + ArgumentNullException.ThrowIfNull(stored); + var kept = new Dictionary(StringComparer.Ordinal); + foreach (var (name, label) in stored) + { + if (name is null || !SoundTypeLabels.TryParse(label, out SoundType type)) continue; + if (type == YamnetThreeClassMapper.MapDisplayNameToCoarse(name)) continue; + kept[name] = label; + } + return kept.AsReadOnly(); + } + + private static Dictionary Copy(IReadOnlyDictionary source) + { + var copy = new Dictionary(source.Count + 8, StringComparer.Ordinal); + foreach (var (name, label) in source) copy[name] = label; + return copy; + } + } +} diff --git a/SoundVisualizer.Core/Status/CaptureRecovery.cs b/SoundVisualizer.Core/Status/CaptureRecovery.cs new file mode 100644 index 0000000..05946cb --- /dev/null +++ b/SoundVisualizer.Core/Status/CaptureRecovery.cs @@ -0,0 +1,26 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Status +{ + /// + /// 캡처가 돌고 있지 않을 때 오버레이를 멈출지와 그 이유를 정한다. + /// + /// 출력 장치를 바꾸면(헤드폰을 뽑는 등) 캡처 엔진은 옛 장치에서 오류를 낸 뒤 0.5초쯤 지나 새 기본 장치를 다시 연다. + /// 그래서 실행 중의 오류는 받자마자 멈추지 않고 만큼 기다려, 그때도 받지 못할 때만 멈춘다. + /// 끝내 되살아나지 않으면 오버레이는 아무것도 그리지 못하는데, 사용자는 그것을 조용한 장면과 구분할 수 없다. + /// + public static class CaptureRecovery + { + /// 실행 중 캡처 오류 뒤 저절로 되살아나기를 기다리는 시간. 엔진이 다시 여는 지연(0.5초)보다 넉넉하다. + public static readonly TimeSpan GracePeriod = TimeSpan.FromSeconds(5); + + /// 캡처가 돌고 있는지. + /// 출력 장치를 열었는지(캡처 형식이 있는지). + /// 멈출 이유. 캡처가 돌고 있으면 null. + public static StopReason? Check(bool isCapturing, bool hasDevice) => + isCapturing ? null + : hasDevice ? StopReason.CaptureError + : StopReason.NoPlaybackDevice; + } +} diff --git a/SoundVisualizer.Core/Status/RunStatus.cs b/SoundVisualizer.Core/Status/RunStatus.cs new file mode 100644 index 0000000..a716215 --- /dev/null +++ b/SoundVisualizer.Core/Status/RunStatus.cs @@ -0,0 +1,82 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Status +{ + /// 홈의 상태 줄 아래에 보이는 안내. 한 번에 하나만 보인다. + public enum HomeNotice + { + None, + + /// 실행 중인데 AI 를 쓸 수 없다. 모든 소리가 환경음 색으로 보인다. + AiUnavailable, + + /// 실행 중인데 AI 를 쓸 수 없고 환경음 표시도 꺼 두었다. 아무것도 보이지 않는다. + AiUnavailableHidden, + + /// 사용자가 끄지 않았는데 멈췄다. 이유는 . + Stopped, + } + + /// + /// 런처의 홈 안내와 트레이가 보는 실행 상태. 안드로이드 SettingsManager 의 isServiceRunning·aiAvailable·lastUnexpectedStop 과 + /// HomeTab 의 안내 고르기를 한곳에 모았다. 무엇을 보일지는 가 정한다. UI 스레드에서만 쓴다. + /// + public sealed class RunStatus + { + /// 오버레이가 떠 있는지. + public bool IsRunning { get; private set; } + + /// 이번 실행에서 AI(소리 종류 구분)를 쓸 수 있는지. 실행 중이 아니면 늘 true 다(알릴 것이 없다). + public bool AiAvailable { get; private set; } = true; + + /// 사용자가 끄지 않았는데 멈춘 마지막 이유. 다시 실행하거나 안내를 닫으면 지운다. + public StopReason? LastUnexpectedStop { get; private set; } + + /// + /// 오버레이가 떴다. 지난 "꺼졌습니다" 안내는 이제 틀린 정보라 지운다. AI 는 만드는 중이라 실패를 알려 올 때까지 + /// 쓸 수 있다고 본다(만드는 0.1초 남짓 동안 안내가 깜빡이지 않게). + /// + public void Started() + { + IsRunning = true; + AiAvailable = true; + LastUnexpectedStop = null; + } + + /// AI 를 만들었거나 만들지 못했다. 멈춘 뒤에 늦게 도착한 알림은 다음 실행의 상태가 되지 않게 버린다. + public void SetAiAvailable(bool available) + { + if (IsRunning) AiAvailable = available; + } + + /// + /// 오버레이가 멈췄다(시작하지 못한 경우도 여기로 온다). + /// + /// 사용자가 끈 게 아니라 알릴 차례면 true. + public bool Stopped(StopReason reason) + { + IsRunning = false; + AiAvailable = true; + if (reason == StopReason.UserRequested) return false; + LastUnexpectedStop = reason; + return true; + } + + /// 사용자가 "안내 닫기"를 눌렀다. + public void Dismiss() => LastUnexpectedStop = null; + + /// + /// 상태 줄 아래에 보일 안내. AI 안내는 실행 중에만, 멈춘 이유는 멈춘 뒤에만 보이므로 둘이 겹치지 않는다. + /// + /// + /// 환경음을 표시하는지. AI 가 없으면 모든 소리가 환경음이 되는데, 환경음 표시를 꺼 두었으면 아무것도 그리지 않는다. + /// 그때 "환경음 색으로 보인다"고 하면 거짓으로 안심시키므로 다른 안내를 고른다(안드로이드 AiUnavailableNotice 가 진동을 두고 한 일). + /// + public HomeNotice Notice(bool showAmbient) + { + if (IsRunning) return AiAvailable ? HomeNotice.None : showAmbient ? HomeNotice.AiUnavailable : HomeNotice.AiUnavailableHidden; + return LastUnexpectedStop != null ? HomeNotice.Stopped : HomeNotice.None; + } + } +} diff --git a/SoundVisualizer.Core/Status/StopReason.cs b/SoundVisualizer.Core/Status/StopReason.cs new file mode 100644 index 0000000..c9c66be --- /dev/null +++ b/SoundVisualizer.Core/Status/StopReason.cs @@ -0,0 +1,23 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Status +{ + /// + /// 오버레이(시각화)가 멈춘 이유. 안드로이드 StopReason 에서 PC 에 있는 경우만 남겼다(화면 녹화 동의, 다른 앱 위에 표시 권한은 PC 에 없다). + /// + /// 청각장애 사용자는 "조용한 장면"과 "꺼진 상태"를 구분할 수 없다. 오버레이는 소리가 없으면 아무것도 그리지 않으므로 + /// 사용자가 직접 끈 게 아니면 반드시 알린다. + /// + public enum StopReason + { + /// 홈·트레이의 실행 종료, 편집 패널의 오버레이 종료, 런처 닫기. 알리지 않는다. + UserRequested, + + /// 열 수 있는 출력 장치가 없다. 시작할 때 없었거나, 실행 중에 장치가 빠졌는데 다른 장치가 없을 때다. + NoPlaybackDevice, + + /// 장치는 열었지만 캡처가 오류로 멈추고 되살아나지 않았다. + CaptureError, + } +} diff --git a/SoundVisualizer.Core/Tutorial/TutorialCueTimer.cs b/SoundVisualizer.Core/Tutorial/TutorialCueTimer.cs new file mode 100644 index 0000000..8ebf79e --- /dev/null +++ b/SoundVisualizer.Core/Tutorial/TutorialCueTimer.cs @@ -0,0 +1,70 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Tutorial +{ + /// + /// 한 쪽 그림의 소리 박자(Android TutorialSounds, #327). 그림이 프레임마다 대본 시각을 으로 넘기면, 그사이 지난 + /// 소리()를 낸다. 그래서 소리가 그림의 박자와 같은 프레임에 시작하고, 그림이 멈추면(쪽을 넘김·멈춤 버튼· + /// 창을 최소화) 다음 소리도 나지 않는다. + /// + /// 늦은 소리는 내지 않는다. 창이 잠깐 멈췄다 돌아오면 그림의 시각이 그동안 흐른 만큼 건너뛰는데, 그사이의 소리를 한꺼번에 내면 + /// 소리가 겹쳐 쏟아진다. 소리를 도중에 켰을 때도 지난 소리를 몰아 내지 않는다. + /// + /// UI 스레드에서만 쓴다. + /// + public sealed class TutorialCueTimer + { + /// 이보다 늦게 알게 된 소리는 내지 않는다. 60fps 의 몇 프레임이 밀려도 낼 만큼. + public const double MaxLateSec = 0.15; + + /// 처음 그리는 프레임(0초)의 소리도 내도록 그보다 조금 앞에서 센다. + private const double BeforeStart = -0.001; + + private readonly IReadOnlyList _cues; + private readonly double _cycleSec; + private readonly Func _canPlay; + private readonly Action _play; + private readonly Action _cancel; + private double _lastT = BeforeStart; + + /// 한 바퀴의 소리. + /// 한 바퀴의 길이(초). + /// 지금 소리를 내도 되는지. 아니면 그 소리는 건너뛴다. + /// 소리 하나를 낸다. + /// 나던 소리를 모두 멈춘다. + public TutorialCueTimer(IReadOnlyList cues, double cycleSec, Func canPlay, Action play, Action cancel) + { + _cues = cues; + _cycleSec = cycleSec; + _canPlay = canPlay; + _play = play; + _cancel = cancel; + } + + /// 그림이 새로 그렸다. 는 대본의 시각(초)이다. 지난 프레임 뒤로 온 소리 가운데 늦지 않은 것을 낸다. + public void OnFrame(double t) + { + double from = _lastT; + _lastT = t; + if (t <= from) return; + int first = (int)Math.Floor(Math.Max(from, 0) / _cycleSec); + int last = (int)Math.Floor(t / _cycleSec); + for (int cycle = first; cycle <= last; cycle++) + { + foreach (var cue in _cues) + { + double at = cycle * _cycleSec + cue.AtSec; + if (at > from && at <= t && t - at <= MaxLateSec && _canPlay()) _play(cue); + } + } + } + + /// 나던 소리를 멈추고, 다시 움직이면 처음부터 센다(그 시각의 소리부터 다시 낸다). + public void Stop() + { + _lastT = BeforeStart; + _cancel(); + } + } +} diff --git a/SoundVisualizer.Core/Tutorial/TutorialDemo.cs b/SoundVisualizer.Core/Tutorial/TutorialDemo.cs new file mode 100644 index 0000000..c5c6ce5 --- /dev/null +++ b/SoundVisualizer.Core/Tutorial/TutorialDemo.cs @@ -0,0 +1,92 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Rendering; + +namespace SoundVisualizer.Tutorial +{ + /// + /// 튜토리얼 한 쪽의 그림을 계산한다(Android TutorialDemoInputs 와 DemoPhone 의 엔진 부분). 의 소리를 + /// 오버레이와 같은 에 흘리므로, 그림은 앱이 실제로 그리는 모양 그대로다. + /// + /// - 엔진은 기준 화면(×, 16:9 모니터)의 크기로 돈다. 튜토리얼 창은 그 결과를 + /// 그림 크기로 줄여 그린다. 그래서 여백·두께·보이기 시작하는 깊이가 실제 화면을 줄여 놓은 비율 그대로다(안드로이드는 엔진의 + /// density 를 줄여 같은 일을 한다). 모니터마다 달리 잡지 않고 한 화면으로 고정해 어느 PC 에서나 같은 그림이 된다. + /// - 사운드 모드는 2.0 으로 고정한다. 대본이 좌우 두 채널이다. + /// - 사용자가 꺼 둔 종류도 모두 그린다. 튜토리얼은 앱이 무엇을 하는지 보여 주는 곳이라 크기·진하기·반응도 지금 설정이 아니라 + /// 로 그린다. 표현 모드와 색만 사용자 것을 쓴다(색은 창이 라벨마다 고른다). + /// - 실제 소리·오버레이와는 닿지 않는다. 튜토리얼을 연 채 오버레이가 돌아도 둘이 서로를 건드리지 않는다. + /// + /// 쪽마다 하나씩 만들고, 처음에는 그 쪽이 말하려는 장면()까지 미리 돌려 둔다. 움직이기 + /// 시작하면 그 장면에서 이어서 움직이고, 멈추면 그 자리에서 멈춘다(안드로이드 #331). UI 스레드에서만 쓴다. + /// + public sealed class TutorialDemo + { + /// 엔진이 그리는 기준 화면(DIP). 16:9 모니터를 100% 배율로 본 크기다. + public const double ReferenceWidth = 1920; + public const double ReferenceHeight = 1080; + + /// 미리 돌릴 때의 프레임 간격(60fps). + private const double PrerollFps = 60; + + private readonly float[] _frame = new float[VisualizerEngine.Channels]; + private readonly VisualModeSettings _settings = DemoSettings(); + + /// 그릴 쪽. + /// 표현 모드. + /// 멈춰 둘 장면까지 미리 돌려 둔다. 끄면 아무것도 흘리지 않은 엔진으로 시작한다(시험이 0초부터 흘릴 때). + public TutorialDemo(TutorialScene scene, int visualMode, bool runToStill = true) + { + Scene = scene; + VisualMode = visualMode; + Engine.SetSurfaceSize(ReferenceWidth, ReferenceHeight); + if (runToStill) RunTo(scene.StillAtSec()); + } + + public TutorialScene Scene { get; } + + /// 그리는 표현 모드( 등). 패드·원형의 움직임은 그리는 모드만 계산한다. + public int VisualMode { get; set; } + + /// 오버레이와 같은 엔진. 기준 화면 크기로 돈다. + public VisualizerEngine Engine { get; } = new(); + + /// 마지막으로 엔진에 흘린 대본 시각(초). 처음에는 멈춰 둘 장면의 시각이다. + public double TimeSec { get; private set; } + + /// 지금 그리는 소리의 종류. 창이 이것으로 색을 고르고 종류 쪽의 범례를 밝힌다. + public SoundType Label => TutorialScript.Label(Scene, TimeSec); + + /// + /// 그림의 모드 설정. 기본값()에서 크기·진하기·반응을 조금씩 올렸다(안드로이드와 같은 값, 민감도는 그 + /// 표시값 40 의 내부값). 그림이 실제 화면보다 훨씬 작아서, 기본값 그대로면 어두운 장면 위의 빨간 위협음이 흐리고 박자가 잘 읽히지 않는다. + /// + public static VisualModeSettings DemoSettings() => new() + { + Intensity = 55, + VisualOpacity = 80, + PositionSpeed = 20, + Sensitivity = 10, + }; + + /// 대본 시각 (초)의 프레임 하나를 엔진에 흘린다. 시각은 앞으로만 간다. + public void Step(double t) + { + TutorialScript.PeakFrame(Scene, t, _frame); + var input = new OverlayInput(_settings, VisualMode, SoundMode: 0, TutorialScript.Label(Scene, t), Shown: true); + // 엔진의 시계도 대본 시각으로 간다. 멈췄다 다시 움직여도 대본이 이어지므로 엔진이 한꺼번에 건너뛰지 않는다. + // 그림은 조용한 틈에도 쉬지 않는다(쉬면 쉬는 동안의 확인을 따로 돌려야 한다). + Engine.Tick(t * 1000.0, _frame, input, keepAwake: true); + TimeSec = t; + } + + /// 멈춰 둘 때: 0초부터 까지 60fps 로 미리 돌려 그 순간의 모양을 만든다. + public void RunTo(double seconds) + { + // 2.4 × 60 처럼 소수 곱이 정수 바로 아래로 떨어져도 한 프레임을 빠뜨리지 않게 아주 조금 올려서 자른다. + int frames = (int)Math.Floor(seconds * PrerollFps + 1e-9); + for (int i = 0; i <= frames; i++) Step(i / PrerollFps); + } + } +} diff --git a/SoundVisualizer.Core/Tutorial/TutorialScript.cs b/SoundVisualizer.Core/Tutorial/TutorialScript.cs new file mode 100644 index 0000000..eaf60d6 --- /dev/null +++ b/SoundVisualizer.Core/Tutorial/TutorialScript.cs @@ -0,0 +1,319 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Rendering; + +namespace SoundVisualizer.Tutorial +{ + /// + /// 튜토리얼 그림 한 쪽의 소리 대본(Android TutorialScene). 쪽마다 초마다 처음부터 되풀이한다. + /// 안드로이드의 진동 쪽은 PC 에 진동이 없어 옮기지 않았다. + /// + public enum TutorialScene + { + /// 무엇을 하는 앱인지. 1초마다 한 번씩 소리가 쿵 울리고, 왼쪽·오른쪽으로 조금씩 번갈아 기운다. + Sound, + + /// 방향. 왼쪽에서 나다가 잠깐 쉬고 오른쪽에서 난다. + Direction, + + /// 종류. 환경음 → 대화음 → 위협음. + Types, + } + + /// + /// 튜토리얼 소리 조각(Android #327). 모두 실제 녹음이다. 원본과 라이선스, 자른 위치는 저장소의 THIRD_PARTY.md(Tutorial sounds)에 있다. + /// 그림이 이 소리를 어느 종류로 그리는지는 이다. + /// + public enum TutorialClip + { + /// 새 울음 한 토막(0.45초). 첫 쪽과 방향 쪽의 박자마다 낸다. + Chirp, + + /// 새소리(2.2초). 종류 쪽의 환경음. + Birds, + + /// 영어 인사말 대화(2.2초). 종류 쪽의 대화음. + Talk, + + /// 자동차 경적 한 번(0.5초). 종류 쪽의 위협음. + Honk, + } + + /// 한 바퀴 안의 초에 을 왼쪽 ·오른쪽 크기(0~1)로 낸다. + public readonly record struct TutorialCue(double AtSec, TutorialClip Clip, float Left, float Right); + + /// + /// 쪽마다 엔진에 흘려 줄 소리(Android TutorialScript). 캡처가 오버레이 엔진에 주는 것과 같은 모양(채널 피크 0~1, AI 라벨)이라 + /// 튜토리얼의 그림은 앱이 실제로 그리는 모양 그대로다. + /// + /// - 피크는 파형이 아니라 그사이 가장 큰 크기다. 그래서 오르내림은 50ms 보다 짧게 만들지 않는다. + /// - 번쩍임은 1초에 세 번보다 적게 둔다(WCAG 2.3.1). 위협음은 빨간색이라 특히 그렇다. + /// + /// 대본의 좌우는 사운드 모드 2.0 의 앞 좌우(FL, FR)다(). PC 엔진은 2.0 을 Mid/Side 로 둘레 여덟 자리에 + /// 펼치므로() 실제 스테레오 소리와 똑같이 그려진다. + /// + /// 모두 시각만 받는 순수 함수다. 같은 시각에는 늘 같은 값을 내므로 화면 없이 검사할 수 있다(TutorialScriptTests). + /// + public static class TutorialScript + { + /// 쪽이 처음부터 되풀이하는 간격(초). + public static double CycleSec(this TutorialScene scene) => scene switch + { + TutorialScene.Sound => 4.0, + TutorialScene.Direction => 4.4, + _ => 6.6, + }; + + /// + /// 그림을 멈춰 둘 때(Windows 에서 애니메이션을 껐거나 멈춤을 눌렀을 때, 쪽을 처음 보일 때) 보여 줄 장면의 시각. + /// 그 쪽이 말하려는 것이 가장 잘 보이는 순간을 고른다. + /// + public static double StillAtSec(this TutorialScene scene) => scene switch + { + TutorialScene.Sound => 2.4, + TutorialScene.Direction => 1.3, + _ => 4.75, + }; + + /// 그림이 이 소리 조각을 그리는 종류. + public static SoundType Label(this TutorialClip clip) => clip switch + { + TutorialClip.Talk => SoundType.Speech, + TutorialClip.Honk => SoundType.Danger, + _ => SoundType.Ambient, + }; + + /// 한 대본 안의 시각. 음수나 한 바퀴를 넘는 값도 받는다. + public static double LoopTime(TutorialScene scene, double t) + { + double c = scene.CycleSec(); + return ((t % c) + c) % c; + } + + /// 의 앞 두 칸에 [왼쪽 피크, 오른쪽 피크] 를 채운다. + public static void Peaks(TutorialScene scene, double t, Span leftRight) + { + double s = LoopTime(scene, t); + double l, r; + switch (scene) + { + case TutorialScene.Sound: + { + // 1초에 한 번 0.45초 동안 이어지는 쿵. 짧게 치고 마는 소리는 엔진이 부드럽게 따라가는 사이에 묻힌다. + // 한 번은 왼쪽, 한 번은 오른쪽으로 조금 기울여 테두리 전체가 숨 쉬듯 움직이게 한다. + int index = (int)Math.Floor(s); + double level = 0.06 + 0.72 * Thump(s - index, hold: 0.45, release: 0.12); + if (index % 2 == 0) + { + l = level; + r = level * SoundLean; + } + else + { + l = level * SoundLean; + r = level; + } + break; + } + case TutorialScene.Direction: + { + int side = DirectionSide(s); + double level = DirectionLevel(s); + // 반대쪽에도 조금 들리게 둔다. 한쪽만 있는 소리는 실제로 드물다. + l = side < 0 ? level : level * DirectionFar; + r = side > 0 ? level : level * DirectionFar; + break; + } + default: + if (s < TypeSegmentSec) + { + // 환경음: 잔잔하게 이어지는 소리 + double level = 0.45 + 0.1 * Math.Sin(2 * Math.PI * 0.8 * s); + l = level; + r = level; + } + else if (s < 2 * TypeSegmentSec) + { + // 대화음: 말소리처럼 오르내린다(초당 두어 번) + double u = s - TypeSegmentSec; + double syllable = Math.Max(0, Math.Sin(2 * Math.PI * 2.2 * u)); + double level = 0.3 + 0.35 * syllable * syllable; + l = level; + r = level * TypeSpeechLean; + } + else + { + // 위협음: 자동차 경적 세 번. 오른쪽에서 난다. + double level = 0.2 + 0.7 * Hits(s - 2 * TypeSegmentSec, TypeDangerHits); + l = level * TypeDangerLean; + r = level; + } + break; + } + leftRight[0] = (float)Math.Clamp(l, 0, 1); + leftRight[1] = (float)Math.Clamp(r, 0, 1); + } + + /// + /// 엔진이 받는 채널 피크 한 프레임(캡처 순서 FL, FR, FC, BL, BR, SL, SR, LFE)을 채운다. 대본의 좌우는 앞 좌우에 넣고 나머지는 비운다. + /// 그림은 사운드 모드 2.0 으로 그린다. 2.0 은 앞 좌우만 읽는다(). + /// + public static void PeakFrame(TutorialScene scene, double t, Span frame) + { + frame[..VisualizerEngine.Channels].Clear(); + Peaks(scene, t, frame); + } + + /// 그 시각에 AI 가 붙였을 종류. + public static SoundType Label(TutorialScene scene, double t) + { + if (scene != TutorialScene.Types) return SoundType.Ambient; + double s = LoopTime(scene, t); + if (s < TypeSegmentSec) return SoundType.Ambient; + if (s < 2 * TypeSegmentSec) return SoundType.Speech; + return SoundType.Danger; + } + + /// + /// 방향 쪽에서 소리가 나는 곳. 왼쪽이면 -1, 오른쪽이면 1, 쉬는 중이면 0. + /// 그림이 장면 안의 그 자리에 소리 표시를 띄운다. + /// + public static int SourceSide(TutorialScene scene, double t) => + scene == TutorialScene.Direction ? DirectionSide(LoopTime(scene, t)) : 0; + + /// 방향 쪽 소리의 세기(0~1). 소리 표시의 진하기로 쓴다. + public static double SourceLevel(TutorialScene scene, double t) => + scene == TutorialScene.Direction ? DirectionLevel(LoopTime(scene, t)) : 0; + + /// 방향 쪽 소리 표시에서 박자마다 새로 퍼지는 파문이 퍼진 정도(0~1). 쉬는 중이면 -1. + public static double SourceRipple(TutorialScene scene, double t) + { + if (scene != TutorialScene.Direction) return -1; + double s = LoopTime(scene, t); + if (DirectionStart(s) is not { } start) return -1; + return ((s - start) % DirectionBeatSec) / DirectionBeatSec; + } + + /// + /// 소리를 켰을 때 그 쪽이 한 바퀴 동안 내는 소리(Android #327). 대본의 박자·구간과 같은 순간에, 그림이 그 순간 그리는 종류의 + /// 소리를, 그림이 기우는 쪽으로 낸다. 대본의 상수에서 만들므로 대본을 고치면 소리도 따라간다. + /// + /// - 첫 쪽: 1초마다 쿵 할 때 새 울음. 그림처럼 왼쪽·오른쪽으로 번갈아 조금 기운다. + /// - 방향 쪽: 박자마다 새 울음. 왼쪽 네 번, 쉬고 오른쪽 네 번. 반대쪽은 그림처럼 작게 들린다. + /// - 종류 쪽: 새소리 → 대화 → 경적 세 번. + /// + public static IReadOnlyList Cues(TutorialScene scene) => scene switch + { + TutorialScene.Sound => SoundCues, + TutorialScene.Direction => DirectionCues, + _ => TypesCues, + }; + + // ---------------- 대본의 조각 ---------------- + + /// 첫 쪽: 쿵이 기운 쪽의 반대편 크기. + private const float SoundLean = 0.72f; + + /// 방향 쪽: 소리가 나는 반대편에 들리는 크기. 한쪽만 있는 소리는 실제로 드물다. + private const float DirectionFar = 0.15f; + + /// 종류 쪽: 대화음의 오른쪽, 위협음의 왼쪽 크기. + private const float TypeSpeechLean = 0.95f; + private const float TypeDangerLean = 0.65f; + + private const double TypeSegmentSec = 2.2; + private static readonly double[] TypeDangerHits = { 0.1, 0.8, 1.5 }; + + private const double DirectionLeftEnd = 1.6; + private const double DirectionRightStart = 2.2; + private const double DirectionRightEnd = 3.8; + private const double DirectionBeatSec = 0.5; + + private static readonly TutorialCue[] SoundCues = BuildSoundCues(); + private static readonly TutorialCue[] DirectionCues = BuildDirectionCues(); + private static readonly TutorialCue[] TypesCues = BuildTypesCues(); + + private static TutorialCue[] BuildSoundCues() + { + var cues = new List(); + for (int beat = 0; beat < (int)TutorialScene.Sound.CycleSec(); beat++) + { + cues.Add(beat % 2 == 0 + ? new TutorialCue(beat, TutorialClip.Chirp, 1f, SoundLean) + : new TutorialCue(beat, TutorialClip.Chirp, SoundLean, 1f)); + } + return cues.ToArray(); + } + + private static TutorialCue[] BuildDirectionCues() + { + var cues = new List(); + foreach (double at in Beats(0, DirectionLeftEnd)) cues.Add(new TutorialCue(at, TutorialClip.Chirp, 1f, DirectionFar)); + foreach (double at in Beats(DirectionRightStart, DirectionRightEnd)) cues.Add(new TutorialCue(at, TutorialClip.Chirp, DirectionFar, 1f)); + return cues.ToArray(); + } + + private static TutorialCue[] BuildTypesCues() + { + var cues = new List + { + new(0, TutorialClip.Birds, 1f, 1f), + new(TypeSegmentSec, TutorialClip.Talk, 1f, TypeSpeechLean), + }; + foreach (double hit in TypeDangerHits) cues.Add(new TutorialCue(2 * TypeSegmentSec + hit, TutorialClip.Honk, TypeDangerLean, 1f)); + return cues.ToArray(); + } + + /// 부터 앞까지 방향 쪽의 박자마다의 시각. 더해 가지 않고 곱해 소수 오차가 쌓이지 않게 한다. + private static IEnumerable Beats(double start, double end) + { + for (int i = 0; start + i * DirectionBeatSec < end; i++) yield return start + i * DirectionBeatSec; + } + + private static int DirectionSide(double s) + { + if (s < DirectionLeftEnd) return -1; + if (s >= DirectionRightStart && s < DirectionRightEnd) return 1; + return 0; + } + + /// 방향 쪽에서 지금 나는 소리가 시작한 시각. 쉬는 중이면 null. + private static double? DirectionStart(double s) => DirectionSide(s) switch + { + -1 => 0, + 1 => DirectionRightStart, + _ => null, + }; + + private static double DirectionLevel(double s) + { + if (DirectionStart(s) is not { } start) return 0; + double u = s - start; + // 소리가 시작할 때 한 프레임에 튀어 오르지 않게 짧게 키운다. + double attack = Math.Min(1, u / 0.06); + return attack * (0.45 + 0.3 * Math.Exp(-(u % DirectionBeatSec) / 0.2)); + } + + /// 쿵 하나: 짧게 올라 초 이어지다 초 간격으로 줄어든다. 는 친 뒤 흐른 시간. + private static double Thump(double u, double hold, double release) + { + if (u < 0) return 0; + if (u < hold) return Math.Min(1, u / 0.04); + return Math.Exp(-(u - hold) / release); + } + + /// 시각마다 한 번씩 크게 치고 0.25초 이어지다 사그라드는 소리. 가 첫 번째보다 앞이면 0. + private static double Hits(double u, double[] starts) + { + double last = -1; + foreach (double start in starts) + { + if (u >= start) last = start; + } + if (last < 0) return 0; + return Thump(u - last, hold: 0.25, release: 0.15); + } + } +} diff --git a/SoundVisualizer.Core/UiColors.cs b/SoundVisualizer.Core/UiColors.cs new file mode 100644 index 0000000..cdc7a7d --- /dev/null +++ b/SoundVisualizer.Core/UiColors.cs @@ -0,0 +1,78 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer +{ + /// + /// 화면 색(0xRRGGBB, 불투명). 앞의 여덟 색은 안드로이드 판 UiColors.kt 와 같은 값이다. + /// WPF 는 SoundVisualizer/Themes/Palette.xaml 에 같은 값을 붓(이름 + "Brush")으로 두고, 둘이 같은지는 테스트가 XAML 을 읽어 본다. + /// + /// 대비 기준(WCAG): 글자는 그 바탕 위에서 4.5:1, 스위치·슬라이더 막대와 초점 테두리 같은 그림 요소는 3:1 이상이다. + /// 파랑을 둘로 나눈 까닭(#312): 한 색으로는 흰 글자를 얹는 바탕과 어두운 바탕 위의 글자 모두에서 4.5:1 을 넘길 수 없다. + /// + public static class UiColors + { + /// 창 바탕. + public const uint Background = 0x2A2C31; + + /// 카드 바탕. + public const uint Card = 0x1E2024; + + /// 본 글자. 카드 위 14.8:1, 바탕 위 12.7:1. + public const uint PrimaryText = 0xF2F4F6; + + /// 설명·보조 글자. 카드 위 5.4:1, 바탕 위 4.6:1. 이 대비 때문에 설명을 13px 보다 작게 하지 않는다. + public const uint SecondaryText = 0x8B95A1; + + /// 어두운 바탕 위의 강조 글자·아이콘(슬라이더 값, 상태 점, 키보드 초점 테두리). 카드 위 5.9:1, 바탕 위 5.0:1. + public const uint Accent = 0x5B9BFF; + + /// 흰 글자를 얹는 강조 바탕(실행 버튼, 고른 칸)과 켠 스위치·슬라이더 막대. 흰 글자 5.2:1, 카드 위 3.2:1. + public const uint AccentFill = 0x2563EB; + + /// 실행 종료처럼 멈추거나 지우는 버튼의 바탕. 흰 글자 5.0:1. + public const uint Stop = 0xD32F2F; + + /// 안내·주의 글자. Stop 은 어두운 바탕에서 작은 글자로 읽기 어려워 밝은 주황을 쓴다. 카드 위 9.4:1. + public const uint Warning = 0xFFB74D; + + /// 강조 바탕(AccentFill, Stop) 위의 글자. + public const uint OnFill = 0xFFFFFF; + + /// + /// 조작 요소의 바탕: 고르지 않은 칸, 보조 버튼, 꺼진 스위치와 슬라이더의 빈 막대, 언어 콤보, 창 테두리. + /// 본 글자 10.4:1. 보조 글자(3.8:1)와 강조 글자(4.1:1)는 이 위에 두지 않는다. + /// + public const uint Control = 0x333A44; + + /// 마우스를 올린 조작 요소 바탕. 본 글자 8.6:1. + public const uint ControlHover = 0x3D4654; + + /// 누르고 있는 조작 요소 바탕. 본 글자 11.9:1. + public const uint ControlPressed = 0x2B313A; + + /// 마우스를 올린 강조 바탕. 흰 글자 6.7:1. + public const uint AccentFillHover = 0x1D4ED8; + + /// 누르고 있는 강조 바탕. 흰 글자 8.7:1. + public const uint AccentFillPressed = 0x1E40AF; + + /// 마우스를 올린 멈춤 버튼 바탕. 흰 글자 6.6:1. + public const uint StopHover = 0xB71C1C; + + /// 누르고 있는 멈춤 버튼 바탕. 흰 글자 8.3:1. + public const uint StopPressed = 0x9A1A1A; + + /// 쓸 수 없는 슬라이더 손잡이와 쓸 수 없는 꺼진 스위치 손잡이. 아래 셋과 함께 안드로이드 판의 비활성 색과 같다. + public const uint DisabledThumb = 0x6B7684; + + /// 쓸 수 없는 켠 스위치 손잡이. + public const uint DisabledCheckedThumb = 0xB0B8C1; + + /// 쓸 수 없는 슬라이더의 채운 막대와 켠 스위치 막대. + public const uint DisabledActiveTrack = 0x3A4351; + + /// 쓸 수 없는 슬라이더의 빈 막대와 꺼진 스위치 막대. + public const uint DisabledTrack = 0x2A3038; + } +} diff --git a/SoundVisualizer.Core/UiFontChain.cs b/SoundVisualizer.Core/UiFontChain.cs new file mode 100644 index 0000000..c82290d --- /dev/null +++ b/SoundVisualizer.Core/UiFontChain.cs @@ -0,0 +1,36 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Localization; + +namespace SoundVisualizer +{ + /// + /// 앱의 글꼴 순서. 모든 화면은 함께 배포하는 Pretendard 를 먼저 쓴다. + /// + /// Pretendard 에는 한글, 라틴(베트남어·터키어·폴란드어 포함), 키릴 문자, 일본어 가나가 있고 한자, 태국 문자, + /// 데바나가리, 아랍 문자는 없다. 없는 글자는 여기서 고른 Windows 글꼴로 채운다. 한자는 언어마다 모양이 달라서 + /// 간체·번체 중국어와 일본어는 각각 그 지역 글꼴을 먼저 둔다(한 목록에 섞으면 앞에 있는 지역의 모양으로 나온다). + /// + public static class UiFontChain + { + public const string Primary = "Pretendard"; + + /// 화면 언어 코드()에 맞는 대체 글꼴 순서. 모르는 코드는 라틴 글꼴 순서다. + public static IReadOnlyList FallbacksFor(string? languageCode) => UiLanguages.Find(languageCode)?.Code switch + { + "ko" => new[] { "Malgun Gothic", "Segoe UI" }, + "ja" => new[] { "Yu Gothic UI", "Meiryo UI", "Segoe UI" }, + "zh-Hans" => new[] { "Microsoft YaHei UI", "Segoe UI" }, + "zh-Hant" => new[] { "Microsoft JhengHei UI", "Segoe UI" }, + "th" => new[] { "Leelawadee UI", "Segoe UI" }, + "hi" => new[] { "Nirmala UI", "Segoe UI" }, + "ar" => new[] { "Segoe UI", "Malgun Gothic" }, // Segoe UI 에 아랍 문자가 있다 + _ => new[] { "Segoe UI", "Malgun Gothic" }, + }; + + /// WPF FontFamily 에 그대로 넘길 목록. 첫 항목은 앱에 포함한 Pretendard 다. + public static string FamilyListFor(string? languageCode, string primaryLocation = "./Fonts/#") + => primaryLocation + Primary + ", " + string.Join(", ", FallbacksFor(languageCode)); + } +} diff --git a/SoundVisualizer.Tests/Ai/AiCaptureInferenceGateTests.cs b/SoundVisualizer.Tests/Ai/AiCaptureInferenceGateTests.cs new file mode 100644 index 0000000..5af7486 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/AiCaptureInferenceGateTests.cs @@ -0,0 +1,124 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// Android 의 AiCaptureInferenceGateTest.kt 를 옮겼다. 뒤의 두 테스트는 Android 에서 후처리(AiPostProcessor)와 함께 도는데, +/// 후처리는 이 묶음 밖이라 게이트 쪽 확인만 옮겼다. 후처리를 옮기면 같은 흐름에 라벨 확인을 붙인다. +/// +public class AiCaptureInferenceGateTests +{ + /// Android 의 추론 간격(RealtimeAiPipeline.AI_PREDICT_INTERVAL_MS = 250ms)보다 100ms 느린 틱. + private const long SlowTickMs = 250 + 100; + + [Fact] + public void 소리가_든_창은_게이트가_열린_것을_보기_전에_링에_들어가_있다() + { + var buffer = new AiAudioBuffer(captureSampleRate: 16000, channels: 2); + var ingress = new AiCaptureInferenceGate(buffer); + var activeWindow = new float[CaptureAudioMath.RequiredMono16kSamples * 2]; + for (int index = 0; index < activeWindow.Length; index++) + { + activeWindow[index] = index % 2 == 0 ? 0.02f : 0f; + } + + ingress.IngestInterleaved(activeWindow, nowMs: 0); + + Assert.True(buffer.HasEnoughForYamnetWindow()); + Assert.True(ingress.IsInferenceOpen(0)); + } + + [Fact] + public void 소리_뒤의_무음도_링에_쌓이고_정리_뒤_새_소리에_다시_열린다() + { + var buffer = new AiAudioBuffer(captureSampleRate: 16000, channels: 2); + var ingress = new AiCaptureInferenceGate(buffer); + float[] active = { 0.02f, 0f }; + var silence = new float[CaptureAudioMath.RequiredMono16kSamples * 2]; + + ingress.IngestInterleaved(active, nowMs: 0); + ingress.IngestInterleaved(silence, nowMs: 1); + + Assert.True(buffer.HasEnoughForYamnetWindow()); + long firstSilentSnapshot = AiSilenceGate.YamnetWindowMs; + ingress.OnInferenceCompleted(firstSilentSnapshot); + Assert.True(ingress.IsInferenceOpen(firstSilentSnapshot + SlowTickMs)); + ingress.OnInferenceCompleted(firstSilentSnapshot + SlowTickMs); + Assert.False(ingress.IsInferenceOpen(firstSilentSnapshot + SlowTickMs)); + int samplesAfterLongSilence = buffer.AvailableSamples; + + long newActiveMs = firstSilentSnapshot + SlowTickMs + 1; + ingress.IngestInterleaved(active, nowMs: newActiveMs); + + Assert.True(ingress.IsInferenceOpen(newActiveMs)); + Assert.Equal(samplesAfterLongSilence + 1, buffer.AvailableSamples); + } + + [Fact] + public void 추론이_계속_실패하면_대비_시간이_지나_닫힌다() + { + var buffer = new AiAudioBuffer(captureSampleRate: 16000, channels: 2); + var ingress = new AiCaptureInferenceGate(buffer); + float[] active = { 0.02f, 0f }; + ingress.IngestInterleaved(active, nowMs: 0); + long firstFailedSnapshot = AiSilenceGate.YamnetWindowMs; + + ingress.OnInferenceFailed(firstFailedSnapshot); + + Assert.True(ingress.IsInferenceOpen(firstFailedSnapshot + AiSilenceGate.FailureFallbackMs - 1)); + Assert.False(ingress.IsInferenceOpen(firstFailedSnapshot + AiSilenceGate.FailureFallbackMs)); + } + + [Fact] + public void 조용한_추론이_두_번_끝나야_닫히고_새_소리에_다시_열린다() + { + // Android 의 completedSilentInferences_clearConfirmedSpeechBeforeGateCloses 의 게이트 쪽. + // Android 는 같은 흐름에서 확정된 대화음이 두 번째 조용한 추론에서 환경음으로 정리된 뒤에야 닫히는지 본다. + var buffer = new AiAudioBuffer(captureSampleRate: 16000, channels: 2); + var ingress = new AiCaptureInferenceGate(buffer); + float[] active = { 0.02f, 0f }; + + ingress.IngestInterleaved(active, nowMs: 0); + + long firstSilentSnapshot = AiSilenceGate.YamnetWindowMs; + ingress.OnInferenceCompleted(firstSilentSnapshot); + Assert.True(ingress.IsInferenceOpen(firstSilentSnapshot + SlowTickMs)); + + ingress.OnInferenceCompleted(firstSilentSnapshot + SlowTickMs); + Assert.False(ingress.IsInferenceOpen(firstSilentSnapshot + SlowTickMs)); + + long newActiveMs = firstSilentSnapshot + SlowTickMs + 1; + ingress.IngestInterleaved(active, nowMs: newActiveMs); + Assert.True(ingress.IsInferenceOpen(newActiveMs)); + } + + [Fact] + public void 정리가_끝나_닫힌_게이트는_새_소리의_창을_따로_연다() + { + // Android 의 speechCandidatesSeparatedBySilentCleanup_doNotCombineAcrossClosedGate 의 게이트 쪽. + // Android 는 닫히기 전과 뒤의 대화음 후보가 이어 세어지지 않는지(후보 연속 1회)도 본다. + var buffer = new AiAudioBuffer(captureSampleRate: 16000, channels: 2); + var ingress = new AiCaptureInferenceGate(buffer); + float[] active = { 0.02f, 0f }; + + ingress.IngestInterleaved(active, nowMs: 0); + + long firstSilentSnapshot = AiSilenceGate.YamnetWindowMs; + for (int index = 0; index < AiSilenceGate.RequiredSilentInferenceCompletions; index++) + { + ingress.OnInferenceCompleted(firstSilentSnapshot + index * SlowTickMs); + } + Assert.False(ingress.IsInferenceOpen(firstSilentSnapshot + SlowTickMs)); + + long newActiveMs = firstSilentSnapshot + SlowTickMs + 1; + ingress.IngestInterleaved(active, nowMs: newActiveMs); + + Assert.True(ingress.IsInferenceOpen(newActiveMs)); + // 새 소리가 창을 빠져나간 뒤에도 조용한 추론 두 번을 처음부터 다시 센다. + ingress.OnInferenceCompleted(newActiveMs + AiSilenceGate.YamnetWindowMs); + Assert.True(ingress.IsInferenceOpen(newActiveMs + AiSilenceGate.YamnetWindowMs + SlowTickMs)); + } +} diff --git a/SoundVisualizer.Tests/Ai/AiCaptureSampleRatePolicyTests.cs b/SoundVisualizer.Tests/Ai/AiCaptureSampleRatePolicyTests.cs new file mode 100644 index 0000000..d5034f1 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/AiCaptureSampleRatePolicyTests.cs @@ -0,0 +1,136 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// Android 의 AiCaptureSampleRatePolicyTest.kt 를 옮겼다. Android 가 "AI 가 못 받는 레이트" 의 예로 쓰는 88.2~192kHz 를 +/// PC 는 받으므로, 그 자리에는 PC 에서도 여전히 못 받는 레이트를 넣었다. PC 고레이트 자체의 확인은 뒤쪽에 따로 둔다. +/// +public class AiCaptureSampleRatePolicyTests +{ + public static TheoryData UnsupportedRates => new() { 8000, 11025, 22050, 24000, 32000, 352800, 384000 }; + + public static TheoryData PcHighRates => new() { 88200, 96000, 176400, 192000 }; + + [Theory] + [MemberData(nameof(UnsupportedRates))] + public void AI가_못_받는_레이트가_보고되면_받을_수_있는_레이트를_먼저_고른다(int reported) + { + Assert.Equal(new[] { 48000, 44100, reported }, AiCaptureSampleRatePolicy.OrderedCaptureCandidates(reported)); + + int selected = AiCaptureSampleRatePolicy.SelectCaptureRate(reported, rate => rate == reported || rate == 48000); + + Assert.Equal(48000, selected); + Assert.True(AiCaptureSampleRatePolicy.IsSupportedForAi(selected)); + } + + [Theory] + [MemberData(nameof(UnsupportedRates))] + public void 받을_수_있는_레이트가_없으면_보고된_레이트로_화면만_돈다(int reported) + { + int selected = AiCaptureSampleRatePolicy.SelectCaptureRate(reported, rate => rate == reported); + + Assert.Equal(reported, selected); + Assert.False(AiCaptureSampleRatePolicy.IsSupportedForAi(selected)); + } + + [Fact] + public void AI가_받는_레이트가_보고되면_그_레이트를_먼저_쓴다() + { + Assert.Equal(new[] { 44100, 48000 }, AiCaptureSampleRatePolicy.OrderedCaptureCandidates(44100)); + Assert.Equal(new[] { 48000, 44100 }, AiCaptureSampleRatePolicy.OrderedCaptureCandidates(48000)); + } + + [Theory] + [InlineData(16000)] + [InlineData(44100)] + [InlineData(48000)] + public void 안드로이드가_받는_레이트는_고정_크기_링에_들어간다(int rate) + { + Assert.True(AiCaptureSampleRatePolicy.IsSupportedForAi(rate)); + Assert.True(CaptureAudioMath.CaptureSamplesForOneYamnetWindow(rate) <= AiAudioBuffer.MaxRingSize); + + var buffer = new AiAudioBuffer(captureSampleRate: rate, channels: 2); + + Assert.Equal(AiAudioBuffer.MaxRingSize, buffer.RingCapacity); + } + + [Fact] + public void 고정_크기_링은_176_4kHz와_192kHz의_한_창도_담지_못한다() + { + // Android 는 그래서 이 레이트를 막았다. PC 는 링을 늘려 받는다(아래 PC 고레이트 테스트). + Assert.True(CaptureAudioMath.CaptureSamplesForOneYamnetWindow(176400) > AiAudioBuffer.MaxRingSize); + Assert.True(CaptureAudioMath.CaptureSamplesForOneYamnetWindow(192000) > AiAudioBuffer.MaxRingSize); + } + + [Theory] + [MemberData(nameof(UnsupportedRates))] + public void 검증하지_않은_레이트는_AI_링에_들어오기_전에_거절한다(int rate) + { + Assert.False(AiCaptureSampleRatePolicy.IsSupportedForAi(rate)); + Assert.Throws(() => new AiAudioBuffer(captureSampleRate: rate, channels: 2)); + } + + [Fact] + public void 보고된_레이트가_없으면_48kHz와_44_1kHz만_시도하고_아무것도_안_되면_48kHz다() + { + Assert.Equal(new[] { 48000, 44100 }, AiCaptureSampleRatePolicy.OrderedCaptureCandidates(null)); + Assert.Equal(new[] { 48000, 44100 }, AiCaptureSampleRatePolicy.OrderedCaptureCandidates(0)); + Assert.Equal(new[] { 48000, 44100 }, AiCaptureSampleRatePolicy.OrderedCaptureCandidates(-1)); + Assert.Equal(AiCaptureSampleRatePolicy.DefaultCaptureSampleRate, AiCaptureSampleRatePolicy.SelectCaptureRate(null, _ => false)); + } + + // PC 에서 더한 것: 88.2·96·176.4·192kHz 도 AI 가 받는다. + + [Theory] + [MemberData(nameof(PcHighRates))] + public void PC_고레이트는_AI가_받고_보고된_레이트를_먼저_쓴다(int reported) + { + Assert.True(AiCaptureSampleRatePolicy.IsSupportedForAi(reported)); + Assert.Equal(new[] { reported, 48000, 44100 }, AiCaptureSampleRatePolicy.OrderedCaptureCandidates(reported)); + Assert.Equal(reported, AiCaptureSampleRatePolicy.SelectCaptureRate(reported, _ => true)); + } + + [Theory] + [InlineData(88200, 262144)] + [InlineData(96000, 262144)] + [InlineData(176400, 524288)] + [InlineData(192000, 524288)] + public void PC_고레이트는_링을_늘려_한_창과_2초를_담는다(int rate, int expectedCapacity) + { + var buffer = new AiAudioBuffer(captureSampleRate: rate, channels: 2); + + Assert.Equal(expectedCapacity, buffer.RingCapacity); + Assert.True(CaptureAudioMath.CaptureSamplesForOneYamnetWindow(rate) <= buffer.RingCapacity); + Assert.True(rate * AiAudioBuffer.RingSeconds <= buffer.RingCapacity); + } + + [Fact] + public void PC_192kHz_링은_2초만_남기고_최근_한_창을_순서대로_돌려준다() + { + const int rate = 192000; + var buffer = new AiAudioBuffer(captureSampleRate: rate, channels: 1); + int total = rate * 3; // 링(524,288)을 한 바퀴 넘게 돈다 + var chunk = new float[rate / 100]; // 10ms 씩 + for (int written = 0; written < total; written += chunk.Length) + { + for (int i = 0; i < chunk.Length; i++) chunk[i] = written + i; + buffer.IngestInterleaved(chunk); + } + + Assert.Equal(rate * AiAudioBuffer.RingSeconds, buffer.AvailableSamples); + Assert.True(buffer.HasEnoughForYamnetWindow()); + + var window = new float[CaptureAudioMath.CaptureSamplesForOneYamnetWindow(rate)]; + int available = buffer.CopyTailRightPadded(window); + + Assert.Equal(rate * AiAudioBuffer.RingSeconds, available); + for (int i = 0; i < window.Length; i++) + { + Assert.Equal((float)(total - window.Length + i), window[i]); + } + } +} diff --git a/SoundVisualizer.Tests/Ai/AiClassificationStageTests.cs b/SoundVisualizer.Tests/Ai/AiClassificationStageTests.cs new file mode 100644 index 0000000..dbff2ac --- /dev/null +++ b/SoundVisualizer.Tests/Ai/AiClassificationStageTests.cs @@ -0,0 +1,186 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Diagnostics; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// 확률 → 확정 결과까지 이은 단계(모바일 RealtimeAiPipeline 의 모델 뒤쪽). 첫 테스트는 모바일 계측 테스트 +/// runtimeMappingChangeInvalidatesPreviousResultAndResetsConfirmation 을 모델 없이 고정 확률로 옮긴 것이다. +/// +public class AiClassificationStageTests +{ + private static readonly YamnetClassMap Names = AiTestData.ClassMap; + + [Fact] // runtimeMappingChangeInvalidatesPreviousResultAndResetsConfirmation + public void 실행_중_매핑이_바뀌면_옛_결과를_바로_숨기고_확정을_처음부터_다시_한다() + { + var stage = new AiClassificationStage(Names); + var probs = AiTestData.LoadCp4Probs("speech"); // Speech .75, Conversation .19, Music .06 + + IReadOnlyDictionary overrides = Names.ToDictionary(name => name, _ => "danger"); + Assert.True(stage.UpdateMapping(overrides)); + var danger = stage.Process(probs); + Assert.Equal(SoundType.Danger, danger.Coarse); + Assert.Equal(521 - 37, danger.MappingOverrideCount); + Assert.Same(danger, stage.LastClassification(overrides)); + + overrides = Names.ToDictionary(name => name, _ => "ambient"); + Assert.Null(stage.LastClassification(overrides)); // 다음 추론을 기다리지 않고 바로 사라진다. + Assert.True(stage.UpdateMapping(overrides)); + var ambient = stage.Process(probs); + Assert.Equal(SoundType.Ambient, ambient.Coarse); + Assert.Equal(YamnetMappingPolicy.From(overrides, Names).Signature, ambient.MappingSignature); + + overrides = new Dictionary(); + Assert.Null(stage.LastClassification(overrides)); + Assert.True(stage.UpdateMapping(overrides)); + Assert.Equal("default", stage.Process(probs).MappingSignature); + } + + [Fact] + public void 무음으로_건너뛴_틱에_바뀐_매핑도_다음_추론에_옛_위협음을_넘기지_않는다() + { + var stage = new AiClassificationStage(Names); + var gunshot = AiTestData.LoadCp4Probs("danger"); // Gunshot .67, Explosion .27, Music .07 + var none = new Dictionary(); + stage.UpdateMapping(none); + Assert.Equal(SoundType.Danger, stage.Process(gunshot).Coarse); + + // 무음 틱: 추론은 건너뛰어도 매핑은 갱신한다. + var demoted = new Dictionary { ["Gunshot, gunfire"] = "ambient", ["Explosion"] = "ambient" }; + Assert.True(stage.UpdateMapping(demoted)); + Assert.Null(stage.LastClassification(demoted)); + + var next = stage.Process(gunshot); + Assert.Equal(SoundType.Ambient, next.Coarse); + Assert.Equal("Gunshot, gunfire", next.Display); // 이름은 모델이 들은 그대로다. 종류만 사용자 지정을 따른다. + Assert.False(next.DangerCuePromoted); + Assert.Equal("Explosion=ambient;Gunshot, gunfire=ambient", next.MappingSignature); + } + + [Fact] + public void 서명이_같은_설정_변화는_확정을_유지하고_옛_결과는_다음_추론까지만_숨긴다() + { + var stage = new AiClassificationStage(Names); + stage.UpdateMapping(new Dictionary()); + Assert.Equal(SoundType.Danger, stage.Process(AiTestData.LoadCp4Probs("danger")).Coarse); + + // 기본 종류와 같은 값과 잘못된 값뿐이라 실제 매핑은 그대로다. + var noOp = new Dictionary { ["Gunshot, gunfire"] = "danger", ["Music"] = "invalid" }; + Assert.Null(stage.LastClassification(noOp)); + Assert.False(stage.UpdateMapping(noOp)); + Assert.Same(YamnetMappingPolicy.Default, stage.Mapping); + + // 확정을 지우지 않았으므로 0.27 미만의 환경음 한 프레임으로는 위협음이 풀리지 않는다. + var weakAmbient = AiTestData.Probabilities( + (Names.IndexOf("Wind"), .26f), (Names.IndexOf("Noise"), .25f), (Names.IndexOf("Music"), .24f)); + var kept = stage.Process(weakAmbient); + Assert.Equal(SoundType.Danger, kept.Coarse); + Assert.True(kept.MeetsThreshold); + Assert.Same(kept, stage.LastClassification(noOp)); + } + + [Fact] + public void 같은_설정을_다시_넘기면_아무것도_바꾸지_않는다() + { + var stage = new AiClassificationStage(Names); + var overrides = new Dictionary { ["Siren"] = "ambient" }; + Assert.True(stage.UpdateMapping(overrides)); + var mapping = stage.Mapping; + var result = stage.Process(AiTestData.LoadCp4Probs("speech")); + + Assert.False(stage.UpdateMapping(new Dictionary { ["Siren"] = "ambient" })); + Assert.Same(mapping, stage.Mapping); + Assert.Same(result, stage.LastClassification(overrides)); + + // 입력은 복사해 두므로 설정 쪽 맵이 바뀌면 옛 결과는 숨는다. + overrides["Siren"] = "speech"; + Assert.Null(stage.LastClassification(overrides)); + } + + [Fact] + public void 경보_샘플은_승인한_경적_단서로_첫_프레임에_위협음이_된다() + { + var stage = new AiClassificationStage(Names); + + var result = stage.Process(AiTestData.LoadCp4Probs("alarm")); + + // 투표도 위협음이지만 확신도 0.14 는 기본 기준 0.25 아래다. 상위 5개의 Air horn 단서로 올려 기준이 0.12 로 내려간다. + Assert.Equal(SoundType.Danger, result.Coarse); + Assert.Equal("Air horn, truck horn", result.Display); + Assert.True(result.DangerCuePromoted); + Assert.True(result.MeetsThreshold); + Assert.Equal(0.13773442804813385f, result.Confidence, 1e-6f); + } + + [Fact] + public void 투표가_환경음이어도_상위_5개의_경보_단서는_위협음으로_올린다() + { + var stage = new AiClassificationStage(Names); + + var result = stage.Process(AiTestData.LoadCp4Probs("gunshot")); // Ding .12, Sound effect .11, Alarm .07, … + + Assert.Equal(SoundType.Danger, result.Coarse); + Assert.Equal("Alarm", result.Display); + Assert.True(result.DangerCuePromoted); + Assert.Equal(0.12252969294786453f, result.Confidence, 1e-6f); // 단서 확률이 아니라 1위 확신도를 그대로 쓴다. + } + + [Fact] + public void 기준을_못_넘으면_처음_상태가_그대로_남는다() + { + var stage = new AiClassificationStage(Names); + + // 효과음에 가려진 총소리를 표시 이름으로 고르지만, 총기 이름은 안전 단서로 올리지 않고 환경음 투표의 확신도 0.17 은 기준 아래다. + var result = stage.Process(AiTestData.LoadCp4Probs("game_mix")); + + Assert.Equal(SoundType.Ambient, result.Coarse); + Assert.Equal("", result.Display); + Assert.Equal(0f, result.Confidence); + Assert.False(result.MeetsThreshold); + Assert.False(result.DangerCuePromoted); + Assert.Equal("Sound effect", result.Top5[0].Name); + } + + [Fact] + public void 결과에_시간과_상위_5개와_매핑을_담는다() + { + var stage = new AiClassificationStage(Names); + long before = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long started = Stopwatch.GetTimestamp(); + + var result = stage.Process(AiTestData.LoadCp4Probs("silence"), started, preprocessMs: 1.5, yamnetMs: 2.5); + + long after = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + Assert.InRange(result.TimestampMs, before, after); + Assert.Equal(1.5, result.PreprocessMs); + Assert.Equal(2.5, result.YamnetMs); + Assert.True(result.TotalMs >= 0); + Assert.Equal(SoundType.Ambient, result.Coarse); + Assert.Equal("Silence", result.Display); + Assert.Equal(5, result.Top5.Count); + Assert.Equal(0, result.MappingOverrideCount); + Assert.Equal("default", result.MappingSignature); + } + + [Fact] + public void Reset_은_확정과_마지막_결과를_지우고_매핑은_남긴다() + { + var stage = new AiClassificationStage(Names); + var overrides = new Dictionary { ["Wind"] = "speech" }; + stage.UpdateMapping(overrides); + stage.Process(AiTestData.LoadCp4Probs("danger")); + + stage.Reset(); + + Assert.Null(stage.LastClassification(overrides)); + Assert.Equal("Wind=speech", stage.Mapping.Signature); + Assert.False(stage.UpdateMapping(overrides)); + var weakAmbient = AiTestData.Probabilities( + (Names.IndexOf("Rain"), .26f), (Names.IndexOf("Noise"), .25f), (Names.IndexOf("Music"), .24f)); + Assert.Equal(SoundType.Ambient, stage.Process(weakAmbient).Coarse); + } +} diff --git a/SoundVisualizer.Tests/Ai/AiPostProcessorTests.cs b/SoundVisualizer.Tests/Ai/AiPostProcessorTests.cs new file mode 100644 index 0000000..3967722 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/AiPostProcessorTests.cs @@ -0,0 +1,175 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Text.Json; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// 모바일 AiPostProcessorTest 를 옮기고, 히스테리시스 골든(yamnet_cp6_meta.json)과 기준 경계를 더했다. +public class AiPostProcessorTests +{ + private static AiPostProcessor.FrameInput Frame(SoundType coarse, string display, float confidence, + bool promoted = false, bool strong = false, bool critical = false) => + new(coarse, display, confidence, DangerCuePromoted: promoted, HasStrongDangerCue: strong, HasCriticalDangerCue: critical); + + [Fact] // speech requires two frames + public void 대화음은_두_프레임이_필요하다() + { + var pp = new AiPostProcessor(); + pp.Process(Frame(SoundType.Ambient, "Wind", .4f)); + Assert.Equal(SoundType.Ambient, pp.Process(Frame(SoundType.Speech, "Speech", .4f)).ConfirmedCoarse); + Assert.Equal(SoundType.Speech, pp.Process(Frame(SoundType.Speech, "Speech", .4f)).ConfirmedCoarse); + } + + [Fact] // danger switches immediately at configured confidence + public void 위협음은_정한_확신도에서_바로_바뀐다() + { + var result = new AiPostProcessor().Process(Frame(SoundType.Danger, "Siren", .28f)); + Assert.Equal(SoundType.Danger, result.UiCoarse); + } + + [Fact] // promoted strong cue uses documented threshold + public void 안전_단서로_올린_위협음은_문서의_기준을_쓴다() + { + var pp = new AiPostProcessor(); + Assert.False(pp.Process(Frame(SoundType.Danger, "Smoke detector, smoke alarm", .119f, promoted: true, strong: true)).MeetsThreshold); + + var confirmed = pp.Process(Frame(SoundType.Danger, "Smoke detector, smoke alarm", .12f, promoted: true, strong: true)); + + Assert.True(confirmed.MeetsThreshold); + Assert.Equal(SoundType.Danger, confirmed.UiCoarse); + } + + [Fact] // thresholds have no Booster-specific path + public void 기준에_Booster_전용_경로가_없다() + { + Assert.Equal(.25f, AiPostProcessor.ComputeEffectiveThreshold(SoundType.Danger, .25f, false, false)); + Assert.Equal(.20f, AiPostProcessor.ComputeEffectiveThreshold(SoundType.Danger, .25f, true, false)); + Assert.Equal(.12f, AiPostProcessor.ComputeEffectiveThreshold(SoundType.Danger, .25f, true, false, true)); + } + + [Fact] // water remains ambient + public void 물소리는_환경음으로_남는다() + { + var result = new AiPostProcessor().Process(Frame(SoundType.Ambient, "Rain", 1f)); + Assert.Equal(SoundType.Ambient, result.UiCoarse); + } + + // 아래는 모바일 JVM 테스트에 없는 확인이다. + + /// + /// Python 기준 구현이 만든 후처리 골든. 모바일은 Booster 를 없앤 뒤 이 파일을 더는 돌리지 않는다. Booster 입력이 든 사례는 + /// 없앤 기준(0.18)을 기대하므로 빼고, 나머지 8개 사례는 지금 규칙과 프레임마다 모든 값이 같아야 한다. + /// + [Fact] + public void Booster_없는_히스테리시스_골든_사례와_프레임마다_같다() + { + var cases = AiTestData.LoadJson("yamnet_cp6_meta.json").EnumerateObject() + .Where(c => c.Value.GetProperty("frames").EnumerateArray() + .All(f => !f.GetProperty("input").GetProperty("adopted_danger_from_booster").GetBoolean())) + .ToList(); + Assert.Equal( + new[] { "A_stable_ambient", "B_ambient_to_speech", "C_ambient_to_danger_streak1", "D_immediate_danger_conf", + "F_low_confidence", "G_candidate_reset", "H_danger_exit", "H2_danger_exit_needs_two" }, + cases.Select(c => c.Name)); + + Assert.All(cases, c => + { + var pp = new AiPostProcessor(); + foreach (var frame in c.Value.GetProperty("frames").EnumerateArray()) + { + var input = frame.GetProperty("input"); + Assert.True(SoundTypeLabels.TryParse(input.GetProperty("coarse").GetString(), out var coarse)); + var actual = pp.Process(new AiPostProcessor.FrameInput( + coarse, + input.GetProperty("display").GetString()!, + (float)input.GetProperty("confidence").GetDouble(), + HasStrongDangerCue: input.GetProperty("has_strong_danger_cue").GetBoolean(), + HasCriticalDangerCue: input.GetProperty("has_critical_danger_cue").GetBoolean(), + TopKSummary: input.GetProperty("top_k_summary").GetString()!)); + + string at = $"{c.Name} frame {frame.GetProperty("frame").GetInt32()}\nexpected {frame.GetRawText()}\nactual {actual}"; + Assert.True((float)frame.GetProperty("effective_threshold").GetDouble() == actual.EffectiveThreshold, at); + Assert.True(frame.GetProperty("meets_threshold").GetBoolean() == actual.MeetsThreshold, at); + Assert.True(frame.GetProperty("candidate_coarse").GetString() == (actual.CandidateCoarse?.ToLabel() ?? ""), at); + Assert.True(frame.GetProperty("candidate_streak").GetInt32() == actual.CandidateStreak, at); + Assert.True(frame.GetProperty("confirmed_coarse").GetString() == actual.ConfirmedCoarse.ToLabel(), at); + Assert.True(frame.GetProperty("confirmed_display").GetString() == actual.ConfirmedDisplay, at); + Assert.True((float)frame.GetProperty("confirmed_confidence").GetDouble() == actual.ConfirmedConfidence, at); + Assert.True(frame.GetProperty("ui_coarse").GetString() == actual.UiCoarse.ToLabel(), at); + Assert.True(frame.GetProperty("ui_display").GetString() == actual.UiDisplay, at); + Assert.True((float)frame.GetProperty("ui_confidence").GetDouble() == actual.UiConfidence, at); + } + }); + } + + [Fact] + public void 기준을_못_넘은_프레임은_확정_라벨을_건드리지_않는다() + { + var pp = new AiPostProcessor(); + pp.Process(Frame(SoundType.Speech, "Speech", .4f)); + pp.Process(Frame(SoundType.Speech, "Speech", .4f)); + + var low = pp.Process(Frame(SoundType.Danger, "Siren", .19f)); + + Assert.False(low.MeetsThreshold); + Assert.Equal(SoundType.Speech, low.ConfirmedCoarse); + Assert.Equal("Speech", low.ConfirmedDisplay); + Assert.Equal(.4f, low.ConfirmedConfidence); + Assert.Null(low.CandidateCoarse); + } + + [Fact] + public void 기준_경계값() + { + var pp = new AiPostProcessor(); + // 대화음 0.25 미만은 못 넘고, 0.25 부터 두 번 이어지면 확정한다. + Assert.False(pp.Process(Frame(SoundType.Speech, "Speech", .2499f)).MeetsThreshold); + var first = pp.Process(Frame(SoundType.Speech, "Speech", .25f)); + Assert.True(first.MeetsThreshold); + Assert.Equal(SoundType.Speech, first.CandidateCoarse); + Assert.Equal(1, first.CandidateStreak); + Assert.Equal(SoundType.Speech, pp.Process(Frame(SoundType.Speech, "Speech", .25f)).ConfirmedCoarse); + + // 강한 단서가 있는 위협음은 0.20 부터, 첫 프레임에 확정한다. + Assert.Equal(SoundType.Speech, pp.Process(Frame(SoundType.Danger, "Siren", .1999f, strong: true)).ConfirmedCoarse); + Assert.Equal(SoundType.Danger, pp.Process(Frame(SoundType.Danger, "Siren", .2f, strong: true)).ConfirmedCoarse); + + // 위협음에서 벗어날 때 0.27 미만이면 두 프레임, 0.27 이상이면 한 프레임. + Assert.Equal(SoundType.Danger, pp.Process(Frame(SoundType.Ambient, "Wind", .2699f)).ConfirmedCoarse); + Assert.Equal(SoundType.Ambient, pp.Process(Frame(SoundType.Ambient, "Wind", .2699f)).ConfirmedCoarse); + Assert.Equal(SoundType.Danger, pp.Process(Frame(SoundType.Danger, "Gunshot, gunfire", .4f, critical: true)).ConfirmedCoarse); + Assert.Equal(SoundType.Ambient, pp.Process(Frame(SoundType.Ambient, "Wind", .27f)).ConfirmedCoarse); + } + + [Fact] + public void Reset_은_처음_상태로_돌린다() + { + var pp = new AiPostProcessor(); + pp.Process(Frame(SoundType.Speech, "Speech", .4f)); + pp.Process(Frame(SoundType.Speech, "Speech", .4f)); + var pending = pp.Process(Frame(SoundType.Ambient, "Wind", .4f)); + Assert.Equal(SoundType.Speech, pending.ConfirmedCoarse); + Assert.Equal(SoundType.Ambient, pending.CandidateCoarse); + + pp.Reset(); + var afterReset = pp.Process(Frame(SoundType.Speech, "Speech", 0f)); + + Assert.Equal(SoundType.Ambient, afterReset.ConfirmedCoarse); + Assert.Equal("", afterReset.ConfirmedDisplay); + Assert.Equal(0f, afterReset.ConfirmedConfidence); + Assert.Null(afterReset.CandidateCoarse); + Assert.Equal(0, afterReset.CandidateStreak); + } + + [Fact] + public void 이름_없이_만든_입력도_확정_이름은_null_이_되지_않는다() + { + // record struct 의 매개변수 없는 생성자는 이름을 null 로 둔다. 화면이 확정 이름을 그대로 쓰므로 빈 문자열로 받는다. + var result = new AiPostProcessor().Process(default(AiPostProcessor.FrameInput) with { Confidence = 1f }); + + Assert.True(result.MeetsThreshold); + Assert.Equal("", result.ConfirmedDisplay); + } +} diff --git a/SoundVisualizer.Tests/Ai/AiSilenceGateTests.cs b/SoundVisualizer.Tests/Ai/AiSilenceGateTests.cs new file mode 100644 index 0000000..3bbaca1 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/AiSilenceGateTests.cs @@ -0,0 +1,151 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// Android 의 AiSilenceGateTest.kt 를 옮겼다. +public class AiSilenceGateTests +{ + /// Android 의 추론 간격(RealtimeAiPipeline.AI_PREDICT_INTERVAL_MS = 250ms)보다 100ms 느린 틱. + private const long SlowTickMs = 250 + 100; + + [Fact] + public void 긴_무음과_기준값_크기의_잡음은_게이트를_열지_않는다() + { + var gate = new AiSilenceGate(); + float[] quiet = { 0.01f, -0.01f, 0.005f, -0.005f }; + + gate.OnInterleavedPcm(quiet, nowMs: 0); + + Assert.False(gate.IsOpen(0)); + Assert.False(gate.IsOpen(10_000)); + } + + [Fact] + public void 느린_추론에서도_완전히_조용한_추론이_두_번_끝날_때까지_열려_있다() + { + var gate = new AiSilenceGate(); + gate.OnInterleavedPcm(new[] { 0.0101f, 0f }, nowMs: 100); + + Assert.True(gate.IsOpen(100)); + long firstSilentSnapshot = 100 + AiSilenceGate.YamnetWindowMs; + Assert.True(gate.IsOpen(firstSilentSnapshot + 10_000)); + + gate.OnInferenceCompleted(firstSilentSnapshot); + Assert.True(gate.IsOpen(firstSilentSnapshot + SlowTickMs)); + + gate.OnInferenceCompleted(firstSilentSnapshot + SlowTickMs); + Assert.False(gate.IsOpen(firstSilentSnapshot + SlowTickMs)); + } + + [Fact] + public void 소리가_창을_빠져나가기_전에_끝난_추론은_게이트를_닫지_않는다() + { + var gate = new AiSilenceGate(); + gate.OnInterleavedPcm(new[] { 0.02f, 0f }, nowMs: 0); + + for (int i = 0; i < AiSilenceGate.RequiredSilentInferenceCompletions; i++) + { + gate.OnInferenceCompleted(AiSilenceGate.YamnetWindowMs - 1); + } + + Assert.True(gate.IsOpen(AiSilenceGate.YamnetWindowMs + 10_000)); + } + + [Fact] + public void 위상이_반대인_짧은_소리도_원래_채널로_게이트를_연다() + { + var gate = new AiSilenceGate(); + // 섞으면 0 이지만, 어느 한 채널의 최고값으로 추론을 다시 연다. + gate.OnInterleavedPcm(new[] { 0.02f, -0.02f }, nowMs: 200); + + Assert.True(gate.IsOpen(200)); + } + + [Fact] + public void 닫힌_게이트는_새_소리가_오면_바로_다시_열린다() + { + var gate = new AiSilenceGate(); + + gate.OnInterleavedPcm(new[] { 0.02f, 0f }, nowMs: 0); + for (int index = 0; index < AiSilenceGate.RequiredSilentInferenceCompletions; index++) + { + gate.OnInferenceCompleted(AiSilenceGate.YamnetWindowMs + index * SlowTickMs); + } + Assert.False(gate.IsOpen(AiSilenceGate.YamnetWindowMs + SlowTickMs)); + + long newActiveMs = AiSilenceGate.YamnetWindowMs + SlowTickMs + 1; + gate.OnInterleavedPcm(new[] { 0.02f, 0f }, nowMs: newActiveMs); + + Assert.True(gate.IsOpen(newActiveMs)); + Assert.True(gate.IsOpen(newActiveMs + AiSilenceGate.YamnetWindowMs + 10_000)); + + for (int index = 0; index < AiSilenceGate.RequiredSilentInferenceCompletions; index++) + { + gate.OnInferenceCompleted(newActiveMs + AiSilenceGate.YamnetWindowMs + index * SlowTickMs); + } + Assert.False(gate.IsOpen(newActiveMs + AiSilenceGate.YamnetWindowMs + SlowTickMs)); + } + + [Fact] + public void 추론이_계속_실패하면_결국_닫힌다() + { + var gate = new AiSilenceGate(); + gate.OnInterleavedPcm(new[] { 0.02f, 0f }, nowMs: 0); + long firstFailedSnapshot = AiSilenceGate.YamnetWindowMs; + + gate.OnInferenceFailed(firstFailedSnapshot); + + Assert.True(gate.IsOpen(firstFailedSnapshot + AiSilenceGate.FailureFallbackMs - 1)); + gate.OnInferenceFailed(firstFailedSnapshot + AiSilenceGate.FailureFallbackMs - 1); + Assert.False(gate.IsOpen(firstFailedSnapshot + AiSilenceGate.FailureFallbackMs)); + + long newActiveMs = firstFailedSnapshot + AiSilenceGate.FailureFallbackMs + 1; + gate.OnInterleavedPcm(new[] { 0.02f, 0f }, nowMs: newActiveMs); + Assert.True(gate.IsOpen(newActiveMs + AiSilenceGate.YamnetWindowMs + 10_000)); + } + + [Fact] + public void 성공한_추론은_실패_대비를_지우되_정리를_줄이지_않는다() + { + var gate = new AiSilenceGate(); + gate.OnInterleavedPcm(new[] { 0.02f, 0f }, nowMs: 0); + long firstSilentSnapshot = AiSilenceGate.YamnetWindowMs; + + gate.OnInferenceFailed(firstSilentSnapshot); + gate.OnInferenceCompleted(firstSilentSnapshot + SlowTickMs); + + Assert.True(gate.IsOpen(firstSilentSnapshot + AiSilenceGate.FailureFallbackMs + 10_000)); + long secondSuccessfulSnapshot = firstSilentSnapshot + AiSilenceGate.FailureFallbackMs + 10_001; + gate.OnInferenceCompleted(secondSuccessfulSnapshot); + Assert.False(gate.IsOpen(secondSuccessfulSnapshot)); + } + + [Fact] + public void Reset_은_소리_완료_실패_상태를_모두_지운다() + { + var gate = new AiSilenceGate(); + gate.OnInterleavedPcm(new[] { 0.02f, 0f }, nowMs: 0); + gate.OnInferenceFailed(AiSilenceGate.YamnetWindowMs); + + gate.Reset(); + + Assert.False(gate.IsOpen(AiSilenceGate.YamnetWindowMs + 10_000)); + long newActiveMs = AiSilenceGate.YamnetWindowMs + 10_001; + gate.OnInterleavedPcm(new[] { 0.02f, 0f }, nowMs: newActiveMs); + Assert.True(gate.IsOpen(newActiveMs + AiSilenceGate.YamnetWindowMs + 10_000)); + } + + [Fact] + public void 정리_기준은_YAMNet_창과_종류_히스테리시스_상수를_따른다() + { + // 15,600샘플 ÷ 16kHz = 975ms. Android 는 AudioPreprocessor 의 상수로 같은 값을 만든다. + Assert.Equal(15600, CaptureAudioMath.RequiredMono16kSamples); + Assert.Equal(16000, CaptureAudioMath.TargetSampleRate); + Assert.Equal(975L, AiSilenceGate.YamnetWindowMs); + // Android 의 AiPostProcessor.COARSE_HYSTERESIS_THRESHOLD. 후처리를 옮기면 그 상수와 직접 비교한다. + Assert.Equal(2, AiSilenceGate.RequiredSilentInferenceCompletions); + } +} diff --git a/SoundVisualizer.Tests/Ai/AiTestData.cs b/SoundVisualizer.Tests/Ai/AiTestData.cs new file mode 100644 index 0000000..0669090 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/AiTestData.cs @@ -0,0 +1,72 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Buffers.Binary; +using System.Globalization; +using System.Text.Json; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// AI 테스트가 같이 쓰는 기준 데이터. 테스트 출력 폴더의 Ai/Reference(모바일 app/src/test/resources/ai_reference 와 같은 파일)와 +/// Models/yamnet_class_map.csv(앱이 쓰는 파일, 모바일 assets 와 같은 내용)를 읽는다. +/// +internal static class AiTestData +{ + private static readonly Lazy RealClassMap = new(() => YamnetClassMap.Load(ClassMapPath)); + + public static string ClassMapPath => Path.Combine(AppContext.BaseDirectory, "Models", "yamnet_class_map.csv"); + + /// 앱이 쓰는 yamnet.onnx·yamnet.data 가 있는 폴더(모바일 assets/ai 와 같은 파일). + public static string ModelDirectory => Path.Combine(AppContext.BaseDirectory, "Models"); + + /// 실제 521개 클래스 목록. 불변이라 테스트끼리 나눠 쓴다. + public static YamnetClassMap ClassMap => RealClassMap.Value; + + public static string ReferencePath(string fileName) => Path.Combine(AppContext.BaseDirectory, "Ai", "Reference", fileName); + + /// 리틀엔디언 float32 배열 파일. + public static float[] LoadFloats(string fileName, int expectedCount) + { + byte[] bytes = File.ReadAllBytes(ReferencePath(fileName)); + Assert.Equal(expectedCount * sizeof(float), bytes.Length); + var values = new float[expectedCount]; + for (int i = 0; i < values.Length; i++) + { + values[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * sizeof(float))); + } + return values; + } + + /// CP4 골든의 YAMNet 확률 521개(cp4_{name}_probs.bin). + public static float[] LoadCp4Probs(string name) => LoadFloats($"cp4_{name}_probs.bin", YamnetCoarseClassifier.NumClasses); + + public static JsonElement LoadJson(string fileName) + { + using var document = JsonDocument.Parse(File.ReadAllText(ReferencePath(fileName))); + return document.RootElement.Clone(); + } + + /// + /// 521개 이름. 앞쪽은 이고 나머지는 +번호다 + /// (모바일 테스트의 List(521) { i -> head.getOrNull(i) ?: "prefix$i" }). + /// + public static string[] Names(string prefix, params string[] head) + { + var names = new string[YamnetCoarseClassifier.NumClasses]; + for (int i = 0; i < names.Length; i++) + { + names[i] = i < head.Length ? head[i] : prefix + i.ToString(CultureInfo.InvariantCulture); + } + return names; + } + + /// 모두 0 이고 지정한 번호만 값이 있는 확률 521개. + public static float[] Probabilities(params (int Index, float Value)[] values) + { + var probabilities = new float[YamnetCoarseClassifier.NumClasses]; + foreach (var (index, value) in values) probabilities[index] = value; + return probabilities; + } +} diff --git a/SoundVisualizer.Tests/Ai/AttentionMappingPolicyTests.cs b/SoundVisualizer.Tests/Ai/AttentionMappingPolicyTests.cs new file mode 100644 index 0000000..e38a4d2 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/AttentionMappingPolicyTests.cs @@ -0,0 +1,56 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// 모바일 AttentionMappingPolicyTest 를 옮겼다. 제품 정책 #343 이지 샘플에 맞춘 감지 규칙이 아니다. +public class AttentionMappingPolicyTests +{ + private static readonly string[] Attention = + { + "Reversing beeps", "Train horn", "Train whistle", "Foghorn", "Bicycle bell", + "Emergency vehicle", "Skidding", "Tire squeal", "Slam", + }; + + [Fact] // approvedLabelsVoteDangerWithoutBecomingSafetyCues + public void 승인한_주의_신호는_위협음으로_투표하지만_안전_단서는_아니다() + { + Assert.All(Attention, name => + { + Assert.Equal(SoundType.Danger, YamnetThreeClassMapper.MapDisplayNameToCoarse(name)); + Assert.False(YamnetSafetyCueDecision.IsStrongDangerKeyword(name)); + Assert.False(AiPostProcessor.IsCriticalDangerKeyword(name)); + }); + } + + [Fact] // instrumentsAndUnapprovedNeighborsStayAmbient + public void 악기와_승인하지_않은_비슷한_소리는_환경음() + { + var ambient = new[] + { + "Singing bowl", "Truck", "Car", "Bus", "Motorcycle", "Train", "Vehicle", + "Air brake", "Doorbell", "Alarm clock", "Honk", "French horn", "Whistle", + "Plop", "Gargling", + }.Concat(Attention.Select(name => name + "-like sound")); + Assert.All(ambient, name => Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + + // #345 는 고함과 울음까지 사람이 내는 소리를 대화음으로 넓혔다. + Assert.All(new[] { "Singing", "Choir", "Screaming", "Shout", "Laughter", "Crying, sobbing", "Yell", "Baby cry, infant cry" }, name => + Assert.Equal(SoundType.Speech, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + } + + [Fact] // weakAttentionCandidateDoesNotPromoteAmbientVote + public void 약한_주의_신호_후보는_환경음_투표를_올리지_않는다() + { + var names = new YamnetClassMap(AiTestData.Names("class_", "Wind", "Rain", "Water", "Slam", "Vehicle")); + var probabilities = AiTestData.Probabilities((0, .55f), (1, .2f), (2, .15f), (3, .06f), (4, .04f)); + + var pre = new YamnetCoarseClassifier(names).Classify(probabilities); + var result = YamnetSafetyCueDecision.Decide(names, pre); + + Assert.Equal(SoundType.Ambient, result.PostCoarse); + Assert.False(result.DangerCuePromoted); + } +} diff --git a/SoundVisualizer.Tests/Ai/CaptureAudioPathTests.cs b/SoundVisualizer.Tests/Ai/CaptureAudioPathTests.cs new file mode 100644 index 0000000..8886afd --- /dev/null +++ b/SoundVisualizer.Tests/Ai/CaptureAudioPathTests.cs @@ -0,0 +1,145 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// Android 의 CaptureAudioPathTest.kt 를 옮겼다. 캡처 섞기, 앨리어싱을 막는 16kHz 변환, 링 스냅샷을 본다(ONNX 없음). +/// 기준 데이터는 Python 기준 구현(tools/ai_reference)이 만든 것이고 오차 기준도 Android 와 같다. +/// +public class CaptureAudioPathTests +{ + [Fact] + public void 한_창에_필요한_캡처_샘플_수는_44100과_48000에서_안드로이드와_같다() + { + Assert.Equal(42998, CaptureAudioMath.CaptureSamplesForOneYamnetWindow(44100)); + Assert.Equal(46800, CaptureAudioMath.CaptureSamplesForOneYamnetWindow(48000)); + } + + [Fact] + public void 스테레오는_좌우를_평균하고_짝이_없는_끝_샘플은_버린다() + { + float[] interleaved = { 1f, 3f, 2f, 4f, 0.5f }; // 끝의 0.5 는 짝이 없다 + var dest = new float[4]; + + int n = CaptureAudioMath.DownmixInterleavedToMono(interleaved, 2, dest); + + Assert.Equal(2, n); + Assert.Equal(2f, dest[0], 1e-6f); + Assert.Equal(3f, dest[1], 1e-6f); + } + + [Fact] + public void 최고값은_섞기_전의_두_채널에서_잰다() + { + Assert.Equal(0.02f, CaptureAudioMath.MaxAbsoluteInterleavedPeak(new[] { 0.02f, -0.02f })); + } + + [Fact] + public void 링은_스테레오를_모노로_쌓고_모자란_앞쪽은_0으로_채운다() + { + var buf = new AiAudioBuffer(44100, 2); + buf.IngestInterleaved(new[] { 1f, 1f, 2f, 2f, 3f, 3f }); // 스테레오 3프레임 + Assert.Equal(3, buf.AvailableSamples); + + var dest = new float[5]; + buf.CopyTailRightPadded(dest); + + Assert.Equal(new[] { 0f, 0f, 1f, 2f, 3f }, dest); + } + + [Theory] + [InlineData("silence")] + [InlineData("gunshot")] + [InlineData("alarm")] + public void 실제_소리를_1024개씩_링에_넣어_16kHz로_바꾸면_Python_기준과_같다(string name) + { + float[] stereo = CaptureAudioProbe.LoadReference($"e2e_{name}_stereo44100.bin"); + float[] expectedMono16 = CaptureAudioProbe.LoadReference($"e2e_{name}_mono16k.bin"); + + var buf = new AiAudioBuffer(44100, 2); + // AudioRecord 가 float 1,024개씩 읽어 오는 것처럼 나눠 넣는다. + for (int offset = 0; offset < stereo.Length; offset += 1024) + { + int n = Math.Min(1024, stereo.Length - offset); + buf.IngestInterleaved(stereo.AsSpan(offset, n)); + } + Assert.True(buf.HasEnoughForYamnetWindow(), $"{name} ring filled"); + + int need = CaptureAudioMath.CaptureSamplesForOneYamnetWindow(44100); + var capture = new float[need]; + buf.CopyTailRightPadded(capture); + + var mono16 = new float[CaptureAudioMath.RequiredMono16kSamples]; + CaptureAudioMath.ResampleMonoFloatTo16kCustom(capture, 44100, mono16); + + float maxAbs = CaptureAudioProbe.MaxAbs(mono16, expectedMono16); + Assert.True(maxAbs < 1e-5f, $"{name} mono16k maxAbs {maxAbs}"); + // Android 는 이어서 이 mono16 의 log-mel 을 e2e_{name}_logmel.bin 과 1e-4 안으로 비교한다. + // log-mel 전처리는 이 묶음 밖이라 그 절반은 전처리를 옮기는 테스트가 맡는다. + } + + [Theory] + [InlineData(44100)] + [InlineData(48000)] + public void 짧은_기준데이터의_FIR_결과가_Python_기준과_같다(int sampleRate) + { + float[] source = CaptureAudioProbe.LoadReference($"resample_parity_{sampleRate}_capture.bin"); + float[] expected = CaptureAudioProbe.LoadReference($"resample_parity_{sampleRate}_mono16k.bin"); + var actual = new float[expected.Length]; + + CaptureAudioMath.ResampleMonoFloatTo16kCustom(source, sampleRate, actual); + + float maxAbs = CaptureAudioProbe.MaxAbs(actual, expected); + Assert.True(maxAbs < 1e-5f, $"{sampleRate} FIR parity maxAbs {maxAbs}"); + } + + [Theory] + [InlineData(44100)] + [InlineData(48000)] + public void 통과대역과_저지대역_요건을_채운다(int sampleRate) + { + CaptureAudioProbe.AssertFrequencyAcceptance(sampleRate); + } + + [Fact] + public void 입력이_16kHz면_비트까지_그대로_통과한다() + { + var source = new float[CaptureAudioMath.RequiredMono16kSamples]; + for (int index = 0; index < source.Length; index++) + { + source[index] = BitConverter.Int32BitsToSingle(index * 7919); + } + var destination = new float[CaptureAudioMath.RequiredMono16kSamples]; + + CaptureAudioMath.ResampleMonoFloatTo16kCustom(source, CaptureAudioMath.TargetSampleRate, destination); + + Assert.Equal( + Array.ConvertAll(source, BitConverter.SingleToInt32Bits), + Array.ConvertAll(destination, BitConverter.SingleToInt32Bits)); + } + + [Theory] + [InlineData(44100)] + [InlineData(48000)] + public void 한_창_길이의_상수_입력은_끝까지_유한하고_안쪽은_1이다(int sampleRate) + { + CaptureAudioProbe.AssertConstantWindowResponse(sampleRate); + } + + [Fact] + public void 원본_범위_밖은_0으로_보고_가장자리는_FIR_일부만_더한다() + { + var source = new float[512]; + Array.Fill(source, 1f); + var destination = new float[64]; + + CaptureAudioMath.ResampleMonoFloatTo16kCustom(source, 48000, destination); + + Assert.All(destination, value => Assert.True(float.IsFinite(value))); + Assert.True(destination[0] > 0f && destination[0] < 1f, "left edge must use a partial FIR sum"); + Assert.Equal(1f, destination[32], 1e-5f); + } +} diff --git a/SoundVisualizer.Tests/Ai/CaptureAudioProbe.cs b/SoundVisualizer.Tests/Ai/CaptureAudioProbe.cs new file mode 100644 index 0000000..2ef2988 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/CaptureAudioProbe.cs @@ -0,0 +1,115 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Buffers.Binary; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// AI 입력 경로 테스트가 함께 쓰는 기준 데이터 읽기와 측정. +internal static class CaptureAudioProbe +{ + /// 테스트 출력의 Ai/Reference 에 복사된 리틀엔디언 float32 배열을 읽는다. + public static float[] LoadReference(string fileName) + { + byte[] bytes = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Ai", "Reference", fileName)); + var values = new float[bytes.Length / sizeof(float)]; + for (int i = 0; i < values.Length; i++) + { + values[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * sizeof(float), sizeof(float))); + } + return values; + } + + public static float MaxAbs(float[] actual, float[] expected) + { + Assert.Equal(expected.Length, actual.Length); + float maximum = 0f; + for (int i = 0; i < actual.Length; i++) + { + maximum = Math.Max(maximum, Math.Abs(actual[i] - expected[i])); + } + return maximum; + } + + /// + /// 캡처 레이트의 코사인을 16kHz 로 바꾼 뒤, 접혀 들어올 주파수 성분의 크기를 dB 로 잰다(Android 테스트의 toneGainDb). + /// 출력 4,096개 가운데 양 끝 128개는 창 가장자리라 빼고, 코사인·사인 두 성분을 최소제곱으로 맞춘다. + /// + public static double ToneGainDb(int sampleRate, double inputFrequencyHz) + { + const int destinationLength = 4096; + int sourceLength = (int)Math.Ceiling(destinationLength * (double)sampleRate / CaptureAudioMath.TargetSampleRate) + 96; + var source = new float[sourceLength]; + for (int index = 0; index < sourceLength; index++) + { + source[index] = (float)Math.Cos(2.0 * Math.PI * inputFrequencyHz * index / sampleRate); + } + var destination = new float[destinationLength]; + CaptureAudioMath.ResampleMonoFloatTo16kCustom(source, sampleRate, destination); + + double aliasFrequency = inputFrequencyHz % CaptureAudioMath.TargetSampleRate; + if (aliasFrequency > CaptureAudioMath.TargetSampleRate / 2.0) + { + aliasFrequency = CaptureAudioMath.TargetSampleRate - aliasFrequency; + } + double yc = 0.0, ys = 0.0, cc = 0.0, ss = 0.0; + for (int index = 128; index < destinationLength - 128; index++) + { + double phase = 2.0 * Math.PI * aliasFrequency * index / CaptureAudioMath.TargetSampleRate; + double cosine = Math.Cos(phase); + double sine = Math.Sin(phase); + double value = destination[index]; + yc += value * cosine; + ys += value * sine; + cc += cosine * cosine; + ss += sine * sine; + } + double cosineCoefficient = yc / cc; + double sineCoefficient = ss < 1e-12 ? 0.0 : ys / ss; + double amplitude = Math.Sqrt(cosineCoefficient * cosineCoefficient + sineCoefficient * sineCoefficient); + return 20.0 * Math.Log10(Math.Max(amplitude, 1e-300)); + } + + public static void AssertGainAtLeast(int sampleRate, double frequencyHz, double minimumDb) + { + double gain = ToneGainDb(sampleRate, frequencyHz); + Assert.True(gain >= minimumDb, $"{sampleRate} Hz, {frequencyHz} Hz gain {gain} dB must be >= {minimumDb} dB"); + } + + public static void AssertGainAtMost(int sampleRate, double frequencyHz, double maximumDb) + { + double gain = ToneGainDb(sampleRate, frequencyHz); + Assert.True(gain <= maximumDb, $"{sampleRate} Hz, {frequencyHz} Hz gain {gain} dB must be <= {maximumDb} dB"); + } + + /// Android 가 44.1·48kHz 에 거는 통과·차단 요건. PC 고레이트에도 그대로 건다. + public static void AssertFrequencyAcceptance(int sampleRate) + { + AssertGainAtLeast(sampleRate, 7000.0, -1.5); + AssertGainAtLeast(sampleRate, 7500.0, -3.0); + AssertGainAtMost(sampleRate, 8500.0, -20.0); + AssertGainAtMost(sampleRate, 9000.0, -45.0); + AssertGainAtMost(sampleRate, 10000.0, -45.0); + AssertGainAtMost(sampleRate, 15000.0, -60.0); + } + + /// 한 창 길이의 상수 1 을 바꾸면 끝까지 유한하고, 가장자리도 0.5~1.2 안이고, 안쪽은 1 이고, 마지막 샘플도 채워진다. + public static void AssertConstantWindowResponse(int sampleRate) + { + int sourceLength = CaptureAudioMath.CaptureSamplesForOneYamnetWindow(sampleRate); + var source = new float[sourceLength]; + Array.Fill(source, 1f); + var destination = new float[CaptureAudioMath.RequiredMono16kSamples]; + + CaptureAudioMath.ResampleMonoFloatTo16kCustom(source, sampleRate, destination); + + Assert.All(destination, value => Assert.True(float.IsFinite(value), $"{sampleRate} output must stay finite")); + Assert.All(destination, value => Assert.InRange(value, 0.5f, 1.2f)); + for (int index = 16; index < destination.Length - 16; index++) + { + Assert.Equal(1f, destination[index], 1e-3f); + } + Assert.NotEqual(0f, destination[^1]); + } +} diff --git a/SoundVisualizer.Tests/Ai/ModelFileIntegrityTests.cs b/SoundVisualizer.Tests/Ai/ModelFileIntegrityTests.cs new file mode 100644 index 0000000..d4b8d87 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/ModelFileIntegrityTests.cs @@ -0,0 +1,167 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// 모델 파일 확인. 모바일 ModelCacheIntegrityTest 에서 검사 부분을 옮기고, 깨진 파일로 세션을 만들지 않는지를 더했다. +/// PC 는 assets 를 앱 저장소로 복사하지 않으므로 복사·복구(ensureFiles, copyIfInvalid) 테스트는 옮기지 않았다. +/// +public class ModelFileIntegrityTests +{ + [Fact] // validity_rejectsMissingEmptyTruncatedAndSameSizeCorruptFiles + public void 없거나_비었거나_잘렸거나_크기는_같은데_깨진_파일은_통과하지_못한다() + { + using var dir = new TempDirectory(); + byte[] source = { 1, 2, 3, 4, 5, 6 }; + var expected = Expected("model.data", source); + string cached = Path.Combine(dir.Path, expected.Name); + + Assert.False(ModelFileIntegrity.IsValid(cached, expected)); + File.WriteAllBytes(cached, Array.Empty()); + Assert.False(ModelFileIntegrity.IsValid(cached, expected)); + File.WriteAllBytes(cached, source[..^1]); + Assert.False(ModelFileIntegrity.IsValid(cached, expected)); + File.WriteAllBytes(cached, new byte[] { 6, 5, 4, 3, 2, 1 }); + Assert.False(ModelFileIntegrity.IsValid(cached, expected)); + File.WriteAllBytes(cached, source); + Assert.True(ModelFileIntegrity.IsValid(cached, expected)); + } + + [Fact] // shippedYamnetAssets_matchTheCacheIntegritySpecifications + public void 배포하는_모델_파일은_고정한_크기와_SHA256과_같다() + { + foreach (var expected in YamnetModelFiles.All) + { + string path = Path.Combine(AiTestData.ModelDirectory, expected.Name); + Assert.True(File.Exists(path), $"Missing shipped model file: {expected.Name}"); + Assert.Equal(expected.ByteCount, new FileInfo(path).Length); + Assert.Equal(expected.Sha256, ModelFileIntegrity.Sha256Hex(path)); + Assert.Matches(new Regex("^[0-9a-f]{64}$"), expected.Sha256); + } + ModelFileIntegrity.Verify(AiTestData.ModelDirectory, YamnetModelFiles.All); + } + + [Fact] + public void 고정값은_Android_의_YamnetModelFiles_와_같다() + { + // 두 앱이 같은 모델을 쓴다는 약속이다. 모델을 바꾸면 YamnetInference.kt 의 값과 함께 여기도 고친다. + Assert.Equal( + new[] + { + ("yamnet.onnx", 25_592L, "290369c40886a4ae948f77671fff901023eec81484ac393465dfb1b342b1ca85"), + ("yamnet.data", 14_915_108L, "aa05b5b196bdfd74fb59ae4cbba22578c5ab25b9e792e52867678844bcecc839"), + }, + YamnetModelFiles.All.Select(file => (file.Name, file.ByteCount, file.Sha256))); + } + + [Fact] + public void 다른_폴더로_옮긴_모델도_그_폴더의_가중치로_불러온다() + { + // 가중치(yamnet.data)는 작업 폴더가 아니라 yamnet.onnx 옆에서 찾는다. 실행 파일 위치와 상관없이 불러와야 한다. + using var dir = CopyOfShippedModel(); + float[] logMel = AiTestData.LoadFloats("logmel_golden.bin", YamnetInference.LogMelSize); + float[] expected = AiTestData.LoadFloats("yamnet_logits_golden.bin", YamnetInference.NumClasses); + var logits = new float[YamnetInference.NumClasses]; + + using (var yamnet = YamnetInference.Create(dir.Path)) + { + yamnet.Infer(logMel, logits, new float[YamnetInference.NumClasses]); + } + + Assert.True(logits.Zip(expected, (a, b) => Math.Abs(a - b)).Max() < 1e-3f); + } + + [Theory] + [InlineData("yamnet.data")] + [InlineData("yamnet.onnx")] + public void 한_바이트만_바뀐_모델_파일로는_세션을_만들지_않는다(string fileName) + { + using var dir = CopyOfShippedModel(); + FlipMiddleByte(Path.Combine(dir.Path, fileName)); + + var error = Assert.Throws(() => YamnetInference.Create(dir.Path)); + + Assert.Contains(fileName, error.Message); + Assert.Contains("SHA-256", error.Message); + } + + [Fact] + public void 잘린_가중치는_크기에서_걸린다() + { + using var dir = CopyOfShippedModel(); + string data = Path.Combine(dir.Path, "yamnet.data"); + using (var stream = new FileStream(data, FileMode.Open, FileAccess.Write)) + { + stream.SetLength(stream.Length - 1); + } + + var error = Assert.Throws(() => YamnetInference.Create(dir.Path)); + + Assert.Contains("yamnet.data", error.Message); + Assert.Contains("14915107 bytes", error.Message); + } + + [Fact] + public void 가중치가_없으면_파일을_찾지_못했다고_알린다() + { + using var dir = CopyOfShippedModel(); + string data = Path.Combine(dir.Path, "yamnet.data"); + File.Delete(data); + + var error = Assert.Throws(() => YamnetInference.Create(dir.Path)); + + Assert.Equal(data, error.FileName); + } + + private static ModelFileIntegrity.ExpectedFile Expected(string name, byte[] bytes) => + new(name, bytes.Length, Convert.ToHexStringLower(SHA256.HashData(bytes))); + + private static TempDirectory CopyOfShippedModel() + { + var dir = new TempDirectory(); + foreach (var file in YamnetModelFiles.All) + { + File.Copy(Path.Combine(AiTestData.ModelDirectory, file.Name), Path.Combine(dir.Path, file.Name)); + } + return dir; + } + + private static void FlipMiddleByte(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite); + stream.Position = stream.Length / 2; + int value = stream.ReadByte(); + stream.Position--; + stream.WriteByte((byte)(value ^ 0x01)); + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = Directory.CreateTempSubdirectory("sv-yamnet-").FullName; + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + // 임시 폴더 정리는 테스트 결과와 상관없다. + } + catch (UnauthorizedAccessException) + { + } + } + } +} diff --git a/SoundVisualizer.Tests/Ai/PcCaptureAudioPathTests.cs b/SoundVisualizer.Tests/Ai/PcCaptureAudioPathTests.cs new file mode 100644 index 0000000..fa4a857 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/PcCaptureAudioPathTests.cs @@ -0,0 +1,416 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Runtime.InteropServices; +using SoundVisualizer.Ai; +using SoundVisualizer.Audio; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// PC 에서 더한 AI 입력 경로(Android 에는 없는 5.1·7.1 섞기와 88.2·96·176.4·192kHz 변환)와, +/// 옮기면서 따로 확인하는 링 버퍼의 보관 규칙과 스레드 계약을 본다. +/// +public class PcCaptureAudioPathTests +{ + public static TheoryData PcHighRates => new() { 88200, 96000, 176400, 192000 }; + + // ── 링 버퍼 ── + + [Theory] + [InlineData(16000)] + [InlineData(44100)] + [InlineData(48000)] + public void 링은_2초를_넘는_앞부분을_버리고_가장_최근_샘플을_순서대로_돌려준다(int rate) + { + var buffer = new AiAudioBuffer(captureSampleRate: rate, channels: 1); + int retained = Math.Max(AiAudioBuffer.DefaultCaptureSampleRate, rate) * AiAudioBuffer.RingSeconds; + int total = AiAudioBuffer.MaxRingSize + 70_000; // 링을 한 바퀴 넘게 돈다 + var ramp = new float[total]; + for (int i = 0; i < total; i++) ramp[i] = i + 1; + + buffer.IngestMono(ramp.AsSpan(0, 1000)); + buffer.IngestMono(ramp.AsSpan(1000)); + + Assert.Equal(retained, buffer.AvailableSamples); + var all = new float[retained + 10]; + Assert.Equal(retained, buffer.CopyTailRightPadded(all)); + Assert.All(all[..10], value => Assert.Equal(0f, value)); + Assert.Equal(ramp[^retained..], all[10..]); + + buffer.Reset(); + Assert.Equal(0, buffer.AvailableSamples); + Assert.False(buffer.HasEnoughForYamnetWindow()); + } + + [Fact] + public async Task 캡처_스레드가_넣는_동안_추론_스레드가_가져가도_창이_끊기지_않는다() + { + // Android 의 synchronized 와 같은 계약: 한 번 넣은 버퍼는 통째로 보이거나 아예 보이지 않는다. + var buffer = new AiAudioBuffer(captureSampleRate: 48000, channels: 2); + const int totalFrames = 3_000_000; // float 가 정수를 정확히 담는 범위(2^24) 안 + var producer = Task.Run(() => + { + var random = new Random(7); + var chunk = new float[2 * 4096]; + int written = 0; + while (written < totalFrames) + { + int frames = Math.Min(random.Next(1, 4097), totalFrames - written); + for (int f = 0; f < frames; f++) + { + float value = written + f + 1; // 0 은 왼쪽 채움과 구분한다 + chunk[2 * f] = value + 0.25f; + chunk[2 * f + 1] = value - 0.25f; + } + buffer.IngestInterleaved(chunk.AsSpan(0, 2 * frames)); + written += frames; + } + }); + + var window = new float[CaptureAudioMath.CaptureSamplesForOneYamnetWindow(48000)]; + int snapshots = 0; + while (!producer.IsCompleted || snapshots == 0) + { + int available = buffer.CopyTailRightPadded(window); + int start = window.Length - Math.Min(available, window.Length); + int broken = -1; + for (int i = 0; i < window.Length && broken < 0; i++) + { + bool ok = i < start ? window[i] == 0f : i == start || window[i] == window[i - 1] + 1f; + if (!ok) broken = i; + } + Assert.True(broken < 0, $"snapshot {snapshots} broke at {broken}"); + snapshots++; + } + await producer; + + buffer.CopyTailRightPadded(window); + Assert.Equal((float)totalFrames, window[^1]); + } + + // ── 채널 섞기 ── + + [Fact] + public void 모노_입력은_그대로_쓴다() + { + float[] mono = { 0.25f, -0.5f, 1.5f }; + var dest = new float[3]; + + Assert.Equal(3, CaptureAudioMath.DownmixInterleavedToMono(mono, 1, dest)); + Assert.Equal(mono, dest); + } + + [Fact] + public void 오점일_채널은_스테레오로_섞은_뒤_좌우를_평균하고_LFE는_뺀다() + { + // FL, FR, FC, LFE, SL, SR + float[] frame = { 0.10f, 0.20f, 0.30f, 0.90f, 0.40f, 0.50f }; + var dest = new float[1]; + + Assert.Equal(1, CaptureAudioMath.DownmixInterleavedToMono(frame, 6, dest)); + + float left = 0.10f + 0.707f * 0.30f + 0.707f * 0.40f; + float right = 0.20f + 0.707f * 0.30f + 0.707f * 0.50f; + Assert.Equal((left + right) / 2f, dest[0], 1e-6f); + } + + [Fact] + public void 칠점일_채널은_스테레오로_섞은_뒤_좌우를_평균하고_LFE는_뺀다() + { + // FL, FR, FC, LFE, BL, BR, SL, SR + float[] frame = { 0.10f, 0.20f, 0.30f, 0.90f, 0.40f, 0.50f, 0.06f, 0.07f }; + var dest = new float[1]; + + Assert.Equal(1, CaptureAudioMath.DownmixInterleavedToMono(frame, 8, dest)); + + float left = 0.10f + 0.707f * 0.30f + 0.707f * 0.06f + 0.5f * 0.40f; + float right = 0.20f + 0.707f * 0.30f + 0.707f * 0.07f + 0.5f * 0.50f; + Assert.Equal((left + right) / 2f, dest[0], 1e-6f); + } + + [Theory] + [InlineData(8, 4, 0.5f)] // BL + [InlineData(8, 5, 0.5f)] // BR + [InlineData(8, 6, 0.707f)] // SL + [InlineData(8, 7, 0.707f)] // SR + [InlineData(6, 4, 0.707f)] // SL + [InlineData(6, 5, 0.707f)] // SR + [InlineData(8, 2, 1.414f)] // FC 는 좌우 모두에 들어간다 + [InlineData(8, 3, 0f)] // LFE + [InlineData(6, 3, 0f)] // LFE + public void 뒤와_옆_채널로만_나는_소리도_AI_입력에_들어간다(int channels, int channel, float stereoGain) + { + // 예전 PC 코드는 FL·FR·FC 만 AI 에 넣어서, 뒤·옆 스피커로만 나는 소리는 AI 가 듣지 못했다. + var frame = new float[channels]; + frame[channel] = 0.5f; + var mono = new float[1]; + + CaptureAudioMath.DownmixInterleavedToMono(frame, channels, mono); + + Assert.Equal(0.5f * stereoGain / 2f, mono[0], 1e-6f); + } + + [Theory] + [InlineData(6)] + [InlineData(8)] + public void AI_섞기는_스피커용과_같은_계수를_쓰되_1을_넘어도_자르지_않는다(int channels) + { + // 1 을 넘지 않는 소리는 스피커용 스테레오(ChannelDownmix)의 (L+R)/2 와 비트까지 같다. + float[] quiet = { 0.10f, -0.20f, 0.05f, 0.90f, 0.15f, -0.25f, 0.03f, 0.04f }; + float[] quietFrame = quiet[..channels]; + float[] quietStereo = SpeakerStereo(quietFrame, channels); + var mono = new float[1]; + CaptureAudioMath.DownmixInterleavedToMono(quietFrame, channels, mono); + Assert.Equal((quietStereo[0] + quietStereo[1]) / 2f, mono[0]); + + // 큰 소리(모든 채널 0.9)는 스피커용이 L·R 을 1 로 자르지만, AI 쪽은 BS.775 합을 그대로 둔다(L 과 R 이 같다). + float[] loudFrame = Enumerable.Repeat(0.9f, channels).ToArray(); + Assert.Equal(new[] { 1f, 1f }, SpeakerStereo(loudFrame, channels)); + float loudSurround = channels == 8 ? 0.707f * 0.9f + 0.5f * 0.9f : 0.707f * 0.9f; + float loudLeft = 0.9f + 0.707f * 0.9f + loudSurround; + CaptureAudioMath.DownmixInterleavedToMono(loudFrame, channels, mono); + Assert.Equal(loudLeft, mono[0], 1e-5f); + } + + [Theory] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(7)] + public void 그_밖의_채널_수는_모든_채널의_평균이다(int channels) + { + var interleaved = new float[channels * 2 + 1]; // 끝의 1개는 짝이 없어 버린다 + for (int i = 0; i < interleaved.Length; i++) interleaved[i] = (i + 1) * 0.01f; + var mono = new float[2]; + + Assert.Equal(2, CaptureAudioMath.DownmixInterleavedToMono(interleaved, channels, mono)); + + for (int f = 0; f < 2; f++) + { + float sum = 0f; + for (int c = 0; c < channels; c++) sum += interleaved[f * channels + c]; + Assert.Equal(sum / channels, mono[f]); + } + } + + [Fact] + public void 받을_자리가_모자라거나_형식이_잘못되면_쓰지_않는다() + { + Assert.Throws(() => CaptureAudioMath.DownmixInterleavedToMono(new float[16], 8, new float[1])); + Assert.Equal(0, CaptureAudioMath.DownmixInterleavedToMono(new float[16], 0, new float[16])); + Assert.Equal(0, CaptureAudioMath.DownmixInterleavedToMono(new float[7], 8, new float[1])); + Assert.Equal(0, CaptureAudioMath.DownmixInterleavedToMono(ReadOnlySpan.Empty, 2, new float[1])); + } + + [Fact] + public void 링은_칠점일_채널을_섞어서_쌓는다() + { + var buffer = new AiAudioBuffer(captureSampleRate: 48000, channels: 8); + float[] pcm = + { + 0.4f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, // FL 만 + 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0.4f, // SR 만 + }; + + buffer.IngestInterleaved(pcm); + + Assert.Equal(2, buffer.AvailableSamples); + var dest = new float[2]; + buffer.CopyTailRightPadded(dest); + Assert.Equal(0.4f / 2f, dest[0], 1e-6f); + Assert.Equal(0.707f * 0.4f / 2f, dest[1], 1e-6f); + } + + [Fact] + public void 임시_버퍼보다_긴_캡처_버퍼도_나눠서_빠짐없이_넣는다() + { + const int channels = 8; + int frames = AiAudioBuffer.MaxRingSize / 2 + 1000; // 섞을 때 쓰는 임시 버퍼(65,536프레임)보다 길다 + var pcm = new float[frames * channels]; + for (int f = 0; f < frames; f++) + { + pcm[f * channels] = f / (float)frames; // FL + pcm[f * channels + 6] = 0.5f - f / (float)frames; // SL + } + var buffer = new AiAudioBuffer(captureSampleRate: 48000, channels: channels); + + buffer.IngestInterleaved(pcm); + + Assert.Equal(frames, buffer.AvailableSamples); + var expected = new float[frames]; + CaptureAudioMath.DownmixInterleavedToMono(pcm, channels, expected); + var actual = new float[frames]; + buffer.CopyTailRightPadded(actual); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(3)] // LFE 만 + [InlineData(5)] // BR 만 + [InlineData(7)] // SR 만 + public void 무음_게이트는_채널_수와_상관없이_섞기_전_어느_채널로든_열린다(int channel) + { + // LFE 는 AI 입력에서 빠지지만, 소리가 났다는 것은 Android 처럼 섞기 전 최고값으로 본다. + var quiet = new float[8]; + Array.Fill(quiet, 0.01f); + var gate = new AiSilenceGate(); + gate.OnInterleavedPcm(quiet, nowMs: 0); + Assert.False(gate.IsOpen(0)); + + var frame = new float[8]; + frame[channel] = -0.02f; + gate.OnInterleavedPcm(frame, nowMs: 0); + Assert.True(gate.IsOpen(0)); + } + + // ── 88.2·96·176.4·192kHz ── + + [Theory] + [InlineData(16000, 96)] + [InlineData(22050, 96)] // AI 가 받지 않는 레이트는 Android 와 같은 96탭 + [InlineData(44100, 96)] + [InlineData(48000, 96)] + [InlineData(88200, 192)] + [InlineData(96000, 192)] + [InlineData(176400, 384)] + [InlineData(192000, 384)] + public void FIR_탭_수는_전이_대역의_폭이_같도록_레이트에_비례한다(int rate, int taps) + { + Assert.Equal(taps, CaptureAudioMath.FirTapsFor(rate)); + } + + [Theory] + [InlineData(88200, 85995)] + [InlineData(96000, 93600)] + [InlineData(176400, 171990)] + [InlineData(192000, 187200)] + public void PC_고레이트의_한_창_길이는_16kHz_15600샘플을_빠짐없이_덮는다(int rate, int windowSamples) + { + Assert.Equal(windowSamples, CaptureAudioMath.CaptureSamplesForOneYamnetWindow(rate)); + // 마지막 출력 샘플의 원본 위치까지 창 안에 있고, 한 샘플이라도 줄이면 모자란다. + long lastPosition = (long)(CaptureAudioMath.RequiredMono16kSamples - 1) * rate / CaptureAudioMath.TargetSampleRate; + Assert.True(lastPosition < windowSamples); + Assert.True((long)(windowSamples - 1) * CaptureAudioMath.TargetSampleRate < (long)CaptureAudioMath.RequiredMono16kSamples * rate); + } + + [Theory] + [MemberData(nameof(PcHighRates))] + public void PC_고레이트도_한_창_길이의_상수_입력은_끝까지_유한하고_안쪽은_1이다(int rate) + { + CaptureAudioProbe.AssertConstantWindowResponse(rate); + } + + [Theory] + [MemberData(nameof(PcHighRates))] + public void PC_고레이트도_원본_범위_밖은_0으로_보고_가장자리는_FIR_일부만_더한다(int rate) + { + var source = new float[rate / 125]; // 8ms + Array.Fill(source, 1f); + var destination = new float[64]; + + CaptureAudioMath.ResampleMonoFloatTo16kCustom(source, rate, destination); + + Assert.All(destination, value => Assert.True(float.IsFinite(value))); + Assert.True(destination[0] > 0f && destination[0] < 1f, "left edge must use a partial FIR sum"); + Assert.Equal(1f, destination[32], 1e-5f); + } + + [Theory] + [MemberData(nameof(PcHighRates))] + public void PC_고레이트에서_1kHz_사인은_거의_그대로_남는다(int rate) + { + int windowSamples = CaptureAudioMath.CaptureSamplesForOneYamnetWindow(rate); + var source = new float[windowSamples]; + for (int i = 0; i < source.Length; i++) + { + source[i] = (float)(0.5 * Math.Sin(2.0 * Math.PI * 1000.0 * i / rate)); + } + var mono16 = new float[CaptureAudioMath.RequiredMono16kSamples]; + + CaptureAudioMath.ResampleMonoFloatTo16kCustom(source, rate, mono16); + + // 창 가장자리 32개는 원본 밖을 0 으로 보는 구간이라 뺀다. 1e-5 는 44.1·48kHz 기준 데이터의 허용 오차와 같다. + double maxError = 0; + for (int i = 32; i < mono16.Length - 32; i++) + { + double ideal = 0.5 * Math.Sin(2.0 * Math.PI * 1000.0 * i / CaptureAudioMath.TargetSampleRate); + maxError = Math.Max(maxError, Math.Abs(mono16[i] - ideal)); + } + Assert.True(maxError < 1e-5, $"{rate} Hz 1 kHz sine max error {maxError}"); + } + + [Theory] + [MemberData(nameof(PcHighRates))] + public void PC_고레이트도_안드로이드와_같은_통과대역과_저지대역_요건을_채운다(int rate) + { + // 96탭을 그대로 쓰면 96kHz 에서 7kHz 가 -1.9dB, 9kHz 가 -20dB 에 그쳐 여기서 걸린다. + CaptureAudioProbe.AssertFrequencyAcceptance(rate); + } + + [Theory] + [InlineData(88200, 12000.0)] + [InlineData(96000, 12000.0)] + [InlineData(176400, 12000.0)] + [InlineData(192000, 12000.0)] + [InlineData(88200, 30000.0)] // 2kHz 로 접힌다 + [InlineData(96000, 30000.0)] + [InlineData(176400, 70000.0)] // 6kHz 로 접힌다 + [InlineData(192000, 70000.0)] + public void PC_고레이트에서_8kHz_위의_소리는_80dB_넘게_줄어든다(int rate, double frequencyHz) + { + // β 8.6 Kaiser 설계의 저지대역 감쇠는 약 86dB(Kaiser 식 β = 0.1102·(A − 8.7))다. 80dB 는 float 계수 반올림에 6dB 여유를 + // 둔 값으로, Android 가 15kHz 에 거는 60dB 보다 엄격하다. 측정값은 12kHz 에서 96~104dB 다. + // 96탭을 그대로 쓰면 176.4·192kHz 에서 12kHz 가 50·43dB 만 줄어 여기서 걸린다. + CaptureAudioProbe.AssertGainAtMost(rate, frequencyHz, -80.0); + } + + [Fact] + public void 칠점일_96kHz_캡처를_링에서_꺼내_바꾸면_옆_채널의_1kHz가_남는다() + { + const int rate = 96000; + const int channels = 8; + const int sideLeft = 6; + var buffer = new AiAudioBuffer(captureSampleRate: rate, channels: channels); + var ingress = new AiCaptureInferenceGate(buffer); + int frames10Ms = rate / 100; + var chunk = new float[frames10Ms * channels]; + int totalFrames = rate * 6 / 5; // 1.2초 + for (int start = 0; start < totalFrames; start += frames10Ms) + { + Array.Clear(chunk); + for (int f = 0; f < frames10Ms; f++) + { + chunk[f * channels + sideLeft] = (float)(0.5 * Math.Sin(2.0 * Math.PI * 1000.0 * (start + f) / rate)); + } + ingress.IngestInterleaved(chunk, nowMs: start * 1000L / rate); + } + Assert.True(ingress.IsInferenceOpen(totalFrames * 1000L / rate)); + Assert.True(buffer.HasEnoughForYamnetWindow()); + + var capture = new float[CaptureAudioMath.CaptureSamplesForOneYamnetWindow(rate)]; + buffer.CopyTailRightPadded(capture); + var mono16 = new float[CaptureAudioMath.RequiredMono16kSamples]; + CaptureAudioMath.ResampleMonoFloatTo16kCustom(capture, rate, mono16); + + // 옆 채널은 0.707 로 왼쪽에만 들어가고 좌우 평균에서 다시 절반이 된다. + int firstFrame = totalFrames - capture.Length; + double amplitude = 0.5 * 0.707 / 2.0; + double maxError = 0; + for (int i = 32; i < mono16.Length - 32; i++) + { + double sourcePosition = firstFrame + (double)i * rate / CaptureAudioMath.TargetSampleRate; + double ideal = amplitude * Math.Sin(2.0 * Math.PI * 1000.0 * sourcePosition / rate); + maxError = Math.Max(maxError, Math.Abs(mono16[i] - ideal)); + } + Assert.True(maxError < 1e-5, $"7.1 96 kHz side-channel sine max error {maxError}"); + } + + private static float[] SpeakerStereo(float[] frame, int channels) + { + byte[] input = MemoryMarshal.AsBytes(frame.AsSpan()).ToArray(); + var output = Array.Empty(); + int written = ChannelDownmix.Convert(input, channels, sizeof(float), 2, ref output); + return MemoryMarshal.Cast(output.AsSpan(0, written)).ToArray(); + } +} diff --git a/SoundVisualizer.Tests/Ai/PipelineTestDoubles.cs b/SoundVisualizer.Tests/Ai/PipelineTestDoubles.cs new file mode 100644 index 0000000..668b884 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/PipelineTestDoubles.cs @@ -0,0 +1,120 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// 테스트가 정하는 단조 밀리초 시계. 여러 스레드에서 읽고 움직여도 된다. +internal sealed class ManualClock +{ + private long _nowMs; + + public ManualClock(long startMs = 0) + { + _nowMs = startMs; + } + + public long Now() => Interlocked.Read(ref _nowMs); + + public void Set(long nowMs) => Interlocked.Exchange(ref _nowMs, nowMs); + + public long Advance(long ms) => Interlocked.Add(ref _nowMs, ms); +} + +/// +/// 모델 자리에 끼우는 가짜. 정한 클래스에 확률을 몰아 주고, 추론마다 를 불러 테스트가 시계를 움직이거나 +/// 추론을 붙잡아 두거나 실패시킨다. 닫힌 뒤에 불렸는지, 추론 중에 닫혔는지를 기록해 세션 해제 경합을 잡는다. +/// +internal sealed class FakeYamnetModel : IYamnetModel +{ + private int _inferCalls; + private int _disposeCalls; + private int _inferring; + private int _disposed; + private int _usedAfterDispose; + private int _disposedDuringInference; + + /// 확률을 몰아 줄 클래스 번호. 0 은 "Speech" 다. + public int TopClass { get; set; } + + /// 추론마다 몇 번째 호출인지(1부터)와 함께 부른다. 예외를 던지면 그 추론이 실패한다. + public Action? OnInfer { get; set; } + + public int InferCalls => Volatile.Read(ref _inferCalls); + + public int DisposeCalls => Volatile.Read(ref _disposeCalls); + + public bool IsDisposed => Volatile.Read(ref _disposed) != 0; + + public bool UsedAfterDispose => Volatile.Read(ref _usedAfterDispose) != 0; + + public bool DisposedDuringInference => Volatile.Read(ref _disposedDuringInference) != 0; + + public void Infer(ReadOnlySpan logMel, Span logits, Span probabilities) + { + // 추론 중 표시를 먼저 세우고 닫힘을 본다. Dispose 는 반대 순서라 둘 중 하나는 반드시 상대를 본다. + Interlocked.Increment(ref _inferring); + try + { + if (Volatile.Read(ref _disposed) != 0) + { + Interlocked.Exchange(ref _usedAfterDispose, 1); + throw new ObjectDisposedException(nameof(FakeYamnetModel)); + } + int call = Interlocked.Increment(ref _inferCalls); + OnInfer?.Invoke(call); + logits.Fill(-10f); + logits[TopClass] = 10f; + YamnetInference.Softmax(logits, probabilities); + } + finally + { + Interlocked.Decrement(ref _inferring); + } + } + + public void Dispose() + { + Interlocked.Exchange(ref _disposed, 1); + if (Volatile.Read(ref _inferring) != 0) Interlocked.Exchange(ref _disposedDuringInference, 1); + Interlocked.Increment(ref _disposeCalls); + } +} + +/// 파이프라인 테스트에 넣는 소리. +internal static class PipelineTestAudio +{ + /// 모바일 계측 테스트의 사인(sin(i × 0.031) × 0.4, 16kHz 에서 약 79Hz)을 모든 채널에 같게 넣은 인터리브 PCM. + public static float[] Sine(int frames, int channels = 1) + { + var pcm = new float[frames * channels]; + for (int frame = 0; frame < frames; frame++) + { + float value = (float)Math.Sin(frame * .031) * .4f; + for (int channel = 0; channel < channels; channel++) pcm[frame * channels + channel] = value; + } + return pcm; + } + + /// -1~1 균등 잡음. 모바일 닫기 경합 테스트의 CHUNK 와 같은 쓰임이다. + public static float[] Noise(int seed, int count) + { + var random = new Random(seed); + var pcm = new float[count]; + for (int i = 0; i < pcm.Length; i++) pcm[i] = random.NextSingle() * 2f - 1f; + return pcm; + } + + /// 캡처 레이트에서 YAMNet 한 창을 채우는 프레임 수. + public static int WindowFrames(int sampleRate) => CaptureAudioMath.CaptureSamplesForOneYamnetWindow(sampleRate); + + /// 캡처처럼 float 1,024개씩 나눠 넣는다. + public static void IngestInChunks(RealtimeAiPipeline pipeline, float[] interleaved, int chunk = 1024) + { + for (int offset = 0; offset < interleaved.Length; offset += chunk) + { + pipeline.Ingest(interleaved.AsSpan(offset, Math.Min(chunk, interleaved.Length - offset))); + } + } +} diff --git a/SoundVisualizer.Tests/Ai/QualcommSourceAudioPreprocessorGoldenTests.cs b/SoundVisualizer.Tests/Ai/QualcommSourceAudioPreprocessorGoldenTests.cs new file mode 100644 index 0000000..9197189 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/QualcommSourceAudioPreprocessorGoldenTests.cs @@ -0,0 +1,83 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Buffers.Binary; +using System.Text.Json; +using SoundVisualizer.Ai; +using Xunit.Abstractions; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// 옮긴 Qualcomm 원본 전처리를, 따로 만든 Python 기준 데이터(qualcomm_source_frontend_meta.json 의 모든 경우)와 +/// 비교한다. 모바일 판 QualcommSourceAudioPreprocessorGoldenTest 와 같은 한도(최대 1e-5, 평균 1e-7)를 쓴다. +/// +public class QualcommSourceAudioPreprocessorGoldenTests +{ + private const string MetaFile = "qualcomm_source_frontend_meta.json"; + private static readonly string ReferenceDirectory = Path.Combine(AppContext.BaseDirectory, "Ai", "Reference"); + + private readonly ITestOutputHelper _output; + + public QualcommSourceAudioPreprocessorGoldenTests(ITestOutputHelper output) => _output = output; + + /// 메타 파일에 적힌 경우를 모두 돈다. 기준 데이터에 경우가 늘면 따로 고치지 않아도 함께 본다. + public static TheoryData Cases() + { + using JsonDocument meta = ReadMeta(); + var cases = new TheoryData(); + foreach (JsonProperty entry in meta.RootElement.GetProperty("cases").EnumerateObject()) + { + cases.Add(entry.Name); + } + return cases; + } + + [Theory] + [MemberData(nameof(Cases))] + public void 로그멜이_고정한_원본_전처리의_기준_데이터와_맞는다(string name) + { + using JsonDocument meta = ReadMeta(); + JsonElement entry = meta.RootElement.GetProperty("cases").GetProperty(name); + JsonElement output = entry.GetProperty("output"); + float[] input = LoadFloat32(entry.GetProperty("input").GetString()!); + float[] expected = LoadFloat32(output.GetProperty("path").GetString()!); + + float[] actual = new QualcommSourceAudioPreprocessor().ComputeLogMelSpectrogram(input); + + Assert.Equal(QualcommSourceAudioPreprocessor.LogMelSize, actual.Length); + Assert.Equal(output.GetProperty("count").GetInt32(), expected.Length); + Assert.Equal(expected.Length, actual.Length); + Assert.All(actual, value => Assert.True(float.IsFinite(value))); + + double maximumAbsoluteError = 0.0; + double absoluteErrorSum = 0.0; + for (int index = 0; index < actual.Length; index++) + { + double error = Math.Abs((double)actual[index] - expected[index]); + maximumAbsoluteError = Math.Max(maximumAbsoluteError, error); + absoluteErrorSum += error; + } + double meanAbsoluteError = absoluteErrorSum / actual.Length; + _output.WriteLine($"{name} Qualcomm source parity: maxAbs={maximumAbsoluteError:R} meanAbs={meanAbsoluteError:R}"); + + Assert.True(maximumAbsoluteError < 1e-5, $"{name} maxAbs={maximumAbsoluteError:R} must stay below 1e-5"); + Assert.True(meanAbsoluteError < 1e-7, $"{name} meanAbs={meanAbsoluteError:R} must stay below 1e-7"); + } + + private static JsonDocument ReadMeta() => + JsonDocument.Parse(File.ReadAllText(Path.Combine(ReferenceDirectory, MetaFile))); + + /// 기준 데이터는 float32 리틀엔디언 배열이다. + private static float[] LoadFloat32(string fileName) + { + byte[] bytes = File.ReadAllBytes(Path.Combine(ReferenceDirectory, fileName)); + Assert.True(bytes.Length % sizeof(float) == 0, $"{fileName} is not a float32 array"); + var values = new float[bytes.Length / sizeof(float)]; + for (int index = 0; index < values.Length; index++) + { + values[index] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(index * sizeof(float))); + } + return values; + } +} diff --git a/SoundVisualizer.Tests/Ai/QualcommSourceAudioPreprocessorTests.cs b/SoundVisualizer.Tests/Ai/QualcommSourceAudioPreprocessorTests.cs new file mode 100644 index 0000000..1ea710f --- /dev/null +++ b/SoundVisualizer.Tests/Ai/QualcommSourceAudioPreprocessorTests.cs @@ -0,0 +1,90 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// 기준 데이터와의 비교() 밖의 약속: 입력 길이 맞추기와 버퍼 재사용. +public class QualcommSourceAudioPreprocessorTests +{ + private const int Required = QualcommSourceAudioPreprocessor.RequiredMono16kSamples; + private const int LogMelSize = QualcommSourceAudioPreprocessor.LogMelSize; + + /// 고정 시드 잡음. 같은 입력을 두 길로 넣어 결과가 비트까지 같은지만 보므로 값 자체는 상관없다. + private static float[] Noise(int length, int seed) + { + var random = new Random(seed); + var samples = new float[length]; + for (int index = 0; index < length; index++) + { + samples[index] = (float)(random.NextDouble() - 0.5); + } + return samples; + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(10_000)] + [InlineData(Required - 1)] + public void 짧은_입력은_뒤를_0_으로_채운_것과_같다(int length) + { + float[] shortInput = Noise(length, seed: 1); + var padded = new float[Required]; + shortInput.CopyTo(padded, 0); + var preprocessor = new QualcommSourceAudioPreprocessor(); + + Assert.Equal(preprocessor.ComputeLogMelSpectrogram(padded), preprocessor.ComputeLogMelSpectrogram(shortInput)); + } + + [Fact] + public void 긴_입력은_앞_15600샘플만_쓴다() + { + float[] longInput = Noise(Required + 1_600, seed: 2); + var preprocessor = new QualcommSourceAudioPreprocessor(); + + Assert.Equal( + preprocessor.ComputeLogMelSpectrogram(longInput.AsSpan(0, Required)), + preprocessor.ComputeLogMelSpectrogram(longInput)); + } + + [Fact] + public void 같은_인스턴스와_버퍼를_다시_써도_앞_호출이_섞이지_않는다() + { + // 짧은 입력 뒤에 긴 입력을 넣는다. 작업 버퍼에 앞 호출의 샘플이 남으면 결과가 달라진다. + float[] first = Noise(Required, seed: 3); + float[] expected = new QualcommSourceAudioPreprocessor().ComputeLogMelSpectrogram(first); + var preprocessor = new QualcommSourceAudioPreprocessor(); + var buffer = new float[LogMelSize]; + + preprocessor.ComputeLogMelSpectrogram(Noise(7_000, seed: 4), buffer); + preprocessor.ComputeLogMelSpectrogram(first, buffer); + + Assert.Equal(expected, buffer); + } + + [Fact] + public void 버퍼를_받는_쪽은_메모리를_새로_잡지_않는다() + { + // 추론은 250ms 마다 돈다. 호출마다 배열을 만들면 GC 가 캡처·렌더 스레드를 멈출 수 있다. + var preprocessor = new QualcommSourceAudioPreprocessor(); + float[] input = Noise(Required, seed: 5); + var buffer = new float[LogMelSize]; + preprocessor.ComputeLogMelSpectrogram(input, buffer); // 첫 호출의 준비 비용은 뺀다 + + long before = GC.GetAllocatedBytesForCurrentThread(); + preprocessor.ComputeLogMelSpectrogram(input, buffer); + + Assert.Equal(before, GC.GetAllocatedBytesForCurrentThread()); + } + + [Fact] + public void 받을_자리가_모자라면_거절한다() + { + var preprocessor = new QualcommSourceAudioPreprocessor(); + + Assert.Throws(() => + preprocessor.ComputeLogMelSpectrogram(new float[Required], new float[LogMelSize - 1])); + } +} diff --git a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineCadenceTests.cs b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineCadenceTests.cs new file mode 100644 index 0000000..8a6f6e1 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineCadenceTests.cs @@ -0,0 +1,200 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Concurrent; +using SoundVisualizer.Ai; +using Xunit.Abstractions; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// 분석 주기(#379). 앞의 세 테스트는 모바일 RealtimeAiPipelineCadenceTest 를 옮긴 것이고, 나머지는 스케줄러 루프가 그 계산을 실제로 +/// 쓰는지를 가짜 시계로 한 걸음씩 돌려 본다. 벽시계를 쓰는 마지막 테스트는 느슨한 하한만 본다. +/// +public class RealtimeAiPipelineCadenceTests(ITestOutputHelper output) +{ + [Fact] // inferenceTimeIsSubtractedFromTickInterval + public void 추론에_걸린_시간은_틱_간격에서_뺀다() + { + Assert.Equal(220L, RealtimeAiPipeline.RemainingTickDelayMs(250L, 1_000L, 1_030L, RealtimeAiPipeline.MinTickRestMs)); + } + + [Fact] // overrunKeepsMinimumRestInsteadOfStartingContinuousInference + public void 틱이_간격보다_길어져도_쉬지_않고_잇지_않고_최소_휴식은_쉰다() + { + Assert.Equal(100L, RealtimeAiPipeline.RemainingTickDelayMs(250L, 1_000L, 1_300L, RealtimeAiPipeline.MinTickRestMs)); + } + + [Fact] // monotonicClockAnomalyDoesNotLengthenTheConfiguredInterval + public void 끝이_시작보다_앞서_보여도_간격을_늘리지_않는다() + { + Assert.Equal(250L, RealtimeAiPipeline.RemainingTickDelayMs(250L, 1_001L, 1_000L, RealtimeAiPipeline.MinTickRestMs)); + } + + [Fact] + public void 스케줄러는_틱_시작에서_다음_시작까지_250ms를_지키고_최소_100ms는_쉰다() + { + var scheduler = new SteppedScheduler(new ManualClock(1_000)); + var durations = new Queue(new[] { 30L, 300L }); + var model = new FakeYamnetModel + { + // 추론이 걸린 시간만큼 가짜 시계를 움직인다. 이 콜백은 틱 스레드에서만 돈다. + OnInfer = _ => scheduler.Clock.Advance(durations.Count > 0 ? durations.Dequeue() : 0), + }; + using var pipeline = new RealtimeAiPipeline( + 16000, 1, model, AiTestData.ClassMap, clock: scheduler.Clock.Now, waitForNextTick: scheduler.Wait); + + pipeline.Start(); + Assert.Equal(250L, scheduler.NextDelay()); // 1,000ms: 소리가 모이기 전이라 바로 끝났다. + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + scheduler.Step(); + Assert.Equal(220L, scheduler.NextDelay()); // 1,250ms: 추론 30ms → 1,500ms 에 다음 틱 + scheduler.Step(); + Assert.Equal(100L, scheduler.NextDelay()); // 1,500ms: 추론 300ms → 쉬지 않고 잇지 않는다 + scheduler.Step(); + Assert.Equal(250L, scheduler.NextDelay()); // 1,900ms: 추론 0ms + + Assert.Equal(3, model.InferCalls); + pipeline.Stop(); + } + + [Fact] + public void 틱이_실패해도_스케줄러는_멈추지_않고_다음_틱을_돌린다() + { + var scheduler = new SteppedScheduler(new ManualClock(1_000)); + var failures = new ConcurrentQueue(); + var model = new FakeYamnetModel + { + OnInfer = call => + { + if (call <= 2) throw new InvalidOperationException($"inference {call} failed"); + }, + }; + using var pipeline = new RealtimeAiPipeline( + 16000, + 1, + model, + AiTestData.ClassMap, + tickFailed: failure => + { + failures.Enqueue(failure); + // 실패를 받는 쪽이 던져도 루프는 살아 있어야 한다. + if (failures.Count == 1) throw new InvalidOperationException("listener failed"); + }, + clock: scheduler.Clock.Now, + waitForNextTick: scheduler.Wait); + + pipeline.Start(); + scheduler.NextDelay(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + for (int tick = 0; tick < 3; tick++) + { + scheduler.Step(); + scheduler.NextDelay(); + } + + Assert.Equal(new[] { "inference 1 failed", "inference 2 failed" }, failures.Select(failure => failure.Message)); + Assert.Equal(3, model.InferCalls); + Assert.NotNull(pipeline.LastClassification()); // 세 번째 추론은 결과를 냈다. + Assert.Equal(new RealtimeAiPipeline.InferenceStats(3, 1, 0), pipeline.Stats); + pipeline.Stop(); + } + + [Fact] + public void 실제_시계에서도_결과_사이가_최소_휴식보다_짧지_않고_틱은_배경_스레드에서_돈다() + { + string? tickThread = null; + bool? background = null; + var model = new FakeYamnetModel + { + OnInfer = _ => + { + tickThread = Thread.CurrentThread.Name; + background = Thread.CurrentThread.IsBackground; + }, + }; + using var pipeline = new RealtimeAiPipeline(48000, 2, model, AiTestData.ClassMap); + pipeline.Start(); + + // 한 창은 미리 채워 둔다. 10ms 버퍼로만 채우면 Windows 의 Sleep 해상도(15.6ms) 때문에 한 창에 1.5초가 걸린다. + pipeline.Ingest(PipelineTestAudio.Noise(2, PipelineTestAudio.WindowFrames(48000) * 2)); + // 그 뒤로는 캡처처럼 다른 스레드가 10ms 버퍼를 계속 넣는다(잡음이라 게이트가 열려 있다). + float[] buffer = PipelineTestAudio.Noise(3, 480 * 2); + using var stopFeeding = new CancellationTokenSource(); + var feeder = new Thread(() => + { + while (!stopFeeding.IsCancellationRequested) + { + pipeline.Ingest(buffer); + Thread.Sleep(10); + } + }) { IsBackground = true }; + feeder.Start(); + + var timestamps = new List(); + AiClassificationResult? seen = null; + long deadline = Environment.TickCount64 + 1_600; + while (Environment.TickCount64 < deadline) + { + var result = pipeline.LastClassification(); + if (result is not null && !ReferenceEquals(result, seen)) + { + seen = result; + timestamps.Add(result.TimestampMs); + } + Thread.Sleep(5); + } + stopFeeding.Cancel(); + feeder.Join(); + // 틱 스레드가 쓴 값은 그 스레드가 끝난 뒤에 읽는다(Stop 이 기다린다). + pipeline.Stop(); + + var intervals = timestamps.Zip(timestamps.Skip(1), (a, b) => b - a).ToList(); + output.WriteLine($"results={timestamps.Count} intervals=[{string.Join(", ", intervals)}] ms"); + Assert.True(timestamps.Count >= 3, $"only {timestamps.Count} results in 1.6 s"); + // 최소 휴식은 100ms 다. 쉬지 않고 연달아 돌면 몇 ms 간격이 된다. CI 의 지연을 생각해 하한만 느슨하게 본다. + Assert.All(intervals, interval => Assert.True(interval >= 50, $"interval {interval} ms")); + Assert.Equal("SV-AiTick", tickThread); + Assert.True(background); + } + + /// + /// 스케줄러의 대기 자리. 틱이 끝나면 쉴 시간을 기록하고 테스트가 할 때까지 멈춘 뒤 가짜 시계를 그만큼 움직인다. + /// 그래서 틱 사이에 테스트가 소리를 넣거나 상태를 볼 수 있고, 실제로는 기다리지 않는다. + /// + private sealed class SteppedScheduler + { + private readonly BlockingCollection _delays = new(); + private readonly SemaphoreSlim _step = new(0); + + public SteppedScheduler(ManualClock clock) + { + Clock = clock; + } + + public ManualClock Clock { get; } + + public bool Wait(long delayMs, CancellationToken stop) + { + _delays.Add(delayMs); + try + { + _step.Wait(stop); + } + catch (OperationCanceledException) + { + return false; + } + Clock.Advance(delayMs); + return true; + } + + public long NextDelay() + { + Assert.True(_delays.TryTake(out long delay, TimeSpan.FromSeconds(10)), "the scheduler did not finish a tick"); + return delay; + } + + public void Step() => _step.Release(); + } +} diff --git a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineCloseTests.cs b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineCloseTests.cs new file mode 100644 index 0000000..bd029f3 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineCloseTests.cs @@ -0,0 +1,371 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; +using SoundVisualizer.Ai; +using Xunit.Abstractions; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// 닫기·멈추기와 추론의 경합(#38, #49). 모바일 계측 테스트 세 개를 옮겼다. +/// - CloseContract: 순서를 정해 경계를 고정한다. 모바일은 리플렉션으로 추론 잠금을 직접 쥐었지만, 여기서는 추론 안에서 멈춰 서는 +/// 가짜 모델로 "오래 걸리는 추론"을 만든다. 틱을 닫힘 검사와 잠금 사이에 세울 때만 모바일처럼 링의 잠금을 리플렉션으로 쥔다. +/// - CloseRace: 실제 모델로 무작위 위상에서 두들긴다. 해제한 세션에 들어가면 예외가 실패 알림으로 오거나 테스트 프로세스가 죽는다. +/// - SchedulerClose: 실제 스케줄러 경로에서 같은 경합을 친다. +/// 시간 단언은 CI 에서도 버티도록 하한과 넉넉한 상한만 둔다. +/// +public class RealtimeAiPipelineCloseTests(ITestOutputHelper output) +{ + /// 타이머 해상도(Windows 15.6ms)와 마감 계산의 반올림을 넉넉히 덮는다. + private const int TimerSlackMs = 100; + + /// 한도 없이 기다리는 회귀만 잡으면 된다. CI 의 지연을 생각해 넉넉히 둔다. + private const int UpperSlackMs = 2000; + + /// 닫기가 무한정 기다리도록 바뀌어도 테스트가 멈추지 않게, 가짜 추론이 붙잡는 시간에도 한도를 둔다. + private static readonly TimeSpan HoldLimit = TimeSpan.FromSeconds(10); + + private static readonly YamnetClassMap Names = AiTestData.ClassMap; + + [Fact] // closeOnMainThread_waitsForRunningInference_butGivesUpAtTimeout + public void 닫기는_돌고_있는_추론을_기다리되_1초에서_포기하고_세션은_그_추론이_끝난_뒤에_닫힌다() + { + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var model = new FakeYamnetModel + { + OnInfer = _ => + { + entered.Set(); + release.Wait(HoldLimit); + }, + }; + var pipeline = new RealtimeAiPipeline(16000, 1, model, Names, clock: new ManualClock().Now, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + AiTickOutcome? outcome = null; + var ticker = new Thread(() => outcome = pipeline.RunTickOnce(0)) { IsBackground = true }; + ticker.Start(); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "inference did not start"); + + long started = Stopwatch.GetTimestamp(); + pipeline.Dispose(); + double elapsedMs = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + bool disposedWhileRunning = model.IsDisposed; + release.Set(); + Assert.True(ticker.Join(TimeSpan.FromSeconds(5)), "tick did not finish"); + output.WriteLine($"Dispose while a tick is inside the model: {elapsedMs:F0} ms (limit {RealtimeAiPipeline.CloseWaitMs} ms)"); + + // 기다리지 않고 닫으면 쓰는 중인 세션을 해제한다(use-after-free). 한도 없이 기다리면 UI 스레드가 멈춘다. + Assert.True(elapsedMs >= RealtimeAiPipeline.CloseWaitMs - TimerSlackMs, $"Dispose returned after {elapsedMs} ms without waiting"); + Assert.True(elapsedMs < RealtimeAiPipeline.CloseWaitMs + UpperSlackMs, $"Dispose blocked for {elapsedMs} ms"); + Assert.False(disposedWhileRunning, "the session was released while the inference was still using it"); + + // 닫기는 틱을 죽이지 않았고, 그 틱이 끝나면서 세션을 닫았다(모바일은 이때 세션을 버린다). + Assert.Equal(AiTickOutcome.Completed, outcome); + Assert.Equal(1, model.DisposeCalls); + Assert.False(model.DisposedDuringInference); + Assert.Null(pipeline.LastClassification()); // 닫힌 뒤 끝난 추론의 결과는 보이지 않는다. + } + + [Fact] // closeWhileIdle_closesSessionsRightAway + public void 한가할_때_닫으면_세션을_바로_닫는다() + { + var yamnet = YamnetInference.Create(AiTestData.ModelDirectory); + var pipeline = new RealtimeAiPipeline(16000, 1, yamnet, Names, clock: new ManualClock().Now, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); // 세션을 데워 둔다. + + long started = Stopwatch.GetTimestamp(); + pipeline.Dispose(); + double elapsedMs = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + output.WriteLine($"idle Dispose: {elapsedMs:F1} ms"); + + Assert.True(elapsedMs < RealtimeAiPipeline.CloseWaitMs, $"idle Dispose took {elapsedMs} ms"); + // 닫힌 세션은 네이티브로 들어가기 전에 막힌다(모바일의 "Trying to score a closed OrtSession." 확인과 같다). + Assert.Throws(() => + yamnet.Infer(new float[YamnetInference.LogMelSize], new float[YamnetInference.NumClasses], new float[YamnetInference.NumClasses])); + } + + [Fact] // tickEnteredBeforeClose_doesNotInferOnceClosedIsSet + public void 닫기_전에_들어온_틱도_잠금을_잡은_뒤_닫힌_것을_보면_추론하지_않는다() + { + var failures = new ConcurrentQueue(); + var model = new FakeYamnetModel(); + var pipeline = new RealtimeAiPipeline( + 16000, 1, model, Names, tickFailed: failures.Enqueue, clock: new ManualClock().Now, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); + int before = model.InferCalls; + + // 링의 잠금을 쥐고 있으면 틱은 닫힘 검사를 지난 뒤 링 검사에서 멈춘다. 그사이 실제 Dispose 가 닫힘을 세우고 모델을 닫는다. + // Dispose 는 링을 건드리지 않으므로 여기서 막히지 않는다. 잠금 안의 재확인이 없으면 틱이 닫힌 모델로 추론한다. + object ringLock = RingLock(pipeline.AudioBufferForTest); + AiTickOutcome? outcome = null; + var ticker = new Thread(() => outcome = pipeline.RunTickOnce(250)) { IsBackground = true }; + lock (ringLock) + { + ticker.Start(); + AwaitBlocked(ticker); + pipeline.Dispose(); + Assert.Equal(1, model.DisposeCalls); + } + + Assert.True(ticker.Join(TimeSpan.FromSeconds(10)), "tick did not finish"); + Assert.Equal(AiTickOutcome.Closed, outcome); + Assert.Equal(before, model.InferCalls); + Assert.False(model.UsedAfterDispose); + Assert.Empty(failures); + } + + [Fact] // closeDuringInference_neverCrashesOrThrows + public void 실제_모델로_추론하는_중에_닫아도_예외나_크래시가_없다() + { + const int iterations = 25; + var random = new Random(1234); + float[] chunk = PipelineTestAudio.Noise(99, 2 * 44100); + long totalExecuted = 0; + double maxCloseMs = 0; + + for (int i = 0; i < iterations; i++) + { + var failures = new ConcurrentQueue(); + var yamnet = YamnetInference.Create(AiTestData.ModelDirectory); + // 프로덕션의 틱 경로(RunTickOnce)를 테스트 스레드에서 쉬지 않고 돌려, 닫기가 추론 한복판에 걸리게 한다. + var pipeline = new RealtimeAiPipeline(44100, 2, yamnet, Names, tickFailed: failures.Enqueue, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(chunk); + Exception? tickerFailure = null; + using var stop = new CancellationTokenSource(); + var ticker = new Thread(() => + { + try + { + while (!stop.IsCancellationRequested) pipeline.RunTickOnce(Environment.TickCount64); + } + catch (Exception ex) + { + tickerFailure = ex; + } + }) { IsBackground = true }; + ticker.Start(); + + // 추론이 실제로 시작된 뒤에 친다. 그냥 몇 ms 자고 닫으면 빈 잠금만 거듭 확인하게 된다(#49). + Assert.True(AwaitFirstInference(pipeline, TimeSpan.FromSeconds(5)), $"iteration {i}: inference did not start"); + int jitterMs = random.Next(0, 4); + if (jitterMs > 0) Thread.Sleep(jitterMs); + + long started = Stopwatch.GetTimestamp(); + pipeline.Dispose(); + maxCloseMs = Math.Max(maxCloseMs, Stopwatch.GetElapsedTime(started).TotalMilliseconds); + stop.Cancel(); + Assert.True(ticker.Join(TimeSpan.FromSeconds(10)), $"iteration {i}: ticker did not stop"); + + Assert.Null(tickerFailure); + Assert.Empty(failures); // 해제한 세션에 들어간 틱이 있었다면 ObjectDisposedException 이 여기 온다. + var stats = pipeline.Stats; + Assert.Equal(stats.Executed, stats.Completed); // 시작한 추론은 모두 결과를 남기고 끝났다. + Assert.Throws(() => + yamnet.Infer(new float[YamnetInference.LogMelSize], new float[YamnetInference.NumClasses], new float[YamnetInference.NumClasses])); + totalExecuted += stats.Executed; + } + + output.WriteLine($"close race: executed={totalExecuted} in {iterations} iterations, max Dispose {maxCloseMs:F1} ms"); + Assert.True(totalExecuted >= iterations); + Assert.True(maxCloseMs < RealtimeAiPipeline.CloseWaitMs + UpperSlackMs, $"Dispose blocked for {maxCloseMs} ms"); + } + + [Fact] // schedulerPath_closeDuringInference_neverLosesATick + public void 스케줄러가_추론하는_중에_닫아도_틱이_도중에_죽지_않는다() + { + const int iterations = 8; + var random = new Random(4321); + float[] chunk = PipelineTestAudio.Noise(7, 2 * 44100); + long totalExecuted = 0; + int iterationsWithInference = 0; + + for (int i = 0; i < iterations; i++) + { + var failures = new ConcurrentQueue(); + var pipeline = RealtimeAiPipeline.Create(44100, 2, AiTestData.ModelDirectory, Names, tickFailed: failures.Enqueue); + pipeline.Start(); + pipeline.Ingest(chunk); // 시작한 뒤에만 받는다(캡처 스레드와 같다). + + if (AwaitFirstInference(pipeline, TimeSpan.FromSeconds(3))) iterationsWithInference++; + int jitterMs = random.Next(0, 4); + if (jitterMs > 0) Thread.Sleep(jitterMs); + pipeline.Dispose(); + + // 1초에서 포기한 닫기 뒤에도 돌던 추론은 끝난다. 그 완료만 잠깐 기다린다. + long executed = pipeline.Stats.Executed; + AwaitCompleted(pipeline, executed, TimeSpan.FromSeconds(2)); + Assert.Equal(executed, pipeline.Stats.Completed); + Assert.Empty(failures); + totalExecuted += executed; + } + + output.WriteLine($"scheduler close: executed={totalExecuted} in {iterationsWithInference}/{iterations} iterations"); + Assert.True(totalExecuted > 0, "no inference ran during the race; the test would prove nothing"); + Assert.True(iterationsWithInference >= iterations / 2, $"only {iterationsWithInference}/{iterations} iterations reached inference"); + } + + [Fact] + public void 다시_시작하면_앞선_추론이_끝나기를_기다렸다_비우므로_그_결과가_되살아나지_않는다() + { + // 모바일은 멈출 때 돌던 추론을 기다리지 않아, 그 결과가 다시 시작한 뒤에 되살아날 수 있었다(서비스가 재시작을 미뤄 피했다). + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var model = new FakeYamnetModel + { + OnInfer = call => + { + if (call != 1) return; + entered.Set(); + release.Wait(HoldLimit); + }, + }; + using var pipeline = new RealtimeAiPipeline(16000, 1, model, Names, clock: new ManualClock().Now, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + AiTickOutcome? outcome = null; + var ticker = new Thread(() => outcome = pipeline.RunTickOnce(0)) { IsBackground = true }; + ticker.Start(); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "inference did not start"); + + pipeline.Stop(); + // 다시 시작하는 스레드가 추론 잠금에서 기다리기 시작한 뒤에 앞선 추론을 끝낸다. + var starter = Thread.CurrentThread; + var releaser = new Thread(() => + { + AwaitBlocked(starter); + release.Set(); + }) { IsBackground = true }; + releaser.Start(); + pipeline.Start(); + + Assert.True(ticker.Join(TimeSpan.FromSeconds(5)), "tick did not finish"); + Assert.Equal(AiTickOutcome.Completed, outcome); // 앞선 추론은 결과를 게시하고 끝났지만 + Assert.Null(pipeline.LastClassification()); // 다시 시작하며 비웠으므로 보이지 않는다. + Assert.Equal(new RealtimeAiPipeline.InferenceStats(0, 0, 0), pipeline.Stats); + Assert.Equal(0, pipeline.AudioBufferForTest.AvailableSamples); + } + + [Fact] + public void 앞선_추론이_1초_넘게_끝나지_않으면_다시_시작하지_않고_알린다() + { + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var model = new FakeYamnetModel + { + OnInfer = call => + { + if (call != 1) return; + entered.Set(); + release.Wait(HoldLimit); + }, + }; + using var pipeline = new RealtimeAiPipeline(16000, 1, model, Names, clock: new ManualClock().Now, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + var ticker = new Thread(() => pipeline.RunTickOnce(0)) { IsBackground = true }; + ticker.Start(); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "inference did not start"); + pipeline.Stop(); + + long started = Stopwatch.GetTimestamp(); + Assert.Throws(() => pipeline.Start()); + double elapsedMs = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + Assert.True(elapsedMs >= RealtimeAiPipeline.CloseWaitMs - TimerSlackMs, $"Start gave up after {elapsedMs} ms"); + Assert.False(pipeline.IsRunning); + + release.Set(); + Assert.True(ticker.Join(TimeSpan.FromSeconds(5)), "tick did not finish"); + pipeline.Start(); // 추론이 끝났으니 이제 시작한다. + Assert.True(pipeline.IsRunning); + } + + [Fact] + public void 멈추면_돌고_있는_틱을_기다리고_그_뒤로는_틱이_돌지_않는다() + { + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var model = new FakeYamnetModel + { + OnInfer = call => + { + if (call != 1) return; + entered.Set(); + release.Wait(HoldLimit); + }, + }; + // 실제 스케줄러와 시계로 돈다. 첫 틱은 소리가 모이기 전이고, 250ms 뒤의 틱이 추론에 들어가 멈춰 선다. + using var pipeline = new RealtimeAiPipeline(16000, 1, model, Names); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "inference did not start"); + + var releaser = new Thread(() => + { + Thread.Sleep(200); + release.Set(); + }) { IsBackground = true }; + releaser.Start(); + long started = Stopwatch.GetTimestamp(); + pipeline.Stop(); + double elapsedMs = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + + // Stop 은 돌던 추론이 끝날 때까지 기다렸다(약 200ms). + Assert.True(elapsedMs >= 150, $"Stop returned after {elapsedMs} ms without waiting for the running tick"); + Assert.True(elapsedMs < RealtimeAiPipeline.CloseWaitMs + UpperSlackMs, $"Stop blocked for {elapsedMs} ms"); + Assert.Equal(1, model.InferCalls); + // 틱 두 번 넘게 지나도 더는 추론하지 않는다. + Thread.Sleep(600); + Assert.Equal(1, model.InferCalls); + Assert.Null(pipeline.LastClassification()); + } + + /// 링 버퍼의 내부 잠금. 틱을 "닫힘 검사 직후 ~ 추론 잠금 직전" 에 세울 수 있는 유일한 자리다(모바일 PipelineInternals.ringMonitor). + private static object RingLock(AiAudioBuffer buffer) + { + var field = typeof(AiAudioBuffer).GetField("_lock", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); // 이름이 바뀌면 여기서 바로 드러난다. + return field.GetValue(buffer)!; + } + + private static void AwaitBlocked(Thread thread) + { + long deadline = Environment.TickCount64 + 5_000; + while (Environment.TickCount64 < deadline) + { + if ((thread.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0) return; + Thread.Sleep(1); + } + throw new TimeoutException($"thread did not block (state={thread.ThreadState})"); + } + + /// Executed 가 오르면 그 추론은 지금 추론 잠금 안에서 돌고 있다. + private static bool AwaitFirstInference(RealtimeAiPipeline pipeline, TimeSpan timeout) + { + long deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + while (Environment.TickCount64 < deadline) + { + if (pipeline.Stats.Executed > 0) return true; + Thread.Sleep(1); + } + return false; + } + + private static void AwaitCompleted(RealtimeAiPipeline pipeline, long expected, TimeSpan timeout) + { + long deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + while (Environment.TickCount64 < deadline && pipeline.Stats.Completed < expected) + { + Thread.Sleep(1); + } + } +} diff --git a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineTests.cs b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineTests.cs new file mode 100644 index 0000000..c8c1a19 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineTests.cs @@ -0,0 +1,468 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using Xunit.Abstractions; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// 실제 YAMNet 으로 도는 파이프라인을 스케줄러 없이 틱 하나씩, 시각을 정해 돌린다. 모바일 계측 테스트 +/// RealtimeAiPipelineInstrumentedTest 를 옮기고, 무음 게이트·실제 소리·실패·수명 경로를 더했다. +/// +public class RealtimeAiPipelineTests(ITestOutputHelper output) +{ + private const int Window44 = 42998; + private static readonly YamnetClassMap Names = AiTestData.ClassMap; + + [Fact] + public void 한_창이_모이기_전에는_추론하지_않는다() + { + var clock = new ManualClock(); + using var pipeline = NewPipeline(44100, 2, clock); + pipeline.Start(); + float[] alarm = Alarm(); + + PipelineTestAudio.IngestInChunks(pipeline, alarm[..^2]); // 한 프레임 모자란다. + Assert.Equal(AiTickOutcome.NotEnoughAudio, pipeline.RunTickOnce(clock.Now())); + Assert.Equal(0, pipeline.Stats.Executed); + Assert.Null(pipeline.LastClassification()); + + pipeline.Ingest(alarm.AsSpan(alarm.Length - 2)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(clock.Now())); + Assert.NotNull(pipeline.LastClassification()); + } + + [Fact] + public void 소리가_그치면_창이_빠져나간_뒤_조용한_추론_두_번을_마치고_추론을_멈춘다() + { + var clock = new ManualClock(10_000); + using var pipeline = NewPipeline(44100, 2, clock); + pipeline.Start(); + + PipelineTestAudio.IngestInChunks(pipeline, Alarm()); // 10,000ms 의 소리가 마지막 소리다. + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(10_000)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(10_500)); + clock.Set(10_900); + PipelineTestAudio.IngestInChunks(pipeline, new float[Window44 * 2]); // 무음은 게이트를 다시 열지 않는다. + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(10_975)); // 창(975ms)이 지난 뒤 조용한 추론 1 + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(11_225)); // 조용한 추론 2 + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(11_475)); + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(20_000)); + Assert.Equal(new RealtimeAiPipeline.InferenceStats(4, 4, 2), pipeline.Stats); + + // 조용한 추론으로 후처리를 정리했으므로 마지막 라벨은 소리가 그친 뒤의 무음이다. + var last = pipeline.LastClassification(); + Assert.NotNull(last); + Assert.Equal(SoundType.Ambient, last.Coarse); + Assert.Equal("Silence", last.Display); + + // 소리가 다시 나면 바로 연다. + clock.Set(20_000); + PipelineTestAudio.IngestInChunks(pipeline, Alarm()); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(20_000)); + } + + [Fact] + public void 조용한_창의_결과는_Python_e2e_기준과_같다() + { + // yamnet_e2e_meta.json 에서 무음 경우만은 지금 파이프라인에도 유효하다. 무음의 로그멜은 옛 전처리와 Qualcomm 전처리에서 + // 똑같이 ln(0.001) 이고(e2e_silence_logmel.bin 과 qualcomm_source_silence_logmel.bin 이 같은 파일이다), 옛 Booster 도 + // 이 경우를 받아들이지 않아 결과에 흔적이 없다. + var meta = AiTestData.LoadJson("yamnet_e2e_meta.json").GetProperty("cases").GetProperty("silence"); + var clock = new ManualClock(); + using var pipeline = NewPipeline(44100, 2, clock); + pipeline.Start(); + + // 무음만으로는 게이트가 열리지 않는다. 짧은 소리로 연 뒤 한 창을 무음으로 채우면 창에는 무음만 남는다. + pipeline.Ingest(Enumerable.Repeat(0.5f, 2048).ToArray()); + clock.Set(10); + PipelineTestAudio.IngestInChunks(pipeline, new float[Window44 * 2]); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(10)); + + Assert.True(pipeline.Mono16kForTest.IndexOfAnyExcept(0f) < 0, "window must be pure silence"); + Assert.True(MaxAbs(pipeline.LogMelForTest, Load("qualcomm_source_silence_logmel.bin", QualcommSourceAudioPreprocessor.LogMelSize)) < 1e-6); + double probError = MaxAbs(pipeline.ProbabilitiesForTest, Load("e2e_silence_probs.bin", YamnetInference.NumClasses)); + output.WriteLine($"silence probs vs Python e2e maxAbs={probError:E3}"); + Assert.True(probError < 1e-4, $"silence probs maxAbs {probError}"); + + var result = pipeline.LastClassification(); + Assert.NotNull(result); + Assert.Equal(meta.GetProperty("ui_coarse").GetString(), result.Coarse.ToLabel()); + Assert.Equal(meta.GetProperty("ui_display").GetString(), result.Display); + // 옛 이름 pre_booster_coarse 는 상위 3개 투표다. Booster 가 없어진 지금은 투표가 곧 결과의 바탕이다. + var vote = new YamnetCoarseClassifier(Names).Classify(pipeline.ProbabilitiesForTest.ToArray()); + Assert.Equal(meta.GetProperty("pre_booster_coarse").GetString(), vote.Coarse.ToLabel()); + } + + [Fact] + public void 실제_소리를_캡처처럼_넣으면_결과를_게시하고_소요시간과_상위_5개가_그럴듯하다() + { + // yamnet_e2e_meta.json 의 창 크기는 지금 파이프라인과 같다. + var meta = AiTestData.LoadJson("yamnet_e2e_meta.json"); + Assert.Equal(44100, meta.GetProperty("capture_sample_rate").GetInt32()); + Assert.Equal(CaptureAudioMath.RequiredMono16kSamples, meta.GetProperty("required_mono_16k").GetInt32()); + Assert.Equal(PipelineTestAudio.WindowFrames(44100), meta.GetProperty("capture_window_samples").GetInt32()); + // 알람·총소리 경우의 pre_booster_coarse·gunshot_score·booster_accepted·ui_coarse·ui_display 는 확인하지 않는다. + // 없앤 Gunshot Booster 와 옛 전처리(AudioPreprocessor, e2e_*_logmel.bin) 시절의 값이다. 지금의 Qualcomm 로그멜은 + // 이 두 소리에서 옛 로그멜과 최대 8~10 이나 다르고, 지금 파이프라인은 이 알람을 Music(환경음)으로 듣는다. + // 메타의 위협음은 Booster 가 받아들여 바꾼 결과다(알람의 "Gunshot, gunfire" 표시도 그 오탐이다, #116). + + var clock = new ManualClock(5_000); + using var pipeline = NewPipeline(44100, 2, clock); + pipeline.Start(); + PipelineTestAudio.IngestInChunks(pipeline, Alarm()); + long before = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(clock.Now())); + long after = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // 링 → 16kHz 는 Python 기준과, 로그멜은 Qualcomm 원본 전처리의 Python 기준과 같다. + double monoError = MaxAbs(pipeline.Mono16kForTest, Load("e2e_alarm_mono16k.bin", CaptureAudioMath.RequiredMono16kSamples)); + float[] qualcommLogMel = Load("qualcomm_source_alarm_logmel.bin", QualcommSourceAudioPreprocessor.LogMelSize); + double logMelError = MaxAbs(pipeline.LogMelForTest, qualcommLogMel); + // 확률은 그 Qualcomm 기준 로그멜을 모델에 바로 넣은 것과 같다. + var expectedProbs = new float[YamnetInference.NumClasses]; + using (var direct = YamnetInference.Create(AiTestData.ModelDirectory)) + { + direct.Infer(qualcommLogMel, new float[YamnetInference.NumClasses], expectedProbs); + } + double probError = MaxAbs(pipeline.ProbabilitiesForTest, expectedProbs); + var result = pipeline.LastClassification(); + Assert.NotNull(result); + output.WriteLine($"mono16k maxAbs={monoError:E3} logmel maxAbs={logMelError:E3} probs maxAbs={probError:E3}"); + output.WriteLine($"{result.Coarse}/{result.Display}/{result.Confidence:F4} pre={result.PreprocessMs:F2}ms yamnet={result.YamnetMs:F2}ms total={result.TotalMs:F2}ms"); + Assert.True(monoError < 1e-5, $"mono16k maxAbs {monoError}"); + Assert.True(logMelError < 1e-5, $"log-mel maxAbs {logMelError}"); + Assert.True(probError < 1e-4, $"probs maxAbs {probError}"); + + // 상위 5개는 이번 확률에서 큰 순서로 고른 실제 클래스다. + float[] probs = pipeline.ProbabilitiesForTest.ToArray(); + Assert.Equal(5, result.Top5.Count); + Assert.Equal(Enumerable.Range(0, probs.Length).OrderByDescending(i => probs[i]).Take(5), result.Top5.Select(hit => hit.Index)); + foreach (var hit in result.Top5) + { + Assert.Equal(Names[hit.Index], hit.Name); + Assert.Equal(probs[hit.Index], hit.Probability); + } + + // 소요시간은 밀리초로, 전처리·모델이 전체 안에 든다. 상한은 단위를 잘못 넣었는지만 본다(틱 단위면 1만 배가 된다). + Assert.True(result.PreprocessMs > 0); + Assert.True(result.YamnetMs > 0); + Assert.True(result.TotalMs >= result.PreprocessMs + result.YamnetMs - 1e-6); + Assert.True(result.TotalMs < 10_000, $"total {result.TotalMs} ms"); + Assert.InRange(result.TimestampMs, before, after); + Assert.Equal("default", result.MappingSignature); + Assert.Equal(0, result.MappingOverrideCount); + } + + [Fact] // realtimePipeline_logMelMatchesQualcommFrontend + public void 파이프라인은_Qualcomm_로그멜을_쓴다() + { + float[] mono = PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples); + var clock = new ManualClock(); + using var pipeline = NewPipeline(16000, 1, clock); + pipeline.Start(); + + // 캡처 쪽 입구로 넣어야 무음 게이트가 열려 틱이 링을 읽는다. + pipeline.Ingest(mono); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(clock.Now())); + + float[] expected = new QualcommSourceAudioPreprocessor().ComputeLogMelSpectrogram(mono); + Assert.True(MaxAbs(pipeline.LogMelForTest, expected) < 1e-5, "pipeline did not use the Qualcomm frontend"); + } + + [Fact] // runtimeMappingChangeInvalidatesPreviousResultAndResetsConfirmation + public void 실행_중_매핑이_바뀌면_옛_결과를_바로_숨기고_새_매핑으로_투표한다() + { + IReadOnlyDictionary overrides = Names.ToDictionary(name => name, _ => "danger"); + float[] mono = PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples); + var clock = new ManualClock(); + using var pipeline = NewPipeline(16000, 1, clock, () => overrides); + pipeline.Start(); + + pipeline.Ingest(mono); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(clock.Now())); + var danger = Vote(pipeline, overrides); + Assert.True(danger.DangerScore > 0f); + Assert.Equal(0f, danger.AmbientScore); + Assert.NotNull(pipeline.LastClassification()); + + overrides = Names.ToDictionary(name => name, _ => "ambient"); + Assert.Null(pipeline.LastClassification()); // 다음 추론을 기다리지 않고 바로 사라진다. + pipeline.Ingest(mono); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(clock.Now())); + Assert.Equal(0f, Vote(pipeline, overrides).DangerScore); + var ambient = pipeline.LastClassification(); + Assert.NotNull(ambient); + Assert.Equal(SoundType.Ambient, ambient.Coarse); + Assert.Equal(YamnetMappingPolicy.From(overrides, Names).Signature, ambient.MappingSignature); + + overrides = new Dictionary(); + Assert.Null(pipeline.LastClassification()); + pipeline.Ingest(mono); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(clock.Now())); + Assert.Equal("default", pipeline.LastClassification()?.MappingSignature); + } + + [Fact] + public void 매핑이_바뀌면_확정을_처음부터_다시_해서_옛_위협음에서_곧바로_넘어가지_않는다() + { + // 모두 위협음이면 알람(Music .97)이 첫 프레임에 위협음으로 확정된다. 모두 대화음으로 바꾸면 히스테리시스가 처음(환경음)부터 + // 다시 시작해 대화음은 두 프레임이 이어져야 확정된다. 다시 시작하지 않았다면 위협음에서 확신도 0.27 이상으로 빠져나가는 + // 규칙 때문에 첫 프레임에 대화음이 됐을 것이다. + IReadOnlyDictionary overrides = Names.ToDictionary(name => name, _ => "danger"); + var clock = new ManualClock(); + using var pipeline = NewPipeline(44100, 2, clock, () => overrides); + pipeline.Start(); + PipelineTestAudio.IngestInChunks(pipeline, Alarm()); + + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); + Assert.Equal(SoundType.Danger, pipeline.LastClassification()?.Coarse); + + overrides = Names.ToDictionary(name => name, _ => "speech"); + Assert.Null(pipeline.LastClassification()); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(250)); + var first = pipeline.LastClassification(); + Assert.NotNull(first); + Assert.True(first.MeetsThreshold); + Assert.Equal(SoundType.Ambient, first.Coarse); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(500)); + Assert.Equal(SoundType.Speech, pipeline.LastClassification()?.Coarse); + } + + [Fact] + public void 무음으로_건너뛴_틱에도_매핑을_반영해_옛_위협음이_되살아나지_않는다() + { + IReadOnlyDictionary allDanger = Names.ToDictionary(name => name, _ => "danger"); + IReadOnlyDictionary overrides = allDanger; + var clock = new ManualClock(); + using var pipeline = NewPipeline(44100, 2, clock, () => overrides); + pipeline.Start(); + PipelineTestAudio.IngestInChunks(pipeline, Alarm()); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(975)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(1_225)); + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(1_475)); + Assert.Equal(SoundType.Danger, pipeline.LastClassification()?.Coarse); + + // 조용한 동안 사용자가 모두 환경음으로 바꿨다가 되돌린다. 바꾼 사이에 돈 틱이 무음으로 건너뛰어도 매핑을 반영해 + // 확정 상태와 마지막 결과를 비운다. 반영하지 않으면 되돌린 순간 옛 매핑의 위협음 결과가 다시 보인다. + overrides = Names.ToDictionary(name => name, _ => "ambient"); + Assert.Null(pipeline.LastClassification()); + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(1_725)); + overrides = allDanger; + Assert.Null(pipeline.LastClassification()); + } + + [Fact] + public void 시작_전과_멈춘_뒤에는_소리를_받지_않고_결과를_숨기며_다시_시작하면_처음부터_한다() + { + float[] mono = PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples); + var clock = new ManualClock(); + using var pipeline = NewPipeline(16000, 1, clock); + + pipeline.Ingest(mono); // 시작 전에는 버린다. + Assert.Equal(0, pipeline.AudioBufferForTest.AvailableSamples); + Assert.False(pipeline.IsRunning); + + pipeline.Start(); + Assert.True(pipeline.IsRunning); + pipeline.Ingest(mono); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(clock.Now())); + var result = pipeline.LastClassification(); + Assert.NotNull(result); + + pipeline.Start(); // 이미 돌고 있으면 아무것도 하지 않는다(비우지 않는다). + Assert.Same(result, pipeline.LastClassification()); + Assert.Equal(mono.Length, pipeline.AudioBufferForTest.AvailableSamples); + + pipeline.Stop(); + Assert.False(pipeline.IsRunning); + Assert.Null(pipeline.LastClassification()); // 멈추면 마지막 결과를 바로 숨긴다. + pipeline.Ingest(mono); // 멈춘 뒤에도 버린다. + Assert.Equal(mono.Length, pipeline.AudioBufferForTest.AvailableSamples); + pipeline.Stop(); // 여러 번 불러도 된다. + + pipeline.Start(); // 링·게이트·히스테리시스·결과·횟수를 비운다. + Assert.Equal(0, pipeline.AudioBufferForTest.AvailableSamples); + Assert.Equal(new RealtimeAiPipeline.InferenceStats(0, 0, 0), pipeline.Stats); + Assert.Null(pipeline.LastClassification()); + Assert.Equal(AiTickOutcome.NotEnoughAudio, pipeline.RunTickOnce(clock.Now())); + } + + [Fact] + public void 추론이_실패하면_알리고_무음_게이트에_넘기며_되살아나면_다시_결과를_낸다() + { + var failures = new List(); + var model = new FakeYamnetModel { OnInfer = _ => throw new InvalidOperationException("inference failed") }; + var clock = new ManualClock(); + using var pipeline = new RealtimeAiPipeline( + 16000, 1, model, Names, tickFailed: failures.Add, clock: clock.Now, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + + Assert.Equal(AiTickOutcome.Failed, pipeline.RunTickOnce(0)); + Assert.IsType(Assert.Single(failures)); + Assert.Equal(new RealtimeAiPipeline.InferenceStats(1, 0, 0), pipeline.Stats); + Assert.Null(pipeline.LastClassification()); + + // 소리가 창을 빠져나간 뒤에도 계속 실패하면 무음 게이트가 창 네 개(3.9초)가 지나 추론을 멈춘다. 실패가 게이트에 닿았다는 뜻이다. + Assert.Equal(AiTickOutcome.Failed, pipeline.RunTickOnce(1_000)); + Assert.Equal(AiTickOutcome.Failed, pipeline.RunTickOnce(1_000 + AiSilenceGate.FailureFallbackMs - 1)); + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(1_000 + AiSilenceGate.FailureFallbackMs)); + Assert.Equal(3, failures.Count); + + model.OnInfer = null; + clock.Set(6_000); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(6_000)); + Assert.NotNull(pipeline.LastClassification()); + } + + [Fact] + public void 사용자_지정을_읽다_실패하면_알리고_추론하지_않는다() + { + var failures = new List(); + bool broken = true; + var model = new FakeYamnetModel(); + var clock = new ManualClock(); + using var pipeline = new RealtimeAiPipeline( + 16000, + 1, + model, + Names, + () => broken ? throw new InvalidOperationException("settings unavailable") : new Dictionary(), + failures.Add, + clock.Now, + runScheduler: false); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + + Assert.Equal(AiTickOutcome.Failed, pipeline.RunTickOnce(0)); + Assert.Equal("settings unavailable", Assert.Single(failures).Message); + Assert.Equal(0, model.InferCalls); + + broken = false; + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(250)); + } + + [Fact] + public void 실패를_받는_쪽이_던져도_틱은_예외를_내지_않는다() + { + var model = new FakeYamnetModel { OnInfer = _ => throw new InvalidOperationException("inference failed") }; + var clock = new ManualClock(); + using var pipeline = new RealtimeAiPipeline( + 16000, 1, model, Names, tickFailed: _ => throw new InvalidOperationException("listener failed"), clock: clock.Now, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples)); + + Assert.Equal(AiTickOutcome.Failed, pipeline.RunTickOnce(0)); + } + + [Fact] + public void 닫은_뒤에는_틱도_소리도_결과도_없고_두_번_닫아도_된다() + { + float[] mono = PipelineTestAudio.Sine(CaptureAudioMath.RequiredMono16kSamples); + var model = new FakeYamnetModel(); + var clock = new ManualClock(); + var pipeline = new RealtimeAiPipeline(16000, 1, model, Names, clock: clock.Now, runScheduler: false); + pipeline.Start(); + pipeline.Ingest(mono); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); + + pipeline.Dispose(); + Assert.Equal(1, model.DisposeCalls); + Assert.False(pipeline.IsRunning); + Assert.Null(pipeline.LastClassification()); + Assert.Equal(AiTickOutcome.Closed, pipeline.RunTickOnce(250)); + pipeline.Start(); + Assert.False(pipeline.IsRunning); + pipeline.Ingest(mono); + Assert.Equal(mono.Length, pipeline.AudioBufferForTest.AvailableSamples); + + pipeline.Dispose(); + Assert.Equal(1, model.DisposeCalls); + Assert.Equal(1, model.InferCalls); + Assert.False(model.UsedAfterDispose); + } + + [Theory] + [InlineData(22050)] + [InlineData(8000)] + [InlineData(0)] + public void AI_가_받지_않는_레이트면_모델을_읽기_전에_거절한다(int sampleRate) + { + // 없는 모델 폴더를 준다. 레이트에서 먼저 걸러야 파일을 찾지 못했다는 오류가 나지 않는다. + string missing = Path.Combine(Path.GetTempPath(), "sv-no-model-" + Guid.NewGuid().ToString("N")); + + var error = Assert.Throws(() => RealtimeAiPipeline.Create(sampleRate, 2, missing, Names)); + + Assert.Equal("captureSampleRate", error.ParamName); + } + + [Fact] + public void 채널이_없거나_클래스가_521개가_아니거나_모델이_없으면_만들지_않는다() + { + string missing = Path.Combine(Path.GetTempPath(), "sv-no-model-" + Guid.NewGuid().ToString("N")); + + Assert.Equal("channels", Assert.Throws(() => RealtimeAiPipeline.Create(44100, 0, missing, Names)).ParamName); + var threeNames = new YamnetClassMap(new[] { "Speech", "Music", "Silence" }); + Assert.Equal("classes", Assert.Throws(() => RealtimeAiPipeline.Create(44100, 2, missing, threeNames)).ParamName); + Assert.Throws(() => RealtimeAiPipeline.Create(44100, 2, missing, Names)); + } + + [Theory] + [InlineData(16000)] + [InlineData(44100)] + [InlineData(48000)] + [InlineData(88200)] + [InlineData(96000)] + [InlineData(176400)] + [InlineData(192000)] + public void AI_가_받는_모든_레이트에서_한_창이_모이면_추론한다(int sampleRate) + { + int frames = PipelineTestAudio.WindowFrames(sampleRate); + float[] stereo = PipelineTestAudio.Sine(frames, channels: 2); + var model = new FakeYamnetModel(); + var clock = new ManualClock(); + using var pipeline = new RealtimeAiPipeline(sampleRate, 2, model, Names, clock: clock.Now, runScheduler: false); + pipeline.Start(); + + PipelineTestAudio.IngestInChunks(pipeline, stereo[..^2], chunk: 4096); + Assert.Equal(AiTickOutcome.NotEnoughAudio, pipeline.RunTickOnce(0)); + pipeline.Ingest(stereo.AsSpan(stereo.Length - 2)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); + + Assert.Equal(1, model.InferCalls); + Assert.NotNull(pipeline.LastClassification()); + Assert.Equal(sampleRate, pipeline.SampleRate); + Assert.Equal(2, pipeline.ChannelCount); + // 16kHz 로 바꾼 창 끝부분에 소리가 들어 있다(사인의 크기 0.4 안팎). + float peak = 0f; + foreach (float value in pipeline.Mono16kForTest[^1000..]) peak = Math.Max(peak, Math.Abs(value)); + Assert.InRange(peak, 0.3f, 0.5f); + } + + private static RealtimeAiPipeline NewPipeline( + int sampleRate, + int channels, + ManualClock clock, + Func>? overrides = null) => + new(sampleRate, channels, YamnetInference.Create(AiTestData.ModelDirectory), Names, overrides, clock: clock.Now, runScheduler: false); + + /// Python 기준 구현이 만든 실제 알람 소리(44.1kHz 스테레오, 한 창). + private static float[] Alarm() => AiTestData.LoadFloats("e2e_alarm_stereo44100.bin", Window44 * 2); + + private static float[] Load(string fileName, int count) => AiTestData.LoadFloats(fileName, count); + + private static YamnetCoarseClassifier.Result Vote(RealtimeAiPipeline pipeline, IReadOnlyDictionary overrides) => + new YamnetCoarseClassifier(Names).Classify(pipeline.ProbabilitiesForTest.ToArray(), YamnetMappingPolicy.From(overrides, Names)); + + private static double MaxAbs(ReadOnlySpan actual, ReadOnlySpan expected) + { + Assert.Equal(expected.Length, actual.Length); + double max = 0; + for (int i = 0; i < actual.Length; i++) max = Math.Max(max, Math.Abs((double)actual[i] - expected[i])); + return max; + } +} diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_alarm_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_alarm_probs.bin new file mode 100644 index 0000000..6e75a09 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_alarm_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_ambient_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_ambient_probs.bin new file mode 100644 index 0000000..1da2597 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_ambient_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_danger_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_danger_probs.bin new file mode 100644 index 0000000..ec6a621 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_danger_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_default_ambient_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_default_ambient_probs.bin new file mode 100644 index 0000000..3921ebc Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_default_ambient_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_game_mix_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_game_mix_probs.bin new file mode 100644 index 0000000..bed5ac6 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_game_mix_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_gunshot_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_gunshot_probs.bin new file mode 100644 index 0000000..b70cb24 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_gunshot_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_priority_tie_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_priority_tie_probs.bin new file mode 100644 index 0000000..7a3d73e Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_priority_tie_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_proxy_plop_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_proxy_plop_probs.bin new file mode 100644 index 0000000..b371935 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_proxy_plop_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_silence_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_silence_probs.bin new file mode 100644 index 0000000..4140506 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_silence_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_speech_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_speech_probs.bin new file mode 100644 index 0000000..a2bf5f6 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_speech_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/cp4_vote_over_top1_probs.bin b/SoundVisualizer.Tests/Ai/Reference/cp4_vote_over_top1_probs.bin new file mode 100644 index 0000000..d8ad995 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/cp4_vote_over_top1_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_logmel.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_logmel.bin new file mode 100644 index 0000000..806f397 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_logmel.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_mono16k.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_mono16k.bin new file mode 100644 index 0000000..b695588 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_mono16k.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_probs.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_probs.bin new file mode 100644 index 0000000..594d320 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_stereo44100.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_stereo44100.bin new file mode 100644 index 0000000..1f38ea6 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_alarm_stereo44100.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_logmel.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_logmel.bin new file mode 100644 index 0000000..e906bb9 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_logmel.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_mono16k.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_mono16k.bin new file mode 100644 index 0000000..1ef4a85 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_mono16k.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_probs.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_probs.bin new file mode 100644 index 0000000..afd655a Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_stereo44100.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_stereo44100.bin new file mode 100644 index 0000000..079b25e Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_gunshot_stereo44100.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_silence_logmel.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_silence_logmel.bin new file mode 100644 index 0000000..b66fcdb --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/e2e_silence_logmel.bin @@ -0,0 +1 @@ +U ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀ \ No newline at end of file diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_silence_mono16k.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_silence_mono16k.bin new file mode 100644 index 0000000..d888a49 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_silence_mono16k.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_silence_probs.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_silence_probs.bin new file mode 100644 index 0000000..f0e292d Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_silence_probs.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/e2e_silence_stereo44100.bin b/SoundVisualizer.Tests/Ai/Reference/e2e_silence_stereo44100.bin new file mode 100644 index 0000000..06416f8 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/e2e_silence_stereo44100.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/logmel_golden.bin b/SoundVisualizer.Tests/Ai/Reference/logmel_golden.bin new file mode 100644 index 0000000..d8e6fd4 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/logmel_golden.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/meta.json b/SoundVisualizer.Tests/Ai/Reference/meta.json new file mode 100644 index 0000000..3512b62 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/meta.json @@ -0,0 +1,29 @@ +{ + "description": "Golden fixtures from tools/ai_reference/preprocess.py", + "sample_rate": 16000, + "mono": { + "path": "mono16k_golden.bin", + "count": 15600, + "min": -0.601978600025177, + "max": 0.601969838142395, + "mean": 0.00022190845629666, + "std": 0.3061859905719757, + "sha256": "9093ef7afd41a1c5cfe9ebb26756d34780d3aa43d39f06e834b96170cc3cb472" + }, + "logmel": { + "path": "logmel_golden.bin", + "count": 6144, + "min": -6.907755374908447, + "max": 7.109968662261963, + "mean": -3.967583417892456, + "std": 4.519693851470947, + "sha256": "679007c2eabb22849c54f2ae9465bcfc556e917b42aace67ed7fdb4f8fb37eaf" + }, + "shape_logical": [ + 1, + 1, + 96, + 64 + ], + "layout": "time * 64 + mel" +} \ No newline at end of file diff --git a/SoundVisualizer.Tests/Ai/Reference/mono16k_golden.bin b/SoundVisualizer.Tests/Ai/Reference/mono16k_golden.bin new file mode 100644 index 0000000..674d9b0 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/mono16k_golden.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_alarm_logmel.bin b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_alarm_logmel.bin new file mode 100644 index 0000000..e4b7205 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_alarm_logmel.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_frontend_meta.json b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_frontend_meta.json new file mode 100644 index 0000000..5ec9e71 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_frontend_meta.json @@ -0,0 +1,65 @@ +{ + "description": "Kotlin parity fixtures from the pinned Qualcomm-recipe source frontend NumPy port", + "source_provenance": { + "qai_hub_models_commit": "7925bdd04586a87b09914164903f51b3f889c4aa", + "torch_audioset_commit": "e8852c53becef811784754a2de9c4617d8db2156", + "upstream_parity_check": "tools/ai_reference/verify_torchaudio_logmel_parity.py" + }, + "shape_logical": [ + 1, + 1, + 96, + 64 + ], + "layout": "time * 64 + mel", + "cases": { + "golden": { + "input": "mono16k_golden.bin", + "output": { + "path": "qualcomm_source_golden_logmel.bin", + "count": 6144, + "min": -6.5995588302612305, + "max": 3.733264446258545, + "mean": -3.047597885131836, + "std": 3.3511555194854736, + "sha256": "3d6e4e822358c1aee08ea6fec843af3901eb02d7c5c51e091482eda865848db0" + } + }, + "silence": { + "input": "e2e_silence_mono16k.bin", + "output": { + "path": "qualcomm_source_silence_logmel.bin", + "count": 6144, + "min": -6.907755374908447, + "max": -6.907755374908447, + "mean": -6.907755374908447, + "std": 0.0, + "sha256": "07bf9c6bb402c2ab2f32504e488114d5003a5c6ddc115032be92341d474281c8" + } + }, + "gunshot": { + "input": "e2e_gunshot_mono16k.bin", + "output": { + "path": "qualcomm_source_gunshot_logmel.bin", + "count": 6144, + "min": -6.907755374908447, + "max": 3.703829765319824, + "mean": 1.3086820840835571, + "std": 2.004591226577759, + "sha256": "8812fd0cc9ee134e92245f751ff9b0dec5c151f171c7be4a759fd16421ade84e" + } + }, + "alarm": { + "input": "e2e_alarm_mono16k.bin", + "output": { + "path": "qualcomm_source_alarm_logmel.bin", + "count": 6144, + "min": -5.538531303405762, + "max": 2.0881121158599854, + "mean": -0.3528178632259369, + "std": 1.6606378555297852, + "sha256": "c70425b671969432d26f39127e31dd062f066d572c027bbdfddc1824d4d55417" + } + } + } +} diff --git a/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_golden_logmel.bin b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_golden_logmel.bin new file mode 100644 index 0000000..da8c8b0 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_golden_logmel.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_gunshot_logmel.bin b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_gunshot_logmel.bin new file mode 100644 index 0000000..db13fb8 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_gunshot_logmel.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_silence_logmel.bin b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_silence_logmel.bin new file mode 100644 index 0000000..b66fcdb --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/qualcomm_source_silence_logmel.bin @@ -0,0 +1 @@ +U ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀU ÝÀ \ No newline at end of file diff --git a/SoundVisualizer.Tests/Ai/Reference/resample_parity_44100_capture.bin b/SoundVisualizer.Tests/Ai/Reference/resample_parity_44100_capture.bin new file mode 100644 index 0000000..8e678aa Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/resample_parity_44100_capture.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/resample_parity_44100_mono16k.bin b/SoundVisualizer.Tests/Ai/Reference/resample_parity_44100_mono16k.bin new file mode 100644 index 0000000..b4a8f07 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/resample_parity_44100_mono16k.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/resample_parity_48000_capture.bin b/SoundVisualizer.Tests/Ai/Reference/resample_parity_48000_capture.bin new file mode 100644 index 0000000..7e2c8c5 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/resample_parity_48000_capture.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/resample_parity_48000_mono16k.bin b/SoundVisualizer.Tests/Ai/Reference/resample_parity_48000_mono16k.bin new file mode 100644 index 0000000..05b652c Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/resample_parity_48000_mono16k.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/resample_parity_meta.json b/SoundVisualizer.Tests/Ai/Reference/resample_parity_meta.json new file mode 100644 index 0000000..2622484 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/resample_parity_meta.json @@ -0,0 +1,17 @@ +{ + "signal": "LCG seeded noise + 300-7500Hz chirp + impulse", + "cases": { + "44100": { + "capture": "resample_parity_44100_capture.bin", + "mono16k": "resample_parity_44100_mono16k.bin", + "source_samples": 2919, + "output_samples": 1024 + }, + "48000": { + "capture": "resample_parity_48000_capture.bin", + "mono16k": "resample_parity_48000_mono16k.bin", + "source_samples": 3168, + "output_samples": 1024 + } + } +} diff --git a/SoundVisualizer.Tests/Ai/Reference/yamnet_cp3_meta.json b/SoundVisualizer.Tests/Ai/Reference/yamnet_cp3_meta.json new file mode 100644 index 0000000..b4e45b3 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/yamnet_cp3_meta.json @@ -0,0 +1,42 @@ +{ + "logits": { + "count": 521, + "min": -77.33924102783203, + "max": -3.350257396697998, + "mean": -29.514366149902344, + "sha256": "cc2b190f597a7134fcfe57b5938f4bd0b3b7f1b6fd6cb7663ba574f3848015d1", + "argmax": 507, + "sum": -15376.984375 + }, + "probs": { + "count": 521, + "min": 2.3667506115211634e-33, + "max": 0.3214843273162842, + "mean": 0.0019193857442587614, + "sha256": "5556f3480a8cab26aa3ee3395507768241711a501b4c9aecbc2dee6dfc8c0c75", + "argmax": 507, + "sum": 1.0 + }, + "top5": [ + { + "index": 507, + "prob": 0.3214843273162842 + }, + { + "index": 153, + "prob": 0.17307284474372864 + }, + { + "index": 147, + "prob": 0.10984371602535248 + }, + { + "index": 494, + "prob": 0.08420199155807495 + }, + { + "index": 495, + "prob": 0.04050412029027939 + } + ] +} \ No newline at end of file diff --git a/SoundVisualizer.Tests/Ai/Reference/yamnet_cp4_meta.json b/SoundVisualizer.Tests/Ai/Reference/yamnet_cp4_meta.json new file mode 100644 index 0000000..4a871a2 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/yamnet_cp4_meta.json @@ -0,0 +1,460 @@ +{ + "num_classes": 521, + "mapping_checks": { + "Gunshot, gunfire": "danger", + "Speech": "speech", + "Wind": "ambient", + "Noise": "ambient", + "Plop": "ambient", + "Sound effect": "ambient", + "Alarm": "danger", + "Rain": "ambient", + "Train": "ambient", + "Gargling": "ambient", + "Music": "ambient", + "Silence": "ambient", + "Footsteps": "ambient", + "Vehicle horn, car horn, honking": "danger", + "Air horn, truck horn": "danger", + "Burst, pop": "danger", + "Boom": "danger", + "Bang": "danger", + "Smash, crash": "danger", + "Breaking": "danger", + "Shatter": "danger" + }, + "handcrafted": { + "ambient": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.6470588445663452, + "yamnet_class_index": 277, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 1.0000000223517418, + "speech": 0.0, + "danger": 0.0 + }, + "top5": [ + { + "index": 277, + "name": "Wind", + "prob": 0.6470588445663452 + }, + { + "index": 132, + "name": "Music", + "prob": 0.23529411852359772 + }, + { + "index": 507, + "name": "Noise", + "prob": 0.11764705926179886 + }, + { + "index": 0, + "name": "Speech", + "prob": 0.0 + }, + { + "index": 1, + "name": "Child speech, kid speaking", + "prob": 0.0 + } + ] + }, + "speech": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.75, + "yamnet_class_index": 0, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 0.0625, + "speech": 0.9375, + "danger": 0.0 + }, + "top5": [ + { + "index": 0, + "name": "Speech", + "prob": 0.75 + }, + { + "index": 2, + "name": "Conversation", + "prob": 0.1875 + }, + { + "index": 132, + "name": "Music", + "prob": 0.0625 + }, + { + "index": 1, + "name": "Child speech, kid speaking", + "prob": 0.0 + }, + { + "index": 3, + "name": "Narration, monologue", + "prob": 0.0 + } + ] + }, + "danger": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.6666666865348816, + "yamnet_class_index": 421, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 0.06666667014360428, + "speech": 0.0, + "danger": 0.9333333671092987 + }, + "top5": [ + { + "index": 421, + "name": "Gunshot, gunfire", + "prob": 0.6666666865348816 + }, + { + "index": 420, + "name": "Explosion", + "prob": 0.2666666805744171 + }, + { + "index": 132, + "name": "Music", + "prob": 0.06666667014360428 + }, + { + "index": 0, + "name": "Speech", + "prob": 0.0 + }, + { + "index": 1, + "name": "Child speech, kid speaking", + "prob": 0.0 + } + ] + }, + "default_ambient": { + "coarse": "ambient", + "display": "Noise", + "confidence": 0.875, + "yamnet_class_index": 507, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 1.0, + "speech": 0.0, + "danger": 0.0 + }, + "top5": [ + { + "index": 507, + "name": "Noise", + "prob": 0.875 + }, + { + "index": 508, + "name": "Environmental noise", + "prob": 0.125 + }, + { + "index": 0, + "name": "Speech", + "prob": 0.0 + }, + { + "index": 1, + "name": "Child speech, kid speaking", + "prob": 0.0 + }, + { + "index": 2, + "name": "Conversation", + "prob": 0.0 + } + ] + }, + "vote_over_top1": { + "coarse": "speech", + "display": "Music", + "confidence": 0.3571428656578064, + "yamnet_class_index": 132, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 0.3571428656578064, + "speech": 0.5833333134651184, + "danger": 0.0 + }, + "top5": [ + { + "index": 132, + "name": "Music", + "prob": 0.3571428656578064 + }, + { + "index": 0, + "name": "Speech", + "prob": 0.2976190447807312 + }, + { + "index": 2, + "name": "Conversation", + "prob": 0.2857142686843872 + }, + { + "index": 507, + "name": "Noise", + "prob": 0.0595238097012043 + }, + { + "index": 1, + "name": "Child speech, kid speaking", + "prob": 0.0 + } + ] + }, + "priority_tie": { + "coarse": "danger", + "display": "Speech", + "confidence": 0.3076923191547394, + "yamnet_class_index": 0, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 0.3076923191547394, + "speech": 0.3076923191547394, + "danger": 0.3076923191547394 + }, + "top5": [ + { + "index": 0, + "name": "Speech", + "prob": 0.3076923191547394 + }, + { + "index": 132, + "name": "Music", + "prob": 0.3076923191547394 + }, + { + "index": 421, + "name": "Gunshot, gunfire", + "prob": 0.3076923191547394 + }, + { + "index": 507, + "name": "Noise", + "prob": 0.07692307978868484 + }, + { + "index": 1, + "name": "Child speech, kid speaking", + "prob": 0.0 + } + ] + }, + "proxy_plop": { + "coarse": "ambient", + "display": "Plop", + "confidence": 0.8461537957191467, + "yamnet_class_index": 488, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 0.9999999403953552, + "speech": 0.0, + "danger": 0.0 + }, + "top5": [ + { + "index": 488, + "name": "Plop", + "prob": 0.8461537957191467 + }, + { + "index": 132, + "name": "Music", + "prob": 0.1538461446762085 + }, + { + "index": 0, + "name": "Speech", + "prob": 0.0 + }, + { + "index": 1, + "name": "Child speech, kid speaking", + "prob": 0.0 + }, + { + "index": 2, + "name": "Conversation", + "prob": 0.0 + } + ] + }, + "game_mix": { + "coarse": "ambient", + "display": "Gunshot, gunfire", + "confidence": 0.1666666567325592, + "yamnet_class_index": 421, + "game_mix_preference_applied": true, + "vote_scores": { + "ambient": 0.7499999552965164, + "speech": 0.0, + "danger": 0.1666666567325592 + }, + "top5": [ + { + "index": 498, + "name": "Sound effect", + "prob": 0.6666666269302368 + }, + { + "index": 421, + "name": "Gunshot, gunfire", + "prob": 0.1666666567325592 + }, + { + "index": 132, + "name": "Music", + "prob": 0.0833333283662796 + }, + { + "index": 507, + "name": "Noise", + "prob": 0.0833333283662796 + }, + { + "index": 0, + "name": "Speech", + "prob": 0.0 + } + ] + } + }, + "samples": { + "silence": { + "coarse": "ambient", + "display": "Silence", + "confidence": 1.0, + "yamnet_class_index": 494, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 1.0, + "speech": 7.855638786658255e-30, + "danger": 0.0 + }, + "top5": [ + { + "index": 494, + "name": "Silence", + "prob": 1.0 + }, + { + "index": 0, + "name": "Speech", + "prob": 7.855638786658255e-30 + }, + { + "index": 132, + "name": "Music", + "prob": 6.4286941411018935e-37 + }, + { + "index": 1, + "name": "Child speech, kid speaking", + "prob": 0.0 + }, + { + "index": 2, + "name": "Conversation", + "prob": 0.0 + } + ] + }, + "gunshot": { + "coarse": "ambient", + "display": "Ding", + "confidence": 0.12252969294786453, + "yamnet_class_index": 477, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 0.23073964565992355, + "speech": 0.0, + "danger": 0.07261022925376892 + }, + "top5": [ + { + "index": 477, + "name": "Ding", + "prob": 0.12252969294786453 + }, + { + "index": 498, + "name": "Sound effect", + "prob": 0.10820995271205902 + }, + { + "index": 382, + "name": "Alarm", + "prob": 0.07261022925376892 + }, + { + "index": 54, + "name": "Hiccup", + "prob": 0.06038668751716614 + }, + { + "index": 500, + "name": "Inside, small room", + "prob": 0.05509820953011513 + } + ] + }, + "alarm": { + "coarse": "danger", + "display": "Air horn, truck horn", + "confidence": 0.13773442804813385, + "yamnet_class_index": 312, + "game_mix_preference_applied": false, + "vote_scores": { + "ambient": 0.09391622245311737, + "speech": 0.0, + "danger": 0.2389892339706421 + }, + "top5": [ + { + "index": 312, + "name": "Air horn, truck horn", + "prob": 0.13773442804813385 + }, + { + "index": 302, + "name": "Vehicle horn, car horn, honking", + "prob": 0.10125480592250824 + }, + { + "index": 153, + "name": "Synthesizer", + "prob": 0.09391622245311737 + }, + { + "index": 498, + "name": "Sound effect", + "prob": 0.07539236545562744 + }, + { + "index": 392, + "name": "Buzzer", + "prob": 0.06290934979915619 + } + ] + } + }, + "class_map_filled": 521 +} diff --git a/SoundVisualizer.Tests/Ai/Reference/yamnet_cp6_meta.json b/SoundVisualizer.Tests/Ai/Reference/yamnet_cp6_meta.json new file mode 100644 index 0000000..8769493 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/yamnet_cp6_meta.json @@ -0,0 +1,1148 @@ +{ + "A_stable_ambient": { + "name": "A_stable_ambient", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.41, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.41, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.41 + }, + { + "frame": 2, + "input": { + "coarse": "ambient", + "display": "Noise", + "confidence": 0.39, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Noise", + "confirmed_confidence": 0.39, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Noise", + "ui_confidence": 0.39 + } + ] + }, + "B_ambient_to_speech": { + "name": "B_ambient_to_speech", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "speech", + "candidate_streak": 1, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 2, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.41, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "speech", + "confirmed_display": "Speech", + "confirmed_confidence": 0.41, + "use_booster_danger_preview": false, + "ui_coarse": "speech", + "ui_display": "Speech", + "ui_confidence": 0.41 + } + ] + }, + "C_ambient_to_danger_streak1": { + "name": "C_ambient_to_danger_streak1", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "danger", + "display": "Siren", + "confidence": 0.22, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Siren", + "confirmed_confidence": 0.22, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Siren", + "ui_confidence": 0.22 + } + ] + }, + "D_immediate_danger_conf": { + "name": "D_immediate_danger_conf", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "danger", + "display": "Siren", + "confidence": 0.28, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Siren", + "confirmed_confidence": 0.28, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Siren", + "ui_confidence": 0.28 + } + ] + }, + "E_critical_danger": { + "name": "E_critical_danger", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.19, + "adopted_danger_from_booster": true, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.18, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.19, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.19 + } + ] + }, + "F_low_confidence": { + "name": "F_low_confidence", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.1, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": false, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 2, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.1, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": false, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 3, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "speech", + "candidate_streak": 1, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + } + ] + }, + "G_candidate_reset": { + "name": "G_candidate_reset", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "speech", + "candidate_streak": 1, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 2, + "input": { + "coarse": "danger", + "display": "Siren", + "confidence": 0.22, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Siren", + "confirmed_confidence": 0.22, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Siren", + "ui_confidence": 0.22 + } + ] + }, + "H_danger_exit": { + "name": "H_danger_exit", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": true, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.27, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.27, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.27 + } + ] + }, + "H2_danger_exit_needs_two": { + "name": "H2_danger_exit_needs_two", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": true, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.26, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "ambient", + "candidate_streak": 1, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.4 + }, + { + "frame": 2, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.26, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.26, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.26 + } + ] + }, + "I_booster_accepted": { + "name": "I_booster_accepted", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Ding", + "confidence": 0.3, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Ding", + "confirmed_confidence": 0.3, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Ding", + "ui_confidence": 0.3 + }, + { + "frame": 1, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.19, + "adopted_danger_from_booster": true, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.18, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.19, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.19 + } + ] + }, + "J_booster_preview_same_frame_confirm": { + "name": "J_booster_preview_same_frame_confirm", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Ding", + "confidence": 0.35, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Ding", + "confirmed_confidence": 0.35, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Ding", + "ui_confidence": 0.35 + }, + { + "frame": 1, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.22, + "adopted_danger_from_booster": true, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.18, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.22, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.22 + } + ] + }, + "J2_booster_siren_confirm": { + "name": "J2_booster_siren_confirm", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "ambient", + "display": "Music", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Music", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Music", + "ui_confidence": 0.4 + }, + { + "frame": 1, + "input": { + "coarse": "danger", + "display": "Siren", + "confidence": 0.22, + "adopted_danger_from_booster": true, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.18, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Siren", + "confirmed_confidence": 0.22, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Siren", + "ui_confidence": 0.22 + } + ] + }, + "K_threshold_boundaries": { + "name": "K_threshold_boundaries", + "frames": [ + { + "frame": 0, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.2499, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": false, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "", + "confirmed_confidence": 0.0, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "", + "ui_confidence": 0.0 + }, + { + "frame": 1, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.25, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "speech", + "candidate_streak": 1, + "confirmed_coarse": "ambient", + "confirmed_display": "", + "confirmed_confidence": 0.0, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "", + "ui_confidence": 0.0 + }, + { + "frame": 2, + "input": { + "coarse": "speech", + "display": "Speech", + "confidence": 0.25, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "speech", + "confirmed_display": "Speech", + "confirmed_confidence": 0.25, + "use_booster_danger_preview": false, + "ui_coarse": "speech", + "ui_display": "Speech", + "ui_confidence": 0.25 + }, + { + "frame": 3, + "input": { + "coarse": "danger", + "display": "Siren", + "confidence": 0.1999, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": false, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "speech", + "confirmed_display": "Speech", + "confirmed_confidence": 0.25, + "use_booster_danger_preview": false, + "ui_coarse": "speech", + "ui_display": "Speech", + "ui_confidence": 0.25 + }, + { + "frame": 4, + "input": { + "coarse": "danger", + "display": "Siren", + "confidence": 0.2, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Siren", + "confirmed_confidence": 0.2, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Siren", + "ui_confidence": 0.2 + }, + { + "frame": 5, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 6, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.1799, + "adopted_danger_from_booster": true, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.18, + "meets_threshold": false, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 7, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.18, + "adopted_danger_from_booster": true, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.18, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.18, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.18 + }, + { + "frame": 8, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 9, + "input": { + "coarse": "danger", + "display": "Siren", + "confidence": 0.2799, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Siren", + "confirmed_confidence": 0.2799, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Siren", + "ui_confidence": 0.2799 + }, + { + "frame": 10, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.4 + }, + { + "frame": 11, + "input": { + "coarse": "danger", + "display": "Siren", + "confidence": 0.28, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": true, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Siren", + "confirmed_confidence": 0.28, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Siren", + "ui_confidence": 0.28 + }, + { + "frame": 12, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": true, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.4 + }, + { + "frame": 13, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.2699, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "ambient", + "candidate_streak": 1, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.4 + }, + { + "frame": 14, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.2699, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.2699, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.2699 + }, + { + "frame": 15, + "input": { + "coarse": "danger", + "display": "Gunshot, gunfire", + "confidence": 0.4, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": true, + "top_k_summary": "" + }, + "effective_threshold": 0.2, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "danger", + "confirmed_display": "Gunshot, gunfire", + "confirmed_confidence": 0.4, + "use_booster_danger_preview": false, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "ui_confidence": 0.4 + }, + { + "frame": 16, + "input": { + "coarse": "ambient", + "display": "Wind", + "confidence": 0.27, + "adopted_danger_from_booster": false, + "has_strong_danger_cue": false, + "has_critical_danger_cue": false, + "top_k_summary": "" + }, + "effective_threshold": 0.25, + "meets_threshold": true, + "candidate_coarse": "", + "candidate_streak": 0, + "confirmed_coarse": "ambient", + "confirmed_display": "Wind", + "confirmed_confidence": 0.27, + "use_booster_danger_preview": false, + "ui_coarse": "ambient", + "ui_display": "Wind", + "ui_confidence": 0.27 + } + ] + } +} \ No newline at end of file diff --git a/SoundVisualizer.Tests/Ai/Reference/yamnet_e2e_meta.json b/SoundVisualizer.Tests/Ai/Reference/yamnet_e2e_meta.json new file mode 100644 index 0000000..82f6be0 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/Reference/yamnet_e2e_meta.json @@ -0,0 +1,76 @@ +{ + "description": "Semantic realtime E2E: 44.1k stereo -> FIR resample -> YAMNet + Booster", + "capture_sample_rate": 44100, + "required_mono_16k": 15600, + "capture_window_samples": 42998, + "cases": { + "silence": { + "source": { + "type": "procedural_silence", + "sample_rate": 44100, + "sha256": null + }, + "pre_booster_coarse": "ambient", + "gunshot_score": 0.5055228471755981, + "booster_accepted": false, + "ui_coarse": "ambient", + "ui_display": "Silence" + }, + "gunshot": { + "source": { + "type": "wav", + "title": "8-bit explosion gun", + "provider": "Mixkit", + "asset_id": 2779, + "download_filename": "mixkit-8-bit-explosion-gun-2779.wav", + "fixture_filename": "Gunshot, gunfire_8-bit-explosion-gun-2779.wav", + "catalog_url": "https://mixkit.co/free-sound-effects/explosion/", + "license": "Mixkit Sound Effects Free License", + "license_url": "https://mixkit.co/license/#sfxFree", + "sample_rate": 44100, + "channels": 2, + "sha256": "5b76ee075ae48fa2ee26a4099a0c643855a4d66c477f7e99966edd835ca64b8a", + "capture_resampler": { + "taps": 192, + "window": "Kaiser", + "beta": 12.0, + "cutoff_hz": 7800.0 + } + }, + "pre_booster_coarse": "ambient", + "gunshot_score": 0.5068124532699585, + "booster_accepted": true, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire" + }, + "alarm": { + "source": { + "type": "wav", + "title": "Classic alarm", + "provider": "Mixkit", + "asset_id": 995, + "download_filename": "mixkit-classic-alarm-995.wav", + "fixture_filename": "Alarm_classic-alarm-995.wav", + "catalog_url": "https://mixkit.co/free-sound-effects/alarm/", + "license": "Mixkit Sound Effects Free License", + "license_url": "https://mixkit.co/license/#sfxFree", + "sample_rate": 44100, + "channels": 2, + "sha256": "5c0302cd06e7eb3c19a4c17e4f4ec60e869a2840e555947bed3422a47bb92d7c", + "capture_resampler": { + "taps": 192, + "window": "Kaiser", + "beta": 12.0, + "cutoff_hz": 7800.0 + } + }, + "pre_booster_coarse": "ambient", + "gunshot_score": 0.5055438280105591, + "booster_accepted": true, + "ui_coarse": "danger", + "ui_display": "Gunshot, gunfire", + "assert_ui_display": false, + "ui_display_follow_up_issue": 116 + } + } +} diff --git a/SoundVisualizer.Tests/Ai/Reference/yamnet_logits_golden.bin b/SoundVisualizer.Tests/Ai/Reference/yamnet_logits_golden.bin new file mode 100644 index 0000000..27ee647 Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/yamnet_logits_golden.bin differ diff --git a/SoundVisualizer.Tests/Ai/Reference/yamnet_probs_golden.bin b/SoundVisualizer.Tests/Ai/Reference/yamnet_probs_golden.bin new file mode 100644 index 0000000..77b07ed Binary files /dev/null and b/SoundVisualizer.Tests/Ai/Reference/yamnet_probs_golden.bin differ diff --git a/SoundVisualizer.Tests/Ai/SoundTypeLabelsTests.cs b/SoundVisualizer.Tests/Ai/SoundTypeLabelsTests.cs new file mode 100644 index 0000000..1a9f4b3 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/SoundTypeLabelsTests.cs @@ -0,0 +1,139 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// 소리 종류의 저장 문자열과, 모바일 AiLabelContractTest 를 옮긴 확인. 모바일은 라벨을 문자열로만 이어서 이름이 어긋나면 +/// 위협음이 환경음처럼 그려졌다. C# 은 열거형이라 그 어긋남은 컴파일러가 막고, 남는 위험은 저장 문자열이 모바일과 다른 것이다. +/// +public class SoundTypeLabelsTests +{ + private static readonly (string Name, SoundType Expected)[] Representatives = + { + ("Gunshot, gunfire", SoundType.Danger), + ("Speech", SoundType.Speech), + ("Rain", SoundType.Ambient), + }; + + [Fact] + public void 저장_문자열은_모바일_설정과_글자까지_같다() + { + Assert.Equal("ambient", SoundType.Ambient.ToLabel()); + Assert.Equal("speech", SoundType.Speech.ToLabel()); + Assert.Equal("danger", SoundType.Danger.ToLabel()); + Assert.Equal("ambient", SoundTypeLabels.Ambient); + Assert.Equal("speech", SoundTypeLabels.Speech); + Assert.Equal("danger", SoundTypeLabels.Danger); + // 결과가 없을 때와 같은 뜻이 되게 초기값은 환경음이다. + Assert.Equal(SoundType.Ambient, default); + } + + [Fact] + public void 저장_문자열은_정확히_같을_때만_읽는다() + { + Assert.All(Enum.GetValues(), type => + { + Assert.True(SoundTypeLabels.TryParse(type.ToLabel(), out var parsed)); + Assert.Equal(type, parsed); + }); + Assert.All(new[] { null, "", "Danger", "SPEECH", " ambient", "ambient ", "환경음", "threat" }, label => + Assert.False(SoundTypeLabels.TryParse(label, out _))); + } + + [Fact] + public void 정의되지_않은_값은_문자열로_바꾸지_않는다() + { + Assert.Throws(() => ((SoundType)3).ToLabel()); + } + + [Fact] // YAMNet 모든 클래스 이름은 세 라벨 중 하나로 묶인다 + public void 모든_클래스_이름은_세_종류_중_하나로_묶인다() + { + var names = AiTestData.ClassMap; + Assert.Equal(YamnetCoarseClassifier.NumClasses, names.Count); + Assert.All(names, name => Assert.True(Enum.IsDefined(YamnetThreeClassMapper.MapDisplayNameToCoarse(name)))); + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse(null)); + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse("")); + } + + [Fact] // 대표 소리는 화면과 진동이 기대하는 라벨 상수로 묶인다 + public void 대표_소리는_화면과_진동이_기대하는_종류로_묶인다() + { + var names = AiTestData.ClassMap; + var classifier = new YamnetCoarseClassifier(names); + Assert.All(Representatives, r => + { + Assert.Equal(r.Expected, YamnetThreeClassMapper.MapDisplayNameToCoarse(r.Name)); + Assert.Equal(r.Expected, classifier.Classify(AiTestData.Probabilities((names.IndexOf(r.Name), 1f))).Coarse); + }); + } + + [Fact] // 어떤 클래스가 1위여도 분류기 투표 결과는 세 라벨 중 하나다 + public void 어떤_클래스가_1위여도_투표_결과는_세_종류_중_하나다() + { + var classifier = new YamnetCoarseClassifier(AiTestData.ClassMap); + for (int i = 0; i < YamnetCoarseClassifier.NumClasses; i++) + { + Assert.True(Enum.IsDefined(classifier.Classify(AiTestData.Probabilities((i, 1f))).Coarse)); + } + } + + [Fact] // YAMNet gunshot top cue는 후처리까지 세 UI 라벨 중 하나다 + public void 음악에_섞인_총소리도_후처리까지_세_종류_중_하나다() + { + var names = AiTestData.ClassMap; + var probs = AiTestData.Probabilities((names.IndexOf("Music"), .5f), (names.IndexOf("Gunshot, gunfire"), .45f)); + var pre = new YamnetCoarseClassifier(names).Classify(probs); + Assert.True(Enum.IsDefined(pre.Coarse)); + + var decision = YamnetSafetyCueDecision.Decide(names, pre); + Assert.True(Enum.IsDefined(decision.PostCoarse)); + + string topKSummary = string.Join(" | ", pre.Top5.Select(hit => hit.Name)); + var post = new AiPostProcessor().Process(new AiPostProcessor.FrameInput( + decision.PostCoarse, + decision.PostDisplay, + decision.PostConfidence, + DangerCuePromoted: decision.DangerCuePromoted, + HasStrongDangerCue: decision.HasStrongDangerCue, + HasCriticalDangerCue: AiPostProcessor.IsCriticalDangerEvent(decision.PostDisplay, topKSummary), + TopKSummary: topKSummary)); + Assert.True(Enum.IsDefined(post.UiCoarse)); + } + + [Fact] // 후처리가 화면에 넘기는 라벨은 항상 세 상수 중 하나다 + public void 후처리는_환경음에서_시작해_종류마다_넘어가고_Reset_하면_처음으로_돌아간다() + { + // 입력 종류도 매퍼에서 받는다. 상수를 넣으면 후처리가 받은 값을 그대로 돌려줘서 어긋남이 가려진다. + static AiPostProcessor.FrameInput Frame(string name, float confidence) => new( + YamnetThreeClassMapper.MapDisplayNameToCoarse(name), + name, + confidence, + HasStrongDangerCue: YamnetSafetyCueDecision.IsStrongDangerKeyword(name), + HasCriticalDangerCue: AiPostProcessor.IsCriticalDangerKeyword(name)); + + var pp = new AiPostProcessor(); + + // 아직 기준을 넘은 결과가 없으면 처음 라벨이 그대로 화면에 간다. + Assert.Equal(SoundType.Ambient, pp.Process(Frame("Speech", 0f)).UiCoarse); + + // 위협음 → 대화음 → 환경음 → 위협음 순서로 다섯 프레임씩 흘려, 히스테리시스가 넘어간 뒤의 라벨을 본다. + foreach (var (name, expected) in Representatives.Append(Representatives[0])) + { + var results = Enumerable.Range(0, 5).Select(_ => pp.Process(Frame(name, .9f))).ToList(); + Assert.All(results, r => + { + Assert.True(Enum.IsDefined(r.UiCoarse)); + Assert.True(Enum.IsDefined(r.ConfirmedCoarse)); + }); + Assert.Equal(expected, results[^1].UiCoarse); + } + + // 캡처를 다시 시작하면(Reset) 처음 라벨로 돌아간다. + pp.Reset(); + Assert.Equal(SoundType.Ambient, pp.Process(Frame("Gunshot, gunfire", 0f)).UiCoarse); + } +} diff --git a/SoundVisualizer.Tests/Ai/YamnetClassMapTests.cs b/SoundVisualizer.Tests/Ai/YamnetClassMapTests.cs new file mode 100644 index 0000000..c0740d1 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/YamnetClassMapTests.cs @@ -0,0 +1,103 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Text; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// 클래스 목록 읽기(모바일 YamnetCoarseClassifier.loadClassNames)와 목록·분류기의 입력 확인. +public class YamnetClassMapTests +{ + private static IReadOnlyList Load(string csv) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv)); + return YamnetClassMap.LoadClassNames(stream); + } + + [Fact] + public void 머리글을_건너뛰고_따옴표_칸과_두_번_쓴_따옴표를_읽는다() + { + var names = Load( + "index,mid,display_name\n" + + "0,/m/09x0r,Speech\n" + + "1,/m/0ytgt,\"Child speech, kid speaking\"\r\n" + + "2,/m/x,\"Say \"\"hi\"\"\"\n" + + "3,/m/y,\"\"\"Quoted\"\"\"\n"); + + Assert.Equal(YamnetCoarseClassifier.NumClasses, names.Count); + Assert.Equal("Speech", names[0]); + Assert.Equal("Child speech, kid speaking", names[1]); + Assert.Equal("Say \"hi\"", names[2]); + Assert.Equal("Quoted", names[3]); // 칸을 푼 뒤에도 양끝이 따옴표면 한 번 더 벗긴다. + Assert.Equal("class_4", names[4]); + Assert.Equal("class_520", names[520]); + } + + [Fact] + public void 범위_밖_번호와_빈_이름은_건너뛰고_칸이_둘이면_두_번째를_이름으로_쓴다() + { + var names = Load( + "521,/m/a,Overflow\n" + + "-1,/m/b,Negative\n" + + "not a number,/m/c,Nope\n" + + "5,/m/d, \n" + + "6,Two columns\n" + + "\"7\",/m/e, Padded \n" + + "8\n" + + "\n"); + + Assert.Equal("class_5", names[5]); + Assert.Equal("Two columns", names[6]); + Assert.Equal("Padded", names[7]); + Assert.Equal("class_8", names[8]); + Assert.DoesNotContain("Overflow", names); + Assert.DoesNotContain("Negative", names); + } + + [Fact] + public void 받은_스트림은_닫지_않는다() + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("0,/m/x,Speech\n")); + YamnetClassMap.LoadClassNames(stream); + Assert.True(stream.CanRead); + } + + [Fact] + public void 이름은_대소문자까지_같아야_번호를_찾는다() + { + var map = AiTestData.ClassMap; + Assert.Equal(421, map.IndexOf("Gunshot, gunfire")); + Assert.Equal(-1, map.IndexOf("gunshot, gunfire")); + Assert.Equal(-1, map.IndexOf(null)); + Assert.Equal(SoundType.Danger, map.DefaultCoarse(421)); + } + + [Fact] + public void 같은_이름이_또_있으면_앞_번호를_찾는다() + { + var map = new YamnetClassMap(new[] { "Speech", "Wind", "Speech" }); + Assert.Equal(0, map.IndexOf("Speech")); + Assert.Equal(3, map.Count); + } + + [Fact] + public void 분류기는_521개가_아닌_목록과_확률을_받지_않는다() + { + Assert.Throws(() => new YamnetCoarseClassifier(new YamnetClassMap(new[] { "Speech", "Wind" }))); + var classifier = new YamnetCoarseClassifier(AiTestData.ClassMap); + Assert.Throws(() => classifier.Classify(new float[YamnetCoarseClassifier.NumClasses - 1])); + } + + [Fact] + public void 확률이_같으면_앞_번호가_상위에_남는다() + { + var classifier = new YamnetCoarseClassifier(AiTestData.ClassMap); + var probs = new float[YamnetCoarseClassifier.NumClasses]; + Array.Fill(probs, 0.5f); + + var result = classifier.Classify(probs); + + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, result.Top5.Select(hit => hit.Index)); + } +} diff --git a/SoundVisualizer.Tests/Ai/YamnetInferenceTests.cs b/SoundVisualizer.Tests/Ai/YamnetInferenceTests.cs new file mode 100644 index 0000000..daa9c85 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/YamnetInferenceTests.cs @@ -0,0 +1,187 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Diagnostics; +using SoundVisualizer.Ai; +using Xunit.Abstractions; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// YAMNet 실행을 Python 기준(onnxruntime 으로 만든 CP3 골든)과 맞춘다. 모바일 계측 테스트 YamnetInferenceInstrumentedTest 를 옮겼다. +/// 모바일은 기기에서만 돌지만 PC 의 ONNX Runtime 은 데스크톱에서 돌므로 단위 테스트로 둔다. 오차 기준도 모바일과 같다. +/// +public class YamnetInferenceTests(ITestOutputHelper output) +{ + private const int Classes = YamnetInference.NumClasses; + + [Fact] // yamnet_matchesPythonCp3Golden + public void Python_기준과_로짓은_1e3_확률은_1e4_안이고_1위와_상위_5개가_같다() + { + float[] logMel = AiTestData.LoadFloats("logmel_golden.bin", YamnetInference.LogMelSize); + float[] expectedLogits = AiTestData.LoadFloats("yamnet_logits_golden.bin", Classes); + float[] expectedProbs = AiTestData.LoadFloats("yamnet_probs_golden.bin", Classes); + var meta = AiTestData.LoadJson("yamnet_cp3_meta.json"); + + using var yamnet = YamnetInference.Create(AiTestData.ModelDirectory); + var logits1 = new float[Classes]; + var probs1 = new float[Classes]; + var logits2 = new float[Classes]; + var probs2 = new float[Classes]; + long started = Stopwatch.GetTimestamp(); + yamnet.Infer(logMel, logits1, probs1); + double firstMs = Stopwatch.GetElapsedTime(started).TotalMilliseconds; + yamnet.Infer(logMel, logits2, probs2); + + double sum = probs1.Sum(p => (double)p); + var logitError = Errors(logits1, expectedLogits); + var probError = Errors(probs1, expectedProbs); + output.WriteLine($"ORT session: {yamnet.InputOutputSummary()}, first infer {firstMs:F2} ms"); + output.WriteLine($"softmax sum={sum:R}"); + output.WriteLine($"logits maxAbs={logitError.MaxAbs:E3} MAE={logitError.Mae:E3} RMSE={logitError.Rmse:E3}"); + output.WriteLine($"probs maxAbs={probError.MaxAbs:E3} MAE={probError.Mae:E3} RMSE={probError.Rmse:E3}"); + + Assert.True(Math.Abs(sum - 1.0) < 1e-5, $"softmax sum≈1 (got {sum})"); + // 같은 입력이면 비트까지 같다. + Assert.Equal(Bits(logits1), Bits(logits2)); + Assert.Equal(Bits(probs1), Bits(probs2)); + // 같은 ONNX 모델이라 ONNX Runtime 판이 달라도 로짓이 아주 가깝다. + Assert.True(logitError.MaxAbs < 1e-3, $"logits maxAbs too large: {logitError.MaxAbs}"); + Assert.True(probError.MaxAbs < 1e-4, $"probs maxAbs too large: {probError.MaxAbs}"); + Assert.Equal(ArgMax(expectedProbs), ArgMax(probs1)); + Assert.Equal(TopK(expectedProbs, 5), TopK(probs1, 5)); + // 메타에 적힌 Python 결과(1위 507, 상위 5개)와도 같다. + Assert.Equal(meta.GetProperty("probs").GetProperty("argmax").GetInt32(), ArgMax(probs1)); + Assert.Equal( + meta.GetProperty("top5").EnumerateArray().Select(hit => hit.GetProperty("index").GetInt32()), + TopK(probs1, 5)); + } + + [Fact] + public void 입력_버퍼를_다시_써도_추론마다_그때의_입력을_본다() + { + // 입력은 IoBinding 으로 묶은 버퍼를 주소로 읽는다. 앞 입력이 남거나 출력이 섞이면 아래 두 기준과 어긋난다. + float[] golden = AiTestData.LoadFloats("logmel_golden.bin", YamnetInference.LogMelSize); + float[] silence = AiTestData.LoadFloats("qualcomm_source_silence_logmel.bin", YamnetInference.LogMelSize); + // 무음 로그멜에 대한 Python 확률. 무음의 로그멜은 옛 전처리와 Qualcomm 전처리에서 똑같으므로 지금도 유효한 기준이다. + float[] silenceExpected = AiTestData.LoadFloats("e2e_silence_probs.bin", Classes); + + using var yamnet = YamnetInference.Create(AiTestData.ModelDirectory); + var first = new float[Classes]; + var silenceProbs = new float[Classes]; + var again = new float[Classes]; + var logits = new float[Classes]; + yamnet.Infer(golden, logits, first); + yamnet.Infer(silence, logits, silenceProbs); + yamnet.Infer(golden, logits, again); + + Assert.True(Errors(silenceProbs, silenceExpected).MaxAbs < 1e-4); + Assert.Equal("Silence", AiTestData.ClassMap[ArgMax(silenceProbs)]); + Assert.Equal(Bits(first), Bits(again)); + } + + [Fact] + public void 추론은_관리_힙에_할당하지_않는다() + { + float[] logMel = AiTestData.LoadFloats("logmel_golden.bin", YamnetInference.LogMelSize); + var logits = new float[Classes]; + var probs = new float[Classes]; + using var yamnet = YamnetInference.Create(AiTestData.ModelDirectory); + for (int i = 0; i < 5; i++) yamnet.Infer(logMel, logits, probs); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) yamnet.Infer(logMel, logits, probs); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // 추론 100번에 걸친 값이다. 추론마다 텐서나 결과 배열을 만들면 수십 KB 가 된다. + Assert.True(allocated <= 64, $"{allocated} bytes allocated by 100 inferences"); + } + + [Fact] + public void softmax_는_Python_기준_확률과_float_마지막_자리_안에서_같다() + { + float[] logits = AiTestData.LoadFloats("yamnet_logits_golden.bin", Classes); + float[] expected = AiTestData.LoadFloats("yamnet_probs_golden.bin", Classes); + var probs = new float[Classes]; + + YamnetInference.Softmax(logits, probs); + + // Kotlin 은 최댓값과의 차를 float 로, Python 은 double 로 구한다. 그 차이는 float 반올림 한 번 수준이다. + double maxAbs = Errors(probs, expected).MaxAbs; + output.WriteLine($"softmax vs Python maxAbs={maxAbs:E3}"); + Assert.True(maxAbs < 1e-7, $"softmax maxAbs {maxAbs}"); + Assert.True(Math.Abs(probs.Sum(p => (double)p) - 1.0) < 1e-6); + } + + [Fact] + public void softmax_는_큰_로짓에서도_넘치지_않는다() + { + var probs = new float[3]; + YamnetInference.Softmax(new[] { 1000f, 0f, -1000f }, probs); + Assert.Equal(new[] { 1f, 0f, 0f }, probs); + } + + [Theory] + [InlineData(float.NaN, 0f, 1f)] + [InlineData(0f, float.NaN, 1f)] + [InlineData(0f, 1f, float.PositiveInfinity)] + [InlineData(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity)] + public void softmax_는_합이_유한하지_않으면_모두_같은_확률을_준다(float a, float b, float c) + { + var probs = new float[3]; + YamnetInference.Softmax(new[] { a, b, c }, probs); + Assert.All(probs, p => Assert.Equal(1f / 3, p)); + } + + [Fact] + public void 길이가_다른_입력이나_출력은_거절한다() + { + using var yamnet = YamnetInference.Create(AiTestData.ModelDirectory); + Assert.Throws(() => yamnet.Infer(new float[YamnetInference.LogMelSize - 1], new float[Classes], new float[Classes])); + Assert.Throws(() => yamnet.Infer(new float[YamnetInference.LogMelSize], new float[Classes - 1], new float[Classes])); + Assert.Throws(() => yamnet.Infer(new float[YamnetInference.LogMelSize], new float[Classes], new float[Classes + 1])); + } + + [Fact] + public void 세션은_audio_입력과_class_scores_출력을_가진다() + { + using var yamnet = YamnetInference.Create(AiTestData.ModelDirectory); + Assert.Equal("in=audio[1,1,96,64] out=class_scores[1,521]", yamnet.InputOutputSummary()); + } + + [Fact] + public void 닫은_뒤에는_네이티브_세션에_들어가지_않고_예외를_던진다() + { + var yamnet = YamnetInference.Create(AiTestData.ModelDirectory); + yamnet.Dispose(); + yamnet.Dispose(); // 두 번 닫아도 된다. + + // 모바일의 "Trying to score a closed OrtSession." 처럼 해제한 네이티브 세션에 들어가기 전에 막는다. + Assert.Throws(() => + yamnet.Infer(new float[YamnetInference.LogMelSize], new float[Classes], new float[Classes])); + } + + private readonly record struct ErrorSummary(double MaxAbs, double Mae, double Rmse); + + private static ErrorSummary Errors(float[] actual, float[] expected) + { + Assert.Equal(expected.Length, actual.Length); + double maxAbs = 0, sumAbs = 0, sumSq = 0; + for (int i = 0; i < actual.Length; i++) + { + double d = Math.Abs((double)actual[i] - expected[i]); + maxAbs = Math.Max(maxAbs, d); + sumAbs += d; + sumSq += d * d; + } + return new ErrorSummary(maxAbs, sumAbs / actual.Length, Math.Sqrt(sumSq / actual.Length)); + } + + private static int[] Bits(float[] values) => Array.ConvertAll(values, BitConverter.SingleToInt32Bits); + + private static int ArgMax(float[] values) => TopK(values, 1)[0]; + + /// 모바일 테스트의 sortedByDescending.take(k) 와 같다. 정렬이 안정적이라 확률이 같으면 앞 번호가 먼저다. + private static int[] TopK(float[] values, int k) => + Enumerable.Range(0, values.Length).OrderByDescending(i => values[i]).Take(k).ToArray(); +} diff --git a/SoundVisualizer.Tests/Ai/YamnetLabelListTests.cs b/SoundVisualizer.Tests/Ai/YamnetLabelListTests.cs new file mode 100644 index 0000000..0e48d22 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/YamnetLabelListTests.cs @@ -0,0 +1,106 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// C# 라벨 목록과 규칙이 모바일과 같은 이름을 같은 종류로 묶는지. 모바일 저장소를 읽지 않도록, 모바일 규칙을 실제 521개 이름에 +/// 적용한 결과(위협음 37, 대화음 33, 환경음 451 — 모바일 tools/ai_reference/mapper.py 결과와도 같다)를 여기에 그대로 적어 둔다. +/// 규칙을 바꾸면 이 목록도 모바일과 함께 바꾼다. +/// +public class YamnetLabelListTests +{ + // 클래스 목록 순서다. + private static readonly string[] ExpectedDanger = + { + "Bicycle bell", "Thunderstorm", "Thunder", "Vehicle horn, car horn, honking", "Car alarm", + "Skidding", "Tire squeal", "Air horn, truck horn", "Reversing beeps", "Emergency vehicle", + "Police car (siren)", "Ambulance (siren)", "Fire engine, fire truck (siren)", "Train whistle", "Train horn", + "Chainsaw", "Slam", "Alarm", "Siren", "Civil defense siren", + "Smoke detector, smoke alarm", "Fire alarm", "Foghorn", "Explosion", "Gunshot, gunfire", + "Machine gun", "Fusillade", "Artillery fire", "Cap gun", "Fireworks", + "Firecracker", "Burst, pop", "Boom", "Shatter", "Bang", + "Smash, crash", "Breaking", + }; + + private static readonly string[] ExpectedSpeech = + { + "Speech", "Child speech, kid speaking", "Conversation", "Narration, monologue", "Babbling", + "Speech synthesizer", "Shout", "Bellow", "Whoop", "Yell", + "Children shouting", "Screaming", "Whispering", "Laughter", "Baby laughter", + "Giggle", "Snicker", "Belly laugh", "Chuckle, chortle", "Crying, sobbing", + "Baby cry, infant cry", "Whimper", "Wail, moan", "Singing", "Choir", + "Child singing", "Synthetic singing", "Rapping", "Cheering", "Chatter", + "Crowd", "Hubbub, speech noise, speech babble", "Children playing", + }; + + private static List NamesMappedTo(SoundType type) => + AiTestData.ClassMap.Where(name => YamnetThreeClassMapper.MapDisplayNameToCoarse(name) == type).ToList(); + + [Fact] + public void 실제_클래스는_위협음_37_대화음_33_환경음_451로_나뉜다() + { + Assert.Equal(37, NamesMappedTo(SoundType.Danger).Count); + Assert.Equal(33, NamesMappedTo(SoundType.Speech).Count); + Assert.Equal(451, NamesMappedTo(SoundType.Ambient).Count); + } + + [Fact] + public void 위협음으로_묶이는_이름은_모바일과_같다() + { + Assert.Equal(ExpectedDanger, NamesMappedTo(SoundType.Danger)); + } + + [Fact] + public void 대화음으로_묶이는_이름은_모바일과_같다() + { + Assert.Equal(ExpectedSpeech, NamesMappedTo(SoundType.Speech)); + } + + [Fact] + public void 정확한_이름_목록은_개수가_같고_모두_실제_클래스_이름이다() + { + // 목록은 소문자로 비교하므로 실제 이름을 소문자로 바꿔 찾는다. 한 글자라도 틀리면 그 라벨은 조용히 규칙에서 빠진다. + var lowered = AiTestData.ClassMap.Select(name => name.ToLowerInvariant()).ToHashSet(StringComparer.Ordinal); + + Assert.Equal(9, YamnetThreeClassMapper.AttentionLabels.Count); + Assert.Equal(11, YamnetThreeClassMapper.HumanVocalizationLabels.Count); + Assert.Equal(8, YamnetSafetyCueDecision.ApprovedAttentionCueLabels.Count); + Assert.All( + YamnetThreeClassMapper.AttentionLabels + .Concat(YamnetThreeClassMapper.HumanVocalizationLabels) + .Concat(YamnetSafetyCueDecision.ApprovedAttentionCueLabels), + label => Assert.Contains(label, lowered)); + } + + [Fact] + public void 목록의_라벨은_정한_종류로_묶인다() + { + Assert.All(YamnetThreeClassMapper.AttentionLabels, label => + Assert.Equal(SoundType.Danger, YamnetThreeClassMapper.MapDisplayNameToCoarse(label))); + Assert.All(YamnetThreeClassMapper.HumanVocalizationLabels, label => + Assert.Equal(SoundType.Speech, YamnetThreeClassMapper.MapDisplayNameToCoarse(label))); + // 안전 단서로 승인한 라벨은 매퍼가 이미 위협음으로 보는 것만이다. + Assert.All(YamnetSafetyCueDecision.ApprovedAttentionCueLabels, label => + { + Assert.Equal(SoundType.Danger, YamnetThreeClassMapper.MapDisplayNameToCoarse(label)); + Assert.True(YamnetSafetyCueDecision.IsStrongDangerKeyword(label)); + }); + } + + [Fact] + public void 미리_계산한_값은_문자열_함수_결과와_같다() + { + var map = AiTestData.ClassMap; + for (int i = 0; i < map.Count; i++) + { + Assert.Equal(YamnetThreeClassMapper.MapDisplayNameToCoarse(map[i]), map.DefaultCoarse(i)); + Assert.Equal(YamnetClassMap.ComputeTraits(map[i]), map.TraitsAt(i)); + } + Assert.Equal( + Enumerable.Range(0, map.Count).Where(i => map.DefaultCoarse(i) == SoundType.Danger), + map.DefaultDangerIndices.ToArray()); + } +} diff --git a/SoundVisualizer.Tests/Ai/YamnetMappingPolicyTests.cs b/SoundVisualizer.Tests/Ai/YamnetMappingPolicyTests.cs new file mode 100644 index 0000000..3e00eda --- /dev/null +++ b/SoundVisualizer.Tests/Ai/YamnetMappingPolicyTests.cs @@ -0,0 +1,218 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// 모바일 YamnetMappingPolicyTest 를 옮겼다(사용자 3분류 매핑, #291). +public class YamnetMappingPolicyTests +{ + // 0 Music, 1 Siren, 2 Explosion, 3 Gunshot, 4 Speech, 5 Footsteps, 6 Wind, 그 뒤는 class_7 … + private static readonly YamnetClassMap Names = + new(AiTestData.Names("class_", "Music", "Siren", "Explosion", "Gunshot, gunfire", "Speech", "Footsteps", "Wind")); + + private static readonly YamnetCoarseClassifier Classifier = new(Names); + + private static YamnetMappingPolicy Policy(params (string Name, string Label)[] values) => + YamnetMappingPolicy.From(values.ToDictionary(v => v.Name, v => v.Label), Names); + + [Fact] // defaultsPreserveEverySingleClassDecision + public void 사용자_지정이_없으면_모든_클래스의_판단이_기본과_같다() + { + var actualNames = AiTestData.ClassMap; + var actualClassifier = new YamnetCoarseClassifier(actualNames); + var empty = YamnetMappingPolicy.From(new Dictionary(), actualNames); + for (int i = 0; i < actualNames.Count; i++) + { + Assert.Equal(YamnetThreeClassMapper.MapDisplayNameToCoarse(actualNames[i]), empty.Coarse(actualNames[i])); + var p = AiTestData.Probabilities((i, 1f)); + Assert.Equal(actualClassifier.Classify(p), actualClassifier.Classify(p, empty)); + var pre = actualClassifier.Classify(p); + Assert.Equal(YamnetSafetyCueDecision.Decide(actualNames, pre), YamnetSafetyCueDecision.Decide(actualNames, pre, empty)); + } + } + + [Fact] // sirenAndExplosionDemotionsAffectVotesAndSafetyCues + public void 사이렌과_폭발을_빼면_투표와_안전_단서_모두에서_빠진다() + { + Assert.All(new[] { 1, 2 }, index => + { + var mapping = Policy((Names[index], "ambient")); + + var pre = Classifier.Classify(AiTestData.Probabilities((index, .8f), (6, .2f)), mapping); + var decision = YamnetSafetyCueDecision.Decide(Names, pre, mapping); + + Assert.Equal(SoundType.Ambient, pre.Coarse); + Assert.Equal(SoundType.Ambient, decision.PostCoarse); + Assert.False(decision.HasStrongDangerCue); + Assert.False(decision.DangerCuePromoted); + }); + } + + [Fact] // anotherIndependentCueStillPromotesDanger + public void 다른_독립_단서는_여전히_위협음으로_올린다() + { + var mapping = Policy(("Siren", "ambient")); + // 실제 후보 다섯. 확률 0 인 총소리가 동점 자리를 채우지 않게 한다. + var pre = Classifier.Classify(AiTestData.Probabilities((1, .6f), (2, .2f), (6, .1f), (0, .06f), (5, .04f)), mapping); + Assert.Equal(SoundType.Ambient, pre.Coarse); + + var decision = YamnetSafetyCueDecision.Decide(Names, pre, mapping); + + Assert.Equal(SoundType.Danger, decision.PostCoarse); + Assert.Equal("Explosion", decision.PostDisplay); + } + + [Fact] // speechOverrideDoesNotBlockIndependentDangerCue + public void 대화음으로_바꾼_라벨이_다른_독립_단서를_막지_않는다() + { + var p = AiTestData.Probabilities((1, .6f), (2, .2f), (6, .1f), (0, .06f), (5, .04f)); + var mapping = Policy(("Siren", "speech")); + + var pre = Classifier.Classify(p, mapping); + Assert.Equal(SoundType.Speech, pre.Coarse); + Assert.Equal(SoundType.Danger, pre.DefaultCoarse); + + var decision = YamnetSafetyCueDecision.Decide(Names, pre, mapping); + Assert.Equal(SoundType.Danger, decision.PostCoarse); + Assert.Equal("Explosion", decision.PostDisplay); + Assert.True(decision.DangerCuePromoted); + } + + [Fact] // ambientToSpeechOverrideDoesNotBlockIndependentDangerCue + public void 환경음을_대화음으로_바꿔도_독립_단서를_막지_않는다() + { + var mapping = Policy(("Wind", "speech")); + + var pre = Classifier.Classify(AiTestData.Probabilities((6, .5f), (1, .2f), (0, .08f), (5, .05f), (2, .01f)), mapping); + + Assert.Equal(SoundType.Speech, pre.Coarse); + Assert.Equal(SoundType.Ambient, pre.DefaultCoarse); + Assert.Equal(SoundType.Danger, YamnetSafetyCueDecision.Decide(Names, pre, mapping).PostCoarse); + } + + [Fact] // demotedSoleCueStaysSpeechWithoutShortcutReentry + public void 뺀_단서가_하나뿐이면_키워드로_다시_들어오지_않고_대화음으로_남는다() + { + var mapping = Policy(("Siren", "speech")); + + var pre = Classifier.Classify(AiTestData.Probabilities((1, .6f), (6, .2f), (0, .1f), (5, .06f), (4, .04f)), mapping); + var decision = YamnetSafetyCueDecision.Decide(Names, pre, mapping); + + Assert.Equal(SoundType.Speech, decision.PostCoarse); + Assert.False(decision.DangerCuePromoted); + } + + [Fact] // defaultSpeechVoteStillBlocksAfterUserDemotion + public void 사용자가_대화음을_빼도_기본_대화음_투표는_승격을_막는다() + { + // 표시 이름은 Wind 다. 여기서 막는 것은 말소리 같은 표시 이름이 아니라 기본 매핑으로 낸 투표다. + var localNames = new YamnetClassMap( + AiTestData.Names("class_", "Music", "Siren", "Explosion", "Gunshot, gunfire", "Speech", "Footsteps", "Wind", "Conversation")); + var mapping = YamnetMappingPolicy.From( + new Dictionary { ["Speech"] = "ambient", ["Conversation"] = "ambient" }, localNames); + + var pre = new YamnetCoarseClassifier(localNames).Classify( + AiTestData.Probabilities((6, .3f), (4, .25f), (7, .24f), (1, .15f), (5, .06f)), mapping); + + Assert.Equal(SoundType.Ambient, pre.Coarse); + Assert.Equal(SoundType.Speech, pre.DefaultCoarse); + Assert.False(YamnetSafetyCueDecision.Decide(localNames, pre, mapping).DangerCuePromoted); + } + + [Fact] // demotedGunshotDoesNotSuppressIndependentSirenCue + public void 뺀_총소리는_독립된_사이렌_단서를_막지_않는다() + { + var mapping = Policy(("Gunshot, gunfire", "ambient")); + + var pre = Classifier.Classify(AiTestData.Probabilities((3, .7f), (1, .2f), (6, .1f)), mapping); + var decision = YamnetSafetyCueDecision.Decide(Names, pre, mapping); + + Assert.False(decision.HasGunshotCue); + Assert.Equal("Siren", decision.PostDisplay); + Assert.True(decision.DangerCuePromoted); + } + + [Fact] // newlyPromotedFootstepsVoteButDoNotBecomeSafetyCue + public void 새로_위협음으로_지정한_발소리는_투표만_하고_안전_단서가_되지_않는다() + { + var mapping = Policy(("Footsteps", "danger")); + + var strong = Classifier.Classify(AiTestData.Probabilities((5, .8f), (6, .2f)), mapping); + Assert.Equal(SoundType.Danger, strong.Coarse); + Assert.False(YamnetSafetyCueDecision.Decide(Names, strong, mapping).HasStrongDangerCue); + + var weak = Classifier.Classify(AiTestData.Probabilities((0, .5f), (6, .3f), (5, .2f)), mapping); + Assert.Equal(SoundType.Ambient, weak.Coarse); + Assert.Equal("Music", weak.DisplayName); + Assert.False(YamnetSafetyCueDecision.Decide(Names, weak, mapping).DangerCuePromoted); + } + + [Fact] // demotedDangerCannotWinGameMixDisplayPreference + public void 뺀_위협음은_가려진_위협음_표시_이름으로도_뽑히지_않는다() + { + var mapping = Policy(("Siren", "ambient")); + + var pre = Classifier.Classify(AiTestData.Probabilities((0, .7f), (1, .3f)), mapping); + + Assert.Equal("Music", pre.DisplayName); + Assert.False(pre.GameMixPreferenceApplied); + } + + [Fact] // policyCopiesValidExactLabelsAndHasCanonicalIdentity + public void 유효한_정확한_이름만_복사하고_내용이_같으면_서명도_같다() + { + var input = new Dictionary { ["Siren"] = "ambient", ["Footsteps"] = "danger" }; + var snapshot = YamnetMappingPolicy.From(input, Names); + input.Clear(); + + Assert.Equal(SoundType.Ambient, snapshot.Coarse("Siren")); + Assert.Equal(2, snapshot.OverrideCount); + Assert.Equal(Policy(("Footsteps", "danger"), ("Siren", "ambient")).Signature, snapshot.Signature); + Assert.Equal("default", Policy(("siren", "ambient"), ("Music", "invalid"), ("unknown", "danger")).Signature); + } + + [Fact] // explicitCriticalDecisionPreventsKeywordReentryInPostprocessor + public void 매핑으로_정한_치명적_단서_판단은_후처리에서_키워드로_되살아나지_않는다() + { + var mapping = Policy(("Gunshot, gunfire", "ambient"), ("Explosion", "ambient")); + var hits = new[] { new YamnetCoarseClassifier.TopClassHit(2, "Explosion", .1f) }; + + bool critical = mapping.HasCriticalDangerCue("Gunshot, gunfire", hits); + + Assert.False(critical); + Assert.True(Policy(("Gunshot, gunfire", "ambient")).HasCriticalDangerCue("Gunshot, gunfire", hits)); + var post = new AiPostProcessor(); + var result = post.Process(new AiPostProcessor.FrameInput( + Coarse: SoundType.Danger, Display: "Gunshot, gunfire", Confidence: .21f, + TopKSummary: "Explosion", CriticalDangerEvent: critical)); + Assert.Equal(.25f, result.EffectiveThreshold); + Assert.False(result.MeetsThreshold); + Assert.Equal(SoundType.Ambient, result.UiCoarse); + } + + // 아래는 모바일에 없는 C# 확인이다. + + [Fact] + public void 서명은_서수_순서로_이름과_종류를_잇는다() + { + // 모바일 toSortedMap 은 UTF-16 서수 순이라 대문자 W 가 소문자 c 보다 앞이다. 문화권 정렬이면 반대가 된다. + var mapping = Policy(("class_7", "danger"), ("Wind", "speech"), ("Siren", "ambient")); + + Assert.Equal("Siren=ambient;Wind=speech;class_7=danger", mapping.Signature); + Assert.Equal("default", YamnetMappingPolicy.Default.Signature); + Assert.Equal(0, YamnetMappingPolicy.Default.OverrideCount); + } + + [Fact] + public void 기본_종류와_같은_값과_대소문자가_다른_라벨은_버린다() + { + var mapping = Policy(("Siren", "danger"), ("Wind", "Speech"), ("Music", " speech"), ("Footsteps", "speech")); + + Assert.Equal(1, mapping.OverrideCount); + Assert.Equal("Footsteps=speech", mapping.Signature); + Assert.Equal(SoundType.Danger, mapping.Coarse("Siren")); + Assert.Equal(SoundType.Ambient, mapping.Coarse("Wind")); + } +} diff --git a/SoundVisualizer.Tests/Ai/YamnetSafetyCueDecisionTests.cs b/SoundVisualizer.Tests/Ai/YamnetSafetyCueDecisionTests.cs new file mode 100644 index 0000000..40c35b2 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/YamnetSafetyCueDecisionTests.cs @@ -0,0 +1,157 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// 모바일 YamnetSafetyCueDecisionTest 를 옮겼다. +public class YamnetSafetyCueDecisionTests +{ + private static string[] BaseNames() => AiTestData.Names("Ambient ", "Music", "Siren", "Gunshot, gunfire", "Speech"); + + private static readonly YamnetClassMap Names = new(BaseNames()); + private static readonly YamnetCoarseClassifier Classifier = new(Names); + + /// 1번 자리(원래 Siren)나 0번 자리(원래 Music)만 바꾼 목록으로 분류하고 판단한다. + private static YamnetSafetyCueDecision.Result DecideWith(int slot, string name, float[] probabilities) + { + var names = BaseNames(); + names[slot] = name; + var map = new YamnetClassMap(names); + return YamnetSafetyCueDecision.Decide(map, new YamnetCoarseClassifier(map).Classify(probabilities)); + } + + [Fact] // strong non-gunshot cue is retained without an auxiliary model + public void 총기가_아닌_강한_단서는_보조_모델_없이도_위협음으로_남는다() + { + var probabilities = AiTestData.Probabilities((0, .6f), (1, .2f), + // 실제 softmax 에는 정확히 0 인 꼬리가 없다. 총기가 아닌 후보 다섯을 채워, 확률 0 인 총소리가 상위 5개에 + // 끼어드는 일을 테스트가 만들어 내지 않게 한다. + (4, .001f), (5, .001f), (6, .001f)); + + var decision = YamnetSafetyCueDecision.Decide(Names, Classifier.Classify(probabilities)); + + Assert.Equal(SoundType.Danger, decision.PostCoarse); + Assert.Equal("Siren", decision.PostDisplay); + Assert.True(decision.DangerCuePromoted); + } + + [Fact] // gunshot cue is not score-promoted by the removed Booster path + public void 총소리_단서는_없앤_Booster_경로로_올리지_않는다() + { + var probabilities = AiTestData.Probabilities((0, .6f), (2, .1f)); + + var decision = YamnetSafetyCueDecision.Decide(Names, Classifier.Classify(probabilities)); + + Assert.Equal(SoundType.Ambient, decision.PostCoarse); + Assert.True(decision.HasGunshotCue); + Assert.False(decision.DangerCuePromoted); + } + + [Fact] // gunshot cue does not veto an independent non-gunshot cue at the policy floor + public void 총소리_후보가_있어도_기준_확률의_다른_단서는_막지_않는다() + { + var probabilities = AiTestData.Probabilities((0, .6f), (2, .2f), (1, .05f), (4, .04f), (5, .03f)); + + var decision = YamnetSafetyCueDecision.Decide(Names, Classifier.Classify(probabilities)); + + Assert.True(decision.HasGunshotCue); + Assert.Equal(SoundType.Danger, decision.PostCoarse); + Assert.Equal("Siren", decision.PostDisplay); + Assert.True(decision.DangerCuePromoted); + } + + [Fact] // gunshot cue cannot lend its evidence to a weaker non-gunshot cue + public void 총소리_단서의_확률을_더_약한_다른_단서에_빌려주지_않는다() + { + var probabilities = AiTestData.Probabilities((0, .6f), (2, .2f), (1, .049f), (4, .04f), (5, .03f)); + + var decision = YamnetSafetyCueDecision.Decide(Names, Classifier.Classify(probabilities)); + + Assert.True(decision.HasStrongDangerCue); + Assert.True(decision.HasGunshotCue); + Assert.Equal(SoundType.Ambient, decision.PostCoarse); + Assert.False(decision.DangerCuePromoted); + } + + [Fact] // speech result is not safety-promoted + public void 대화음_결과는_안전_단서로_올리지_않는다() + { + var probabilities = AiTestData.Probabilities((3, .6f), (1, .1f)); + + var decision = YamnetSafetyCueDecision.Decide(Names, Classifier.Classify(probabilities)); + + Assert.Equal(SoundType.Speech, decision.PostCoarse); + Assert.False(decision.DangerCuePromoted); + } + + [Fact] // approved firearm change keeps speech silence and low-confidence guards + public void 말소리_무음_낮은_확신도_가드는_그대로다() + { + var cases = new[] { ("Speech", .6f, .1f), ("Silence", .6f, .1f), ("Music", .119f, .1f) }; + Assert.All(cases, c => + { + var (display, topProbability, sirenProbability) = c; + var probabilities = AiTestData.Probabilities((0, topProbability), (2, .08f), (1, sirenProbability), (4, .04f), (5, .03f)); + + var decision = DecideWith(0, display, probabilities); + + Assert.False(decision.DangerCuePromoted); + }); + } + + [Fact] // approved attention cues are safety-promoted + public void 승인한_경적과_깨짐_단서는_위협음으로_올린다() + { + var attentionCues = new[] + { + "Vehicle horn, car horn, honking", + "Air horn, truck horn", + "Burst, pop", + "Boom", + "Bang", + "Smash, crash", + "Breaking", + "Shatter", + }; + Assert.All(attentionCues, cue => + { + var probabilities = AiTestData.Probabilities((0, .6f), (1, .2f), (4, .001f), (5, .001f), (6, .001f)); + + var decision = DecideWith(1, cue, probabilities); + + Assert.Equal(SoundType.Danger, decision.PostCoarse); + Assert.Equal(cue, decision.PostDisplay); + Assert.True(decision.DangerCuePromoted); + }); + } + + [Fact] // temporary proxies and partial label matches are not safety-promoted + public void 옛_대리_라벨과_이름이_일부만_겹치는_소리는_올리지_않는다() + { + Assert.All(new[] { "Plop", "Gargling", "Breaking news" }, proxy => + { + var probabilities = AiTestData.Probabilities((0, .6f), (1, .2f), (4, .001f), (5, .001f), (6, .001f)); + + var decision = DecideWith(1, proxy, probabilities); + + Assert.Equal(SoundType.Ambient, decision.PostCoarse); + Assert.False(decision.DangerCuePromoted); + }); + } + + [Fact] // chainsaw and thunder labels do not bypass mapper vote through safety cue + public void 전기톱과_천둥은_안전_단서로_투표를_건너뛰지_않는다() + { + Assert.All(new[] { "Chainsaw", "Thunder", "Thunderstorm" }, cue => + { + var probabilities = AiTestData.Probabilities((0, .6f), (1, .2f), (4, .001f), (5, .001f), (6, .001f)); + + var decision = DecideWith(1, cue, probabilities); + + Assert.Equal(SoundType.Ambient, decision.PostCoarse); + Assert.False(decision.DangerCuePromoted); + }); + } +} diff --git a/SoundVisualizer.Tests/Ai/YamnetThreeClassMapperTests.cs b/SoundVisualizer.Tests/Ai/YamnetThreeClassMapperTests.cs new file mode 100644 index 0000000..890308d --- /dev/null +++ b/SoundVisualizer.Tests/Ai/YamnetThreeClassMapperTests.cs @@ -0,0 +1,222 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Text.Json; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests.Ai; + +/// +/// 모바일 YamnetThreeClassMapperTest 를 옮겼다. CP4: 확률 521개 → 상위 5개 → 가려진 위협음 표시 → 상위 3개 투표. +/// 안전 단서·기준·히스테리시스는 보지 않는다. 기대값은 Python 기준 구현이 만든 yamnet_cp4_meta.json 이다. +/// +public class YamnetThreeClassMapperTests +{ + private static readonly YamnetClassMap ClassNames = AiTestData.ClassMap; + private static readonly YamnetCoarseClassifier Classifier = new(ClassNames); + private static readonly JsonElement Meta = AiTestData.LoadJson("yamnet_cp4_meta.json"); + + [Fact] // classMap_has521Entries + public void 클래스_목록은_521개이고_따옴표로_감싼_이름도_읽는다() + { + Assert.Equal(521, ClassNames.Count); + Assert.Equal(521, Meta.GetProperty("num_classes").GetInt32()); + Assert.Equal(521, Meta.GetProperty("class_map_filled").GetInt32()); + Assert.Equal("Speech", ClassNames[0]); + Assert.Equal("Field recording", ClassNames[520]); + Assert.Equal("Child speech, kid speaking", ClassNames[1]); + Assert.Equal("Gunshot, gunfire", ClassNames[421]); + } + + [Fact] // mappingChecks_matchPython + public void 이름별_기본_종류가_Python_기준과_같다() + { + var checks = Meta.GetProperty("mapping_checks").EnumerateObject().ToList(); + Assert.NotEmpty(checks); + Assert.All(checks, check => + Assert.Equal(check.Value.GetString(), YamnetThreeClassMapper.MapDisplayNameToCoarse(check.Name).ToLabel())); + } + + [Fact] // A_ambient + public void A_환경음() => AssertCase("ambient"); + + [Fact] // B_speech + public void B_대화음() => AssertCase("speech"); + + [Fact] // C_danger + public void C_위협음() => AssertCase("danger"); + + [Fact] // D_defaultUnmappedToAmbient + public void D_규칙에_없는_소리는_환경음() => AssertCase("default_ambient"); + + [Fact] // E_voteOverTop1 + public void E_1위보다_상위_3개_투표가_이긴다() + { + AssertCase("vote_over_top1"); + var r = Classifier.Classify(AiTestData.LoadCp4Probs("vote_over_top1")); + Assert.Equal("Music", r.Top5[0].Name); + Assert.Equal(SoundType.Speech, r.Coarse); + } + + [Fact] // F_priorityDangerWinsTie + public void F_점수가_같으면_위협음이_이긴다() + { + AssertCase("priority_tie"); + var r = Classifier.Classify(AiTestData.LoadCp4Probs("priority_tie")); + Assert.Equal(r.DangerScore, r.SpeechScore, 1e-6f); + Assert.Equal(r.DangerScore, r.AmbientScore, 1e-6f); + Assert.Equal(SoundType.Danger, r.Coarse); + } + + [Fact] // G_formerGunshotProxiesAndWaterSoundsAreAmbient + public void G_옛_총소리_대리_라벨과_물소리는_환경음() + { + AssertCase("proxy_plop"); + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse("Plop")); + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse("Gargling")); + Assert.All(new[] { "Rain", "Raindrop", "Rain on surface", "Waterfall" }, name => + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + } + + [Fact] // approvedFootstepAndVehicleHornPolicy + public void 발소리는_환경음이고_차량_경적_두_라벨만_위협음() + { + Assert.All(new[] { "Footstep", "Footsteps" }, name => + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + Assert.All(new[] { "Vehicle horn, car horn, honking", "Air horn, truck horn" }, name => + Assert.Equal(SoundType.Danger, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + } + + [Fact] // approvedSuddenImpactPolicy + public void 터지거나_부서지는_소리는_위협음이고_비슷한_충격음은_환경음() + { + Assert.All(new[] { "Burst, pop", "Boom", "Bang", "Smash, crash", "Breaking", "Shatter" }, name => + Assert.Equal(SoundType.Danger, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + Assert.All(new[] { "Glass", "Door", "Knock", "Thump, thud", "Whack, thwack", "Clang" }, name => + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + } + + [Fact] // approvedChainsawAndThunderPolicyUsesExactLabels + public void 전기톱과_천둥은_정확히_같은_이름만_위협음() + { + Assert.All(new[] { "Chainsaw", "Thunder", "Thunderstorm" }, name => + Assert.Equal(SoundType.Danger, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + Assert.All(new[] { "Rain", "Wind", "Distant thunder", "Chainsaw-like sound" }, name => + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + } + + [Fact] // approvedHumanVocalizationsAreSpeechUsingExactLabels + public void 사람이_내는_소리는_정확히_같은_이름만_대화음() + { + Assert.All(new[] + { + "Bellow", "Whoop", "Yell", "Cheering", + "Giggle", "Snicker", "Belly laugh", "Chuckle, chortle", + "Baby cry, infant cry", "Whimper", "Wail, moan", + }, name => Assert.Equal(SoundType.Speech, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + Assert.All(new[] { "Whimper (dog)", "Bellow-like sound", "Cheering music" }, name => + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse(name))); + } + + [Fact] // waterSoundsDoNotProduceDangerCoarseClassification + public void 물소리가_1위여도_투표는_환경음() + { + Assert.All(new[] { "Rain", "Raindrop", "Rain on surface", "Waterfall" }, name => + { + var probabilities = AiTestData.Probabilities((ClassNames.IndexOf(name), 1f)); + + var result = Classifier.Classify(probabilities); + + Assert.Equal(SoundType.Ambient, result.Coarse); + Assert.Equal(name, result.DisplayName); + }); + } + + [Fact] // H_gameMixPreference + public void H_효과음에_가려진_총소리를_표시_이름으로_고른다() + { + AssertCase("game_mix"); + var r = Classifier.Classify(AiTestData.LoadCp4Probs("game_mix")); + Assert.True(r.GameMixPreferenceApplied); + Assert.Equal("Gunshot, gunfire", r.DisplayName); + // 투표는 원래 상위 5개 그대로라 환경음이 이길 수 있다. + Assert.Equal(SoundType.Ambient, r.Coarse); + } + + [Fact] // sample_silence_preBoosterAmbient + public void 무음_샘플은_환경음() + { + AssertSample("silence"); + var r = Classifier.Classify(AiTestData.LoadCp4Probs("silence")); + Assert.Equal(SoundType.Ambient, r.Coarse); + Assert.False(r.GameMixPreferenceApplied); + } + + [Fact] // sample_gunshot_preBoosterAmbientIsExpected + public void 총소리_샘플은_투표_단계에서는_환경음이_맞다() + { + // 투표 단계만 본다. 위협음 판단은 그다음 안전 단서와 후처리가 한다. + AssertSample("gunshot"); + var r = Classifier.Classify(AiTestData.LoadCp4Probs("gunshot")); + Assert.Equal(SoundType.Ambient, r.Coarse); + } + + [Fact] // sample_alarm_preBooster + public void 경보_샘플() => AssertSample("alarm"); + + // 아래는 모바일에 없는 C# 확인이다. + + [Theory] + [InlineData(.5f, .11f, true)] // 1위 × 0.22 = 0.11 와 같으면 고른다. + [InlineData(.5f, .1099f, false)] + [InlineData(.2f, .06f, true)] // 1위 × 0.22 = 0.044 보다 하한 0.06 이 높다. + [InlineData(.2f, .0599f, false)] + public void 가려진_위협음은_1위의_22퍼센트와_0_06_중_큰_값_이상이어야_고른다(float music, float gunshot, bool applied) + { + var probabilities = AiTestData.Probabilities( + (ClassNames.IndexOf("Music"), music), (ClassNames.IndexOf("Gunshot, gunfire"), gunshot)); + + var r = Classifier.Classify(probabilities); + + Assert.Equal(applied, r.GameMixPreferenceApplied); + Assert.Equal(applied ? "Gunshot, gunfire" : "Music", r.DisplayName); + Assert.Equal(applied ? gunshot : music, r.Confidence); + } + + private static void AssertCase(string name) => + AssertResultMatches(Meta.GetProperty("handcrafted").GetProperty(name), Classifier.Classify(AiTestData.LoadCp4Probs(name)), name); + + private static void AssertSample(string name) => + AssertResultMatches(Meta.GetProperty("samples").GetProperty(name), Classifier.Classify(AiTestData.LoadCp4Probs(name)), "sample_" + name); + + private static void AssertResultMatches(JsonElement expected, YamnetCoarseClassifier.Result actual, string label) + { + Assert.Equal(expected.GetProperty("coarse").GetString(), actual.Coarse.ToLabel()); + Assert.Equal(expected.GetProperty("display").GetString(), actual.DisplayName); + Assert.Equal(expected.GetProperty("yamnet_class_index").GetInt32(), actual.YamnetClassIndex); + Assert.Equal(expected.GetProperty("game_mix_preference_applied").GetBoolean(), actual.GameMixPreferenceApplied); + AssertClose(expected.GetProperty("confidence"), actual.Confidence, $"{label} confidence"); + + var votes = expected.GetProperty("vote_scores"); + AssertClose(votes.GetProperty("ambient"), actual.AmbientScore, $"{label} ambient vote"); + AssertClose(votes.GetProperty("speech"), actual.SpeechScore, $"{label} speech vote"); + AssertClose(votes.GetProperty("danger"), actual.DangerScore, $"{label} danger vote"); + + var top5 = expected.GetProperty("top5"); + Assert.Equal(top5.GetArrayLength(), actual.Top5.Count); + for (int i = 0; i < top5.GetArrayLength(); i++) + { + var e = top5[i]; + var a = actual.Top5[i]; + Assert.Equal(e.GetProperty("index").GetInt32(), a.Index); + Assert.Equal(e.GetProperty("name").GetString(), a.Name); + AssertClose(e.GetProperty("prob"), a.Probability, $"{label} top5[{i}].prob"); + } + } + + private static void AssertClose(JsonElement expected, float actual, string what) + { + float e = (float)expected.GetDouble(); + Assert.True(MathF.Abs(actual - e) < 1e-5f, $"{what}: {actual} vs {e}"); + } +} diff --git a/SoundVisualizer.Tests/AppSettingsTests.cs b/SoundVisualizer.Tests/AppSettingsTests.cs new file mode 100644 index 0000000..a707eb4 --- /dev/null +++ b/SoundVisualizer.Tests/AppSettingsTests.cs @@ -0,0 +1,478 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Text.Json; +using SoundVisualizer.Ai; + +namespace SoundVisualizer.Tests; + +/// AppSettings 는 정적이라 이 묶음의 테스트는 한 번에 하나씩 돈다. +[CollectionDefinition(Name, DisableParallelization = true)] +public class AppSettingsCollection +{ + public const string Name = "AppSettings"; +} + +[Collection(AppSettingsCollection.Name)] +public sealed class AppSettingsTests : IDisposable +{ + private readonly string _originalPath = AppSettings.SettingsFilePath; + private readonly string _path = Path.Combine(Path.GetTempPath(), $"sv-settings-{Guid.NewGuid():N}.json"); + + public AppSettingsTests() + { + AppSettings.SettingsFilePath = _path; + AppSettings.ResetToDefaults(); + AppSettings.Language = "ko"; + ClearSoundTab(); + } + + public void Dispose() + { + AppSettings.Flush(); + AppSettings.ResetToDefaults(); + ClearSoundTab(); + AppSettings.SettingsFilePath = _originalPath; + if (File.Exists(_path)) File.Delete(_path); + } + + // 전체 초기화는 소리 탭의 두 값과 튜토리얼을 본 기록을 건드리지 않으므로 테스트마다 따로 비운다. + private static void ClearSoundTab() + { + AppSettings.SoundTypes = new Dictionary(); + AppSettings.ClassifyAdvanced = false; + AppSettings.TutorialSeen = false; + } + + [Fact] + public void 전체_초기화는_실제로_쓰는_단축키_목록까지_되돌리고_언어는_남긴다() + { + AppSettings.StereoUpmixKeyBind = new List { 0x11, 0x41 }; // Ctrl+A + AppSettings.VisualModeKeyBind = new List { 0x42 }; + AppSettings.EditModeKeyBind = new List { 0x43 }; + AppSettings.VisualMode = 2; + AppSettings.CircleRadius = 90; + AppSettings.ColorDanger = "#FF00FF00"; + AppSettings.IsAdminMode = true; + AppSettings.DeveloperRecord = true; + AppSettings.Language = "ja"; + + AppSettings.ResetToDefaults(); + + Assert.Equal(new[] { AppSettings.DefaultSoundModeKey }, AppSettings.StereoUpmixKeyBind); + Assert.Equal(new[] { AppSettings.DefaultVisualModeKey }, AppSettings.VisualModeKeyBind); + Assert.Equal(new[] { AppSettings.DefaultEditModeKey }, AppSettings.EditModeKeyBind); + Assert.Equal(0, AppSettings.VisualMode); + Assert.Equal(40.0, AppSettings.CircleMode.CircleRadius); + Assert.Equal(AppSettings.DefaultColorDanger, AppSettings.ColorDanger); + Assert.False(AppSettings.IsAdminMode); + Assert.False(AppSettings.DeveloperRecord); + Assert.Equal("ja", AppSettings.Language); + } + + [Fact] + public void 결과_기록은_꺼진_채로_시작하고_개발자_모드와_따로_저장한다() + { + // 켜 두고 잊으면 디스크를 먹으므로 기본은 꺼짐이다. + Assert.False(AppSettings.DeveloperRecord); + + // 모바일처럼 개발자 모드를 꺼도 기록 스위치의 값은 남는다. 두 값은 서로 다른 항목에 저장한다. + AppSettings.IsAdminMode = false; + AppSettings.DeveloperRecord = true; + AppSettings.SaveNow(); + AppSettings.ResetToDefaults(); + AppSettings.Load(); + + Assert.False(AppSettings.IsAdminMode); + Assert.True(AppSettings.DeveloperRecord); + } + + [Fact] + public void 결과_기록이_없는_예전_설정은_꺼진_채로_읽는다() + { + // 앞에서 들고 있던 값이 남지 않는지도 본다. + AppSettings.DeveloperRecord = true; + File.WriteAllText(_path, """{ "IsAdminMode": true, "Language": "ko" }"""); + + AppSettings.Load(); + + Assert.True(AppSettings.IsAdminMode); + Assert.False(AppSettings.DeveloperRecord); + } + + [Fact] + public void 저장은_모았다가_한_번에_쓴다() + { + AppSettings.VisualMode = 1; + AppSettings.Save(); + AppSettings.VisualMode = 3; + AppSettings.Save(); + + Assert.True(AppSettings.HasPendingSave); + Assert.False(File.Exists(_path)); + + AppSettings.Flush(); + + Assert.False(AppSettings.HasPendingSave); + using var doc = JsonDocument.Parse(File.ReadAllText(_path)); + Assert.Equal(3, doc.RootElement.GetProperty("VisualMode").GetInt32()); + } + + [Fact] + public void 미룬_저장은_시간이_지나면_저절로_쓴다() + { + AppSettings.Save(); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!File.Exists(_path) && DateTime.UtcNow < deadline) Thread.Sleep(20); + + Assert.True(File.Exists(_path)); + Assert.False(AppSettings.HasPendingSave); + } + + [Fact] + public void 저장한_값은_다시_읽힌다() + { + AppSettings.VisualMode = 2; + AppSettings.CircleRadius = 77; + AppSettings.VisualModeKeyBind = new List { 0x11, 0x56 }; + AppSettings.ShowSpeech = false; + AppSettings.SaveNow(); + + AppSettings.ResetToDefaults(); + AppSettings.Load(); + + Assert.Equal(2, AppSettings.VisualMode); + Assert.Equal(77, AppSettings.CircleMode.CircleRadius); + Assert.Equal(new[] { 0x11, 0x56 }, AppSettings.VisualModeKeyBind); + Assert.False(AppSettings.ShowSpeech); + } + + [Fact] + public void 키_목록이_없는_예전_설정은_예전_단축키_하나로_읽는다() + { + File.WriteAllText(_path, """{ "VisualModeHotkey": 120, "Language": "Deutsch" }"""); + + AppSettings.Load(); + + Assert.Equal(new[] { 120 }, AppSettings.VisualModeKeyBind); + Assert.Equal(new[] { AppSettings.DefaultSoundModeKey }, AppSettings.StereoUpmixKeyBind); + Assert.Equal("de", AppSettings.Language); // 예전 판의 언어 이름은 코드로 옮겨 읽는다 + } + + [Fact] + public void 깨진_설정_파일은_기본값으로_시작한다() + { + File.WriteAllText(_path, "{ not json"); + + AppSettings.Load(); + + Assert.Equal(0, AppSettings.VisualMode); + Assert.Equal(new[] { AppSettings.DefaultEditModeKey }, AppSettings.EditModeKeyBind); + } + + [Fact] + public void 모드별_기본값은_한_곳에서_온다() + { + var fresh = new VisualModeSettings(); + foreach (var mode in new[] { AppSettings.WaveMode, AppSettings.PadMode, AppSettings.CircleMode, AppSettings.OutlineMode }) + { + Assert.Equal(JsonSerializer.Serialize(fresh), JsonSerializer.Serialize(mode)); + } + } + + [Fact] + public void 새로_설치하면_Windows_화면_언어를_따른다() + { + Assert.Equal("system", AppSettings.DefaultLanguage); + } + + [Fact] + public void 언어는_예전과_같은_이름의_항목에_코드로_저장한다() + { + AppSettings.Language = "zh-Hant"; + AppSettings.SaveNow(); + + using (var doc = JsonDocument.Parse(File.ReadAllText(_path))) + { + Assert.Equal("zh-Hant", doc.RootElement.GetProperty("Language").GetString()); + } + + AppSettings.Language = "ko"; + AppSettings.Load(); + Assert.Equal("zh-Hant", AppSettings.Language); + } + + [Theory] + [InlineData("KOR", "ko")] + [InlineData("English", "en")] + [InlineData("ENG", "en")] + [InlineData("日本語", "ja")] + [InlineData("中文", "zh-Hans")] + [InlineData("Español", "es")] + [InlineData("Français", "fr")] + [InlineData("Русский", "ru")] + [InlineData("system", "system")] + [InlineData("pt-BR", "pt-BR")] + public void 예전_언어_값은_읽을_때_코드로_옮기고_다음_저장부터_코드로_쓴다(string stored, string expected) + { + File.WriteAllText(_path, JsonSerializer.Serialize(new { Language = stored })); + + AppSettings.Load(); + Assert.Equal(expected, AppSettings.Language); + + AppSettings.SaveNow(); + using var doc = JsonDocument.Parse(File.ReadAllText(_path)); + Assert.Equal(expected, doc.RootElement.GetProperty("Language").GetString()); + } + + [Fact] + public void 언어_항목이_없는_아주_예전_설정은_한국어로_읽는다() + { + File.WriteAllText(_path, """{ "VisualMode": 1 }"""); + + AppSettings.Load(); + + Assert.Equal("ko", AppSettings.Language); + } + + [Theory] + [InlineData("""{ "Language": "Klingon" }""")] + [InlineData("""{ "Language": null }""")] + [InlineData("""{ "Language": "" }""")] + public void 읽을_수_없는_언어_값은_Windows_화면_언어를_따른다(string json) + { + File.WriteAllText(_path, json); + + AppSettings.Load(); + + Assert.Equal("system", AppSettings.Language); + } + + // 소리 탭의 두 값. 초인종(Doorbell)은 기본이 환경음, 사이렌(Siren)은 기본이 위협음인 소리로 쓴다. + private static Dictionary Types(params (string Name, string Label)[] values) => + values.ToDictionary(value => value.Name, value => value.Label); + + private static List<(string, string)> Sorted(IReadOnlyDictionary types) => + types.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => (pair.Key, pair.Value)).ToList(); + + [Fact] + public void 시험에_쓰는_소리의_기본_종류() + { + Assert.Equal(SoundType.Ambient, YamnetThreeClassMapper.MapDisplayNameToCoarse("Doorbell")); + Assert.Equal(SoundType.Danger, YamnetThreeClassMapper.MapDisplayNameToCoarse("Siren")); + } + + [Fact] + public void 바꾼_소리_종류와_고급_모드는_저장한_뒤_다시_읽힌다() + { + AppSettings.SoundTypes = Types(("Doorbell", SoundTypeLabels.Danger), ("Siren", SoundTypeLabels.Ambient)); + AppSettings.ClassifyAdvanced = true; + AppSettings.SaveNow(); + + ClearSoundTab(); + AppSettings.Load(); + + Assert.Equal(new List<(string, string)> { ("Doorbell", "danger"), ("Siren", "ambient") }, Sorted(AppSettings.SoundTypes)); + Assert.True(AppSettings.ClassifyAdvanced); + } + + [Fact] + public void 소리_종류는_이름_순서의_JSON_객체로_저장한다() + { + AppSettings.SoundTypes = Types(("Siren", SoundTypeLabels.Ambient), ("Doorbell", SoundTypeLabels.Danger)); + AppSettings.SaveNow(); + + using var doc = JsonDocument.Parse(File.ReadAllText(_path)); + var types = doc.RootElement.GetProperty("SoundTypes"); + Assert.Equal(JsonValueKind.Object, types.ValueKind); + Assert.Equal(new[] { "Doorbell", "Siren" }, types.EnumerateObject().Select(property => property.Name)); + Assert.Equal("danger", types.GetProperty("Doorbell").GetString()); + Assert.False(doc.RootElement.GetProperty("ClassifyAdvanced").GetBoolean()); + } + + [Fact] + public void 바꾼_소리가_없으면_빈_객체로_저장한다() + { + AppSettings.SaveNow(); + + using var doc = JsonDocument.Parse(File.ReadAllText(_path)); + Assert.Empty(doc.RootElement.GetProperty("SoundTypes").EnumerateObject()); + } + + [Fact] + public void 소리_종류는_읽을_때_거른다() + { + File.WriteAllText(_path, """ + { "SoundTypes": { "Doorbell": "danger", "Siren": "danger", "Speech": "loud", "Dog": 3, "Old model name": "speech" }, + "ClassifyAdvanced": false } + """); + + AppSettings.Load(); + + // 기본 종류와 같은 값(사이렌 = 위협음), 세 라벨이 아닌 값, 문자열이 아닌 값은 버리고, 모르는 이름은 모바일처럼 남긴다. + Assert.Equal(new List<(string, string)> { ("Doorbell", "danger"), ("Old model name", "speech") }, Sorted(AppSettings.SoundTypes)); + } + + [Fact] + public void 소리_탭_항목이_없는_예전_설정은_바꾼_소리_없이_기본_모드로_읽는다() + { + // 앞에서 들고 있던 값이 남지 않는지도 본다. + AppSettings.SoundTypes = Types(("Doorbell", SoundTypeLabels.Danger)); + AppSettings.ClassifyAdvanced = true; + File.WriteAllText(_path, """{ "VisualMode": 1, "Language": "ko" }"""); + + AppSettings.Load(); + + Assert.Empty(AppSettings.SoundTypes); + Assert.False(AppSettings.ClassifyAdvanced); + Assert.Equal(1, AppSettings.VisualMode); + } + + [Fact] + public void 고급_모드를_저장한_적이_없으면_바꾼_소리가_있을_때만_고급_모드로_연다() + { + File.WriteAllText(_path, """{ "SoundTypes": { "Doorbell": "danger" } }"""); + AppSettings.Load(); + Assert.True(AppSettings.ClassifyAdvanced); + + // 거르고 나서 남은 것이 없으면 바꾼 소리가 없는 것이다. + File.WriteAllText(_path, """{ "SoundTypes": { "Siren": "danger" } }"""); + AppSettings.Load(); + Assert.False(AppSettings.ClassifyAdvanced); + + // 저장한 값이 있으면 그것을 따른다. + File.WriteAllText(_path, """{ "SoundTypes": { "Doorbell": "danger" }, "ClassifyAdvanced": false }"""); + AppSettings.Load(); + Assert.False(AppSettings.ClassifyAdvanced); + } + + [Theory] + [InlineData("\"danger\"")] + [InlineData("""[ "Doorbell", "danger" ]""")] + [InlineData("null")] + public void 소리_종류를_읽지_못해도_다른_설정은_읽는다(string stored) + { + File.WriteAllText(_path, "{ \"VisualMode\": 2, \"SoundTypes\": " + stored + " }"); + + AppSettings.Load(); + + Assert.Equal(2, AppSettings.VisualMode); + Assert.Empty(AppSettings.SoundTypes); + } + + [Fact] + public void 전체_초기화는_바꾼_소리_종류와_고급_모드를_건드리지_않는다() + { + AppSettings.SoundTypes = Types(("Doorbell", SoundTypeLabels.Danger)); + AppSettings.ClassifyAdvanced = true; + + AppSettings.ResetToDefaults(); + + Assert.Equal(SoundTypeLabels.Danger, AppSettings.SoundTypes["Doorbell"]); + Assert.True(AppSettings.ClassifyAdvanced); + } + + [Fact] + public void 넣은_소리_종류_맵은_얼려_두어_넘긴_쪽이_고쳐도_바뀌지_않는다() + { + var types = Types(("Doorbell", SoundTypeLabels.Danger)); + AppSettings.SoundTypes = types; + types["Siren"] = SoundTypeLabels.Ambient; + types.Remove("Doorbell"); + + Assert.Equal(new[] { "Doorbell" }, AppSettings.SoundTypes.Keys); + + // AI 가 앞서 읽어 둔 맵도 바꿔 끼운 뒤에 그대로다. + var before = AppSettings.SoundTypes; + AppSettings.SoundTypes = Types(("Siren", SoundTypeLabels.Ambient)); + Assert.Equal(new[] { "Doorbell" }, before.Keys); + Assert.Equal(new[] { "Siren" }, AppSettings.SoundTypes.Keys); + } + + // ===== 튜토리얼을 본 적이 있는지(모바일 TutorialSettingTest) ===== + // 처음 여는 사람에게만 튜토리얼을 저절로 띄운다. 이 기능 전부터 쓰던 사람은 이미 앱을 알므로 띄우지 않는다. + + [Fact] + public void 새로_설치하면_튜토리얼을_아직_보지_않은_것이다() + { + AppSettings.TutorialSeen = true; // 앞에서 들고 있던 값이 남지 않는지도 본다 + + AppSettings.Load(); + + Assert.False(File.Exists(_path)); + Assert.False(AppSettings.TutorialSeen); + } + + [Fact] + public void 이_기능_전부터_쓰던_사람은_튜토리얼을_본_것으로_친다() + { + File.WriteAllText(_path, """{ "VisualMode": 1, "Language": "ko" }"""); + + AppSettings.Load(); + + Assert.True(AppSettings.TutorialSeen); + } + + [Fact] + public void 깨진_설정_파일이_있어도_쓰던_사람이라_튜토리얼을_본_것으로_친다() + { + File.WriteAllText(_path, "{ not json"); + + AppSettings.Load(); + + Assert.True(AppSettings.TutorialSeen); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void 저장한_튜토리얼_값이_있으면_다른_설정과_상관없이_그_값을_쓴다(bool seen) + { + File.WriteAllText(_path, "{ \"VisualMode\": 1, \"TutorialSeen\": " + (seen ? "true" : "false") + " }"); + + AppSettings.Load(); + + Assert.Equal(seen, AppSettings.TutorialSeen); + } + + [Fact] + public void 처음_정한_값은_다른_설정을_먼저_저장해도_바뀌지_않는다() + { + // 새로 설치하고 튜토리얼을 닫지 않은 채 언어만 바꾸고 나갔다가 다시 여는 경우. 저장된 파일이 생겼다고 쓰던 사람으로 치면 안 된다. + AppSettings.Load(); + Assert.False(AppSettings.TutorialSeen); + + AppSettings.Language = "ja"; + AppSettings.SaveNow(); + AppSettings.Load(); + + Assert.False(AppSettings.TutorialSeen); + using var doc = JsonDocument.Parse(File.ReadAllText(_path)); + Assert.False(doc.RootElement.GetProperty("TutorialSeen").GetBoolean()); + } + + [Fact] + public void 닫아서_본_것으로_저장하면_다시_열어도_띄우지_않는다() + { + AppSettings.Load(); + AppSettings.TutorialSeen = true; + AppSettings.SaveNow(); + + AppSettings.TutorialSeen = false; + AppSettings.Load(); + + Assert.True(AppSettings.TutorialSeen); + } + + [Fact] + public void 전체_초기화는_튜토리얼을_본_기록을_건드리지_않는다() + { + // 모양 설정을 되돌렸다고 튜토리얼이 다시 뜨면 안 된다. 본 기록은 사람에 대한 값이다. + AppSettings.TutorialSeen = true; + + AppSettings.ResetToDefaults(); + + Assert.True(AppSettings.TutorialSeen); + } +} diff --git a/SoundVisualizer.Tests/Audio/CaptureLevelTests.cs b/SoundVisualizer.Tests/Audio/CaptureLevelTests.cs new file mode 100644 index 0000000..00294f0 --- /dev/null +++ b/SoundVisualizer.Tests/Audio/CaptureLevelTests.cs @@ -0,0 +1,78 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Audio; + +namespace SoundVisualizer.Tests.Audio; + +/// +/// 개발자 모드 HUD·결과 기록의 소리 크기(모바일 AudioEngine.currentLevel). 이 값이 틀리면 채점할 때 "조용해서 안 그려졌다" 와 +/// "AI 가 못 잡았다" 를 거꾸로 읽는다. +/// +public class CaptureLevelTests +{ + [Fact] + public void 버퍼를_받기_전에는_0이다() + { + Assert.Equal(0f, new CaptureLevel().Read(nowMs: 1_000L)); + } + + [Fact] + public void 가장_최근_버퍼의_채널_가운데_가장_큰_피크를_읽는다() + { + var level = new CaptureLevel(); + level.Offer([0.1f, 0.7f, 0.2f, 0f, 0f, 0f, 0f, 0f], nowMs: 1_000L); + Assert.Equal(0.7f, level.Read(1_000L)); + + // LFE 만 울려도 AI 무음 게이트는 소리가 있다고 본다. 그리는 쪽과 달리 이 값도 그 채널을 센다. + level.Offer([0f, 0f, 0f, 0f, 0f, 0f, 0f, 0.4f], nowMs: 1_010L); + Assert.Equal(0.4f, level.Read(1_010L)); + } + + [Fact] + public void 앞_버퍼의_큰_값을_모으지_않는다() + { + // PeakAccumulator 와 다른 점이다. 지금 순간의 크기라 총성 뒤의 조용한 버퍼가 오면 바로 작아진다. + var level = new CaptureLevel(); + level.Offer([0.9f, 0.9f], nowMs: 1_000L); + level.Offer([0.05f, 0.02f], nowMs: 1_010L); + + Assert.Equal(0.05f, level.Read(1_010L)); + } + + [Fact] + public void 읽어도_비우지_않는다() + { + var level = new CaptureLevel(); + level.Offer([0.3f, 0.6f], nowMs: 1_000L); + + Assert.Equal(0.6f, level.Read(1_050L)); + Assert.Equal(0.6f, level.Read(1_100L)); + } + + [Fact] + public void NaN_인_채널은_건너뛴다() + { + var level = new CaptureLevel(); + level.Offer([float.NaN, 0.25f, float.NaN], nowMs: 1_000L); + Assert.Equal(0.25f, level.Read(1_000L)); + + level.Offer([float.NaN, float.NaN], nowMs: 1_010L); + Assert.Equal(0f, level.Read(1_010L)); + } + + [Fact] + public void 버퍼가_끊기면_정한_시간이_지난_뒤부터_0으로_읽는다() + { + // WASAPI 루프백은 아무것도 재생하지 않으면 버퍼를 보내지 않는다. 마지막 값이 남으면 조용한데도 소리가 나는 것처럼 보인다. + var level = new CaptureLevel(); + level.Offer([0.5f, 0.5f], nowMs: 10_000L); + + Assert.Equal(0.5f, level.Read(10_000L + CaptureLevel.StaleAfterMs)); + Assert.Equal(0f, level.Read(10_000L + CaptureLevel.StaleAfterMs + 1)); + + // 다시 소리가 나면 바로 읽힌다. + level.Offer([0.2f, 0.1f], nowMs: 20_000L); + Assert.Equal(0.2f, level.Read(20_000L)); + } +} diff --git a/SoundVisualizer.Tests/Audio/ChannelDownmixTests.cs b/SoundVisualizer.Tests/Audio/ChannelDownmixTests.cs new file mode 100644 index 0000000..f62dbfd --- /dev/null +++ b/SoundVisualizer.Tests/Audio/ChannelDownmixTests.cs @@ -0,0 +1,112 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Runtime.InteropServices; +using SoundVisualizer.Audio; + +namespace SoundVisualizer.Tests.Audio; + +public class ChannelDownmixTests +{ + private static byte[] Floats(params float[] values) => MemoryMarshal.AsBytes(values.AsSpan()).ToArray(); + + private static float[] AsFloats(byte[] bytes, int length) => MemoryMarshal.Cast(bytes.AsSpan(0, length)).ToArray(); + + [Fact] + public void 녹음된_바이트까지만_쓰고_재사용_버퍼의_남은_부분은_버린다() + { + // NAudio 는 캡처 버퍼를 재사용한다. 이번 버퍼는 앞의 1프레임(2ch)뿐이고 뒤는 지난 버퍼의 찌꺼기다. + byte[] reused = Floats(0.1f, 0.2f, /* 찌꺼기 */ 0.9f, 0.9f, 0.9f, 0.9f); + int bytesRecorded = 2 * sizeof(float); + var output = Array.Empty(); + + int written = ChannelDownmix.Convert(reused.AsSpan(0, bytesRecorded), inChannels: 2, bytesPerSample: 4, outChannels: 1, ref output); + + Assert.Equal(sizeof(float), written); + Assert.Equal(new[] { 0.1f }, AsFloats(output, written)); + } + + [Fact] + public void 칠점일_채널은_WASAPI_순서로_섞고_LFE_는_뺀다() + { + // FL, FR, FC, LFE, BL, BR, SL, SR + byte[] frame = Floats(0.10f, 0.20f, 0.30f, 0.90f, 0.40f, 0.50f, 0.06f, 0.07f); + var output = Array.Empty(); + + int written = ChannelDownmix.Convert(frame, 8, 4, 2, ref output); + float[] lr = AsFloats(output, written); + + Assert.Equal(0.10f + 0.707f * 0.30f + 0.707f * 0.06f + 0.5f * 0.40f, lr[0], 5); + Assert.Equal(0.20f + 0.707f * 0.30f + 0.707f * 0.07f + 0.5f * 0.50f, lr[1], 5); + } + + [Fact] + public void 오점일_채널은_서라운드_한_쌍을_섞고_LFE_는_뺀다() + { + // FL, FR, FC, LFE, SL, SR + byte[] frame = Floats(0.10f, 0.20f, 0.30f, 0.90f, 0.40f, 0.50f); + var output = Array.Empty(); + + int written = ChannelDownmix.Convert(frame, 6, 4, 2, ref output); + float[] lr = AsFloats(output, written); + + Assert.Equal(0.10f + 0.707f * 0.30f + 0.707f * 0.40f, lr[0], 5); + Assert.Equal(0.20f + 0.707f * 0.30f + 0.707f * 0.50f, lr[1], 5); + } + + [Fact] + public void 섞은_값은_1_을_넘지_않는다() + { + byte[] frame = Floats(1f, -1f, 1f, 0f, 1f, -1f, 1f, -1f); + var output = Array.Empty(); + + int written = ChannelDownmix.Convert(frame, 8, 4, 2, ref output); + + Assert.Equal(new[] { 1f, -1f }, AsFloats(output, written)); + } + + [Fact] + public void 모노는_두_채널로_복제한다() + { + byte[] frames = Floats(0.25f, -0.5f); + var output = Array.Empty(); + + int written = ChannelDownmix.Convert(frames, 1, 4, 2, ref output); + + Assert.Equal(new[] { 0.25f, 0.25f, -0.5f, -0.5f }, AsFloats(output, written)); + } + + [Fact] + public void 정수_PCM_도_샘플_단위로_옮긴다() + { + // 16bit 스테레오 2프레임 → 모노: 각 프레임의 왼쪽 샘플(2바이트)만 남는다. + byte[] pcm = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 }; + var output = Array.Empty(); + + int written = ChannelDownmix.Convert(pcm, 2, 2, 1, ref output); + + Assert.Equal(new byte[] { 0x01, 0x02, 0x05, 0x06 }, output.AsSpan(0, written).ToArray()); + } + + [Fact] + public void 자리가_모자라면_버퍼를_늘리고_넉넉하면_그대로_쓴다() + { + byte[] frame = Floats(0.1f, 0.2f, 0.3f, 0f, 0.4f, 0.5f, 0.6f, 0.7f); + var output = new byte[2]; + + ChannelDownmix.Convert(frame, 8, 4, 2, ref output); + Assert.True(output.Length >= 2 * sizeof(float)); + + var kept = output; + ChannelDownmix.Convert(frame, 8, 4, 2, ref output); + Assert.Same(kept, output); + } + + [Fact] + public void 잘못된_형식은_아무것도_쓰지_않는다() + { + var output = Array.Empty(); + Assert.Equal(0, ChannelDownmix.Convert(Floats(0.1f), 0, 4, 2, ref output)); + Assert.Equal(0, ChannelDownmix.Convert(Floats(0.1f), 2, 0, 2, ref output)); + } +} diff --git a/SoundVisualizer.Tests/Audio/PeakAccumulatorTests.cs b/SoundVisualizer.Tests/Audio/PeakAccumulatorTests.cs new file mode 100644 index 0000000..b386b7a --- /dev/null +++ b/SoundVisualizer.Tests/Audio/PeakAccumulatorTests.cs @@ -0,0 +1,98 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Audio; + +namespace SoundVisualizer.Tests.Audio; + +public class PeakAccumulatorTests +{ + private readonly float[] _frame = new float[PeakAccumulator.ChannelCount]; + + [Fact] + public void 프레임_사이의_버퍼들은_채널마다_가장_큰_값만_남는다() + { + var acc = new PeakAccumulator(); + acc.Offer(0.9f, 0.1f, 0f, 0f, 0f, 0f, 0f, 0f); // 총성 + acc.Offer(0.05f, 0.3f, 0f, 0f, 0f, 0f, 0f, 0.2f); // 바로 뒤의 조용한 버퍼 + + Assert.True(acc.TryTake(_frame)); + Assert.Equal(new[] { 0.9f, 0.3f, 0f, 0f, 0f, 0f, 0f, 0.2f }, _frame); + } + + [Fact] + public void 버퍼가_오지_않았으면_가져갈_것이_없고_이전_값을_건드리지_않는다() + { + var acc = new PeakAccumulator(); + Array.Fill(_frame, 0.5f); + + Assert.False(acc.TryTake(_frame)); + Assert.All(_frame, v => Assert.Equal(0.5f, v)); + } + + [Fact] + public void 가져가면_비워진다() + { + var acc = new PeakAccumulator(); + acc.Offer(0.4f, 0.4f, 0.4f, 0.4f, 0.4f, 0.4f, 0.4f, 0.4f); + Assert.True(acc.TryTake(_frame)); + + Assert.False(acc.TryTake(_frame)); + acc.Offer(0.1f, 0f, 0f, 0f, 0f, 0f, 0f, 0f); + Assert.True(acc.TryTake(_frame)); + Assert.Equal(0.1f, _frame[0]); + Assert.Equal(0f, _frame[1]); + } + + [Fact] + public void 조용한_버퍼도_새_값으로_가져간다() + { + // 소리가 멈추면 0 이 바로 들어와야 그래픽이 줄어든다. "버퍼 없음" 과 구분한다. + var acc = new PeakAccumulator(); + acc.Offer(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f); + Array.Fill(_frame, 0.7f); + + Assert.True(acc.TryTake(_frame)); + Assert.All(_frame, v => Assert.Equal(0f, v)); + } + + [Fact] + public void Clear_는_모인_값을_버린다() + { + var acc = new PeakAccumulator(); + acc.Offer(1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f); + acc.Clear(); + Assert.False(acc.TryTake(_frame)); + } + + [Fact] + public void NaN_은_버린다() + { + var acc = new PeakAccumulator(); + acc.Offer(float.NaN, 0.2f, 0f, 0f, 0f, 0f, 0f, 0f); + Assert.True(acc.TryTake(_frame)); + Assert.Equal(0f, _frame[0]); + Assert.Equal(0.2f, _frame[1]); + } + + [Fact] + public void 여러_스레드가_동시에_넣어도_가장_큰_값이_남는다() + { + var acc = new PeakAccumulator(); + Parallel.For(0, 10_000, i => + { + float v = i / 10_000f; + acc.Offer(v, v, v, v, v, v, v, v); + }); + + Assert.True(acc.TryTake(_frame)); + Assert.All(_frame, v => Assert.Equal(9_999 / 10_000f, v)); + } + + [Fact] + public void 받을_자리가_모자라면_거절한다() + { + var acc = new PeakAccumulator(); + Assert.Throws(() => acc.TryTake(new float[4])); + } +} diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugCsvTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugCsvTests.cs new file mode 100644 index 0000000..12fb208 --- /dev/null +++ b/SoundVisualizer.Tests/Diagnostics/AiDebugCsvTests.cs @@ -0,0 +1,217 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using System.Text; +using SoundVisualizer.Ai; +using SoundVisualizer.Diagnostics; +using TopClassHit = SoundVisualizer.Ai.YamnetCoarseClassifier.TopClassHit; + +namespace SoundVisualizer.Tests.Diagnostics; + +/// +/// 개발자 모드 기록의 CSV 한 줄(모바일 AiDebugCsvTest, #193). +/// +/// 고정하는 것은 표가 어긋나는 경우들이다. 이름에 쉼표가 들어가는 것(YAMNet 은 "Gunshot, gunfire" 처럼 쉼표가 든 이름을 쓴다), +/// 로캘이 바뀌어 숫자가 달라지는 것, 값이 없는데 NaN 이 찍히는 것. PC 는 두 판의 기록을 같은 스크립트로 읽도록 머리글과 한 줄 전체를 +/// 모바일 규칙으로 만든 글자와 맞춰 본다. capture_source 는 늘 Loopback 이다. +/// +public class AiDebugCsvTests +{ + private static AiClassificationResult Result( + SoundType coarse = SoundType.Danger, + string display = "Gunshot, gunfire", + float confidence = 0.54321f, + IReadOnlyList? top5 = null, + long timestampMs = 1_000L, + double preprocessMs = 3.21, + double yamnetMs = 41.0, + double totalMs = 46.21) => new() + { + Coarse = coarse, + Display = display, + Confidence = confidence, + Top5 = top5 ?? [new TopClassHit(1, "Gunshot, gunfire", 0.54321f), new TopClassHit(2, "Speech", 0.1f)], + DangerCuePromoted = false, + MeetsThreshold = true, + TimestampMs = timestampMs, + PreprocessMs = preprocessMs, + YamnetMs = yamnetMs, + TotalMs = totalMs, + }; + + /// 칸 수를 맞춰 세려면 따옴표 안의 쉼표를 빼고 세야 한다. 아주 작은 CSV 파서. + private static List Cells(string line) + { + var cells = new List(); + var cell = new StringBuilder(); + bool quoted = false; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (c == '"' && quoted && i + 1 < line.Length && line[i + 1] == '"') + { + cell.Append('"'); + i++; + } + else if (c == '"') + { + quoted = !quoted; + } + else if (c == ',' && !quoted) + { + cells.Add(cell.ToString()); + cell.Clear(); + } + else + { + cell.Append(c); + } + } + cells.Add(cell.ToString()); + return cells; + } + + private static int Column(string name) => AiDebugCsv.Columns.ToList().IndexOf(name); + + [Fact] // 머리글과 줄의 칸 수가 같다 + public void 머리글과_줄의_칸_수가_같다() + { + var header = Cells(AiDebugCsv.Header); + var row = Cells(AiDebugCsv.Row(Result(), nowMs: 1_500L, level: 0.4f, shown: true)); + Assert.Equal(header.Count, row.Count); + Assert.Equal(AiDebugCsv.Columns, header); + } + + [Fact] + public void 머리글은_모바일과_칸_이름과_순서가_같다() + { + // 모바일 AiDebugCsv.COLUMNS 를 그대로 적었다. 두 판의 기록을 같은 스크립트로 읽는다. + Assert.Equal( + "time_ms,result_ms,age_ms,capture_source,coarse,display,confidence,meets_threshold,danger_cue_promoted," + + "top1_name,top1_prob,top2_name,top2_prob,top3_name,top3_prob,top4_name,top4_prob,top5_name,top5_prob," + + "level,shown,preprocess_ms,yamnet_ms,total_ms,mapping_override_count,mapping_signature", + AiDebugCsv.Header); + } + + [Fact] + public void 한_줄의_모든_칸이_모바일_규칙의_글자와_같다() + { + // 모든 칸을 따옴표로 감싸고, 참거짓은 소문자, 종류는 저장 문자열, 확률은 다섯 자리, 소요시간은 한 자리다. + Assert.Equal( + "\"1500\",\"1000\",\"500\",\"Loopback\",\"danger\",\"Gunshot, gunfire\",\"0.54321\",\"true\",\"false\"," + + "\"Gunshot, gunfire\",\"0.54321\",\"Speech\",\"0.10000\",\"\",\"\",\"\",\"\",\"\",\"\"," + + "\"0.40000\",\"true\",\"3.2\",\"41.0\",\"46.2\",\"0\",\"default\"", + AiDebugCsv.Row(Result(), nowMs: 1_500L, level: 0.4f, shown: true)); + } + + [Fact] // 쉼표가 든 이름이 칸을 밀지 않는다 + public void 쉼표가_든_이름이_칸을_밀지_않는다() + { + var parsed = Cells(AiDebugCsv.Row(Result(), nowMs: 1_500L, level: 0.4f, shown: true)); + Assert.Equal("Gunshot, gunfire", parsed[Column("display")]); + Assert.Equal("Gunshot, gunfire", parsed[Column("top1_name")]); + Assert.Equal(Cells(AiDebugCsv.Header).Count, parsed.Count); + } + + [Fact] // 이름에 든 따옴표도 살아남는다 + public void 이름에_든_따옴표도_살아남는다() + { + var parsed = Cells(AiDebugCsv.Row(Result(display: "he said \"stop\""), nowMs: 1_500L, level: 0.4f, shown: true)); + Assert.Equal("he said \"stop\"", parsed[Column("display")]); + Assert.Equal(Cells(AiDebugCsv.Header).Count, parsed.Count); + } + + [Fact] // 모델이 덜 내놓으면 남은 top 칸은 비운다 + public void 모델이_덜_내놓으면_남은_top_칸은_비운다() + { + var parsed = Cells(AiDebugCsv.Row(Result(), nowMs: 1_500L, level: 0f, shown: false)); + Assert.Equal("Speech", parsed[Column("top2_name")]); + Assert.Equal(AiDebugCsv.None, parsed[Column("top3_name")]); + Assert.Equal(AiDebugCsv.None, parsed[Column("top5_prob")]); + } + + [Fact] // 언어를 바꿔도 숫자 서식이 같다 + public void 언어를_바꿔도_숫자_서식이_같다() + { + // 아랍어 로캘은 소수점(٫)과 음수 표기가 다르다. 기본 서식을 쓰면 표가 읽히지 않는다. + string us = AiDebugCsv.Row(Result(), nowMs: 1_500L, level: 0.4f, shown: true); + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("ar-EG"); + Assert.NotEqual("0.54321", 0.54321f.ToString("F5", CultureInfo.CurrentCulture)); + string arabic = AiDebugCsv.Row(Result(), nowMs: 1_500L, level: 0.4f, shown: true); + Assert.Equal(us, arabic); + } + finally + { + CultureInfo.CurrentCulture = original; + } + Assert.Contains("0.54321", us); // 확신도가 0.54321 로 적히지 않았다 + } + + [Fact] // 나이는 음수로 적지 않는다 + public void 나이는_음수로_적지_않는다() + { + // 벽시계는 뒤로 튈 수 있다. + var parsed = Cells(AiDebugCsv.Row(Result(timestampMs: 5_000L), nowMs: 1_000L, level: 0f, shown: false)); + Assert.Equal("0", parsed[Column("age_ms")]); + } + + [Fact] // 캡처 소스를 행에 기록한다 + public void 캡처_소스는_PC_에서_늘_Loopback_이다() + { + // 모바일은 실행마다 InternalPlayback·Microphone 을 적는다. PC 는 시스템 소리(루프백)만 받는다. + var parsed = Cells(AiDebugCsv.Row(Result(), nowMs: 1_500L, level: 0.4f, shown: true)); + Assert.Equal("Loopback", parsed[Column("capture_source")]); + Assert.Equal(AiDebugCsv.CaptureSource, parsed[Column("capture_source")]); + } + + [Fact] // 매핑 설정은 추론 결과 스냅샷으로 기록한다 + public void 매핑_설정은_추론_결과_스냅샷으로_기록한다() + { + var baseline = Result(); + var mapped = new AiClassificationResult + { + Coarse = baseline.Coarse, + Display = baseline.Display, + Confidence = baseline.Confidence, + Top5 = baseline.Top5, + MeetsThreshold = baseline.MeetsThreshold, + TimestampMs = baseline.TimestampMs, + PreprocessMs = baseline.PreprocessMs, + YamnetMs = baseline.YamnetMs, + TotalMs = baseline.TotalMs, + MappingOverrideCount = 1, + MappingSignature = "Siren=ambient", + }; + + var parsed = Cells(AiDebugCsv.Row(mapped, 1_500L, .4f, true)); + Assert.Equal("1", parsed[Column("mapping_override_count")]); + Assert.Equal("Siren=ambient", parsed[Column("mapping_signature")]); + var plain = Cells(AiDebugCsv.Row(baseline, 1_500L, .4f, true)); + Assert.Equal("0", plain[Column("mapping_override_count")]); + Assert.Equal("default", plain[Column("mapping_signature")]); + } + + [Fact] + public void 소요시간은_모바일처럼_5에서_올린다() + { + // .NET "F1" 이면 0.2·0.1·12.3 이 된다. 같은 결과를 두 판이 다르게 적지 않게 한다. + var parsed = Cells(AiDebugCsv.Row(Result(preprocessMs: 0.25, yamnetMs: 0.15, totalMs: 12.35), 1_500L, 0.4f, true)); + Assert.Equal("0.3", parsed[Column("preprocess_ms")]); + Assert.Equal("0.2", parsed[Column("yamnet_ms")]); + Assert.Equal("12.4", parsed[Column("total_ms")]); + } + + [Fact] + public void 값이_없는_숫자는_빈_칸으로_둔다() + { + var parsed = Cells(AiDebugCsv.Row(Result(confidence: float.NaN, totalMs: double.NaN), 1_500L, float.NaN, true)); + Assert.Equal(AiDebugCsv.None, parsed[Column("confidence")]); + Assert.Equal(AiDebugCsv.None, parsed[Column("level")]); + Assert.Equal(AiDebugCsv.None, parsed[Column("total_ms")]); + Assert.Equal("3.2", parsed[Column("preprocess_ms")]); + } +} diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugHudTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugHudTests.cs new file mode 100644 index 0000000..c0489ee --- /dev/null +++ b/SoundVisualizer.Tests/Diagnostics/AiDebugHudTests.cs @@ -0,0 +1,251 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Diagnostics; +using TopClassHit = SoundVisualizer.Ai.YamnetCoarseClassifier.TopClassHit; + +namespace SoundVisualizer.Tests.Diagnostics; + +/// +/// 개발자 모드 HUD 를 언제 읽고 언제 기록하는지(모바일 AiDebugOverlay 의 produceState·DisposableEffect). +/// +/// 기록은 오버레이가 열려 있고, 개발자 모드가 켜져 있고, 기록 스위치가 켜져 있을 때만 돈다. 이 셋이 어긋나면 조용히 어긋난다. +/// 켜 둔 줄 알았는데 파일이 비어 있으면 그 구간을 "결과가 없었다" 로 읽고, 끈 줄 알았는데 계속 쓰면 디스크를 먹는다. +/// +public sealed class AiDebugHudTests : IDisposable +{ + private readonly FakeRecorder _recorder = new(); + private readonly string _root = Path.Combine(Path.GetTempPath(), "sv-aihud-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // 임시 폴더라 남아도 된다. + } + catch (UnauthorizedAccessException) + { + } + } + + [Fact] + public void 처음에는_읽지도_기록하지도_않는다() + { + using var hud = new AiDebugHud(_recorder); + + Assert.False(hud.IsPolling); + Assert.False(hud.IsRecording); + Assert.Empty(_recorder.Calls); + } + + [Fact] + public void 개발자_모드를_켜면_읽기_시작하고_기록은_스위치를_따른다() + { + using var hud = new AiDebugHud(_recorder); + + Assert.True(hud.Update(developerMode: true, record: false)); + Assert.True(hud.IsPolling); + Assert.False(hud.IsRecording); + Assert.Empty(_recorder.Calls); + + // 읽는 중에 기록 스위치를 켜면 HUD 는 그대로 두고 기록만 켠다. + Assert.False(hud.Update(developerMode: true, record: true)); + Assert.True(hud.IsRecording); + Assert.Equal(new[] { "Start" }, _recorder.Calls); + + Assert.False(hud.Update(developerMode: true, record: false)); + Assert.False(hud.IsRecording); + Assert.Equal(new[] { "Start", "Stop" }, _recorder.Calls); + } + + [Fact] + public void 기록_스위치만_켜져_있으면_기록하지_않는다() + { + // 모바일처럼 개발자 모드를 꺼도 기록 스위치의 값은 남는다. 그 값만으로 파일을 열면 안 된다. + using var hud = new AiDebugHud(_recorder); + + Assert.False(hud.Update(developerMode: false, record: true)); + Assert.False(hud.IsPolling); + Assert.False(hud.IsRecording); + Assert.Empty(_recorder.Calls); + + // 개발자 모드를 켜는 순간 남아 있던 스위치대로 기록을 시작한다. + Assert.True(hud.Update(developerMode: true, record: true)); + Assert.Equal(new[] { "Start" }, _recorder.Calls); + } + + [Fact] + public void 개발자_모드를_끄면_읽기와_기록을_함께_멈춘다() + { + using var hud = new AiDebugHud(_recorder); + hud.Update(developerMode: true, record: true); + + Assert.True(hud.Update(developerMode: false, record: true)); + Assert.False(hud.IsPolling); + Assert.False(hud.IsRecording); + Assert.Equal(new[] { "Start", "Stop" }, _recorder.Calls); + } + + [Fact] + public void 같은_설정을_거듭_알려도_한_번만_켜고_끈다() + { + // 오버레이는 프레임마다 지금 설정을 알린다. 그때마다 켜면 기록 파일이 프레임마다 새로 생긴다. + using var hud = new AiDebugHud(_recorder); + + Assert.True(hud.Update(developerMode: true, record: true)); + Assert.False(hud.Update(developerMode: true, record: true)); + Assert.False(hud.Update(developerMode: true, record: true)); + Assert.False(hud.Update(developerMode: true, record: false)); + Assert.False(hud.Update(developerMode: true, record: false)); + + Assert.Equal(new[] { "Start", "Stop" }, _recorder.Calls); + } + + [Fact] + public void 기록을_껐다_켜면_다시_시작한다() + { + // 기록기가 상한에 닿아 스스로 멈췄을 때도 스위치를 껐다 켜면 새 파일로 다시 시작한다. + using var hud = new AiDebugHud(_recorder); + hud.Update(developerMode: true, record: true); + hud.Update(developerMode: true, record: false); + hud.Update(developerMode: true, record: true); + + Assert.Equal(new[] { "Start", "Stop", "Start" }, _recorder.Calls); + } + + [Fact] + public void 기록_중이면_읽을_때마다_그_값을_그대로_넘긴다() + { + using var hud = new AiDebugHud(_recorder); + hud.Update(developerMode: true, record: true); + var result = Result(timestampMs: 5_000L); + + hud.Poll(result, nowMs: 5_040L, level: 0.42f, shown: false, aiAvailable: true); + hud.Poll(result, nowMs: 5_140L, level: 0.40f, shown: false, aiAvailable: true); + + // 같은 결과도 읽을 때마다 넘긴다. 한 줄만 남기는 일은 기록기가 한다(그 결과를 처음 본 때의 값이 남는다). + Assert.Equal(new[] { (result, 5_040L, 0.42f, false), (result, 5_140L, 0.40f, false) }, _recorder.Offers); + } + + [Fact] + public void 결과가_없으면_기록기에_넘기지_않는다() + { + using var hud = new AiDebugHud(_recorder); + hud.Update(developerMode: true, record: true); + + hud.Poll(null, nowMs: 1_000L, level: 0.3f, shown: false, aiAvailable: true); + + Assert.Empty(_recorder.Offers); + } + + [Fact] + public void 기록하지_않을_때는_읽기만_한다() + { + using var hud = new AiDebugHud(_recorder); + hud.Update(developerMode: true, record: false); + + hud.Poll(Result(timestampMs: 1_000L), nowMs: 1_050L, level: 0.3f, shown: true, aiAvailable: true); + + Assert.Empty(_recorder.Offers); + } + + [Fact] + public void 읽은_글자는_AiDebugText_와_같다() + { + using var hud = new AiDebugHud(_recorder); + hud.Update(developerMode: true, record: false); + var result = Result(timestampMs: 2_000L); + + Assert.Equal(AiDebugText.Format(result, 2_300L, 0.5f, true, true), hud.Poll(result, 2_300L, 0.5f, true, true)); + + // 결과가 없을 때 AI 를 만들지 못했으면 AI OFF, 만드는 중이거나 첫 결과를 기다리면 WAIT 다. + Assert.Equal(AiDebugText.CoarseAiOff, hud.Poll(null, 2_400L, 0f, false, aiAvailable: false).Coarse); + Assert.Equal(AiDebugText.CoarseWaiting, hud.Poll(null, 2_500L, 0f, false, aiAvailable: true).Coarse); + } + + [Fact] + public void 닫으면_기록기를_닫고_다시_켜지_않는다() + { + var hud = new AiDebugHud(_recorder); + hud.Update(developerMode: true, record: true); + + hud.Dispose(); + Assert.False(hud.IsPolling); + Assert.False(hud.IsRecording); + Assert.Equal(new[] { "Start", "Dispose" }, _recorder.Calls); + + // 닫힌 오버레이에 늦게 온 설정이나 읽기는 아무것도 하지 않는다. + Assert.False(hud.Update(developerMode: false, record: false)); + Assert.False(hud.Update(developerMode: true, record: true)); + hud.Poll(Result(timestampMs: 3_000L), 3_050L, 0.5f, true, true); + hud.Dispose(); + + Assert.Equal(new[] { "Start", "Dispose" }, _recorder.Calls); + Assert.Empty(_recorder.Offers); + } + + [Fact] + public void 실제_기록기로_켜면_파일이_생기고_개발자_모드를_끄면_닫힌다() + { + string portable = Path.Combine(_root, "exe"); + var recorder = new AiDebugRecorder(portable, Path.Combine(_root, "local")); + using var hud = new AiDebugHud(recorder); + var first = Result(timestampMs: 10_000L); + var second = Result(timestampMs: 10_250L); + + hud.Update(developerMode: true, record: true); + hud.Poll(first, 10_050L, 0.5f, true, true); + hud.Poll(first, 10_150L, 0.6f, true, true); // 같은 결과를 또 읽었다 + hud.Poll(second, 10_300L, 0.4f, false, true); + hud.Update(developerMode: false, record: true); + + // 끄면 쓰는 스레드가 받아 둔 줄을 모두 쓰고 파일을 닫는다. + Assert.True(recorder.WaitForWriters(10_000), "쓰는 스레드가 끝나지 않았다"); + Assert.False(recorder.IsRecording); + string file = Assert.Single(Directory.GetFiles(Path.Combine(portable, AiDebugRecorder.DirectoryName))); + string[] lines = File.ReadAllLines(file); + Assert.Equal(3, lines.Length); // 머리글과 두 결과. 같은 결과는 처음 본 때의 한 줄만 남는다. + Assert.Equal(AiDebugCsv.Header, lines[0]); + Assert.Equal(AiDebugCsv.Row(first, 10_050L, 0.5f, true), lines[1]); + Assert.Equal(AiDebugCsv.Row(second, 10_300L, 0.4f, false), lines[2]); + + // 닫힌 파일이라 다른 프로그램이 바로 옮기거나 지울 수 있다. + using (new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + } + } + + private static AiClassificationResult Result(long timestampMs) => new() + { + Coarse = SoundType.Speech, + Display = "Speech", + Confidence = 0.8f, + Top5 = [new TopClassHit(0, "Speech", 0.8f), new TopClassHit(1, "Conversation", 0.1f)], + MeetsThreshold = true, + TimestampMs = timestampMs, + PreprocessMs = 1.0, + YamnetMs = 2.0, + TotalMs = 3.5, + }; + + /// 켜고 끈 순서와 넘긴 값을 적어 두는 기록기. + private sealed class FakeRecorder : IAiDebugRecorder + { + public List Calls { get; } = []; + + public List<(AiClassificationResult Result, long NowMs, float Level, bool Shown)> Offers { get; } = []; + + public void Start() => Calls.Add("Start"); + + public void Stop() => Calls.Add("Stop"); + + public void Offer(AiClassificationResult result, long nowMs, float level, bool shown) => Offers.Add((result, nowMs, level, shown)); + + public void Dispose() => Calls.Add("Dispose"); + } +} diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugLogWriterTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugLogWriterTests.cs new file mode 100644 index 0000000..7b8861b --- /dev/null +++ b/SoundVisualizer.Tests/Diagnostics/AiDebugLogWriterTests.cs @@ -0,0 +1,129 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Diagnostics; + +namespace SoundVisualizer.Tests.Diagnostics; + +/// +/// 개발자 모드 기록의 쌓는 규칙(모바일 AiDebugLogWriterTest, #193). +/// +/// 세 가지를 고정한다. 머리글이 한 번만 나가는 것, 같은 결과를 두 번 적지 않는 것(HUD 는 추론 250ms 보다 자주 읽는다), 상한에 닿으면 +/// 멈추는 것. PC 는 줄 끝이 모바일 파일과 같은 \n 인 것과, 상한을 어떻게 세는지를 더했다. +/// +public class AiDebugLogWriterTests +{ + private static AiClassificationResult Result(long timestampMs, SoundType coarse = SoundType.Ambient) => new() + { + Coarse = coarse, + Display = "Speech", + Confidence = 0.5f, + Top5 = [], + DangerCuePromoted = false, + MeetsThreshold = true, + TimestampMs = timestampMs, + PreprocessMs = 1.0, + YamnetMs = 2.0, + TotalMs = 3.0, + }; + + private static string[] Lines(StringWriter sink) => sink.ToString().Trim().Split('\n'); + + [Fact] // 머리글은 첫 줄과 함께 한 번만 나간다 + public void 머리글은_첫_줄과_함께_한_번만_나간다() + { + var sink = new StringWriter(); + var writer = new AiDebugLogWriter(sink); + Assert.True(writer.Write(Result(1_000L), 1_000L, 0.1f, true)); + Assert.True(writer.Write(Result(1_250L), 1_250L, 0.1f, true)); + + var lines = Lines(sink); + Assert.Equal(3, lines.Length); + Assert.Equal(AiDebugCsv.Header, lines[0]); + Assert.Single(lines, line => line == AiDebugCsv.Header); + Assert.Equal(2, writer.Rows); + } + + [Fact] // 결과가 없으면 아무것도 쓰지 않는다 + public void 결과가_없으면_아무것도_쓰지_않는다() + { + // 기록만 켜고 소리가 없으면 빈 파일이어야 한다. 머리글만 남은 파일은 "돌았는데 결과가 없었다" 와 "켜지지 않았다" 를 헷갈리게 한다. + var sink = new StringWriter(); + _ = new AiDebugLogWriter(sink); + Assert.Equal("", sink.ToString()); + } + + [Fact] // 같은 결과를 두 번 읽으면 한 번만 적는다 + public void 같은_결과를_두_번_읽으면_한_번만_적는다() + { + var sink = new StringWriter(); + var writer = new AiDebugLogWriter(sink); + var same = Result(2_000L); + Assert.True(writer.Write(same, 2_000L, 0.1f, true)); + Assert.False(writer.Write(same, 2_100L, 0.1f, true)); // 같은 추론 결과가 두 줄이 됐다 + Assert.False(writer.Write(Result(2_000L), 2_200L, 0.1f, true)); + Assert.True(writer.Write(Result(2_250L), 2_250L, 0.1f, true)); + Assert.Equal(2, writer.Rows); + } + + [Fact] // 상한에 닿으면 멈추고 더 쓰지 않는다 + public void 상한에_닿으면_멈추고_더_쓰지_않는다() + { + var sink = new StringWriter(); + // 머리글 + 한 줄이면 이미 넘는 아주 작은 상한. + var writer = new AiDebugLogWriter(sink, maxBytes: 10L); + Assert.True(writer.Write(Result(1_000L), 1_000L, 0f, false)); + Assert.True(writer.Stopped); // 상한에 닿았는데 멈추지 않았다 + Assert.False(writer.Write(Result(1_250L), 1_250L, 0f, false)); + Assert.Equal(1, writer.Rows); + Assert.Equal(2, Lines(sink).Length); + } + + [Fact] + public void 상한은_줄_끝까지_센_글자_수로_보고_그_줄까지_쓰고_멈춘다() + { + Assert.Equal(20L * 1024 * 1024, AiDebugLogWriter.DefaultMaxBytes); + + // 머리글과 두 줄이 딱 들어가는 상한. 세 번째 줄은 쓰지 않는다. + long twoRows = AiDebugCsv.Header.Length + 1 + + AiDebugCsv.Row(Result(1_000L), 1_000L, 0f, false).Length + 1 + + AiDebugCsv.Row(Result(1_250L), 1_250L, 0f, false).Length + 1; + var sink = new StringWriter(); + var writer = new AiDebugLogWriter(sink, maxBytes: twoRows); + + Assert.True(writer.Write(Result(1_000L), 1_000L, 0f, false)); + Assert.False(writer.Stopped); + Assert.True(writer.Write(Result(1_250L), 1_250L, 0f, false)); + Assert.True(writer.Stopped); + Assert.False(writer.Write(Result(1_500L), 1_500L, 0f, false)); + Assert.Equal(twoRows, sink.ToString().Length); + } + + [Fact] // 쓴 줄에 그때의 값이 들어간다 + public void 쓴_줄에_그때의_값이_들어간다() + { + var sink = new StringWriter(); + var writer = new AiDebugLogWriter(sink); + writer.Write(Result(3_000L, SoundType.Danger), 3_120L, 0.42f, false); + + string row = Lines(sink)[1]; + Assert.Equal(AiDebugCsv.Row(Result(3_000L, SoundType.Danger), 3_120L, 0.42f, false), row); + Assert.Contains("\"Loopback\"", row); // 캡처 소스가 빠졌다 + Assert.Contains("0.42000", row); // 소리 크기가 빠졌다 + Assert.Contains("false", row); // 표시 여부가 빠졌다 + } + + [Fact] + public void 줄_끝은_모바일_파일처럼_LF_하나다() + { + // TextWriter.WriteLine 은 Windows 에서 \r\n 을 쓴다. 두 판의 파일이 바이트까지 같은 모양이어야 같은 스크립트로 읽는다. + var sink = new StringWriter(); + var writer = new AiDebugLogWriter(sink); + writer.Write(Result(1_000L), 1_000L, 0.1f, true); + + string text = sink.ToString(); + Assert.DoesNotContain("\r", text); + Assert.Equal(AiDebugCsv.Header + "\n" + AiDebugCsv.Row(Result(1_000L), 1_000L, 0.1f, true) + "\n", text); + } +} diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugRecorderTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugRecorderTests.cs new file mode 100644 index 0000000..13f8872 --- /dev/null +++ b/SoundVisualizer.Tests/Diagnostics/AiDebugRecorderTests.cs @@ -0,0 +1,512 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Diagnostics; +using SoundVisualizer.Ai; +using SoundVisualizer.Diagnostics; +using TopClassHit = SoundVisualizer.Ai.YamnetCoarseClassifier.TopClassHit; + +namespace SoundVisualizer.Tests.Diagnostics; + +/// +/// 개발자 모드 기록이 실제로 파일에 닿는지(모바일 AiDebugRecorderInstrumentedTest, #193, #195, #199, #208). +/// +/// 줄을 만드는 규칙은 AiDebugCsvTests·AiDebugLogWriterTests 가 지킨다. 여기서 보는 것은 파일이 생기는지, 쓰는 스레드가 실제로 쓰는지, +/// 끄면 닫히고 다시 켜면 새 파일이 열리는지다. 이 부분이 깨지면 조용히 깨진다. 파일이 안 열려도 앱은 멀쩡히 돌고 기록만 비어 있어서, +/// 채점할 때 "이 구간에는 결과가 없었다" 로 읽힌다. +/// +/// 모바일 일곱 개를 옮기고, PC 의 폴더 고르기(실행 파일 폴더, 안 되면 LocalAppData), 상한, 끌 때 받아 둔 줄 비우기, 큐가 찰 때 오래된 +/// 줄 버리기, 같은 초에 다시 켜기, 닫기를 더했다. 폴더는 테스트마다 임시 폴더를 준다. +/// +public sealed class AiDebugRecorderTests : IDisposable +{ + /// 쓰는 스레드를 기다리는 한도. 빨리 끝나는 일이라 느린 CI 에서도 넉넉하다. + private const int PatienceMs = 10_000; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "sv-ailog-" + Guid.NewGuid().ToString("N")); + private readonly List _recorders = []; + + private string PortableBase => Path.Combine(_root, "exe"); + + private string FallbackBase => Path.Combine(_root, "local"); + + private string PortableLog => Path.Combine(PortableBase, AiDebugRecorder.DirectoryName); + + private string FallbackLog => Path.Combine(FallbackBase, AiDebugRecorder.DirectoryName); + + public void Dispose() + { + // 쓰는 스레드를 먼저 끝내야 파일을 지울 수 있다. + foreach (var recorder in _recorders) recorder.Dispose(); + try + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // 임시 폴더라 남아도 된다. + } + catch (UnauthorizedAccessException) + { + } + } + + [Fact] // 켜지_않으면_폴더도_만들지_않는다 + public void 켜지_않으면_폴더도_만들지_않는다() + { + // 기본이 꺼짐이라, 기록을 쓰지 않는 사용자의 PC 에는 아무 흔적도 남지 않아야 한다. + var recorder = NewRecorder(); + recorder.Offer(Result(1_000L), 1_000L, 0.5f, true); + + Assert.False(recorder.IsRecording); + Assert.Null(recorder.CurrentFile); + Assert.False(Directory.Exists(PortableLog)); + Assert.False(Directory.Exists(FallbackLog)); + } + + [Fact] // 켜면_파일이_생기고_넘긴_줄이_바로_들어간다 + public void 켜면_첫_줄과_함께_파일이_생기고_끄지_않아도_줄이_바로_보인다() + { + var recorder = NewRecorder(); + recorder.Start(); + Assert.True(recorder.IsRecording); + // 폴더와 파일은 첫 줄이 올 때 쓰는 스레드가 만든다. + Assert.Null(recorder.CurrentFile); + + recorder.Offer(Result(2_000L), 2_050L, 0.42f, true); + string file = WaitForCurrentFile(recorder); + Assert.Equal(PortableLog, Path.GetDirectoryName(file)); + Assert.Matches(@"^ai-\d{8}-\d{6}\.csv$", Path.GetFileName(file)); + Assert.Equal("ai-20261014-093000.csv", Path.GetFileName(file)); + + // 줄마다 밀어 넣으므로 끄지 않고도 파일에서 보여야 한다. 모아 두면 기록 중에 연 파일에 최근 몇 초가 비어, + // 그 구간을 "결과가 없었다" 로 읽게 된다. + WaitFor("넘긴 줄이 파일에 나타남", () => Lines(file).Length >= 2); + var lines = Lines(file); + Assert.Equal(AiDebugCsv.Header, lines[0]); + Assert.Equal(AiDebugCsv.Row(Result(2_000L), 2_050L, 0.42f, true), lines[1]); + Assert.Contains("\"Loopback\"", lines[1]); + } + + [Fact] // 켰다_바로_끄면_빈_파일이_남지_않는다 + public void 켰다_바로_끄면_빈_파일도_폴더도_남지_않는다() + { + // 켜 보고 바로 끈 사용자의 PC 에 빈 파일이 쌓이지 않는 편이 낫다. PC 는 폴더도 첫 줄이 올 때 만든다. + var recorder = NewRecorder(); + recorder.Start(); + recorder.Stop(); + + Assert.True(recorder.WaitForWriters(PatienceMs), "쓰는 스레드가 끝나지 않았다"); + Assert.False(Directory.Exists(PortableLog)); + Assert.False(Directory.Exists(FallbackLog)); + } + + [Fact] // 같은_결과를_두_번_넘겨도_한_줄만_남는다 + public void 같은_결과를_여러_번_넘겨도_처음_본_값으로_한_줄만_남는다() + { + // 렌더 루프는 프레임마다 같은 결과를 넘긴다. 추론은 250ms 마다라 한 결과를 수십 번 본다. + var recorder = NewRecorder(); + recorder.Start(); + var same = Result(3_000L); + recorder.Offer(same, 3_000L, 0.1f, true); + recorder.Offer(same, 3_100L, 0.2f, false); + recorder.Offer(Result(3_000L), 3_150L, 0.3f, true); // 시각이 같으면 다른 객체도 같은 결과다 + recorder.Offer(Result(3_250L), 3_250L, 0.1f, true); + recorder.Stop(); + + Assert.True(recorder.WaitForWriters(PatienceMs)); + Assert.Equal( + new[] + { + AiDebugCsv.Header, + AiDebugCsv.Row(same, 3_000L, 0.1f, true), + AiDebugCsv.Row(Result(3_250L), 3_250L, 0.1f, true), + }, + Lines(Assert.Single(Files(PortableLog)))); + } + + [Fact] // 끄면_닫히고_다시_켜면_새_파일에_쓴다 + public void 끄면_닫히고_다시_켜면_새_파일에_쓴다() + { + var recorder = NewRecorder(); + recorder.Start(); + recorder.Offer(Result(4_000L), 4_000L, 0.2f, true); + string first = WaitForCurrentFile(recorder); + recorder.Stop(); + // 끈 뒤에도 파일이 남아 보이면 켠 상태와 끈 상태를 가릴 수 없다. + Assert.Null(recorder.CurrentFile); + Assert.False(recorder.IsRecording); + + recorder.Start(); + recorder.Offer(Result(5_000L), 5_000L, 0.3f, false); + string second = WaitForCurrentFile(recorder); + Assert.NotEqual(first, second); // 다시 켰는데 같은 파일에 이어 쓴다 + recorder.Stop(); + + // 앞 파일은 그대로 남아 있어야 한다. 회차마다 파일이 갈려야 채점이 섞이지 않는다. + Assert.True(recorder.WaitForWriters(PatienceMs)); + Assert.Equal(2, Files(PortableLog).Length); + Assert.Equal(new[] { AiDebugCsv.Header, AiDebugCsv.Row(Result(4_000L), 4_000L, 0.2f, true) }, Lines(first)); + Assert.Equal(new[] { AiDebugCsv.Header, AiDebugCsv.Row(Result(5_000L), 5_000L, 0.3f, false) }, Lines(second)); + } + + [Fact] // 껐다_켜기를_되풀이해도_파일이_새지_않는다 + public void 껐다_켜기를_되풀이해도_스레드와_열린_파일이_남지_않는다() + { + // 설정 스위치를 빠르게 누르는 경우. 스레드나 열린 파일이 쌓이면 여기서 드러난다. + var recorder = NewRecorder(); + for (int i = 0; i < 5; i++) + { + recorder.Start(); + recorder.Offer(Result(6_000L + i), 6_000L + i, 0.1f, true); + recorder.Stop(); + } + + Assert.Null(recorder.CurrentFile); + Assert.True(recorder.WaitForWriters(PatienceMs), "기록 스레드가 남았다"); + var files = Files(PortableLog); + Assert.Equal(5, files.Length); + Assert.All(files, file => + { + Assert.Equal(2, Lines(file).Length); + // 아무도 열고 있지 않아야 혼자 열 수 있다. + using var exclusive = new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + }); + } + + [Fact] // 껐다_곧바로_켜면_줄이_새_파일에만_들어간다 + public void 껐다_곧바로_켜면_줄이_새_파일에만_들어간다() + { + // Stop 은 쓰는 스레드를 기다리지 않는다. 옛 스레드가 큐를 기다리는 동안 다시 켜면 두 스레드가 한 큐를 나눠 먹을 수 있었다(#208). + var recorder = NewRecorder(); + recorder.Start(); + recorder.Offer(Result(7_000L), 7_000L, 0.1f, true); + string first = WaitForCurrentFile(recorder); + WaitFor("첫 파일에 한 줄", () => Lines(first).Length == 2); + + recorder.Stop(); + recorder.Start(); // 기다리지 않고 곧바로 켠다 + for (int i = 0; i < 6; i++) recorder.Offer(Result(8_000L + i), 8_000L + i, 0.2f, true); + string second = WaitForCurrentFile(recorder); + Assert.NotEqual(first, second); // 같은 파일을 다시 열었다 + WaitFor("새 파일에 여섯 줄", () => Lines(second).Length == 7); + recorder.Stop(); + + Assert.True(recorder.WaitForWriters(PatienceMs)); + Assert.Equal(2, Lines(first).Length); // 새 회차의 줄이 옛 파일로 새어 들어갔다 + Assert.Equal(7, Lines(second).Length); + } + + [Fact] + public void 같은_초에_다시_켜도_앞_회차를_덮어쓰지_않는다() + { + // 모바일은 같은 이름을 덮어쓴다. 여기서는 번호를 붙인다. 앞 회차가 이미 파일을 닫은 경우(덮어쓰기)와 옛 스레드가 아직 열고 있는 + // 경우(Windows 에서는 다시 열 수도 없다)를 함께 본다. + var sameSecond = new DateTime(2026, 10, 14, 9, 30, 0, DateTimeKind.Local); + var recorder = NewRecorder(clock: () => sameSecond); + recorder.Start(); + recorder.Offer(Result(9_000L), 9_000L, 0.1f, true); + recorder.Stop(); + Assert.True(recorder.WaitForWriters(PatienceMs)); + + recorder.Start(); + recorder.Offer(Result(9_250L), 9_250L, 0.1f, true); + recorder.Stop(); + recorder.Start(); // 앞 스레드를 기다리지 않는다 + recorder.Offer(Result(9_500L), 9_500L, 0.1f, true); + recorder.Stop(); + + Assert.True(recorder.WaitForWriters(PatienceMs)); + var files = Files(PortableLog); + Assert.Equal( + new[] { "ai-20261014-093000-2.csv", "ai-20261014-093000-3.csv", "ai-20261014-093000.csv" }, + files.Select(Path.GetFileName)); + Assert.Equal(new[] { AiDebugCsv.Header, AiDebugCsv.Row(Result(9_000L), 9_000L, 0.1f, true) }, Lines(files[2])); + // 뒤의 두 회차는 쓰는 스레드가 함께 돌아 어느 쪽이 먼저 번호를 가질지 정해져 있지 않다. 내용만 본다. + var rows = files[..2].Select(file => Lines(file)).ToArray(); + Assert.All(rows, lines => Assert.Equal(2, lines.Length)); + Assert.Equal( + new[] { AiDebugCsv.Row(Result(9_250L), 9_250L, 0.1f, true), AiDebugCsv.Row(Result(9_500L), 9_500L, 0.1f, true) }, + rows.Select(lines => lines[1]).Order(StringComparer.Ordinal)); + } + + [Fact] + public void 프레임마다_같은_결과를_넘겨도_큐가_차지_않는다() + { + // PC 렌더 루프는 프레임마다(60~144Hz) 부른다. 같은 결과로 큐가 차면 디스크가 잠깐 밀릴 때 새 결과가 밀려난다. + using var opening = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var recorder = NewRecorder(createFile: path => + { + opening.Set(); + release.Wait(PatienceMs); + return CreateNew(path); + }); + var shown = Result(17_000L); + try + { + recorder.Start(); + recorder.Offer(shown, 17_000L, 0.1f, true); + Assert.True(opening.Wait(PatienceMs), "쓰는 스레드가 파일을 열러 가지 않았다"); + for (int frame = 1; frame <= 3 * AiDebugRecorder.QueueCapacity; frame++) + { + recorder.Offer(shown, 17_000L + frame, 0.2f, false); + } + Assert.Equal(0, recorder.Dropped); + recorder.Offer(Result(17_250L), 17_250L, 0.3f, true); + recorder.Stop(); + } + finally + { + release.Set(); + } + + Assert.True(recorder.WaitForWriters(PatienceMs)); + Assert.Equal( + new[] + { + AiDebugCsv.Header, + AiDebugCsv.Row(shown, 17_000L, 0.1f, true), + AiDebugCsv.Row(Result(17_250L), 17_250L, 0.3f, true), + }, + Lines(Assert.Single(Files(PortableLog)))); + } + + [Fact] + public void 끄기_전에_받은_줄은_끈_뒤에도_모두_쓰고_끈_뒤에_온_줄은_받지_않는다() + { + using var opening = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var recorder = NewRecorder(createFile: path => + { + // 쓰는 스레드를 파일을 여는 자리에 세워, 나머지 줄이 큐에 남은 채로 끄게 한다. + opening.Set(); + release.Wait(PatienceMs); + return CreateNew(path); + }); + try + { + recorder.Start(); + recorder.Offer(Result(10_000L), 10_000L, 0.1f, true); + Assert.True(opening.Wait(PatienceMs), "쓰는 스레드가 파일을 열러 가지 않았다"); + for (int i = 1; i <= 20; i++) recorder.Offer(Result(10_000L + i), 10_000L + i, 0.1f, true); + recorder.Stop(); + recorder.Offer(Result(10_999L), 10_999L, 0.1f, true); + } + finally + { + release.Set(); + } + + Assert.True(recorder.WaitForWriters(PatienceMs)); + var lines = Lines(Assert.Single(Files(PortableLog))); + Assert.Equal(22, lines.Length); // 머리글 + 끄기 전에 받은 21줄 + Assert.Equal(AiDebugCsv.Row(Result(10_000L), 10_000L, 0.1f, true), lines[1]); + Assert.Equal(AiDebugCsv.Row(Result(10_020L), 10_020L, 0.1f, true), lines[^1]); + } + + [Fact] + public void 큐가_차면_가장_오래된_줄을_버리고_버린_수를_센다() + { + // 기록이 밀리는 것보다 화면이 밀리는 것이 나쁘다. Offer 는 기다리지 않고, 최근 것이 궁금한 도구라 뒤를 살린다. + using var opening = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var recorder = NewRecorder(createFile: path => + { + opening.Set(); + release.Wait(PatienceMs); + return CreateNew(path); + }); + const int extra = 5; + const int last = AiDebugRecorder.QueueCapacity + extra; + try + { + recorder.Start(); + // 첫 줄은 쓰는 스레드가 꺼내 들고 파일을 열러 간다. 그 뒤의 줄이 큐에 쌓인다. + recorder.Offer(Result(20_000L), 20_000L, 0.1f, true); + Assert.True(opening.Wait(PatienceMs), "쓰는 스레드가 파일을 열러 가지 않았다"); + for (int i = 1; i <= last; i++) recorder.Offer(Result(20_000L + i), 20_000L + i, 0.1f, true); + Assert.Equal(extra, recorder.Dropped); + recorder.Stop(); + } + finally + { + release.Set(); + } + + Assert.True(recorder.WaitForWriters(PatienceMs)); + var lines = Lines(Assert.Single(Files(PortableLog))); + Assert.Equal(1 + 1 + AiDebugRecorder.QueueCapacity, lines.Length); + Assert.Equal(AiDebugCsv.Row(Result(20_000L), 20_000L, 0.1f, true), lines[1]); + Assert.Equal(AiDebugCsv.Row(Result(20_000L + extra + 1), 20_000L + extra + 1, 0.1f, true), lines[2]); + Assert.Equal(AiDebugCsv.Row(Result(20_000L + last), 20_000L + last, 0.1f, true), lines[^1]); + } + + [Fact] + public void 실행_파일_폴더에_폴더를_만들_수_없으면_LocalAppData_에_쓴다() + { + // 폴더를 만들 자리 위에 같은 이름의 파일이 있으면 만들 수 없다. 쓸 수 없는 실행 파일 폴더를 실제 파일 시스템으로 흉내 낸다. + Directory.CreateDirectory(_root); + string blocked = Path.Combine(_root, "exe-is-a-file"); + File.WriteAllText(blocked, ""); + var recorder = NewRecorder(portableBase: blocked); + recorder.Start(); + recorder.Offer(Result(11_000L), 11_000L, 0.1f, true); + + string file = WaitForCurrentFile(recorder); + Assert.Equal(FallbackLog, Path.GetDirectoryName(file)); + WaitFor("넘긴 줄이 파일에 나타남", () => Lines(file).Length == 2); + } + + [Fact] + public void 실행_파일_폴더에_쓸_권한이_없으면_LocalAppData_에_쓴다() + { + // Program Files 에 풀어 둔 경우. 일반 권한으로는 그 아래에 파일을 만들 수 없다. + var recorder = NewRecorder(createFile: path => + path.StartsWith(PortableLog, StringComparison.OrdinalIgnoreCase) + ? throw new UnauthorizedAccessException("Access to the path is denied.") + : CreateNew(path)); + recorder.Start(); + recorder.Offer(Result(12_000L), 12_000L, 0.1f, true); + recorder.Stop(); + + Assert.True(recorder.WaitForWriters(PatienceMs)); + Assert.Empty(Files(PortableLog)); + Assert.Equal( + new[] { AiDebugCsv.Header, AiDebugCsv.Row(Result(12_000L), 12_000L, 0.1f, true) }, + Lines(Assert.Single(Files(FallbackLog)))); + } + + [Fact] + public void 어디에도_쓸_수_없으면_스스로_꺼지고_더_받지_않는다() + { + // 기록이 안 되는 것이 앱이 멈추는 것보다 낫다. 쓰는 스레드의 예외가 새면 프로세스가 끝난다. + var recorder = NewRecorder(createFile: _ => throw new UnauthorizedAccessException("read-only")); + recorder.Start(); + recorder.Offer(Result(13_000L), 13_000L, 0.1f, true); + + WaitFor("회차가 스스로 닫힘", () => !recorder.IsRecording); + Assert.Null(recorder.CurrentFile); + recorder.Offer(Result(13_250L), 13_250L, 0.1f, true); // 아무도 비우지 않는 큐에 넣지 않는다 + Assert.True(recorder.WaitForWriters(PatienceMs)); + Assert.Empty(Files(PortableLog)); + Assert.Empty(Files(FallbackLog)); + } + + [Fact] + public void 상한에_닿으면_그_줄까지_쓰고_스스로_멈추고_다시_켜면_새_파일에_쓴다() + { + // 켜 둔 것을 잊은 PC 의 디스크를 먹지 않게 한다. 기본 상한은 20MB 라 테스트는 머리글과 세 줄 크기로 준다. + long threeRows = AiDebugCsv.Header.Length + 1 + 3 * (AiDebugCsv.Row(Result(14_000L), 14_000L, 0.1f, true).Length + 1L); + var recorder = NewRecorder(maxBytes: threeRows); + recorder.Start(); + for (int i = 0; i < 5; i++) recorder.Offer(Result(14_000L + i), 14_000L + i, 0.1f, true); + + WaitFor("상한에 닿아 스스로 닫힘", () => !recorder.IsRecording); + Assert.Null(recorder.CurrentFile); + recorder.Offer(Result(14_500L), 14_500L, 0.1f, true); // 꺼진 뒤에는 받지 않는다 + Assert.True(recorder.WaitForWriters(PatienceMs)); + string first = Assert.Single(Files(PortableLog)); + var lines = Lines(first); + Assert.Equal(4, lines.Length); + Assert.Equal(AiDebugCsv.Row(Result(14_002L), 14_002L, 0.1f, true), lines[^1]); + + recorder.Start(); + recorder.Offer(Result(15_000L), 15_000L, 0.1f, true); + string second = WaitForCurrentFile(recorder); + Assert.NotEqual(first, second); + } + + [Fact] + public void 닫으면_남은_줄을_쓰고_파일을_닫은_뒤에_돌아오고_다시_켜지지_않는다() + { + // 앱을 닫을 때. 쓰는 스레드는 배경 스레드라 기다리지 않으면 프로세스가 끝나면서 받아 둔 줄을 잃는다. + var recorder = NewRecorder(); + recorder.Start(); + recorder.Offer(Result(16_000L), 16_000L, 0.1f, true); + recorder.Offer(Result(16_250L), 16_250L, 0.1f, true); + recorder.Dispose(); + + Assert.False(recorder.IsRecording); + string file = Assert.Single(Files(PortableLog)); + Assert.Equal(3, Lines(file).Length); + using (new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + // 아무도 열고 있지 않다. + } + + recorder.Start(); + Assert.False(recorder.IsRecording); + recorder.Dispose(); // 여러 번 불러도 된다 + } + + private AiDebugRecorder NewRecorder( + string? portableBase = null, + long maxBytes = AiDebugLogWriter.DefaultMaxBytes, + Func? clock = null, + Func? createFile = null) + { + var recorder = new AiDebugRecorder(portableBase ?? PortableBase, FallbackBase, maxBytes, clock ?? new SteppingClock().Next, createFile); + _recorders.Add(recorder); + return recorder; + } + + private static AiClassificationResult Result(long timestampMs) => new() + { + Coarse = SoundType.Danger, + Display = "Gunshot, gunfire", + Confidence = 0.5f, + Top5 = [new TopClassHit(1, "Gunshot, gunfire", 0.5f)], + DangerCuePromoted = false, + MeetsThreshold = true, + TimestampMs = timestampMs, + PreprocessMs = 1.0, + YamnetMs = 2.0, + TotalMs = 3.5, + }; + + private static Stream CreateNew(string path) => new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + + /// 조건이 될 때까지 짧게 기다린다. 쓰는 스레드가 따로 돌기 때문이다. 고정 sleep 은 느린 PC 에서 깜빡이고 빠른 PC 에서는 시간을 버린다. + private static void WaitFor(string what, Func condition) + { + var watch = Stopwatch.StartNew(); + while (!condition()) + { + if (watch.ElapsedMilliseconds > PatienceMs) Assert.Fail($"{what}: {PatienceMs}ms 안에 일어나지 않았다"); + Thread.Sleep(10); + } + } + + /// 켠 회차가 쓰는 파일. 첫 줄이 와서 파일이 생겨야 정해진다. + private static string WaitForCurrentFile(AiDebugRecorder recorder) + { + WaitFor("기록 파일이 생김", () => recorder.CurrentFile is not null); + return recorder.CurrentFile!; + } + + private static string[] Files(string directory) => + Directory.Exists(directory) ? [.. Directory.GetFiles(directory).Order(StringComparer.Ordinal)] : []; + + /// 쓰는 스레드가 아직 열고 있어도 읽는다. 파일은 첫 줄이 올 때 생기므로 없으면 빈 목록. + private static string[] Lines(string path) + { + if (!File.Exists(path)) return []; + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + return reader.ReadToEnd().Split('\n', StringSplitOptions.RemoveEmptyEntries); + } + + /// 켤 때마다 1초씩 가는 시계. 모바일 테스트는 파일 이름이 갈리도록 1.1초를 쉬었다. + private sealed class SteppingClock + { + private DateTime _now = new(2026, 10, 14, 9, 30, 0, DateTimeKind.Local); + + public DateTime Next() + { + var now = _now; + _now = _now.AddSeconds(1); + return now; + } + } +} diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs new file mode 100644 index 0000000..fd26a79 --- /dev/null +++ b/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs @@ -0,0 +1,221 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using SoundVisualizer.Ai; +using SoundVisualizer.Diagnostics; +using SoundVisualizer.Tests.Ai; +using TopClassHit = SoundVisualizer.Ai.YamnetCoarseClassifier.TopClassHit; + +namespace SoundVisualizer.Tests.Diagnostics; + +/// +/// 개발자 모드 HUD 의 글자. 모바일 AiDebugTextTest 다섯 개를 옮기고, 모바일 문서(ARCHITECTURE 4장)가 고정한다고 적은 것 +/// (모델 없음, 빈 확정 이름, 뒤로 간 시계, 오래된 나이의 짧은 표기, 한 줄 58자)과 PC 오버레이가 기대는 것(값 비교, 글자 종류)을 더했다. +/// +public class AiDebugTextTests +{ + private static AiClassificationResult Result( + SoundType coarse = SoundType.Danger, + string display = "Gunshot, gunfire", + float confidence = 0.41f, + bool meetsThreshold = true, + long timestampMs = 1_000L, + bool dangerCuePromoted = false, + IReadOnlyList? top5 = null, + double preprocessMs = 12.0, + double yamnetMs = 48.0, + double totalMs = 65.0) => new() + { + Coarse = coarse, + Display = display, + Confidence = confidence, + Top5 = top5 ?? [], + DangerCuePromoted = dangerCuePromoted, + MeetsThreshold = meetsThreshold, + TimestampMs = timestampMs, + PreprocessMs = preprocessMs, + YamnetMs = yamnetMs, + TotalMs = totalMs, + }; + + private static AiDebugLines Format(AiClassificationResult? result, long nowMs = 1_200L) => + AiDebugText.Format(result, nowMs, level: 0.14f, shown: true, aiAvailable: true); + + [Fact] // 종류와 원래 이름과 확신도를 보여준다 + public void 종류와_원래_이름과_확신도를_보여준다() + { + var lines = Format(Result()); + Assert.Equal("DANGER", lines.Coarse); + Assert.Equal("Gunshot, gunfire", lines.Label); + Assert.Equal("0.41", lines.Confidence); + Assert.Equal(SoundType.Danger, lines.ColorType); + } + + [Fact] // 임계값 나이와 YAMNet safety cue를 보여준다 + public void 임계값_나이와_YAMNet_위험_단서를_보여준다() + { + var lines = Format(Result(meetsThreshold: false, dangerCuePromoted: true)); + Assert.Equal("thr N age 0.2s", lines.Detail); + Assert.Equal("cue Y", lines.Verdict); + Assert.Equal("lvl 0.14 shown Y 65ms (12/48)", lines.Timing); + } + + [Fact] // top5는 순위와 고정폭 확률로 보인다 + public void top5_는_순위와_고정폭_확률로_보인다() + { + var hit = new TopClassHit(0, "Vehicle horn, car horn, honking", 0.1f); + string line = Assert.Single(Format(Result(top5: [hit])).Top5); + Assert.Contains("Vehicle horn, car horn, h" + AiDebugText.Ellipsis, line); + Assert.EndsWith(" 0.10", line); + } + + [Fact] // 결과 없음을 구분한다 + public void 결과_없음을_구분한다() + { + var waiting = Format(null); + Assert.Equal(AiDebugText.CoarseWaiting, waiting.Coarse); + Assert.Null(waiting.ColorType); + } + + [Fact] // 로캘과 무관하게 숫자를 표기한다 + public void 로캘과_무관하게_숫자를_표기한다() + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("ar"); + Assert.NotEqual("0.41", 0.41f.ToString("F2", CultureInfo.CurrentCulture)); + Assert.Equal("0.41", Format(Result()).Confidence); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void 모델을_못_불러오면_AI_OFF_로_적고_소리_크기만_남긴다() + { + var off = AiDebugText.Format(null, nowMs: 0L, level: 0.5f, shown: false, aiAvailable: false); + Assert.Equal(AiDebugText.CoarseAiOff, off.Coarse); + Assert.Equal("model load failed", off.Label); + Assert.Equal(AiDebugText.None, off.Confidence); + Assert.Equal(AiDebugText.None, off.Detail); + Assert.Equal(AiDebugText.None, off.Verdict); + Assert.Equal("lvl 0.50", off.Timing); + Assert.Empty(off.Top5); + Assert.Equal("no result yet", Format(null).Label); + } + + [Fact] + public void 확정된_이름이_없으면_none_으로_적는다() + { + // 임계값을 한 번도 넘지 못하면 확정 이름이 빈 문자열이다. 빈 칸으로 두면 줄이 비어 보인다. + Assert.Equal("(none)", Format(Result(display: "")).Label); + } + + [Theory] + [InlineData(5_000L, 1_000L, "0.0s")] // 벽시계가 뒤로 갔다 + [InlineData(1_000L, 1_000L, "0.0s")] + [InlineData(1_000L, 1_150L, "0.2s")] // 모바일처럼 5 에서 올린다(.NET "F1" 은 0.1s) + [InlineData(1_000L, 1_250L, "0.3s")] // .NET "F1" 은 짝수 쪽 0.2s + [InlineData(0L, 99_949L, "99.9s")] + [InlineData(0L, 99_950L, "100s")] + [InlineData(0L, 999_499L, "999s")] + [InlineData(0L, 999_500L, "17m")] + [InlineData(0L, 3_600_000L, "60m")] + public void 나이는_오래될수록_짧게_쓰고_음수로_적지_않는다(long timestampMs, long nowMs, string age) + { + Assert.Equal($"thr Y age {age}", Format(Result(timestampMs: timestampMs), nowMs).Detail); + } + + [Fact] + public void 소요시간은_정수_밀리초로_적고_값이_없으면_자리_표시를_쓴다() + { + var lines = AiDebugText.Format( + Result(confidence: float.NaN, preprocessMs: 0.5, yamnetMs: double.NaN, totalMs: 12.5), + nowMs: 1_200L, level: float.NaN, shown: false, aiAvailable: true); + Assert.Equal(AiDebugText.None, lines.Confidence); + Assert.Equal("lvl - shown N 13ms (1/-)", lines.Timing); + } + + [Fact] + public void top5_는_다섯까지만_적고_이름_칸을_채워_확률이_한_열에_선다() + { + string[] names = ["Speech", "Vehicle horn, car horn, honking", "Alarm", "Livestock, farm animals, working animals", "Siren", "Music"]; + float[] probabilities = [0.5f, 0.45f, 0.4f, 0.35f, 0.3f, 0.25f]; + var hits = names.Select((name, i) => new TopClassHit(i, name, probabilities[i])).ToArray(); + + var top5 = Format(Result(top5: hits)).Top5; + + Assert.Equal( + new[] + { + "1 " + "Speech".PadRight(AiDebugText.Top5NameWidth) + " 0.50", + "2 Vehicle horn, car horn, h" + AiDebugText.Ellipsis + " 0.45", + "3 " + "Alarm".PadRight(AiDebugText.Top5NameWidth) + " 0.40", + "4 Livestock, farm animals, " + AiDebugText.Ellipsis + " 0.35", + "5 " + "Siren".PadRight(AiDebugText.Top5NameWidth) + " 0.30", + }, + top5); + Assert.All(top5, line => Assert.Equal(top5[0].Length, line.Length)); + } + + [Fact] + public void 모든_줄이_HUD_한_줄_58자_안에_든다() + { + foreach (var lines in WorstCases()) + { + // 첫 줄은 오버레이가 "종류 ● 이름 확신도" 로 붙인다. 점과 그 양옆 여백을 세 칸으로 센다(모바일 6dp + 8dp + 6dp). + int header = lines.Coarse.Length + 3 + lines.Label.Length + 1 + lines.Confidence.Length; + Assert.True(header <= AiDebugText.HudMaxColumns, $"{header}: {lines.Coarse} ● {lines.Label} {lines.Confidence}"); + foreach (string line in new[] { lines.Detail, lines.Verdict, lines.Timing }.Concat(lines.Top5)) + { + Assert.True(line.Length <= AiDebugText.HudMaxColumns, $"{line.Length}: {line}"); + } + } + } + + [Fact] + public void HUD_글자는_아스키와_말줄임표뿐이다() + { + // 고정폭 글꼴에 없는 글자가 섞이면 그 글자만 다른 글꼴로 그려져 줄이 다시 흔들린다(모바일 #162). + // 아스키와 말줄임표만 쓰면 Windows 의 고정폭 글꼴 어느 것으로 그려도 된다. + string text = string.Concat(WorstCases().Select(lines => + lines.Coarse + lines.Label + lines.Confidence + lines.Detail + lines.Verdict + lines.Timing + string.Concat(lines.Top5))); + + Assert.Contains(AiDebugText.Ellipsis, text); + Assert.Empty(text.Where(c => !(c is >= ' ' and <= '~' || c == '…')).Distinct()); + } + + [Fact] + public void 같은_입력이면_같은_줄로_본다() + { + // 오버레이는 지난번과 같은 줄이면 글자를 다시 넣지 않는다. top-5 목록도 내용으로 비교해야 한다. + var a = Format(Result(top5: [new TopClassHit(1, "Speech", 0.3f)])); + var b = Format(Result(top5: [new TopClassHit(1, "Speech", 0.3f)])); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.NotEqual(a, Format(Result(top5: [new TopClassHit(1, "Speech", 0.4f)]))); + Assert.NotEqual(a, Format(Result(top5: [new TopClassHit(1, "Speech", 0.3f)]), nowMs: 1_400L)); + } + + /// + /// 가장 긴 줄이 나오는 입력들. 가장 긴 종류 이름(AMBIENT), YAMNet 이름 521개 전부를 이름과 top-5 에, 다섯 글자가 되는 나이, + /// 다섯 자리 소요시간. 결과 없음과 모델 없음도 넣는다. + /// + private static IEnumerable WorstCases() + { + yield return AiDebugText.Format(null, 0L, 0.99f, shown: false, aiAvailable: true); + yield return AiDebugText.Format(null, 0L, 0.99f, shown: false, aiAvailable: false); + foreach (string name in AiTestData.ClassMap) + { + var hits = Enumerable.Range(0, 5).Select(i => new TopClassHit(i, name, 0.99f)).ToArray(); + var result = Result(SoundType.Ambient, name, 0.99f, timestampMs: 0L, top5: hits, + preprocessMs: 99_999, yamnetMs: 99_999, totalMs: 99_999); + yield return AiDebugText.Format(result, nowMs: 99_949L, level: 0.99f, shown: true, aiAvailable: true); + } + } +} diff --git a/SoundVisualizer.Tests/Diagnostics/JavaNumberFormatTests.cs b/SoundVisualizer.Tests/Diagnostics/JavaNumberFormatTests.cs new file mode 100644 index 0000000..0989da0 --- /dev/null +++ b/SoundVisualizer.Tests/Diagnostics/JavaNumberFormatTests.cs @@ -0,0 +1,80 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using SoundVisualizer.Diagnostics; + +namespace SoundVisualizer.Tests.Diagnostics; + +/// +/// HUD·CSV 의 소수가 모바일(Kotlin String.format(Locale.US, "%.Nf"))과 같은 글자인지. 기대값은 Java Formatter 의 규칙 +/// (가장 짧은 십진 표현을 5 에서 올리고, 부호는 크기와 따로 붙인다)으로 정했다. .NET 의 "F" 서식과 갈리는 경계를 고정한다. +/// +public class JavaNumberFormatTests +{ + [Theory] + [InlineData(0.15, 1, "0.2")] // .NET "F1" 은 "0.1" (이진값이 0.1499…) + [InlineData(0.25, 1, "0.3")] // .NET 은 짝수 쪽으로 "0.2" + [InlineData(0.35, 1, "0.4")] + [InlineData(0.125, 2, "0.13")] + [InlineData(1.005, 2, "1.01")] + [InlineData(12.5, 0, "13")] + [InlineData(2.5, 0, "3")] + [InlineData(0.5, 0, "1")] + [InlineData(0.4, 0, "0")] + [InlineData(65.0, 0, "65")] + [InlineData(41.0, 1, "41.0")] + [InlineData(99.949, 1, "99.9")] + [InlineData(1e-30, 2, "0.00")] + public void 자바처럼_가장_짧은_십진_표현을_5에서_올린다(double value, int decimals, string expected) + { + Assert.Equal(expected, JavaNumberFormat.Fixed(value, decimals)); + } + + [Fact] + public void float_는_자바처럼_double_로_넓힌_값을_적는다() + { + // Java Formatter 는 float 를 double 로 바꾼 뒤 적는다. 0.145f 는 0.14499999582767487 이라 "0.15" 가 아니다. + Assert.Equal("0.14", JavaNumberFormat.Fixed(0.145f, 2)); + Assert.Equal("0.41", JavaNumberFormat.Fixed(0.41f, 2)); + Assert.Equal("0.54321", JavaNumberFormat.Fixed(0.54321f, 5)); + Assert.Equal("0.42000", JavaNumberFormat.Fixed(0.42f, 5)); + Assert.Equal("0.10000", JavaNumberFormat.Fixed(0.1f, 5)); + } + + [Fact] + public void 부호는_크기를_적은_뒤에_붙인다() + { + double negativeZero = -0.0; + Assert.True(double.IsNegative(negativeZero)); + Assert.Equal("-0.00", JavaNumberFormat.Fixed(negativeZero, 2)); + Assert.Equal("-0.00", JavaNumberFormat.Fixed(-0.001, 2)); + Assert.Equal("-1.3", JavaNumberFormat.Fixed(-1.25, 1)); + } + + [Fact] + public void 숫자가_아닌_값은_자바와_같은_글자다() + { + Assert.Equal("NaN", JavaNumberFormat.Fixed(double.NaN, 2)); + Assert.Equal("Infinity", JavaNumberFormat.Fixed(double.PositiveInfinity, 2)); + Assert.Equal("-Infinity", JavaNumberFormat.Fixed(double.NegativeInfinity, 2)); + } + + [Fact] + public void 로캘과_무관하다() + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("ar-EG"); + // 이 로캘의 기본 서식은 소수점이 다르다(0٫41). 고정하지 않았다면 여기서 드러난다. + Assert.NotEqual("0.41", 0.41.ToString("F2", CultureInfo.CurrentCulture)); + Assert.Equal("0.41", JavaNumberFormat.Fixed(0.41, 2)); + Assert.Equal("-1.3", JavaNumberFormat.Fixed(-1.25, 1)); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } +} diff --git a/SoundVisualizer.Tests/Help/AppVersionTextTests.cs b/SoundVisualizer.Tests/Help/AppVersionTextTests.cs new file mode 100644 index 0000000..d46262c --- /dev/null +++ b/SoundVisualizer.Tests/Help/AppVersionTextTests.cs @@ -0,0 +1,37 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Help; + +namespace SoundVisualizer.Tests.Help; + +/// 앱 정보와 제보에 적는 버전. .NET SDK 가 붙이는 커밋(+ 뒤)을 앱 정보에서는 떼고, 제보에서는 7자로 줄인다. +public class AppVersionTextTests +{ + private const string Built = "1.0.0+a8d9c377c4a44972738beae1cea166def54bc7a5"; + + [Fact] + public void 앱_정보에는_빌드_정보를_떼고_보인다() + { + Assert.Equal("1.0.0", AppVersionText.Display(Built)); + Assert.Equal("1.2.3-beta", AppVersionText.Display("1.2.3-beta+abc")); + Assert.Equal("1.0.0", AppVersionText.Display("1.0.0")); + } + + [Fact] + public void 제보에는_커밋을_7자로_남긴다() + { + Assert.Equal("1.0.0+a8d9c37", AppVersionText.ForReport(Built)); + Assert.Equal("1.0.0+abc", AppVersionText.ForReport("1.0.0+abc")); + Assert.Equal("1.0.0", AppVersionText.ForReport("1.0.0")); + Assert.Equal("1.0.0", AppVersionText.ForReport("1.0.0+")); + } + + [Fact] + public void 값이_없으면_빈_문자열이다() + { + Assert.Equal("", AppVersionText.Display(null)); + Assert.Equal("", AppVersionText.ForReport(null)); + Assert.Equal("", AppVersionText.Display(" ")); + } +} diff --git a/SoundVisualizer.Tests/Help/LicenseNoticesTests.cs b/SoundVisualizer.Tests/Help/LicenseNoticesTests.cs new file mode 100644 index 0000000..fa71584 --- /dev/null +++ b/SoundVisualizer.Tests/Help/LicenseNoticesTests.cs @@ -0,0 +1,186 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Text.RegularExpressions; +using SoundVisualizer.Help; +using SoundVisualizer.Localization; + +namespace SoundVisualizer.Tests.Help; + +/// +/// 도움말의 오픈소스 라이선스 창. 안드로이드 LicenseAssetsTest·LicenseRulersTest 를 옮겼다. +/// 고지는 저장소 원본을 제자리에서 넣으므로 복사본과 맞춰 보는 대신, 넣은 리소스가 그 원본인지와 순서를 본다. +/// +public class LicenseNoticesTests +{ + private static readonly string Ruler80 = new('=', 80); + + public static TheoryData Codes() + { + var data = new TheoryData(); + foreach (var code in StringTables.AvailableCodes) data.Add(code); + return data; + } + + [Fact] + public void 넣어_둔_고지는_저장소_원본과_같다() + { + Assert.All(LicenseNotices.Documents, document => + { + string path = Path.Combine(RepositoryPaths.Root, document.Path); + Assert.True(File.Exists(path), "원본이 없습니다: " + path); + Assert.Equal(Lf(File.ReadAllText(path)), Lf(LicenseNotices.Read(document))); + }); + } + + [Fact] + public void 고지는_목록_글꼴_소리_이름_앱_라이선스_순서다() + { + Assert.Equal( + new[] { "THIRD_PARTY.md", "SoundVisualizer/Fonts/Pretendard-LICENSE.txt", "SoundVisualizer.Core/Sounds/Data/NOTICE.txt", "LICENSE" }, + LicenseNotices.Documents.Select(d => d.Path)); + } + + [Fact] + public void 창에_보일_전문은_고지마다_머리를_달고_순서대로_잇는다() + { + string all = LicenseNotices.ReadAll(); + + Assert.DoesNotContain("\r", all, StringComparison.Ordinal); + int previous = -1; + foreach (var document in LicenseNotices.Documents) + { + int at = all.IndexOf("==== " + document.Path + " ====", StringComparison.Ordinal); + Assert.True(at > previous, document.Path + " 의 머리가 없거나 순서가 틀립니다."); + previous = at; + } + // 고지마다 알아볼 말이 들어 있다 + Assert.Contains("Third-Party Licenses", all, StringComparison.Ordinal); + Assert.Contains("SIL OPEN FONT LICENSE Version 1.1", all, StringComparison.Ordinal); + Assert.Contains("\"AudioSet Ontology\" by Google Inc.", all, StringComparison.Ordinal); + Assert.Contains("GNU AFFERO GENERAL PUBLIC LICENSE", all, StringComparison.Ordinal); + } + + [Fact] + public void 소리_이름_고지는_온톨로지의_각색물과_CC_BY_SA_4_0_을_적는다() + { + // 분류 탭의 묶음 표와 소리 이름(번역)은 AudioSet 온톨로지(CC BY-SA 4.0)를 바탕으로 만들었다. 출처, 라이선스와 그 링크, + // 바꾼 점을 고지에 적는다(안드로이드 #349). + string notice = Lf(LicenseNotices.Read(LicenseNotices.Documents[2])); + Assert.All( + new[] + { + "SoundVisualizer.Core/Sounds/Data/sound_groups.tsv", + "SoundVisualizer.Core/Sounds/Data/SoundNames/*.json", + "\"AudioSet Ontology\" by Google Inc.", + "https://github.com/audioset/ontology (commit d417d32)", + "(CC BY-SA 4.0)", + "https://creativecommons.org/licenses/by-sa/4.0/", + "regrouped into everyday groups", + }, + phrase => Assert.Contains(phrase, notice, StringComparison.Ordinal)); + } + + [Fact] + public void 온톨로지의_라이선스를_데이터셋의_CC_BY_4_0_으로_적지_않는다() + { + // 온톨로지는 CC BY-SA 4.0, 데이터셋은 CC BY 4.0 이다. 안드로이드의 예전 고지는 온톨로지를 CC BY 4.0 이라고 적었다(#342). + var sentences = LicenseNotices.Documents + .SelectMany(d => Regex.Split(Regex.Replace(LicenseNotices.Read(d), @"\s+", " "), @"(?<=[.;]) ")) + .Where(s => (s.Contains("온톨로지", StringComparison.Ordinal) || s.Contains("ontology", StringComparison.OrdinalIgnoreCase)) + && s.Contains("(CC BY 4.0)", StringComparison.Ordinal)) + .ToArray(); + Assert.True(sentences.Length == 0, "온톨로지를 CC BY 4.0 으로 적은 문장:\n" + string.Join("\n", sentences)); + } + + [Theory] + [MemberData(nameof(Codes))] + public void 앱_정보의_라이선스_안내는_AGPL_과_소리_이름의_CC_BY_SA_4_0_을_적는다(string code) + { + // PC 판은 AGPL-3.0 이다(안드로이드 판의 Apache License 2.0 문장을 옮기지 않는다). 소리 이름과 묶음 표는 AudioSet + // 온톨로지의 각색물이라 CC BY-SA 4.0 이고, 자세한 고지는 바로 아래 버튼을 그 언어의 버튼 이름 그대로 가리킨다. + var table = StringTables.Get(code)!; + Assert.True(table.TryGet("help.about.license", out var text), code + ": help.about.license 가 없습니다"); + Assert.True(table.TryGet("help.licenses.button", out var button), code + ": help.licenses.button 이 없습니다"); + Assert.All(new[] { "AGPL-3.0", "AudioSet", "CC BY-SA 4.0", button }, phrase => Assert.Contains(phrase, text, StringComparison.Ordinal)); + Assert.DoesNotContain("Apache", text, StringComparison.Ordinal); + } + + // ===== 구분선 맞추기(안드로이드 LicenseRulersTest, #167) ===== + + [Fact] + public void 긴_구분선은_창에_들어가는_칸_수로_줄인다() + { + Assert.Equal(new string('=', 35), LicenseNotices.FitRulers(Ruler80, 35)); + } + + [Fact] + public void 창이_더_넓으면_구분선을_늘리지_않는다() + { + Assert.Equal(Ruler80, LicenseNotices.FitRulers(Ruler80, 120)); + } + + [Fact] + public void 구분선이_아닌_줄은_그대로_둔다() + { + string text = string.Join("\n", + "1. ONNX Runtime", + "a = b", + "== 제목 ==", + "=====", // 짧은 = 줄은 구분선이 아니라 내용으로 본다 + "=-=-=-=-=-=-=-=-=-=-=-", // 두 글자가 섞이면 구분선이 아니다 + "| --- | --- | --- |", // Markdown 표의 머리 구분 줄 + " Permission is hereby granted, free of charge, to any person obtaining a"); + Assert.Equal(text, LicenseNotices.FitRulers(text, 20)); + } + + [Fact] + public void 빼기표_구분선도_맞춘다() + { + // PC 판 고지에는 '-' 구분선도 있다(NOTICE 80칸, Pretendard OFL 59칸). + Assert.Equal(new string('-', 30), LicenseNotices.FitRulers(new string('-', 59), 30)); + } + + [Fact] + public void 칸_수가_없으면_손대지_않는다() + { + Assert.Equal(Ruler80, LicenseNotices.FitRulers(Ruler80, 0)); + } + + [Fact] + public void CRLF_로_읽혀도_창에_CR_이_남지_않는다() + { + // Windows 작업 폴더(autocrlf)에서 빌드하면 리소스의 고지가 CRLF 다. + string shown = LicenseNotices.DisplayText("머리말\r\n" + Ruler80 + "\r\n1. ONNX Runtime\r\n", 35); + Assert.Equal("머리말\n" + new string('=', 35) + "\n1. ONNX Runtime\n", shown); + Assert.DoesNotContain("\r", shown, StringComparison.Ordinal); + } + + [Fact] + public void 실제_고지는_구분선만_바뀌고_나머지_줄은_그대로다() + { + string all = LicenseNotices.ReadAll(); + string[] before = all.Split('\n'); + string[] after = LicenseNotices.DisplayText(all, 35).Split('\n'); + + Assert.Equal(before.Length, after.Length); + int rulers = 0; + for (int i = 0; i < before.Length; i++) + { + string line = before[i]; + bool isRuler = line.Length >= 10 && (line[0] == '=' || line[0] == '-') && line.All(c => c == line[0]); + if (isRuler) + { + rulers++; + Assert.Equal(new string(line[0], Math.Min(35, line.Length)), after[i]); + } + else + { + Assert.Equal(line, after[i]); + } + } + Assert.True(rulers > 0, "고지에서 구분선을 찾지 못했습니다"); + } + + private static string Lf(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal); +} diff --git a/SoundVisualizer.Tests/Help/ReportLinkTests.cs b/SoundVisualizer.Tests/Help/ReportLinkTests.cs new file mode 100644 index 0000000..63b5796 --- /dev/null +++ b/SoundVisualizer.Tests/Help/ReportLinkTests.cs @@ -0,0 +1,124 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Help; + +namespace SoundVisualizer.Tests.Help; + +/// 제보 링크가 깨지지 않고, 넘긴 값 말고는 아무것도 붙지 않는지 고정한다(안드로이드 ReportLinkTest 를 옮겼다). +public class ReportLinkTests +{ + private static readonly string Env = ReportLink.EnvironmentBlock( + appVersion: "1.0.0+a8d9c37", + windows: "Microsoft Windows 10.0.26200", + dotnet: ".NET 10.0.0", + language: "ko"); + + [Fact] + public void 환경_정보는_네_줄이고_넘긴_값만_담는다() + { + Assert.Equal( + new[] + { + "App version: 1.0.0+a8d9c37", + "Windows: Microsoft Windows 10.0.26200", + ".NET: .NET 10.0.0", + "App language: ko", + }, + Env.Split('\n')); + } + + [Fact] + public void 이슈_주소는_제목과_본문을_인코딩해_붙인다() + { + string url = ReportLink.IssueUrl("앱에서 보낸 제보", "무슨 일이 있었나요?\n\n무엇을 듣고 있었나요?", Env); + + Assert.StartsWith(ReportLink.IssuesUrl + "?title=", url, StringComparison.Ordinal); + Assert.Contains("&body=", url, StringComparison.Ordinal); + // 주소에는 날것의 공백이나 줄바꿈이 남으면 안 된다. 남으면 일부 앱이 링크를 잘라 버린다. + Assert.DoesNotContain(" ", url, StringComparison.Ordinal); + Assert.DoesNotContain("\n", url, StringComparison.Ordinal); + + Assert.Equal("앱에서 보낸 제보", Title(url)); + Assert.Equal("무슨 일이 있었나요?\n\n무엇을 듣고 있었나요?\n\n" + Env, BodyOf(url)); + } + + [Fact] + public void 본문_안내가_비어_있으면_환경_정보만_남는다() + { + string body = BodyOf(ReportLink.IssueUrl("제보", "", Env)); + + Assert.Equal(Env, body); + Assert.False(body.StartsWith('\n')); + } + + [Fact] + public void 메일과_이슈가_같은_본문을_쓴다() + { + const string prompt = "무슨 일이 있었나요?"; + string body = ReportLink.Body(prompt, Env); + + Assert.Equal(body, BodyOf(ReportLink.IssueUrl("제보", prompt, Env))); + Assert.StartsWith(prompt, body, StringComparison.Ordinal); + Assert.EndsWith(Env, body, StringComparison.Ordinal); + // 메일은 줄바꿈만 CRLF 다(RFC 6068). 내용은 같다. + Assert.Equal(body.Replace("\n", "\r\n", StringComparison.Ordinal), ReportLink.MailBody(prompt, Env)); + } + + [Fact] + public void 메일_주소에_제목과_본문이_들어간다() + { + string uri = ReportLink.MailtoUri("team@example.com", "앱에서 보낸 제보", "무슨 일이 있었나요?", Env); + + Assert.StartsWith("mailto:team@example.com?subject=", uri, StringComparison.Ordinal); + Assert.Contains("&body=", uri, StringComparison.Ordinal); + // mailto 쿼리에서 + 는 공백이 아니라 글자 그대로다. 공백은 %20 으로 들어가야 한다. + Assert.DoesNotContain("+", uri, StringComparison.Ordinal); + Assert.DoesNotContain(" ", uri, StringComparison.Ordinal); + Assert.DoesNotContain("\n", uri, StringComparison.Ordinal); + Assert.Contains("%0D%0A", uri, StringComparison.Ordinal); + + string subject = Uri.UnescapeDataString(Between(uri, "?subject=", "&body=")); + Assert.Equal("앱에서 보낸 제보", subject); + Assert.Equal("무슨 일이 있었나요?\r\n\r\n" + Env.Replace("\n", "\r\n", StringComparison.Ordinal), BodyOf(uri)); + } + + [Fact] + public void 쿼리를_나누는_글자도_값_안에서는_인코딩된다() + { + // 제목이나 번역문에 &, #, +, = 가 들어가도 다른 쿼리나 조각으로 읽히지 않아야 한다. + string url = ReportLink.IssueUrl("A&B #1 + C=D?", "x", Env); + + Assert.Equal(2, url.Split('?').Length); // '?' 는 쿼리를 여는 하나뿐이다 + Assert.Equal(2, url.Split('&').Length); // '&' 는 title 과 body 사이의 하나뿐이다 + Assert.DoesNotContain("#", url, StringComparison.Ordinal); + Assert.Equal("A&B #1 + C=D?", Title(url)); + } + + [Fact] + public void 빈_값이_와도_줄_수와_순서는_그대로다() + { + string empty = ReportLink.EnvironmentBlock("", "", "", ""); + + Assert.Equal(4, empty.Split('\n').Length); + Assert.StartsWith("App version: ", empty, StringComparison.Ordinal); + Assert.EndsWith("App language: ", empty, StringComparison.Ordinal); + } + + [Fact] + public void 제보는_PC_판_저장소와_안드로이드와_같은_메일로_간다() + { + Assert.Equal("https://github.com/amophi/SoundVisualizer/issues/new", ReportLink.IssuesUrl); + Assert.Equal("nawwhod@gmail.com", ReportLink.ReportEmail); + } + + private static string Title(string url) => Uri.UnescapeDataString(Between(url, "?title=", "&body=")); + + private static string BodyOf(string url) => Uri.UnescapeDataString(url[(url.IndexOf("&body=", StringComparison.Ordinal) + "&body=".Length)..]); + + private static string Between(string text, string start, string end) + { + int from = text.IndexOf(start, StringComparison.Ordinal) + start.Length; + return text[from..text.IndexOf(end, from, StringComparison.Ordinal)]; + } +} diff --git a/SoundVisualizer.Tests/Localization/AppSourceGuardTests.cs b/SoundVisualizer.Tests/Localization/AppSourceGuardTests.cs new file mode 100644 index 0000000..b2801ac --- /dev/null +++ b/SoundVisualizer.Tests/Localization/AppSourceGuardTests.cs @@ -0,0 +1,191 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Text.RegularExpressions; +using System.Xml.Linq; +using SoundVisualizer.Localization; +using static SoundVisualizer.Tests.Localization.CSharpLiteralScanner; + +namespace SoundVisualizer.Tests.Localization; + +/// +/// 앱(SoundVisualizer 프로젝트)의 .cs·.xaml 을 읽어, 화면 글자가 모두 문자열 표(Localization/Strings)를 거치는지 지킨다. +/// 한국어가 소스에 다시 들어오거나, XAML 에 영어 글자를 그대로 적거나, 표에 없는 키를 쓰면 여기서 걸린다. +/// +public class AppSourceGuardTests +{ + // 허용 목록 ①: 인자에 비 ASCII 를 써도 되는 호출. 늘릴 때는 왜 번역하지 않는지 적는다. + // - AppLog.Info/Warn: 개발자용 기록이다. 화면에 나오지 않고, 프로젝트 관례대로 한국어로 남긴다. + private static readonly HashSet AllowedCalls = new(StringComparer.Ordinal) { "AppLog.Info", "AppLog.Warn" }; + + // 허용 목록 ②: XAML 에 그대로 두는 영어 글자. 번역하지 않는 이름과, 코드가 바로 덮어쓰는 처음 값이다. + // (언어 이름은 Core 의 UiLanguages 에, Pretendard 같은 글꼴 이름은 UiFontChain 에 있어 여기에 없다.) + private static readonly HashSet AllowedXamlTexts = new(StringComparer.Ordinal) + { + "SoundVisualizer", // 앱 이름. 어느 언어에서나 같다 + "FPS: --", // 개발자 모드 FPS 표시의 처음 값. 렌더 루프가 숫자로 바꾼다 + "144 FPS", // 편집 패널 최대 프레임 값의 처음 값. 설정을 읽으며 바꾼다 + "#FF0000", // 색 선택 창 16진 코드의 처음 값. 창을 열며 바꾼다 + }; + + // 사람이 읽는 글자를 담는 XAML 속성 + private static readonly HashSet TextAttributes = new(StringComparer.Ordinal) { "Text", "Content", "Header", "Title", "ToolTip" }; + + private static readonly Regex XamlComment = new(@"", RegexOptions.Singleline); + private static readonly Regex XamlLocKey = new(@"\{l:Loc\s+([^\s}]+)\s*\}"); + private static readonly Regex KeyPattern = new(@"^[a-z0-9_]+(\.[a-z0-9_]+)+$"); + private static readonly Regex Word = new("[A-Za-z]{2,}"); + + private static readonly string AppDirectory = Path.Combine(RepositoryRoot(), "SoundVisualizer"); + + [Fact] + public void 앱_소스를_찾는다() + { + Assert.True(File.Exists(Path.Combine(AppDirectory, "LauncherWindow.xaml")), AppDirectory); + Assert.NotEmpty(SourceFiles("*.cs")); + Assert.NotEmpty(SourceFiles("*.xaml")); + } + + [Fact] + public void 코드의_문자열에는_한글이나_다른_비ASCII_글자가_없다() + { + var violations = new List(); + foreach (var file in SourceFiles("*.cs")) + { + var tokens = Tokenize(File.ReadAllText(file)); + var openCalls = new Stack(); // 허용 호출의 여는 괄호 깊이 + int depth = 0; + int pendingCall = -1; + for (int k = 0; k < tokens.Count; k++) + { + var token = tokens[k]; + if (token.Kind == Kind.Word && k + 3 < tokens.Count && tokens[k + 1].Text == "." && tokens[k + 3].Text == "(" + && AllowedCalls.Contains(token.Text + "." + tokens[k + 2].Text)) + { + pendingCall = k + 3; + } + + if (token.Kind == Kind.Symbol && token.Text == "(") + { + depth++; + if (k == pendingCall) openCalls.Push(depth); + } + else if (token.Kind == Kind.Symbol && token.Text == ")") + { + if (openCalls.Count > 0 && openCalls.Peek() == depth) openCalls.Pop(); + depth--; + } + else if (token.Kind == Kind.Literal && openCalls.Count == 0 && token.Text.Any(ch => ch > 127)) + { + violations.Add($"{Relative(file)}:{token.Line}: {token.Text}"); + } + } + } + Assert.True(violations.Count == 0, "화면 글자는 Loc 키로 바꾸세요:" + Environment.NewLine + string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void XAML_에는_주석_밖에_비ASCII_글자가_없다() + { + var violations = new List(); + foreach (var file in SourceFiles("*.xaml")) + { + // 주석은 지우되 줄 수는 남겨 줄 번호를 맞춘다. 아이콘 글자는  처럼 적으므로 원문은 ASCII 다. + string text = XamlComment.Replace(File.ReadAllText(file), m => new string('\n', m.Value.Count(ch => ch == '\n'))); + var lines = text.Split('\n'); + for (int n = 0; n < lines.Length; n++) + { + if (lines[n].Any(ch => ch > 127)) violations.Add($"{Relative(file)}:{n + 1}: {lines[n].Trim()}"); + } + } + Assert.True(violations.Count == 0, "XAML 글자는 {l:Loc 키} 로 바꾸세요:" + Environment.NewLine + string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void XAML_의_화면_글자는_문자열_표를_거친다() + { + var violations = new List(); + foreach (var file in SourceFiles("*.xaml")) + { + var document = XDocument.Load(file, LoadOptions.SetLineInfo); + foreach (var attribute in document.Descendants().Attributes()) + { + if (!TextAttributes.Contains(attribute.Name.LocalName)) continue; + if (IsTranslatableLiteral(attribute.Value)) + { + violations.Add($"{Relative(file)}:{((System.Xml.IXmlLineInfo)attribute).LineNumber}: {attribute.Name.LocalName}=\"{attribute.Value}\""); + } + } + foreach (var textNode in document.DescendantNodes().OfType()) + { + if (textNode is XCData || textNode.Parent?.Name.LocalName is "FontFamily") continue; // 글꼴 이름 목록은 화면 글자가 아니다 + if (IsTranslatableLiteral(textNode.Value.Trim())) + { + violations.Add($"{Relative(file)}:{((System.Xml.IXmlLineInfo)textNode).LineNumber}: {textNode.Value.Trim()}"); + } + } + } + Assert.True(violations.Count == 0, "XAML 글자는 {l:Loc 키} 로 바꾸세요:" + Environment.NewLine + string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void 앱이_쓰는_문자열_키는_모두_영어_표에_있다() + { + var english = StringTables.Get(UiLanguages.DefaultCode)!; + var missing = ReferencedKeys().Where(key => !english.TryGet(key, out _)).Order(StringComparer.Ordinal).ToArray(); + Assert.True(missing.Length == 0, "en.json 에 없는 키: " + string.Join(", ", missing)); + } + + [Fact] + public void 영어_표의_키는_모두_앱에서_쓴다() + { + // 쓰지 않는 키는 번역가가 헛수고를 한다. 키는 코드에서 이어 붙이지 말고 통째로 적는다. + var referenced = ReferencedKeys(); + var unused = StringTables.Get(UiLanguages.DefaultCode)!.Entries.Keys.Where(key => !referenced.Contains(key)).Order(StringComparer.Ordinal).ToArray(); + Assert.True(unused.Length == 0, "앱에서 쓰지 않는 키: " + string.Join(", ", unused)); + } + + private static bool IsTranslatableLiteral(string value) + { + if (value.StartsWith("{}", StringComparison.Ordinal)) value = value[2..]; // {} 로 시작하면 그 뒤는 글자 그대로다 + else if (value.StartsWith('{')) return false; // 바인딩·리소스·{l:Loc} + return Word.IsMatch(value) && !AllowedXamlTexts.Contains(value); + } + + /// XAML 의 {l:Loc 키} 와, 코드에서 키 꼴(화면 이름으로 시작하는 점 찍은 소문자)인 문자열 리터럴. + private static HashSet ReferencedKeys() + { + var groups = StringTables.Get(UiLanguages.DefaultCode)!.Entries.Keys.Select(key => key[..key.IndexOf('.')]).ToHashSet(StringComparer.Ordinal); + var keys = new HashSet(StringComparer.Ordinal); + foreach (var file in SourceFiles("*.xaml")) + { + string text = XamlComment.Replace(File.ReadAllText(file), ""); + foreach (Match match in XamlLocKey.Matches(text)) keys.Add(match.Groups[1].Value); + } + foreach (var file in SourceFiles("*.cs")) + { + foreach (var token in Tokenize(File.ReadAllText(file))) + { + if (token.Kind != Kind.Literal || PlainContent(token.Text) is not { } content) continue; + if (KeyPattern.IsMatch(content) && groups.Contains(content[..content.IndexOf('.')])) keys.Add(content); + } + } + return keys; + } + + private static IEnumerable SourceFiles(string pattern) => + Directory.EnumerateFiles(AppDirectory, pattern, SearchOption.AllDirectories) + .Where(path => !Relative(path).Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(part => part is "bin" or "obj")); + + private static string Relative(string path) => Path.GetRelativePath(AppDirectory, path); + + /// 테스트 실행 폴더에서 위로 올라가며 SoundVisualizer.slnx 가 있는 저장소 뿌리를 찾는다. + private static string RepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory != null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "SoundVisualizer.slnx"))) return directory.FullName; + } + throw new InvalidOperationException("SoundVisualizer.slnx 를 찾지 못했습니다: " + AppContext.BaseDirectory); + } +} diff --git a/SoundVisualizer.Tests/Localization/CSharpLiteralScanner.cs b/SoundVisualizer.Tests/Localization/CSharpLiteralScanner.cs new file mode 100644 index 0000000..37adeab --- /dev/null +++ b/SoundVisualizer.Tests/Localization/CSharpLiteralScanner.cs @@ -0,0 +1,199 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Tests.Localization; + +/// +/// 소스 지킴이 테스트용 간단한 C# 어휘 분석기. 주석을 건너뛰고 문자열·문자 리터럴, 이름, 기호를 순서대로 내놓는다. +/// 보통 문자열, @ 축자 문자열, $ 보간 문자열(구멍 안의 문자열까지), """ 원시 문자열을 다룬다. 컴파일러만큼 엄밀하지는 않지만 +/// 앱 소스에 쓰는 꼴은 모두 읽는다. +/// +internal static class CSharpLiteralScanner +{ + public enum Kind + { + Word, + Symbol, + Literal, + } + + public readonly record struct Token(Kind Kind, string Text, int Line); + + public static List Tokenize(string code) + { + var tokens = new List(); + int i = 0; + int line = 1; + while (i < code.Length) + { + char c = code[i]; + if (c == '\n') + { + line++; + i++; + } + else if (char.IsWhiteSpace(c)) + { + i++; + } + else if (c == '/' && At(code, i + 1) == '/') + { + while (i < code.Length && code[i] != '\n') i++; + } + else if (c == '/' && At(code, i + 1) == '*') + { + int end = code.IndexOf("*/", i + 2, StringComparison.Ordinal); + end = end < 0 ? code.Length : end + 2; + line += Count(code, i, end, '\n'); + i = end; + } + else if (c == '\'') + { + int start = i; + i++; + while (i < code.Length && code[i] != '\'') i += code[i] == '\\' ? 2 : 1; + i++; + tokens.Add(new Token(Kind.Literal, code[start..Math.Min(i, code.Length)], line)); + } + else if (c == '"' || ((c == '$' || c == '@') && IsStringPrefix(code, i))) + { + int start = i; + int startLine = line; + i = SkipString(code, i); + line += Count(code, start, i, '\n'); + tokens.Add(new Token(Kind.Literal, code[start..i], startLine)); + } + else if (char.IsLetterOrDigit(c) || c == '_') + { + int start = i; + while (i < code.Length && (char.IsLetterOrDigit(code[i]) || code[i] == '_')) i++; + tokens.Add(new Token(Kind.Word, code[start..i], line)); + } + else + { + tokens.Add(new Token(Kind.Symbol, c.ToString(), line)); + i++; + } + } + return tokens; + } + + /// 문자열 리터럴의 내용(따옴표와 접두어를 뺀 것). 키를 찾는 데만 쓰므로 보통 문자열만 푼다. + public static string? PlainContent(string literal) => + literal.Length >= 2 && literal[0] == '"' && literal[^1] == '"' && !literal.StartsWith("\"\"\"", StringComparison.Ordinal) + ? literal[1..^1] + : null; + + private static char At(string code, int index) => index < code.Length ? code[index] : '\0'; + + private static int Count(string code, int start, int end, char wanted) + { + int n = 0; + for (int k = start; k < end && k < code.Length; k++) + { + if (code[k] == wanted) n++; + } + return n; + } + + private static bool IsStringPrefix(string code, int i) + { + while (i < code.Length && (code[i] == '$' || code[i] == '@')) i++; + return At(code, i) == '"'; + } + + /// i 에서 시작하는 문자열 리터럴 바로 뒤의 위치. + private static int SkipString(string code, int i) + { + bool verbatim = false; + int dollars = 0; + while (code[i] == '$' || code[i] == '@') + { + if (code[i] == '@') verbatim = true; + else dollars++; + i++; + } + + // 원시 문자열: 여는 따옴표 수만큼의 닫는 따옴표까지 + int quotes = 0; + while (At(code, i + quotes) == '"') quotes++; + if (quotes >= 3) + { + string fence = new('"', quotes); + int end = code.IndexOf(fence, i + quotes, StringComparison.Ordinal); + return end < 0 ? code.Length : end + quotes; + } + + i++; // 여는 따옴표 + while (i < code.Length) + { + char c = code[i]; + if (verbatim && c == '"' && At(code, i + 1) == '"') + { + i += 2; + } + else if (!verbatim && c == '\\') + { + i += 2; + } + else if (c == '"') + { + return i + 1; + } + else if (dollars > 0 && c == '{' && At(code, i + 1) == '{') + { + i += 2; + } + else if (dollars > 0 && c == '{') + { + i = SkipHole(code, i + 1); + } + else + { + i++; + } + } + return code.Length; + } + + /// 보간 구멍 {…} 의 닫는 중괄호 바로 뒤. 구멍 안의 문자열·괄호를 건너뛴다. + private static int SkipHole(string code, int i) + { + int depth = 0; + while (i < code.Length) + { + char c = code[i]; + if (c == '"' || ((c == '$' || c == '@') && IsStringPrefix(code, i))) + { + i = SkipString(code, i); + } + else if (c == '\'') + { + i++; + while (i < code.Length && code[i] != '\'') i += code[i] == '\\' ? 2 : 1; + i++; + } + else if (c == '(' || c == '[' || c == '{') + { + depth++; + i++; + } + else if ((c == ')' || c == ']') && depth > 0) + { + depth--; + i++; + } + else if (c == '}') + { + if (depth == 0) return i + 1; + depth--; + i++; + } + else + { + i++; + } + } + return code.Length; + } +} diff --git a/SoundVisualizer.Tests/Localization/CSharpLiteralScannerTests.cs b/SoundVisualizer.Tests/Localization/CSharpLiteralScannerTests.cs new file mode 100644 index 0000000..f2774ad --- /dev/null +++ b/SoundVisualizer.Tests/Localization/CSharpLiteralScannerTests.cs @@ -0,0 +1,65 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using static SoundVisualizer.Tests.Localization.CSharpLiteralScanner; + +namespace SoundVisualizer.Tests.Localization; + +/// 소스 지킴이가 기대는 어휘 분석기가 주석과 여러 문자열 꼴을 제대로 가르는지. +public class CSharpLiteralScannerTests +{ + private static string[] Literals(string code) => Tokenize(code).Where(t => t.Kind == Kind.Literal).Select(t => t.Text).ToArray(); + + [Fact] + public void 주석_안의_글자는_리터럴이_아니다() + { + const string code = "// \"주석\"\n/* \"여러 줄\n 주석\" */ var a = \"b\"; /// \"문서\""; + + Assert.Equal(new[] { "\"b\"" }, Literals(code)); + } + + [Fact] + public void 이스케이프와_축자_문자열을_끝까지_읽는다() + { + const string code = "var a = \"x\\\"y\"; var b = @\"c:\\dir\"\"q\"\"\"; var c = '\\''; var d = '\"';"; + + Assert.Equal(new[] { "\"x\\\"y\"", "@\"c:\\dir\"\"q\"\"\"", "'\\''", "'\"'" }, Literals(code)); + } + + [Fact] + public void 보간_구멍_안의_문자열까지_한_리터럴로_읽는다() + { + const string code = "var a = $\"{(ok ? \"한\" : \"b\")} {{x}} {n:F0}\"; var b = 1;"; + + var literals = Literals(code); + + Assert.Single(literals); + Assert.Contains("한", literals[0]); + Assert.Contains(Tokenize(code), t => t.Kind == Kind.Word && t.Text == "b"); + } + + [Fact] + public void 원시_문자열을_닫는_따옴표까지_읽는다() + { + const string code = "var a = \"\"\"{ \"Language\": \"ko\" }\"\"\"; var z = 2;"; + + Assert.Equal(new[] { "\"\"\"{ \"Language\": \"ko\" }\"\"\"" }, Literals(code)); + } + + [Fact] + public void 줄_번호를_센다() + { + const string code = "var a = 1;\n/* x\ny */\nvar b = \"two\";"; + + var literal = Assert.Single(Tokenize(code), t => t.Kind == Kind.Literal); + Assert.Equal(4, literal.Line); + } + + [Fact] + public void 보통_문자열만_내용을_꺼낸다() + { + Assert.Equal("launcher.tab.home", PlainContent("\"launcher.tab.home\"")); + Assert.Null(PlainContent("$\"{x}\"")); + Assert.Null(PlainContent("'a'")); + } +} diff --git a/SoundVisualizer.Tests/Localization/LocalizerTests.cs b/SoundVisualizer.Tests/Localization/LocalizerTests.cs new file mode 100644 index 0000000..3515d69 --- /dev/null +++ b/SoundVisualizer.Tests/Localization/LocalizerTests.cs @@ -0,0 +1,167 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using SoundVisualizer.Localization; + +namespace SoundVisualizer.Tests.Localization; + +public class LocalizerTests +{ + private static readonly StringTable English = StringTable.Parse("en", """{ "a": "Apple", "b": "Banana", "size": "Size: {0}", "pair": "{0} / {1}" }"""); + private static readonly StringTable Korean = StringTable.Parse("ko", """{ "a": "사과", "size": "크기: {0" }"""); + + private static Localizer WithTables(params StringTable[] tables) => new(code => tables.FirstOrDefault(t => t.Code == code)); + + [Fact] + public void 처음에는_영어로_시작한다() + { + var localizer = new Localizer(); + + Assert.Equal("en", localizer.Code); + Assert.Equal("Home", localizer.Get("launcher.tab.home")); + } + + [Fact] + public void 언어를_바꾸면_그_언어의_표에서_찾는다() + { + var localizer = new Localizer(); + + localizer.SetLanguage("ko"); + + Assert.Equal("ko", localizer.Code); + Assert.Equal("홈", localizer.Get("launcher.tab.home")); + Assert.Equal("파도", localizer.Get("settings.mode.wave")); + Assert.Equal("표현 모드: [F3] 파도", localizer.Format("hud.visual_mode", "F3", localizer.Get("settings.mode.wave"))); + } + + [Fact] + public void 그_언어_표에_없는_키는_영어로_보인다() + { + var localizer = WithTables(English, Korean); + + localizer.SetLanguage("ko"); + + Assert.Equal("사과", localizer.Get("a")); + Assert.Equal("Banana", localizer.Get("b")); + } + + [Fact] + public void 번역_파일이_없는_언어는_키마다_영어로_보인다() + { + var localizer = WithTables(English); + + localizer.SetLanguage("fr"); + + Assert.Equal("fr", localizer.Code); // 화면 방향과 글꼴은 그 언어를 따른다 + Assert.Equal("Apple", localizer.Get("a")); + } + + [Fact] + public void 영어에도_없는_키는_키를_그대로_보여_준다() + { + var localizer = WithTables(English); + + Assert.Equal("no.such.key", localizer.Get("no.such.key")); + Assert.Equal("no.such.key", localizer.Format("no.such.key", 1)); + } + + [Theory] + [InlineData("xx")] + [InlineData("system")] + [InlineData("")] + public void 지원하지_않는_코드는_영어가_된다(string code) + { + var localizer = WithTables(English, Korean); + localizer.SetLanguage("ko"); + + localizer.SetLanguage(code); + + Assert.Equal("en", localizer.Code); + Assert.Equal("Apple", localizer.Get("a")); + } + + [Fact] + public void 코드는_정해진_표기로_맞춘다() + { + var localizer = WithTables(English); + + localizer.SetLanguage("ZH-HANT"); + + Assert.Equal("zh-Hant", localizer.Code); + } + + [Fact] + public void 숫자는_Windows_지역_설정과_상관없이_채운다() + { + var localizer = WithTables(English); + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); // 소수점이 쉼표인 지역 + Assert.Equal("1.5 / 2", localizer.Format("pair", 1.5, 2)); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void 자리표시자가_깨진_번역은_영어_문장으로_채운다() + { + var localizer = WithTables(English, Korean); + localizer.SetLanguage("ko"); + + Assert.Equal("Size: 5", localizer.Format("size", 5)); + } + + [Fact] + public void 깨진_표는_건너뛰고_영어로_보인다() + { + var localizer = new Localizer(code => code == "ko" ? throw new FormatException("broken") : English); + + localizer.SetLanguage("ko"); + + Assert.Equal("ko", localizer.Code); + Assert.Equal("Apple", localizer.Get("a")); + } + + [Fact] + public void 오른쪽에서_왼쪽으로_쓰는지는_지금_언어를_따른다() + { + var localizer = WithTables(English); + + localizer.SetLanguage("ar"); + Assert.True(localizer.IsRightToLeft); + + localizer.SetLanguage("ko"); + Assert.False(localizer.IsRightToLeft); + } + + [Fact] + public async Task 언어를_바꾸는_도중에도_어느_한_언어의_문자열만_읽힌다() + { + var localizer = WithTables(English, Korean); + using var stop = new CancellationTokenSource(); + var unexpected = new List(); + + var reader = Task.Run(() => + { + while (!stop.IsCancellationRequested) + { + string text = localizer.Get("a"); + if (text != "Apple" && text != "사과") + { + lock (unexpected) unexpected.Add(text); + } + } + }); + + for (int i = 0; i < 2000; i++) localizer.SetLanguage(i % 2 == 0 ? "ko" : "en"); + stop.Cancel(); + await reader; + + Assert.Empty(unexpected); + } +} diff --git a/SoundVisualizer.Tests/Localization/StringTableFilesTests.cs b/SoundVisualizer.Tests/Localization/StringTableFilesTests.cs new file mode 100644 index 0000000..752a6d4 --- /dev/null +++ b/SoundVisualizer.Tests/Localization/StringTableFilesTests.cs @@ -0,0 +1,133 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using System.Text.RegularExpressions; +using SoundVisualizer.Localization; + +namespace SoundVisualizer.Tests.Localization; + +/// +/// 앱에 넣는 문자열 표 파일(Localization/Strings/*.json) 검사. 번역가가 en.json 으로 새 언어 파일을 만들어 넣으면 +/// 그 파일도 같은 검사를 받는다. 키가 빠지거나 자리표시자가 어긋난 번역은 여기서 걸린다. +/// +public class StringTableFilesTests +{ + // 화면별로 점을 찍어 묶은 소문자 키(launcher.tab.home) + private static readonly Regex KeyPattern = new(@"^[a-z0-9_]+(\.[a-z0-9_]+)+$"); + private static readonly Regex Placeholder = new(@"\{(\d+)(?:[,:][^{}]*)?\}"); + + public static TheoryData Codes() + { + var data = new TheoryData(); + foreach (var code in StringTables.AvailableCodes) data.Add(code); + return data; + } + + private static StringTable English => StringTables.Get(UiLanguages.DefaultCode)!; + + [Fact] + public void 영어와_한국어_표가_들어_있다() + { + Assert.Contains("en", StringTables.AvailableCodes); + Assert.Contains("ko", StringTables.AvailableCodes); + } + + [Fact] + public void 표_파일은_목록에_있는_언어의_코드로만_만든다() + { + Assert.All(StringTables.AvailableCodes, code => + { + var language = UiLanguages.Find(code); + Assert.NotNull(language); + Assert.Equal(language.Code, code); // 파일 이름도 정해진 표기(zh-Hans, pt-BR)여야 찾을 수 있다 + }); + } + + [Theory] + [MemberData(nameof(Codes))] + public void 표는_평평한_JSON_객체로_읽힌다(string code) + { + var table = StringTable.Parse(code, StringTables.ReadJson(code)!); + Assert.NotEmpty(table.Entries); + Assert.Equal(code, table.Code); + } + + [Theory] + [MemberData(nameof(Codes))] + public void 모든_언어_표는_영어와_똑같은_키를_가진다(string code) + { + var keys = StringTables.Get(code)!.Entries.Keys.ToHashSet(StringComparer.Ordinal); + var englishKeys = English.Entries.Keys.ToHashSet(StringComparer.Ordinal); + + var missing = englishKeys.Except(keys).Order(StringComparer.Ordinal).ToArray(); + var extra = keys.Except(englishKeys).Order(StringComparer.Ordinal).ToArray(); + Assert.True(missing.Length == 0 && extra.Length == 0, + $"{code}: 빠진 키 [{string.Join(", ", missing)}], 영어에 없는 키 [{string.Join(", ", extra)}]"); + } + + [Theory] + [MemberData(nameof(Codes))] + public void 자리표시자는_영어와_같다(string code) + { + var table = StringTables.Get(code)!; + foreach (var (key, english) in English.Entries) + { + if (!table.TryGet(key, out var text)) continue; // 빠진 키는 위 테스트가 잡는다 + Assert.True(Placeholders(english).SequenceEqual(Placeholders(text)), + $"{code}: '{key}' 의 자리표시자가 영어({string.Join(",", Placeholders(english))})와 다릅니다({string.Join(",", Placeholders(text))})."); + } + } + + [Theory] + [MemberData(nameof(Codes))] + public void 빈_값이나_앞뒤_공백이_없다(string code) + { + Assert.All(StringTables.Get(code)!.Entries, entry => + { + Assert.False(string.IsNullOrWhiteSpace(entry.Value), $"{code}: '{entry.Key}' 가 비었습니다."); + Assert.True(entry.Value == entry.Value.Trim(), $"{code}: '{entry.Key}' 의 앞뒤에 공백이 있습니다."); + }); + } + + [Theory] + [MemberData(nameof(Codes))] + public void 모든_값은_자리표시자를_채울_수_있는_꼴이다(string code) + { + // 화면은 일부 문자열을 string.Format 으로 채운다. 짝이 안 맞는 중괄호가 있으면 그 화면에서 예외가 난다. + var args = Enumerable.Range(0, 10).Select(i => (object)i).ToArray(); + Assert.All(StringTables.Get(code)!.Entries, entry => + { + var error = Record.Exception(() => string.Format(CultureInfo.InvariantCulture, entry.Value, args)); + Assert.True(error == null, $"{code}: '{entry.Key}' 를 채우지 못합니다({error?.Message})."); + }); + } + + [Fact] + public void 키는_화면별로_점을_찍은_소문자다() + { + Assert.All(English.Entries.Keys, key => Assert.Matches(KeyPattern, key)); + } + + [Fact] + public void 어떤_키도_다른_키의_묶음_이름이_아니다() + { + // "a.b" 와 "a.b.c" 가 함께 있으면 키를 중첩 구조로 바꿔 쓰는 번역 도구에서 둘 중 하나가 사라진다. + var keys = English.Entries.Keys.ToArray(); + Assert.All(keys, key => Assert.DoesNotContain(keys, other => other.StartsWith(key + ".", StringComparison.Ordinal))); + } + + [Fact] + public void 읽은_표는_고칠_수_없다() + { + var entries = Assert.IsAssignableFrom>(English.Entries); + Assert.Throws(() => entries["launcher.tab.home"] = "x"); + Assert.Throws(() => entries.Add("new.key", "x")); + } + + private static int[] Placeholders(string text) + { + string unescaped = text.Replace("{{", "", StringComparison.Ordinal).Replace("}}", "", StringComparison.Ordinal); + return Placeholder.Matches(unescaped).Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture)).Distinct().Order().ToArray(); + } +} diff --git a/SoundVisualizer.Tests/Localization/StringTableTests.cs b/SoundVisualizer.Tests/Localization/StringTableTests.cs new file mode 100644 index 0000000..afadb1a --- /dev/null +++ b/SoundVisualizer.Tests/Localization/StringTableTests.cs @@ -0,0 +1,52 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Localization; + +namespace SoundVisualizer.Tests.Localization; + +public class StringTableTests +{ + [Fact] + public void 키로_문자열을_찾는다() + { + var table = StringTable.Parse("ko", """{ "launcher.tab.home": "홈", "multi.line": "첫 줄\n둘째 줄" }"""); + + Assert.True(table.TryGet("launcher.tab.home", out var home)); + Assert.Equal("홈", home); + Assert.True(table.TryGet("multi.line", out var multi)); + Assert.Equal("첫 줄\n둘째 줄", multi); + Assert.False(table.TryGet("missing.key", out _)); + } + + [Fact] + public void 키는_대소문자까지_같아야_찾는다() + { + var table = StringTable.Parse("en", """{ "a.b": "x" }"""); + + Assert.False(table.TryGet("A.B", out _)); + } + + [Theory] + [InlineData("not json")] + [InlineData("[]")] + [InlineData("\"text\"")] + [InlineData("""{ "a": 1 }""")] + [InlineData("""{ "a": null }""")] + [InlineData("""{ "a": { "b": "c" } }""")] + [InlineData("""{ "a": "x", "a": "y" }""")] + public void 형식이_맞지_않으면_읽지_않는다(string json) + { + Assert.Throws(() => StringTable.Parse("xx", json)); + } + + [Fact] + public void 빈_표에는_아무_키도_없다() + { + var table = StringTable.Empty("fr"); + + Assert.Equal("fr", table.Code); + Assert.Empty(table.Entries); + Assert.False(table.TryGet("launcher.tab.home", out _)); + } +} diff --git a/SoundVisualizer.Tests/Localization/UiLanguagesTests.cs b/SoundVisualizer.Tests/Localization/UiLanguagesTests.cs new file mode 100644 index 0000000..81ce8ee --- /dev/null +++ b/SoundVisualizer.Tests/Localization/UiLanguagesTests.cs @@ -0,0 +1,164 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using System.Text; +using SoundVisualizer.Localization; + +namespace SoundVisualizer.Tests.Localization; + +public class UiLanguagesTests +{ + [Fact] + public void 화면_언어는_18개이고_코드가_겹치지_않는다() + { + Assert.Equal(18, UiLanguages.All.Count); + Assert.Equal(18, UiLanguages.All.Select(l => l.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(UiLanguages.All, l => Assert.False(string.IsNullOrWhiteSpace(l.NativeName))); + Assert.DoesNotContain(UiLanguages.All, l => l.Code == UiLanguages.SystemCode); + } + + [Fact] + public void 목록은_안드로이드_판과_같은_언어를_정해진_순서로_담는다() + { + string[] expected = { "en", "ko", "ja", "zh-Hans", "zh-Hant", "es", "pt-BR", "fr", "de", "ru", "it", "vi", "th", "id", "tr", "pl", "hi", "ar" }; + Assert.Equal(expected, UiLanguages.All.Select(l => l.Code)); + Assert.Equal("한국어", UiLanguages.Find("ko")!.NativeName); + Assert.Equal("简体中文", UiLanguages.Find("zh-Hans")!.NativeName); + Assert.Equal("繁體中文", UiLanguages.Find("zh-Hant")!.NativeName); + Assert.Equal("Português (Brasil)", UiLanguages.Find("pt-BR")!.NativeName); + Assert.Equal("العربية", UiLanguages.Find("ar")!.NativeName); + } + + [Fact] + public void 오른쪽에서_왼쪽으로_쓰는_언어는_아랍어_하나다() + { + var rtl = Assert.Single(UiLanguages.All, l => l.IsRightToLeft); + Assert.Equal("ar", rtl.Code); + Assert.True(UiLanguages.IsRightToLeft("ar")); + Assert.False(UiLanguages.IsRightToLeft("ko")); + Assert.False(UiLanguages.IsRightToLeft(UiLanguages.SystemCode)); + Assert.False(UiLanguages.IsRightToLeft(null)); + } + + [Theory] + [InlineData("ko-KR", "ko")] + [InlineData("en-US", "en")] + [InlineData("en-GB", "en")] + [InlineData("ja-JP", "ja")] + [InlineData("zh-CN", "zh-Hans")] + [InlineData("zh-SG", "zh-Hans")] + [InlineData("zh", "zh-Hans")] + [InlineData("zh-Hans", "zh-Hans")] + [InlineData("zh-Hans-HK", "zh-Hans")] + [InlineData("zh-TW", "zh-Hant")] + [InlineData("zh-HK", "zh-Hant")] + [InlineData("zh-MO", "zh-Hant")] + [InlineData("zh-Hant", "zh-Hant")] + [InlineData("zh-Hant-TW", "zh-Hant")] + [InlineData("zh-CHT", "zh-Hant")] + [InlineData("pt-PT", "pt-BR")] + [InlineData("pt-BR", "pt-BR")] + [InlineData("pt", "pt-BR")] + [InlineData("id-ID", "id")] + [InlineData("in-ID", "id")] + [InlineData("in", "id")] + [InlineData("ar-SA", "ar")] + [InlineData("ar-EG", "ar")] + [InlineData("hi-IN", "hi")] + [InlineData("th-TH", "th")] + [InlineData("vi-VN", "vi")] + [InlineData("tr-TR", "tr")] + [InlineData("pl-PL", "pl")] + [InlineData("it-IT", "it")] + [InlineData("es-MX", "es")] + [InlineData("fr-CA", "fr")] + [InlineData("de-AT", "de")] + [InlineData("ru-RU", "ru")] + [InlineData("sw-KE", "en")] + [InlineData("nb-NO", "en")] + [InlineData("", "en")] + public void 시스템_언어는_Windows_문화권_이름에서_고른다(string cultureName, string expected) + { + Assert.Equal(expected, UiLanguages.FromCultureName(cultureName)); + } + + [Theory] + [InlineData("zh-TW", "zh-Hant")] + [InlineData("zh-HK", "zh-Hant")] + [InlineData("zh-CN", "zh-Hans")] + [InlineData("pt-PT", "pt-BR")] + [InlineData("ko-KR", "ko")] + [InlineData("id-ID", "id")] + [InlineData("de-CH", "de")] + [InlineData("fil-PH", "en")] + public void 시스템_설정은_실제_문화권으로도_같은_언어가_된다(string culture, string expected) + { + var uiCulture = new CultureInfo(culture); + Assert.Equal(expected, UiLanguages.Resolve(UiLanguages.SystemCode, uiCulture)); + Assert.Equal(expected, UiLanguages.Resolve(null, uiCulture)); + } + + [Fact] + public void 언어를_고른_설정은_Windows_언어와_상관없다() + { + Assert.Equal("ja", UiLanguages.Resolve("ja", new CultureInfo("ko-KR"))); + Assert.Equal("ar", UiLanguages.Resolve("ar", CultureInfo.InvariantCulture)); + Assert.Equal("ko", UiLanguages.Resolve("KOR", new CultureInfo("en-US"))); + } + + [Theory] + [InlineData("KOR", "ko")] + [InlineData("English", "en")] + [InlineData("ENG", "en")] + [InlineData("日本語", "ja")] + [InlineData("中文", "zh-Hans")] + [InlineData("Español", "es")] + [InlineData("Français", "fr")] + [InlineData("Deutsch", "de")] + [InlineData("Русский", "ru")] + public void 예전_판의_언어_값은_코드로_옮긴다(string legacy, string code) + { + Assert.Equal(code, UiLanguages.NormalizeSetting(legacy)); + } + + [Fact] + public void 분해형으로_적힌_예전_값도_옮긴다() + { + Assert.Equal("es", UiLanguages.NormalizeSetting("Español".Normalize(NormalizationForm.FormD))); + Assert.Equal("fr", UiLanguages.NormalizeSetting("Français".Normalize(NormalizationForm.FormD))); + } + + [Theory] + [InlineData("ko", "ko")] + [InlineData("zh-Hant", "zh-Hant")] + [InlineData("ZH-HANS", "zh-Hans")] + [InlineData(" pt-br ", "pt-BR")] + [InlineData("system", "system")] + [InlineData("System", "system")] + public void 코드로_저장된_값은_정해진_표기로_읽는다(string stored, string expected) + { + Assert.Equal(expected, UiLanguages.NormalizeSetting(stored)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Klingon")] + [InlineData("sw")] + [InlineData("Japanese")] + public void 비었거나_모르는_값은_시스템_언어를_따른다(string? stored) + { + Assert.Equal(UiLanguages.SystemCode, UiLanguages.NormalizeSetting(stored)); + } + + [Fact] + public void 저장값을_다시_맞춰도_그대로다() + { + foreach (var value in UiLanguages.All.Select(l => l.Code).Append(UiLanguages.SystemCode)) + { + Assert.Equal(value, UiLanguages.NormalizeSetting(value)); + } + } +} diff --git a/SoundVisualizer.Tests/Rendering/EngineHarness.cs b/SoundVisualizer.Tests/Rendering/EngineHarness.cs new file mode 100644 index 0000000..1829d50 --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/EngineHarness.cs @@ -0,0 +1,138 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Rendering; + +namespace SoundVisualizer.Tests.Rendering; + +/// +/// 엔진에 넣을 바깥 값을 들고 프레임을 흘린다(Android VisualizerEngineTest 의 FakeInputs 와 advance). +/// 채널 값은 캡처 순서 FL, FR, FC, BL, BR, SL, SR, LFE 다. +/// +internal sealed class EngineHarness +{ + /// 60fps 한 프레임. 엔진의 시간 정규화 기준값과 같다. + public const double Frame60 = FrameStep.ReferenceFrameMs; + + public const double Width = 1920; + public const double Height = 1080; + + /// 크기 100% 의 깊이: 짧은 변의 절반 - 10. + public const double MaxBase = 530; + + public readonly VisualizerEngine Engine = new(); + public VisualModeSettings Settings = new(); + public int VisualMode = VisualizerEngine.ModeWave; + public int SoundMode = 2; + + /// AI 가 확정한 종류와, 그 종류를 보이게 해 두었는지. + public SoundType Label = SoundType.Ambient; + public bool Shown = true; + + public OverlayInput Input => new(Settings, VisualMode, SoundMode, Label, Shown); + + /// 다음 프레임에 들어올 채널별 피크. + public readonly float[] Peaks = new float[VisualizerEngine.Channels]; + + /// false 면 지난 프레임 뒤로 버퍼가 하나도 오지 않은 것으로 넘긴다(엔진이 이전 값을 줄인다). + public bool Buffers = true; + + /// 오버레이 편집 중처럼, 보이는 것이 없어도 쉬지 않게 한다. + public bool KeepAwake; + + /// 쉬는 동안의 확인 간격. 33ms(60fps 두 프레임)다. + public const double PollMs = 2 * Frame60; + + /// 마지막으로 흘린 프레임의 시각. + public double Now { get; private set; } + + public EngineHarness(double width = Width, double height = Height) + { + Engine.SetSurfaceSize(width, height); + } + + /// 모든 채널을 같은 값으로. + public void All(float value) => Array.Fill(Peaks, value); + + public void Silence() => Array.Clear(Peaks); + + /// 앞 좌우(FL, FR)만 울리는 소리. Android 테스트의 좌우 피크에 해당한다. + public void Stereo(float left, float right) + { + Silence(); + Peaks[0] = left; + Peaks[1] = right; + } + + /// 가장 깊은 자리의 깊이. + public double DeepestDepth => Engine.Depths.ToArray().Max(); + + /// + /// 위협음을 표시 중인 엔진이 같은 소리를 제때 받았을 때 평소 경로가 도달하는 가장 큰 전체 크기. + /// 프레임 동안 , 이어서 프레임 동안 을 + /// 앞 좌우에 같게 넣는다. + /// + public static double OnTimePeakSmoothTotal(float loud, int loudFrames, float tail, int tailFrames, int soundMode = 2) + { + var run = new EngineHarness { Label = SoundType.Danger, Shown = true, SoundMode = soundMode }; + double peak = 0; + for (int n = 0; n < loudFrames + tailFrames; n++) + { + float v = n < loudFrames ? loud : tail; + run.Stereo(v, v); + run.Tick(); + peak = Math.Max(peak, run.Engine.SmoothTotal); + } + return peak; + } + + /// 뒤의 프레임 하나. + public void Tick(double stepMs = Frame60) + { + // 오버레이는 쉬는 동안 프레임을 흘리지 않고 PollWake 로 확인한다. 테스트도 그 약속을 지킨다. + Assert.False(Engine.IsIdle, "쉬는 엔진에 프레임을 흘렸다"); + Now += stepMs; + Engine.Tick(Now, Buffers ? Peaks : ReadOnlySpan.Empty, Input, KeepAwake); + } + + /// 쉬는 동안의 확인 한 번. 깨어났으면 true. + public bool Poll(double stepMs = PollMs) + { + Now += stepMs; + return Engine.PollWake(Now, Buffers ? Peaks : ReadOnlySpan.Empty, Input); + } + + /// 확인을 번. + public void Polls(int times, double stepMs = PollMs) + { + for (int i = 0; i < times; i++) Poll(stepMs); + } + + /// + /// 쉴 때까지 프레임을 흘리고 흘린 프레임 수를 돌려준다. 오버레이처럼 쉬기 시작하면 프레임을 멈춘다. + /// + public int TickUntilIdle(double stepMs = Frame60, int maxFrames = 2000) + { + int frames = 0; + while (!Engine.IsIdle && frames < maxFrames) + { + Tick(stepMs); + frames++; + } + Assert.True(Engine.IsIdle, $"쉬기로 내려가지 않았다 (frames={frames})"); + return frames; + } + + /// 분류기가 위협음으로 판정했고, 위협음은 표시가 켜져 있다. + public void BecomeDanger() + { + Label = SoundType.Danger; + Shown = true; + } + + public void Advance(int frames, double stepMs = Frame60) + { + for (int i = 0; i < frames; i++) Tick(stepMs); + } +} diff --git a/SoundVisualizer.Tests/Rendering/FrameStepTests.cs b/SoundVisualizer.Tests/Rendering/FrameStepTests.cs new file mode 100644 index 0000000..9f7aefa --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/FrameStepTests.cs @@ -0,0 +1,93 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Rendering; + +namespace SoundVisualizer.Tests.Rendering; + +public class FrameStepTests +{ + [Fact] + public void 프레임_사이_시간을_60fps_프레임_수로_잰다() + { + Assert.Equal(1.0, FrameStep.FromElapsed(FrameStep.ReferenceFrameMs)); + Assert.Equal(2.0, FrameStep.FromElapsed(1000.0 / 30.0), 9); + Assert.Equal(60.0 / 144.0, FrameStep.FromElapsed(1000.0 / 144.0), 9); + } + + [Fact] + public void 멈췄다_돌아온_프레임은_4프레임까지만_진행한다() + { + Assert.Equal(FrameStep.MaxStep, FrameStep.FromElapsed(1000)); + Assert.Equal(4.0, FrameStep.MaxStep); + } + + [Fact] + public void 주사율이_240Hz_를_넘는_모니터도_자르지_않는다() + { + // PC 는 최대 프레임을 모니터 주사율까지 올린다. Android 처럼 0.25 에서 자르면 360Hz 에서 1.5배 빨라진다. + Assert.Equal(1.0 / 6.0, FrameStep.FromElapsed(1000.0 / 360.0), 9); + } + + [Fact] + public void 시간이_흐르지_않았으면_진행하지_않는다() + { + Assert.Equal(0.0, FrameStep.FromElapsed(0)); + Assert.Equal(0.0, FrameStep.FromElapsed(-3)); + Assert.Equal(0.0, FrameStep.FromElapsed(double.NaN)); + Assert.Equal(1f, FrameStep.Decay(0.87f, 0)); + Assert.Equal(0.0, FrameStep.Approach(0.3, 0)); + } + + [Fact] + public void 한_프레임이면_예전_계수를_그대로_쓴다() + { + Assert.Equal(0.87f, FrameStep.Decay(0.87f, 1.0)); + Assert.Equal(0.3, FrameStep.Approach(0.3, 1.0)); + } + + [Fact] + public void 감쇠는_나눠_가도_한_번에_가도_같다() + { + float twoSteps = FrameStep.Decay(0.87f, 0.4) * FrameStep.Decay(0.87f, 1.6); + Assert.Equal(FrameStep.Decay(0.87f, 2.0), twoSteps, 6); + Assert.Equal(0.87f * 0.87f, FrameStep.Decay(0.87f, 2.0), 6); + } + + [Fact] + public void 따라붙기는_나눠_가도_한_번에_가도_같다() + { + // 0 에서 1 로 따라붙는 값을 0.4 + 1.6 프레임과 2 프레임으로 나눠 본다. + double split = 0; + split += (1 - split) * FrameStep.Approach(0.25, 0.4); + split += (1 - split) * FrameStep.Approach(0.25, 1.6); + + double once = (1 - 0.0) * FrameStep.Approach(0.25, 2.0); + + double perFrame = 0; + perFrame += (1 - perFrame) * 0.25; + perFrame += (1 - perFrame) * 0.25; + + Assert.Equal(once, split, 12); + Assert.Equal(perFrame, once, 12); + } + + [Fact] + public void 계수가_1이면_바로_붙고_0이면_움직이지_않는다() + { + Assert.Equal(1.0, FrameStep.Approach(1.0, 0.5)); + Assert.Equal(0.0, FrameStep.Approach(0.0, 3.0)); + } + + [Fact] + public void 시계는_첫_프레임과_다시_시작한_프레임을_한_프레임으로_센다() + { + var clock = new FrameClock(); + Assert.Equal(1.0, clock.Next(5_000)); // 처음 시각이 얼마든 한 프레임 + Assert.Equal(2.0, clock.Next(5_000 + 1000.0 / 30.0), 9); + Assert.Equal(FrameStep.MaxStep, clock.Next(9_000)); // 4초 멈춤 + + clock.Reset(); + Assert.Equal(1.0, clock.Next(60_000)); // 쉬다 깨어난 첫 프레임은 쉰 시간만큼 건너뛰지 않는다 + } +} diff --git a/SoundVisualizer.Tests/Rendering/LateDangerHoldTests.cs b/SoundVisualizer.Tests/Rendering/LateDangerHoldTests.cs new file mode 100644 index 0000000..d2d35b3 --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/LateDangerHoldTests.cs @@ -0,0 +1,98 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Rendering; + +namespace SoundVisualizer.Tests.Rendering; + +public class LateDangerHoldTests +{ + private const float Threshold = 0.01f; + private const int Newest = LateDangerHold.Slots - 1; // 링에서 가장 최근 칸 + private const int Current = LateDangerHold.Slots; // 채우는 중인 칸 + + private static float[] Frame(float fl, float fr, float sl = 0) => new[] { fl, fr, 0f, 0f, 0f, sl, 0f, 0f }; + + [Fact] + public void 문턱_이하의_소리는_보관하지_않는다() + { + var hold = new LateDangerHold(); + hold.Keep(Frame(0.01f, 0.005f), level: 0.01f, Threshold); + Assert.False(hold.HasData); + } + + [Fact] + public void 칸마다_가장_큰_프레임_하나를_통째로_남긴다() + { + // 채널마다 최댓값을 따로 남기면 왼쪽 소리와 오른쪽 소리가 섞여 방향이 흐려진다. + var hold = new LateDangerHold(); + hold.Keep(Frame(0.5f, 0.1f), level: 0.5f, Threshold); + hold.Keep(Frame(0.05f, 0.6f), level: 0.6f, Threshold); + hold.Keep(Frame(0.3f, 0.3f), level: 0.3f, Threshold); + + Assert.Equal(Frame(0.05f, 0.6f), hold.Slot(Current).ToArray()); + Assert.Equal(0.6f, hold.SlotLevel(Current)); + + hold.Advance(LateDangerHold.SlotFrames); + Assert.Equal(Frame(0.05f, 0.6f), hold.Slot(Newest).ToArray()); + Assert.All(hold.Slot(Current).ToArray(), v => Assert.Equal(0f, v)); + } + + [Fact] + public void 오래된_칸부터_읽는다() + { + var hold = new LateDangerHold(); + hold.Keep(Frame(0.2f, 0f), 0.2f, Threshold); + hold.Advance(2); + hold.Keep(Frame(0f, 0.4f), 0.4f, Threshold); + hold.Advance(2); + + Assert.Equal(Frame(0.2f, 0f), hold.Slot(Newest - 1).ToArray()); + Assert.Equal(Frame(0f, 0.4f), hold.Slot(Newest).ToArray()); + Assert.Equal(0.2f, hold.SlotLevel(Newest - 1)); + Assert.Equal(0.4f, hold.SlotLevel(Newest)); + } + + [Fact] + public void 시간을_조금씩_흘려도_칸_길이마다_넘어간다() + { + var hold = new LateDangerHold(); + hold.Keep(Frame(0.5f, 0.5f), 0.5f, Threshold); + hold.Advance(0.5); + hold.Advance(0.5); + hold.Advance(0.5); + Assert.Equal(0.5f, hold.SlotLevel(Current)); // 1.5 프레임: 아직 지금 칸 + hold.Advance(0.5); + Assert.Equal(0.5f, hold.SlotLevel(Newest)); // 2 프레임: 링으로 + } + + [Fact] + public void 보관한_소리는_약_1초_뒤_모두_밀려난다() + { + // 지금 칸 + 링 한 바퀴(30칸) = 31칸 × 2프레임 ≈ 1.03초 + var hold = new LateDangerHold(); + hold.Keep(Frame(0.9f, 0.9f), 0.9f, Threshold); + for (int i = 0; i < LateDangerHold.Slots; i++) hold.Advance(LateDangerHold.SlotFrames); + Assert.True(hold.HasData); + Assert.Equal(0.9f, hold.SlotLevel(0)); // 가장 오래된 칸에 남아 있다 + + hold.Advance(LateDangerHold.SlotFrames); + Assert.False(hold.HasData); + for (int n = 0; n <= LateDangerHold.Slots; n++) Assert.Equal(0f, hold.SlotLevel(n)); + } + + [Fact] + public void 비우면_다시_보관할_수_있다() + { + var hold = new LateDangerHold(); + hold.Keep(Frame(0.9f, 0.9f), 0.9f, Threshold); + hold.Advance(5); + hold.Clear(); + Assert.False(hold.HasData); + for (int n = 0; n <= LateDangerHold.Slots; n++) Assert.Equal(0f, hold.SlotLevel(n)); + + hold.Keep(Frame(0.3f, 0f), 0.3f, Threshold); + Assert.True(hold.HasData); + Assert.Equal(0.3f, hold.SlotLevel(Current)); + } +} diff --git a/SoundVisualizer.Tests/Rendering/LateDangerTests.cs b/SoundVisualizer.Tests/Rendering/LateDangerTests.cs new file mode 100644 index 0000000..5437158 --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/LateDangerTests.cs @@ -0,0 +1,254 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Rendering; +using static SoundVisualizer.Tests.Rendering.EngineHarness; + +namespace SoundVisualizer.Tests.Rendering; + +/// +/// 늦게 온 위협음 판정: 숨긴 라벨로 먼저 들어온 짧은 소리(Android VisualizerEngineTest 의 같은 묶음). +/// 깨어 있는 엔진의 경우다. 쉬는 엔진의 경우는 에 있다. +/// +public class LateDangerTests +{ + // 캡처 채널 번호 + private const int FL = 0, FR = 1, BL = 3, BR = 4, SL = 5, SR = 6; + + /// 환경음을 숨기고 위협음만 보이게 한 엔진. 조용한 프레임 10개를 흘려 둔다. + private static EngineHarness HiddenAmbient() + { + var run = new EngineHarness { Label = SoundType.Ambient, Shown = false }; + run.Advance(10); + return run; + } + + [Fact] + public void 숨긴_라벨로_지나간_짧은_소리는_늦게_온_위협음_판정에_제때_표시했을_크기로_커진다() + { + // 총성 두 프레임 뒤로는 약한 잔향만 남는다. 아직 라벨은 숨긴 환경음이다. + var run = HiddenAmbient(); + run.Stereo(0.9f, 0.9f); + run.Advance(2); + run.Stereo(0.03f, 0.03f); + run.Advance(20); + double before = run.Engine.SmoothTotal; + + // 약 0.36초 뒤 위협음 판정이 온다. + run.BecomeDanger(); + run.Tick(); + + // 위협음을 제때 표시했다면 그렸을 크기로 다시 커진다. 보관한 0.9 를 그대로 전체 크기로 쓰면 소리가 1초 넘게 + // 이어질 때의 크기라 짧은 총성마다 크기 한도까지 뛴다. + double onTime = OnTimePeakSmoothTotal(0.9f, 2, 0.03f, 20); + double seeded = run.Engine.SmoothTotal; + Assert.True(seeded > before * 1.2, $"보관한 크기로 커지지 않았다 ({before} -> {seeded})"); + Assert.InRange(seeded, onTime * 0.8, onTime * 1.2); + } + + [Fact] + public void 제때_표시해도_안_보일_작은_위협음도_늦게_온_판정에서는_보이게_그린다() + { + // 전제: 0.1 짜리 짧은 소리는 위협음을 표시 중이어도 파도가 보이는 깊이(3)에 못 미친다. + var onTime = new EngineHarness { Label = SoundType.Danger, Shown = true }; + double onTimeDeepest = 0; + for (int n = 0; n < 32; n++) + { + float v = n < 2 ? 0.1f : 0f; + onTime.Stereo(v, v); + onTime.Tick(); + onTimeDeepest = Math.Max(onTimeDeepest, onTime.DeepestDepth); + } + Assert.True(onTimeDeepest < VisualizerEngine.WaveActiveDepth, $"전제가 틀렸다: 제때 표시해도 보이는 크기다 ({onTimeDeepest})"); + + // 판정까지 난 위협음은 안 보이면 안 되므로, 늦게 온 판정에서는 보이는 깊이까지 올린다. + var run = HiddenAmbient(); + run.Stereo(0.1f, 0.1f); + run.Advance(2); + run.Stereo(0f, 0f); + run.Advance(10); + + run.BecomeDanger(); + run.Advance(4); + Assert.True(run.DeepestDepth > VisualizerEngine.WaveActiveDepth, $"판정까지 난 작은 위협음이 보이지 않는다 ({run.DeepestDepth})"); + } + + [Fact] + public void 크기_고정_모드는_전체_크기를_최소_깊이까지_올리지_않는다() + { + // 크기 고정 모드에서 전체 크기는 깊이가 아니라 진하기다. 보관한 소리가 제때 그렸을 크기로만 되돌린다. + var settings = new VisualModeSettings { IntensityAsOpacity = true }; + var run = new EngineHarness { Settings = settings, Label = SoundType.Ambient, Shown = false }; + run.Advance(10); + run.Stereo(0.1f, 0.1f); + run.Advance(2); + run.Stereo(0f, 0f); + run.Advance(10); + run.BecomeDanger(); + run.Tick(); + + double onTime = OnTimePeakSmoothTotal(0.1f, 2, 0f, 10); + Assert.InRange(run.Engine.SmoothTotal, onTime * 0.8, onTime * 1.2); + } + + [Fact] + public void 위협음_판정이_소리가_끝난_뒤에_와도_보관한_방향으로_보인다() + { + // 왼쪽에서 난 짧은 소리. 판정이 올 때는 완전히 조용하다. + var run = HiddenAmbient(); + run.Peaks[FL] = 0.6f; run.Peaks[SL] = 0.6f; run.Peaks[BL] = 0.6f; + run.Peaks[FR] = 0.2f; run.Peaks[SR] = 0.2f; run.Peaks[BR] = 0.2f; + run.Tick(); + run.Silence(); + run.Advance(30); + + run.BecomeDanger(); + run.Advance(3); + var d = run.Engine.Depths; + Assert.True(d[6] > VisualizerEngine.WaveActiveDepth, $"좌측이 보이지 않는다 ({d[6]})"); + Assert.True(d[6] > d[2], $"좌측({d[6]}) 이 우측({d[2]}) 보다 깊어야 한다"); + } + + [Fact] + public void 위협음_라벨이어도_숨겨서_놓친_소리가_없으면_보통처럼_서서히_커진다() + { + var run = new EngineHarness { Label = SoundType.Danger, Shown = true }; + run.Advance(10); + run.Stereo(0.9f, 0.9f); + run.Tick(); + + var fresh = new EngineHarness { Label = SoundType.Danger, Shown = true }; + fresh.Stereo(0.9f, 0.9f); + fresh.Tick(); + + Assert.Equal(fresh.Engine.SmoothTotal, run.Engine.SmoothTotal, 9); + Assert.True(run.Engine.SmoothTotal < 0.5, $"보통의 소리가 한 번에 커졌다 ({run.Engine.SmoothTotal})"); + } + + [Fact] + public void 숨긴_소리가_끝나고_위협음이_아닌_라벨로_돌아오면_보관한_피크를_쓰지_않는다() + { + // 대화음을 숨긴 채 말소리가 지나가고, 끝난 뒤 라벨이 표시 중인 환경음으로 돌아온다. + var run = new EngineHarness { Label = SoundType.Speech, Shown = false }; + run.Advance(10); + run.Stereo(0.7f, 0.7f); + run.Advance(5); + run.Stereo(0f, 0f); + run.Advance(5); + double before = run.Engine.SmoothTotal; + + run.Label = SoundType.Ambient; + run.Shown = true; + run.Tick(); + Assert.True(run.Engine.SmoothTotal < before, $"지나간 말소리가 환경음으로 다시 커졌다 ({before} -> {run.Engine.SmoothTotal})"); + } + + [Fact] + public void 한_번_쓴_소리로는_다시_커지지_않는다() + { + var run = HiddenAmbient(); + run.Stereo(0.9f, 0.9f); + run.Tick(); + run.Stereo(0f, 0f); + run.Advance(10); + + run.BecomeDanger(); + run.Tick(); + double released = run.Engine.SmoothTotal; + + // 라벨이 숨김과 위협음 사이를 오가도 새 소리가 없으면 줄어들기만 한다. + double previous = released; + for (int n = 0; n < 30; n++) + { + run.Shown = n % 10 != 5; + run.Tick(); + Assert.True(run.Engine.SmoothTotal <= previous, $"프레임 {n}: 이미 그린 소리로 다시 커졌다"); + previous = run.Engine.SmoothTotal; + } + } + + [Theory] + [InlineData(30.0)] + [InlineData(60.0)] + [InlineData(144.0)] + public void 보관한_피크는_최대_프레임과_상관없이_약_1초_뒤_사라진다(double fps) + { + // 깨어 있는 엔진은 보이는 것 없이 1초가 지나면 쉰다. 1초 넘게 깨어 있는 것은 오버레이 편집 중일 때다. + bool SeedsAfter(double quietSeconds) + { + var run = new EngineHarness { Label = SoundType.Ambient, Shown = false, KeepAwake = true }; + run.Advance(10, 1000.0 / fps); + run.Stereo(0.9f, 0.9f); + run.Tick(1000.0 / fps); + run.Stereo(0f, 0f); + run.Advance((int)Math.Round(quietSeconds * fps), 1000.0 / fps); + double before = run.Engine.SmoothTotal; + run.BecomeDanger(); + run.Tick(1000.0 / fps); + return run.Engine.SmoothTotal > before; + } + + Assert.True(SeedsAfter(0.8), "0.8초 전 소리를 벌써 잊었다"); + Assert.False(SeedsAfter(1.2), "1.2초 전 소리를 아직 쓴다"); + } + + [Fact] + public void 그릴_수_없는_설정일_때_지나간_소리는_보관하지_않는다() + { + // 진하기 0 이면 판정이 와도 보일 것이 없다. 그사이 지나간 소리를 나중에 진하기를 올린 뒤 그리지 않는다. + var settings = new VisualModeSettings { VisualOpacity = 0 }; + var run = new EngineHarness { Settings = settings, Label = SoundType.Ambient, Shown = false }; + run.Advance(10); + run.Stereo(0.9f, 0.9f); + run.Tick(); + run.Stereo(0f, 0f); + run.Advance(10); + double before = run.Engine.SmoothTotal; + + settings.VisualOpacity = 50; + run.BecomeDanger(); + run.Tick(); + Assert.True(run.Engine.SmoothTotal < before); + } + + [Theory] + [InlineData(0.0, 50.0, 30.0, false, false)] // 진하기 0 + [InlineData(50.0, 0.0, 30.0, false, false)] // 크기 0 + [InlineData(50.0, 50.0, 30.0, false, true)] + [InlineData(0.0, 0.0, 30.0, true, true)] // 크기 고정은 고정 크기와 최대 진하기를 본다 + [InlineData(50.0, 50.0, 0.0, true, false)] // 고정 크기 0 + public void 그릴_수_있는지는_진하기와_크기로_정한다(double opacity, double intensity, double fixedSize, bool sizeLocked, bool expected) + { + var s = new VisualModeSettings + { + VisualOpacity = opacity, + Intensity = intensity, + OpacityFixedSize = fixedSize, + IntensityAsOpacity = sizeLocked, + OpacityFixedMaxOpacity = 100, + }; + Assert.Equal(expected, VisualizerEngine.CanDraw(s)); + } + + [Fact] + public void 크기_고정_모드는_최대_진하기가_0이면_그릴_수_없다() + { + var s = new VisualModeSettings { IntensityAsOpacity = true, OpacityFixedMaxOpacity = 0, VisualOpacity = 100 }; + Assert.False(VisualizerEngine.CanDraw(s)); + } + + [Fact] + public void 소리_크기는_사운드_모드가_그리는_채널만_본다() + { + float[] lfeOnly = { 0, 0, 0, 0, 0, 0, 0, 0.9f }; + float[] centerOnly = { 0, 0, 0.5f, 0, 0, 0, 0, 0 }; + float[] rearRight = { 0.1f, 0.2f, 0, 0, 0.7f, 0, 0, 0 }; + + Assert.Equal(0f, VisualizerEngine.Loudness(lfeOnly, 2)); // LFE 는 자리가 없다 + Assert.Equal(0.5f, VisualizerEngine.Loudness(centerOnly, 1)); + Assert.Equal(0f, VisualizerEngine.Loudness(centerOnly, 0)); // 2.0 은 앞 좌우만 + Assert.Equal(0.7f, VisualizerEngine.Loudness(rearRight, 2)); + Assert.Equal(0.2f, VisualizerEngine.Loudness(rearRight, 0)); + } +} diff --git a/SoundVisualizer.Tests/Rendering/OverlayIdleTests.cs b/SoundVisualizer.Tests/Rendering/OverlayIdleTests.cs new file mode 100644 index 0000000..a070073 --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/OverlayIdleTests.cs @@ -0,0 +1,449 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Rendering; +using static SoundVisualizer.Tests.Rendering.EngineHarness; + +namespace SoundVisualizer.Tests.Rendering; + +/// +/// 보이는지, 쉬기와 깨어나기, 쉬는 동안 늦게 온 위협음 판정(Android VisualizerEngineTest 의 같은 묶음을 PC 값으로 옮겼다). +/// +public class OverlayIdleTests +{ + // 캡처 채널 번호 + private const int FL = 0, FR = 1, BL = 3, BR = 4, SL = 5, SR = 6, LFE = 7; + + /// 환경음을 숨기고 위협음만 보이게 한 채, 조용해서 쉬기로 내려간 엔진. + private static EngineHarness HiddenAmbientIdle() + { + var run = new EngineHarness { Label = SoundType.Ambient, Shown = false }; + run.TickUntilIdle(); + return run; + } + + // --------------------------------------------------------------- + // 보이는지 + // --------------------------------------------------------------- + + [Fact] + public void 네_모드_모두_큰_소리에서_보인다() + { + foreach (int mode in new[] { VisualizerEngine.ModeWave, VisualizerEngine.ModePad, VisualizerEngine.ModeCircle, VisualizerEngine.ModeOutline }) + { + var run = new EngineHarness { VisualMode = mode, Settings = new VisualModeSettings { Sensitivity = 25 } }; + run.Stereo(1f, 0.2f); + run.Advance(200); + Assert.True(run.Engine.Visible, $"모드 {mode} 가 큰 소리에서 보이지 않는다"); + } + } + + [Fact] + public void 무음에서는_보이지_않는다() + { + var run = new EngineHarness(); + run.Advance(10); + Assert.False(run.Engine.Visible); + } + + [Fact] + public void 표시를_끈_라벨은_보이지_않는다() + { + var run = new EngineHarness { Settings = new VisualModeSettings { Sensitivity = 25 } }; + run.Stereo(1f, 1f); + run.Advance(120); + Assert.True(run.Engine.Visible, "소리가 크면 보여야 한다"); + + run.Shown = false; + run.Tick(); + Assert.False(run.Engine.Visible, "표시를 끄면 안 보여야 한다"); + } + + [Fact] + public void 진하기_설정이_알파에_그대로_반영된다() + { + // 값이 클수록 진해야 한다. 1 - 진하기 로 뒤집히면 이 테스트가 깨진다. + var run = new EngineHarness { Settings = new VisualModeSettings { VisualOpacity = 50 } }; + run.Tick(); + Assert.Equal(0.5, run.Engine.Alpha, 9); + + run.Settings.VisualOpacity = 100; + run.Tick(); + Assert.Equal(1.0, run.Engine.Alpha, 9); + } + + [Fact] + public void 크기_고정_모드의_알파는_최대_진하기와_소리_크기를_따른다() + { + var run = new EngineHarness + { + Settings = new VisualModeSettings { IntensityAsOpacity = true, OpacityFixedMaxOpacity = 100, Sensitivity = 25, VisualOpacity = 10 }, + }; + run.Tick(); + Assert.Equal(0.0, run.Engine.Alpha, 9); // 무음 + + run.All(1f); + run.Advance(300); + Assert.True(run.Engine.Alpha > 0.5, $"소리가 크면 알파가 올라야 한다 ({run.Engine.Alpha})"); + } + + // --------------------------------------------------------------- + // 쉬기와 깨어나기 + // --------------------------------------------------------------- + + [Fact] + public void 아무것도_보이지_않은_지_1초가_지나고_조용하면_쉰다() + { + var run = new EngineHarness(); + int frames = run.TickUntilIdle(); + // 1초 = 60 프레임. 그보다 이르게 내려가면 안 된다. + Assert.True(frames >= 60, $"너무 빨리 쉬었다 (frames={frames})"); + Assert.False(run.Engine.Visible); + } + + [Theory] + [InlineData(30.0)] + [InlineData(60.0)] + [InlineData(144.0)] + [InlineData(240.0)] + public void 쉬기까지_걸리는_시간은_최대_프레임과_상관없다(double fps) + { + double interval = 1000.0 / fps; + var run = new EngineHarness(); + int frames = run.TickUntilIdle(interval); + double elapsed = frames * interval; + Assert.InRange(elapsed, VisualizerEngine.IdleAfterMs, VisualizerEngine.IdleAfterMs + 2 * interval + 0.001); + } + + [Fact] + public void 보이는_동안에는_쉬지_않는다() + { + var run = new EngineHarness(); + run.All(0.5f); + run.Advance(600); + Assert.True(run.Engine.Visible); + Assert.False(run.Engine.IsIdle); + } + + [Fact] + public void 편집_중에는_조용해도_쉬지_않는다() + { + var run = new EngineHarness { KeepAwake = true }; + run.Advance(300); + Assert.False(run.Engine.IsIdle); + + run.KeepAwake = false; + run.TickUntilIdle(); + } + + [Fact] + public void 편집을_시작하면_소리가_없어도_깨어난다() + { + var run = new EngineHarness(); + run.TickUntilIdle(); + run.Engine.Resume(); + Assert.False(run.Engine.IsIdle); + } + + [Fact] + public void 쉬는_중_소리가_나면_깨어난다() + { + var run = new EngineHarness(); + run.TickUntilIdle(); + run.Stereo(0.5f, 0f); + Assert.True(run.Poll(), "소리가 오면 프레임마다 그리기로 돌아가야 한다"); + Assert.False(run.Engine.IsIdle); + } + + [Fact] + public void 쉬는_중_조용하면_그대로_쉰다() + { + var run = new EngineHarness(); + run.TickUntilIdle(); + Assert.False(run.Poll()); + Assert.True(run.Engine.IsIdle); + + run.Buffers = false; // 캡처가 아예 버퍼를 보내지 않아도 마찬가지다 + Assert.False(run.Poll()); + Assert.True(run.Engine.IsIdle); + } + + [Fact] + public void 깨어난_첫_프레임은_쉰_시간만큼_건너뛰지_않는다() + { + // 위협음을 보이는 중에 조용해서 쉬다가 10초 뒤 소리가 난다. 처음 켠 엔진이 같은 소리를 첫 프레임에 받았을 때와 같아야 한다. + var run = new EngineHarness { Label = SoundType.Danger, Shown = true }; + run.TickUntilIdle(); + run.Stereo(0.9f, 0.9f); + Assert.True(run.Poll(10_000)); + run.Tick(); + + var fresh = new EngineHarness { Label = SoundType.Danger, Shown = true }; + fresh.Stereo(0.9f, 0.9f); + fresh.Tick(); + + Assert.Equal(fresh.Engine.SmoothTotal, run.Engine.SmoothTotal, 9); + Assert.True(run.Engine.SmoothTotal < 0.5, $"보통의 깨어남이 한 번에 커졌다 ({run.Engine.SmoothTotal})"); + } + + [Fact] + public void 표시가_꺼진_종류의_소리가_계속_나면_쉰다() + { + // 소리 조건만 보면 그릴 것도 없이 최대 프레임으로 계속 돈다. + var run = new EngineHarness { Shown = false }; + run.Stereo(0.8f, 0.8f); + Assert.True(run.TickUntilIdle() < 120); + } + + [Fact] + public void 진하기가_0이면_소리가_나도_쉰다() + { + var run = new EngineHarness { Settings = new VisualModeSettings { VisualOpacity = 0 } }; + run.Stereo(0.8f, 0.8f); + Assert.True(run.TickUntilIdle() < 120); + } + + [Fact] + public void 자리가_없는_LFE_만_울리면_쉰다() + { + // LFE 는 전체 크기에는 들어가지만 그려지는 자리가 없다. 그것만 울리는 동안 그릴 것도 없이 돌지 않는다. + var run = new EngineHarness { SoundMode = 2 }; + run.Peaks[LFE] = 0.9f; + int frames = run.TickUntilIdle(); + Assert.True(frames < 120, $"LFE 만 울리는데 쉬지 않았다 (frames={frames})"); + } + + [Fact] + public void 크기_고정_모드에서_소리가_잦아들면_보이지_않는_것으로_보고_쉰다() + { + // 크기 고정 모드는 도형이 늘 화면 둘레에 있고 진하기만 소리를 따라 0 으로 다가간다. 진하기 문턱이 없으면 + // 소리가 그친 뒤에도 보이지 않는 도형을 화면 전체 크기로 몇 분 동안 그린다. + var run = new EngineHarness { Settings = new VisualModeSettings { IntensityAsOpacity = true } }; + run.Stereo(0.5f, 0.5f); + run.Advance(60); + Assert.True(run.Engine.Visible); + + run.Silence(); + int frames = run.TickUntilIdle(); + Assert.True(frames < 600, $"10초가 지나도 쉬지 않았다 (frames={frames})"); + } + + [Fact] + public void 쉬는_중_표시_대상이_아닌_소리로는_깨어나지_않는다() + { + var run = new EngineHarness { Shown = false }; + run.Stereo(0.8f, 0.8f); + Assert.True(run.TickUntilIdle() < 120); + + Assert.False(run.Poll()); + Assert.True(run.Engine.IsIdle, "표시가 꺼진 소리에 깨어났다"); + } + + [Fact] + public void 쉬는_중_표시_대상이_되면_깨어난다() + { + var run = new EngineHarness { Shown = false }; + run.Stereo(0.8f, 0.8f); + Assert.True(run.TickUntilIdle() < 120); + + run.Shown = true; + Assert.True(run.Poll(), "표시를 켰는데 깨어나지 않았다"); + } + + // --------------------------------------------------------------- + // 쉬는 동안 소리 신호로 잠들어도 되는지 (#170) + // --------------------------------------------------------------- + + [Fact] + public void 조용한_대기는_한_번_확인한_뒤부터_소리가_날_때까지_잠들어도_된다() + { + var run = new EngineHarness(); + run.TickUntilIdle(); + Assert.False(run.Engine.CanSleepUntilLoud, "쉬기로 내려오자마자 확인도 없이 잠든다"); + + run.Poll(); + Assert.True(run.Engine.CanSleepUntilLoud); + } + + [Fact] + public void 표시를_꺼_둔_소리가_나는_동안에는_잠들지_않고_33ms_확인을_이어_간다() + { + // 잠들면 캡처 스레드가 버퍼마다 깨워서 확인이 오히려 잦아진다. + var run = HiddenAmbientIdle(); + run.Stereo(0.8f, 0.8f); + run.Poll(); + Assert.True(run.Engine.IsIdle); + Assert.False(run.Engine.CanSleepUntilLoud); + } + + [Fact] + public void 소리를_보관하는_동안에는_잠들지_않고_보관이_끝나면_잠든다() + { + // 늦게 올 위협음 판정을 보려면 소리가 끝난 뒤에도 약 1초 동안 확인해야 한다. + var run = HiddenAmbientIdle(); + run.Stereo(0.9f, 0.9f); + run.Poll(); + run.Silence(); + run.Polls(24); // 0.8초 + Assert.False(run.Engine.CanSleepUntilLoud, "보관한 소리가 남았는데 잠든다"); + + run.Polls(12); // 1.2초 + Assert.True(run.Engine.CanSleepUntilLoud, "보관이 끝났는데 계속 확인한다"); + } + + [Fact] + public void 그릴_수_없는_설정에서_소리가_나면_잠들지_않는다() + { + var run = new EngineHarness { Settings = new VisualModeSettings { VisualOpacity = 0 } }; + run.Stereo(0.8f, 0.8f); + Assert.True(run.TickUntilIdle() < 120); + + run.Poll(); + Assert.False(run.Engine.CanSleepUntilLoud); + } + + // --------------------------------------------------------------- + // 쉬는 동안 늦게 온 위협음 판정 + // --------------------------------------------------------------- + + [Fact] + public void 숨긴_라벨로_지나간_짧은_소리는_쉬는_중에도_늦게_온_위협음_판정에_제때_표시했을_크기로_보인다() + { + var run = HiddenAmbientIdle(); + + // 총성 한 번(33ms) 뒤로는 약한 잔향만 남는다. 아직 라벨은 숨긴 환경음이다. + run.Stereo(0.9f, 0.9f); + run.Poll(); + run.Stereo(0.03f, 0.03f); + run.Polls(10); + Assert.True(run.Engine.IsIdle, "숨긴 라벨의 소리에 깨어났다"); + + // 약 0.36초 뒤 위협음 판정이 도착한다. + run.BecomeDanger(); + Assert.True(run.Poll(), "위협음 판정이 왔는데 깨어나지 않았다"); + + // 위협음을 제때 표시했다면 그렸을 크기로 시작한다. 확인 한 번(33ms)은 60fps 두 프레임이다. + double seeded = run.Engine.SmoothTotal; + double onTime = OnTimePeakSmoothTotal(0.9f, 2, 0.03f, 20); + Assert.InRange(seeded, onTime * 0.8, onTime * 1.2); + + run.Advance(3); + // 잔향(0.03)으로 0 에서 자라면 보이지 않는다. + Assert.True(run.Engine.Visible, $"잔향만으로 그려서 보이지 않는다 (smoothTotal={run.Engine.SmoothTotal})"); + } + + [Fact] + public void 제때_표시해도_안_보일_작은_위협음도_쉬는_중_늦게_온_판정에서는_보이게_그린다() + { + var run = HiddenAmbientIdle(); + run.Stereo(0.1f, 0.1f); + run.Poll(); + run.Silence(); + run.Polls(10); + + run.BecomeDanger(); + Assert.True(run.Poll()); + run.Advance(3); + Assert.True(run.Engine.Visible, $"판정까지 난 작은 위협음이 보이지 않는다 (가장 깊은 곳 {run.DeepestDepth})"); + } + + [Fact] + public void 쉬는_중_위협음_판정이_소리가_끝난_뒤에_와도_보관한_방향으로_보인다() + { + var run = HiddenAmbientIdle(); + + // 왼쪽에서 난 짧은 소리. 판정이 올 때는 완전히 조용하다. + run.Peaks[FL] = 0.6f; run.Peaks[SL] = 0.6f; run.Peaks[BL] = 0.6f; + run.Peaks[FR] = 0.2f; run.Peaks[SR] = 0.2f; run.Peaks[BR] = 0.2f; + run.Poll(); + run.Silence(); + run.Polls(15); + + run.BecomeDanger(); + Assert.True(run.Poll(), "소리가 끝났다고 위협음 판정을 무시했다"); + + run.Advance(3); + var d = run.Engine.Depths; + Assert.True(run.Engine.Visible); + Assert.True(d[6] > d[2], $"좌측({d[6]}) 이 우측({d[2]}) 보다 깊어야 한다"); + Assert.True(d[7] > d[1], $"좌상단({d[7]}) 이 우상단({d[1]}) 보다 깊어야 한다"); + } + + [Fact] + public void 쉬는_중_숨긴_소리가_끝나고_위협음이_아닌_라벨로_돌아오면_깨어나지_않는다() + { + // 대화음을 숨긴 채 말소리가 지나가고, 끝난 뒤 라벨이 표시 중인 환경음으로 돌아온다. + var run = new EngineHarness { Label = SoundType.Speech, Shown = false }; + run.TickUntilIdle(); + run.Stereo(0.7f, 0.7f); + run.Polls(5); + run.Silence(); + run.Polls(5); + + run.Label = SoundType.Ambient; + run.Shown = true; + Assert.False(run.Poll(), "지나간 말소리가 환경음으로 그려졌다"); + } + + [Theory] + [InlineData(PollMs)] + [InlineData(20.0)] // 캡처 스레드가 앞당긴 확인이 섞여 촘촘할 때 + [InlineData(47.0)] // 타이머 해상도(15.6ms) 때문에 33ms 가 47ms 로 늘어날 때 + public void 쉬는_중_보관한_피크는_확인_간격과_상관없이_약_1초_뒤_사라진다(double pollMs) + { + bool WakesAfter(double quietMs) + { + var run = HiddenAmbientIdle(); + run.Stereo(0.9f, 0.9f); + run.Poll(pollMs); + run.Silence(); + run.Polls((int)Math.Round(quietMs / pollMs), pollMs); + run.BecomeDanger(); + return run.Poll(pollMs); + } + + Assert.True(WakesAfter(800), "0.8초 전 소리를 벌써 잊었다"); + Assert.False(WakesAfter(1200), "1.2초 전 소리로 깨어났다"); + } + + [Fact] + public void 쉬기로_내려가기_직전에_숨긴_라벨로_받은_소리도_위협음_판정에_쓴다() + { + var run = new EngineHarness { Label = SoundType.Ambient, Shown = false }; + // 보이지 않은 지 1초가 안 되어 아직 프레임마다 그린다. + run.Advance(50); + Assert.False(run.Engine.IsIdle); + + run.Stereo(0.9f, 0.9f); + run.Tick(); + run.Silence(); + run.TickUntilIdle(); + + run.BecomeDanger(); + Assert.True(run.Poll(), "쉬기로 내려가며 직전 소리를 잊었다"); + run.Advance(3); + Assert.True(run.Engine.Visible); + } + + [Fact] + public void 깨어_있는_중_위협음_판정으로_그린_소리로는_쉬다가_다시_깨어나지_않는다() + { + var run = new EngineHarness { Label = SoundType.Ambient, Shown = false }; + run.Advance(10); + run.Stereo(0.9f, 0.9f); + run.Tick(); + run.Silence(); + run.Advance(10); + double beforeLabel = run.Engine.SmoothTotal; + + run.BecomeDanger(); + run.Tick(); + Assert.True(run.Engine.Visible); + Assert.True(run.Engine.SmoothTotal > beforeLabel * 2, $"보관한 크기로 커지지 않았다 ({beforeLabel} -> {run.Engine.SmoothTotal})"); + + // 다 사라지고 쉬기로 내려간 뒤, 라벨이 그대로여도 이미 그린 소리로 다시 깨어나면 안 된다. + run.TickUntilIdle(); + Assert.False(run.Poll(), "이미 그린 소리로 다시 깨어났다"); + } +} diff --git a/SoundVisualizer.Tests/Rendering/SoundWakeSignalTests.cs b/SoundVisualizer.Tests/Rendering/SoundWakeSignalTests.cs new file mode 100644 index 0000000..61b57d5 --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/SoundWakeSignalTests.cs @@ -0,0 +1,91 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Rendering; + +namespace SoundVisualizer.Tests.Rendering; + +/// +/// 쉬는 오버레이를 깨우는 소리 신호(Android SoundWakeSignalTest). 오버레이는 확인 직전에 하고, +/// 캡처 스레드는 버퍼마다 를 부른다. true 를 받은 쪽이 오버레이의 확인을 앞당긴다. +/// +public class SoundWakeSignalTests +{ + private readonly SoundWakeSignal _signal = new(threshold: 0.01f); + + /// 캡처 스레드처럼 다른 스레드에서 버퍼 하나를 넘긴다. + private bool BufferFromCaptureThread(float level) + { + bool woke = false; + var thread = new Thread(() => woke = _signal.OnBuffer(level)); + thread.Start(); + thread.Join(); + return woke; + } + + [Fact] + public void 조용한_버퍼로는_깨지_않는다() + { + _signal.Arm(); + Assert.False(BufferFromCaptureThread(0.005f)); + Assert.False(BufferFromCaptureThread(0.01f)); // 문턱과 같으면 조용한 것이다 + Assert.True(_signal.IsArmed); + } + + [Fact] + public void 큰_소리_버퍼가_오면_한_번만_깨운다() + { + _signal.Arm(); + Assert.True(BufferFromCaptureThread(0.5f)); + // 이미 알렸다. 오버레이가 다시 무장할 때까지 버퍼마다 깨우지 않는다. + Assert.False(BufferFromCaptureThread(0.5f)); + Assert.False(_signal.IsArmed); + } + + [Fact] + public void 무장하기_전에_온_소리로는_깨지_않는다() + { + // 큰 소리 뒤 캡처가 멈춰 마지막 소리 크기가 큰 채로 남았다. 새 버퍼가 없으니 깨우면 안 된다. + Assert.False(BufferFromCaptureThread(0.5f)); + _signal.Arm(); + Assert.True(_signal.IsArmed); + } + + [Fact] + public void 다시_무장하면_다음_큰_소리로_또_깨운다() + { + _signal.Arm(); + Assert.True(BufferFromCaptureThread(0.5f)); + _signal.Arm(); + Assert.True(BufferFromCaptureThread(0.5f)); + } + + [Fact] + public void 무장을_풀면_큰_소리로도_깨우지_않는다() + { + // 표시를 꺼 둔 소리가 나는 동안에는 33ms 확인만 한다. + _signal.Arm(); + _signal.Disarm(); + Assert.False(BufferFromCaptureThread(0.9f)); + } + + [Fact] + public void 여러_스레드가_동시에_큰_소리를_넘겨도_한_번만_깨운다() + { + for (int round = 0; round < 50; round++) + { + _signal.Arm(); + int woke = 0; + using var start = new ManualResetEventSlim(); + var threads = Enumerable.Range(0, 8).Select(_ => new Thread(() => + { + start.Wait(); + if (_signal.OnBuffer(0.5f)) Interlocked.Increment(ref woke); + })).ToList(); + threads.ForEach(t => t.Start()); + start.Set(); + threads.ForEach(t => t.Join()); + Assert.Equal(1, woke); + } + } +} diff --git a/SoundVisualizer.Tests/Rendering/StereoUpmixTests.cs b/SoundVisualizer.Tests/Rendering/StereoUpmixTests.cs new file mode 100644 index 0000000..45fe751 --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/StereoUpmixTests.cs @@ -0,0 +1,263 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Rendering; +using static SoundVisualizer.Tests.Rendering.EngineHarness; + +namespace SoundVisualizer.Tests.Rendering; + +/// 사운드 모드 2.0 의 Mid/Side 펼치기와 뒤쪽 지연(Android VisualizerEngine 의 upmix·공간 리플). +public class StereoUpmixTests +{ + // 둘레 자리 + private const int TopCenter = 0, TopRight = 1, RightCenter = 2, BottomRight = 3, BottomCenter = 4, BottomLeft = 5, LeftCenter = 6, TopLeft = 7; + + private static EngineHarness Stereo2(VisualModeSettings? settings = null) => + new() { SoundMode = 0, Settings = settings ?? new VisualModeSettings { Sensitivity = 15 } }; + + // --------------------------------------------------------------- + // 펼치기 + // --------------------------------------------------------------- + + [Fact] + public void 가운데_소리는_앞에_서고_옆은_비운다() + { + var p = new float[8]; + float total = StereoUpmix.Spread(0.5f, 0.5f, 0.5f, 0.5f, p); + + Assert.Equal(0.6f, p[TopCenter], 6); + Assert.Equal(0.45f, p[TopRight], 6); + Assert.Equal(0.45f, p[TopLeft], 6); + Assert.Equal(0f, p[RightCenter]); + Assert.Equal(0f, p[LeftCenter]); + Assert.Equal(0f, p[BottomRight]); + Assert.Equal(0f, p[BottomLeft]); + Assert.Equal(0.4f, p[BottomCenter], 6); + Assert.Equal(1.9f, total, 5); + } + + [Fact] + public void 한쪽에서만_나는_소리는_그쪽_옆으로만_펼친다() + { + var p = new float[8]; + float total = StereoUpmix.Spread(1f, 0f, 1f, 0f, p); + + Assert.Equal(0.9f, p[TopLeft], 6); + Assert.Equal(1.5f, p[LeftCenter], 6); + Assert.Equal(1.2f, p[BottomLeft], 6); + foreach (int i in new[] { TopCenter, TopRight, RightCenter, BottomRight, BottomCenter }) Assert.Equal(0f, p[i]); + Assert.Equal(3.6f, total, 5); + } + + [Fact] + public void 뒤쪽_자리는_늦은_좌우로_그린다() + { + var p = new float[8]; + StereoUpmix.Spread(0.8f, 0.2f, 0f, 0f, p); + Assert.True(p[LeftCenter] > 0); + Assert.Equal(0f, p[BottomLeft]); + Assert.Equal(0f, p[BottomCenter]); + Assert.Equal(0f, p[BottomRight]); + } + + // --------------------------------------------------------------- + // 뒤쪽 지연 + // --------------------------------------------------------------- + + [Fact] + public void 뒤쪽은_60fps_네_프레임_전의_좌우를_쓴다() + { + // Android 는 5칸 히스토리의 가장 오래된 값, 곧 네 틱 전 값을 쓴다. 60fps 에서는 같아야 한다. + var upmix = new StereoUpmix(); + for (int n = 0; n < 12; n++) + { + upmix.Push(n * Frame60, n, -n, out float rearL, out float rearR); + float expected = n >= 4 ? n - 4 : 0; + Assert.Equal(expected, rearL); + Assert.Equal(-expected, rearR); + } + } + + [Theory] + [InlineData(30.0)] + [InlineData(60.0)] + [InlineData(144.0)] + [InlineData(240.0)] + [InlineData(360.0)] + public void 뒤쪽_지연은_최대_프레임과_상관없이_약_67ms_다(double fps) + { + double interval = 1000.0 / fps; + var upmix = new StereoUpmix(); + double soundAt = 10 * interval; + double rearAt = double.NaN; + for (int n = 0; n < 200 && double.IsNaN(rearAt); n++) + { + double t = n * interval; + float value = t >= soundAt ? 1f : 0f; + upmix.Push(t, value, value, out float rearL, out _); + if (rearL > 0) rearAt = t; + } + double delay = rearAt - soundAt; + Assert.InRange(delay, StereoUpmix.RearDelayMs - 1.01, StereoUpmix.RearDelayMs + interval); + } + + [Fact] + public void 지연만큼_오래된_프레임이_없으면_뒤쪽은_0이다() + { + var upmix = new StereoUpmix(); + upmix.Push(0, 0.9f, 0.9f, out float l, out float r); + Assert.Equal(0f, l); + Assert.Equal(0f, r); + upmix.Push(30, 0.9f, 0.9f, out l, out r); + Assert.Equal(0f, l); + + upmix.Push(70, 0.5f, 0.5f, out l, out r); + Assert.Equal(0.9f, l); // 70ms 전의 값 + + upmix.Clear(); + upmix.Push(5000, 0.5f, 0.5f, out l, out r); + Assert.Equal(0f, l); // 비운 뒤로는 지난 프레임이 없다 + } + + // --------------------------------------------------------------- + // 엔진의 2.0 모드 + // --------------------------------------------------------------- + + [Fact] + public void 왼쪽에서_소리가_나면_왼쪽이_더_깊다() + { + var run = Stereo2(); + run.Stereo(1f, 0.05f); + run.Advance(300); + + var d = run.Engine.Depths; + Assert.True(d[LeftCenter] > d[RightCenter], $"좌측({d[LeftCenter]}) 이 우측({d[RightCenter]}) 보다 깊어야 한다"); + Assert.True(d[TopLeft] > d[TopRight]); + Assert.True(d[BottomLeft] > d[BottomRight]); + } + + [Fact] + public void 좌우가_같으면_좌우가_대칭이다() + { + var run = Stereo2(); + run.Stereo(0.7f, 0.7f); + run.Advance(300); + + var d = run.Engine.Depths; + Assert.Equal(d[LeftCenter], d[RightCenter], 9); + Assert.Equal(d[TopLeft], d[TopRight], 9); + Assert.Equal(d[BottomLeft], d[BottomRight], 9); + } + + [Fact] + public void 가운데_소리는_앞_가운데가_가장_깊고_양옆에는_그리지_않는다() + { + // 예전 2.0 은 좌우를 옆으로 복사해 가운데 소리가 양옆에서 똑같이 그려졌다. 양쪽에서 나는 소리와 구별되지 않았다. + var run = Stereo2(); + run.Stereo(0.6f, 0.6f); + run.Advance(300); + + var d = run.Engine.Depths.ToArray(); + Assert.Equal(d.Max(), d[TopCenter]); + Assert.True(d[TopCenter] > 3); + Assert.True(d[LeftCenter] < 0.01, $"좌측 {d[LeftCenter]}"); + Assert.True(d[RightCenter] < 0.01, $"우측 {d[RightCenter]}"); + } + + [Fact] + public void 한쪽으로_치우친_소리는_반대쪽_옆을_비운다() + { + var run = Stereo2(); + run.Stereo(0.6f, 0.2f); + run.Advance(300); + + var d = run.Engine.Depths; + Assert.True(d[LeftCenter] > 3); + Assert.True(d[RightCenter] < 0.01, $"우측 {d[RightCenter]}"); + Assert.True(d[TopLeft] > d[TopRight] * 2.9, $"좌상단 {d[TopLeft]} / 우상단 {d[TopRight]}"); + } + + [Fact] + public void 뒤쪽은_앞보다_조금_늦게_따라온다() + { + var run = Stereo2(new VisualModeSettings { Sensitivity = 100, PositionSpeed = 100 }); + run.Stereo(0.5f, 0.5f); + run.Advance(2); // 33ms + Assert.True(run.Engine.Depths[TopCenter] > 0); + Assert.Equal(0, run.Engine.Depths[BottomCenter]); + + run.Advance(4); // 100ms + Assert.True(run.Engine.Depths[BottomCenter] > 0); + } + + [Fact] + public void 앞_좌우_말고는_보지_않는다() + { + // 2.0 에서 그리는 것은 앞 좌우뿐이다. 다른 채널만 울리면 그릴 것이 없고 쉰다. + var run = Stereo2(); + run.Peaks[2] = 0.9f; // FC + run.Peaks[5] = 0.9f; // SL + run.Peaks[7] = 0.9f; // LFE + run.Advance(30); + Assert.All(run.Engine.Depths.ToArray(), v => Assert.Equal(0, v)); + Assert.False(run.Engine.Visible); + run.TickUntilIdle(); + } + + [Fact] + public void 사운드_모드를_바꾸면_방향_분포를_고르게_놓고_새로_따라간다() + { + // 5.1·7.1 의 분포는 채널 순서, 2.0 의 분포는 둘레 자리 순서라 그대로 이어 쓰면 바꾼 직후 엉뚱한 자리가 깊어진다. + var run = new EngineHarness { SoundMode = 2, Settings = new VisualModeSettings { Sensitivity = 15 } }; + run.Peaks[0] = 1f; // FL + run.Advance(300); + Assert.True(run.Engine.Distribution[0] > 0.9); // 채널 순서의 FL + + run.SoundMode = 0; + run.Stereo(1f, 0f); + run.Tick(); + var d = run.Engine.Depths; + Assert.True(d[LeftCenter] > d[TopCenter], $"바꾼 직후 위 가운데({d[TopCenter]})가 왼쪽({d[LeftCenter]}) 보다 깊다"); + } + + [Fact] + public void 쉬다가_깨어나면_뒤쪽_지연을_새로_시작한다() + { + // 숨긴 종류의 소리가 나는 채로 쉬면 지연 히스토리에 그 소리가 남는다. 표시를 켜 깨어난 첫 프레임에 1초 넘게 지난 + // 그 소리로 뒤쪽을 그리면 안 된다. + var run = Stereo2(new VisualModeSettings { Sensitivity = 100, PositionSpeed = 100 }); + run.Shown = false; + run.Stereo(0.5f, 0.5f); + run.TickUntilIdle(); + + run.Shown = true; + Assert.True(run.Poll()); + run.Tick(); + Assert.Equal(0, run.Engine.Depths[BottomCenter]); + Assert.True(run.Engine.Depths[TopCenter] > 0); + + run.Advance(5); // 지연(약 67ms)이 지나면 뒤쪽도 그린다 + Assert.True(run.Engine.Depths[BottomCenter] > 0); + } + + [Fact] + public void 늦게_온_위협음_판정에_제때_표시했을_크기로_보인다() + { + // Android 와 같은 소리(좌우 0.9 한 번, 잔향 0.03)로, 2.0 의 펼치기를 거친 크기를 견준다. + var run = new EngineHarness { SoundMode = 0, Label = SoundType.Ambient, Shown = false }; + run.TickUntilIdle(); + run.Stereo(0.9f, 0.9f); + run.Poll(); + run.Stereo(0.03f, 0.03f); + run.Polls(10); + + run.BecomeDanger(); + Assert.True(run.Poll()); + double onTime = OnTimePeakSmoothTotal(0.9f, 2, 0.03f, 20, soundMode: 0); + Assert.InRange(run.Engine.SmoothTotal, onTime * 0.8, onTime * 1.2); + + run.Advance(3); + Assert.True(run.Engine.Visible); + } +} diff --git a/SoundVisualizer.Tests/Rendering/VisualizerEngineTests.cs b/SoundVisualizer.Tests/Rendering/VisualizerEngineTests.cs new file mode 100644 index 0000000..2593706 --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/VisualizerEngineTests.cs @@ -0,0 +1,306 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Rendering; +using static SoundVisualizer.Tests.Rendering.EngineHarness; + +namespace SoundVisualizer.Tests.Rendering; + +/// 오버레이 엔진의 스무딩·깊이(Android VisualizerEngineTest 의 해당 부분을 PC 값으로 옮겼다). +public class VisualizerEngineTests +{ + // 캡처 채널 번호 + private const int FL = 0, FR = 1, FC = 2, BL = 3, BR = 4, SL = 5, SR = 6; + + // --------------------------------------------------------------- + // 피크 유지와 감쇠 + // --------------------------------------------------------------- + + [Fact] + public void 새_버퍼가_오면_전체_크기가_올라간다() + { + var run = new EngineHarness(); + run.All(0.9f); + run.Advance(30); + Assert.True(run.Engine.SmoothTotal > 0); + } + + [Fact] + public void 버퍼가_끊기면_전체_크기가_줄어든다() + { + var run = new EngineHarness(); + run.All(0.9f); + run.Advance(30); + double loud = run.Engine.SmoothTotal; + + run.Buffers = false; + run.Advance(60); + Assert.True(run.Engine.SmoothTotal < loud, $"{run.Engine.SmoothTotal} < {loud}"); + } + + // --------------------------------------------------------------- + // 시간 정규화: 프레임 간격이 달라도 같은 시간에 같은 결과 + // --------------------------------------------------------------- + + [Fact] + public void 프레임_간격이_두_배여도_같은_시간_뒤_스무딩_결과가_같다() + { + var settings = new VisualModeSettings { Sensitivity = 10, PositionSpeed = 30 }; + + var fast = new EngineHarness { Settings = settings }; + fast.Peaks[FL] = 0.8f; + fast.Peaks[FR] = 0.4f; + fast.Peaks[SL] = 0.3f; + // 첫 프레임은 둘 다 1 프레임이다. 그 뒤로 fast 는 1 프레임씩 20번, slow 는 2 프레임씩 10번이라 + // 둘 다 60fps 기준 21 프레임 분량이다. + fast.Advance(21, Frame60); + + var slow = new EngineHarness { Settings = settings }; + fast.Peaks.CopyTo(slow.Peaks, 0); + slow.Advance(11, Frame60 * 2); + + double a = fast.Engine.SmoothTotal, b = slow.Engine.SmoothTotal; + Assert.True(a > 0, "스무딩이 진행되지 않았다"); + Assert.Equal(a, b, 9); + for (int i = 0; i < VisualizerEngine.Channels; i++) + { + Assert.Equal(fast.Engine.Distribution[i], slow.Engine.Distribution[i], 9); + } + for (int p = 0; p < VisualizerEngine.Positions; p++) + { + Assert.Equal(fast.Engine.Depths[p], slow.Engine.Depths[p], 6); + } + } + + [Fact] + public void 버퍼가_끊긴_뒤_줄어드는_빠르기는_최대_프레임과_상관없다() + { + float Remaining(double fps) + { + var run = new EngineHarness(); + run.All(0.5f); + run.Advance(10, 1000.0 / fps); + run.Buffers = false; + run.Advance((int)fps, 1000.0 / fps); // 1초 + return run.Engine.TotalTarget; + } + + // 1초 = 60fps 60 프레임 → 0.87^60. 채널 값이 float 라 곱할 때마다 생기는 반올림만큼은 봐준다. + double expected = 8 * 0.5 * Math.Pow(0.87, 60); + foreach (double fps in new[] { 30.0, 60.0, 144.0, 240.0 }) + { + Assert.InRange(Remaining(fps), expected * (1 - 1e-4), expected * (1 + 1e-4)); + } + } + + [Fact] + public void 패드와_원형은_최대_프레임과_상관없이_같은_시간에_같은_크기가_된다() + { + // 민감도·속도를 100 으로 두어 깊이가 첫 프레임부터 고정되게 하고, 패드·원형의 따라붙기만 본다. + var settings = new VisualModeSettings { Sensitivity = 100, PositionSpeed = 100 }; + foreach (int mode in new[] { VisualizerEngine.ModePad, VisualizerEngine.ModeCircle }) + { + EngineHarness RunFor(double fps, int ticks) + { + var run = new EngineHarness { Settings = settings, VisualMode = mode }; + run.Peaks[FL] = 0.6f; + run.Peaks[SL] = 0.3f; + run.Peaks[FC] = 0.1f; + run.Advance(ticks, 1000.0 / fps); + return run; + } + + // 첫 프레임 + 0.5초: 30fps 는 1 + 15×2, 144fps 는 1 + 72×(60/144), 둘 다 31 프레임 분량 + var slow = RunFor(30, 16); + var fast = RunFor(144, 73); + var slowValues = mode == VisualizerEngine.ModePad ? slow.Engine.PadThickness : slow.Engine.CircleDepths; + var fastValues = mode == VisualizerEngine.ModePad ? fast.Engine.PadThickness : fast.Engine.CircleDepths; + Assert.True(slowValues[7] > 1, $"모드 {mode}: 움직이지 않았다"); + for (int p = 0; p < VisualizerEngine.Positions; p++) + { + Assert.Equal(slowValues[p], fastValues[p], 6); + } + } + } + + [Fact] + public void 애니메이션_시각은_흐른_시간만큼_간다() + { + // 첫 프레임 + 1초 = 61 프레임 분량. 보이는 것 없이 1초가 지나면 쉬므로 소리를 넣어 둔다. + var slow = new EngineHarness(); + slow.All(0.5f); + slow.Advance(31, 1000.0 / 30); + var fast = new EngineHarness(); + fast.All(0.5f); + fast.Advance(121, 1000.0 / 120); + + Assert.Equal(VisualizerEngine.AnimationStep * 61, slow.Engine.AnimationTime, 9); + Assert.Equal(VisualizerEngine.AnimationStep * 61, fast.Engine.AnimationTime, 9); + } + + [Fact] + public void 화면이_60fps_면_예전처럼_프레임마다_계수를_그대로_곱한다() + { + // 시간 정규화 전 MainWindow 의 계산을 그대로 옮긴 것과 견준다. 60fps 화면에서 보이는 모양이 바뀌면 안 된다. + var settings = new VisualModeSettings { Sensitivity = 3.75, PositionSpeed = 20 }; + var run = new EngineHarness { Settings = settings, KeepAwake = true }; // 소리가 그친 뒤의 계산도 견주게 쉬지 않는다 + var legacy = new LegacyFrameLoop(); + var rnd = new Random(60); + + for (int frame = 0; frame < 240; frame++) + { + bool buffer = frame < 90 || rnd.Next(3) == 0; + if (buffer) + { + for (int i = 0; i < VisualizerEngine.Channels; i++) run.Peaks[i] = frame < 90 ? (float)rnd.NextDouble() : 0; + } + run.Buffers = buffer; + run.Tick(); + legacy.Frame(buffer ? run.Peaks : null, settings); + + Assert.Equal(legacy.SmoothTotal, run.Engine.SmoothTotal, 9); + for (int i = 0; i < VisualizerEngine.Channels; i++) Assert.Equal(legacy.Dist[i], run.Engine.Distribution[i], 9); + } + } + + /// 시간 정규화 전 MainWindow.RenderFrame 의 스무딩. 버퍼가 오면 바꾸고, 그린 뒤 프레임마다 0.87 을 곱했다. + private sealed class LegacyFrameLoop + { + private readonly float[] _targets = new float[8]; + public double SmoothTotal; + public readonly double[] Dist = { 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125 }; + + public void Frame(float[]? peaks, VisualModeSettings s) + { + if (peaks != null) peaks.CopyTo(_targets, 0); + float total = 0; + for (int i = 0; i < 8; i++) total += _targets[i]; + float sfTremor = (float)(Math.Max(0.1, s.Sensitivity) / 100.0); + if (sfTremor > 1.0f) sfTremor = 1.0f; + SmoothTotal += (total - SmoothTotal) * sfTremor; + float sfPosition = (float)(Math.Max(0.1, s.PositionSpeed) / 100.0); + if (sfPosition > 1.0f) sfPosition = 1.0f; + if (total > 0.0001f) + { + for (int i = 0; i < 8; i++) Dist[i] += ((_targets[i] / total) - Dist[i]) * sfPosition; + } + for (int i = 0; i < 8; i++) _targets[i] *= 0.87f; + } + } + + // --------------------------------------------------------------- + // 깊이 + // --------------------------------------------------------------- + + [Fact] + public void 크기_100이면_baseDepth_가_화면_중앙_한계선까지_닿는다() + { + var run = new EngineHarness { Settings = new VisualModeSettings { Intensity = 100 } }; + run.Tick(); + Assert.Equal(MaxBase, run.Engine.BaseDepth, 9); + } + + [Fact] + public void baseDepth_는_크기_설정에_비례한다() + { + var run = new EngineHarness { Settings = new VisualModeSettings { Intensity = 50 } }; + run.Tick(); + Assert.Equal(MaxBase / 2, run.Engine.BaseDepth, 9); + } + + [Fact] + public void 크기_고정_모드는_고정_크기의_절반을_쓴다() + { + var run = new EngineHarness + { + Settings = new VisualModeSettings { IntensityAsOpacity = true, OpacityFixedSize = 30, Intensity = 100 }, + }; + run.Tick(); + Assert.Equal(MaxBase * 0.15, run.Engine.BaseDepth, 9); + } + + [Fact] + public void 깊이는_baseDepth_를_넘지_않는다() + { + // 아주 큰 소리를 길게 넣어 스무딩이 포화되게 만든다. 민감도 25 는 편집 패널의 100 이다. + foreach (int soundMode in new[] { 0, 1, 2 }) + { + var run = new EngineHarness { Settings = new VisualModeSettings { Intensity = 100, Sensitivity = 25 }, SoundMode = soundMode }; + run.All(1f); + run.Advance(600); + foreach (double d in run.Engine.Depths) + { + Assert.InRange(d, 0, run.Engine.BaseDepth + 0.001); + } + } + } + + [Fact] + public void 무음에서는_깊이가_없다() + { + var run = new EngineHarness(); + run.Advance(10); + Assert.All(run.Engine.Depths.ToArray(), d => Assert.Equal(0, d)); + } + + [Fact] + public void 민감도가_높으면_더_빨리_반응한다() + { + double SmoothAfter(double sensitivity) + { + var run = new EngineHarness { Settings = new VisualModeSettings { Sensitivity = sensitivity } }; + run.All(1f); + run.Advance(10); + return run.Engine.SmoothTotal; + } + Assert.True(SmoothAfter(20) > SmoothAfter(1.25)); + } + + [Fact] + public void 서라운드에서_왼쪽_채널이_크면_왼쪽_자리가_더_깊다() + { + var run = new EngineHarness { Settings = new VisualModeSettings { Sensitivity = 15 } }; + run.Peaks[FL] = 1f; + run.Peaks[SL] = 1f; + run.Peaks[BL] = 1f; + run.Peaks[FR] = 0.05f; + run.Peaks[SR] = 0.05f; + run.Peaks[BR] = 0.05f; + run.Advance(300); + + var d = run.Engine.Depths; + Assert.True(d[7] > d[1], $"좌상단 {d[7]} > 우상단 {d[1]}"); + Assert.True(d[6] > d[2], $"좌측 {d[6]} > 우측 {d[2]}"); + Assert.True(d[5] > d[3], $"좌하단 {d[5]} > 우하단 {d[3]}"); + } + + [Fact] + public void 패드와_원형은_그리는_모드에서만_움직인다() + { + var run = new EngineHarness { VisualMode = VisualizerEngine.ModeWave }; + run.All(1f); + run.Advance(60); + Assert.All(run.Engine.PadThickness.ToArray(), t => Assert.Equal(0, t)); + Assert.All(run.Engine.CircleDepths.ToArray(), t => Assert.Equal(0, t)); + + run.VisualMode = VisualizerEngine.ModePad; + run.Advance(60); + Assert.Contains(run.Engine.PadThickness.ToArray(), t => t > 0.5); + Assert.All(run.Engine.CircleDepths.ToArray(), t => Assert.Equal(0, t)); + } + + [Fact] + public void 패드_두께는_55에서_자른다() + { + // 크기 100 의 상단 가운데 깊이는 530 이라 그 0.25 배(132.5)가 한도를 넘는다. + var pad = new EngineHarness + { + Settings = new VisualModeSettings { Intensity = 100, Sensitivity = 100, PositionSpeed = 100 }, + VisualMode = VisualizerEngine.ModePad, + }; + pad.Peaks[FC] = 1f; + pad.Advance(300); + Assert.Equal(MaxBase, pad.Engine.Depths[0], 6); + Assert.Equal(VisualizerEngine.PadMaxThickness, pad.Engine.PadThickness[0], 6); + } +} diff --git a/SoundVisualizer.Tests/Rendering/WaveShapeTests.cs b/SoundVisualizer.Tests/Rendering/WaveShapeTests.cs new file mode 100644 index 0000000..4cb4ac3 --- /dev/null +++ b/SoundVisualizer.Tests/Rendering/WaveShapeTests.cs @@ -0,0 +1,244 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Rendering; + +namespace SoundVisualizer.Tests.Rendering; + +/// 파도·외곽선의 안쪽 곡선 점(WaveVisualizer·OutlineVisualizer 가 쓰던 계산을 Core 로 옮긴 것). +public class WaveShapeTests +{ + /// 16:9 화면. 150 점 가운데 넷(24·51·99·126번)이 네 모서리에 정확히 놓인다(배율 125%·150% 도 같다). + public static readonly TheoryData WideScreens = new() + { + { 1920, 1080 }, { 2560, 1440 }, { 3840, 2160 }, { 1536, 864 }, { 1280, 720 }, + }; + + [Theory] + [MemberData(nameof(WideScreens))] + public void 모서리_자리가_조용해도_점이_모두_화면_안의_수다(double w, double h) + { + // 예전 2.0 처럼 좌우 가운데만 깊고 네 모서리가 0 인 경우와, Mid/Side 의 가운데 소리처럼 아래 모서리만 0 인 경우. + // 예전에는 모서리에 놓인 점이 0/0 으로 NaN 이 되어 파도·외곽선이 통째로 그려지지 않았다. + double[][] cases = + { + new double[] { 0, 0, 80, 0, 0, 0, 80, 0 }, + new double[] { 120, 90, 0, 0, 80, 0, 0, 90 }, + new double[] { 0, 0, 0, 0, 0, 0, 0, 0 }, + }; + foreach (var depths in cases) + { + var shape = new WaveShape(); + shape.Build(w, h, depths, maxDepth: 265); + for (int i = 0; i < WaveShape.Samples; i++) + { + Assert.True(double.IsFinite(shape.X[i]) && double.IsFinite(shape.Y[i]), $"{w}x{h} 점 {i}: ({shape.X[i]}, {shape.Y[i]})"); + Assert.InRange(shape.X[i], 0, w); + Assert.InRange(shape.Y[i], 0, h); + } + } + } + + [Fact] + public void 깊이가_0인_모서리의_점은_화면_모서리에_놓인다() + { + var shape = new WaveShape(); + shape.Build(1920, 1080, new double[] { 0, 0, 80, 0, 0, 0, 80, 0 }, maxDepth: 265); + AssertPoint(1920, 0, shape, 24); // 우상단 + AssertPoint(1920, 1080, shape, 51); // 우하단 + AssertPoint(0, 1080, shape, 99); // 좌하단 + AssertPoint(0, 0, shape, 126); // 좌상단 + } + + private static void AssertPoint(double x, double y, WaveShape shape, int i) + { + Assert.Equal(x, shape.X[i], 9); + Assert.Equal(y, shape.Y[i], 9); + } + + [Fact] + public void 깊이가_있는_모서리는_깊이의_두_배_반경으로_둥글린다() + { + // 모서리 깊이 50 이면 반경 100 의 2차 베지어 한가운데: (w-62.5, 62.5) + var shape = new WaveShape(); + shape.Build(1920, 1080, new double[] { 50, 50, 50, 50, 50, 50, 50, 50 }, maxDepth: 265); + Assert.Equal(1920 - 62.5, shape.X[24], 9); + Assert.Equal(62.5, shape.Y[24], 9); + } + + [Fact] + public void 점은_화면_안에_있고_둥근_모서리를_빼면_기준_깊이보다_깊지_않다() + { + // 모서리는 그 모서리 깊이의 두 배를 반경으로 둥글리므로 그만큼까지 들어올 수 있다(Android 도 같다). + var rnd = new Random(350); + for (int n = 0; n < 200; n++) + { + double w = 800 + rnd.Next(3000), h = 600 + rnd.Next(1600); + double maxDepth = rnd.NextDouble() * (Math.Min(w, h) / 2 - 10); + var depths = Enumerable.Range(0, 8).Select(_ => rnd.NextDouble() < 0.3 ? 0 : rnd.NextDouble() * maxDepth * 1.5).ToArray(); + var shape = new WaveShape(); + shape.Build(w, h, depths, maxDepth); + double rounding = Math.Min(Math.Min(w, h) / 2, 2 * maxDepth); + for (int i = 0; i < WaveShape.Samples; i++) + { + double x = shape.X[i], y = shape.Y[i]; + Assert.True(double.IsFinite(x) && double.IsFinite(y)); + Assert.InRange(x, 0, w); + Assert.InRange(y, 0, h); + double edge = Math.Min(Math.Min(x, w - x), Math.Min(y, h - y)); + Assert.True(edge <= Math.Max(maxDepth, rounding) + 1e-9, $"{w}x{h} 점 {i} 가 가장자리에서 {edge} 들어왔다 (기준 {maxDepth})"); + } + } + } + + [Fact] + public void 모서리_깊이가_0이_아니면_예전_계산과_같다() + { + // Core 로 옮기며 바꾼 것은 반경 0 인 모서리를 건너뛰는 조건뿐이다. 그 밖의 점은 예전 Visualizers 의 계산과 같아야 한다. + var rnd = new Random(24); + foreach (var (w, h) in new[] { (1920.0, 1080.0), (2560.0, 1080.0), (1366.0, 768.0), (1080.0, 1920.0), (1919.0, 1080.0) }) + { + for (int n = 0; n < 50; n++) + { + var depths = Enumerable.Range(0, 8).Select(_ => 0.5 + rnd.NextDouble() * 300).ToArray(); + double maxDepth = 1 + rnd.NextDouble() * 400; + var shape = new WaveShape(); + shape.Build(w, h, depths, maxDepth); + var legacy = LegacyWavePoints(w, h, depths, maxDepth); + for (int i = 0; i < WaveShape.Samples; i++) + { + Assert.Equal(legacy[i].X, shape.X[i]); + Assert.Equal(legacy[i].Y, shape.Y[i]); + } + } + } + } + + // --------------------------------------------------------------- + // 예전 WaveVisualizer(=OutlineVisualizer)의 점 계산. WPF Point 만 튜플로 바꿨다. + // --------------------------------------------------------------- + + private static (double X, double Y)[] LegacyWavePoints(double w, double h, double[] channelDepths, double baseDepth) + { + var channelPos = new double[8]; + double P = 2 * (w + h); + double dist_tr = w / 2.0; + double dist_rc = w / 2.0 + h / 2.0; + double dist_br = w / 2.0 + h; + double dist_bc = w / 2.0 + h + w / 2.0; + double dist_bl = w / 2.0 + h + w; + double dist_lc = w / 2.0 + h + w + h / 2.0; + double dist_tl = w / 2.0 + h + w + h; + channelPos[0] = 0; + channelPos[1] = dist_tr / P; + channelPos[2] = dist_rc / P; + channelPos[3] = dist_br / P; + channelPos[4] = dist_bc / P; + channelPos[5] = dist_bl / P; + channelPos[6] = dist_lc / P; + channelPos[7] = dist_tl / P; + + double d_tr = GetWaveDepth(dist_tr / P, channelDepths, channelPos, baseDepth); + double d_br = GetWaveDepth(dist_br / P, channelDepths, channelPos, baseDepth); + double d_bl = GetWaveDepth(dist_bl / P, channelDepths, channelPos, baseDepth); + double d_tl = GetWaveDepth(dist_tl / P, channelDepths, channelPos, baseDepth); + + int N = 150; + var pts = new (double, double)[N]; + for (int i = 0; i < N; i++) + { + double dist = (P * i) / N; + double t = dist / P; + double d = GetWaveDepth(t, channelDepths, channelPos, baseDepth); + pts[i] = GetRoundedInnerPoint(dist, w, h, P, d, d_tr, d_br, d_bl, d_tl); + } + return pts; + } + + private static (double, double) GetRoundedInnerPoint(double dist, double w, double h, double P, double d, double d_tr, double d_br, double d_bl, double d_tl) + { + double c_tr = w / 2.0; + double c_br = w / 2.0 + h; + double c_bl = c_br + w; + double c_tl = c_bl + h; + double R_tr = Math.Min(d_tr * 2.0, Math.Min(w / 2.0, h / 2.0)); + double R_br = Math.Min(d_br * 2.0, Math.Min(w / 2.0, h / 2.0)); + double R_bl = Math.Min(d_bl * 2.0, Math.Min(w / 2.0, h / 2.0)); + double R_tl = Math.Min(d_tl * 2.0, Math.Min(w / 2.0, h / 2.0)); + double distToTL = dist - c_tl; + if (dist < w / 2.0) distToTL = dist + P - c_tl; + + if (Math.Abs(dist - c_tr) <= R_tr) + { + double t = (dist - c_tr + R_tr) / (2 * R_tr); + return Bezier(t, (w - R_tr, d), (w - d, d), (w - d, R_tr)); + } + if (Math.Abs(dist - c_br) <= R_br) + { + double t = (dist - c_br + R_br) / (2 * R_br); + return Bezier(t, (w - d, h - R_br), (w - d, h - d), (w - R_br, h - d)); + } + if (Math.Abs(dist - c_bl) <= R_bl) + { + double t = (dist - c_bl + R_bl) / (2 * R_bl); + return Bezier(t, (R_bl, h - d), (d, h - d), (d, h - R_bl)); + } + if (Math.Abs(distToTL) <= R_tl) + { + double t = (distToTL + R_tl) / (2 * R_tl); + return Bezier(t, (d, R_tl), (d, d), (R_tl, d)); + } + + var edgePos = GetEdgePosition(dist, w, h, P); + if (dist <= c_tr || dist > c_tl) return (Math.Max(d, Math.Min(w - d, edgePos.X)), d); + if (dist <= c_br) return (w - d, Math.Max(d, Math.Min(h - d, edgePos.Y))); + if (dist <= c_bl) return (Math.Max(d, Math.Min(w - d, edgePos.X)), h - d); + return (d, Math.Max(d, Math.Min(h - d, edgePos.Y))); + } + + private static (double, double) Bezier(double t, (double X, double Y) p0, (double X, double Y) p1, (double X, double Y) p2) => + ((1 - t) * (1 - t) * p0.X + 2 * (1 - t) * t * p1.X + t * t * p2.X, + (1 - t) * (1 - t) * p0.Y + 2 * (1 - t) * t * p1.Y + t * t * p2.Y); + + private static (double X, double Y) GetEdgePosition(double dist, double w, double h, double P) + { + dist = ((dist % P) + P) % P; + if (dist <= w / 2) return (w / 2 + dist, 0); + if (dist <= w / 2 + h) return (w, dist - w / 2); + if (dist <= w / 2 + h + w) return (w - (dist - (w / 2 + h)), h); + if (dist <= w / 2 + h + w + h) return (0, h - (dist - (w / 2 + h + w))); + return (dist - (w / 2 + h + w + h), 0); + } + + private static double GetWaveDepth(double t, double[] depths, double[] positions, double maxDepth) => + Math.Min(maxDepth, Math.Max(0, InterpolateDepthCatmullRom(t, depths, positions))); + + private static double InterpolateDepthCatmullRom(double t, double[] depths, double[] positions) + { + t = ((t % 1.0) + 1.0) % 1.0; + int n = positions.Length; + int idx1 = 0; + for (int i = 0; i < n; i++) + { + if (positions[i] <= t) idx1 = i; + } + int idx0 = (idx1 - 1 + n) % n; + int idx2 = (idx1 + 1) % n; + int idx3 = (idx1 + 2) % n; + double p1 = positions[idx1]; + double p2 = positions[idx2]; + if (p2 <= p1) p2 += 1.0; + double at = t; + if (at < p1) at += 1.0; + double segLen = p2 - p1; + double lt = (segLen > 0) ? (at - p1) / segLen : 0; + lt = Math.Max(0, Math.Min(1, lt)); + double d0 = depths[idx0], d1 = depths[idx1]; + double d2 = depths[idx2], d3 = depths[idx3]; + double a = -0.5 * d0 + 1.5 * d1 - 1.5 * d2 + 0.5 * d3; + double b = d0 - 2.5 * d1 + 2.0 * d2 - 0.5 * d3; + double c = -0.5 * d0 + 0.5 * d2; + double dv = d1; + return Math.Max(0, a * lt * lt * lt + b * lt * lt + c * lt + dv); + } +} diff --git a/SoundVisualizer.Tests/RepositoryPaths.cs b/SoundVisualizer.Tests/RepositoryPaths.cs new file mode 100644 index 0000000..7dc912f --- /dev/null +++ b/SoundVisualizer.Tests/RepositoryPaths.cs @@ -0,0 +1,20 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Tests; + +/// 앱 소스(XAML 등)를 읽는 테스트가 저장소 뿌리를 찾는 곳. +internal static class RepositoryPaths +{ + /// 테스트 실행 폴더에서 위로 올라가며 SoundVisualizer.slnx 가 있는 폴더를 찾는다. + public static string Root { get; } = FindRoot(); + + private static string FindRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory != null; directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "SoundVisualizer.slnx"))) return directory.FullName; + } + throw new InvalidOperationException("SoundVisualizer.slnx 를 찾지 못했습니다: " + AppContext.BaseDirectory); + } +} diff --git a/SoundVisualizer.Tests/SoundVisualizer.Tests.csproj b/SoundVisualizer.Tests/SoundVisualizer.Tests.csproj new file mode 100644 index 0000000..e39d4d4 --- /dev/null +++ b/SoundVisualizer.Tests/SoundVisualizer.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SoundVisualizer.Tests/Sounds/SoundCatalogTests.cs b/SoundVisualizer.Tests/Sounds/SoundCatalogTests.cs new file mode 100644 index 0000000..ffc5c23 --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundCatalogTests.cs @@ -0,0 +1,202 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// 분류 탭의 목록(기본 종류)과 찾기·거르기. 모바일 SoundCatalogTest 를 옮겼다. +public class SoundCatalogTests +{ + private static readonly IReadOnlyList Entries = SoundCatalog.FromNames(SoundTestData.Names); + + private static readonly Dictionary NoOverrides = new(); + + private static SoundEntry Entry(string name) => Entries.First(entry => entry.Name == name); + + /// 테스트에서는 보이는 이름으로 영어 이름을 그대로 쓰되, 로 몇 개만 다른 말로 바꿔 본다. + private static List Keys(Dictionary? labels = null) => + Entries.Select(entry => SoundCatalog.SearchKey(labels?.GetValueOrDefault(entry.Name) ?? entry.Name, entry.Name)).ToList(); + + private static List Find( + string query, + SoundFilter filter = SoundFilter.All, + Dictionary? labels = null, + Dictionary? overrides = null) => + SoundTestData.NamesOf(SoundCatalog.Filter(Entries, Keys(labels), overrides ?? NoOverrides, query, filter)); + + [Fact] + public void 모델의_소리_521가지를_번호_순서대로_늘어놓는다() + { + Assert.Equal(YamnetCoarseClassifier.NumClasses, Entries.Count); + for (int i = 0; i < Entries.Count; i++) Assert.Equal(i, Entries[i].Index); + Assert.Equal("Speech", Entries[0].Name); + Assert.Equal("Gunshot, gunfire", Entries[421].Name); + } + + [Fact] + public void 기본_종류는_AI_의_매핑이고_사용자가_바꾼_것에_흔들리지_않는다() + { + // 모바일은 설정에 사이렌을 환경음으로 적은 뒤 목록을 새로 만든다. 여기서는 바꾼 맵을 만들고 목록을 새로 만든다. + var overrides = SoundTypeOverrides.WithType(NoOverrides, new[] { Entry("Siren") }, SoundType.Ambient); + var fresh = SoundCatalog.FromNames(SoundTestData.Names); + + Assert.Equal(SoundType.Danger, fresh.First(entry => entry.Name == "Siren").DefaultType); + Assert.Equal(SoundType.Ambient, SoundCatalog.TypeOf(fresh.First(entry => entry.Name == "Siren"), overrides)); + Assert.All(fresh, entry => Assert.Equal(YamnetThreeClassMapper.MapDisplayNameToCoarse(entry.Name), entry.DefaultType)); + } + + [Fact] + public void 지금_종류는_바꾼_것이_있으면_그것_없으면_기본_종류다() + { + var overrides = new Dictionary { ["Siren"] = SoundTypeLabels.Ambient }; + Assert.Equal(SoundType.Ambient, SoundCatalog.TypeOf(Entry("Siren"), overrides)); + Assert.Equal(SoundType.Danger, SoundCatalog.TypeOf(Entry("Explosion"), overrides)); + } + + [Fact] + public void 세_라벨이_아닌_값은_AI_처럼_바꾸지_않은_것으로_본다() + { + // 저장값을 읽을 때 버리는 값이라 보통은 없다. 그래도 들어오면 AI(YamnetMappingPolicy.From)처럼 기본 종류로 본다. + var overrides = new Dictionary { ["Siren"] = "Ambient", ["Explosion"] = "" }; + Assert.Equal(SoundType.Danger, SoundCatalog.TypeOf(Entry("Siren"), overrides)); + Assert.Equal(SoundType.Danger, SoundCatalog.TypeOf(Entry("Explosion"), overrides)); + Assert.Equal(0, SoundCatalog.CountChanged(Entries, overrides)); + } + + [Fact] + public void 모델_이름으로_클래스_번호를_찾는다() + { + // 모델 이름은 서로 겹치지 않는다. 겹치면 다른 소리의 이름을 보고 종류를 바꾸게 된다. + Assert.Equal(Entries.Count, Entries.Select(entry => entry.Name).ToHashSet(StringComparer.Ordinal).Count); + Assert.All(Entries, entry => Assert.Equal(entry.Index, SoundCatalog.IndexOf(Entries, entry.Name))); + Assert.Equal(390, SoundCatalog.IndexOf(Entries, "Siren")); + Assert.Equal(421, SoundCatalog.IndexOf(Entries, "Gunshot, gunfire")); + Assert.Equal(-1, SoundCatalog.IndexOf(Entries, "siren")); // 이름은 글자 그대로 맞춰 본다. + Assert.Equal(-1, SoundCatalog.IndexOf(Entries, "")); + Assert.Equal(-1, SoundCatalog.IndexOf(Entries, null)); + Assert.Equal(-1, SoundCatalog.IndexOf(Array.Empty(), "Siren")); // 목록을 아직 읽지 않았으면 찾지 못한다. + } + + [Fact] + public void 찾는_말이_없으면_거르기만_적용하고_순서를_지킨다() + { + var all = SoundCatalog.Filter(Entries, Keys(), NoOverrides, "", SoundFilter.All); + Assert.Equal(Entries, all); + + var danger = SoundCatalog.Filter(Entries, Keys(), NoOverrides, " ", SoundFilter.Danger); + Assert.NotEmpty(danger); + Assert.All(danger, entry => Assert.Equal(SoundType.Danger, entry.DefaultType)); + Assert.Equal(danger.OrderBy(entry => entry.Index), danger); // 번호 순서 그대로 + } + + [Fact] + public void 보이는_이름과_모델_이름_어느_쪽으로도_찾는다() + { + var labels = new Dictionary { ["Siren"] = "사이렌", ["Civil defense siren"] = "민방위 사이렌" }; + Assert.Equal(new[] { "Siren", "Civil defense siren" }, Find("사이렌", labels: labels)); + Assert.Equal(new[] { "Civil defense siren" }, Find("civil defense", labels: labels)); // 영어 이름으로도 찾는다. + } + + [Fact] + public void 한글은_치는_중의_모든_모양에서_결과가_끊기지_않는다() + { + // 한글 자판으로 칠 때 입력 칸에 차례로 들어오는 모양. 다음 글자의 첫소리가 앞 글자의 받침으로 먼저 붙는다. + var typing = new Dictionary + { + ["사이렌"] = new[] { "ㅅ", "사", "상", "사이", "사일", "사이레", "사이렌" }, + ["화재 경보음"] = new[] { "ㅎ", "호", "화", "홪", "화재", "화잭", "화재 겨", "화재 경", "화재 경보", "화재 경봉", "화재 경보음" }, + ["닭 울음소리"] = new[] { "ㄷ", "다", "달", "닭", "닭 울" }, + }; + foreach (var (label, steps) in typing) + { + var labels = new Dictionary { ["Siren"] = label }; + foreach (string step in steps) + { + Assert.True(Find(step, labels: labels).Exists(name => name == "Siren"), $"\"{label}\" 을 치는 중 \"{step}\" 에서 찾아져야 한다"); + } + } + } + + [Fact] + public void 대소문자_띄어쓰기_악센트를_가리지_않는다() + { + var labels = new Dictionary + { + ["Siren"] = "Sirène", + ["Fire engine, fire truck (siren)"] = "Camion de pompiers", + }; + Assert.Equal(new[] { "Siren" }, Find("SIRENE", labels: labels)); + Assert.Equal(new[] { "Fire engine, fire truck (siren)" }, Find("firetruck", labels: labels)); + Assert.Equal(new[] { "Fire engine, fire truck (siren)" }, Find("camion de", labels: labels)); + } + + [Fact] + public void 보이는_이름과_모델_이름을_이어_붙인_곳에서는_맞지_않는다() + { + // "Rain" 을 "비" 로 보일 때 "비rain" 처럼 둘을 잇는 말은 어느 이름에도 없다. + Assert.Empty(Find("비rain", labels: new Dictionary { ["Rain"] = "비" })); + } + + [Fact] + public void 종류_거르기는_사용자가_바꾼_종류를_따른다() + { + var overrides = new Dictionary { ["Siren"] = SoundTypeLabels.Ambient, ["Doorbell"] = SoundTypeLabels.Danger }; + var danger = Find("", SoundFilter.Danger, overrides: overrides); + Assert.Contains("Doorbell", danger); + Assert.DoesNotContain("Siren", danger); + Assert.Contains("Siren", Find("", SoundFilter.Ambient, overrides: overrides)); + } + + [Fact] + public void 바꾼_소리_거르기는_기본_종류와_다른_것만_보여_준다() + { + var overrides = new Dictionary { ["Siren"] = SoundTypeLabels.Ambient, ["Doorbell"] = SoundTypeLabels.Danger }; + Assert.Equal(new[] { "Siren", "Doorbell" }.OrderBy(name => Entry(name).Index), Find("", SoundFilter.Changed, overrides: overrides)); + Assert.Equal(new[] { "Doorbell" }, Find("door", SoundFilter.Changed, overrides: overrides)); // 찾는 말과 함께 쓴다. + Assert.Empty(Find("", SoundFilter.Changed)); + } + + [Fact] + public void 바꾼_소리_수는_묶음이_아니라_소리를_센다() + { + // 사이렌 묶음(5개)을 통째로 환경음으로, 초인종 하나를 위협음으로 바꾼 것이다. "바꾼 소리" 거르기와 같은 기준이다. + var entries = SoundTestData.Entries(); + var sirens = entries.Where(entry => entry.Group == SoundGroup.Sirens).ToList(); + var overrides = SoundTypeOverrides.WithType(new Dictionary(), sirens, SoundType.Ambient); + overrides = SoundTypeOverrides.WithType(overrides, entries.Where(entry => entry.Name == "Doorbell"), SoundType.Danger); + + Assert.Equal(6, SoundCatalog.CountChanged(entries, overrides)); + Assert.Equal( + SoundCatalog.Filter(entries, SoundTestData.Names.ToList(), overrides, "", SoundFilter.Changed).Count, + SoundCatalog.CountChanged(entries, overrides)); + + // 목록에 없는 이름이나 기본 종류와 같아진 값(매핑이 바뀐 뒤)이 저장값에 남아 있어도 세지 않는다. + var stale = new Dictionary { ["Siren"] = SoundTypeLabels.Danger, ["Old name"] = SoundTypeLabels.Speech }; + Assert.Equal(0, SoundCatalog.CountChanged(entries, stale)); + } + + [Fact] + public void 다듬은_말은_부호와_띄어쓰기와_결합_부호가_없고_한글은_낱자로_푼다() + { + Assert.Equal(SoundCatalog.Normalize("부엌요리"), SoundCatalog.Normalize("부엌·요리")); + Assert.Equal("tvradioandbackgroundsound", SoundCatalog.Normalize("TV, radio and background sound")); + Assert.Equal("sirene", SoundCatalog.Normalize("Sirène")); + Assert.Equal("カメラ", SoundCatalog.Normalize("カメ・ラ")); // 가운뎃점(・)도 문장 부호다. + Assert.Equal("ab", SoundCatalog.Normalize("a  b")); // 줄 바꿈 없는 띄어쓰기, 전각 띄어쓰기 + Assert.Equal("ㄷㅏㄹㄱ", SoundCatalog.Normalize("닭")); // 첫소리·받침은 호환용 낱자로, 겹받침은 둘로 + Assert.Equal(SoundCatalog.Normalize("ㄹㄱ"), SoundCatalog.Normalize("ㄺ")); // 치는 중의 겹받침 + Assert.Equal(SoundCatalog.Normalize("ㅗㅏ"), SoundCatalog.Normalize("ㅘ")); + Assert.Equal("", SoundCatalog.Normalize(" ,·- ")); + } + + [Fact] + public void 짝_없는_서로게이트가_있어도_다듬는다() + { + // 붙여 넣은 글 등. .NET 의 정규화는 이런 글을 받지 않으므로 대체 문자로 바꿔 다듬는다. 다른 글자는 그대로다. + string normalized = SoundCatalog.Normalize("Si\uD800ren"); + Assert.Equal("si�ren", normalized); + Assert.Empty(Find("\uDC00")); + } +} diff --git a/SoundVisualizer.Tests/Sounds/SoundGroupSearchTests.cs b/SoundVisualizer.Tests/Sounds/SoundGroupSearchTests.cs new file mode 100644 index 0000000..60e9017 --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundGroupSearchTests.cs @@ -0,0 +1,270 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 묶음 화면의 찾기와 거르기(#349). 기본 모드는 카드로, 고급 모드는 같은 결과를 줄로 편다. 모바일 SoundGroupSearchTest 를 옮겼다. +/// +/// 보이는 이름은 실제 한국어 데이터(SoundNames/ko.json, GroupTitles/ko.json)다. 기본 종류는 매핑과 상관없이 정해 둔다(사이렌·경보음 +/// 묶음만 위협음, 나머지는 환경음). 매핑이 바뀌어도 이 테스트가 흔들리지 않게 하려는 것이다. +/// +public class SoundGroupSearchTests +{ + private static readonly IReadOnlyList Entries = SoundTestData.Entries(name => + SoundTestData.Table.TryGetValue(name, out var row) && row.Group is SoundGroup.Sirens or SoundGroup.Alarms + ? SoundType.Danger + : SoundType.Ambient); + + private static readonly IReadOnlyList Sections = SoundGroups.Sections(Entries); + + private static readonly IReadOnlyList KoNames = SoundData.Names("ko"); + + /// 화면이 한국어일 때의 찾기 값. 분류 탭처럼 보이는 이름과 모델 이름을 잇는다. + private static readonly List KoKeys = + Entries.Select(entry => SoundCatalog.SearchKey(KoNames[entry.Index], entry.Name)).ToList(); + + private static readonly IReadOnlyDictionary KoTitles = SoundData.GroupTitles("ko") + .ToDictionary(pair => pair.Key, pair => SoundCatalog.SearchKey(pair.Value, "")); + + private static readonly Dictionary NoOverrides = new(); + + private static IReadOnlyList Search( + string query, + SoundFilter filter = SoundFilter.All, + IReadOnlyDictionary? overrides = null, + IReadOnlySet? kept = null) => + SoundGroups.Filter(Sections, KoKeys, KoTitles, overrides ?? NoOverrides, query, filter, kept); + + private static SoundEntry Entry(string name) => Entries.First(entry => entry.Name == name); + + private static List Names(IEnumerable list) => SoundTestData.NamesOf(list); + + private static List Groups(IReadOnlyList shown) => + shown.SelectMany(section => section.Cards).Select(card => card.Card.Group).ToList(); + + private static ShownCard CardOf(IReadOnlyList shown, SoundGroup group) => + Assert.Single(shown.SelectMany(section => section.Cards), card => card.Card.Group == group); + + private static Dictionary Map(params (string Name, SoundType Type)[] values) => + values.ToDictionary(value => value.Name, value => value.Type.ToLabel()); + + [Fact] + public void 찾는_말과_거르기가_없으면_모든_카드와_소리가_남는다() + { + var all = Search(" "); + Assert.Equal(Sections.Select(section => section.Type), all.Select(section => section.Type)); + Assert.Equal(Sections.SelectMany(section => section.Cards), all.SelectMany(section => section.Cards).Select(card => card.Card)); + Assert.All(all.SelectMany(section => section.Cards), card => Assert.Equal(card.Card.Members, card.Shown)); + } + + [Fact] + public void 묶음_이름이_맞으면_묶음_전체가_남는다() + { + // 사이렌 묶음의 이름은 '사이렌' 소리의 이름이다. + var sirens = Search("사이렌"); + Assert.Equal(new SoundGroup?[] { SoundGroup.Sirens }, Groups(sirens)); + Assert.Equal(CardOf(sirens, SoundGroup.Sirens).Card.Members, CardOf(sirens, SoundGroup.Sirens).Shown); + // 새로 지은 묶음 이름으로도 찾는다. '부엌' 은 소리 이름에는 없는 말이다. + var kitchen = Search("부엌"); + Assert.Equal(new SoundGroup?[] { SoundGroup.Kitchen }, Groups(kitchen)); + Assert.Equal(CardOf(kitchen, SoundGroup.Kitchen).Card.Members, CardOf(kitchen, SoundGroup.Kitchen).Shown); + } + + [Fact] + public void 소리_이름이_맞으면_묶음이_남고_맞은_소리가_미리보기_앞에_온다() + { + // '노크' 를 찾으면 환경음 › 초인종 묶음이 '노크 소리 • 딩동 • 톡톡 두드리는 소리' 로 보인다. 빈 구역은 숨는다. + var result = Search("노크"); + Assert.Equal(new[] { SoundType.Ambient }, result.Select(section => section.Type)); + Assert.Equal(new SoundGroup?[] { SoundGroup.Doorbell }, Groups(result)); + var doorbell = CardOf(result, SoundGroup.Doorbell); + Assert.Equal(new[] { "Knock" }, Names(doorbell.Shown)); + var preview = SoundGroups.Preview(doorbell.Card, doorbell.Shown); + Assert.Equal(new[] { "노크 소리", "딩동", "톡톡 두드리는 소리" }, preview.Names.Select(entry => KoNames[entry.Index])); + Assert.Equal(0, preview.More); + } + + [Fact] + public void 묶음_이름에_맞은_묶음과_소리_이름에_맞은_묶음이_함께_남는다() + { + // '경적' 은 경적 묶음의 이름에도, 다른 묶음의 기차 경적·안개 경적·짧은 경적에도 들어 있다. + var result = Search("경적"); + Assert.Equal(new SoundGroup?[] { SoundGroup.Horns, SoundGroup.VehicleWarnings, SoundGroup.Vehicle }, Groups(result)); + Assert.Equal(CardOf(result, SoundGroup.Horns).Card.Members, CardOf(result, SoundGroup.Horns).Shown); + Assert.Equal(new[] { "Train horn", "Foghorn" }, Names(CardOf(result, SoundGroup.VehicleWarnings).Shown)); + Assert.Equal(new[] { "Toot" }, Names(CardOf(result, SoundGroup.Vehicle).Shown)); + } + + [Fact] + public void 번역된_이름과_영어_이름_어느_쪽으로도_찾는다() + { + // 화면이 한국어여도 모델 이름(영어)으로 찾는다. + var knock = Search("knock"); + Assert.Equal(new[] { "Knock" }, Names(CardOf(knock, SoundGroup.Doorbell).Shown)); + Assert.Equal(new[] { "Engine knocking" }, Names(CardOf(knock, SoundGroup.Vehicle).Shown)); + // 화면이 영어면 새로 지은 묶음 이름도 영어 문구로 찾는다. + var enKeys = Entries.Select(entry => SoundCatalog.SearchKey(entry.Name, entry.Name)).ToList(); + var enTitles = SoundData.GroupTitles(SoundLanguages.English) + .ToDictionary(pair => pair.Key, pair => SoundCatalog.SearchKey(pair.Value, "")); + var kitchen = SoundGroups.Filter(Sections, enKeys, enTitles, NoOverrides, "Kitchen", SoundFilter.All); + Assert.Equal(new SoundGroup?[] { SoundGroup.Kitchen }, Groups(kitchen)); + } + + [Fact] + public void 화면이_한국어여도_새로_지은_묶음_이름을_영어로_찾는다() + { + // 분류 탭과 같은 방법(SoundGroups.TitleKeys)으로 한국어 이름과 영어 이름을 잇는다. + var ko = SoundData.GroupTitles("ko"); + var en = SoundData.GroupTitles(SoundLanguages.English); + var titles = SoundGroups.TitleKeys(local: group => ko[group], english: group => en[group]); + foreach (string query in new[] { "부엌", "kitchen", "Kitchen and cooking" }) + { + var result = SoundGroups.Filter(Sections, KoKeys, titles, NoOverrides, query, SoundFilter.All); + Assert.Equal(new SoundGroup?[] { SoundGroup.Kitchen }, Groups(result)); + Assert.Equal(CardOf(result, SoundGroup.Kitchen).Card.Members, CardOf(result, SoundGroup.Kitchen).Shown); + } + // 영어 이름의 쉼표 자리를 띄어 쳐도 찾는다. + var background = SoundGroups.Filter(Sections, KoKeys, titles, NoOverrides, "tv radio", SoundFilter.All); + Assert.Equal(new SoundGroup?[] { SoundGroup.Background }, Groups(background)); + } + + [Fact] + public void 문장_부호로_이은_이름은_띄어_쳐도_찾는다() + { + // 새로 지은 묶음 이름은 낱말을 '·' 로 잇는다(부엌·요리). 사람은 그 자리를 띄어 친다(#349). + Assert.Equal(new SoundGroup?[] { SoundGroup.Kitchen }, Groups(Search("부엌 요리"))); + Assert.Equal(new SoundGroup?[] { SoundGroup.Horns }, Groups(Search("경적 자동차"))); + Assert.Equal(new SoundGroup?[] { SoundGroup.Background }, Groups(Search("TV 라디오"))); + // 소리 이름의 쉼표·괄호도 같다. 모델 이름은 "Fire engine, fire truck (siren)" 이다. + var fireTruck = Search("fire engine fire truck siren"); + Assert.Equal(new SoundGroup?[] { SoundGroup.Sirens }, Groups(fireTruck)); + Assert.Equal(new[] { "Fire engine, fire truck (siren)" }, Names(CardOf(fireTruck, SoundGroup.Sirens).Shown)); + } + + [Fact] + public void 보이는_이름과_모델_이름을_이어_붙인_곳에서는_맞지_않는다() + { + Assert.Empty(Search("노크 소리knock")); + Assert.Empty(Search("zzzz")); + } + + [Fact] + public void 종류_거르기는_지금_종류를_따르고_바꾼_소리도_제_구역에_있다() + { + var overrides = Map(("Doorbell", SoundType.Danger), ("Siren", SoundType.Ambient)); + + var danger = Search("", SoundFilter.Danger, overrides); + Assert.Equal(new SoundGroup?[] { SoundGroup.Alarms, SoundGroup.Sirens, SoundGroup.Doorbell }, Groups(danger)); + Assert.DoesNotContain("Siren", Names(CardOf(danger, SoundGroup.Sirens).Shown)); + Assert.Equal(4, CardOf(danger, SoundGroup.Sirens).Shown.Count); + Assert.Equal(new[] { "Doorbell" }, Names(CardOf(danger, SoundGroup.Doorbell).Shown)); + // 구역은 기본 종류라 위협음으로 바꾼 초인종도 환경음 구역에 있다. + Assert.Equal(new[] { SoundType.Danger, SoundType.Ambient }, danger.Select(section => section.Type)); + + var ambient = Search("", SoundFilter.Ambient, overrides); + Assert.Equal(new[] { "Siren" }, Names(CardOf(ambient, SoundGroup.Sirens).Shown)); + Assert.Equal(new[] { "Ding-dong", "Knock", "Tap" }, Names(CardOf(ambient, SoundGroup.Doorbell).Shown)); + Assert.Empty(Search("", SoundFilter.Speech, overrides)); // 대화음인 소리가 없다. + } + + [Fact] + public void 바꾼_소리_거르기는_바꾼_소리가_있는_카드만_남긴다() + { + var overrides = Map(("Doorbell", SoundType.Danger), ("Siren", SoundType.Ambient)); + var changed = Search("", SoundFilter.Changed, overrides); + Assert.Equal(new SoundGroup?[] { SoundGroup.Sirens, SoundGroup.Doorbell }, Groups(changed)); + Assert.Equal(new[] { "Siren" }, Names(CardOf(changed, SoundGroup.Sirens).Shown)); + Assert.Equal(new[] { "Doorbell" }, Names(CardOf(changed, SoundGroup.Doorbell).Shown)); + Assert.Equal(new SoundGroup?[] { SoundGroup.Doorbell }, Groups(Search("초인종", SoundFilter.Changed, overrides))); // 찾는 말과 함께 쓴다. + Assert.Empty(Search("", SoundFilter.Changed)); + } + + [Fact] + public void 거르기에서_방금_바꾼_소리는_찾는_말이_그대로인_동안_남는다() + { + // 위협음만 보다가 사이렌 묶음을 환경음으로 바꾼 것이다(#374). 남기지 않으면 카드가 눈앞에서 사라진다. + var sirenNames = SoundTestData.Members(SoundGroup.Sirens); + var allAmbient = sirenNames.ToDictionary(name => name, _ => SoundTypeLabels.Ambient); + Assert.DoesNotContain(SoundGroup.Sirens, Groups(Search("", SoundFilter.Danger, allAmbient))); + var kept = Search("", SoundFilter.Danger, allAmbient, sirenNames.ToHashSet()); + Assert.Equal(CardOf(kept, SoundGroup.Sirens).Card.Members, CardOf(kept, SoundGroup.Sirens).Shown); + + // 남긴 소리는 거르기에 맞는 소리와 함께 표 순서로 보인다. + var one = Search("", SoundFilter.Danger, Map(("Siren", SoundType.Ambient)), new HashSet { "Siren" }); + Assert.Equal(sirenNames, Names(CardOf(one, SoundGroup.Sirens).Shown)); + + // 거르기만 넘고 찾는 말은 넘지 않는다. + Assert.DoesNotContain(SoundGroup.Sirens, Groups(Search("노크", SoundFilter.Danger, allAmbient, sirenNames.ToHashSet()))); + + // "바꾼 소리" 거르기에서 기본 종류로 되돌린 소리도 남는다. + var changed = Search("", SoundFilter.Changed, NoOverrides, new HashSet { "Doorbell" }); + Assert.Equal(new SoundGroup?[] { SoundGroup.Doorbell }, Groups(changed)); + Assert.Equal(new[] { "Doorbell" }, Names(CardOf(changed, SoundGroup.Doorbell).Shown)); + + // 고급 모드는 그 줄과 그 위의 묶음·구역 머리가 남는다. + var rows = SoundGroups.AdvancedItems(Search("", SoundFilter.Danger, NoOverrides, new HashSet { "Doorbell" })); + Assert.Equal( + new[] { "s:ambient", "g:doorbell:ambient", "r:" + Entry("Doorbell").Index }, + rows.SkipWhile(item => item.Key != "s:ambient").Select(item => item.Key)); + } + + [Fact] + public void 한글은_치는_중의_모든_모양에서_묶음이_끊기지_않는다() + { + // SoundCatalogTests 와 같은 경우를 묶음 찾기로 본다. 보이는 이름은 영어 그대로 두고 사이렌만 다른 말로 보인다고 친다. + var typing = new Dictionary + { + ["사이렌"] = new[] { "ㅅ", "사", "상", "사이", "사일", "사이레", "사이렌" }, + ["화재 경보음"] = new[] { "ㅎ", "호", "화", "홪", "화재", "화잭", "화재 겨", "화재 경", "화재 경보", "화재 경봉", "화재 경보음" }, + ["닭 울음소리"] = new[] { "ㄷ", "다", "달", "닭", "닭 울" }, + }; + var noTitles = new Dictionary(); + foreach (var (label, steps) in typing) + { + var keys = Entries.Select(entry => SoundCatalog.SearchKey(entry.Name == "Siren" ? label : entry.Name, entry.Name)).ToList(); + foreach (string step in steps) + { + var sirens = SoundGroups.Filter(Sections, keys, noTitles, NoOverrides, step, SoundFilter.All) + .SelectMany(section => section.Cards) + .SingleOrDefault(card => card.Card.Group == SoundGroup.Sirens); + Assert.True(sirens?.Shown.Count == 5, $"\"{label}\" 을 치는 중 \"{step}\" 에서 사이렌 묶음이 통째로 남아야 한다"); + } + } + } + + [Fact] + public void 고급_모드는_맞는_줄과_그_위의_묶음과_구역_머리만_보인다() + { + var knock = SoundGroups.AdvancedItems(Search("노크")); + Assert.Equal(new[] { "s:ambient", "g:doorbell:ambient", "r:" + Entry("Knock").Index }, knock.Select(item => item.Key)); + + // 묶음 이름이 맞으면 그 묶음의 줄이 모두 보인다. + var doorbell = SoundGroups.AdvancedItems(Search("초인종")); + Assert.Equal( + new[] { "s:ambient", "g:doorbell:ambient" } + .Concat(new[] { "Doorbell", "Ding-dong", "Knock", "Tap" }.Select(name => "r:" + Entry(name).Index)), + doorbell.Select(item => item.Key)); + + // 거르기에 맞는 줄이 없는 묶음은 머리도 없다. + var danger = SoundGroups.AdvancedItems(Search("", SoundFilter.Danger, Map(("Doorbell", SoundType.Danger)))); + Assert.Equal( + new[] { "s:ambient", "g:doorbell:ambient", "r:" + Entry("Doorbell").Index }, + danger.SkipWhile(item => item.Key != "s:ambient").Select(item => item.Key)); + } + + [Fact] + public void 고급_모드에는_모든_소리가_한_번씩_보이고_키가_겹치지_않는다() + { + var items = SoundGroups.AdvancedItems(Search("")); + Assert.Equal( + Entries.Select(entry => entry.Index).Order(), + items.OfType().Select(row => row.Entry.Index).Order()); + Assert.Equal(Sections.Sum(section => section.Cards.Count), items.OfType().Count()); + Assert.Equal(Sections.Count, items.OfType().Count()); + Assert.Equal(items.Count, items.Select(item => item.Key).Distinct().Count()); // 목록의 키가 겹치지 않는다. + } +} diff --git a/SoundVisualizer.Tests/Sounds/SoundGroupStringsTests.cs b/SoundVisualizer.Tests/Sounds/SoundGroupStringsTests.cs new file mode 100644 index 0000000..2a98512 --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundGroupStringsTests.cs @@ -0,0 +1,150 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 묶음 이름(#349)이 모든 언어에 있고 서로 가려지는지 본다. 모바일 SoundGroupStringsTest 가운데 이 데이터(소리 이름, 새로 지은 묶음 +/// 이름)에 대한 것을 옮겼다. 분류 탭의 안내 문구(classify_*)에 대한 것은 화면 문구와 함께 옮긴다. +/// +/// 묶음 이름은 다른 묶음의 이름이나 다른 묶음에 든 소리의 이름과 같지 않다(#349 의 번역 규칙, #370). 구역 머리(종류 이름)와도, +/// 새로 지은 이름이면 제 묶음에 든 소리의 이름과도 같지 않다(#374). 찾기와 같은 기준(대소문자·띄어쓰기·문장 부호 무시)으로 본다. +/// +public class SoundGroupStringsTests +{ + private static readonly IReadOnlyList ClassNames = SoundTestData.Names; + + private static string SoundName(string language, string name) => SoundData.Names(language)[IndexOf(name)]; + + private static int IndexOf(string name) + { + for (int i = 0; i < ClassNames.Count; i++) + { + if (ClassNames[i] == name) return i; + } + throw new ArgumentException(name); + } + + [Fact] + public void 새로_지은_묶음_이름은_모든_언어에_있고_다른_것은_두지_않는다() + { + // 한 언어라도 빠지면 그 언어의 탭에 영어 이름이 섞인다. 파일 안의 차례도 묶음 순서로 두어 언어끼리 맞춰 보기 쉽게 한다. + var expected = SoundGroupInfo.All.Where(group => group.HasOwnTitle()).Select(group => group.Id()).ToList(); + Assert.Equal(15, expected.Count); + var problems = new List(); + foreach (string language in SoundLanguages.All) + { + var pairs = SoundData.ReadGroupTitlesFile(language); + if (pairs is null) + { + problems.Add($"{language}: 파일이 없다"); + continue; + } + var ids = pairs.Select(pair => pair.Key).ToList(); + var blank = pairs.Where(pair => string.IsNullOrWhiteSpace(pair.Value) || pair.Value != pair.Value.Trim()).Select(pair => pair.Key).ToList(); + if (!ids.SequenceEqual(expected) || blank.Count > 0) + { + problems.Add($"{language}: id [{string.Join(", ", ids)}], 비었거나 앞뒤에 공백 [{string.Join(", ", blank)}]"); + } + } + Assert.True(problems.Count == 0, "묶음 이름은 모든 언어에 넣습니다:\n" + string.Join("\n", problems)); + } + + [Fact] + public void 묶음_이름은_다른_묶음의_이름이나_다른_묶음에_든_소리의_이름과_같지_않다() + { + // 같으면 화면에서도 찾기에서도 어느 묶음인지 가릴 수 없다. 소리를 다른 묶음으로 옮기면 그 소리 이름을 그대로 쓴 묶음 이름이 + // 여기에 걸린다. 여러 낱말을 이은 이름의 한 낱말이 다른 묶음의 소리 이름인지는 언어마다 낱말을 나누는 법이 달라 보지 않는다. + var table = SoundTestData.Table; + var problems = new List(); + foreach (string language in SoundLanguages.All) + { + var titles = SoundGroupInfo.All.ToDictionary(group => group, group => SoundTestData.GroupTitle(language, group)); + problems.AddRange(titles + .GroupBy(pair => SoundCatalog.Normalize(pair.Value), pair => pair.Key) + .Where(same => same.Count() > 1) + .Select(same => $"{language}: 이름이 같은 묶음 [{string.Join(", ", same)}]")); + foreach (var (group, title) in titles.Where(pair => pair.Key.HasOwnTitle())) + { + foreach (string name in ClassNames) + { + SoundGroup? owner = table.TryGetValue(name, out var row) ? row.Group : null; + if (owner != group && SoundCatalog.Normalize(SoundName(language, name)) == SoundCatalog.Normalize(title)) + { + problems.Add($"{language}: {group} 의 이름 \"{title}\" 이 다른 묶음({owner})의 소리 {name} 의 이름이다"); + } + } + } + } + Assert.True(problems.Count == 0, "묶음 이름이 겹친다:\n" + string.Join("\n", problems)); + } + + [Fact] + public void 묶음_이름은_구역_머리인_종류_이름과_같지_않다() + { + // 묶음은 기본 종류의 구역 머리 밑에 보인다. 같은 낱말이면 머리 밑에 같은 이름이 한 번 더 보여 무엇이 구역이고 무엇이 묶음인지 + // 가릴 수 없다. 말소리 묶음이 소리 이름 'Speech' 를 쓰던 때 영어 등 6개 언어가 그랬다(#374). + var problems = new List(); + foreach (string language in SoundLanguages.All) + { + var headings = SectionHeadings[language].ToDictionary(SoundCatalog.Normalize); + foreach (SoundGroup group in SoundGroupInfo.All) + { + string title = SoundTestData.GroupTitle(language, group); + if (headings.TryGetValue(SoundCatalog.Normalize(title), out string? heading)) + { + problems.Add($"{language}: {group} 의 이름 \"{title}\" 이 구역 머리 \"{heading}\" 와 같다"); + } + } + } + Assert.True(problems.Count == 0, "묶음 이름이 구역 머리와 같다:\n" + string.Join("\n", problems)); + Assert.True(SoundLanguages.All.ToHashSet().SetEquals(SectionHeadings.Keys)); + } + + [Fact] + public void 새로_지은_묶음_이름은_제_묶음에_든_소리의_이름과_같지_않다() + { + // 새로 지은 이름이 제 소리 하나의 이름과 같으면 그 소리 이름을 묶음 이름으로 쓴 것과 다르지 않다(#374). 다른 묶음의 소리와 + // 겹치는지는 위의 테스트가 본다. + var problems = new List(); + foreach (string language in SoundLanguages.All) + { + foreach (SoundGroup group in SoundGroupInfo.All.Where(group => group.HasOwnTitle())) + { + string title = SoundData.GroupTitles(language)[group]; + problems.AddRange(SoundTestData.Members(group) + .Where(name => SoundCatalog.Normalize(SoundName(language, name)) == SoundCatalog.Normalize(title)) + .Select(name => $"{language}: {group} 의 이름 \"{title}\" 이 제 소리 {name} 의 이름이다")); + } + } + Assert.True(problems.Count == 0, "묶음 이름이 제 소리 이름과 같다:\n" + string.Join("\n", problems)); + } + + /// + /// 구역 머리(환경음·대화음·위협음). 모바일 values*/strings.xml 의 sound_type_ambient·speech·danger 를 옮겨 적었다. + /// PC 화면의 구역 머리 문구를 바꾸면 여기도 함께 바꾼다. + /// + private static readonly Dictionary SectionHeadings = new() + { + ["en"] = new[] { "Ambient", "Speech", "Danger" }, + ["ko"] = new[] { "환경음", "대화음", "위협음" }, + ["ja"] = new[] { "環境音", "話し声", "危険音" }, + ["zh-Hans"] = new[] { "环境音", "人声", "危险音" }, + ["zh-Hant"] = new[] { "環境音", "人聲", "危險音" }, + ["es"] = new[] { "Sonido ambiente", "Voz", "Peligro" }, + ["pt-BR"] = new[] { "Som ambiente", "Voz", "Som de perigo" }, + ["fr"] = new[] { "Ambiance", "Voix", "Danger" }, + ["de"] = new[] { "Umgebungsgeräusche", "Stimmen", "Gefahrengeräusche" }, + ["ru"] = new[] { "Фоновые звуки", "Речь", "Опасные звуки" }, + ["it"] = new[] { "Suoni ambientali", "Voci", "Suoni di pericolo" }, + ["vi"] = new[] { "Âm thanh môi trường", "Giọng nói", "Âm thanh nguy hiểm" }, + ["th"] = new[] { "เสียงแวดล้อม", "เสียงพูด", "เสียงอันตราย" }, + ["id"] = new[] { "Suara latar", "Percakapan", "Suara bahaya" }, + ["tr"] = new[] { "Ortam sesi", "Konuşma", "Tehlike sesi" }, + ["pl"] = new[] { "Dźwięki otoczenia", "Mowa", "Dźwięki zagrożenia" }, + ["hi"] = new[] { "माहौल की आवाज़ें", "बातचीत", "खतरे की आवाज़ें" }, + ["ar"] = new[] { "الأصوات المحيطة", "الكلام", "أصوات الخطر" }, + }; +} diff --git a/SoundVisualizer.Tests/Sounds/SoundGroupTableTests.cs b/SoundVisualizer.Tests/Sounds/SoundGroupTableTests.cs new file mode 100644 index 0000000..daa259f --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundGroupTableTests.cs @@ -0,0 +1,311 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 분류 탭의 묶음 표(Sounds/Data/sound_groups.tsv, #349). 모바일 SoundGroupTableTest 를 옮겼다. +/// +/// 표는 사람이 검토한 데이터라 만드는 스크립트가 없다(모바일 표를 글자 그대로 옮겼다). 지켜야 할 규칙은 여기서 막는다. +/// 묶음마다의 기본 종류는 일부러 보지 않는다. 그것은 AI 의 매핑이 정하고, 매핑이 바뀌어도 화면이 그때그때 나눈다(SoundGroupsTests). +/// +public class SoundGroupTableTests +{ + private static readonly string Text = SoundTestData.TableText(); + + /// 파일의 줄. 윈도에서는 git 이 CRLF 로 꺼내므로 줄 끝을 가리지 않고 나눈다. + private static readonly string[] Lines = Text.Split('\n').Select(line => line.TrimEnd('\r')).Where(line => line.Length > 0).ToArray(); + + private static readonly string[] Header = Lines.TakeWhile(line => line.StartsWith('#')).ToArray(); + private static readonly string[] Rows = Lines.Skip(Header.Length).ToArray(); + private static readonly IReadOnlyDictionary Table = SoundTestData.Table; + + private static SoundGroup Group(string name) => Table[name].Group; + + private static List Members(SoundGroup group) => SoundTestData.Members(group); + + private static string IdOf(string row) => row[..row.IndexOf('\t')]; + + private static string NameOf(string row) => row[(row.IndexOf('\t') + 1)..]; + + [Fact] + public void 머리글에_라이선스와_출처와_바꾼_점을_적는다() + { + // 온톨로지를 읽고 만든 표라 CC BY-SA 4.0 의 각색물로 본다. 출처, 라이선스와 링크, 바꾼 점을 파일 안에 적는다. + Assert.Equal("# SPDX-License-Identifier: CC-BY-SA-4.0", Lines[0]); + string joined = string.Join("\n", Header); + foreach (string expected in new[] + { + "AudioSet Ontology", + "Google Inc.", + "https://github.com/audioset/ontology", + "d417d32", + "https://creativecommons.org/licenses/by-sa/4.0/", + "the 521 YAMNet sound names were regrouped into everyday groups by SoundVisualizer contributors", + }) + { + Assert.Contains(expected, joined); + } + Assert.DoesNotContain(Rows, row => row.StartsWith('#')); // # 줄은 맨 위에만 둔다. + } + + [Fact] + public void 모델의_소리_521가지가_한_번씩_들어_있다() + { + Assert.Equal(521, Rows.Length); + Assert.All(Rows, row => Assert.Single(row, c => c == '\t')); // 줄마다 묶음과 소리 이름 사이에 탭이 하나다. + var names = Rows.Select(NameOf).ToList(); + var twice = names.GroupBy(name => name).Where(same => same.Count() > 1).Select(same => same.Key).ToList(); + Assert.True(twice.Count == 0, $"두 번 나온 소리(한 소리는 한 묶음에만 둔다): {string.Join(", ", twice)}"); + // 모델의 클래스 목록과 같아야 한다. + Assert.True(SoundTestData.Names.ToHashSet().SetEquals(names), "표의 소리가 모델의 클래스 목록과 다르다"); + } + + [Fact] + public void 묶음_이름은_모두_SoundGroup_항목이고_항목은_모두_쓰인다() + { + var ids = Rows.Select(IdOf).ToHashSet(); + Assert.True(SoundGroupInfo.All.Select(group => group.Id()).ToHashSet().SetEquals(ids)); + Assert.All(SoundGroupInfo.All, group => Assert.Equal(group, SoundGroupInfo.FromId(group.Id()))); + Assert.Null(SoundGroupInfo.FromId("Sirens")); // 대소문자까지 같아야 한다. + Assert.Null(SoundGroupInfo.FromId("nope")); + Assert.Null(SoundGroupInfo.FromId(null)); + Assert.Equal(37, SoundGroupInfo.All.Count); + } + + [Fact] + public void 한_묶음의_줄은_붙어_있고_SoundGroup_순서를_따른다() + { + var runs = new List(); + foreach (string id in Rows.Select(IdOf)) + { + if (runs.Count == 0 || runs[^1] != id) runs.Add(id); + } + Assert.True(runs.Distinct().Count() == runs.Count, $"한 묶음의 줄이 흩어져 있다: {string.Join(", ", runs)}"); + Assert.Equal(SoundGroupInfo.All.Select(group => group.Id()), runs); + } + + [Fact] + public void 읽은_자리는_표의_줄_순서다() + { + Assert.Equal(Rows.Select(NameOf), Table.Keys); + Assert.Equal(Enumerable.Range(0, Rows.Length), Table.Values.Select(row => row.Order)); + Assert.Equal(Rows.Select(IdOf), Table.Values.Select(row => row.Group.Id())); + } + + [Fact] + public void 줄_끝이_CRLF_여도_같게_읽고_머리글과_빈_줄은_건너뛴다() + { + string lf = Text.Replace("\r\n", "\n"); + AssertSameTable(Table, SoundGroups.ParseTable(SoundTestData.Stream(lf))); + AssertSameTable(Table, SoundGroups.ParseTable(SoundTestData.Stream(lf.Replace("\n", "\r\n")))); + + const string sample = "# SPDX-License-Identifier: CC-BY-SA-4.0\r\n#\r\nsirens\tSiren\r\n\r\n# 가운데 줄\r\nalarms\tAlarm\r\n"; + AssertSameTable( + new Dictionary + { + ["Siren"] = new(SoundGroup.Sirens, 0), + ["Alarm"] = new(SoundGroup.Alarms, 1), + }, + SoundGroups.ParseTable(SoundTestData.Stream(sample))); + } + + [Fact] + public void 모르는_묶음과_망가진_줄은_건너뛰고_먼저_나온_줄을_쓴다() + { + // 그런 소리는 표에 없는 것이 되어 제 이름의 카드로 보인다. 탭이 깨지지 않는 쪽이다. + const string sample = "sirens\tSiren\nnope\tAlarm\nno tab here\nalarms\tSiren\nalarms\t\nalarms\tFire alarm\n"; + AssertSameTable( + new Dictionary + { + ["Siren"] = new(SoundGroup.Sirens, 0), + ["Fire alarm"] = new(SoundGroup.Alarms, 1), + }, + SoundGroups.ParseTable(SoundTestData.Stream(sample))); + } + + [Fact] + public void 받은_스트림은_닫지_않는다() + { + using var stream = SoundTestData.Stream("sirens\tSiren\n"); + SoundGroups.ParseTable(stream); + Assert.True(stream.CanRead); + } + + [Fact] + public void 묶음_이름은_새로_지은_문구이거나_그_묶음의_소리_이름이다() + { + var english = SoundTestData.GroupTitlesFile(SoundLanguages.English); + foreach (SoundGroup group in SoundGroupInfo.All) + { + string? sound = group.TitleSound(); + Assert.Equal(sound is null, group.HasOwnTitle()); + if (sound is not null) + { + // 이름인 소리가 그 묶음에 있어야 한다. + Assert.True(Table.TryGetValue(sound, out var row) && row.Group == group, $"{group} 의 이름인 소리 {sound} 가 그 묶음에 없다"); + } + else + { + Assert.True(english.ContainsKey(group.Id()), $"{group} 의 이름이 GroupTitles/en.json 에 없다"); + } + } + // 새로 지은 묶음 이름은 15개다. 나머지는 이미 번역된 소리 이름을 쓴다. + Assert.Equal( + new[] + { + SoundGroup.Horns, SoundGroup.VehicleWarnings, SoundGroup.Breaking, SoundGroup.Talking, SoundGroup.Distress, + SoundGroup.Signals, SoundGroup.Voices, SoundGroup.Water, SoundGroup.Kitchen, SoundGroup.Appliances, + SoundGroup.Tools, SoundGroup.Nature, SoundGroup.Body, SoundGroup.Objects, SoundGroup.Background, + }, + SoundGroupInfo.All.Where(group => group.HasOwnTitle())); + } + + [Fact] + public void 사이렌_5개와_일반_경보음_3개는_따로_한_묶음씩이다() + { + Assert.Equal( + new[] { "Siren", "Ambulance (siren)", "Fire engine, fire truck (siren)", "Police car (siren)", "Civil defense siren" }, + Members(SoundGroup.Sirens)); + Assert.Equal(new[] { "Alarm", "Smoke detector, smoke alarm", "Fire alarm" }, Members(SoundGroup.Alarms)); + } + + [Fact] + public void 자동차_경적과_트럭_경적과_자동차_경보음은_한_묶음이다() + { + Assert.Equal(new[] { "Vehicle horn, car horn, honking", "Air horn, truck horn", "Car alarm" }, Members(SoundGroup.Horns)); + } + + [Fact] + public void 초인종은_노크와_한_묶음이고_사이렌이나_전화와_섞이지_않는다() + { + Assert.Equal(new[] { "Doorbell", "Ding-dong", "Knock", "Tap" }, Members(SoundGroup.Doorbell)); + Assert.NotEqual(Group("Doorbell"), Group("Siren")); + Assert.NotEqual(Group("Doorbell"), Group("Telephone")); + } + + [Fact] + public void 함께_들리는_비슷한_소리는_같은_묶음이다() + { + Assert.Equal(Group("Singing"), Group("Choir")); + Assert.Equal(Group("Baby cry, infant cry"), Group("Whimper")); + Assert.Equal(Group("Music"), Group("Musical instrument")); + Assert.Equal(Group("Music"), Group("Guitar")); + Assert.Equal(Group("Bell"), Group("Cowbell")); + } + + [Fact] + public void 기본_종류가_바뀐_소리에_맞춰_묶음을_옮겼다() + { + // #344 로 위협음이 된 차량 경고음은 경적 바로 뒤에, 쾅 닫는 소리는 유리 깨짐 바로 뒤에 저 혼자 묶음으로 둔다. + // 어느 구역에 보이는지는 매핑이 정하므로 여기서는 표와 순서만 본다. + Assert.Equal( + new[] + { + "Reversing beeps", "Bicycle bell", "Tire squeal", "Skidding", "Emergency vehicle", "Train horn", + "Train whistle", "Foghorn", + }, + Members(SoundGroup.VehicleWarnings)); + Assert.Equal((int)SoundGroup.Horns + 1, (int)SoundGroup.VehicleWarnings); + Assert.Equal(new[] { "Slam" }, Members(SoundGroup.Slam)); + Assert.Equal("Slam", SoundGroup.Slam.TitleSound()); // 전기톱처럼 제 이름이 묶음 이름이다. + Assert.Equal((int)SoundGroup.Breaking + 1, (int)SoundGroup.Slam); + Assert.DoesNotContain("Slam", Members(SoundGroup.Door)); + // 환경음으로 남은 짧은 경적은 탈것 끝으로, 환경음이 된 싱잉볼은 음악 끝으로 옮겼다. + Assert.Equal("Toot", Members(SoundGroup.Vehicle)[^1]); + Assert.Equal("Singing bowl", Members(SoundGroup.Music)[^1]); + Assert.DoesNotContain("Singing bowl", Members(SoundGroup.Singing)); + } + + [Fact] + public void 대화음이_된_사람_발성은_대화음_묶음으로_옮겼다() + { + // #369 로 웃음·고함·울음·환호 11개가 대화음이 되어 환경음 묶음(목소리, 아기, 몸)에서 옮겼다(#370). 이미 있던 소리는 + // 순서를 그대로 두고 뒤에 붙여 미리보기가 바뀌지 않는다. 어느 구역에 보이는지는 매핑이 정하므로 표와 순서만 본다. + Assert.Equal( + new[] { "Laughter", "Baby laughter", "Giggle", "Belly laugh", "Chuckle, chortle", "Snicker" }, + Members(SoundGroup.Laughter)); + Assert.Equal( + new[] { "Shout", "Screaming", "Crying, sobbing", "Children shouting", "Yell", "Bellow", "Whoop", "Wail, moan" }, + Members(SoundGroup.Distress)); + Assert.Equal( + new[] { "Crowd", "Chatter", "Hubbub, speech noise, speech babble", "Children playing", "Cheering" }, + Members(SoundGroup.Crowd)); + // 아기 울음 묶음은 그대로 두고 자리만 외침·비명·울음 바로 뒤로 옮겼다. 개의 낑낑거림은 개 묶음에 남는다. + Assert.Equal(new[] { "Baby cry, infant cry", "Whimper" }, Members(SoundGroup.Baby)); + Assert.Equal((int)SoundGroup.Distress + 1, (int)SoundGroup.Baby); + Assert.Equal(SoundGroup.Dog, Group("Whimper (dog)")); + // 목소리 묶음에는 박수·콧노래·휘파람 같은 소리만 남고(이름도 그에 맞춤), 몸 소리 묶음에서는 신음이 빠졌다. + Assert.Equal( + new[] { "Applause", "Clapping", "Humming", "Whistling", "Yodeling", "Chant", "Mantra", "Finger snapping", "Hands" }, + Members(SoundGroup.Voices)); + Assert.DoesNotContain("Wail, moan", Members(SoundGroup.Body)); + } + + [Fact] + public void 부모가_여럿인_소리_36개는_정해_둔_묶음에_있다() + { + var moved = MultiParent.Where(pair => !Table.TryGetValue(pair.Key, out var row) || row.Group != pair.Value) + .Select(pair => $"{pair.Key} → {pair.Value}") + .ToList(); + Assert.True(moved.Count == 0, $"규칙 D 로 정한 자리에서 옮겼다. 일부러 옮긴 것이면 이 목록도 고친다: {string.Join(", ", moved)}"); + Assert.Equal(36, MultiParent.Count); + } + + private static void AssertSameTable(IReadOnlyDictionary expected, IReadOnlyDictionary actual) + { + // 순서까지 같아야 한다(키 순서가 표의 줄 순서다). + Assert.Equal(expected.Select(pair => (pair.Key, pair.Value)), actual.Select(pair => (pair.Key, pair.Value))); + } + + /// + /// AudioSet 온톨로지(ontology.json, 커밋 d417d32, SHA-256 9c685f44…d15d)에서 부모가 둘 이상인 소리 36개와 그 묶음. + /// + /// 이런 소리는 어느 부모를 따를지 정해야 했다(규칙 D). 규칙대로 정한 것과 뜻으로 정한 것(Knock·Tap 은 초인종, Bicycle bell 은 + /// 차량 경고음, Air horn 은 경적, Rattle·Hiss 는 물건 소리, Cowbell 은 종, Wind noise (microphone) 는 배경 소리)이 있다. + /// #344 로 Emergency vehicle 등이 위협음이 된 뒤로는 기계적인 규칙이 사이렌 셋과 자동차 경적·경보음을 차량 경고음 쪽으로 + /// 보내지만, 묶음은 그대로 둔다. 온톨로지 파일은 저장소에 두지 않아 목록을 여기에 적는다(모바일 테스트와 같은 목록). + /// 하나를 옮기면 이 목록도 일부러 고쳐야 한다. + /// + private static readonly Dictionary MultiParent = new() + { + ["Children shouting"] = SoundGroup.Distress, + ["Choir"] = SoundGroup.Singing, + ["Chant"] = SoundGroup.Voices, + ["Clapping"] = SoundGroup.Voices, + ["Hubbub, speech noise, speech babble"] = SoundGroup.Crowd, + ["Howl"] = SoundGroup.Dog, + ["Growling"] = SoundGroup.Dog, + ["Hiss"] = SoundGroup.Objects, + ["Clip-clop"] = SoundGroup.Animal, + ["Cowbell"] = SoundGroup.Bell, + ["Bleat"] = SoundGroup.Animal, + ["Chirp, tweet"] = SoundGroup.Animal, + ["Buzz"] = SoundGroup.Animal, + ["Rattle"] = SoundGroup.Objects, + ["Bell"] = SoundGroup.Bell, + ["Bicycle bell"] = SoundGroup.VehicleWarnings, + ["Beatboxing"] = SoundGroup.Music, + ["Wind noise (microphone)"] = SoundGroup.Background, + ["Crackle"] = SoundGroup.Fire, + ["Vehicle horn, car horn, honking"] = SoundGroup.Horns, + ["Car alarm"] = SoundGroup.Horns, + ["Air horn, truck horn"] = SoundGroup.Horns, + ["Police car (siren)"] = SoundGroup.Sirens, + ["Ambulance (siren)"] = SoundGroup.Sirens, + ["Fire engine, fire truck (siren)"] = SoundGroup.Sirens, + ["Jet engine"] = SoundGroup.Vehicle, + ["Dental drill, dentist's drill"] = SoundGroup.Tools, + ["Doorbell"] = SoundGroup.Doorbell, + ["Knock"] = SoundGroup.Doorbell, + ["Tap"] = SoundGroup.Doorbell, + ["Squeak"] = SoundGroup.Door, + ["Tick"] = SoundGroup.Appliances, + ["Crack"] = SoundGroup.Objects, + ["Squish"] = SoundGroup.Water, + ["Whoosh, swoosh, swish"] = SoundGroup.Objects, + ["Thump, thud"] = SoundGroup.Objects, + }; +} diff --git a/SoundVisualizer.Tests/Sounds/SoundGroupsTests.cs b/SoundVisualizer.Tests/Sounds/SoundGroupsTests.cs new file mode 100644 index 0000000..e03359a --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundGroupsTests.cs @@ -0,0 +1,303 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 분류 탭의 카드 나누기(카드 = (묶음, 기본 종류)), 묶음 상태, 미리보기, 덮어쓰는 수(#349), 묻는 때와 거르기에 남길 소리(#374). +/// 모바일 SoundGroupsTest 를 옮겼다. +/// +/// 지금 매핑으로는 어떤 매핑에서도 지켜야 할 규칙만 보고, 나머지는 기본 종류를 정해 둔 가짜 매핑을 써서 매핑이 바뀌어도 흔들리지 않게 +/// 한다. 지금 매핑의 구역 구성(위협음 11, 대화음 6, 환경음 20)은 한 테스트에만 적어 둔다. +/// +public class SoundGroupsTests +{ + /// 모든 소리의 기본 종류가 환경음인 목록. 매핑과 상관없이 묶음 하나가 카드 하나다. + private static readonly IReadOnlyList Plain = SoundTestData.Entries(_ => SoundType.Ambient); + + private static readonly IReadOnlyList PlainSections = SoundGroups.Sections(Plain); + + private static readonly Dictionary NoOverrides = new(); + + private static readonly SoundType[] Types = { SoundType.Ambient, SoundType.Speech, SoundType.Danger }; + + private static SoundEntry Entry(string name, IReadOnlyList? list = null) => + (list ?? Plain).First(entry => entry.Name == name); + + private static List Names(IEnumerable list) => SoundTestData.NamesOf(list); + + private static SoundCard Card(IReadOnlyList sections, SoundGroup group, SoundType type = SoundType.Ambient) => + Assert.Single(Assert.Single(sections, section => section.Type == type).Cards, card => card.Group == group); + + private static Dictionary AllOf(SoundCard card, SoundType type) => + card.Members.ToDictionary(entry => entry.Name, _ => type.ToLabel()); + + private static Dictionary Map(params (string Name, SoundType Type)[] values) => + values.ToDictionary(value => value.Name, value => value.Type.ToLabel()); + + private static Dictionary With(Dictionary map, params (string Name, SoundType Type)[] values) + { + var copy = new Dictionary(map); + foreach (var (name, type) in values) copy[name] = type.ToLabel(); + return copy; + } + + /// 어떤 매핑에서도 지켜야 할 것. + private static void AssertCardRules(IReadOnlyList sections, IReadOnlyList entries) + { + var types = sections.Select(section => section.Type).ToList(); + Assert.Equal(SoundGroups.SectionOrder.Where(types.Contains), types); // 구역은 위협음, 대화음, 환경음 순서다. + foreach (SoundSection section in sections) + { + Assert.NotEmpty(section.Cards); // 빈 구역이 없다. + foreach (SoundCard card in section.Cards) + { + Assert.Equal(section.Type, card.DefaultType); + Assert.All(card.Members, entry => Assert.Equal(card.DefaultType, entry.DefaultType)); // 카드에 기본 종류가 섞이지 않는다. + Assert.All(card.Members, entry => Assert.Equal(card.Group, entry.Group)); // 카드에 묶음이 섞이지 않는다. + Assert.Equal(card.Members.OrderBy(entry => entry.Order), card.Members); // 카드의 소리는 표 순서다. + } + var groups = section.Cards.Where(card => card.Group is not null).Select(card => card.Group!.Value).ToList(); + Assert.Equal(groups.Order(), groups); // 구역 안의 카드는 묶음 순서다. + Assert.Equal(groups.Count, groups.Distinct().Count()); // 구역 안에서 묶음 하나는 카드 하나다. + int firstSingle = section.Cards.ToList().FindIndex(card => card.Group is null); + if (firstSingle >= 0) + { + Assert.All(section.Cards.Skip(firstSingle), card => Assert.Null(card.Group)); // 표에 없는 소리는 구역 끝에 온다. + } + } + var shown = sections.SelectMany(section => section.Cards).SelectMany(card => card.Members).Select(entry => entry.Index); + Assert.Equal(entries.Select(entry => entry.Index).Order(), shown.Order()); // 모든 소리가 한 번씩 카드에 든다. + // 카드 id 는 목록의 키다. 겹치면 목록이 깨진다. 표에 없는 소리 둘이 같은 구역에 와도 겹치지 않아야 한다. + var ids = sections.SelectMany(section => section.Cards).Select(card => card.Id).ToList(); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + [Fact] + public void 지금_매핑으로_나눠도_카드마다_기본_종류가_하나다() + { + var entries = SoundTestData.Entries(); + AssertCardRules(SoundGroups.Sections(entries), entries); + Assert.Equal(new[] { SoundType.Danger, SoundType.Speech, SoundType.Ambient }, SoundGroups.SectionOrder); + } + + [Fact] + public void 지금_매핑으로는_위협음_11_대화음_6_환경음_20_묶음이다() + { + // 지금 매핑에서는 묶음마다 기본 종류가 하나라 카드 하나가 곧 묶음 하나다. 매핑을 모바일에서 옮겨 바꾸면 이 수도 함께 본다 + // (YamnetLabelListTests 와 같다). 카드 나누기 자체는 매핑과 상관없이 위의 규칙을 지킨다. + var sections = SoundGroups.Sections(SoundTestData.Entries()); + Assert.Equal(new[] { SoundType.Danger, SoundType.Speech, SoundType.Ambient }, sections.Select(section => section.Type)); + Assert.Equal(new[] { 11, 6, 20 }, sections.Select(section => section.Cards.Count)); + Assert.Equal(SoundGroupInfo.All, sections.SelectMany(section => section.Cards).Select(card => card.Group!.Value)); + } + + [Fact] + public void 매핑이_바뀐_소리는_새_구역에_같은_묶음_이름으로_따로_보인다() + { + // 매핑이 노크를 위협음으로, 사이렌을 대화음으로 옮겼다고 친다. 표는 고치지 않는다. + var flipped = new Dictionary { ["Knock"] = SoundType.Danger, ["Siren"] = SoundType.Speech }; + var drifted = SoundTestData.Entries(name => flipped.GetValueOrDefault(name, SoundType.Ambient)); + var sections = SoundGroups.Sections(drifted); + AssertCardRules(sections, drifted); + Assert.Equal(new[] { SoundType.Danger, SoundType.Speech, SoundType.Ambient }, sections.Select(section => section.Type)); + + var knock = Card(sections, SoundGroup.Doorbell, SoundType.Danger); + Assert.Equal(new[] { "Knock" }, Names(knock.Members)); + Assert.Equal("Doorbell", knock.TitleEntry?.Name); // 묶음 이름은 그대로 초인종이다. + Assert.Equal(new[] { "Doorbell", "Ding-dong", "Tap" }, Names(Card(sections, SoundGroup.Doorbell).Members)); + + var siren = Card(sections, SoundGroup.Sirens, SoundType.Speech); + Assert.Equal(new[] { "Siren" }, Names(siren.Members)); + var otherSirens = Card(sections, SoundGroup.Sirens); + Assert.Equal(4, otherSirens.Members.Count); + Assert.Equal("Siren", otherSirens.TitleEntry?.Name); // 다른 구역으로 간 사이렌의 이름을 그대로 쓴다. + Assert.NotEqual(siren.Id, otherSirens.Id); // 한 묶음이 두 구역에 나뉘어도 카드 이름은 겹치지 않는다. + } + + [Fact] + public void 표에_없는_소리는_그_소리_하나로_구역_끝에_온다() + { + // 모델이 바뀌어 표에 없는 소리가 생겼다고 친다. 하나는 표에서 빠졌고, 하나는 처음 보는 이름이다. + var table = SoundTestData.Table.Where(row => row.Key != "Doorbell").ToDictionary(row => row.Key, row => row.Value); + var list = SoundCatalog.FromNames(SoundTestData.Names.Append("Brand new sound").ToList(), table, _ => SoundType.Ambient); + var sections = SoundGroups.Sections(list); + AssertCardRules(sections, list); + + var singles = Assert.Single(sections).Cards.TakeLast(2).ToList(); + Assert.Equal(new[] { "Doorbell", "Brand new sound" }, singles.Select(card => Assert.Single(card.Members).Name)); + foreach (SoundCard single in singles) + { + Assert.Null(single.Group); + Assert.Equal(single.Members[0], single.TitleEntry); // 제 이름이 카드 이름이다. + Assert.Null(single.TitleGroup); + Assert.Equal(new GroupPreview(Array.Empty(), 0), SoundGroups.Preview(single)); + } + Assert.Equal("sound_" + list.First(entry => entry.Name == "Doorbell").Index + ":ambient", singles[0].Id); + var doorbell = Card(sections, SoundGroup.Doorbell); + Assert.Equal(new[] { "Ding-dong", "Knock", "Tap" }, Names(doorbell.Members)); + Assert.Equal("Doorbell", doorbell.TitleEntry?.Name); // 표에서 빠져도 모델에 있는 소리의 이름은 쓴다. + } + + [Fact] + public void 이름인_소리가_모델에서_사라지면_맨_앞_소리의_이름을_쓴다() + { + var names = SoundTestData.Names.Select(name => name == "Doorbell" ? "Door bell" : name).ToList(); + var sections = SoundGroups.Sections(SoundCatalog.FromNames(names, SoundTestData.Table, _ => SoundType.Ambient)); + Assert.Equal("Ding-dong", Card(sections, SoundGroup.Doorbell).TitleEntry?.Name); + } + + [Fact] + public void 카드_이름은_새로_지은_문구이거나_소리_이름이다() + { + var horns = Card(PlainSections, SoundGroup.Horns); + Assert.Equal(SoundGroup.Horns, horns.TitleGroup); + Assert.Null(horns.TitleEntry); + var sirens = Card(PlainSections, SoundGroup.Sirens); + Assert.Null(sirens.TitleGroup); + Assert.Equal(Entry("Siren"), sirens.TitleEntry); + Assert.Equal("sirens:ambient", sirens.Id); + } + + [Fact] + public void 묶음_상태는_소리마다의_지금_종류로_정한다() + { + var sirens = Card(PlainSections, SoundGroup.Sirens); + Assert.Equal(new GroupState.Uniform(SoundType.Ambient), SoundGroups.State(sirens, NoOverrides)); // 기본 그대로 + Assert.Equal(new GroupState.Uniform(SoundType.Danger), SoundGroups.State(sirens, AllOf(sirens, SoundType.Danger))); // 묶음째 바꾼 것 + Assert.Same(GroupState.Mixed.Instance, SoundGroups.State(sirens, Map(("Civil defense siren", SoundType.Danger)))); // 하나만 바꾼 것 + // 바꿔도 모두 같은 종류면 섞인 것이 아니다. + Assert.Equal(new GroupState.Uniform(SoundType.Speech), SoundGroups.State(sirens, AllOf(sirens, SoundType.Speech))); + } + + [Fact] + public void 섞인_카드가_있는지는_모든_카드를_본다() + { + Assert.False(SoundGroups.AnyMixed(PlainSections, NoOverrides)); + Assert.False(SoundGroups.AnyMixed(PlainSections, Map(("Chainsaw", SoundType.Danger)))); // 소리 하나뿐인 묶음은 섞일 수 없다. + var wholeSirens = AllOf(Card(PlainSections, SoundGroup.Sirens), SoundType.Danger); + Assert.False(SoundGroups.AnyMixed(PlainSections, wholeSirens)); // 묶음째 바꾼 것은 섞인 것이 아니다. + Assert.True(SoundGroups.AnyMixed(PlainSections, With(wholeSirens, ("Doorbell", SoundType.Danger)))); + Assert.True(SoundGroups.AnyMixed(PlainSections, Map(("Singing bowl", SoundType.Speech)))); + + // 구역이 여럿이어도 모두 본다. 사이렌 묶음만 위협음인 목록에서 초인종 묶음은 두 번째(환경음) 구역에 있다. + var two = SoundGroups.Sections(SoundTestData.Entries(name => + SoundTestData.Table.TryGetValue(name, out var row) && row.Group == SoundGroup.Sirens ? SoundType.Danger : SoundType.Ambient)); + Assert.Equal(new[] { SoundType.Danger, SoundType.Ambient }, two.Select(section => section.Type)); + Assert.False(SoundGroups.AnyMixed(two, NoOverrides)); + Assert.True(SoundGroups.AnyMixed(two, Map(("Knock", SoundType.Danger)))); // 첫 구역 밖의 섞인 묶음 + } + + [Fact] + public void 미리보기는_이름인_소리를_빼고_세_개까지_보이고_나머지는_센다() + { + Assert.Equal( + new GroupPreview(new[] { Entry("Ambulance (siren)"), Entry("Fire engine, fire truck (siren)"), Entry("Police car (siren)") }, 1), + SoundGroups.Preview(Card(PlainSections, SoundGroup.Sirens))); + // 새로 지은 이름을 쓰는 묶음은 맨 앞 소리부터 보인다. + Assert.Equal( + new GroupPreview(new[] { Entry("Vehicle horn, car horn, honking"), Entry("Air horn, truck horn"), Entry("Car alarm") }, 0), + SoundGroups.Preview(Card(PlainSections, SoundGroup.Horns))); + var music = Card(PlainSections, SoundGroup.Music); + Assert.Equal(music.Members.Count - 1 - SoundGroups.PreviewSize, SoundGroups.Preview(music).More); + } + + [Fact] + public void 이름인_소리_하나뿐인_묶음은_미리보기가_없다() + { + Assert.Equal(new GroupPreview(Array.Empty(), 0), SoundGroups.Preview(Card(PlainSections, SoundGroup.Chainsaw))); + Assert.Equal(new GroupPreview(Array.Empty(), 0), SoundGroups.Preview(Card(PlainSections, SoundGroup.Slam))); + } + + [Fact] + public void 찾은_소리는_미리보기_앞에_온다() + { + var doorbell = Card(PlainSections, SoundGroup.Doorbell); + Assert.Equal( + new GroupPreview(new[] { Entry("Knock"), Entry("Ding-dong"), Entry("Tap") }, 0), + SoundGroups.Preview(doorbell, new[] { Entry("Knock") })); + // 묶음 이름인 소리는 찾아도 미리보기에 다시 넣지 않는다. + var music = Card(PlainSections, SoundGroup.Music); + var preview = SoundGroups.Preview(music, new[] { Entry("Music"), Entry("Singing bowl"), Entry("Piano") }); + Assert.Equal(new[] { Entry("Piano"), Entry("Singing bowl"), Entry("Musical instrument") }, preview.Names); + Assert.Equal(music.Members.Count - 1 - SoundGroups.PreviewSize, preview.More); + } + + [Fact] + public void 덮어쓰는_수는_고른_종류와_지금_종류가_다른_소리다() + { + var sirens = Card(PlainSections, SoundGroup.Sirens); + var one = Map(("Civil defense siren", SoundType.Danger)); + Assert.Equal(1, SoundGroups.OverwriteCount(sirens, one, SoundType.Ambient)); // 기본을 고르면 하나씩 바꾼 소리만 바뀐다. + Assert.Equal(4, SoundGroups.OverwriteCount(sirens, one, SoundType.Danger)); + Assert.Equal(5, SoundGroups.OverwriteCount(sirens, one, SoundType.Speech)); + Assert.Equal(0, SoundGroups.OverwriteCount(sirens, NoOverrides, SoundType.Ambient)); + } + + [Fact] + public void 하나씩_바꾼_소리의_종류가_바뀔_때만_묻는다() + { + // 사이렌 카드의 기본 종류는 환경음이다(Plain). + var sirens = Card(PlainSections, SoundGroup.Sirens); + bool Asks(IReadOnlyDictionary overrides, SoundType picked) => SoundGroups.OverwritesOneByOne(sirens, overrides, picked); + + foreach (SoundType type in Types) Assert.False(Asks(NoOverrides, type), $"바꾼 것이 없다: {type}"); + + var whole = AllOf(sirens, SoundType.Danger); + Assert.False(Asks(whole, SoundType.Speech)); // 묶음째 바꾼 카드는 묻지 않는다. + Assert.False(Asks(whole, SoundType.Ambient)); // 묶음째 바꾼 카드는 기본 종류를 골라도 묻지 않는다. + + var one = Map(("Civil defense siren", SoundType.Danger)); + Assert.False(Asks(one, SoundType.Danger)); // 하나씩 바꾼 소리와 같은 종류를 고르면 그 소리는 그대로다. + Assert.True(Asks(one, SoundType.Ambient)); // 기본 종류를 고르면 하나씩 바꾼 것이 되돌려진다. + Assert.True(Asks(one, SoundType.Speech)); + + var two = With(one, ("Police car (siren)", SoundType.Speech)); + foreach (SoundType type in Types) + { + Assert.True(Asks(two, type), $"하나씩 바꾼 소리 둘의 종류가 다르면 어느 쪽을 골라도 하나는 바뀐다: {type}"); + } + + // 묶음째 위협음으로 바꾼 뒤 민방위 사이렌만 기본 종류로 되돌린 카드. 저장값으로는 되돌린 소리와 건드리지 않은 소리를 가릴 수 + // 없어서, 위협음을 고르면 묻지 않는다(알고 둔 한계). + var reverted = new Dictionary(whole); + reverted.Remove("Civil defense siren"); + Assert.Same(GroupState.Mixed.Instance, SoundGroups.State(sirens, reverted)); + Assert.False(Asks(reverted, SoundType.Danger)); + Assert.True(Asks(reverted, SoundType.Ambient)); + + var chainsaw = Card(PlainSections, SoundGroup.Chainsaw); + var changedChainsaw = Map(("Chainsaw", SoundType.Danger)); + foreach (SoundType type in Types) + { + Assert.False(SoundGroups.OverwritesOneByOne(chainsaw, changedChainsaw, type)); // 소리 하나뿐인 카드는 섞이지 않는다. + } + } + + [Fact] + public void 남길_소리는_찾는_말과_거르기가_그대로인_동안만_남는다() + { + var empty = KeptSounds.Empty; + Assert.Empty(empty.NamesFor("", SoundFilter.All)); + + var kept = empty.Plus("사이렌", SoundFilter.Danger, new[] { "Siren" }); + Assert.Equal(new[] { "Siren" }, kept.NamesFor("사이렌", SoundFilter.Danger)); + Assert.Empty(kept.NamesFor("사이레", SoundFilter.Danger)); // 찾는 말이 다르다. + Assert.Empty(kept.NamesFor("사이렌", SoundFilter.Changed)); // 거르기가 다르다. + + var more = kept.Plus("사이렌", SoundFilter.Danger, new[] { "Civil defense siren" }); + // 같은 찾는 말과 거르기에서는 쌓인다. + Assert.True(more.NamesFor("사이렌", SoundFilter.Danger).SetEquals(new[] { "Siren", "Civil defense siren" })); + + var restarted = more.Plus("", SoundFilter.Danger, new[] { "Doorbell" }); + Assert.Equal(new[] { "Doorbell" }, restarted.NamesFor("", SoundFilter.Danger)); // 찾는 말이 바뀌면 앞의 것은 버린다. + Assert.Equal(new[] { "Knock" }, restarted.Plus("", SoundFilter.Speech, new[] { "Knock" }).Names); + Assert.Equal(KeptSounds.Empty, restarted.Plus("", SoundFilter.All, new[] { "Siren" })); // 전체에서는 남기지 않는다. + Assert.Equal( + new KeptSounds("a", SoundFilter.Danger, new HashSet { "x", "y" }), + new KeptSounds("a", SoundFilter.Danger, new HashSet { "y", "x" })); // 집합은 순서와 상관없이 같다. + } +} diff --git a/SoundVisualizer.Tests/Sounds/SoundIndexTests.cs b/SoundVisualizer.Tests/Sounds/SoundIndexTests.cs new file mode 100644 index 0000000..fed2089 --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundIndexTests.cs @@ -0,0 +1,239 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 분류 탭의 색인(#368). 화면 언어로 한 번 만들어 두고(언어 하나의 것만 둔다), 찾는 결과는 바탕 계산을 그때그때 하는 것과 같아야 한다. +/// 모바일 SoundIndexTest 를 옮겼다. 캐시는 테스트마다 따로 만든다. +/// +public class SoundIndexTests +{ + private static readonly IReadOnlyList Entries = SoundTestData.Entries(); + + private static readonly IReadOnlyDictionary EnglishTitles = SoundData.GroupTitles(SoundLanguages.English); + + /// 앱()이 넘기는 것과 같은 값으로 색인을 만든다. + private static SoundIndex Build(string language, IReadOnlyList? labels = null) => + SoundCatalog.BuildIndex(language, Entries, labels ?? SoundData.Names(language), SoundData.GroupTitles(language), EnglishTitles); + + /// + /// 색인 없이 그때그때 하는 바탕 계산(모바일 #368 전의 분류 탭이 그릴 때마다 만들던 값): 이름 배열, 소리마다의 찾기 값, 묶음, + /// 영어 이름과 이은 묶음 이름 값. + /// + private sealed class Before + { + public Before(string language, IReadOnlyList labels) + { + LabelOf = entry => entry.Index < labels.Count ? labels[entry.Index] : entry.Name; + SearchKeys = Entries.Select(entry => SoundCatalog.SearchKey(LabelOf(entry), entry.Name)).ToList(); + Sections = SoundGroups.Sections(Entries); + var local = SoundData.GroupTitles(language); + TitleKeys = SoundGroups.TitleKeys(local: group => local[group], english: group => EnglishTitles[group]); + } + + public Func LabelOf { get; } + + public List SearchKeys { get; } + + public IReadOnlyList Sections { get; } + + public IReadOnlyDictionary TitleKeys { get; } + } + + [Fact] + public void 색인은_언어마다_한_번만_만들고_다시_쓴다() + { + var cache = new SoundIndexCache(SoundTestData.Names); + int builds = 0; + var first = cache.GetOrBuild("ko", code => { builds++; return Build(code); }); + Assert.Same(first, cache.CachedOrNull("ko")); + // 탭에 다시 들어올 때다. + for (int i = 0; i < 3; i++) + { + Assert.Same(first, cache.GetOrBuild("ko", code => { builds++; return Build(code); })); + } + Assert.Same(first, cache.GetOrBuild("ko")); + Assert.Equal(1, builds); // 이름 521개를 읽고 정규화를 1,042번 하는 일은 한 번이다. + } + + [Fact] + public void 언어가_다르면_색인을_따로_만들고_다른_언어의_것을_쓰지_않는다() + { + var cache = new SoundIndexCache(SoundTestData.Names); + var ko = cache.GetOrBuild("ko"); + Assert.Null(cache.CachedOrNull("en")); // 영어로는 아직 만들지 않았다. + int builds = 0; + var en = cache.GetOrBuild("en", code => { builds++; return Build(code); }); + Assert.Equal(1, builds); + Assert.NotSame(ko, en); + Assert.Same(en, cache.CachedOrNull("en")); + Assert.Null(cache.CachedOrNull("ko")); // 두는 것은 마지막 언어의 것 하나다. + + var siren = Entries.First(entry => entry.Name == "Siren"); + Assert.Equal("사이렌", ko.LabelOf(siren)); + Assert.Equal("Siren", en.LabelOf(siren)); + Assert.Contains(SoundCatalog.Normalize("부엌"), ko.TitleKeys[SoundGroup.Kitchen]); // 한국어 색인은 한국어 묶음 이름으로 찾는다. + Assert.DoesNotContain(SoundCatalog.Normalize("부엌"), en.TitleKeys[SoundGroup.Kitchen]); // 영어 색인에는 한국어 묶음 이름이 없다. + } + + [Fact] + public void 언어_코드는_대소문자를_가리지_않고_없는_언어는_영어로_만든다() + { + var cache = new SoundIndexCache(SoundTestData.Names); + var ptBr = cache.GetOrBuild("PT-br"); + Assert.Equal("pt-BR", ptBr.Language); + Assert.Same(ptBr, cache.CachedOrNull("pt-BR")); + + var unknown = cache.GetOrBuild("xx-YY"); + Assert.Equal(SoundLanguages.English, unknown.Language); + Assert.Same(unknown, cache.CachedOrNull(null)); + Assert.Equal("Siren", unknown.LabelOf(Entries.First(entry => entry.Name == "Siren"))); + } + + [Fact] + public async Task 이미_만든_색인은_기다리지_않고_돌려준다() + { + var cache = new SoundIndexCache(SoundTestData.Names); + var built = await cache.GetOrBuildAsync("ja"); + Assert.Equal("ja", built.Language); + var again = cache.GetOrBuildAsync("ja"); + Assert.True(again.IsCompletedSuccessfully); // 탭에 다시 들어오면 바로 그린다. + Assert.Same(built, await again); + } + + [Fact] + public void 카드_이름은_그_언어의_소리_이름이거나_새로_지은_묶음_이름이다() + { + var cache = new SoundIndexCache(SoundTestData.Names); + var ko = cache.GetOrBuild("ko"); + SoundCard Card(SoundIndex index, SoundGroup group) => + index.Sections.SelectMany(section => section.Cards).First(card => card.Group == group); + + Assert.Equal("사이렌", ko.CardTitle(Card(ko, SoundGroup.Sirens))); + Assert.Equal("경적·자동차 경보음", ko.CardTitle(Card(ko, SoundGroup.Horns))); + Assert.Equal("말소리·속삭임", ko.CardTitle(Card(ko, SoundGroup.Talking))); + + var en = cache.GetOrBuild("en"); + Assert.Equal("Siren", en.CardTitle(Card(en, SoundGroup.Sirens))); + Assert.Equal("Car horns and car alarms", en.CardTitle(Card(en, SoundGroup.Horns))); + } + + [Fact] + public void 색인으로_찾은_결과는_바탕_계산을_그때그때_한_것과_같다() + { + // SoundGroupSearchTests 와 SoundCatalogTests 가 보는 찾는 말들이다. 한국어·영어 소리 이름, 두 언어의 묶음 이름, 문장 부호와 + // 띄어쓰기, 한글을 치는 중의 모양, 맞지 않는 말. + string[] queries = + { + "", " ", "사이렌", "노크", "경적", "초인종", "부엌", "부엌 요리", "부엌·요리", "경적 자동차", "TV 라디오", + "tv radio", "kitchen", "Kitchen and cooking", "knock", "Siren", "SIRENE", "firetruck", + "fire engine fire truck siren", "Fire engine, fire truck (siren)", "civil defense", "door", + "ㅅ", "상", "사일", "홪", "화재 경봉", "닭 울", "노크 소리knock", "비rain", "zzzz", + }; + var overridesList = new IReadOnlyDictionary[] + { + new Dictionary(), + new Dictionary { ["Doorbell"] = SoundTypeLabels.Danger, ["Siren"] = SoundTypeLabels.Ambient }, + }; + // 이름 배열이 소리 수보다 짧은 경우(모델이 바뀐 직후 등)도 모델 이름으로 찾는다. + var cases = new (string Language, IReadOnlyList Labels)[] + { + ("ko", SoundData.Names("ko")), + ("en", SoundData.Names("en")), + ("ko", SoundData.Names("ko").Take(100).ToList()), + }; + foreach (var (language, labels) in cases) + { + var before = new Before(language, labels); + var index = Build(language, labels); + Assert.Same(Entries, index.Entries); + Assert.Equal(Entries.Select(before.LabelOf), Entries.Select(index.LabelOf)); + Assert.Equal(before.SearchKeys, index.SearchKeys); + Assert.Equal(before.Sections, index.Sections); + Assert.Equal(before.TitleKeys.OrderBy(pair => pair.Key), index.TitleKeys.OrderBy(pair => pair.Key)); + foreach (var overrides in overridesList) + { + foreach (SoundFilter filter in Enum.GetValues()) + { + foreach (string query in queries) + { + var expected = SoundGroups.Filter(before.Sections, before.SearchKeys, before.TitleKeys, overrides, query, filter); + var actual = index.Filter(overrides, query, filter); + Assert.True(expected.SequenceEqual(actual), $"{language}({labels.Count}) \"{query}\" {filter} {overrides.Count}"); + } + } + } + } + + // 위의 비교가 빈 결과끼리만 맞춘 것이 아니다. + var ko = Build("ko"); + List Groups(string query) => + ko.Filter(new Dictionary(), query, SoundFilter.All) + .SelectMany(section => section.Cards).Select(card => card.Card.Group).ToList(); + Assert.Equal(new SoundGroup?[] { SoundGroup.Kitchen }, Groups("부엌 요리")); + Assert.Equal(new SoundGroup?[] { SoundGroup.Kitchen }, Groups("Kitchen and cooking")); + Assert.Equal(new SoundGroup?[] { SoundGroup.Background }, Groups("tv radio")); + Assert.Equal(new SoundGroup?[] { SoundGroup.Sirens }, Groups("fire engine fire truck siren")); + } + + [Fact] + public async Task 만드는_중에_탭을_떠나도_다_만든_색인이_남고_다시_연_탭은_그것을_기다려_쓴다() + { + var cache = new SoundIndexCache(SoundTestData.Names); + using var started = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + int builds = 0; + // 탭이 하는 대로 스레드 풀에서 색인을 만든다. + var first = Task.Run(() => cache.GetOrBuild("ko", code => + { + Interlocked.Increment(ref builds); + started.Set(); + // 기다림이 실패해도 테스트가 멈추지 않게 끝을 둔다. + release.Wait(TimeSpan.FromSeconds(30)); + return Build(code); + })); + Assert.True(started.Wait(TimeSpan.FromSeconds(10))); + + // 만드는 중에 탭을 떠나 기다리기를 그만둔다. + using (var leave = new CancellationTokenSource()) + { + var waiting = first.WaitAsync(leave.Token); + leave.Cancel(); + await Assert.ThrowsAnyAsync(() => waiting); + } + + // 곧 탭이 다시 열려 색인을 찾는다. 앞의 것이 끝나기를 기다려야 한다(따로 만들기 시작하면 안 된다). + SoundIndex? second = null; + var waiter = new Thread(() => second = cache.GetOrBuild("ko", code => + { + Interlocked.Increment(ref builds); + return Build(code); + })) + { IsBackground = true }; + waiter.Start(); + // 확인이 실패해도 앞의 것을 풀어 준다. 풀지 않으면 앞의 것이 자물쇠를 쥔 채 남는다. + try + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while ((waiter.ThreadState & ThreadState.WaitSleepJoin) == 0 && Volatile.Read(ref builds) < 2) + { + Assert.True(DateTime.UtcNow < deadline, "뒤의 탭이 색인을 찾으러 오지 않았다"); + Thread.Sleep(1); + } + } + finally + { + release.Set(); + } + var built = await first; + Assert.True(waiter.Join(TimeSpan.FromSeconds(30))); + + Assert.Same(built, cache.CachedOrNull("ko")); // 기다리기를 그만둔 쪽이 만든 색인도 캐시에 남는다. + Assert.Same(built, second); // 다시 연 탭은 앞에서 만든 색인을 쓴다. + Assert.Equal(1, Volatile.Read(ref builds)); // 색인은 한 번만 만든다. + } +} diff --git a/SoundVisualizer.Tests/Sounds/SoundListTests.cs b/SoundVisualizer.Tests/Sounds/SoundListTests.cs new file mode 100644 index 0000000..19758f4 --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundListTests.cs @@ -0,0 +1,173 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 소리 탭 목록의 줄(모바일 ClassifyTab 의 LazyColumn 항목)과 결과까지 올릴지의 규칙. +/// 기본 종류는 매핑과 상관없이 정해 둔다(사이렌·경보음·전기톱 묶음만 위협음, 나머지는 환경음). 이름은 실제 영어 데이터다. +/// +public class SoundListTests +{ + private static readonly IReadOnlyList Entries = SoundTestData.Entries(name => + SoundTestData.Table.TryGetValue(name, out var row) && row.Group is SoundGroup.Sirens or SoundGroup.Alarms or SoundGroup.Chainsaw + ? SoundType.Danger + : SoundType.Ambient); + + private static readonly SoundIndex Index = SoundCatalog.BuildIndex( + SoundLanguages.English, Entries, SoundData.Names(SoundLanguages.English), + SoundData.GroupTitles(SoundLanguages.English), SoundData.GroupTitles(SoundLanguages.English)); + + private static readonly Dictionary NoOverrides = new(); + + private static IReadOnlyList Build( + IReadOnlyDictionary? overrides = null, + string query = "", + SoundFilter filter = SoundFilter.All, + KeptSounds? kept = null, + bool advanced = false) => + SoundList.Build(Index, overrides ?? NoOverrides, query, filter, kept ?? KeptSounds.Empty, advanced); + + private static SoundCard Card(SoundGroup group) => + Index.Sections.SelectMany(section => section.Cards).Single(card => card.Group == group); + + private static Dictionary AllOf(SoundCard card, SoundType type) => + card.Members.ToDictionary(entry => entry.Name, _ => type.ToLabel()); + + private static List Cards(IReadOnlyList items) => items.OfType().ToList(); + + [Fact] + public void 기본_모드는_소개_바꾼_수_구역과_카드_끝_여백의_차례다() + { + var items = Build(); + + Assert.Same(SoundListItem.Intro.Instance, items[0]); + Assert.Equal(new SoundListItem.Changed(0), items[SoundList.ResultsIndex]); + Assert.Same(SoundListItem.End.Instance, items[^1]); + Assert.DoesNotContain(SoundListItem.Empty.Instance, items); + + // 구역 머리 밑에 그 구역의 카드가 색인의 차례 그대로 온다. 고급 모드의 칸은 없다. + var expected = new List(); + foreach (SoundSection section in Index.Sections) + { + expected.Add(new SoundListItem.SectionHeading(section.Type)); + expected.AddRange(section.Cards.Select(card => (SoundListItem)new SoundListItem.GroupCard( + new ShownCard(card, card.Members), new GroupState.Uniform(card.DefaultType), SoundGroups.Preview(card, card.Members)))); + } + Assert.Equal(expected, items.Skip(2).SkipLast(1)); + Assert.Equal(new[] { SoundType.Danger, SoundType.Ambient }, items.OfType().Select(heading => heading.Type)); + } + + [Fact] + public void 고급_모드는_구역_머리_묶음_머리_소리_줄을_편다() + { + var overrides = new Dictionary { ["Siren"] = SoundTypeLabels.Speech }; + + var items = Build(overrides, advanced: true); + + Assert.Same(SoundListItem.Intro.Instance, items[0]); + Assert.Equal(new SoundListItem.Changed(1), items[1]); + Assert.Same(SoundListItem.End.Instance, items[^1]); + Assert.Empty(Cards(items)); + var advanced = SoundGroups.AdvancedItems(Index.Filter(overrides, "", SoundFilter.All)); + Assert.Equal(advanced.Select(item => item.Key), items.Skip(2).SkipLast(1).Select(item => item.Key)); + // 소리 줄은 바꾼 것까지 반영한 지금 종류를 든다. + var siren = Assert.Single(items.OfType(), row => row.Entry.Name == "Siren"); + Assert.Equal(SoundType.Speech, siren.Type); + Assert.All(items.OfType().Where(row => row.Entry.Name != "Siren"), row => Assert.Equal(row.Entry.DefaultType, row.Type)); + } + + [Fact] + public void 맞는_소리가_없으면_바꾼_수_밑에_빈_결과_안내를_둔다() + { + foreach (bool advanced in new[] { false, true }) + { + var items = Build(query: "zzzzqqq", advanced: advanced); + + Assert.Equal(new[] { "intro", "changed", "empty", "end" }, items.Select(item => item.Key)); + } + } + + [Fact] + public void 바꾼_수는_묶음이_아니라_소리를_센다() + { + SoundCard sirens = Card(SoundGroup.Sirens); + var overrides = AllOf(sirens, SoundType.Ambient); + overrides["Doorbell"] = SoundTypeLabels.Danger; + + var items = Build(overrides); + + Assert.Equal(new SoundListItem.Changed(sirens.Members.Count + 1), items[1]); + Assert.Equal(Index.CountChanged(overrides), ((SoundListItem.Changed)items[1]).Count); + } + + [Fact] + public void 카드는_지금_종류와_찾는_말에_맞은_소리를_앞에_둔_미리보기를_든다() + { + SoundCard sirens = Card(SoundGroup.Sirens); + var mixed = new Dictionary { ["Police car (siren)"] = SoundTypeLabels.Ambient }; + + var card = Assert.Single(Cards(Build(mixed, query: "police"))); + + Assert.Equal(sirens, card.Shown.Card); + Assert.Equal(new[] { "Police car (siren)" }, SoundTestData.NamesOf(card.Shown.Shown)); + Assert.Same(GroupState.Mixed.Instance, card.State); + Assert.Equal("Police car (siren)", card.Preview.Names[0].Name); + Assert.Equal(SoundGroups.Preview(sirens, card.Shown.Shown), card.Preview); + + var changed = Assert.Single(Cards(Build(AllOf(sirens, SoundType.Speech), query: "police"))); + Assert.Equal(new GroupState.Uniform(SoundType.Speech), changed.State); + } + + [Fact] + public void 거르기를_켠_채로_바꾼_카드는_찾는_말과_거르기가_그대로일_때만_남는다() + { + SoundCard sirens = Card(SoundGroup.Sirens); + var overrides = AllOf(sirens, SoundType.Ambient); // 위협음 거르기에서 빠진다 + var kept = KeptSounds.Empty.Plus("", SoundFilter.Danger, sirens.Members.Select(entry => entry.Name)); + + Assert.Contains(Cards(Build(overrides, filter: SoundFilter.Danger, kept: kept)), card => card.Shown.Card == sirens); + Assert.DoesNotContain(Cards(Build(overrides, filter: SoundFilter.Danger)), card => card.Shown.Card == sirens); + // 찾는 말이나 거르기가 남기기 시작할 때와 다르면 남기지 않는다. + Assert.DoesNotContain(Cards(Build(overrides, query: "s", filter: SoundFilter.Danger, kept: kept)), card => card.Shown.Card == sirens); + Assert.DoesNotContain(Cards(Build(overrides, filter: SoundFilter.Speech, kept: kept)), card => card.Shown.Card == sirens); + } + + [Fact] + public void 키는_겹치지_않고_두_모드의_구역_머리는_키가_같다() + { + var basic = Build().Select(item => item.Key).ToList(); + var advanced = Build(advanced: true).Select(item => item.Key).ToList(); + + Assert.Equal(basic.Count, basic.Distinct().Count()); + Assert.Equal(advanced.Count, advanced.Distinct().Count()); + Assert.Equal(basic.Where(key => key.StartsWith("s:", StringComparison.Ordinal)), advanced.Where(key => key.StartsWith("s:", StringComparison.Ordinal))); + Assert.Equal(new[] { "s:danger", "s:ambient" }, basic.Where(key => key.StartsWith("s:", StringComparison.Ordinal))); + Assert.Contains("c:sirens:danger", basic); + Assert.Contains("g:sirens:danger", advanced); + Assert.Contains("r:" + SoundCatalog.IndexOf(Entries, "Siren"), advanced); + } + + [Fact] + public void 줄의_차례는_바꾼_종류와_상관없다() + { + // 바꾼 카드는 다른 구역으로 옮겨 가지 않는다. 그래서 종류를 골라도 화면은 줄을 옮기지 않고 고쳐 쓰기만 한다. + var before = Build().Select(item => item.Key); + var after = Build(AllOf(Card(SoundGroup.Sirens), SoundType.Ambient)).Select(item => item.Key); + + Assert.Equal(before, after); + } + + [Theory] + [InlineData("", false, false)] + [InlineData("", true, true)] + [InlineData("bell", false, true)] + [InlineData("bell", true, true)] + public void 찾는_말이_있으면_맨_위에서도_결과까지_올리고_거르기만_바꾸면_내려가_있을_때만_올린다(string query, bool introScrolledAway, bool expected) + { + Assert.Equal(expected, SoundList.ShouldScrollToResults(query, introScrolledAway)); + } +} diff --git a/SoundVisualizer.Tests/Sounds/SoundNamesDataTests.cs b/SoundVisualizer.Tests/Sounds/SoundNamesDataTests.cs new file mode 100644 index 0000000..be4dbeb --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundNamesDataTests.cs @@ -0,0 +1,140 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Text.RegularExpressions; +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 분류 탭의 소리 이름(Sounds/Data/SoundNames/{언어}.json)을 검사한다. 모바일 SoundNamesResourceTest 를 옮기고, JSON 으로 옮긴 데이터를 +/// 몇 가지 더 본다. +/// +/// - 영어는 모델의 클래스 목록(yamnet_class_map.csv)과 번호·이름이 같아야 한다. 탭은 이 번호로 번역된 이름과 모델 이름을 짝짓고, +/// 사용자가 고른 종류는 모델 이름으로 저장한다. 하나라도 밀리면 다른 소리의 이름을 보고 종류를 바꾸게 된다. +/// - 번역은 언어마다 전부 있어야 한다. 일부만 있으면 목록에 두 언어가 섞여 보인다. +/// - 한 언어 안에서 두 소리가 같은 이름이면 어느 줄이 어느 소리인지 알 수 없다. +/// +public class SoundNamesDataTests +{ + [Fact] + public void 영어_이름은_모델의_클래스_목록과_번호와_이름이_같다() + { + Assert.Equal(YamnetCoarseClassifier.NumClasses, SoundTestData.Names.Count); + Assert.Equal(SoundTestData.Names, SoundTestData.NamesFile(SoundLanguages.English)); + } + + [Fact] + public void 언어마다_이름_521개를_번호_순서의_배열로_둔다() + { + var problems = new List(); + foreach (string language in SoundLanguages.All) + { + var names = SoundTestData.NamesFile(language); + if (names.Count != YamnetCoarseClassifier.NumClasses) problems.Add($"{language}: {names.Count}개"); + var empty = Enumerable.Range(0, names.Count).Where(i => string.IsNullOrWhiteSpace(names[i])).ToList(); + if (empty.Count > 0) problems.Add($"{language}: 빈 이름 {string.Join(", ", empty)}"); + } + Assert.True(problems.Count == 0, "번역은 521개를 모두 넣습니다:\n" + string.Join("\n", problems)); + } + + [Fact] + public void 한국어_이름이_있다() + { + Assert.Contains("ko", SoundData.EmbeddedLanguages().Names); + } + + [Fact] + public void 한_언어_안에서_두_소리가_같은_이름을_쓰지_않는다() + { + // 대소문자와 공백만 다른 이름도 같은 이름으로 본다. + var problems = new List(); + foreach (string language in SoundLanguages.All) + { + var names = SoundTestData.NamesFile(language); + problems.AddRange(Enumerable.Range(0, names.Count) + .GroupBy(i => Regex.Replace(names[i].ToLowerInvariant(), @"\s+", " ").Trim()) + .Where(same => same.Count() > 1) + .Select(same => $"{language}: \"{same.Key}\" ← {string.Join(", ", same)}")); + } + Assert.True(problems.Count == 0, "같은 이름은 어느 줄이 어느 소리인지 알 수 없습니다:\n" + string.Join("\n", problems)); + } + + [Fact] + public void 이름은_비어_있지_않고_앞뒤에_공백이_없다() + { + var problems = new List(); + foreach (string language in SoundLanguages.All) + { + var names = SoundTestData.NamesFile(language); + problems.AddRange(Enumerable.Range(0, names.Count) + .Where(i => string.IsNullOrWhiteSpace(names[i]) || names[i] != names[i].Trim()) + .Select(i => $"{language}: {i}")); + } + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + [Fact] + public void 데이터가_있는_언어는_모두_지원_언어이고_지원_언어는_모두_데이터가_있다() + { + var (names, titles) = SoundData.EmbeddedLanguages(); + Assert.True(SoundLanguages.All.ToHashSet().SetEquals(names), "SoundNames: " + string.Join(", ", names)); + Assert.True(SoundLanguages.All.ToHashSet().SetEquals(titles), "GroupTitles: " + string.Join(", ", titles)); + Assert.Equal(18, SoundLanguages.All.Count); + Assert.Equal(SoundLanguages.English, SoundLanguages.All[0]); + } + + [Fact] + public void 한국어와_영어_이름을_몇_개_맞춰_본다() + { + var en = SoundData.Names("en"); + var ko = SoundData.Names("ko"); + Assert.Equal("Speech", en[0]); + Assert.Equal("말소리", ko[0]); + Assert.Equal("Siren", en[390]); + Assert.Equal("사이렌", ko[390]); + Assert.Equal("Alarm", en[382]); + Assert.Equal("경보음", ko[382]); + Assert.Equal("Gunshot, gunfire", en[421]); + Assert.Equal("총소리", ko[421]); + Assert.Equal("Knock", en[SoundTestData.Names.ToList().IndexOf("Knock")]); + Assert.Equal("노크 소리", ko[SoundTestData.Names.ToList().IndexOf("Knock")]); + Assert.Equal("Rhythm and blues", en[221]); + Assert.Equal("R&B", ko[221]); // & 를 풀었다. + Assert.Equal("Dental drill, dentist's drill", en[339]); // \' 를 풀었다. + Assert.Equal("Parole d'enfant", SoundData.Names("fr")[1]); + Assert.Equal("Nhạc R&B", SoundData.Names("vi")[221]); + Assert.Equal("Signaux d'alerte des véhicules", SoundData.GroupTitles("fr")[SoundGroup.VehicleWarnings]); + Assert.Equal("Clacson e allarmi dell'auto", SoundData.GroupTitles("it")[SoundGroup.Horns]); + Assert.Equal("부엌·요리", SoundData.GroupTitles("ko")[SoundGroup.Kitchen]); + Assert.Equal("Kitchen and cooking", SoundData.GroupTitles("en")[SoundGroup.Kitchen]); + } + + [Fact] + public void 안드로이드_이스케이프는_남아_있지_않다() + { + // 모바일 문구 파일은 \' 와 & 를 쓴다. 옮긴 JSON 에는 푼 글자만 있어야 한다. + var problems = new List(); + foreach (string language in SoundLanguages.All) + { + var texts = SoundTestData.NamesFile(language).Concat(SoundTestData.GroupTitlesFile(language).Values); + problems.AddRange(texts + .Where(text => text.Contains('\\') || text.Contains("&", StringComparison.Ordinal) || text.Contains('"') || text.Contains('\n')) + .Select(text => $"{language}: {text}")); + } + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + [Fact] + public void 없는_언어와_빠진_이름은_영어로_채운다() + { + Assert.Equal(SoundData.Names("en"), SoundData.Names("xx")); + Assert.Equal(SoundData.Names("en"), SoundData.Names(null)); + Assert.Same(SoundData.Names("ko"), SoundData.Names("KO")); // 대소문자는 가리지 않고, 언어마다 한 번만 읽는다. + Assert.Equal(SoundData.GroupTitles("en"), SoundData.GroupTitles("xx")); + Assert.Equal(15, SoundData.GroupTitles("th").Count); + Assert.Equal(SoundLanguages.English, SoundLanguages.Resolve("zh")); + Assert.Equal("zh-Hant", SoundLanguages.Resolve("ZH-HANT")); + } +} diff --git a/SoundVisualizer.Tests/Sounds/SoundRowTextTests.cs b/SoundVisualizer.Tests/Sounds/SoundRowTextTests.cs new file mode 100644 index 0000000..06bfe5c --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundRowTextTests.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 소리 탭 줄의 글 꼴(모바일 GroupCard·SoundTypeRow 의 description 과 accent 갈래). 문구는 앱의 문자열 표에 있고, 여기서는 어느 +/// 꼴을 고르는지만 본다. +/// +public class SoundRowTextTests +{ + // 전기톱 묶음은 이름이 곧 묶음 이름인 소리 하나뿐이라 미리보기가 없다. 사이렌 묶음은 미리보기가 있다. + private static readonly SoundEntry Chainsaw = new(0, "Chainsaw", SoundType.Danger, SoundGroup.Chainsaw, 0); + private static readonly SoundEntry Siren = new(1, "Siren", SoundType.Danger, SoundGroup.Sirens, 1); + private static readonly SoundEntry Ambulance = new(2, "Ambulance (siren)", SoundType.Danger, SoundGroup.Sirens, 2); + private static readonly SoundEntry Police = new(3, "Police car (siren)", SoundType.Danger, SoundGroup.Sirens, 3); + + private static readonly SoundCard ChainsawCard = new(SoundGroup.Chainsaw, SoundType.Danger, new[] { Chainsaw }, Chainsaw); + private static readonly SoundCard SirensCard = new(SoundGroup.Sirens, SoundType.Danger, new[] { Siren, Ambulance, Police }, Siren); + + private static SoundListItem.GroupCard Item(SoundCard card, params (string Name, SoundType Type)[] changed) + { + var overrides = changed.ToDictionary(value => value.Name, value => value.Type.ToLabel()); + return new SoundListItem.GroupCard(new ShownCard(card, card.Members), SoundGroups.State(card, overrides), SoundGroups.Preview(card, card.Members)); + } + + [Fact] + public void 미리보기가_없는_카드는_소리_한_줄처럼_읽는다() + { + Assert.Empty(Item(ChainsawCard).Preview.Names); + Assert.Equal(SoundRowSpeech.Sound, SoundRowText.SpeechOf(Item(ChainsawCard))); + Assert.Equal(SoundRowSpeech.SoundChanged, SoundRowText.SpeechOf(Item(ChainsawCard, ("Chainsaw", SoundType.Ambient)))); + } + + [Fact] + public void 묶음_카드는_소리_수와_미리보기를_함께_읽고_바꿨으면_기본_종류도_읽는다() + { + Assert.Equal(SoundRowSpeech.Group, SoundRowText.SpeechOf(Item(SirensCard))); + Assert.Equal(SoundRowSpeech.GroupChanged, SoundRowText.SpeechOf(Item(SirensCard, + ("Siren", SoundType.Speech), ("Ambulance (siren)", SoundType.Speech), ("Police car (siren)", SoundType.Speech)))); + // 일부만 바꾼 카드는 지금 종류가 "일부 다름" 이라 기본 종류를 함께 읽는다(모바일 current == null). + Assert.Equal(SoundRowSpeech.GroupChanged, SoundRowText.SpeechOf(Item(SirensCard, ("Police car (siren)", SoundType.Ambient)))); + } + + [Fact] + public void 이름_밑에는_묶음째_바꾼_것과_일부만_바꾼_것을_적는다() + { + Assert.Equal(SoundRowNote.None, SoundRowText.NoteOf(Item(SirensCard))); + Assert.Equal(SoundRowNote.Mixed, SoundRowText.NoteOf(Item(SirensCard, ("Police car (siren)", SoundType.Ambient)))); + Assert.Equal(SoundRowNote.GroupChanged, SoundRowText.NoteOf(Item(SirensCard, + ("Siren", SoundType.Ambient), ("Ambulance (siren)", SoundType.Ambient), ("Police car (siren)", SoundType.Ambient)))); + Assert.Equal(SoundRowNote.GroupChanged, SoundRowText.NoteOf(Item(ChainsawCard, ("Chainsaw", SoundType.Speech)))); + } + + [Fact] + public void 소리_한_줄은_바꿨을_때만_기본_종류를_적고_읽는다() + { + var same = new SoundListItem.Sound(Police, SoundType.Danger); + var changed = new SoundListItem.Sound(Police, SoundType.Ambient); + + Assert.Equal(SoundRowSpeech.Sound, SoundRowText.SpeechOf(same)); + Assert.Equal(SoundRowNote.None, SoundRowText.NoteOf(same)); + Assert.Equal(SoundRowSpeech.SoundChanged, SoundRowText.SpeechOf(changed)); + Assert.Equal(SoundRowNote.SoundChanged, SoundRowText.NoteOf(changed)); + } + + [Fact] + public void 미리보기의_기호와_나머지_수는_번역하지_않는다() + { + Assert.Equal(" • ", SoundRowText.ShownSeparator); + Assert.Equal(", ", SoundRowText.SpokenSeparator); + Assert.Equal("+12", SoundRowText.More(12)); + } +} diff --git a/SoundVisualizer.Tests/Sounds/SoundTestData.cs b/SoundVisualizer.Tests/Sounds/SoundTestData.cs new file mode 100644 index 0000000..173a82e --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundTestData.cs @@ -0,0 +1,61 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Text; +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; +using SoundVisualizer.Tests.Ai; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 분류 탭 테스트들이 함께 읽는 실제 데이터(모바일 SoundGroupFixtures). 앱과 같은 클래스 목록(Models/yamnet_class_map.csv), +/// Core 에 넣어 둔 묶음 표와 이름을 읽는다. +/// +internal static class SoundTestData +{ + /// 모델의 소리 이름 521개. 번호 순서다. + public static IReadOnlyList Names => AiTestData.ClassMap; + + /// 묶음 표. 앱이 읽는 것과 같다. + public static IReadOnlyDictionary Table => SoundData.GroupTable; + + /// 묶음 표 파일의 글자 그대로(머리글 포함). + public static string TableText() + { + using var reader = new StreamReader(SoundData.OpenGroupTable(), Encoding.UTF8); + return reader.ReadToEnd(); + } + + /// + /// 기본 종류를 로 정한 목록. 앱()과 같은 표를 쓰고, 기본 종류만 바꿔 끼운다. + /// 묶음 표만 보는 테스트는 매핑이 바뀌어도 흔들리지 않게 기본 종류를 정해 두고 쓴다. + /// + public static IReadOnlyList Entries(Func? defaultOf = null) => + SoundCatalog.FromNames(Names, Table, defaultOf); + + /// 묶음 표에서 의 소리 이름. 표의 순서다. + public static List Members(SoundGroup group) => + Table.Where(row => row.Value.Group == group).Select(row => row.Key).ToList(); + + /// [언어]의 소리 이름(파일 그대로, 채우지 않은 것). + public static IReadOnlyList NamesFile(string language) => + SoundData.ReadNamesFile(language) ?? throw new InvalidOperationException($"{language} 소리 이름 파일이 없다"); + + /// [언어]의 새로 지은 묶음 이름(파일 그대로, 묶음 id → 이름). + public static Dictionary GroupTitlesFile(string language) => + (SoundData.ReadGroupTitlesFile(language) ?? throw new InvalidOperationException($"{language} 묶음 이름 파일이 없다")) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + + /// [언어]에서 묶음의 이름. 소리 이름이 곧 묶음 이름이면 그 소리의 이름, 아니면 새로 지은 이름이다. + public static string GroupTitle(string language, SoundGroup group) => + group.TitleSound() is { } sound + ? SoundData.Names(language)[AiTestData.ClassMap.IndexOf(sound)] + : SoundData.GroupTitles(language)[group]; + + /// 글을 UTF-8 스트림으로(모바일 테스트의 byteInputStream). + public static MemoryStream Stream(string text) => new(Encoding.UTF8.GetBytes(text)); + + /// 이름 목록. 실패 메시지가 읽기 쉽게 비교할 때 쓴다. + public static List NamesOf(IEnumerable entries) => entries.Select(entry => entry.Name).ToList(); +} diff --git a/SoundVisualizer.Tests/Sounds/SoundTypeOverridesTests.cs b/SoundVisualizer.Tests/Sounds/SoundTypeOverridesTests.cs new file mode 100644 index 0000000..b77d1f2 --- /dev/null +++ b/SoundVisualizer.Tests/Sounds/SoundTypeOverridesTests.cs @@ -0,0 +1,135 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Sounds; + +namespace SoundVisualizer.Tests.Sounds; + +/// +/// 분류 탭에서 바꾼 종류의 맵(설정의 sound_types). 모바일 SettingsManager.setSoundTypes·loadSoundTypes(SoundTypeSettingsTest)의 규칙이다. +/// 기본 종류와 같은 값은 두지 않아 AI 의 이 버리는 값이 없다. +/// +public class SoundTypeOverridesTests +{ + private static readonly IReadOnlyList Entries = SoundTestData.Entries(); + + private static readonly IReadOnlyList Sections = SoundGroups.Sections(Entries); + + private static readonly SoundType[] Types = { SoundType.Ambient, SoundType.Speech, SoundType.Danger }; + + private static readonly IReadOnlyDictionary NoOverrides = new Dictionary(); + + private static SoundCard Card(SoundGroup group) => + Sections.SelectMany(section => section.Cards).First(card => card.Group == group); + + private static SoundEntry Entry(string name) => Entries.First(entry => entry.Name == name); + + /// 기본 종류와 같은 값이 없고, 모두 세 라벨 중 하나다. + private static void AssertNoDefaults(IReadOnlyDictionary overrides) + { + foreach (var (name, label) in overrides) + { + Assert.True(SoundTypeLabels.TryParse(label, out SoundType type), $"{name}: {label}"); + Assert.NotEqual(Entry(name).DefaultType, type); + } + // AI 가 같은 맵에서 버리는 값이 없다. + Assert.Equal(overrides.Count, YamnetMappingPolicy.From(overrides, SoundTestData.Names).OverrideCount); + } + + [Fact] + public void 묶음째_바꾸면_소리마다_기본_종류와_다른_것만_적는다() + { + var sirens = Card(SoundGroup.Sirens); + var ambient = SoundTypeOverrides.WithType(NoOverrides, sirens.Members, SoundType.Ambient); + Assert.Equal(sirens.Members.Select(entry => entry.Name).Order(), ambient.Keys.Order()); + Assert.All(ambient.Values, label => Assert.Equal(SoundTypeLabels.Ambient, label)); + Assert.Equal(new GroupState.Uniform(SoundType.Ambient), SoundGroups.State(sirens, ambient)); + Assert.Equal(5, SoundCatalog.CountChanged(Entries, ambient)); // 묶음 하나를 바꾸면 그 소리 수만큼 는다. + AssertNoDefaults(ambient); + + // 기본 종류를 고르면 묶음의 바꾼 것을 모두 지운다. + var back = SoundTypeOverrides.WithType(ambient, sirens.Members, SoundType.Danger); + Assert.Empty(back); + } + + [Fact] + public void 다른_소리의_값은_그대로_둔다() + { + var current = new Dictionary { ["Doorbell"] = SoundTypeLabels.Danger, ["Civil defense siren"] = SoundTypeLabels.Speech }; + var updated = SoundTypeOverrides.WithType(current, Card(SoundGroup.Sirens).Members, SoundType.Ambient); + Assert.Equal(SoundTypeLabels.Danger, updated["Doorbell"]); + Assert.Equal(SoundTypeLabels.Ambient, updated["Civil defense siren"]); // 묶음의 소리는 하나씩 바꾼 것도 덮어쓴다. + Assert.Equal(6, updated.Count); + Assert.Equal(SoundTypeLabels.Speech, current["Civil defense siren"]); // 받은 맵은 고치지 않는다. + } + + [Fact] + public void 바뀌는_것이_없으면_같은_맵을_돌려준다() + { + // 그때는 저장하지 않는다. AI 도 맵이 그대로라 판정을 다시 시작하지 않는다. + var sirens = Card(SoundGroup.Sirens); + Assert.Same(NoOverrides, SoundTypeOverrides.WithType(NoOverrides, sirens.Members, SoundType.Danger)); + var ambient = SoundTypeOverrides.WithType(NoOverrides, sirens.Members, SoundType.Ambient); + Assert.Same(ambient, SoundTypeOverrides.WithType(ambient, sirens.Members, SoundType.Ambient)); + Assert.Same(ambient, SoundTypeOverrides.WithType(ambient, Array.Empty(), SoundType.Speech)); + } + + [Fact] + public void 고급_모드는_소리_하나를_바꾼다() + { + var one = SoundTypeOverrides.WithType(NoOverrides, new[] { Entry("Knock") }, SoundType.Danger); + Assert.Equal(new[] { "Knock" }, one.Keys); + Assert.Same(GroupState.Mixed.Instance, SoundGroups.State(Card(SoundGroup.Doorbell), one)); + Assert.True(SoundGroups.OverwritesOneByOne(Card(SoundGroup.Doorbell), one, SoundType.Ambient)); + Assert.Empty(SoundTypeOverrides.WithType(one, new[] { Entry("Knock") }, SoundType.Ambient)); // 기본으로 되돌리면 지운다. + } + + [Fact] + public void 어느_묶음을_어느_종류로_바꿔도_기본값을_저장하지_않는다() + { + // 바꾼 것이 없을 때, 하나씩 바꿔 둔 소리가 섞였을 때, 다른 묶음을 바꿔 둔 때에서 모든 카드를 세 종류로 차례로 바꿔 본다. + var mixed = new Dictionary + { + ["Knock"] = SoundTypeLabels.Danger, + ["Siren"] = SoundTypeLabels.Speech, + ["Music"] = SoundTypeLabels.Danger, + ["Chainsaw"] = SoundTypeLabels.Ambient, + }; + var starts = new IReadOnlyDictionary[] { NoOverrides, mixed }; + foreach (var start in starts) + { + IReadOnlyDictionary current = start; + foreach (SoundCard card in Sections.SelectMany(section => section.Cards)) + { + foreach (SoundType type in Types) + { + current = SoundTypeOverrides.WithType(current, card.Members, type); + Assert.Equal(new GroupState.Uniform(type), SoundGroups.State(card, current)); + Assert.False(SoundGroups.OverwritesOneByOne(card, current, type)); + } + } + AssertNoDefaults(current); + Assert.Equal(Entries.Count(entry => entry.DefaultType != SoundType.Danger), SoundCatalog.CountChanged(Entries, current)); + } + } + + [Fact] + public void 저장값을_읽을_때_세_라벨이_아닌_값과_기본_종류와_같은_값은_버린다() + { + var stored = new Dictionary + { + ["Siren"] = SoundTypeLabels.Ambient, // 남는다 + ["Explosion"] = SoundTypeLabels.Danger, // 기본 종류와 같다(매핑이 바뀌어 같아진 값) + ["Doorbell"] = "Danger", // 대소문자가 다르다 + ["Knock"] = "", // 라벨이 아니다 + ["Old name"] = SoundTypeLabels.Speech, // 목록에 없는 이름은 모바일처럼 남긴다 + }; + var kept = SoundTypeOverrides.Sanitize(stored); + Assert.Equal(new[] { "Old name", "Siren" }, kept.Keys.Order()); + Assert.Equal(SoundTypeLabels.Ambient, kept["Siren"]); + Assert.Empty(SoundTypeOverrides.Sanitize(new Dictionary())); + Assert.Equal(1, SoundCatalog.CountChanged(Entries, kept)); + Assert.Equal(1, YamnetMappingPolicy.From(kept, SoundTestData.Names).OverrideCount); // AI 는 목록에 없는 이름도 버린다. + } +} diff --git a/SoundVisualizer.Tests/Status/RunStatusTests.cs b/SoundVisualizer.Tests/Status/RunStatusTests.cs new file mode 100644 index 0000000..b14e6b2 --- /dev/null +++ b/SoundVisualizer.Tests/Status/RunStatusTests.cs @@ -0,0 +1,177 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Status; + +namespace SoundVisualizer.Tests.Status; + +/// +/// 홈의 상태 줄 아래 안내. 안드로이드 HomeTab 의 안내 고르기와 StopReasonTest 의 "사용자가 끈 경우만 알리지 않는다"를 옮겼다. +/// +/// 청각장애 사용자는 조용한 장면과 꺼진 상태를 구분하지 못하므로, 사용자가 끈 경우만 빼고 모두 알려야 한다. +/// 반대로 직접 끌 때마다 안내가 남으면 성가셔서 안내 자체를 읽지 않게 된다. +/// +public class RunStatusTests +{ + private static readonly StopReason[] Unexpected = Enum.GetValues().Where(r => r != StopReason.UserRequested).ToArray(); + + [Fact] + public void 처음에는_멈춘_상태이고_안내가_없다() + { + var status = new RunStatus(); + Assert.False(status.IsRunning); + Assert.Null(status.LastUnexpectedStop); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); + } + + [Fact] + public void 사용자가_끈_경우에는_알리지_않는다() + { + var status = new RunStatus(); + status.Started(); + Assert.False(status.Stopped(StopReason.UserRequested)); + Assert.Null(status.LastUnexpectedStop); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); + } + + [Fact] + public void 사용자가_끈_게_아니면_모든_이유를_멈춘_뒤에_알린다() + { + Assert.NotEmpty(Unexpected); + foreach (var reason in Unexpected) + { + var status = new RunStatus(); + status.Started(); + Assert.True(status.Stopped(reason), reason.ToString()); + Assert.Equal(reason, status.LastUnexpectedStop); + Assert.Equal(HomeNotice.Stopped, status.Notice(showAmbient: true)); + } + } + + [Fact] + public void 시작하지_못한_경우도_멈춘_이유로_알린다() + { + // 장치를 열지 못하면 오버레이를 띄우지 않으므로 Started 없이 Stopped 만 온다. + var status = new RunStatus(); + Assert.True(status.Stopped(StopReason.NoPlaybackDevice)); + Assert.False(status.IsRunning); + Assert.Equal(HomeNotice.Stopped, status.Notice(showAmbient: true)); + } + + [Fact] + public void 다시_실행하면_지난_안내를_지운다() + { + var status = new RunStatus(); + status.Stopped(StopReason.CaptureError); + status.Started(); + Assert.Null(status.LastUnexpectedStop); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); + + // 이번에는 직접 끄면 옛 이유가 되살아나지 않는다. + status.Stopped(StopReason.UserRequested); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); + } + + [Fact] + public void 안내_닫기를_누르면_지운다() + { + var status = new RunStatus(); + status.Stopped(StopReason.NoPlaybackDevice); + status.Dismiss(); + Assert.Null(status.LastUnexpectedStop); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); + } + + [Fact] + public void 나중_이유가_앞_이유를_덮는다() + { + // 장치가 없어 시작하지 못한 뒤 다시 눌러 또 실패하면, 지금 실패한 이유를 보인다. + var status = new RunStatus(); + status.Stopped(StopReason.CaptureError); + status.Stopped(StopReason.NoPlaybackDevice); + Assert.Equal(StopReason.NoPlaybackDevice, status.LastUnexpectedStop); + } + + [Fact] + public void AI_를_쓸_수_없으면_실행_중에만_알린다() + { + var status = new RunStatus(); + status.Started(); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); // 만드는 동안은 쓸 수 있다고 본다 + + status.SetAiAvailable(false); + Assert.Equal(HomeNotice.AiUnavailable, status.Notice(showAmbient: true)); + + // 장치가 바뀌어 다시 만들면 안내가 사라진다. + status.SetAiAvailable(true); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); + + status.SetAiAvailable(false); + status.Stopped(StopReason.UserRequested); + Assert.True(status.AiAvailable); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); + } + + [Fact] + public void 환경음_표시를_꺼_두었으면_아무것도_보이지_않는다고_알린다() + { + // AI 가 없으면 모든 소리가 환경음이 된다. 환경음 표시가 꺼져 있으면 "환경음 색으로 보인다"는 거짓이다. + var status = new RunStatus(); + status.Started(); + status.SetAiAvailable(false); + Assert.Equal(HomeNotice.AiUnavailableHidden, status.Notice(showAmbient: false)); + Assert.Equal(HomeNotice.AiUnavailable, status.Notice(showAmbient: true)); + } + + [Fact] + public void 멈춘_뒤에_늦게_온_AI_알림은_다음_실행에_남지_않는다() + { + var status = new RunStatus(); + status.Started(); + status.Stopped(StopReason.UserRequested); + status.SetAiAvailable(false); // 닫히기 직전에 만들기를 끝낸 파이프라인의 알림 + status.Started(); + Assert.True(status.AiAvailable); + Assert.Equal(HomeNotice.None, status.Notice(showAmbient: true)); + } + + [Fact] + public void 멈춘_이유가_있으면_AI_안내보다_먼저다() + { + var status = new RunStatus(); + status.Started(); + status.SetAiAvailable(false); + status.Stopped(StopReason.CaptureError); + Assert.Equal(HomeNotice.Stopped, status.Notice(showAmbient: true)); + } +} + +/// 실행 중 캡처 오류 뒤에 오버레이를 멈출지와 그 이유. +public class CaptureRecoveryTests +{ + [Fact] + public void 캡처가_돌고_있으면_멈추지_않는다() + { + Assert.Null(CaptureRecovery.Check(isCapturing: true, hasDevice: true)); + } + + [Fact] + public void 장치를_열지_못했으면_출력_장치가_없다고_알린다() + { + Assert.Equal(StopReason.NoPlaybackDevice, CaptureRecovery.Check(isCapturing: false, hasDevice: false)); + } + + [Fact] + public void 장치는_열었는데_멈췄으면_캡처_오류다() + { + Assert.Equal(StopReason.CaptureError, CaptureRecovery.Check(isCapturing: false, hasDevice: true)); + } + + [Fact] + public void 기다리는_시간은_엔진이_장치를_다시_여는_지연보다_넉넉하다() + { + // AudioCaptureEngine 은 장치 알림 뒤 0.5초 기다렸다가 다시 연다. 그 안에 멈추면 장치를 바꾸기만 해도 꺼진다. + Assert.True(CaptureRecovery.GracePeriod >= TimeSpan.FromSeconds(2)); + Assert.True(CaptureRecovery.GracePeriod <= TimeSpan.FromSeconds(10)); // 너무 길면 꺼진 줄 모르는 시간이 길어진다 + } +} diff --git a/SoundVisualizer.Tests/Tutorial/TutorialCueTimerTests.cs b/SoundVisualizer.Tests/Tutorial/TutorialCueTimerTests.cs new file mode 100644 index 0000000..7b6ba60 --- /dev/null +++ b/SoundVisualizer.Tests/Tutorial/TutorialCueTimerTests.cs @@ -0,0 +1,111 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Tutorial; + +namespace SoundVisualizer.Tests.Tutorial; + +/// +/// 튜토리얼 소리의 박자(Android TutorialSoundsTest, #327). 그림이 프레임마다 시각을 넘기면 대본의 소리를 한 번씩, 제때 낸다. +/// 창이 멈췄다 돌아와 시각이 건너뛰어도 지난 소리를 몰아 내지 않는다. +/// +public class TutorialCueTimerTests +{ + private readonly List<(double At, TutorialCue Cue)> _played = new(); + private int _cancels; + private bool _allowed = true; + private double _now; + private readonly TutorialScene _scene = TutorialScene.Types; + private readonly TutorialCueTimer _timer; + + public TutorialCueTimerTests() + { + _timer = new TutorialCueTimer( + TutorialScript.Cues(_scene), _scene.CycleSec(), + canPlay: () => _allowed, + play: cue => _played.Add((_now, cue)), + cancel: () => _cancels++); + } + + /// 부터 앞까지 로 프레임을 넘긴다. + private void Frames(double fromSec, double toSec, int fps = 60) + { + for (int i = 0; fromSec + i / (double)fps < toSec; i++) + { + _now = fromSec + i / (double)fps; + _timer.OnFrame(_now); + } + } + + [Fact] + public void 바퀴마다_대본의_소리를_한_번씩_제때_낸다() + { + Frames(0, _scene.CycleSec() * 2); + + var cues = TutorialScript.Cues(_scene); + Assert.Equal(cues.Concat(cues), _played.Select(p => p.Cue)); + foreach (var (at, cue) in _played) + { + // 첫 바퀴인지 둘째 바퀴인지는 낸 시각으로 안다. 낸 시각은 대본 시각에서 한 프레임 안이다. + double expected = cue.AtSec + (at >= _scene.CycleSec() ? _scene.CycleSec() : 0); + Assert.InRange(at - expected, 0, 1.0 / 60 + 1e-4); + } + } + + [Fact] + public void 첫_프레임_0초의_소리도_낸다() + { + _timer.OnFrame(0); + Assert.Equal(TutorialClip.Birds, Assert.Single(_played).Cue.Clip); + } + + [Fact] + public void 시각이_건너뛰면_지난_소리는_내지_않는다() + { + // 창이 잠깐 멈췄다 돌아오면 그림의 시각이 그만큼 건너뛴다. 그사이의 대화·경적을 한꺼번에 내지 않는다. + Frames(0, 1); + _now = 5.5; + _timer.OnFrame(_now); + Assert.Equal(new[] { TutorialClip.Birds }, _played.Select(p => p.Cue.Clip)); + + // 그다음부터는 다시 제때 낸다(5.9초의 경적, 다음 바퀴의 새소리). + Frames(5.5 + 1.0 / 60, _scene.CycleSec() + 0.1); + Assert.Equal(new[] { TutorialClip.Birds, TutorialClip.Honk, TutorialClip.Birds }, _played.Select(p => p.Cue.Clip)); + } + + [Fact] + public void 내면_안_될_때_시작한_소리는_건너뛰고_멈췄다_다시_움직이면_처음부터_센다() + { + _allowed = false; + Frames(0, 1); + Assert.Empty(_played); + + _allowed = true; + _timer.Stop(); + Assert.Equal(1, _cancels); // 멈출 때 나던 소리를 멈춘다 + + Frames(0, 0.1); + Assert.Equal(TutorialClip.Birds, Assert.Single(_played).Cue.Clip); + } + + [Fact] + public void 멈춘_자리에서_다시_움직이면_그_뒤의_소리부터_낸다() + { + // 쪽을 넘겼다 돌아오거나 멈춤을 풀면 그림은 멈춘 시각에서 이어 간다. 그 앞의 소리는 늦었으므로 내지 않는다. + Frames(0, 3); + _timer.Stop(); + _played.Clear(); + + Frames(3, 4.6); + Assert.Equal(new[] { TutorialClip.Honk }, _played.Select(p => p.Cue.Clip)); // 4.5초의 첫 경적 + } + + [Fact] + public void 시각이_그대로거나_뒤로_가면_아무것도_내지_않는다() + { + _timer.OnFrame(0); + _timer.OnFrame(0); + _timer.OnFrame(-0.5); + Assert.Single(_played); + } +} diff --git a/SoundVisualizer.Tests/Tutorial/TutorialDemoTests.cs b/SoundVisualizer.Tests/Tutorial/TutorialDemoTests.cs new file mode 100644 index 0000000..977a42c --- /dev/null +++ b/SoundVisualizer.Tests/Tutorial/TutorialDemoTests.cs @@ -0,0 +1,138 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Rendering; +using SoundVisualizer.Tutorial; + +namespace SoundVisualizer.Tests.Tutorial; + +/// +/// 튜토리얼 그림이 실제 오버레이 엔진을 거쳐 그리는 모양(Android TutorialScriptTest 의 엔진 시험). +/// 쪽의 글과 그림이 어긋나면 처음 보는 사람을 오히려 헷갈리게 한다. 엔진의 기본값이나 계산이 바뀌어 그림이 밋밋해져도 여기서 걸린다. +/// 둘레 자리는 0:상단 가운데부터 시계 방향이다(2:오른쪽 가운데, 6:왼쪽 가운데). +/// +public class TutorialDemoTests +{ + private const int RightSide = 2; + private const int LeftSide = 6; + + /// 튜토리얼 그림처럼 0초부터 60fps 로 대본을 흘린다. 미리 돌린 장면 없이 시작한다. + private sealed class DemoRun + { + public readonly TutorialDemo Demo; + private int _frame; + + public DemoRun(TutorialScene scene, int visualMode = VisualizerEngine.ModeWave) + { + Demo = new TutorialDemo(scene, visualMode, runToStill: false); + } + + public VisualizerEngine Engine => Demo.Engine; + + /// 지난번에 멈춘 프레임 다음부터 까지 흘린다. + public void RunTo(double seconds) + { + while (_frame / 60.0 <= seconds) + { + Demo.Step(_frame / 60.0); + _frame++; + } + } + + public double Deepest => Engine.Depths.ToArray().Max(); + } + + [Fact] + public void 멈춰_둔_장면에는_그_쪽이_말하는_것이_크게_그려져_있다() + { + // 멈춘 한 장면이 비거나 작으면, 애니메이션을 꺼 둔 사람에게 그 쪽의 그림은 빈 화면이 된다. + foreach (var scene in Enum.GetValues()) + { + var demo = new TutorialDemo(scene, VisualizerEngine.ModeWave); + Assert.True(demo.Engine.Visible, $"{scene} 의 멈춘 장면에 아무것도 없다"); + double deepest = demo.Engine.Depths.ToArray().Max(); + Assert.True(deepest > demo.Engine.BaseDepth * 0.5, $"{scene} 의 멈춘 장면이 너무 작다: {deepest / demo.Engine.BaseDepth:0.00}"); + Assert.Equal(scene.StillAtSec(), demo.TimeSec, 2); + } + + var depths = new TutorialDemo(TutorialScene.Direction, VisualizerEngine.ModeWave).Engine.Depths.ToArray(); + Assert.True(depths[LeftSide] > depths[RightSide] * 3, "방향 쪽의 멈춘 장면에서 왼쪽이 깊지 않다: " + string.Join(", ", depths)); + Assert.Equal(SoundType.Danger, new TutorialDemo(TutorialScene.Types, VisualizerEngine.ModeWave).Label); // 범례도 위협음을 밝힌다 + } + + [Fact] + public void 엔진을_거치면_왼쪽_소리는_왼쪽이_오른쪽_소리는_오른쪽이_깊다() + { + var run = new DemoRun(TutorialScene.Direction); + run.RunTo(1.5); + var left = run.Engine.Depths.ToArray(); + Assert.True(run.Engine.Visible, "왼쪽 구간에 그리지 않는다"); + Assert.True(left[LeftSide] > left[RightSide] * 3, "왼쪽 소리인데 왼쪽이 깊지 않다: " + string.Join(", ", left)); + + run.RunTo(3.7); + var right = run.Engine.Depths.ToArray(); + Assert.True(run.Engine.Visible, "오른쪽 구간에 그리지 않는다"); + Assert.True(right[RightSide] > right[LeftSide] * 3, "오른쪽 소리인데 오른쪽이 깊지 않다: " + string.Join(", ", right)); + } + + [Fact] + public void 엔진을_거치면_종류마다_그_종류로_그린다() + { + // 색은 창이 라벨로 고른다(오버레이와 같다). 라벨이 바뀌는 순간 그림이 보이고 있어야 그 색이 보인다. + var run = new DemoRun(TutorialScene.Types); + run.RunTo(1.5); + Assert.Equal(SoundType.Ambient, run.Demo.Label); + Assert.True(run.Engine.Visible); + run.RunTo(3.5); + Assert.Equal(SoundType.Speech, run.Demo.Label); + Assert.True(run.Engine.Visible); + run.RunTo(5.0); + Assert.Equal(SoundType.Danger, run.Demo.Label); + Assert.True(run.Engine.Visible); + } + + [Fact] + public void 첫_쪽의_쿵은_눈에_띄게_커졌다_작아진다() + { + // 짧게 치고 마는 소리는 엔진이 부드럽게 따라가는 사이에 묻혀, 작은 그림에서는 거의 움직이지 않는다. + var run = new DemoRun(TutorialScene.Sound); + double cycle = TutorialScene.Sound.CycleSec(); + run.RunTo(cycle); // 한 바퀴는 흘려보내고 두 번째 바퀴를 잰다 + double smallest = double.MaxValue, largest = 0; + for (int frame = 0; frame < 240; frame++) + { + run.RunTo(cycle + frame / 60.0); + smallest = Math.Min(smallest, run.Deepest); + largest = Math.Max(largest, run.Deepest); + } + double baseDepth = run.Engine.BaseDepth; + Assert.True(largest > baseDepth * 0.5, $"쿵이 너무 작다: {largest / baseDepth:0.00}"); + Assert.True(largest > smallest * 3, $"쿵 사이에 충분히 가라앉지 않는다: {smallest:0.0} → {largest:0.0}"); + } + + [Theory] + [InlineData(VisualizerEngine.ModePad)] + [InlineData(VisualizerEngine.ModeCircle)] + [InlineData(VisualizerEngine.ModeOutline)] + public void 다른_표현_모드로도_멈춰_둔_장면이_보인다(int mode) + { + // 그림은 사용자가 고른 표현 모드로 그린다. 패드·원형은 깊이를 한 번 더 따라가므로 그 모드로 미리 돌려야 보인다. + foreach (var scene in Enum.GetValues()) + { + var demo = new TutorialDemo(scene, mode); + Assert.True(demo.Engine.Visible, $"{scene} 을 모드 {mode} 로 멈춰 둔 장면에 아무것도 없다"); + } + } + + [Fact] + public void 그림의_설정은_사용자_설정과_따로_만든다() + { + // 그림이 사용자의 모드 설정을 고치거나, 사용자의 설정(진하기 0 등)에 따라 그림이 사라지면 안 된다. + var settings = TutorialDemo.DemoSettings(); + Assert.NotSame(settings, TutorialDemo.DemoSettings()); + Assert.True(VisualizerEngine.CanDraw(settings)); + Assert.False(settings.IntensityAsOpacity); + Assert.False(settings.IsGlowMode); + } +} diff --git a/SoundVisualizer.Tests/Tutorial/TutorialScriptTests.cs b/SoundVisualizer.Tests/Tutorial/TutorialScriptTests.cs new file mode 100644 index 0000000..afe186f --- /dev/null +++ b/SoundVisualizer.Tests/Tutorial/TutorialScriptTests.cs @@ -0,0 +1,161 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Ai; +using SoundVisualizer.Rendering; +using SoundVisualizer.Tutorial; + +namespace SoundVisualizer.Tests.Tutorial; + +/// +/// 튜토리얼 그림의 소리 대본(Android TutorialScriptTest). 진동 쪽은 PC 에 없어 그 시험도 옮기지 않았다. +/// +/// 그림은 앱의 오버레이 엔진이 그대로 그리므로, 대본이 말하려는 것(왼쪽 소리는 왼쪽이 깊다, 위협음은 위협음이다)이 실제 엔진을 +/// 거쳐서도 그렇게 그려지는지 본다(). 여기서는 대본 자체를 본다. +/// +public class TutorialScriptTests +{ + private static readonly TutorialScene[] Scenes = Enum.GetValues(); + + [Fact] + public void 피크는_늘_0에서_1_사이다() + { + Span lr = stackalloc float[2]; + foreach (var scene in Scenes) + { + for (double t = -scene.CycleSec(); t < scene.CycleSec() * 2; t += 0.01) + { + TutorialScript.Peaks(scene, t, lr); + Assert.True(lr[0] is >= 0f and <= 1f, $"{scene} t={t} 왼쪽 {lr[0]}"); + Assert.True(lr[1] is >= 0f and <= 1f, $"{scene} t={t} 오른쪽 {lr[1]}"); + } + } + } + + [Fact] + public void 엔진이_받는_프레임은_앞_좌우에만_대본을_넣는다() + { + // 사운드 모드 2.0 은 앞 좌우(FL, FR)만 읽어 둘레로 펼친다. 다른 채널에 남은 값이 있으면 5.1·7.1 처럼 그려질 수 있다. + var frame = Enumerable.Repeat(0.9f, VisualizerEngine.Channels).ToArray(); + Span lr = stackalloc float[2]; + foreach (var scene in Scenes) + { + TutorialScript.PeakFrame(scene, 1.0, frame); + TutorialScript.Peaks(scene, 1.0, lr); + Assert.Equal(lr[0], frame[0]); + Assert.Equal(lr[1], frame[1]); + Assert.All(frame[2..], value => Assert.Equal(0f, value)); + } + } + + [Fact] + public void 방향_쪽은_왼쪽_소리_다음에_오른쪽_소리가_난다() + { + Span lr = stackalloc float[2]; + var scene = TutorialScene.Direction; + + TutorialScript.Peaks(scene, 1.0, lr); + Assert.True(lr[0] > lr[1] * 3, "왼쪽 구간인데 오른쪽이 더 크다"); + Assert.Equal(-1, TutorialScript.SourceSide(scene, 1.0)); + + TutorialScript.Peaks(scene, 3.0, lr); + Assert.True(lr[1] > lr[0] * 3, "오른쪽 구간인데 왼쪽이 더 크다"); + Assert.Equal(1, TutorialScript.SourceSide(scene, 3.0)); + + TutorialScript.Peaks(scene, 2.0, lr); + Assert.Equal(0f, lr[0] + lr[1]); // 쉬는 구간 + Assert.Equal(0, TutorialScript.SourceSide(scene, 2.0)); + Assert.Equal(-1, TutorialScript.SourceRipple(scene, 2.0)); + } + + [Fact] + public void 소리_표시는_방향_쪽에만_있다() + { + foreach (var scene in Scenes.Where(s => s != TutorialScene.Direction)) + { + Assert.Equal(0, TutorialScript.SourceSide(scene, 1.0)); + Assert.Equal(0, TutorialScript.SourceLevel(scene, 1.0)); + Assert.Equal(-1, TutorialScript.SourceRipple(scene, 1.0)); + } + // 방향 쪽의 파문은 박자마다 0 에서 1 로 퍼진다. + double ripple = TutorialScript.SourceRipple(TutorialScene.Direction, 0.25); + Assert.InRange(ripple, 0.49, 0.51); + } + + [Fact] + public void 종류_쪽은_환경음_대화음_위협음_순서로_바뀐다() + { + var scene = TutorialScene.Types; + Assert.Equal(SoundType.Ambient, TutorialScript.Label(scene, 1)); + Assert.Equal(SoundType.Speech, TutorialScript.Label(scene, 3)); + Assert.Equal(SoundType.Danger, TutorialScript.Label(scene, 5)); + Assert.Equal(SoundType.Ambient, TutorialScript.Label(scene, scene.CycleSec() + 1)); // 한 바퀴 돌면 처음부터 + } + + [Fact] + public void 첫_쪽과_방향_쪽은_모두_환경음이다() + { + foreach (var scene in new[] { TutorialScene.Sound, TutorialScene.Direction }) + { + for (double t = 0; t < scene.CycleSec(); t += 0.05) Assert.Equal(SoundType.Ambient, TutorialScript.Label(scene, t)); + } + } + + [Fact] + public void 소리는_그림이_그_소리를_그리는_순간에_같은_종류로_같은_쪽에서_난다() + { + // 소리를 켜면 대본의 박자에 맞춰 실제 녹음을 낸다(Android #327). 듣는 것과 보는 것이 어긋나면 처음 보는 사람을 헷갈리게 한다. + Span lr = stackalloc float[2]; + foreach (var scene in Scenes) + { + var cues = TutorialScript.Cues(scene); + Assert.NotEmpty(cues); + foreach (var cue in cues) + { + string what = $"{scene} {cue.AtSec}초의 {cue.Clip}"; + Assert.True(cue.AtSec >= 0 && cue.AtSec < scene.CycleSec(), what + ": 한 바퀴 안이어야 한다"); + // 소리가 시작하고 조금 뒤(0.05초)의 그림 + double t = cue.AtSec + 0.05; + Assert.Equal(cue.Clip.Label(), TutorialScript.Label(scene, t)); + TutorialScript.Peaks(scene, t, lr); + Assert.True(Math.Max(lr[0], lr[1]) > 0.3f, what + ": 그림이 조용하다"); + if (cue.Left != cue.Right) + { + Assert.True(cue.Left > cue.Right == lr[0] > lr[1], what + ": 그림이 기운 쪽과 다르다"); + } + } + } + } + + [Fact] + public void 종류_쪽은_새소리_대화_경적_세_번이다() + { + Assert.Equal( + new[] { TutorialClip.Birds, TutorialClip.Talk, TutorialClip.Honk, TutorialClip.Honk, TutorialClip.Honk }, + TutorialScript.Cues(TutorialScene.Types).Select(c => c.Clip)); + // 방향 쪽은 왼쪽 네 번, 오른쪽 네 번 + var direction = TutorialScript.Cues(TutorialScene.Direction); + Assert.Equal(4, direction.Count(c => c.Left > c.Right)); + Assert.Equal(4, direction.Count(c => c.Right > c.Left)); + // 첫 쪽은 1초마다 한 번, 왼쪽과 오른쪽을 번갈아 + Assert.Equal(new[] { 0.0, 1.0, 2.0, 3.0 }, TutorialScript.Cues(TutorialScene.Sound).Select(c => c.AtSec)); + } + + [Fact] + public void 소리_조각의_종류() + { + Assert.Equal(SoundType.Ambient, TutorialClip.Chirp.Label()); + Assert.Equal(SoundType.Ambient, TutorialClip.Birds.Label()); + Assert.Equal(SoundType.Speech, TutorialClip.Talk.Label()); + Assert.Equal(SoundType.Danger, TutorialClip.Honk.Label()); + } + + [Fact] + public void 대본_시각은_음수와_한_바퀴를_넘는_값도_한_바퀴_안으로_접는다() + { + var scene = TutorialScene.Direction; + Assert.Equal(1.0, TutorialScript.LoopTime(scene, 1.0), 9); + Assert.Equal(1.0, TutorialScript.LoopTime(scene, 1.0 + scene.CycleSec() * 3), 9); + Assert.Equal(scene.CycleSec() - 0.5, TutorialScript.LoopTime(scene, -0.5), 9); + } +} diff --git a/SoundVisualizer.Tests/Ui/ChoiceRowsTests.cs b/SoundVisualizer.Tests/Ui/ChoiceRowsTests.cs new file mode 100644 index 0000000..2a5270c --- /dev/null +++ b/SoundVisualizer.Tests/Ui/ChoiceRowsTests.cs @@ -0,0 +1,55 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Tests.Ui; + +/// +/// 선택지 칸(표현 모드 넷, 사운드 모드 셋)의 한 줄 칸 수. 런처는 넓어 한 줄이고, 380px 편집 패널에서 긴 번역은 줄을 나눈다. +/// 안드로이드 판 choicesPerRow 와 같이 마지막 줄에 한 칸만 남는 나눔은 쓰지 않는다. +/// +public class ChoiceRowsTests +{ + private const double Gap = 8; + + [Fact] + public void 넓으면_모두_한_줄에_둔다() + { + // 런처 설정 카드 안쪽 너비(약 700px)에 네 칸 + Assert.Equal(4, ChoiceRows.PerRow(4, 700, Gap, 120)); + Assert.Equal(3, ChoiceRows.PerRow(3, 700, Gap, 200)); + } + + [Fact] + public void 가장_넓은_칸이_들어가지_않으면_칸_수를_줄인다() + { + // 편집 패널 카드 안쪽 너비 279px: 네 칸이면 칸마다 63.75px + Assert.Equal(4, ChoiceRows.PerRow(4, 279, Gap, 63)); + Assert.Equal(2, ChoiceRows.PerRow(4, 279, Gap, 64)); + } + + [Fact] + public void 마지막_줄에_한_칸만_남는_나눔은_건너뛴다() + { + // 네 칸은 3+1 을 건너뛰고 2+2, 다섯 칸은 4+1 을 건너뛰고 3+2 + Assert.Equal(2, ChoiceRows.PerRow(4, 300, Gap, 90)); + Assert.Equal(3, ChoiceRows.PerRow(5, 300, Gap, 90)); + // 세 칸의 2+1 은 마지막 줄이 반이라 쓴다 + Assert.Equal(2, ChoiceRows.PerRow(3, 279, Gap, 120)); + } + + [Fact] + public void 한_칸도_안_들어가면_한_칸씩_두고_칸_안에서_줄을_바꾼다() + { + Assert.Equal(1, ChoiceRows.PerRow(4, 279, Gap, 500)); + Assert.Equal(1, ChoiceRows.PerRow(1, 279, Gap, 500)); + Assert.Equal(1, ChoiceRows.PerRow(0, 279, Gap, 10)); + } + + [Fact] + public void 칸_너비는_간격을_뺀_줄_너비를_나눈_값이다() + { + Assert.Equal(63.75, ChoiceRows.CellWidth(279, 4, Gap), 6); + Assert.Equal(279, ChoiceRows.CellWidth(279, 1, Gap), 6); + Assert.Equal(0, ChoiceRows.CellWidth(10, 4, Gap), 6); + } +} diff --git a/SoundVisualizer.Tests/Ui/ComboListKeysTests.cs b/SoundVisualizer.Tests/Ui/ComboListKeysTests.cs new file mode 100644 index 0000000..008b39f --- /dev/null +++ b/SoundVisualizer.Tests/Ui/ComboListKeysTests.cs @@ -0,0 +1,76 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Tests.Ui; + +/// +/// 언어 콤보의 키 규칙. 닫혀 있을 때 화살표나 글자 키를 누르면 언어가 바로 바뀌던 결함(키마다 앱 전체의 글꼴·방향·글자가 바뀜)이 +/// 다시 생기지 않게, 닫힌 콤보에서 값을 바꾸던 키가 모두 목록 열기로 바뀌는지 지킨다. +/// +public class ComboListKeysTests +{ + public static TheoryData MovingKeys() => new() + { + ComboKey.Up, ComboKey.Down, ComboKey.Left, ComboKey.Right, ComboKey.Home, ComboKey.End, ComboKey.PageUp, ComboKey.PageDown, + }; + + [Theory] + [MemberData(nameof(MovingKeys))] + public void 닫힌_콤보의_이동_키는_값을_바꾸지_않고_목록을_연다(ComboKey key) + { + Assert.Equal(ComboKeyAction.OpenList, ComboListKeys.OnKeyDown(key, alt: false, listOpen: false)); + } + + [Theory] + [InlineData(ComboKey.Enter)] + [InlineData(ComboKey.Space)] + public void 닫힌_콤보의_Enter_와_스페이스도_목록을_연다(ComboKey key) + { + Assert.Equal(ComboKeyAction.OpenList, ComboListKeys.OnKeyDown(key, alt: false, listOpen: false)); + } + + [Theory] + [MemberData(nameof(MovingKeys))] + public void 열린_목록에서는_콤보가_하던_대로_항목만_옮긴다(ComboKey key) + { + Assert.Equal(ComboKeyAction.Default, ComboListKeys.OnKeyDown(key, alt: false, listOpen: true)); + } + + [Fact] + public void 열린_목록의_Enter_는_콤보가_고르고_닫는다() + { + Assert.Equal(ComboKeyAction.Default, ComboListKeys.OnKeyDown(ComboKey.Enter, alt: false, listOpen: true)); + } + + [Fact] + public void 열린_목록의_Tab_은_고르지_않고_닫은_뒤_다음_칸으로_간다() + { + Assert.Equal(ComboKeyAction.CloseAndMoveFocus, ComboListKeys.OnKeyDown(ComboKey.Tab, alt: false, listOpen: true)); + // 닫혀 있으면 Tab 은 그냥 다음 칸으로 간다. + Assert.Equal(ComboKeyAction.Default, ComboListKeys.OnKeyDown(ComboKey.Tab, alt: false, listOpen: false)); + } + + [Fact] + public void Alt_와_함께_누른_키는_콤보가_열고_닫게_둔다() + { + Assert.Equal(ComboKeyAction.Default, ComboListKeys.OnKeyDown(ComboKey.Down, alt: true, listOpen: false)); + Assert.Equal(ComboKeyAction.Default, ComboListKeys.OnKeyDown(ComboKey.Up, alt: true, listOpen: true)); + } + + [Fact] + public void 그_밖의_키는_건드리지_않는다() + { + // F4 는 WPF 가 목록을 열고 닫고, Esc 는 열린 목록을 고르지 않고 닫는다. + Assert.Equal(ComboKeyAction.Default, ComboListKeys.OnKeyDown(ComboKey.Other, alt: false, listOpen: false)); + Assert.Equal(ComboKeyAction.Default, ComboListKeys.OnKeyDown(ComboKey.Other, alt: false, listOpen: true)); + } + + [Fact] + public void 닫힌_콤보에_친_글자는_목록을_연다() + { + Assert.True(ComboListKeys.TextOpensList("d", listOpen: false)); + Assert.False(ComboListKeys.TextOpensList("d", listOpen: true)); + Assert.False(ComboListKeys.TextOpensList("", listOpen: false)); + Assert.False(ComboListKeys.TextOpensList(null, listOpen: false)); + } +} diff --git a/SoundVisualizer.Tests/Ui/EditPanelSourceTests.cs b/SoundVisualizer.Tests/Ui/EditPanelSourceTests.cs new file mode 100644 index 0000000..5075b48 --- /dev/null +++ b/SoundVisualizer.Tests/Ui/EditPanelSourceTests.cs @@ -0,0 +1,36 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +namespace SoundVisualizer.Tests.Ui; + +/// +/// 오버레이 편집 패널의 코드 숨김(MainWindow.xaml.cs)을 읽어 확인한다. MainWindow 는 화면 전체를 덮는 WPF 창이라 단위 시험에서 띄우지 않는다. +/// +public class EditPanelSourceTests +{ + [Fact] + public void 편집_패널에서_모드_칸을_바꾸면_슬라이더를_새_모드의_값으로_다시_채운다() + { + // 크기·진하기·속도 같은 값은 모드마다 따로 저장된다(AppSettings 의 모드별 접근자). 모드를 바꾼 뒤 다시 채우지 않으면 + // 슬라이더가 이전 모드의 값을 보이고, 그 상태로 움직이면 새 모드에 엉뚱한 값이 들어간다. F3 단축키로 바꿀 때는 이미 다시 채운다. + string body = MethodBody("EditPanelVisualMode_SelectionChanged"); + + Assert.Contains("LoadSettingsToEditPanel();", body); + } + + private static string MethodBody(string name) + { + string source = File.ReadAllText(Path.Combine(RepositoryPaths.Root, "SoundVisualizer", "MainWindow.xaml.cs")); + int start = source.IndexOf("void " + name + "(", StringComparison.Ordinal); + Assert.True(start >= 0, name + " 를 찾지 못했습니다."); + + int open = source.IndexOf('{', start); + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) return source.Substring(open, i - open + 1); + } + throw new InvalidOperationException(name + " 의 끝을 찾지 못했습니다."); + } +} diff --git a/SoundVisualizer.Tests/Ui/KeyedListTests.cs b/SoundVisualizer.Tests/Ui/KeyedListTests.cs new file mode 100644 index 0000000..cc085f2 --- /dev/null +++ b/SoundVisualizer.Tests/Ui/KeyedListTests.cs @@ -0,0 +1,113 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Collections.ObjectModel; +using System.Collections.Specialized; + +namespace SoundVisualizer.Tests.Ui; + +/// +/// 화면 목록의 줄을 새 항목에 맞추기(소리 탭 목록). 키가 같은 줄은 같은 객체로 남아야 그 줄의 키보드 초점과 자리가 남는다. +/// 줄은 ObservableCollection 이고, 목록이 받는 바뀜 알림까지 본다. +/// +public class KeyedListTests +{ + private sealed class Row + { + public Row(string key, int value) + { + Key = key; + Value = value; + } + + public string Key { get; } + + public int Value { get; set; } + + public int Updates { get; set; } + } + + private sealed record Item(string Key, int Value); + + private static List Sync(ObservableCollection rows, params Item[] items) + { + var events = new List(); + void Record(object? sender, NotifyCollectionChangedEventArgs e) => events.Add(e); + rows.CollectionChanged += Record; + KeyedList.Sync(rows, items, item => item.Key, row => row.Key, item => new Row(item.Key, item.Value), (row, item) => + { + row.Value = item.Value; + row.Updates++; + }); + rows.CollectionChanged -= Record; + return events; + } + + private static ObservableCollection Rows(params string[] keys) => new(keys.Select(key => new Row(key, 0))); + + [Fact] + public void 같은_키의_줄은_같은_객체를_고쳐_쓰고_목록은_바뀌지_않는다() + { + var rows = Rows("a", "b", "c"); + var before = rows.ToList(); + + var events = Sync(rows, new Item("a", 1), new Item("b", 2), new Item("c", 3)); + + Assert.Empty(events); + Assert.Equal(before, rows); + Assert.Equal(new[] { 1, 2, 3 }, rows.Select(row => row.Value)); + Assert.All(rows, row => Assert.Equal(1, row.Updates)); + } + + [Fact] + public void 없어진_줄은_지우고_새_줄은_제자리에_넣으며_남는_줄은_옮기지_않는다() + { + var rows = Rows("intro", "changed", "s:danger", "c:sirens", "c:alarms", "end"); + Row intro = rows[0], sirens = rows[3]; + + var events = Sync(rows, new Item("intro", 0), new Item("changed", 0), new Item("empty", 0), new Item("s:danger", 0), + new Item("c:sirens", 0), new Item("c:horns", 0), new Item("end", 0)); + + Assert.Equal(new[] { "intro", "changed", "empty", "s:danger", "c:sirens", "c:horns", "end" }, rows.Select(row => row.Key)); + Assert.Same(intro, rows[0]); + Assert.Same(sirens, rows[4]); + Assert.All(events, e => Assert.Contains(e.Action, new[] { NotifyCollectionChangedAction.Add, NotifyCollectionChangedAction.Remove })); + Assert.Equal(1, events.Count(e => e.Action == NotifyCollectionChangedAction.Remove)); // c:alarms + Assert.Equal(2, events.Count(e => e.Action == NotifyCollectionChangedAction.Add)); // empty, c:horns + } + + [Fact] + public void 모두_바뀌어도_새_차례가_된다() + { + var rows = Rows("intro", "changed", "s:danger", "c:sirens", "end"); + + Sync(rows, new Item("intro", 0), new Item("changed", 0), new Item("s:danger", 0), new Item("g:sirens", 0), + new Item("r:1", 0), new Item("r:2", 0), new Item("end", 0)); + + Assert.Equal(new[] { "intro", "changed", "s:danger", "g:sirens", "r:1", "r:2", "end" }, rows.Select(row => row.Key)); + + Sync(rows); + Assert.Empty(rows); + } + + [Fact] + public void 남는_줄의_순서가_바뀌면_옮겨서라도_새_차례를_맞춘다() + { + var rows = Rows("a", "b", "c", "d"); + Row b = rows[1]; + + Sync(rows, new Item("c", 0), new Item("a", 0), new Item("b", 0), new Item("e", 0)); + + Assert.Equal(new[] { "c", "a", "b", "e" }, rows.Select(row => row.Key)); + Assert.Same(b, rows[2]); + } + + [Fact] + public void 겹친_키는_받지_않는다() + { + var rows = Rows("a"); + + Assert.Throws(() => Sync(rows, new Item("x", 0), new Item("x", 1))); + Assert.Equal(new[] { "a" }, rows.Select(row => row.Key)); // 고치기 전에 거른다 + } +} diff --git a/SoundVisualizer.Tests/Ui/ThemePaletteXamlTests.cs b/SoundVisualizer.Tests/Ui/ThemePaletteXamlTests.cs new file mode 100644 index 0000000..1b0b514 --- /dev/null +++ b/SoundVisualizer.Tests/Ui/ThemePaletteXamlTests.cs @@ -0,0 +1,79 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace SoundVisualizer.Tests.Ui; + +/// +/// WPF 화면이 쓰는 색 사전(SoundVisualizer/Themes/Palette.xaml)이 Core 의 와 같은지. +/// 테스트 프로젝트는 WPF 를 모르므로 XAML 파일을 XML 로 읽는다. 붓 이름은 색 이름 + "Brush" 다(Background → BackgroundBrush). +/// 한쪽만 바꾸면 대비 테스트는 Core 값으로 통과하는데 화면은 다른 색을 그리게 되므로, 둘을 여기서 묶는다. +/// +public class ThemePaletteXamlTests +{ + private static readonly XNamespace Presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml"; + private static readonly Regex OpaqueHex = new("^#[0-9A-Fa-f]{6}$"); + + private static string PalettePath => Path.Combine(RepositoryPaths.Root, "SoundVisualizer", "Themes", "Palette.xaml"); + + [Fact] + public void 팔레트_XAML_의_붓은_Core_의_색과_같다() + { + var core = CoreColors(); + Assert.All(XamlBrushes(), brush => + { + Assert.True(brush.Key.EndsWith("Brush", StringComparison.Ordinal), $"붓 이름은 Brush 로 끝나야 합니다: {brush.Key}"); + string name = brush.Key[..^"Brush".Length]; + Assert.True(core.TryGetValue(name, out uint expected), $"UiColors 에 {name} 가 없습니다({brush.Key})."); + Assert.True(OpaqueHex.IsMatch(brush.Value), $"{brush.Key} 는 불투명한 #RRGGBB 로 적습니다: {brush.Value}"); + uint actual = uint.Parse(brush.Value[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + Assert.True(expected == actual, $"{brush.Key}: XAML #{actual:X6}, Core #{expected:X6}"); + }); + } + + [Fact] + public void Core_의_색은_모두_팔레트_XAML_에_붓으로_있다() + { + var keys = XamlBrushes().Select(b => b.Key).ToHashSet(StringComparer.Ordinal); + var missing = CoreColors().Keys.Where(name => !keys.Contains(name + "Brush")).Order(StringComparer.Ordinal).ToArray(); + Assert.True(missing.Length == 0, "Palette.xaml 에 없는 색: " + string.Join(", ", missing)); + } + + [Fact] + public void 팔레트_XAML_은_이름_붙은_단색_붓만_둔다() + { + // 투명도나 다른 리소스가 끼면 같은 이름이라도 다른 색으로 그려진다. 파생 색은 Theme.xaml 에서 만든다. + var root = XDocument.Load(PalettePath).Root!; + Assert.Equal(Presentation + "ResourceDictionary", root.Name); + Assert.All(root.Elements(), element => + { + Assert.Equal(Presentation + "SolidColorBrush", element.Name); + var attributes = element.Attributes().Select(a => a.Name).ToHashSet(); + Assert.True(attributes.SetEquals(new[] { Xaml + "Key", XName.Get("Color") }), $"{element.Attribute(Xaml + "Key")?.Value}: x:Key 와 Color 만 적습니다."); + }); + } + + [Fact] + public void 붓_이름은_겹치지_않는다() + { + var keys = XamlBrushes().Select(b => b.Key).ToArray(); + Assert.Equal(keys.Length, keys.Distinct(StringComparer.Ordinal).Count()); + } + + private static IReadOnlyList> XamlBrushes() => + XDocument.Load(PalettePath).Root! + .Elements(Presentation + "SolidColorBrush") + .Select(e => new KeyValuePair(e.Attribute(Xaml + "Key")?.Value ?? "", e.Attribute("Color")?.Value ?? "")) + .ToArray(); + + /// UiColors 의 상수 전부(이름 → 0xRRGGBB). 색을 더하면 XAML 에도 붓을 더해야 이 테스트가 통과한다. + private static Dictionary CoreColors() => + typeof(UiColors).GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && f.FieldType == typeof(uint)) + .ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal); +} diff --git a/SoundVisualizer.Tests/Ui/UiColorsContrastTests.cs b/SoundVisualizer.Tests/Ui/UiColorsContrastTests.cs new file mode 100644 index 0000000..080aaf8 --- /dev/null +++ b/SoundVisualizer.Tests/Ui/UiColorsContrastTests.cs @@ -0,0 +1,116 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using static SoundVisualizer.UiColors; + +namespace SoundVisualizer.Tests.Ui; + +/// +/// 글자와 바탕의 대비가 WCAG 기준(글자 4.5:1, 그림 요소 3:1)을 넘는지. 안드로이드 판 ColorContrastTest(#312)를 옮기고, +/// PC 에만 있는 마우스 올림·누름 바탕과 키보드 초점 테두리를 더했다. 색을 바꾸다 기준 아래로 내려가면 여기서 막힌다. +/// +public class UiColorsContrastTests +{ + [Fact] + public void 대비_계산은_WCAG_의_양_끝_값과_같다() + { + Assert.Equal(21.0, Contrast(0x000000, 0xFFFFFF), 6); + Assert.Equal(1.0, Contrast(0x777777, 0x777777), 6); + Assert.Equal(Contrast(AccentFill, OnFill), Contrast(OnFill, AccentFill), 9); + } + + [Fact] + public void 팔레트의_여덟_색은_안드로이드_판과_같다() + { + // 안드로이드 UiColors.kt 의 값. 두 판이 같은 앱으로 보이도록 바꿀 때는 함께 바꾼다. + Assert.Equal(0x2A2C31u, Background); + Assert.Equal(0x1E2024u, Card); + Assert.Equal(0xF2F4F6u, PrimaryText); + Assert.Equal(0x8B95A1u, SecondaryText); + Assert.Equal(0x5B9BFFu, Accent); + Assert.Equal(0x2563EBu, AccentFill); + Assert.Equal(0xD32F2Fu, Stop); + Assert.Equal(0xFFB74Du, Warning); + } + + [Fact] + public void 흰_글자를_얹는_바탕은_4_5대1_을_넘는다() + { + AssertReadable("실행 버튼·고른 칸", OnFill, AccentFill); + AssertReadable("실행 버튼에 마우스를 올림", OnFill, AccentFillHover); + AssertReadable("실행 버튼을 누름", OnFill, AccentFillPressed); + AssertReadable("실행 종료 버튼", OnFill, Stop); + AssertReadable("실행 종료 버튼에 마우스를 올림", OnFill, StopHover); + AssertReadable("실행 종료 버튼을 누름", OnFill, StopPressed); + } + + [Fact] + public void 어두운_바탕_위의_글자는_4_5대1_을_넘는다() + { + AssertReadable("카드 위 본 글자", PrimaryText, Card); + AssertReadable("바탕 위 본 글자", PrimaryText, Background); + AssertReadable("카드 위 파란 글자", Accent, Card); + AssertReadable("바탕 위 파란 글자", Accent, Background); + AssertReadable("카드 위 회색 글자", SecondaryText, Card); + // 탭 이름, 언어 콤보 이름, 홈 설명은 카드가 아니라 바탕 위에 있다. 이 대비(4.6:1) 때문에 더 작게 하지 않는다. + AssertReadable("바탕 위 회색 글자", SecondaryText, Background); + AssertReadable("카드 위 안내 글자", Warning, Card); + AssertReadable("바탕 위 안내 글자", Warning, Background); + } + + [Fact] + public void 조작_요소_바탕_위의_본_글자는_4_5대1_을_넘는다() + { + AssertReadable("고르지 않은 칸·보조 버튼·언어 콤보", PrimaryText, Control); + AssertReadable("마우스를 올린 칸·버튼", PrimaryText, ControlHover); + AssertReadable("누르고 있는 칸·버튼", PrimaryText, ControlPressed); + } + + [Fact] + public void 스위치와_슬라이더_막대는_카드_위에서_그림_요소_기준_3대1_을_넘는다() + { + // 슬라이더와 스위치는 카드 안에만 둔다. 바탕(#2A2C31) 위에서는 2.7:1 로 모자란다. + AssertVisible("켠 스위치·채운 슬라이더 막대", AccentFill, Card); + // 꺼진 스위치는 막대(#333A44)가 카드와 거의 같아, 상태는 손잡이 색과 자리로 읽힌다. + AssertVisible("꺼진 스위치 손잡이와 카드", SecondaryText, Card); + AssertVisible("꺼진 스위치 손잡이와 막대", SecondaryText, Control); + AssertVisible("켠 스위치 손잡이와 막대", OnFill, AccentFill); + } + + [Fact] + public void 키보드_초점_테두리는_바탕과_카드_위에서_3대1_을_넘는다() + { + AssertVisible("카드 위 초점 테두리", Accent, Card); + AssertVisible("바탕 위 초점 테두리", Accent, Background); + } + + private static void AssertReadable(string what, uint text, uint background) + { + double ratio = Contrast(text, background); + Assert.True(ratio >= 4.5, $"{what}: {ratio:F2}:1 < 4.5:1"); + } + + private static void AssertVisible(string what, uint graphic, uint background) + { + double ratio = Contrast(graphic, background); + Assert.True(ratio >= 3.0, $"{what}: {ratio:F2}:1 < 3:1"); + } + + /// WCAG 2 의 대비. 상대 휘도는 sRGB 를 선형으로 풀어 구한다(안드로이드 Color.luminance() 와 같은 식). + private static double Contrast(uint a, uint b) + { + double la = Luminance(a), lb = Luminance(b); + return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05); + } + + private static double Luminance(uint rgb) + { + static double Linear(uint channel) + { + double c = channel / 255.0; + return c <= 0.04045 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4); + } + + return 0.2126 * Linear((rgb >> 16) & 0xFF) + 0.7152 * Linear((rgb >> 8) & 0xFF) + 0.0722 * Linear(rgb & 0xFF); + } +} diff --git a/SoundVisualizer.Tests/UiFontChainTests.cs b/SoundVisualizer.Tests/UiFontChainTests.cs new file mode 100644 index 0000000..14c5721 --- /dev/null +++ b/SoundVisualizer.Tests/UiFontChainTests.cs @@ -0,0 +1,59 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using SoundVisualizer.Localization; + +namespace SoundVisualizer.Tests; + +public class UiFontChainTests +{ + [Fact] + public void 모든_언어가_Pretendard_를_먼저_쓴다() + { + foreach (var code in UiLanguages.All.Select(l => (string?)l.Code).Append("xx").Append(null)) + { + Assert.StartsWith("./Fonts/#Pretendard, ", UiFontChain.FamilyListFor(code)); + } + } + + [Theory] + [InlineData("ko", "Malgun Gothic")] + [InlineData("ja", "Yu Gothic UI")] + [InlineData("zh-Hans", "Microsoft YaHei UI")] + [InlineData("zh-Hant", "Microsoft JhengHei UI")] + [InlineData("th", "Leelawadee UI")] + [InlineData("hi", "Nirmala UI")] + [InlineData("ar", "Segoe UI")] + [InlineData("en", "Segoe UI")] + [InlineData("ru", "Segoe UI")] + [InlineData("vi", "Segoe UI")] + public void Pretendard_에_없는_글자는_그_문자에_맞는_Windows_글꼴로_채운다(string code, string firstFallback) + { + var fallbacks = UiFontChain.FallbacksFor(code); + + Assert.Equal(firstFallback, fallbacks[0]); + Assert.Contains("Segoe UI", fallbacks); + } + + [Fact] + public void 한자는_언어에_맞는_지역의_글꼴로_채운다() + { + var japanese = UiFontChain.FallbacksFor("ja"); + var simplified = UiFontChain.FallbacksFor("zh-Hans"); + var traditional = UiFontChain.FallbacksFor("zh-Hant"); + + Assert.DoesNotContain("Microsoft YaHei UI", japanese); + Assert.DoesNotContain("Yu Gothic UI", simplified); + Assert.DoesNotContain("Microsoft JhengHei UI", simplified); + Assert.DoesNotContain("Microsoft YaHei UI", traditional); + Assert.Contains("Meiryo UI", japanese); + } + + [Fact] + public void 코드의_대소문자와_모르는_코드도_받는다() + { + Assert.Equal(UiFontChain.FallbacksFor("zh-Hant"), UiFontChain.FallbacksFor("ZH-HANT")); + Assert.Equal(UiFontChain.FallbacksFor("en"), UiFontChain.FallbacksFor("xx")); + Assert.Equal(UiFontChain.FallbacksFor("en"), UiFontChain.FallbacksFor(null)); + } +} diff --git a/SoundVisualizer.slnx b/SoundVisualizer.slnx index cb2cd5a..057154c 100644 --- a/SoundVisualizer.slnx +++ b/SoundVisualizer.slnx @@ -1,3 +1,5 @@ + + diff --git a/SoundVisualizer/AIModel/FileInferenceTestRunner.cs b/SoundVisualizer/AIModel/FileInferenceTestRunner.cs deleted file mode 100644 index 380e17d..0000000 --- a/SoundVisualizer/AIModel/FileInferenceTestRunner.cs +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (C) 2026 amophi (SoundVisualizer Contributors) -// This file is part of SoundVisualizer. -// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace SoundVisualizer.AIModel -{ - /// - /// 사전 녹음 WAV로 추론 파이프라인 검증(콘솔 출력). UI/DSP와 무관. - /// - public static class FileInferenceTestRunner - { - /// 상위 확률이 이 값 이상일 때만 "수용"으로 표시합니다. - public const float ConfidenceThreshold = 0.6f; - private static readonly string[] CoarseClasses = { "ambient", "speech", "danger" }; - - /// 단일 .wav 파일 추론 결과를 출력합니다. - public static void RunWavFile(SoundClassifier classifier, string wavPath) - { - if (classifier == null) throw new ArgumentNullException(nameof(classifier)); - if (string.IsNullOrWhiteSpace(wavPath)) - throw new ArgumentException("path required", nameof(wavPath)); - - string full = Path.GetFullPath(wavPath); - if (!File.Exists(full)) - { - Console.WriteLine($"file: {wavPath}"); - Console.WriteLine("error: file not found"); - return; - } - - float[] mono = WavAudioLoader.LoadMono16kHz(full); - InferenceResult r = classifier.PredictFromMono16k(mono, ConfidenceThreshold); - - Console.WriteLine($"file: {Path.GetFileName(full)}"); - Console.WriteLine($"predicted_class: {r.CoarseClass}"); - Console.WriteLine($"confidence: {r.Confidence:F2}"); - Console.WriteLine($"inference_time_ms: {r.InferenceTimeMs:F1}"); - } - - /// 폴더 내 .wav 파일을 순회합니다(비재귀). - public static void RunWavFolder(SoundClassifier classifier, string folderPath) - { - if (classifier == null) throw new ArgumentNullException(nameof(classifier)); - string full = Path.GetFullPath(folderPath); - if (!Directory.Exists(full)) - { - Console.WriteLine($"error: folder not found: {folderPath}"); - return; - } - - var files = Directory.GetFiles(full, "*.wav", SearchOption.TopDirectoryOnly) - .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - foreach (var f in files) - { - RunWavFile(classifier, f); - Console.WriteLine(); - } - } - - /// - /// 파일명 접두사로 기대 라벨을 읽어(ambient_/speech_/danger_) 성능을 정량 평가합니다. - /// 예) speech_meeting_01.wav, danger_gunshot_02.wav, ambient_rain_01.wav - /// - public static void RunWavFolderBenchmark( - SoundClassifier classifier, - string folderPath, - float confidenceThreshold = ConfidenceThreshold) - { - if (classifier == null) throw new ArgumentNullException(nameof(classifier)); - string full = Path.GetFullPath(folderPath); - if (!Directory.Exists(full)) - { - Console.WriteLine($"error: folder not found: {folderPath}"); - return; - } - - var files = Directory.GetFiles(full, "*.wav", SearchOption.TopDirectoryOnly) - .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (files.Length == 0) - { - Console.WriteLine("error: no wav files found"); - return; - } - - int total = 0; - int labeled = 0; - int correct = 0; - int accepted = 0; - int acceptedCorrect = 0; - double confSum = 0; - double inferMsSum = 0; - - var confusion = CreateConfusionMatrix(); - - Console.WriteLine("=== YAMNet Benchmark (coarse classes) ==="); - Console.WriteLine($"folder: {full}"); - Console.WriteLine($"threshold: {confidenceThreshold:F2}"); - Console.WriteLine(); - - foreach (var f in files) - { - total++; - string fileName = Path.GetFileName(f); - string? expected = TryParseExpectedCoarseFromFileName(fileName); - - float[] mono = WavAudioLoader.LoadMono16kHz(f); - InferenceResult r = classifier.PredictFromMono16k(mono, confidenceThreshold); - - bool isAccepted = r.Confidence >= confidenceThreshold; - bool hasExpected = expected != null; - bool isCorrect = hasExpected && string.Equals(r.CoarseClass, expected, StringComparison.Ordinal); - - confSum += r.Confidence; - inferMsSum += r.InferenceTimeMs; - - if (isAccepted) accepted++; - if (hasExpected) - { - labeled++; - if (isCorrect) correct++; - if (isAccepted && isCorrect) acceptedCorrect++; - if (expected != null) - AddConfusion(confusion, expected, r.CoarseClass); - } - - string expectedText = hasExpected ? (expected ?? "n/a") : "n/a"; - string status = hasExpected ? (isCorrect ? "OK" : "MISS") : "SKIP"; - string acceptMark = isAccepted ? "accepted" : "rejected"; - Console.WriteLine( - $"{status,-5} [{acceptMark}] file={fileName} | expected={expectedText} | pred={r.CoarseClass} | top={r.YamnetDisplayName} | conf={r.Confidence:F3} | t={r.InferenceTimeMs:F1}ms"); - } - - Console.WriteLine(); - Console.WriteLine("=== Summary ==="); - Console.WriteLine($"total_files: {total}"); - Console.WriteLine($"labeled_files: {labeled}"); - Console.WriteLine($"accepted(>=th): {accepted} ({ToPercent(accepted, total):F1}%)"); - Console.WriteLine($"avg_confidence: {(total > 0 ? confSum / total : 0):F3}"); - Console.WriteLine($"avg_inference_ms: {(total > 0 ? inferMsSum / total : 0):F2}"); - Console.WriteLine($"coarse_accuracy(all_labeled): {ToPercent(correct, labeled):F1}% ({correct}/{labeled})"); - Console.WriteLine($"coarse_accuracy(accepted_only): {ToPercent(acceptedCorrect, accepted):F1}% ({acceptedCorrect}/{accepted})"); - Console.WriteLine(); - PrintConfusionMatrix(confusion); - } - - private static string? TryParseExpectedCoarseFromFileName(string fileName) - { - if (string.IsNullOrWhiteSpace(fileName)) - return null; - - string lower = fileName.ToLowerInvariant(); - if (lower.StartsWith("ambient_")) return "ambient"; - if (lower.StartsWith("speech_")) return "speech"; - if (lower.StartsWith("danger_")) return "danger"; - return null; - } - - private static Dictionary> CreateConfusionMatrix() - { - var matrix = new Dictionary>(StringComparer.Ordinal); - foreach (string expected in CoarseClasses) - { - var row = new Dictionary(StringComparer.Ordinal); - foreach (string predicted in CoarseClasses) - row[predicted] = 0; - matrix[expected] = row; - } - return matrix; - } - - private static void AddConfusion( - Dictionary> matrix, - string expected, - string predicted) - { - if (!matrix.ContainsKey(expected)) - return; - if (!matrix[expected].ContainsKey(predicted)) - return; - matrix[expected][predicted]++; - } - - private static void PrintConfusionMatrix(Dictionary> matrix) - { - Console.WriteLine("confusion_matrix (rows=expected, cols=predicted)"); - Console.WriteLine(" ambient speech danger"); - foreach (string expected in CoarseClasses) - { - int a = matrix[expected]["ambient"]; - int s = matrix[expected]["speech"]; - int d = matrix[expected]["danger"]; - Console.WriteLine($"{expected,-10} {a,7} {s,7} {d,7}"); - } - } - - private static double ToPercent(int numerator, int denominator) - { - if (denominator <= 0) return 0; - return 100.0 * numerator / denominator; - } - } -} - diff --git a/SoundVisualizer/AIModel/InferenceResult.cs b/SoundVisualizer/AIModel/InferenceResult.cs deleted file mode 100644 index 2ded8af..0000000 --- a/SoundVisualizer/AIModel/InferenceResult.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (C) 2026 amophi (SoundVisualizer Contributors) -// This file is part of SoundVisualizer. -// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. -namespace SoundVisualizer.AIModel -{ - /// - /// YAMNet 추론 결과(파일 테스트·통합용). - /// - public readonly struct InferenceResult - { - public InferenceResult( - int yamnetClassIndex, - string yamnetDisplayName, - float confidence, - string coarseClass, - bool meetsThreshold, - double inferenceTimeMs, - string? topKSummary = null, - bool adoptedDangerFromBooster = false) - { - YamnetClassIndex = yamnetClassIndex; - YamnetDisplayName = yamnetDisplayName; - Confidence = confidence; - CoarseClass = coarseClass; - MeetsThreshold = meetsThreshold; - InferenceTimeMs = inferenceTimeMs; - TopKSummary = topKSummary; - AdoptedDangerFromBooster = adoptedDangerFromBooster; - } - - public int YamnetClassIndex { get; } - public string YamnetDisplayName { get; } - /// 0~1 사이 상위 클래스 확률(softmax 후). - public float Confidence { get; } - /// ambient | speech | danger - public string CoarseClass { get; } - public bool MeetsThreshold { get; } - public double InferenceTimeMs { get; } - /// 디버그용 softmax 상위 k개 요약 문자열. - public string? TopKSummary { get; } - /// 이번 프레임에서 gunshot booster가 danger 채택했는지. - public bool AdoptedDangerFromBooster { get; } - } -} - diff --git a/SoundVisualizer/AIModel/SoundClassifier.cs b/SoundVisualizer/AIModel/SoundClassifier.cs deleted file mode 100644 index f9e4a44..0000000 --- a/SoundVisualizer/AIModel/SoundClassifier.cs +++ /dev/null @@ -1,1284 +0,0 @@ -// Copyright (C) 2026 amophi (SoundVisualizer Contributors) -// This file is part of SoundVisualizer. -// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. - -using System; -using System.Buffers; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Numerics; -using Microsoft.ML.OnnxRuntime; -using Microsoft.ML.OnnxRuntime.Tensors; -using NAudio.Wave; -using NAudio.Wave.SampleProviders; - -namespace SoundVisualizer.AIModel -{ - public class SoundClassifier : IDisposable - { - private InferenceSession? _session; - private InferenceSession? _coarseHeadSession; - private string? _coarseHeadInputName; - private string? _coarseHeadOutputName; - private InferenceSession? _gunshotBoosterSession; - private string? _gunshotBoosterInputName; - private string? _gunshotBoosterOutputName; - private string[] _classNames = Array.Empty(); // YAMNet의 521개 소리 이름 목록 - - // YAMNet: 16kHz mono → log-mel, 일반적으로 time × mel = 96 × 64 - // ONNX 입력 shape [1, 1, 96, 64] = [batch, ch, time_frames, mel_bins] - private const int SampleRate = 16000; - private const int MelBins = 64; - private const int TimeFrames = 96; - private const int WindowLength = 400; // 25ms @ 16kHz - private const int HopLength = 160; // 10ms @ 16kHz - private const int FftSize = 512; // TF YAMNet 계열과 맞추기 위한 실험 (>= WindowLength, 2의 거듭제곱) - private const float MelFMin = 125f; - private const float MelFMax = 7500f; - private const float LogEps = 0.001f; - - /// 멜 스펙트럼 한 윈도우에 필요한 16kHz 모노 샘플 수(≈0.975s). - private static readonly int RequiredMono16kSamples = WindowLength + HopLength * (TimeFrames - 1); - - private const int RingSeconds = 2; - - private readonly object _inferenceLock = new(); - private bool _isDisposed; - - private readonly object _captureRingLock = new(); - - // 고정 크기 순환 링 버퍼 필드 - private float[]? _ringBuffer; - private int _ringHead; - private int _ringTail; - private int _ringCount; - private const int MaxRingSize = 131072; // 2의 거듭제곱 (비트마스킹 & 131071 가속) - - // GC Free 전처리 및 추론용 캐시 버퍼 - private float[]? _preprocessedAudio; // 크기: RequiredMono16kSamples - private Complex[]? _fftComplexBuffer; // 크기: FftSize (512) - private float[]? _fftPowerBuffer; // 크기: FftSize / 2 + 1 (257) - private float[]? _logMelSpectrogramBuffer; // 크기: TimeFrames * MelBins (6144) - private float[]? _softmaxBuffer; // 크기: 521 - private float[]? _captureTailBuffer; // 16kHz 리샘플 이전 원시 오디오 복사용 임시 버퍼 - - // Top-K 계산용 캐시 버퍼 (가비지 프리) - private int[]? _topKIndices; // 크기: 5 - private float[]? _topKProbs; // 크기: 5 - - private string _lastPredictResult = "오디오 대기… | ambient | —"; - -#if DEBUG - private static long _lastClassifyDebugTickMs; - private const int ClassifyDebugMinIntervalMs = 200; -#endif - - // coarse 히스테리시스: 연속 N프레임 동일해야 전환 - private const int CoarseHysteresisThreshold = 2; - private const int DangerHysteresisThreshold = 1; - private const float DangerImmediateSwitchConfidence = 0.28f; - /// danger→non-danger: booster 미재채택·충분 신뢰 시에만 이탈 1프레임 (총 직후 잔향/BGM). - private const float DangerExitRelaxedConfidence = 0.27f; - private string _confirmedCoarse = "ambient"; - private string _confirmedDisplay = ""; - private float _confirmedConfidence; - private string _candidateCoarse = ""; - private int _candidateStreak; - - /// - /// WASAPI 루프백이 흔히 48kHz float이므로, 호출부에서 샘플레이트를 넘기지 않을 때의 기본값입니다. - /// - public const int DefaultCaptureSampleRate = 48000; - - // Precomputed preprocessing artifacts - private readonly float[] _hannWindow; - private readonly float[,] _melFilterBank; // [MelBins, FftSize/2+1] - private readonly int[] _melStartBins; - private readonly int[] _melEndBins; - private readonly int[] _bitReversed; // [FftSize] - - public SoundClassifier() - { - _hannWindow = CreateHannWindow(WindowLength); - _melFilterBank = CreateMelFilterBank(MelBins, FftSize, SampleRate, MelFMin, MelFMax); - - _melStartBins = new int[MelBins]; - _melEndBins = new int[MelBins]; - int freqBins = (FftSize / 2) + 1; - for (int m = 0; m < MelBins; m++) - { - int start = 0; - while (start < freqBins && _melFilterBank[m, start] == 0f) - { - start++; - } - int last = freqBins - 1; - while (last >= start && _melFilterBank[m, last] == 0f) - { - last--; - } - _melStartBins[m] = start; - _melEndBins[m] = Math.Min(freqBins, last + 1); - } - - _bitReversed = CreateBitReversedIndices(FftSize); - - // 링 버퍼 및 사전 캐싱 버퍼들 할당 - _ringBuffer = new float[MaxRingSize]; - _preprocessedAudio = new float[RequiredMono16kSamples]; - _fftComplexBuffer = new Complex[FftSize]; - _fftPowerBuffer = new float[(FftSize / 2) + 1]; - _logMelSpectrogramBuffer = new float[TimeFrames * MelBins]; - _softmaxBuffer = new float[521]; - _topKIndices = new int[5]; - _topKProbs = new float[5]; - - // 1. AI 뇌(ONNX 모델) 로드 (SessionOptions 세팅으로 스레드 1개 제한) - try - { - using var options = new SessionOptions(); - options.IntraOpNumThreads = 1; - options.InterOpNumThreads = 1; - options.ExecutionMode = ExecutionMode.ORT_SEQUENTIAL; - - string modelPath = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AIModel", "yamnet.onnx"); - _session = new InferenceSession(modelPath, options); - LoadClassNames(); - TryLoadGunshotBooster(options); - - foreach (var kv in _session.InputMetadata) - { - } - } - catch (Exception) - { - } - } - - public string PredictSoundType(byte[] rawAudioData, int bytesRecorded, int channels, int captureSampleRate = DefaultCaptureSampleRate) - { - IngestAudio(rawAudioData, bytesRecorded, channels, captureSampleRate); - return PredictSoundType(captureSampleRate); - } - - /// - /// 실시간으로 캡처되는 원시 오디오 데이터를 다운믹싱하여 링 버퍼에 적재하기만 합니다. - /// - public void IngestAudio(byte[] rawAudioData, int bytesRecorded, int channels, int captureSampleRate = DefaultCaptureSampleRate) - { - if (_isDisposed || bytesRecorded <= 0) return; - - float[] monoAudio = DownmixToMono(rawAudioData, bytesRecorded, channels, out int actualFrames); - try - { - lock (_captureRingLock) - { - AppendMonoCaptureRing(monoAudio, actualFrames, captureSampleRate); - } - } - finally - { - if (monoAudio != Array.Empty()) - { - ArrayPool.Shared.Return(monoAudio); - } - } - } - - /// - /// 링 버퍼에 누적된 최신 오디오 데이터를 기반으로 YAMNet ONNX 추론을 수행하고 결과를 캐시합니다. - /// - public string PredictSoundType(int captureSampleRate = DefaultCaptureSampleRate) - { - lock (_inferenceLock) - { - if (_isDisposed || _session == null) - { - _lastPredictResult = "AI 꺼짐"; - return _lastPredictResult; - } - - const float threshold = 0.25f; - int resampleFrom = captureSampleRate == DefaultCaptureSampleRate ? 48000 : captureSampleRate; - int n = CaptureSamplesForOneYamnetWindow(resampleFrom); - int ringCount; - - lock (_captureRingLock) - { - ringCount = _ringCount; - if (_captureTailBuffer == null || _captureTailBuffer.Length != n) - { - _captureTailBuffer = new float[n]; - } - CopyRingTailRightPadded(_captureTailBuffer, n); - } - - int minRingSamples = CaptureSamplesForOneYamnetWindow(resampleFrom); - if (ringCount < minRingSamples) - { - _lastPredictResult = "오디오 축적 중… | ambient | —"; - return _lastPredictResult; - } - - if (_preprocessedAudio == null) - { - _preprocessedAudio = new float[RequiredMono16kSamples]; - } - ResampleMonoFloatTo16kCustom(_captureTailBuffer, n, resampleFrom, _preprocessedAudio); - - InferenceResult r = PredictFromMono16k(_preprocessedAudio, threshold); - - if (r.YamnetClassIndex < 0) - { - _lastPredictResult = "AI 에러"; - return _lastPredictResult; - } - -#if DEBUG - LogClassificationDebugThrottled(in r, threshold); -#endif - ApplyCoarseHysteresis(in r); - - // booster가 danger로 올린 프레임은 UI만 즉시 반영, 확정 상태는 히스테리시스 유지 - bool useBoosterDangerPreview = - _confirmedCoarse != "danger" && - r.AdoptedDangerFromBooster && - r.CoarseClass == "danger" && - r.MeetsThreshold; - - string displayForUi = useBoosterDangerPreview ? r.YamnetDisplayName : _confirmedDisplay; - string coarseForUi = useBoosterDangerPreview ? "danger" : _confirmedCoarse; - float confidenceForUi = useBoosterDangerPreview ? r.Confidence : _confirmedConfidence; - - string translatedName = YamnetThreeClassMapper.TranslateToKorean(displayForUi); - string resultText; - - if (confidenceForUi < threshold) - resultText = $"{translatedName} | {coarseForUi} | {confidenceForUi * 100f:F1}% (저신뢰)"; - else - resultText = $"{translatedName} | {coarseForUi} | {confidenceForUi * 100f:F1}%"; - - _lastPredictResult = resultText; - return resultText; - } - } - - /// - /// 마지막으로 갱신된 YAMNet 예측 결과를 락 경합 없이 O(1)로 빠르게 반환합니다. - /// - public string GetLastPredictResult() - { - return _lastPredictResult; - } - - private void ApplyCoarseHysteresis(in InferenceResult r) - { - // 저신뢰 프레임은 확정 상태·후보 스트릭을 바꾸지 않음(오탐/깜빡임 억제) - if (!r.MeetsThreshold) - return; - - string newCoarse = r.CoarseClass; - - if (newCoarse == _confirmedCoarse) - { - _candidateStreak = 0; - _candidateCoarse = ""; - _confirmedDisplay = r.YamnetDisplayName; - _confirmedConfidence = r.Confidence; - return; - } - - // 총성/폭발처럼 짧은 transient는 즉시 반영해 미검출을 줄입니다. - if (newCoarse == "danger" && - (r.Confidence >= DangerImmediateSwitchConfidence || IsCriticalDangerEvent(in r))) - { - _confirmedCoarse = newCoarse; - _confirmedDisplay = r.YamnetDisplayName; - _confirmedConfidence = r.Confidence; - _candidateStreak = 0; - _candidateCoarse = ""; - return; - } - - if (newCoarse == _candidateCoarse) - { - _candidateStreak++; - } - else - { - _candidateCoarse = newCoarse; - _candidateStreak = 1; - } - - int requiredStreak = newCoarse == "danger" ? DangerHysteresisThreshold : CoarseHysteresisThreshold; - if (_confirmedCoarse == "danger" && - newCoarse != "danger" && - !r.AdoptedDangerFromBooster && - r.Confidence >= DangerExitRelaxedConfidence) - { - requiredStreak = 1; - } - - if (_candidateStreak >= requiredStreak) - { - _confirmedCoarse = newCoarse; - _confirmedDisplay = r.YamnetDisplayName; - _confirmedConfidence = r.Confidence; - _candidateStreak = 0; - _candidateCoarse = ""; - } - } - -#if DEBUG - /// 출력 창에서 UI보다 읽기 쉽게 분류 결과를 확인하기 위한 로그(스로틀). - private static void LogClassificationDebugThrottled(in InferenceResult r, float threshold) - { - long now = Environment.TickCount64; - if (now - _lastClassifyDebugTickMs < ClassifyDebugMinIntervalMs) - return; - _lastClassifyDebugTickMs = now; - - string topK = string.IsNullOrEmpty(r.TopKSummary) ? "" : $" | top3: {r.TopKSummary}"; - string lo = r.MeetsThreshold ? "OK" : "저신뢰"; - } -#endif - - private static int CaptureSamplesForOneYamnetWindow(int captureSampleRate) => - (int)Math.Ceiling(RequiredMono16kSamples * (double)captureSampleRate / SampleRate); - - private void AppendMonoCaptureRing(float[] monoChunk, int length, int captureSampleRate) - { - if (length <= 0) - return; - if (captureSampleRate <= 0) - captureSampleRate = DefaultCaptureSampleRate; - - int cap = Math.Max(DefaultCaptureSampleRate * RingSeconds, captureSampleRate * RingSeconds); - if (_ringBuffer == null) - { - _ringBuffer = new float[MaxRingSize]; - } - - for (int i = 0; i < length; i++) - { - _ringBuffer[_ringTail] = monoChunk[i]; - _ringTail = (_ringTail + 1) & (MaxRingSize - 1); - - if (_ringCount < MaxRingSize) - { - _ringCount++; - } - else - { - _ringHead = (_ringHead + 1) & (MaxRingSize - 1); - } - } - - while (_ringCount > cap) - { - _ringHead = (_ringHead + 1) & (MaxRingSize - 1); - _ringCount--; - } - } - - private void CopyRingTailRightPadded(float[] destination, int count) - { - if (destination == null || count <= 0 || _ringBuffer == null) - return; - - int take = Math.Min(_ringCount, count); - int dstStart = count - take; - - // 앞부분(왼쪽)을 0f로 패딩 (오른쪽 정렬을 위함) - for (int i = 0; i < dstStart; i++) - { - destination[i] = 0f; - } - - if (take > 0) - { - int ringStart = (_ringTail - take) & (MaxRingSize - 1); - for (int i = 0; i < take; i++) - { - destination[dstStart + i] = _ringBuffer[(ringStart + i) & (MaxRingSize - 1)]; - } - } - } - - /// - /// 모노 PCM(float)을 16kHz로 맞춘 뒤 전처리·추론합니다. WAV 파일 테스트 등에 사용합니다. - /// - /// 상위 클래스 확률이 이 값 미만이면 MeetsThreshold=false. - public InferenceResult PredictFromMono16k(float[] monoAudio, float confidenceThreshold) - { - if (_session == null) - return new InferenceResult(-1, "AI 꺼짐", 0f, "ambient", false, 0); - - float[] logMel = ComputeLogMelSpectrogram(monoAudio); - - // [1,1,96,64] = time(96) × mel(64), row-major에서 mel이 마지막 축 - var inputTensor = new DenseTensor(logMel, new[] { 1, 1, TimeFrames, MelBins }); - var inputs = new[] { NamedOnnxValue.CreateFromTensor("audio", inputTensor) }; - - float[] logits; - double inferMs; - var sw = Stopwatch.StartNew(); - try - { - using var results = _session.Run(inputs); - logits = results.First().AsEnumerable().ToArray(); - } - catch (Exception) - { - return new InferenceResult(-1, "AI 에러", 0f, "ambient", false, 0); - } - finally - { - sw.Stop(); - } - - inferMs = sw.Elapsed.TotalMilliseconds; - - float[] probs = Softmax(logits); - - if (_topKIndices == null) _topKIndices = new int[5]; - if (_topKProbs == null) _topKProbs = new float[5]; - ComputeTop5(probs, _topKIndices, _topKProbs); - - int maxIndex = _topKIndices[0]; - float conf = _topKProbs[0]; - string display = _classNames[maxIndex]; - - PreferDangerWhenTopIsMaskedByGameMix(probs, ref maxIndex, ref conf, ref display); - - string coarse = VoteCoarseFromTop5(_topKIndices, _topKProbs, 3); - float coarseConf = conf; - float dangerEvidence = SumCoarseProbabilityFromTop5(_topKIndices, _topKProbs, 5, "danger"); - bool hasStrongDangerCue = HasStrongDangerCueInTop5(_topKIndices, 5); - bool hasCriticalDangerCue = HasCriticalDangerCueInTop5(_topKIndices, 5); - string yamnetCoarse = coarse; - bool adoptedDangerFromBooster = false; - - if (TryPredictGunshotBoosterScore(probs, out float gunshotScore)) - { - float gunshotEvidence = SumGunshotProbabilityFromTop5(_topKIndices, _topKProbs, 5); - bool hasGunshotCue = HasGunshotCueInTop5(_topKIndices, 5); - - // speech·무음·저신뢰에서는 booster danger 채택 금지 (전 클래스 danger 오탐 방지) - bool blockBooster = - yamnetCoarse == "speech" || - IsSpeechLikeDisplay(display) || - IsSilenceLikeDisplay(display) || - conf < 0.12f; - - bool adoptGunshotDanger = false; - if (!blockBooster) - { - if (hasGunshotCue) - { - adoptGunshotDanger = gunshotScore >= 0.20f && gunshotEvidence >= 0.05f; - } - else if (IsGameMixMaskDisplay(display) || hasStrongDangerCue) - { - // 게임 BGM(음악·Sound effect) 위 총소리: booster·총 클래스 확률 둘 다 참고 - adoptGunshotDanger = - gunshotScore >= 0.50f || - (gunshotScore >= 0.40f && gunshotEvidence >= 0.04f); - } - else - { - adoptGunshotDanger = gunshotScore >= 0.45f && gunshotEvidence >= 0.10f; - } - } - - if (adoptGunshotDanger) - { - adoptedDangerFromBooster = true; - coarse = "danger"; - coarseConf = MathF.Max(coarseConf, MathF.Max(gunshotScore, gunshotEvidence)); - if (TryPickBestGunshotDisplay(probs, _classNames, out int gunIdx, out float gunProb)) - { - maxIndex = gunIdx; - display = _classNames[gunIdx]; - coarseConf = MathF.Max(coarseConf, gunProb); - } - } - } - - // A 모드: 3클래스 헤드(danger 재판정) 경로 비활성 - - // danger는 강한 단서(top-k)에서 임계를 낮춰 미검출을 줄이고, - // speech/ambient는 기본 임계를 그대로 유지합니다. - float effectiveThreshold = confidenceThreshold; - if (coarse == "danger" && (hasStrongDangerCue || hasCriticalDangerCue || adoptedDangerFromBooster)) - { - effectiveThreshold = MathF.Min(effectiveThreshold, adoptedDangerFromBooster ? 0.18f : 0.20f); - } - else if (coarse == "speech") - { - // speech 오탐을 줄이기 위해 최소 임계를 상향합니다. - effectiveThreshold = MathF.Max(effectiveThreshold, 0.25f); - } - - bool ok = coarseConf >= effectiveThreshold; - string? topK = FormatTop5SoftmaxLabels(_topKIndices, _topKProbs, 3); - - return new InferenceResult( - maxIndex, display, coarseConf, coarse, ok, inferMs, topK, adoptedDangerFromBooster); - } - - private void TryLoadDistilledCoarseHead(SessionOptions options) - { - try - { - string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AIModel", "three_class_score_head.onnx"); - if (!File.Exists(path)) - { - return; - } - - _coarseHeadSession = new InferenceSession(path, options); - _coarseHeadInputName = _coarseHeadSession.InputMetadata.Keys.FirstOrDefault(); - _coarseHeadOutputName = _coarseHeadSession.OutputMetadata.Keys.FirstOrDefault(); - } - catch (Exception) - { - _coarseHeadSession = null; - _coarseHeadInputName = null; - _coarseHeadOutputName = null; - } - } - - private void TryLoadGunshotBooster(SessionOptions options) - { - try - { - string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AIModel", "gunshot_booster.onnx"); - if (!File.Exists(path)) - { - return; - } - - _gunshotBoosterSession = new InferenceSession(path, options); - _gunshotBoosterInputName = _gunshotBoosterSession.InputMetadata.Keys.FirstOrDefault(); - _gunshotBoosterOutputName = _gunshotBoosterSession.OutputMetadata.Keys.FirstOrDefault(); - } - catch (Exception) - { - _gunshotBoosterSession = null; - _gunshotBoosterInputName = null; - _gunshotBoosterOutputName = null; - } - } - - private float[]? _boosterInputTensorBuffer; - private DenseTensor? _boosterInputTensor; - - private bool TryPredictGunshotBoosterScore(float[] yamnetProbs, out float gunshotScore) - { - gunshotScore = 0f; - if (_gunshotBoosterSession == null || string.IsNullOrEmpty(_gunshotBoosterInputName)) - return false; - if (yamnetProbs.Length < 521) - return false; - - try - { - if (_boosterInputTensorBuffer == null) - { - _boosterInputTensorBuffer = new float[521]; - _boosterInputTensor = new DenseTensor(_boosterInputTensorBuffer, new[] { 1, 521 }); - } - - for (int i = 0; i < 521; i++) - _boosterInputTensorBuffer[i] = yamnetProbs[i]; - - var inputs = new[] { NamedOnnxValue.CreateFromTensor(_gunshotBoosterInputName, _boosterInputTensor) }; - using var results = _gunshotBoosterSession.Run(inputs); - var outTensor = string.IsNullOrEmpty(_gunshotBoosterOutputName) - ? results.First().AsTensor() - : results.First(r => r.Name == _gunshotBoosterOutputName).AsTensor(); - if (outTensor.Length < 1) - return false; - - gunshotScore = outTensor.GetValue(0); - return true; - } - catch - { - return false; - } - } - - private float[]? _coarseInputTensorBuffer; - private DenseTensor? _coarseInputTensor; - - private bool TryPredictDangerScoreFromDistilledHead(float[] yamnetProbs, out float dangerScore) - { - dangerScore = 0f; - if (_coarseHeadSession == null || string.IsNullOrEmpty(_coarseHeadInputName)) - return false; - if (yamnetProbs.Length < 521) - return false; - - try - { - if (_coarseInputTensorBuffer == null) - { - _coarseInputTensorBuffer = new float[521]; - _coarseInputTensor = new DenseTensor(_coarseInputTensorBuffer, new[] { 1, 521 }); - } - - for (int i = 0; i < 521; i++) - _coarseInputTensorBuffer[i] = yamnetProbs[i]; - - var inputs = new[] { NamedOnnxValue.CreateFromTensor(_coarseHeadInputName, _coarseInputTensor) }; - using var results = _coarseHeadSession.Run(inputs); - var outTensor = string.IsNullOrEmpty(_coarseHeadOutputName) - ? results.First().AsTensor() - : results.First(r => r.Name == _coarseHeadOutputName).AsTensor(); - if (outTensor.Length < 3) - return false; - - dangerScore = outTensor.GetValue(0); - return true; - } - catch - { - return false; - } - } - - // Top-5 데이터를 한 번의 패스로 계산하는 최적화 메서드 (0-allocation) - private void ComputeTop5(float[] probs, int[] outIndices, float[] outProbs) - { - for (int i = 0; i < 5; i++) - { - outIndices[i] = -1; - outProbs[i] = -1f; - } - - int n = Math.Min(probs.Length, _classNames.Length); - for (int i = 0; i < n; i++) - { - float p = probs[i]; - for (int j = 0; j < 5; j++) - { - if (p > outProbs[j]) - { - for (int k = 4; k > j; k--) - { - outProbs[k] = outProbs[k - 1]; - outIndices[k] = outIndices[k - 1]; - } - outProbs[j] = p; - outIndices[j] = i; - break; - } - } - } - } - - private string VoteCoarseFromTop5(int[] topIndices, float[] topProbs, int k) - { - float dangerSum = 0f, speechSum = 0f, ambientSum = 0f; - int limit = Math.Min(5, k); - for (int idx = 0; idx < limit; idx++) - { - int i = topIndices[idx]; - if (i < 0) continue; - string c = YamnetThreeClassMapper.MapDisplayNameToCoarse(_classNames[i]); - float p = topProbs[idx]; - if (c == "danger") dangerSum += p; - else if (c == "speech") speechSum += p; - else ambientSum += p; - } - - if (dangerSum >= speechSum && dangerSum >= ambientSum) return "danger"; - if (speechSum >= ambientSum) return "speech"; - return "ambient"; - } - - private float SumCoarseProbabilityFromTop5(int[] topIndices, float[] topProbs, int k, string targetCoarse) - { - float sum = 0f; - int limit = Math.Min(5, k); - for (int idx = 0; idx < limit; idx++) - { - int i = topIndices[idx]; - if (i < 0) continue; - if (YamnetThreeClassMapper.MapDisplayNameToCoarse(_classNames[i]) == targetCoarse) - sum += topProbs[idx]; - } - return sum; - } - - private float SumGunshotProbabilityFromTop5(int[] topIndices, float[] topProbs, int k) - { - float sum = 0f; - int limit = Math.Min(5, k); - for (int idx = 0; idx < limit; idx++) - { - int i = topIndices[idx]; - if (i < 0) continue; - string name = _classNames[i]; - if (IsGunshotKeyword(name)) - { - sum += topProbs[idx]; - } - } - return sum; - } - - private bool HasGunshotCueInTop5(int[] topIndices, int k) - { - int limit = Math.Min(5, k); - for (int idx = 0; idx < limit; idx++) - { - int i = topIndices[idx]; - if (i < 0) continue; - string name = _classNames[i]; - if (IsGunshotKeyword(name)) - { - return true; - } - } - return false; - } - - private bool HasStrongDangerCueInTop5(int[] topIndices, int k) - { - int limit = Math.Min(5, k); - for (int idx = 0; idx < limit; idx++) - { - int i = topIndices[idx]; - if (i < 0) continue; - string name = _classNames[i]; - if (IsStrongDangerKeyword(name)) - { - return true; - } - } - return false; - } - - private bool HasCriticalDangerCueInTop5(int[] topIndices, int k) - { - int limit = Math.Min(5, k); - for (int idx = 0; idx < limit; idx++) - { - int i = topIndices[idx]; - if (i < 0) continue; - string name = _classNames[i]; - if (IsCriticalDangerKeyword(name)) - { - return true; - } - } - return false; - } - - private static bool IsGunshotKeyword(string name) - { - if (string.IsNullOrEmpty(name)) return false; - return name.Contains("gunshot", StringComparison.OrdinalIgnoreCase) || - name.Contains("gunfire", StringComparison.OrdinalIgnoreCase) || - name.Contains("machine gun", StringComparison.OrdinalIgnoreCase) || - name.Contains("artillery", StringComparison.OrdinalIgnoreCase) || - name.Contains("fusillade", StringComparison.OrdinalIgnoreCase) || - name.Contains("cap gun", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsSpeechLikeDisplay(string display) - { - if (string.IsNullOrEmpty(display)) return false; - var s = display.ToLowerInvariant(); - return s.Contains("speech") || s.Contains("conversation") || s.Contains("narration") || - s.Contains("speaking") || s.Contains("babbling") || s.Contains("whisper") || - s.Contains("singing") || s.Contains("choir") || s.Contains("laughter") || - s.Contains("crying") || s.Contains("sobbing") || s.Contains("shout"); - } - - private static bool IsSilenceLikeDisplay(string display) - { - if (string.IsNullOrEmpty(display)) return false; - var s = display.ToLowerInvariant(); - return s.Contains("silence") || s.Contains("quiet") || s.Contains("background noise"); - } - - /// 게임 BGM·효과음 레이어로 총소리가 가려지는 top-1 패턴 (말소리 제외). - private static bool IsGameMixMaskDisplay(string display) - { - if (string.IsNullOrEmpty(display)) return false; - if (IsSpeechLikeDisplay(display) || IsSilenceLikeDisplay(display)) return false; - if (YamnetThreeClassMapper.IsGenericSoundEffectLabel(display)) return true; - var s = display.ToLowerInvariant(); - return s.Contains("music") || s.Contains("video game"); - } - - private static bool TryPickBestGunshotDisplay(float[] probs, string[] classNames, out int index, out float prob) - { - index = -1; - prob = 0f; - int n = Math.Min(probs.Length, classNames.Length); - for (int i = 0; i < n; i++) - { - if (!IsGunshotKeyword(classNames[i])) continue; - if (probs[i] > prob) - { - prob = probs[i]; - index = i; - } - } - return index >= 0; - } - - private static bool IsStrongDangerKeyword(string name) - { - if (string.IsNullOrEmpty(name)) return false; - return IsGunshotKeyword(name) || - name.Contains("explosion", StringComparison.OrdinalIgnoreCase) || - name.Contains("fireworks", StringComparison.OrdinalIgnoreCase) || - name.Contains("firecracker", StringComparison.OrdinalIgnoreCase) || - name.Contains("siren", StringComparison.OrdinalIgnoreCase) || - name.Contains("alarm", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsCriticalDangerKeyword(string name) - { - if (string.IsNullOrEmpty(name)) return false; - return IsGunshotKeyword(name) || - name.Contains("explosion", StringComparison.OrdinalIgnoreCase) || - name.Contains("fireworks", StringComparison.OrdinalIgnoreCase) || - name.Contains("firecracker", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsCriticalDangerEvent(in InferenceResult r) - { - string display = r.YamnetDisplayName ?? ""; - string top = r.TopKSummary ?? ""; - return IsCriticalDangerKeyword(display) || IsCriticalDangerKeyword(top); - } - - private string FormatTop5SoftmaxLabels(int[] topIndices, float[] topProbs, int k) - { - int limit = Math.Min(5, k); - var sb = new System.Text.StringBuilder(); - for (int idx = 0; idx < limit; idx++) - { - int i = topIndices[idx]; - if (i < 0) continue; - if (sb.Length > 0) sb.Append(" > "); - sb.Append(_classNames[i]).Append(' ').Append((topProbs[idx] * 100f).ToString("F1")).Append('%'); - } - return sb.ToString(); - } - - /// - /// top-1이 음악·Sound effect 등 게임 믹스에 가려질 때, 521클래스 중 danger(총·폭발) 후보를 찾습니다. - /// - private void PreferDangerWhenTopIsMaskedByGameMix(float[] probs, ref int maxIndex, ref float conf, ref string display) - { - if (maxIndex < 0 || maxIndex >= _classNames.Length || probs.Length != _classNames.Length) - return; - if (!IsGameMixMaskDisplay(display)) - return; - - int bestDangerIdx = -1; - float bestDangerProb = 0f; - int n = Math.Min(probs.Length, _classNames.Length); - for (int i = 0; i < n; i++) - { - if (YamnetThreeClassMapper.MapDisplayNameToCoarse(_classNames[i]) != "danger") - continue; - if (probs[i] > bestDangerProb) - { - bestDangerProb = probs[i]; - bestDangerIdx = i; - } - } - - if (bestDangerIdx < 0) - return; - - float topProb = probs[maxIndex]; - float bar = Math.Max(0.06f, topProb * 0.22f); - if (bestDangerProb < bar) - return; - - maxIndex = bestDangerIdx; - conf = bestDangerProb; - display = _classNames[bestDangerIdx]; - } - - /// log-mel 텐서 통계(FFT/멜 실험 시 비교용). - private static void LogLogMelTensorStats(float[] logMel) - { - if (logMel.Length == 0) return; - - float min = logMel[0], max = logMel[0]; - double sum = 0; - foreach (var v in logMel) - { - if (v < min) min = v; - if (v > max) max = v; - sum += v; - } - - float mean = (float)(sum / logMel.Length); - double varAcc = 0; - foreach (var v in logMel) - { - double d = v - mean; - varAcc += d * d; - } - - float std = (float)Math.Sqrt(varAcc / logMel.Length); - if (std < 1e-4f) - return; - - } - - private float[] Softmax(float[] logits) - { - if (logits.Length == 0) - return Array.Empty(); - - if (_softmaxBuffer == null) _softmaxBuffer = new float[logits.Length]; - - float max = logits.Max(); - double sum = 0; - for (int i = 0; i < logits.Length; i++) - { - _softmaxBuffer[i] = MathF.Exp(logits[i] - max); - sum += _softmaxBuffer[i]; - } - - if (sum <= 0 || double.IsNaN(sum)) - { - float inv = 1f / logits.Length; - for (int i = 0; i < logits.Length; i++) - _softmaxBuffer[i] = inv; - return _softmaxBuffer; - } - - for (int i = 0; i < logits.Length; i++) - _softmaxBuffer[i] = (float)(_softmaxBuffer[i] / sum); - - return _softmaxBuffer; - } - - /// - /// rawAudioData(멀티채널 float)를 모노 float으로 다운믹스합니다. - /// 반환된 배열은 ArrayPool에서 빌린 것이므로 사용 후 반드시 ArrayPool.Shared.Return으로 반환해야 합니다. - /// - private static float[] DownmixToMono(byte[] rawAudioData, int bytesRecorded, int channels, out int actualFrames) - { - int floatCount = bytesRecorded / 4; - if (floatCount == 0 || channels <= 0) - { - actualFrames = 0; - return Array.Empty(); - } - - int frames = floatCount / channels; - actualFrames = frames; - float[] monoAudio = ArrayPool.Shared.Rent(frames); - - ReadOnlySpan samples = System.Runtime.InteropServices.MemoryMarshal.Cast(rawAudioData.AsSpan(0, bytesRecorded)); - - if (channels == 8) - { - const float eps = 1e-12f; - for (int f = 0; f < frames; f++) - { - int o = f * channels; - float fl = samples[o + 0]; - float fr = samples[o + 1]; - float fc = samples[o + 2]; - float lr = 0.5f * (fl + fr); - float eFc = fc * fc; - float eLr = lr * lr; - float w = eFc / (eFc + eLr + eps); - monoAudio[f] = w * fc + (1f - w) * lr; - } - - return monoAudio; - } - - int frameIndex = 0; - for (int i = 0; i < floatCount - (channels - 1); i += channels) - { - float sum = 0f; - for (int c = 0; c < channels; c++) - sum += samples[i + c]; - monoAudio[frameIndex++] = sum / channels; - } - - return monoAudio; - } - - /// - /// 고속 0-allocation 선형 보간 리샘플러 - /// - private void ResampleMonoFloatTo16kCustom(float[] source, int sourceLength, int sourceSampleRate, float[] destination) - { - int destLength = destination.Length; - if (sourceSampleRate == SampleRate) - { - int copyLen = Math.Min(sourceLength, destLength); - Array.Copy(source, 0, destination, 0, copyLen); - if (copyLen < destLength) - { - Array.Clear(destination, copyLen, destLength - copyLen); - } - return; - } - - double factor = (double)sourceSampleRate / SampleRate; - for (int i = 0; i < destLength; i++) - { - double srcPos = i * factor; - int index1 = (int)srcPos; - int index2 = index1 + 1; - float alpha = (float)(srcPos - index1); - - if (index1 >= sourceLength) - { - destination[i] = 0f; - } - else - { - float val1 = source[index1]; - float val2 = (index2 < sourceLength) ? source[index2] : val1; - destination[i] = (1f - alpha) * val1 + alpha * val2; - } - } - } - - /// - /// log-mel을 [time, mel] 순으로 평탄화: 인덱스 time * MelBins + mel → DenseTensor [1,1,TimeFrames,MelBins]. - /// - private float[] ComputeLogMelSpectrogram(float[] monoAudio) - { - int requiredSamples = RequiredMono16kSamples; - - if (_preprocessedAudio == null) _preprocessedAudio = new float[requiredSamples]; - int copyLen = Math.Min(monoAudio.Length, requiredSamples); - if (copyLen > 0) - Array.Copy(monoAudio, 0, _preprocessedAudio, 0, copyLen); - if (copyLen < requiredSamples) - Array.Clear(_preprocessedAudio, copyLen, requiredSamples - copyLen); - - int freqBins = (FftSize / 2) + 1; - if (_fftPowerBuffer == null) _fftPowerBuffer = new float[freqBins]; - if (_logMelSpectrogramBuffer == null) _logMelSpectrogramBuffer = new float[TimeFrames * MelBins]; - if (_fftComplexBuffer == null) _fftComplexBuffer = new Complex[FftSize]; - - for (int t = 0; t < TimeFrames; t++) - { - int start = t * HopLength; - - for (int i = 0; i < FftSize; i++) - { - float sample = (i < WindowLength) ? _preprocessedAudio[start + i] * _hannWindow[i] : 0f; - _fftComplexBuffer[i] = new Complex(sample, 0f); - } - - FFTInPlace(_fftComplexBuffer, _bitReversed); - - for (int k = 0; k < freqBins; k++) - { - var c = _fftComplexBuffer[k]; - _fftPowerBuffer[k] = (float)(c.Real * c.Real + c.Imaginary * c.Imaginary); - } - - for (int mel = 0; mel < MelBins; mel++) - { - double melSum = 0.0; - int startBin = _melStartBins[mel]; - int endBin = _melEndBins[mel]; - for (int k = startBin; k < endBin; k++) - { - melSum += _melFilterBank[mel, k] * _fftPowerBuffer[k]; - } - - float value = (float)Math.Log(melSum + LogEps); - _logMelSpectrogramBuffer[t * MelBins + mel] = value; - } - } - - return _logMelSpectrogramBuffer; - } - - private static float[] CreateHannWindow(int length) - { - var window = new float[length]; - if (length <= 1) return window; - for (int n = 0; n < length; n++) - { - window[n] = (float)(0.5 - 0.5 * Math.Cos(2 * Math.PI * n / length)); - } - return window; - } - - private static int[] CreateBitReversedIndices(int n) - { - int bits = (int)Math.Log2(n); - var arr = new int[n]; - for (int i = 0; i < n; i++) - { - int v = i; - int r = 0; - for (int b = 0; b < bits; b++) - { - r = (r << 1) | (v & 1); - v >>= 1; - } - arr[i] = r; - } - return arr; - } - - private static void FFTInPlace(Complex[] buffer, int[] bitReversed) - { - int n = buffer.Length; - - for (int i = 0; i < n; i++) - { - int j = bitReversed[i]; - if (j > i) - { - (buffer[i], buffer[j]) = (buffer[j], buffer[i]); - } - } - - for (int len = 2; len <= n; len <<= 1) - { - double ang = -2 * Math.PI / len; - Complex wLen = new Complex(Math.Cos(ang), Math.Sin(ang)); - - for (int i = 0; i < n; i += len) - { - Complex w = Complex.One; - int half = len >> 1; - for (int j = 0; j < half; j++) - { - Complex u = buffer[i + j]; - Complex v = buffer[i + j + half] * w; - buffer[i + j] = u + v; - buffer[i + j + half] = u - v; - w *= wLen; - } - } - } - } - - private static float[,] CreateMelFilterBank(int melBins, int fftSize, int sampleRate, float fMin, float fMax) - { - int freqBins = (fftSize / 2) + 1; - var weights = new float[melBins, freqBins]; - - float hzToMel(float hz) => 2595f * (float)Math.Log10(1f + hz / 700f); - float melToHz(float mel) => 700f * (float)(Math.Pow(10d, mel / 2595f) - 1d); - - float melMin = hzToMel(fMin); - float melMax = hzToMel(fMax); - - float[] melPoints = new float[melBins + 2]; - for (int i = 0; i < melPoints.Length; i++) - { - melPoints[i] = melMin + (melMax - melMin) * i / (melBins + 1); - } - - int[] bin = new int[melBins + 2]; - for (int i = 0; i < melPoints.Length; i++) - { - float hz = melToHz(melPoints[i]); - int b = (int)Math.Floor((fftSize + 1) * hz / sampleRate); - b = Math.Max(0, Math.Min(freqBins - 1, b)); - bin[i] = b; - } - - for (int m = 0; m < melBins; m++) - { - int f0 = bin[m]; - int f1 = bin[m + 1]; - int f2 = bin[m + 2]; - - if (f1 <= f0 || f2 <= f1) continue; - - for (int k = f0; k < f1; k++) - { - weights[m, k] = (float)(k - f0) / (f1 - f0); - } - for (int k = f1; k < f2; k++) - { - weights[m, k] = (float)(f2 - k) / (f2 - f1); - } - } - - return weights; - } - - private void LoadClassNames() - { - _classNames = new string[521]; - for (int i = 0; i < 521; i++) - _classNames[i] = $"class_{i}"; - - string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AIModel", "yamnet_class_map.csv"); - if (!File.Exists(path)) - { - return; - } - - foreach (var line in File.ReadLines(path)) - { - if (string.IsNullOrWhiteSpace(line)) - continue; - - var parts = line.Split(',', 3); - if (parts.Length < 2) - continue; - - string indexToken = parts[0].Trim().Trim('"'); - if (!int.TryParse(indexToken, out int index) || index < 0 || index >= 521) - continue; - - string name = (parts.Length == 2 ? parts[1] : parts[2]).Trim(); - if (name.Length >= 2 && name[0] == '"' && name[^1] == '"') - name = name.Substring(1, name.Length - 2).Replace("\"\"", "\"", StringComparison.Ordinal); - - if (string.IsNullOrWhiteSpace(name)) - continue; - - _classNames[index] = name; - } - } - - // 메모리 누수 방지용 - public void Dispose() - { - lock (_inferenceLock) - { - if (_isDisposed) return; - _isDisposed = true; - - lock (_captureRingLock) - { - _ringBuffer = null; - _ringHead = 0; - _ringTail = 0; - _ringCount = 0; - } - - _session?.Dispose(); - _coarseHeadSession?.Dispose(); - _gunshotBoosterSession?.Dispose(); - } - } - } -} diff --git a/SoundVisualizer/AIModel/WavAudioLoader.cs b/SoundVisualizer/AIModel/WavAudioLoader.cs deleted file mode 100644 index 579248b..0000000 --- a/SoundVisualizer/AIModel/WavAudioLoader.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (C) 2026 amophi (SoundVisualizer Contributors) -// This file is part of SoundVisualizer. -// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. -using System; -using System.Collections.Generic; -using NAudio.Wave; -using NAudio.Wave.SampleProviders; - -namespace SoundVisualizer.AIModel -{ - /// - /// WAV(또는 AudioFileReader가 읽을 수 있는 형식) → 모노 IEEE float, 목표 샘플레이트. - /// - public static class WavAudioLoader - { - public const int TargetSampleRate = 16000; - - /// - /// 파일에서 모노 16kHz float 샘플을 읽습니다. - /// - public static float[] LoadMono16kHz(string filePath) - { - using var reader = new AudioFileReader(filePath); - ISampleProvider sampleProvider = reader.ToSampleProvider(); - - if (sampleProvider.WaveFormat.Channels > 1) - { - sampleProvider = new StereoToMonoSampleProvider(sampleProvider) - { - LeftVolume = 0.5f, - RightVolume = 0.5f - }; - } - - if (sampleProvider.WaveFormat.SampleRate != TargetSampleRate) - sampleProvider = new WdlResamplingSampleProvider(sampleProvider, TargetSampleRate); - - var buffer = new float[4096]; - var samples = new List(); - int read; - while ((read = sampleProvider.Read(buffer, 0, buffer.Length)) > 0) - { - for (int i = 0; i < read; i++) - samples.Add(buffer[i]); - } - - return samples.ToArray(); - } - } -} - diff --git a/SoundVisualizer/AIModel/YamnetThreeClassMapper.cs b/SoundVisualizer/AIModel/YamnetThreeClassMapper.cs deleted file mode 100644 index 2b3565c..0000000 --- a/SoundVisualizer/AIModel/YamnetThreeClassMapper.cs +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (C) 2026 amophi (SoundVisualizer Contributors) -// This file is part of SoundVisualizer. -// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. -using System; - -namespace SoundVisualizer.AIModel -{ - /// - /// YAMNet 521 클래스 표시명 → 시스템 3분류(ambient / speech / danger). - /// 규칙은 키워드 우선순위: danger > speech > ambient > 기본 ambient. - /// - public static class YamnetThreeClassMapper - { - public static string TranslateToKorean(string displayName) - { - if (string.IsNullOrEmpty(displayName)) return "알 수 없음"; - var s = displayName.ToLowerInvariant(); - - if (s.Contains("gunshot") || s.Contains("gunfire")) return "총소리"; - if (s.Contains("machine gun")) return "기관총"; - if (s.Contains("explosion") || s.Contains("폭발")) return "폭발음"; - if (s.Contains("artillery") || s.Contains("fusillade") || s.Contains("cap gun")) return "총소리"; - - if (s.Contains("footstep") || s.Contains("footsteps")) return "발소리"; - if (s.Contains("siren") || s.Contains("civil defense")) return "사이렌"; - if (s.Contains("horn")) return "경적"; - if (s.Contains("car") || s.Contains("truck") || s.Contains("bus") || s.Contains("motorcycle")) return "자동차"; - if (s.Contains("helicopter")) return "헬리콥터"; - if (s.Contains("engine") || s.Contains("idling") || s.Contains("accelerating")) return "엔진소리"; - if (s.Contains("alarm") || s.Contains("smoke detector")) return "사이렌"; - if (s.Contains("police car") || s.Contains("ambulance") || s.Contains("fire engine") || s.Contains("fire truck")) return "사이렌"; - - if (s.Contains("speech") || s.Contains("conversation") || s.Contains("narration") || s.Contains("speaking") || s.Contains("babbling")) return "대화음"; - if (s.Contains("shout") || s.Contains("screaming") || s.Contains("yell") || s.Contains("laughter") || s.Contains("crying") || s.Contains("sobbing")) return "사람 소리"; - if (s.Contains("music")) return "음악"; - if (s.Contains("wind") || s.Contains("rustling leaves")) return "바람 소리"; - if (s.Contains("rain") || s.Contains("water") || s.Contains("ocean") || s.Contains("waves")) return "비/물 소리"; - if (s.Contains("animal") || s.Contains("dog") || s.Contains("cat") || s.Contains("bird") || s.Contains("bark") || s.Contains("meow")) return "동물 소리"; - if (s.Contains("door") || s.Contains("knock") || s.Contains("slam")) return "문 소리"; - - return displayName; - } - - /// - /// YAMNet 521 클래스 중 "Sound effect" 등. 게임/편집 총·폭발이 실제 총 클래스 대신 여기로 자주 붙습니다. - /// - public static bool IsGenericSoundEffectLabel(string displayName) - { - if (string.IsNullOrEmpty(displayName)) - return false; - return displayName.ToLowerInvariant().Contains("sound effect"); - } - - public static string MapDisplayNameToCoarse(string displayName) - { - if (string.IsNullOrEmpty(displayName)) - return "ambient"; - - var s = displayName.ToLowerInvariant(); - - if (MatchesDanger(s)) - return "danger"; - if (MatchesSpeech(s)) - return "speech"; - if (MatchesAmbient(s)) - return "ambient"; - - return "ambient"; - } - - /// - /// 실험용: 총성이 Plop / Gargling / 비 계열로 top-1 나올 때 coarse를 danger로 올림. - /// Water·Ocean·Waves/surf는 ambient 오탐 가능성으로 제외. - /// - private static bool MatchesTemporaryGunshotProxyDanger(string s) - { - if (s.Contains("plop")) - return true; - if (s.Contains("gargling")) - return true; - // "rain" 단독 contains는 Train(기차) 오탐 → 비 YAMNet 라벨만 - if (s == "rain" || s.Contains("raindrop") || s.Contains("rain on surface")) - return true; - if (s.Contains("waterfall")) - return true; - return false; - } - - private static bool MatchesDanger(string s) - { - if (MatchesTemporaryGunshotProxyDanger(s)) - return true; - - // 발소리, 총·폭발, 경적/사이렌·비상 차량, 폭죽 등 - if (s.Contains("footstep") || s.Contains("footsteps")) - return true; - if (s.Contains("gunshot") || s.Contains("gunfire") || s.Contains("machine gun") || - s.Contains("artillery") || s.Contains("fusillade") || s.Contains("cap gun")) - return true; - if (s.Contains("explosion") || s.Contains("fireworks") || s.Contains("firecracker")) - return true; - if (s.Contains("civil defense siren")) - return true; - if (s.Contains("police car") && s.Contains("siren")) - return true; - if (s.Contains("ambulance") && s.Contains("siren")) - return true; - if (s.Contains("fire engine") || s.Contains("fire truck")) - return true; - if (s.Contains("siren") && !s.Contains("telephone")) - return true; - if (s.Contains("smoke detector") || s.Contains("fire alarm")) - return true; - if (s == "alarm" || s.Contains("car alarm")) - return true; - return false; - } - - private static bool MatchesSpeech(string s) - { - // 대화·발화·웃음·울음·노래 등 대화음 계열 - if (s.Contains("speech") || s.Contains("conversation") || s.Contains("narration")) - return true; - if (s.Contains("speaking") || s.Contains("babbling")) - return true; - if (s.Contains("shout") || s.Contains("whisper") || s.Contains("screaming")) - return true; - if (s.Contains("laughter") || s.Contains("crying") || s.Contains("sobbing")) - return true; - if (s.Contains("singing") || s.Contains("choir") || s.Contains("rapping")) - return true; - if (s.Contains("crowd") || s.Contains("chatter") || s.Contains("hubbub")) - return true; - if (s.Contains("children playing")) - return true; - return false; - } - - private static bool MatchesAmbient(string s) - { - if (s.Contains("wind") || s.Contains("rustling leaves")) - return true; - if (s.Contains("traffic")) - return true; - if (s.Contains("music") || s.EndsWith(" music", StringComparison.Ordinal)) - return true; - if (s.Contains("environmental noise") || s.Contains("field recording")) - return true; - if (s.Contains("ocean") || s.Contains("waves") || s.Contains("rain") || s.Contains("thunder")) - return true; - if (s.Contains("white noise") || s.Contains("pink noise") || s.Contains("static")) - return true; - return false; - } - } -} - diff --git a/SoundVisualizer/AIModel/gunshot_booster.onnx b/SoundVisualizer/AIModel/gunshot_booster.onnx deleted file mode 100644 index 1c44bcf..0000000 Binary files a/SoundVisualizer/AIModel/gunshot_booster.onnx and /dev/null differ diff --git a/SoundVisualizer/AIModel/three_class_score_head.onnx b/SoundVisualizer/AIModel/three_class_score_head.onnx deleted file mode 100644 index 77867cf..0000000 Binary files a/SoundVisualizer/AIModel/three_class_score_head.onnx and /dev/null differ diff --git a/SoundVisualizer/App.xaml b/SoundVisualizer/App.xaml index fea51ec..37f66ca 100644 --- a/SoundVisualizer/App.xaml +++ b/SoundVisualizer/App.xaml @@ -4,6 +4,17 @@ xmlns:local="clr-namespace:SoundVisualizer" StartupUri="LauncherWindow.xaml"> - + + + + + + + + ./Fonts/#Pretendard, Segoe UI, Malgun Gothic + + + Consolas, Cascadia Mono, Courier New + diff --git a/SoundVisualizer/App.xaml.cs b/SoundVisualizer/App.xaml.cs index 42be80a..7330b85 100644 --- a/SoundVisualizer/App.xaml.cs +++ b/SoundVisualizer/App.xaml.cs @@ -4,6 +4,7 @@ using System.Configuration; using System.Data; using System.Windows; +using SoundVisualizer.Localization; namespace SoundVisualizer { @@ -13,7 +14,27 @@ public partial class App : Application protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + + // 개발용: 환경 변수 SOUNDVISUALIZER_TRACE 에 파일 경로를 주면 AppLog 기록을 그 파일에도 남긴다. + var tracePath = Environment.GetEnvironmentVariable("SOUNDVISUALIZER_TRACE"); + if (!string.IsNullOrWhiteSpace(tracePath)) + { + System.Diagnostics.Trace.Listeners.Add(new System.Diagnostics.TextWriterTraceListener(tracePath)); + System.Diagnostics.Trace.AutoFlush = true; + AppLog.Info("app", $"시작합니다({DateTime.Now:yyyy-MM-dd HH:mm:ss})."); + } + AppSettings.Load(); + + // 첫 창을 띄우기 전에 화면 언어와 그 글꼴을 정한다. 창의 {l:Loc} 바인딩이 처음부터 이 언어로 읽힌다. + Loc.Apply(AppSettings.Language); + } + + protected override void OnExit(ExitEventArgs e) + { + // 슬라이더를 움직인 직후에 닫아도 마지막 값이 남게, 미뤄 둔 저장을 지금 쓴다. + AppSettings.Flush(); + base.OnExit(e); } } } diff --git a/SoundVisualizer/AppFonts.cs b/SoundVisualizer/AppFonts.cs new file mode 100644 index 0000000..67ea450 --- /dev/null +++ b/SoundVisualizer/AppFonts.cs @@ -0,0 +1,29 @@ +// Copyright (C) 2026 amophi (SoundVisualizer Contributors) +// This file is part of SoundVisualizer. +// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. +using System.Windows; +using System.Windows.Media; + +namespace SoundVisualizer +{ + /// + /// 앱 전체 글꼴(AppFontFamily 리소스)을 언어에 맞게 바꾼다. 창들은 이 리소스를 DynamicResource 로 쓰므로 + /// 언어를 바꾸면 열린 창의 글꼴도 바로 바뀐다. 글꼴 순서는 이 정한다. + /// + internal static class AppFonts + { + public const string ResourceKey = "AppFontFamily"; + + private static readonly Uri BaseUri = new("pack://application:,,,/"); + + /// 화면 언어 코드에 맞는 글꼴로 앱 전체 글꼴을 바꾼다. + public static void Apply(string languageCode) + { + if (Application.Current == null) return; + Application.Current.Resources[ResourceKey] = FamilyFor(languageCode); + } + + /// 그 언어의 글꼴 순서. 언어 콤보가 언어 이름마다 따로 쓴다. + public static FontFamily FamilyFor(string languageCode) => new(BaseUri, UiFontChain.FamilyListFor(languageCode)); + } +} diff --git a/SoundVisualizer/AppSettings.cs b/SoundVisualizer/AppSettings.cs deleted file mode 100644 index 8be2009..0000000 --- a/SoundVisualizer/AppSettings.cs +++ /dev/null @@ -1,313 +0,0 @@ -// Copyright (C) 2026 amophi (SoundVisualizer Contributors) -// This file is part of SoundVisualizer. -// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3. -using System; -using System.IO; -using System.Text.Json; -using System.Collections.Generic; - -namespace SoundVisualizer -{ - public class VisualModeSettings - { - public double Intensity { get; set; } = 50.0; - public double PositionSpeed { get; set; } = 20.0; - public double Sensitivity { get; set; } = 3.75; - public double VisualOpacity { get; set; } = 50.0; - public bool IsGlowMode { get; set; } = false; - public double GlowIntensity { get; set; } = 0.0; - public double CircleRadius { get; set; } = 40.0; - public bool IntensityAsOpacity { get; set; } = false; - public double OpacityFixedSize { get; set; } = 30.0; - public double OpacityFixedMaxOpacity { get; set; } = 100.0; - } - - public static class AppSettings - { - // 모드별 개별 설정 인스턴스 - public static VisualModeSettings WaveMode { get; set; } = new VisualModeSettings { Intensity = 50.0, PositionSpeed = 20.0, Sensitivity = 3.75, VisualOpacity = 50.0, IsGlowMode = false, GlowIntensity = 0.0, CircleRadius = 40.0 }; - public static VisualModeSettings PadMode { get; set; } = new VisualModeSettings { Intensity = 50.0, PositionSpeed = 20.0, Sensitivity = 3.75, VisualOpacity = 50.0, IsGlowMode = false, GlowIntensity = 0.0, CircleRadius = 40.0 }; - public static VisualModeSettings CircleMode { get; set; } = new VisualModeSettings { Intensity = 50.0, PositionSpeed = 20.0, Sensitivity = 3.75, VisualOpacity = 50.0, IsGlowMode = false, GlowIntensity = 0.0, CircleRadius = 40.0 }; - public static VisualModeSettings OutlineMode { get; set; } = new VisualModeSettings { Intensity = 50.0, PositionSpeed = 20.0, Sensitivity = 3.75, VisualOpacity = 50.0, IsGlowMode = false, GlowIntensity = 0.0, CircleRadius = 40.0 }; - - // 1. 파도의 크기 (현재 모드값 반환/설정) - public static double WaveIntensity - { - get => GetCurrentModeSettings().Intensity; - set => GetCurrentModeSettings().Intensity = value; - } - - // 2. 파도 위치 변화 속도 - public static double WavePositionSpeed - { - get => GetCurrentModeSettings().PositionSpeed; - set => GetCurrentModeSettings().PositionSpeed = value; - } - - // 3. 파도의 민감성/떨림 - public static double WaveSensitivity - { - get => GetCurrentModeSettings().Sensitivity; - set => GetCurrentModeSettings().Sensitivity = value; - } - - // 4. 파도/그래픽 투명도 (0 ~ 100) - public static double VisualOpacity - { - get => GetCurrentModeSettings().VisualOpacity; - set => GetCurrentModeSettings().VisualOpacity = value; - } - - // 5. 시각화 모드 (0 = Wave 모드, 1 = Pad 모드, 2 = Circle 모드) - public static int VisualMode { get; set; } = 0; - - // 5.5. 원형 모드 반지름 (10 ~ 100) - public static double CircleRadius - { - get => GetCurrentModeSettings().CircleRadius; - set => GetCurrentModeSettings().CircleRadius = value; - } - - // 6. 투명도로 세기 표현 (크기 고정) - public static bool IntensityAsOpacity - { - get => GetCurrentModeSettings().IntensityAsOpacity; - set => GetCurrentModeSettings().IntensityAsOpacity = value; - } - - public static double OpacityFixedSize - { - get => GetCurrentModeSettings().OpacityFixedSize; - set => GetCurrentModeSettings().OpacityFixedSize = value; - } - - public static double OpacityFixedMaxOpacity - { - get => GetCurrentModeSettings().OpacityFixedMaxOpacity; - set => GetCurrentModeSettings().OpacityFixedMaxOpacity = value; - } - - // 6. 스테레오 확장 모드 - public static int SoundMode { get; set; } = 2; - - // 6.5. 광원(Glow) 효과 모드 - public static bool IsGlowMode - { - get => GetCurrentModeSettings().IsGlowMode; - set => GetCurrentModeSettings().IsGlowMode = value; - } - - public static double GlowIntensity - { - get => GetCurrentModeSettings().GlowIntensity; - set => GetCurrentModeSettings().GlowIntensity = value; - } - - // 7. 단축키 설정 - public static List StereoUpmixKeyBind { get; set; } = new List { 0x71 }; // F2 - public static List VisualModeKeyBind { get; set; } = new List { 0x72 }; // F3 - public static List EditModeKeyBind { get; set; } = new List { 0x73 }; // F4 - - // 구버전 호환용 - public static int StereoUpmixHotkey { get; set; } = 0x71; - public static int VisualModeHotkey { get; set; } = 0x72; - public static int EditModeHotkey { get; set; } = 0x73; - - // 7. 현재 언어 설정 - public static string Language { get; set; } = "ENG"; - - // 8.5. 관리자 모드 - public static bool IsAdminMode { get; set; } = false; - public static bool AutoMinimizeOnLaunch { get; set; } = false; - - // 8.6. 렌더링 최대 프레임 - public static double TargetFps { get; set; } = 144.0; - - // 9. 화면 표시 여부 (환경음, 말소리, 강조음) - public static bool ShowAmbient { get; set; } = true; - public static bool ShowSpeech { get; set; } = true; - public static bool ShowDanger { get; set; } = true; - - // 10. 화면 표시 색상 (환경음, 말소리, 강조음) - public static string ColorAmbient { get; set; } = "#FFFFFFFF"; - public static string ColorSpeech { get; set; } = "#FFFFFF00"; - public static string ColorDanger { get; set; } = "#FFFF0000"; - - private static string SettingsFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json"); - - private static VisualModeSettings GetCurrentModeSettings() - { - if (VisualMode == 0) return WaveMode; - if (VisualMode == 1) return PadMode; - if (VisualMode == 2) return CircleMode; - return OutlineMode; - } - - public static void Load() - { - try - { - if (File.Exists(SettingsFilePath)) - { - var json = File.ReadAllText(SettingsFilePath); - var data = JsonSerializer.Deserialize(json); - if (data != null) - { - VisualMode = data.VisualMode; - SoundMode = data.SoundMode; - if (data.StereoUpmixKeyBind != null && data.StereoUpmixKeyBind.Count > 0) StereoUpmixKeyBind = data.StereoUpmixKeyBind; - else StereoUpmixKeyBind = new List { data.StereoUpmixHotkey != 0 ? data.StereoUpmixHotkey : 0x71 }; - - if (data.VisualModeKeyBind != null && data.VisualModeKeyBind.Count > 0) VisualModeKeyBind = data.VisualModeKeyBind; - else VisualModeKeyBind = new List { data.VisualModeHotkey != 0 ? data.VisualModeHotkey : 0x72 }; - - if (data.EditModeKeyBind != null && data.EditModeKeyBind.Count > 0) EditModeKeyBind = data.EditModeKeyBind; - else EditModeKeyBind = new List { data.EditModeHotkey != 0 ? data.EditModeHotkey : 0x73 }; - - Language = data.Language ?? "ENG"; - - IsAdminMode = data.IsAdminMode; - AutoMinimizeOnLaunch = data.AutoMinimizeOnLaunch; - if (data.TargetFps >= 30.0) TargetFps = data.TargetFps; - ShowAmbient = data.ShowAmbient; - ShowSpeech = data.ShowSpeech; - ShowDanger = data.ShowDanger; - ColorAmbient = data.ColorAmbient ?? "#FFFFFFFF"; - ColorSpeech = data.ColorSpeech ?? "#FFFFFF00"; - ColorDanger = data.ColorDanger ?? "#FFFF0000"; - - // 하이브리드 모드 로드 (모드별 설정이 없는 구버전 호환) - if (data.WaveMode != null) WaveMode = data.WaveMode; - else WaveMode = new VisualModeSettings { Intensity = data.WaveIntensity, PositionSpeed = data.WavePositionSpeed, Sensitivity = data.WaveSensitivity, VisualOpacity = data.VisualOpacity, IsGlowMode = data.IsGlowMode, GlowIntensity = data.GlowIntensity, CircleRadius = data.CircleRadius }; - - if (data.PadMode != null) PadMode = data.PadMode; - else PadMode = new VisualModeSettings { Intensity = data.WaveIntensity, PositionSpeed = data.WavePositionSpeed, Sensitivity = data.WaveSensitivity, VisualOpacity = data.VisualOpacity, IsGlowMode = data.IsGlowMode, GlowIntensity = data.GlowIntensity, CircleRadius = data.CircleRadius }; - - if (data.CircleMode != null) CircleMode = data.CircleMode; - else CircleMode = new VisualModeSettings { Intensity = data.WaveIntensity, PositionSpeed = data.WavePositionSpeed, Sensitivity = data.WaveSensitivity, VisualOpacity = data.VisualOpacity, IsGlowMode = data.IsGlowMode, GlowIntensity = data.GlowIntensity, CircleRadius = data.CircleRadius }; - - if (data.OutlineMode != null) OutlineMode = data.OutlineMode; - else OutlineMode = new VisualModeSettings { Intensity = data.WaveIntensity, PositionSpeed = data.WavePositionSpeed, Sensitivity = data.WaveSensitivity, VisualOpacity = data.VisualOpacity, IsGlowMode = data.IsGlowMode, GlowIntensity = data.GlowIntensity, CircleRadius = data.CircleRadius }; - - int maxRate = GetMonitorRefreshRate(); - if (TargetFps > maxRate) TargetFps = maxRate; - } - } - } - catch { } - } - - public static void Save() - { - try - { - var data = new SettingsData - { - VisualMode = VisualMode, - SoundMode = SoundMode, - StereoUpmixKeyBind = StereoUpmixKeyBind, - VisualModeKeyBind = VisualModeKeyBind, - EditModeKeyBind = EditModeKeyBind, - Language = Language, - - IsAdminMode = IsAdminMode, - AutoMinimizeOnLaunch = AutoMinimizeOnLaunch, - TargetFps = TargetFps, - ShowAmbient = ShowAmbient, - ShowSpeech = ShowSpeech, - ShowDanger = ShowDanger, - ColorAmbient = ColorAmbient, - ColorSpeech = ColorSpeech, - ColorDanger = ColorDanger, - - WaveMode = WaveMode, - PadMode = PadMode, - CircleMode = CircleMode, - OutlineMode = OutlineMode, - - // 구버전 호환성 저장을 위해 현재 모드의 최종 설정값도 루트 필드에 동시 저장 - WaveIntensity = WaveIntensity, - WavePositionSpeed = WavePositionSpeed, - WaveSensitivity = WaveSensitivity, - VisualOpacity = VisualOpacity, - CircleRadius = CircleRadius, - IsGlowMode = IsGlowMode, - GlowIntensity = GlowIntensity, - OpacityFixedMaxOpacity = OpacityFixedMaxOpacity - }; - var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(SettingsFilePath, json); - } - catch { } - } - - [System.Runtime.InteropServices.DllImport("user32.dll")] - private static extern IntPtr GetDC(IntPtr hWnd); - - [System.Runtime.InteropServices.DllImport("user32.dll")] - private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); - - [System.Runtime.InteropServices.DllImport("gdi32.dll")] - private static extern int GetDeviceCaps(IntPtr hDC, int nIndex); - - private const int VREFRESH = 116; - - public static int GetMonitorRefreshRate() - { - IntPtr hDC = GetDC(IntPtr.Zero); - try - { - int rate = GetDeviceCaps(hDC, VREFRESH); - return rate > 0 ? rate : 60; // 디바이스 주사율 질의 실패 시 안전하게 60Hz 리턴 - } - catch - { - return 60; - } - finally - { - ReleaseDC(IntPtr.Zero, hDC); - } - } - - private class SettingsData - { - public double WaveIntensity { get; set; } = 50.0; - public double WavePositionSpeed { get; set; } = 20.0; - public double WaveSensitivity { get; set; } = 3.75; - public double VisualOpacity { get; set; } = 50.0; - public int VisualMode { get; set; } = 0; - public double CircleRadius { get; set; } = 40.0; - public int SoundMode { get; set; } = 2; - public bool IsGlowMode { get; set; } = false; - public double GlowIntensity { get; set; } = 0.0; - public double OpacityFixedMaxOpacity { get; set; } = 100.0; - public List StereoUpmixKeyBind { get; set; } = new List(); - public List VisualModeKeyBind { get; set; } = new List(); - public List EditModeKeyBind { get; set; } = new List(); - - // 구버전 호환 - public int StereoUpmixHotkey { get; set; } = 0x71; - public int VisualModeHotkey { get; set; } = 0x72; - public int EditModeHotkey { get; set; } = 0x73; - public string Language { get; set; } = "KOR"; - - public bool IsAdminMode { get; set; } = false; - public bool AutoMinimizeOnLaunch { get; set; } = false; - public double TargetFps { get; set; } = 144.0; - public bool ShowAmbient { get; set; } = true; - public bool ShowSpeech { get; set; } = true; - public bool ShowDanger { get; set; } = true; - public string ColorAmbient { get; set; } = "#FFFFFFFF"; - public string ColorSpeech { get; set; } = "#FFFFFF00"; - public string ColorDanger { get; set; } = "#FFFF0000"; - - public VisualModeSettings WaveMode { get; set; } = new VisualModeSettings(); - public VisualModeSettings PadMode { get; set; } = new VisualModeSettings(); - public VisualModeSettings CircleMode { get; set; } = new VisualModeSettings(); - public VisualModeSettings OutlineMode { get; set; } = new VisualModeSettings(); - } - } -} - - diff --git a/SoundVisualizer/ColorPickerWindow.xaml b/SoundVisualizer/ColorPickerWindow.xaml index b032435..fd777f9 100644 --- a/SoundVisualizer/ColorPickerWindow.xaml +++ b/SoundVisualizer/ColorPickerWindow.xaml @@ -1,293 +1,117 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + FontFamily="{DynamicResource AppFontFamily}"> + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + +