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; } } }