diff --git a/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs b/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs
index d81b329..e986569 100644
--- a/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs
+++ b/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs
@@ -29,7 +29,7 @@ namespace SoundVisualizer.Ai
/// - 가 1초 안에 추론을 기다리지 못하면 Android 는 세션을 버리지만(누수), 여기서는 그 추론이 끝나면서 닫는다.
/// Silero 스레드도 같은 1초 안에서 끝나기를 기다린다(Android 는 기다리지 않는다). 못 기다리면 그 스레드가 끝나며 세션을 닫는다.
/// - 미리보기 시각은 의 단조 나노초로 잰다(Android 의 elapsedRealtimeNanos). 틱의 밀리초 시계(15.6ms 단위)로는
- /// 250·275ms 창이 한 프레임씩 흔들린다.
+ /// 250·350ms 창이 한 프레임씩 흔들린다.
///
public sealed class RealtimeAiPipeline : IDisposable
{
@@ -51,8 +51,14 @@ public sealed class RealtimeAiPipeline : IDisposable
/// 정규 추론이 소리를 가져간 때부터 이 시간 안에(다음 틱 무렵까지) Silero 가 확인해야 미리보기를 받아들인다.
internal const long SpeechPreviewAcceptNs = 250_000_000L;
- /// 정규 추론이 소리를 가져간 때부터 이 시간이 지나면 미리보기가 사라진다. 다음 정규 결과가 오면 그 전에라도 사라진다.
- internal const long SpeechPreviewExpiryNs = 275_000_000L;
+ ///
+ /// 정규 추론이 소리를 가져간 때부터 이 시간이 지나면 미리보기가 사라진다. 다음 정규 결과가 오면 그 전에라도 사라진다.
+ /// Android 는 275ms 다(정확한 250ms 주기 + 추론 시간). PC 의 틱은 15.6ms 단위 시계와 대기로 재서 실제 주기가 255~270ms 이고,
+ /// 275ms 로는 다음 정규 결과가 오기 전에 미리보기가 먼저 사라져 사람 소리 → 환경음 → 사람 소리로 깜빡인다(이 PC 에서 틱의 7~9%).
+ /// 그래서 주기 250ms 에 시계 오차 두 번(31ms)과 느린 추론·바쁜 CPU 의 여유를 더해 350ms 로 둔다. 다음 정규 결과·위협음·사용자 지정은
+ /// 부모 검사로 여전히 바로 이기므로, 달라지는 것은 다음 틱이 무음으로 건너뛰어질 때 미리보기가 75ms 더 남는 것뿐이다.
+ ///
+ internal const long SpeechPreviewExpiryNs = 350_000_000L;
private const string Tag = nameof(RealtimeAiPipeline);
private const long FailureLogIntervalMs = 2000;
@@ -446,7 +452,7 @@ internal AiTickOutcome RunTickOnce(long nowMs)
private void InferLocked()
{
Interlocked.Increment(ref _inferenceExecutions);
- // 미리보기 창(250·275ms)은 정규 추론이 소리를 가져가기 직전부터 잰다(Android 의 snapshotBootNs).
+ // 미리보기 창(받아들임 250ms·만료 350ms)은 정규 추론이 소리를 가져가기 직전부터 잰다(Android 의 snapshotBootNs).
long snapshotNs = _speechWorker is null ? 0L : _nanoClock();
long started = Stopwatch.GetTimestamp();
_buffer.CopyTailRightPadded(_captureScratch);
diff --git a/SoundVisualizer.Core/Ai/SileroStreamWorker.cs b/SoundVisualizer.Core/Ai/SileroStreamWorker.cs
index ed4c35f..2df35df 100644
--- a/SoundVisualizer.Core/Ai/SileroStreamWorker.cs
+++ b/SoundVisualizer.Core/Ai/SileroStreamWorker.cs
@@ -67,8 +67,9 @@ public SileroStreamWorker(
{
IsBackground = true,
Name = "SV-SileroPreview",
- // Android 의 THREAD_PRIORITY_BACKGROUND. 캡처·화면·정규 추론보다 뒤에 선다.
- Priority = ThreadPriority.BelowNormal,
+ // 우선순위는 낮추지 않는다(Android 는 THREAD_PRIORITY_BACKGROUND). 이 스레드는 링 버퍼 잠금과 깨우기 신호를 캡처 스레드와
+ // 나눠 쓰는데, Windows 는 우선순위가 엄격하고 Monitor 에 우선순위 상속이 없다. 게임이 모든 코어를 채운 동안 낮은 우선순위로
+ // 잠금을 잡은 채 밀려나면 캡처 스레드(소리 내보내기와 화면 크기 계산도 한다)가 함께 멈춘다. 일의 양은 코어의 1% 남짓이다.
};
_thread.Start();
}
diff --git a/SoundVisualizer.Core/AppSettings.cs b/SoundVisualizer.Core/AppSettings.cs
index d2350bd..e05d03b 100644
--- a/SoundVisualizer.Core/AppSettings.cs
+++ b/SoundVisualizer.Core/AppSettings.cs
@@ -565,10 +565,12 @@ private class SettingsData
// 설정 탭을 쉬운 설정으로 열지. 예전 설정 파일에는 없어 null 로 읽힌다(그때는 모드 설정을 보고 정한다).
public bool? EasySettings { 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();
+ // 모드별 설정. 초기값을 두지 않는다. 모드별 설정이 없던 아주 예전 파일은 null 로 읽혀야 Load 가 그때의 값(WaveIntensity 등)을
+ // 네 모드로 옮긴다. 초기값이 있으면 늘 '있음'으로 읽혀 옮기지 않고, 판을 올린 사람의 크기·진하기가 기본값으로 돌아간다.
+ public VisualModeSettings? WaveMode { get; set; }
+ public VisualModeSettings? PadMode { get; set; }
+ public VisualModeSettings? CircleMode { get; set; }
+ public VisualModeSettings? OutlineMode { get; set; }
}
}
}
diff --git a/SoundVisualizer.Core/Localization/Strings/ar.json b/SoundVisualizer.Core/Localization/Strings/ar.json
index b3a021f..1064f09 100644
--- a/SoundVisualizer.Core/Localization/Strings/ar.json
+++ b/SoundVisualizer.Core/Localization/Strings/ar.json
@@ -118,7 +118,7 @@
"sounds.filter.all": "الكل",
"sounds.filter.changed": "المعدّلة",
"sounds.type.ambient": "الأصوات المحيطة",
- "sounds.type.speech": "أصوات بشرية",
+ "sounds.type.speech": "الأصوات البشرية",
"sounds.type.danger": "أصوات الخطر",
"sounds.type.default": "{0} (تلقائي)",
"sounds.type.mixed": "مختلط",
diff --git a/SoundVisualizer.Core/Localization/Strings/id.json b/SoundVisualizer.Core/Localization/Strings/id.json
index 7253fdf..659ea80 100644
--- a/SoundVisualizer.Core/Localization/Strings/id.json
+++ b/SoundVisualizer.Core/Localization/Strings/id.json
@@ -299,7 +299,7 @@
"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 tampilan “Detail” pada tab Setelan.",
"tutorial.type.ambient_examples": "Musik, hujan, dan suara lainnya",
- "tutorial.type.speech_examples": "Suara manusia",
+ "tutorial.type.speech_examples": "Percakapan",
"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/ja.json b/SoundVisualizer.Core/Localization/Strings/ja.json
index a4cddee..e19f689 100644
--- a/SoundVisualizer.Core/Localization/Strings/ja.json
+++ b/SoundVisualizer.Core/Localization/Strings/ja.json
@@ -299,7 +299,7 @@
"tutorial.types.title": "音の種類を色で区別します",
"tutorial.types.body": "AI が音を3種類に分類し、それぞれ違う色で描きます。色は「設定」タブの「詳細設定」で変更できます。",
"tutorial.type.ambient_examples": "音楽・雨音など、その他の音",
- "tutorial.type.speech_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
index d8630d2..276ba8a 100644
--- a/SoundVisualizer.Core/Localization/Strings/ko.json
+++ b/SoundVisualizer.Core/Localization/Strings/ko.json
@@ -169,7 +169,7 @@
"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": "💡 7.1 출력 장치가 없으면 {0}를 설치하고 출력 장치를 7.1채널로 설정해 보세요. 7.1 서라운드 사운드로 오버레이를 볼 수 있습니다.",
"help.sound_mode.tip_link": "VB-CABLE 가상 드라이버",
"help.hotkeys.title": "실시간 커스텀 단축키 제어",
"help.hotkeys.body": "설정 탭의 ‘자세한 설정’에서 핫키 버튼을 클릭한 뒤, 단일 키 또는 복합 키 조합(예: Ctrl + Shift + A)을 눌러 자유롭게 단축키를 변경할 수 있습니다. 백그라운드에서 지정된 단축키를 누르면 실시간으로 모드가 즉시 전환됩니다.",
diff --git a/SoundVisualizer.Core/Localization/Strings/th.json b/SoundVisualizer.Core/Localization/Strings/th.json
index 91f0a6b..f14cd00 100644
--- a/SoundVisualizer.Core/Localization/Strings/th.json
+++ b/SoundVisualizer.Core/Localization/Strings/th.json
@@ -299,7 +299,7 @@
"tutorial.types.title": "เสียงแต่ละประเภทมีสีของตัวเอง",
"tutorial.types.body": "AI จะแยกเสียงออกเป็น 3 ประเภท และวาดแต่ละประเภทด้วยสีของตัวเอง คุณเปลี่ยนสีได้ที่ “แบบละเอียด” ในแท็บการตั้งค่า",
"tutorial.type.ambient_examples": "เพลง เสียงฝน และเสียงอื่นทั้งหมด",
- "tutorial.type.speech_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
index d37865d..d8bd58b 100644
--- a/SoundVisualizer.Core/Localization/Strings/tr.json
+++ b/SoundVisualizer.Core/Localization/Strings/tr.json
@@ -299,7 +299,7 @@
"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” sekmesindeki “Ayrıntılı” görünümünden 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.speech_examples": "Konuşma",
"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/Tutorial/TutorialDemo.cs b/SoundVisualizer.Core/Tutorial/TutorialDemo.cs
index 7030d2c..e87274d 100644
--- a/SoundVisualizer.Core/Tutorial/TutorialDemo.cs
+++ b/SoundVisualizer.Core/Tutorial/TutorialDemo.cs
@@ -83,6 +83,9 @@ public TutorialDemo(TutorialScene scene, int visualMode, bool runToStill = true,
/// 마지막으로 엔진에 흘린 대본 시각(초). 처음에는 멈춰 둘 장면의 시각이다.
public double TimeSec { get; private set; }
+ /// 이 그림의 설정에 든 원형 고리 반지름(10~100). 그리는 쪽이 원형 시각화에 넘긴다(전역 설정이 아니라 이 그림의 값).
+ public double CircleRadius => _settings.CircleRadius;
+
/// 지금 그리는 소리의 종류. 창이 이것으로 색을 고르고 종류 쪽의 범례를 밝힌다.
public SoundType Label => TutorialScript.Label(Scene, TimeSec);
diff --git a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs
index d481d67..42c8baf 100644
--- a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs
+++ b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs
@@ -65,7 +65,24 @@ public class RealtimeAiPipelineSpeechPreviewTests(ITestOutputHelper output)
}
[Fact]
- public void 미리보기는_정규_스냅샷부터_275ms_에_사라지고_정규_결과로_돌아간다()
+ public void 미리보기는_PC_의_실제_틱_주기에서도_다음_정규_결과까지_이어진다()
+ {
+ // PC 의 틱은 15.6ms 단위 시계와 대기로 재서 다음 정규 결과가 스냅샷 뒤 255~280ms 에 온다(바쁜 PC 는 더 늦다). Android 의 275ms 로
+ // 만료하면 그 사이에 미리보기가 먼저 사라져 사람 소리 → 환경음 → 사람 소리로 깜빡였다(v2.1 을 내기 전 검토에서 틱의 7~9%).
+ using var h = new Harness(16000, 1);
+ var regular = h.FirstSpeechCandidate();
+ h.FeedFreshBlocks(3, h.Frames);
+
+ foreach (long ms in new long[] { 255, 266, 275, 281, 300, 330 })
+ {
+ h.Nanos.Set(ms * 1_000_000L);
+ Assert.True(h.Pipeline.LastDisplayClassification()?.SpeechPreview, ms + "ms 에 미리보기가 사라졌다");
+ }
+ Assert.Same(regular, h.Pipeline.LastClassification()); // 그동안에도 정규 결과는 그대로다
+ }
+
+ [Fact]
+ public void 미리보기는_만료_시각에_사라지고_정규_결과로_돌아간다()
{
using var h = new Harness(16000, 1);
var regular = h.FirstSpeechCandidate();
diff --git a/SoundVisualizer.Tests/AppSettingsTests.cs b/SoundVisualizer.Tests/AppSettingsTests.cs
index f8ba5d1..027f22d 100644
--- a/SoundVisualizer.Tests/AppSettingsTests.cs
+++ b/SoundVisualizer.Tests/AppSettingsTests.cs
@@ -161,6 +161,40 @@ private static void ClearSoundTab()
Assert.Equal("de", AppSettings.Language); // 예전 판의 언어 이름은 코드로 옮겨 읽는다
}
+ [Fact]
+ public void 모드별_설정이_없는_아주_예전_파일은_그때의_값을_네_모드에_옮긴다()
+ {
+ // 모드별 설정이 생기기 전의 판은 값을 하나씩만 저장했다. 옮기지 않으면 판을 올린 사람의 크기·진하기가 기본값으로 돌아간다.
+ File.WriteAllText(_path, """{ "WaveIntensity": 80, "VisualOpacity": 35, "WavePositionSpeed": 60, "WaveSensitivity": 5, "CircleRadius": 70 }""");
+
+ AppSettings.Load();
+
+ Assert.All(new[] { AppSettings.WaveMode, AppSettings.PadMode, AppSettings.CircleMode, AppSettings.OutlineMode }, mode =>
+ {
+ Assert.Equal(80, mode.Intensity);
+ Assert.Equal(35, mode.VisualOpacity);
+ Assert.Equal(60, mode.PositionSpeed);
+ Assert.Equal(5, mode.Sensitivity);
+ Assert.Equal(70, mode.CircleRadius);
+ });
+ // 네 모드는 서로 다른 객체다. 하나를 바꿔도 다른 모드가 따라 바뀌지 않는다.
+ AppSettings.WaveMode.Intensity = 10;
+ Assert.Equal(80, AppSettings.PadMode.Intensity);
+ // 상세 값을 바꿔 쓰던 사람이라 자세한 설정으로 연다.
+ Assert.False(AppSettings.EasySettings);
+ }
+
+ [Fact]
+ public void 모드별_설정이_일부만_있는_파일은_있는_모드를_그대로_읽는다()
+ {
+ File.WriteAllText(_path, """{ "WaveIntensity": 80, "PadMode": { "Intensity": 33 } }""");
+
+ AppSettings.Load();
+
+ Assert.Equal(33, AppSettings.PadMode.Intensity);
+ Assert.Equal(80, AppSettings.WaveMode.Intensity);
+ }
+
[Fact]
public void 깨진_설정_파일은_기본값으로_시작한다()
{
diff --git a/SoundVisualizer.Tests/Localization/EasySettingsStringsTests.cs b/SoundVisualizer.Tests/Localization/EasySettingsStringsTests.cs
index 1679e21..cf22fdd 100644
--- a/SoundVisualizer.Tests/Localization/EasySettingsStringsTests.cs
+++ b/SoundVisualizer.Tests/Localization/EasySettingsStringsTests.cs
@@ -100,6 +100,29 @@ private static string Get(string code, string key)
}
}
+ [Fact]
+ public void 아랍어의_사람_소리_줄은_같은_말을_두_번_읽지_않는다()
+ {
+ // 종류 이름에만 관사가 없으면 스위치 이름(إظهار الأصوات البشرية) 안에서 찾지 못해 "사람 소리, 사람 소리 표시" 꼴로 겹쳐 읽힌다.
+ // 환경음(الأصوات المحيطة)처럼 관사를 붙인 이름이어야 스위치 이름 하나로 읽힌다.
+ Assert.Equal(Get("ar", "settings.show_speech"),
+ EasyNames.TypeRow(Get("ar", "sounds.type.speech"), Get("ar", "settings.show_speech"), Get("ar", "easy.name_pair")));
+ }
+
+ [Theory]
+ [MemberData(nameof(Codes))]
+ public void 튜토리얼_범례의_종류_이름과_예시는_같은_글자가_아니다(string code)
+ {
+ // 범례는 이름과 예시를 나란히 보인다("사람 소리 사람 목소리"). 종류 이름을 바꾸면서 일본어·인도네시아어·태국어·터키어는
+ // 새 이름이 원래 예시와 같은 낱말이 되어 "人の声 人の声" 처럼 겹쳤다. 이름을 바꿀 때는 예시도 함께 본다.
+ foreach (var type in new[] { "ambient", "speech", "danger" })
+ {
+ Assert.False(
+ string.Equals(Get(code, "sounds.type." + type).Trim(), Get(code, "tutorial.type." + type + "_examples").Trim(), StringComparison.OrdinalIgnoreCase),
+ $"{code}: {type} 의 이름과 예시가 같습니다.");
+ }
+ }
+
[Fact]
public void 읽는_이름은_들어_있으면_자세한_설정의_스위치_이름_그대로이고_없으면_앞에_붙인다()
{
diff --git a/SoundVisualizer.Tests/Ui/HotkeyBindingSourceTests.cs b/SoundVisualizer.Tests/Ui/HotkeyBindingSourceTests.cs
new file mode 100644
index 0000000..7873a19
--- /dev/null
+++ b/SoundVisualizer.Tests/Ui/HotkeyBindingSourceTests.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.
+namespace SoundVisualizer.Tests.Ui;
+
+///
+/// 단축키 입력 대기(LauncherWindow 의 _bindingTarget)는 창이 모든 키를 가로채 떼는 순간 단축키로 저장한다. 단축키 버튼은 자세한 설정에만
+/// 있으므로, 버튼이 보이지 않게 될 때(쉬운 설정이나 다른 탭으로 넘어갈 때) 대기를 그만두지 않으면 그 뒤에 누른 Tab 이나 방향키가 먹지 않고
+/// 단축키로 저장된다. 런처는 WPF 창이라 소스를 읽어 확인한다.
+///
+public class HotkeyBindingSourceTests
+{
+ private static string Read(string file) => File.ReadAllText(Path.Combine(RepositoryPaths.Root, "SoundVisualizer", file));
+
+ [Fact]
+ public void 쉬운_설정과_자세한_설정을_오갈_때_단축키_입력_대기를_그만둔다()
+ {
+ string source = Read("LauncherWindow.EasySettings.cs");
+ int method = source.IndexOf("private void SetSettingsView(bool easy)", StringComparison.Ordinal);
+ Assert.True(method >= 0, "SetSettingsView 를 찾지 못했습니다.");
+ int cancel = source.IndexOf("CancelHotkeyBinding();", method, StringComparison.Ordinal);
+ int show = source.IndexOf("ShowSettingsView();", method, StringComparison.Ordinal);
+
+ Assert.True(cancel >= 0 && cancel < show, "화면을 바꾸기 전에 CancelHotkeyBinding 을 불러야 합니다.");
+ }
+
+ [Fact]
+ public void 탭을_바꿀_때와_Esc_를_누를_때_같은_취소를_쓴다()
+ {
+ string source = Read("LauncherWindow.xaml.cs");
+
+ Assert.Contains("Tabs.SelectionChanged += (s, e) => { if (ReferenceEquals(e.OriginalSource, Tabs)) CancelHotkeyBinding(); };", source);
+ int escape = source.IndexOf("if (e.Key == System.Windows.Input.Key.Escape)", StringComparison.Ordinal);
+ Assert.True(escape >= 0);
+ Assert.Contains("CancelHotkeyBinding();", source.Substring(escape, 200));
+ }
+}
diff --git a/SoundVisualizer.Tests/Ui/VisualizerSettingsSourceTests.cs b/SoundVisualizer.Tests/Ui/VisualizerSettingsSourceTests.cs
new file mode 100644
index 0000000..a0e2d88
--- /dev/null
+++ b/SoundVisualizer.Tests/Ui/VisualizerSettingsSourceTests.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 SoundVisualizer.Rendering;
+using SoundVisualizer.Tutorial;
+
+namespace SoundVisualizer.Tests.Ui;
+
+///
+/// 시각화(Visualizers)는 그릴 값을 모두 넘겨받는다. 전역 설정(AppSettings)은 "지금 표현 모드"의 값이라, 시각화가 그것을 직접 읽으면
+/// 다른 모드를 쓰는 중에 그리는 그림(쉬운 설정의 모양 카드, 튜토리얼)이 엉뚱한 값으로 그려진다. v2.1 을 내기 전, 원형 카드가 원형 모드의
+/// 반지름이 아니라 지금 모드의 반지름으로 그려지던 것이 그 예다. 시각화는 WPF 프로젝트에 있어 소스를 읽어 확인한다.
+///
+public class VisualizerSettingsSourceTests
+{
+ [Fact]
+ public void 시각화는_전역_설정을_직접_읽지_않는다()
+ {
+ string folder = Path.Combine(RepositoryPaths.Root, "SoundVisualizer", "Visualizers");
+ var files = Directory.GetFiles(folder, "*.cs");
+ Assert.NotEmpty(files);
+ Assert.All(files, file => Assert.DoesNotContain("AppSettings.", File.ReadAllText(file)));
+ }
+
+ [Fact]
+ public void 오버레이와_미리보기_그림은_원형_반지름을_각자의_설정에서_넘긴다()
+ {
+ string overlay = File.ReadAllText(Path.Combine(RepositoryPaths.Root, "SoundVisualizer", "MainWindow.xaml.cs"));
+ string demo = File.ReadAllText(Path.Combine(RepositoryPaths.Root, "SoundVisualizer", "Tutorial", "TutorialDemoRenderer.cs"));
+
+ Assert.Contains("_renderContext.CircleRadius = AppSettings.CircleRadius;", overlay);
+ Assert.Contains("_context.CircleRadius = demo.CircleRadius;", demo);
+ }
+
+ [Theory]
+ [InlineData(10.0)]
+ [InlineData(40.0)]
+ [InlineData(100.0)]
+ public void 그림의_원형_반지름은_전역_설정이_아니라_그_그림의_설정이다(double radius)
+ {
+ // 전역 설정은 건드리지 않는다. 그림이 받은 설정의 값만 내보여야 한다.
+ var settings = new VisualModeSettings { CircleRadius = radius };
+ var demo = new TutorialDemo(TutorialScene.Types, VisualizerEngine.ModeCircle, runToStill: true, TutorialDemo.PreviewSettings(settings),
+ TutorialDemo.PreviewWidth, TutorialDemo.PreviewHeight);
+
+ Assert.Equal(radius, demo.CircleRadius);
+ }
+}
diff --git a/SoundVisualizer/LauncherWindow.EasySettings.cs b/SoundVisualizer/LauncherWindow.EasySettings.cs
index 826f70a..c276656 100644
--- a/SoundVisualizer/LauncherWindow.EasySettings.cs
+++ b/SoundVisualizer/LauncherWindow.EasySettings.cs
@@ -39,6 +39,8 @@ private void ChipsSettingsView_SelectionChanged(object sender, SelectionChangedE
/// 화면을 바꾸고 저장한다. 화면만 바뀌고 다른 설정은 건드리지 않는다.
private void SetSettingsView(bool easy)
{
+ // 단축키 버튼은 자세한 설정에만 있다. 입력을 기다리는 채로 화면이 바뀌면 다음에 누른 키가 단축키로 저장된다.
+ CancelHotkeyBinding();
if (AppSettings.EasySettings != easy)
{
AppSettings.EasySettings = easy;
diff --git a/SoundVisualizer/LauncherWindow.xaml.cs b/SoundVisualizer/LauncherWindow.xaml.cs
index 4a4092b..3aa4a71 100644
--- a/SoundVisualizer/LauncherWindow.xaml.cs
+++ b/SoundVisualizer/LauncherWindow.xaml.cs
@@ -27,6 +27,8 @@ public partial class LauncherWindow : Window, IMMNotificationClient
public LauncherWindow()
{
InitializeComponent();
+ // 탭을 바꾸면 단축키 입력 대기를 그만둔다. 안쪽 목록의 선택 알림도 여기로 올라오므로 탭 줄의 것만 본다.
+ Tabs.SelectionChanged += (s, e) => { if (ReferenceEquals(e.OriginalSource, Tabs)) CancelHotkeyBinding(); };
_modes = new[]
{
new ModeControls(() => AppSettings.WaveMode, ChkIntensityAsOpacityWave, SldOpacityFixedSizeWave, SldOpacityFixedMaxOpacityWave,
@@ -389,6 +391,20 @@ private void BtnEditHotkey_Click(object sender, RoutedEventArgs e)
StartBinding("Edit");
}
+ ///
+ /// 단축키 입력 대기를 그만둔다(Esc 와 같다). 대기 중에는 창이 모든 키를 가로채 떼는 순간 단축키로 저장하므로, 단축키 버튼이 보이지
+ /// 않게 될 때(쉬운 설정이나 다른 탭으로 넘어갈 때)에도 그만둬야 한다. 그대로 두면 그 뒤에 누른 Tab 이나 방향키가 먹지 않고
+ /// 단축키로 저장된다.
+ ///
+ private void CancelHotkeyBinding()
+ {
+ if (_bindingTarget == null) return;
+ _bindingTarget = null;
+ _currentlyHeldKeys.Clear();
+ _maxKeysInCurrentBinding.Clear();
+ LoadSettingsToUI();
+ }
+
private void StartBinding(string target)
{
_bindingTarget = target;
@@ -406,10 +422,7 @@ private void Window_PreviewKeyDown(object sender, System.Windows.Input.KeyEventA
{
if (e.Key == System.Windows.Input.Key.Escape)
{
- _bindingTarget = null;
- _currentlyHeldKeys.Clear();
- _maxKeysInCurrentBinding.Clear();
- LoadSettingsToUI();
+ CancelHotkeyBinding();
e.Handled = true;
return;
}
diff --git a/SoundVisualizer/MainWindow.xaml.cs b/SoundVisualizer/MainWindow.xaml.cs
index dd62973..8e08696 100644
--- a/SoundVisualizer/MainWindow.xaml.cs
+++ b/SoundVisualizer/MainWindow.xaml.cs
@@ -444,6 +444,7 @@ private void DrawWave(double w, double h, Color activeColor)
_engine.CircleDepths.CopyTo(_renderContext.CircleDepths);
_renderContext.TotalVolume = _engine.TotalTarget;
_renderContext.AnimationTime = _engine.AnimationTime;
+ _renderContext.CircleRadius = AppSettings.CircleRadius;
// 선택된 렌더러(Wave 또는 Pad)에게 그리기 위임
int modeIndex = (AppSettings.VisualMode >= 0 && AppSettings.VisualMode < _visualizers.Length) ? AppSettings.VisualMode : 0;
@@ -623,29 +624,42 @@ private void RequestAiPipeline(int rate, int channels)
{
previous?.Dispose();
RealtimeAiPipeline? created = null;
+ bool published = false; // 넣은 뒤에는 이 작업이 닫지 않는다(창 닫기나 다음 형식 변경이 닫는다)
try
{
var classes = _classMap ??= YamnetClassMap.Load(System.IO.Path.Combine(ModelDirectory, "yamnet_class_map.csv"));
created = RealtimeAiPipeline.Create(rate, channels, ModelDirectory, classes,
mappingOverrides: () => AppSettings.SoundTypes,
tickFailed: ex => AppLog.Warn("ai", "AI 추론 한 번이 실패했습니다.", ex));
+ RealtimeAiPipeline? replaced = null;
+ bool discard;
lock (_aiGate)
{
- // 만드는 사이 형식이 또 바뀌었거나 창이 닫혔으면 버린다.
- if (_isClosed || _aiRequestedRate != rate || _aiRequestedChannels != channels)
+ // 만드는 사이 형식이 또 바뀌었거나 창이 닫혔으면 버린다. 닫기(Dispose)는 잠금 밖에서 한다. 방금 띄운 말소리 미리보기
+ // 스레드가 모델을 다 읽기를 최대 1초 기다리는데, 그동안 캡처 스레드(RequestAiPipeline)와 창 닫기가 이 잠금에서 함께
+ // 멈추면 안 된다.
+ discard = _isClosed || _aiRequestedRate != rate || _aiRequestedChannels != channels;
+ if (!discard)
{
- created.Dispose();
- return;
+ created.Start();
+ // 형식이 X→Y→X 로 빠르게 바뀌면 같은 형식의 파이프라인이 두 번 만들어진다. 먼저 넣은 것을 꺼내 닫아야
+ // 스레드와 모델 세션이 남지 않는다.
+ replaced = Interlocked.Exchange(ref _aiPipeline, created);
+ published = true;
}
- created.Start();
- Volatile.Write(ref _aiPipeline, created);
}
+ if (discard)
+ {
+ created.Dispose();
+ return;
+ }
+ replaced?.Dispose();
AppLog.Info("ai", $"AI 를 시작했습니다({rate}Hz, {channels}ch).");
NotifyAiAvailability(true);
}
catch (Exception ex)
{
- created?.Dispose();
+ if (!published) created?.Dispose();
AppLog.Warn("ai", $"AI 를 시작하지 못해 그래픽만 표시합니다({rate}Hz, {channels}ch).", ex);
NotifyAiAvailability(false); // 홈이 "모든 소리가 환경음 색" 이라고 알린다
}
diff --git a/SoundVisualizer/Tutorial/TutorialDemoRenderer.cs b/SoundVisualizer/Tutorial/TutorialDemoRenderer.cs
index df566a0..adbd369 100644
--- a/SoundVisualizer/Tutorial/TutorialDemoRenderer.cs
+++ b/SoundVisualizer/Tutorial/TutorialDemoRenderer.cs
@@ -74,6 +74,7 @@ public void Draw(TutorialDemo demo, System.Windows.Shapes.Path shape, Color colo
engine.CircleDepths.CopyTo(_context.CircleDepths);
_context.TotalVolume = engine.TotalTarget;
_context.AnimationTime = engine.AnimationTime;
+ _context.CircleRadius = demo.CircleRadius;
shape.Data = visualizer.GenerateGeometry(_context);
diff --git a/SoundVisualizer/Visualizers/CircleRippleVisualizer.cs b/SoundVisualizer/Visualizers/CircleRippleVisualizer.cs
index e2509f1..7ae4b24 100644
--- a/SoundVisualizer/Visualizers/CircleRippleVisualizer.cs
+++ b/SoundVisualizer/Visualizers/CircleRippleVisualizer.cs
@@ -23,9 +23,9 @@ public Geometry GenerateGeometry(VisualizerContext context)
double cy = h / 2.0;
// 화면 크기에 비례하며 설정값을 반영하는 중앙의 빈 공간
- // AppSettings.CircleRadius 범위: 10 ~ 100
+ // context.CircleRadius 범위: 10 ~ 100 (오버레이는 지금 모드의 설정을, 미리보기 그림은 그 그림의 설정을 넘긴다)
// 이를 0.05 ~ 0.40 비율 정도로 매핑
- double radiusRatio = 0.05 + (AppSettings.CircleRadius - 10.0) / 90.0 * 0.35;
+ double radiusRatio = 0.05 + (context.CircleRadius - 10.0) / 90.0 * 0.35;
double baseRadius = Math.Min(w, h) * radiusRatio;
var geometry = new StreamGeometry();
diff --git a/SoundVisualizer/Visualizers/IVisualizerMode.cs b/SoundVisualizer/Visualizers/IVisualizerMode.cs
index 49b0276..37d98fc 100644
--- a/SoundVisualizer/Visualizers/IVisualizerMode.cs
+++ b/SoundVisualizer/Visualizers/IVisualizerMode.cs
@@ -23,6 +23,9 @@ public class VisualizerContext
public double[] PadThickness { get; set; } = new double[8];
public double[] CircleDepths { get; set; } = new double[8];
public float TotalVolume { get; set; }
+ // 원형 모드의 가운데 고리 반지름 설정(10~100). 그리는 쪽이 넘긴다. 시각화가 전역 설정(지금 모드의 값)을 직접 읽으면
+ // 쉬운 설정의 원형 카드처럼 다른 모드를 쓰는 중에 그리는 그림이 원형 모드의 반지름이 아닌 값으로 그려진다.
+ public double CircleRadius { get; set; } = 40.0;
public double AnimationTime { get; set; }
}
}