From 0231501dec4d85077fd43b71c40248e55c273703 Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 15:21:48 +0900 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EC=97=90=20=EB=A7=90=EC=86=8C=EB=A6=AC=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EC=9D=B8=EC=A7=80=EB=A5=BC?= =?UTF-8?q?=20=EC=A0=81=EB=8A=94=20=EC=B9=B8=EC=9D=84=20=EB=A7=A8=20?= =?UTF-8?q?=EB=81=9D=EC=97=90=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미리보기도 HUD 가 읽은 그대로 한 줄로 남는데 정규 결과와 가를 칸이 없어, 화면에만 먼저 보인 대화음이 확정 결과와 섞여 세어졌다. 칸 이름(speech_preview)과 자리(맨 끝)는 Android #456 과 같다. 앞의 칸 번호로 읽는 스크립트는 그대로 읽힌다. --- .../Diagnostics/AiDebugCsv.cs | 4 ++++ .../Diagnostics/AiDebugCsvTests.cs | 24 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/SoundVisualizer.Core/Diagnostics/AiDebugCsv.cs b/SoundVisualizer.Core/Diagnostics/AiDebugCsv.cs index b62ae69..5ca41ab 100644 --- a/SoundVisualizer.Core/Diagnostics/AiDebugCsv.cs +++ b/SoundVisualizer.Core/Diagnostics/AiDebugCsv.cs @@ -69,6 +69,7 @@ public static string Row(AiClassificationResult result, long nowMs, float level, Cell(row, Millis(result.TotalMs)); Cell(row, result.MappingOverrideCount.ToString(CultureInfo.InvariantCulture)); Cell(row, result.MappingSignature); + Cell(row, Bool(result.SpeechPreview)); return row.ToString(); } @@ -93,6 +94,9 @@ private static IReadOnlyList BuildColumns() columns.Add(string.Create(CultureInfo.InvariantCulture, $"top{slot}_prob")); } columns.AddRange(["level", "shown", "preprocess_ms", "yamnet_ms", "total_ms", "mapping_override_count", "mapping_signature"]); + // 화면에만 한 박자 먼저 보인 말소리 미리보기(모바일 #422)인지. 미리보기도 한 줄로 남으므로(HUD 가 읽은 그대로 적는다), 이 칸이 없으면 + // 정규 확정과 섞여 세어진다. 새 칸은 늘 맨 끝에 더한다(모바일 #456). 앞의 칸 번호로 읽는 스크립트가 어긋나지 않는다. + columns.Add("speech_preview"); return columns.AsReadOnly(); } diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugCsvTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugCsvTests.cs index 12fb208..a6eb7e4 100644 --- a/SoundVisualizer.Tests/Diagnostics/AiDebugCsvTests.cs +++ b/SoundVisualizer.Tests/Diagnostics/AiDebugCsvTests.cs @@ -90,10 +90,30 @@ public void 머리글은_모바일과_칸_이름과_순서가_같다() 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", + "level,shown,preprocess_ms,yamnet_ms,total_ms,mapping_override_count,mapping_signature,speech_preview", AiDebugCsv.Header); } + [Fact] + public void 말소리_미리보기인지는_맨_끝_칸에_적는다() + { + // 미리보기도 HUD 가 읽은 그대로 한 줄로 남는다. 이 칸이 없으면 화면에만 먼저 보인 대화음이 정규 확정과 섞여 세어진다. + // 모바일(#456)과 이름·자리가 같고, 앞의 칸 번호로 읽는 스크립트가 어긋나지 않게 맨 끝에 둔다. + Assert.Equal("speech_preview", AiDebugCsv.Columns[^1]); + + var regular = Result(coarse: SoundType.Ambient, display: "Music"); + var preview = regular.AsSpeechPreview("Speech", 0.9f, timestampMs: 1_100L); + var regularCells = Cells(AiDebugCsv.Row(regular, 1_500L, 0.4f, true)); + var previewCells = Cells(AiDebugCsv.Row(preview, 1_500L, 0.4f, true)); + + // 다른 참거짓 칸과 같은 소문자다(Kotlin Boolean.toString). + Assert.Equal("false", regularCells[^1]); + Assert.Equal("true", previewCells[^1]); + Assert.Equal("true", previewCells[Column("speech_preview")]); + Assert.Equal("speech", previewCells[Column("coarse")]); + Assert.Equal(AiDebugCsv.Columns.Count, previewCells.Count); + } + [Fact] public void 한_줄의_모든_칸이_모바일_규칙의_글자와_같다() { @@ -101,7 +121,7 @@ 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\"", + "\"0.40000\",\"true\",\"3.2\",\"41.0\",\"46.2\",\"0\",\"default\",\"false\"", AiDebugCsv.Row(Result(), nowMs: 1_500L, level: 0.4f, shown: true)); } From 55e42d625f564765da008c9ec8aa1eb3e20a1d18 Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 15:21:55 +0900 Subject: [PATCH 02/10] =?UTF-8?q?test:=20=EB=A7=90=EC=86=8C=EB=A6=AC=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EC=9D=98=20HUD=20=EC=B2=AB?= =?UTF-8?q?=20=EC=A4=84=EC=9D=B4=20=ED=95=9C=20=EC=A4=84=2058=EC=9E=90=20?= =?UTF-8?q?=EC=95=88=EC=97=90=20=EB=93=9C=EB=8A=94=EC=A7=80=20=EC=A7=80?= =?UTF-8?q?=ED=82=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미리보기의 종류 칸("SPEECH ~PREVIEW")은 정규 결과보다 여덟 자 길고, 기본 종류가 대화음인 이름 가운데 가장 긴 것이 꼭 58자라 여유가 없다. 기존 시험은 정규 결과의 첫 줄만 보았다. 1위가 환경음 이름인데 투표가 대화음인 프레임은 이 시험 밖이다(시험의 주석에 적었다). --- .../Diagnostics/AiDebugTextTests.cs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs index c156119..6cc9a8b 100644 --- a/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs +++ b/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs @@ -167,8 +167,7 @@ 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; + int header = HeaderColumns(lines); 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)) { @@ -204,6 +203,42 @@ public void 말소리_미리보기는_종류_뒤에_PREVIEW_를_붙여_정규_ Assert.Equal("thr Y age 0.1s", preview.Detail); // 나이는 미리보기를 만든 때부터 } + [Fact] + public void 말소리_미리보기의_첫_줄은_기본_종류가_대화음인_모든_이름에서_58자_안에_든다() + { + // 미리보기의 종류 칸은 "SPEECH ~PREVIEW"(15자)로 정규 결과의 가장 긴 "AMBIENT"(7자)보다 여덟 자 길다. 위의 시험은 정규 결과만 본다. + // 미리보기는 사용자 지정이 하나도 없을 때만 생기므로(AiClassificationStage.SpeechOnset) 기본 매핑으로 대화음인 이름을 모두 넣어 본다. + // 지금 가장 긴 "Hubbub, speech noise, speech babble"(35자)이 꼭 58자라 여유가 없다. 표시를 한 글자라도 늘리면 여기서 걸린다. + // + // 이 시험이 지키지 않는 경우: 미리보기의 이름은 그 프레임의 1위 이름이고 종류는 상위 3개의 투표라, 1위가 환경음 이름이어도 + // 2·3위의 대화음이 합쳐 이기면 그 환경음 이름이 실린다. 35자를 넘는 이름 셋("Livestock, farm animals, working animals", + // "Bird vocalization, bird call, bird song", "Wind instrument, woodwind instrument")이 그렇게 실리면 59~63자가 된다. + // 줄을 줄이려면 두 판(모바일 AiDebugText)이 함께 정해야 해서 여기서는 고치지 않았다. PC HUD 는 폭을 내용에 맞추므로 꺾이지 않는다. + var classes = AiTestData.ClassMap; + var speechNames = Enumerable.Range(0, classes.Count) + .Where(i => classes.DefaultCoarse(i) == SoundType.Speech) + .Select(i => classes[i]) + .ToArray(); + Assert.Contains("Speech", speechNames); + Assert.Contains("Hubbub, speech noise, speech babble", speechNames); + + var parent = Result(SoundType.Ambient, "Music", 0.6f); + int longest = 0; + foreach (string name in speechNames) + { + var lines = Format(parent.AsSpeechPreview(name, 0.99f, timestampMs: 1_100L)); + Assert.Equal("SPEECH" + AiDebugText.PreviewMarker, lines.Coarse); + int header = HeaderColumns(lines); + Assert.True(header <= AiDebugText.HudMaxColumns, $"{header}: {lines.Coarse} ● {lines.Label} {lines.Confidence}"); + longest = Math.Max(longest, header); + } + Assert.Equal(AiDebugText.HudMaxColumns, longest); + } + + /// 첫 줄의 글자 수. 오버레이가 "종류 ● 이름 확신도" 로 붙인다. 점과 그 양옆 여백을 세 칸으로 센다(모바일 6dp + 8dp + 6dp). + private static int HeaderColumns(AiDebugLines lines) => + lines.Coarse.Length + 3 + lines.Label.Length + 1 + lines.Confidence.Length; + [Fact] public void 같은_입력이면_같은_줄로_본다() { From bc561bc793b7d775a6f891fb84b625e64424b57c Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 15:25:04 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=EC=89=AC=EC=9A=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=ED=81=AC=EA=B8=B0=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=8D=94=EC=97=90=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EC=88=98=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자세한 설정에서 크기가 49.5 면 슬라이더는 "50" 이라 적는데 쉬운 설정은 보통이 아니라고 했다(차이가 0.5 보다 작아야 했다). 슬라이더 줄의 글자를 만드는 규칙을 Core 의 ValueText 로 옮기고, 단계인지도 그 글자로 견준다. 진하기도 같다. .NET 의 F0 는 꼭 .5 인 값을 짝수 쪽으로 적어(50.5 는 "50", 74.5 는 "74") 부등호로는 맞출 수 없어서 글자를 견준다. --- SoundVisualizer.Core/EasySettings.cs | 15 ++-- SoundVisualizer.Core/ValueText.cs | 29 ++++++++ SoundVisualizer.Tests/Ui/EasySizesTests.cs | 53 ++++++++++++++ SoundVisualizer.Tests/Ui/ValueTextTests.cs | 85 ++++++++++++++++++++++ SoundVisualizer/Ui/ValueFormatConverter.cs | 6 +- 5 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 SoundVisualizer.Core/ValueText.cs create mode 100644 SoundVisualizer.Tests/Ui/ValueTextTests.cs diff --git a/SoundVisualizer.Core/EasySettings.cs b/SoundVisualizer.Core/EasySettings.cs index bda17dc..c89f067 100644 --- a/SoundVisualizer.Core/EasySettings.cs +++ b/SoundVisualizer.Core/EasySettings.cs @@ -45,13 +45,6 @@ public enum EasySizeControl /// public static class EasySizes { - /// - /// 슬라이더 값이 단계 값과 이만큼 안쪽이면 같은 것으로 본다. 자세한 설정의 슬라이더는 끌면 소수 값(49.8)이 되지만 화면에는 - /// 정수("50")로 보인다. 안드로이드처럼 꼭 같은 값만 치면, 자세한 설정에서 50 으로 보이는데 쉬운 설정은 "자세한 설정에서 맞춘 - /// 크기"라고 하는 엇갈림이 생긴다. 그래서 화면에 같은 정수로 보이는 값은 같은 단계로 본다. - /// - public const double Tolerance = 0.5; - private static readonly VisualModeSettings Defaults = new(); /// 화면에 놓는 차례(작게, 보통, 크게). @@ -117,7 +110,13 @@ public static EasySizeControl DetailControl(VisualModeSettings settings) return EasySizeControl.Size; } - private static bool Near(double value, double step) => Math.Abs(value - step) < Tolerance; + /// + /// 슬라이더 값이 단계 값과 같은 것으로 볼 만한지. 자세한 설정의 슬라이더는 끌면 소수 값(49.8)이 되지만 화면에는 정수("50")로 보인다. + /// 안드로이드처럼 꼭 같은 값만 치면, 자세한 설정에서 50 으로 보이는데 쉬운 설정은 "자세한 설정에서 맞춘 크기"라고 하는 엇갈림이 + /// 생긴다. 그래서 슬라이더 줄에 단계의 수로 보이는 값을 그 단계로 본다. 슬라이더가 글자를 만드는 바로 그 함수()로 + /// 견주므로 꼭 x.5 인 값에서도 엇갈리지 않는다. 예전에는 차이가 0.5 보다 작은지로 따져, "50" 으로 보이는 49.5 가 보통이 아니었다. + /// + private static bool Near(double value, double step) => ValueText.ShowsSameNumber(value, step); } /// 쉬운 설정에서 화면에 보이지 않는 이름(화면 읽어주기)을 짓는 규칙. diff --git a/SoundVisualizer.Core/ValueText.cs b/SoundVisualizer.Core/ValueText.cs new file mode 100644 index 0000000..1525206 --- /dev/null +++ b/SoundVisualizer.Core/ValueText.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.Globalization; + +namespace SoundVisualizer +{ + /// + /// 슬라이더 줄의 값 글자를 적는 규칙. 화면(Ui/ValueFormatConverter)이 이 함수로 글자를 만들고, "화면에 같은 수로 보이는가"를 따지는 쪽 + /// ()도 같은 함수로 견준다. 반올림 규칙을 두 곳에 따로 적으면 꼭 x.5 인 값에서 어긋난다. 슬라이더는 49.5 를 + /// "50" 으로 적는데, 차이가 0.5 보다 작은지로 따지던 쪽은 50 이 아니라고 했다. .NET 의 F0 는 꼭 .5 인 값을 짝수 쪽으로 적어 + /// (49.5 와 50.5 는 "50", 74.5 는 "74", 75.5 는 "76") 부등호 하나로는 흉내 낼 수 없다. 그래서 수를 따로 계산하지 않고 글자를 견준다. + /// + public static class ValueText + { + /// 꼴을 주지 않은 슬라이더 줄의 꼴. 정수로 적는다. "{0:F0}%", "{0:F0} FPS" 처럼 단위를 붙인 꼴도 수는 같은 정수다. + public const string DefaultFormat = "{0:F0}"; + + /// + /// 값을 꼴(string.Format)로 적는다. 꼴이 없으면 이다. + /// 숫자는 Windows 지역 설정과 상관없이 같은 꼴(InvariantCulture)이다. + /// + public static string Format(double value, string? format = null) => + string.Format(CultureInfo.InvariantCulture, string.IsNullOrEmpty(format) ? DefaultFormat : format, value); + + /// 두 값이 슬라이더 줄에 같은 수로 보이는지(기본 꼴의 글자가 같은지). + public static bool ShowsSameNumber(double a, double b) => Format(a) == Format(b); + } +} diff --git a/SoundVisualizer.Tests/Ui/EasySizesTests.cs b/SoundVisualizer.Tests/Ui/EasySizesTests.cs index a35d04b..7419d8f 100644 --- a/SoundVisualizer.Tests/Ui/EasySizesTests.cs +++ b/SoundVisualizer.Tests/Ui/EasySizesTests.cs @@ -85,6 +85,59 @@ public void 다른_정수로_보이는_값은_단계가_아니다(double intensi Assert.Null(EasySizes.Of(new VisualModeSettings { Intensity = intensity, VisualOpacity = opacity })); } + [Theory] + [InlineData(49.5, 50, EasySize.Medium)] // 슬라이더에 "50" 으로 보인다 + [InlineData(50.5, 50, EasySize.Medium)] // 이것도 "50" 이다(.NET 의 F0 는 꼭 .5 인 값을 짝수 쪽으로 적는다) + [InlineData(50, 49.5, EasySize.Medium)] + [InlineData(50, 50.5, EasySize.Medium)] + [InlineData(29.5, 39.5, EasySize.Small)] + [InlineData(30.5, 40.5, EasySize.Small)] + [InlineData(75, 69.5, EasySize.Large)] + [InlineData(75, 70.5, EasySize.Large)] + public void 꼭_x점5_인_값도_슬라이더에_단계의_수로_보이면_그_단계다(double intensity, double opacity, EasySize expected) + { + // 예전에는 단계 값과의 차이가 0.5 보다 작아야 했다. 49.5 는 슬라이더에 "50" 으로 보이는데도 보통이 아니어서, 자세한 설정은 50 이라 + // 하고 쉬운 설정은 "자세한 설정에서 맞춘 크기"라고 했다. + var settings = new VisualModeSettings { Intensity = intensity, VisualOpacity = opacity }; + Assert.Equal(ValueText.Format(expected.Intensity()), ValueText.Format(intensity)); + Assert.Equal(ValueText.Format(expected.Opacity()), ValueText.Format(opacity)); + + Assert.Equal(expected, EasySizes.Of(settings)); + Assert.Equal(EasySizeControl.Size, EasySizes.DetailControl(settings)); + } + + [Theory] + [InlineData(74.5, 70, "74")] // 홀수인 75 의 양옆 .5 는 짝수인 74·76 으로 보인다 + [InlineData(75.5, 70, "76")] + public void 꼭_x점5_인_값이_다른_수로_보이면_단계가_아니다(double intensity, double opacity, string shown) + { + // 차이가 0.5 이하인지로 따지면 이 값들이 크게가 되어, 자세한 설정은 74 라 하고 쉬운 설정은 크게(75)를 선택해 보인다. + var settings = new VisualModeSettings { Intensity = intensity, VisualOpacity = opacity }; + Assert.Equal(shown, ValueText.Format(intensity)); + + Assert.Null(EasySizes.Of(settings)); + Assert.Equal(EasySizeControl.Size, EasySizes.DetailControl(settings)); + } + + [Fact] + public void 단계인지는_슬라이더의_값_글자가_단계의_수와_같은지와_늘_같다() + { + // 단계마다 크기와 진하기를 ±0.7 안에서 0.01 씩 움직여 본다. 꼭 .5 인 값(1/100 로 나누어떨어지지 않는 이진 소수 곁의 값 포함)도 지나간다. + foreach (var step in EasySizes.All) + { + for (int i = -70; i <= 70; i++) + { + double size = step.Intensity() + i / 100.0; + double opacity = step.Opacity() + i / 100.0; + bool sizeShownAsStep = ValueText.Format(size) == ValueText.Format(step.Intensity()); + bool opacityShownAsStep = ValueText.Format(opacity) == ValueText.Format(step.Opacity()); + + Assert.Equal(sizeShownAsStep, EasySizes.Of(new VisualModeSettings { Intensity = size, VisualOpacity = step.Opacity() }) == step); + Assert.Equal(opacityShownAsStep, EasySizes.Of(new VisualModeSettings { Intensity = step.Intensity(), VisualOpacity = opacity }) == step); + } + } + } + [Theory] [InlineData(63, 50, false, EasySizeControl.Size)] [InlineData(50, 90, false, EasySizeControl.Opacity)] diff --git a/SoundVisualizer.Tests/Ui/ValueTextTests.cs b/SoundVisualizer.Tests/Ui/ValueTextTests.cs new file mode 100644 index 0000000..c938d2b --- /dev/null +++ b/SoundVisualizer.Tests/Ui/ValueTextTests.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. +using System.Globalization; +using System.Text.RegularExpressions; + +namespace SoundVisualizer.Tests.Ui; + +/// +/// 슬라이더 줄의 값 글자(Core 의 ValueText). 화면(Ui/ValueFormatConverter)과 쉬운 설정의 크기 단계(EasySizes)가 같은 함수를 써야 +/// "슬라이더에 50 으로 보이는데 보통이 아니다" 같은 엇갈림이 생기지 않는다. 화면 쪽은 WPF 라 소스를 읽어 확인한다. +/// +public class ValueTextTests +{ + private static string Source(params string[] parts) => File.ReadAllText(Path.Combine(new[] { RepositoryPaths.Root, "SoundVisualizer" }.Concat(parts).ToArray())); + + [Theory] + [InlineData(49.4, "49")] + [InlineData(49.5, "50")] // 꼭 .5 인 값은 짝수 쪽으로 적는다(.NET 의 F0). 49.5 와 50.5 가 모두 "50" 이다. + [InlineData(49.6, "50")] + [InlineData(50.4999, "50")] + [InlineData(50.5, "50")] + [InlineData(50.5001, "51")] + [InlineData(74.5, "74")] + [InlineData(75.5, "76")] + [InlineData(100, "100")] + public void 기본_꼴은_정수로_적는다(double value, string expected) + { + Assert.Equal(expected, ValueText.Format(value)); + Assert.Equal(expected, ValueText.Format(value, "")); + Assert.Equal(expected + "%", ValueText.Format(value, "{0:F0}%")); + } + + [Fact] + public void 숫자는_Windows_지역_설정과_상관없이_같은_꼴이다() + { + var original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("ar-EG"); + Assert.Equal("7.5", ValueText.Format(7.5, "{0:F1}")); + Assert.Equal("60 FPS", ValueText.Format(60, "{0:F0} FPS")); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void 같은_수로_보이는지는_기본_꼴의_글자로_견준다() + { + // 반올림을 따로 계산하지 않고 화면에 적는 글자 그대로 견준다. 그래서 꼭 .5 인 값을 어느 쪽으로 적든 화면과 어긋나지 않는다. + Assert.True(ValueText.ShowsSameNumber(49.5, 50)); + Assert.True(ValueText.ShowsSameNumber(50.49, 50)); + Assert.True(ValueText.ShowsSameNumber(50.5, 50)); + Assert.False(ValueText.ShowsSameNumber(49.49, 50)); + Assert.False(ValueText.ShowsSameNumber(50.51, 50)); + Assert.False(ValueText.ShowsSameNumber(74.5, 75)); + Assert.False(ValueText.ShowsSameNumber(75.5, 75)); + } + + [Fact] + public void 슬라이더_줄은_같은_함수로_글자를_만든다() + { + // 화면이 따로 반올림하면 크기 단계 판정과 다시 어긋난다. + string converter = Source("Ui", "ValueFormatConverter.cs"); + Assert.Contains("ValueText.Format(value,", converter); + Assert.DoesNotContain("string.Format(", converter); + Assert.Contains("DefaultFormat = ValueText.DefaultFormat;", converter); + } + + [Fact] + public void 슬라이더마다_준_꼴도_수는_정수로_적는다() + { + // 크기 단계는 기본 꼴의 수로 견준다(ValueText.ShowsSameNumber). 크기·진하기 슬라이더에 소수 꼴("{0:F1}")을 주면 화면의 수와 + // 단계 판정이 다시 갈리므로, 지금 쓰는 꼴이 모두 정수 꼴에 단위만 붙인 것임을 지킨다. + var formats = new[] { "LauncherWindow.xaml", "MainWindow.xaml" } + .SelectMany(file => Regex.Matches(Source(file), "ui:SettingRow\\.ValueFormat=\"([^\"]*)\"").Select(match => match.Groups[1].Value)) + .ToArray(); + + Assert.NotEmpty(formats); + Assert.All(formats, format => Assert.Contains(ValueText.DefaultFormat, format)); + } +} diff --git a/SoundVisualizer/Ui/ValueFormatConverter.cs b/SoundVisualizer/Ui/ValueFormatConverter.cs index 6a2746b..eafa4ef 100644 --- a/SoundVisualizer/Ui/ValueFormatConverter.cs +++ b/SoundVisualizer/Ui/ValueFormatConverter.cs @@ -9,16 +9,16 @@ namespace SoundVisualizer.Ui /// /// 슬라이더 줄의 값 글자. [값, 꼴]을 받아 꼴대로 적는다. 숫자는 Windows 지역 설정과 상관없이 같은 꼴(InvariantCulture)이다. /// 꼴은 XAML 에서 슬라이더마다 다르게 주므로(StringFormat 은 바인딩할 수 없다) MultiBinding 으로 받는다. + /// 글자는 Core 의 가 만든다. 쉬운 설정의 크기 단계(EasySizes)가 "슬라이더에 같은 수로 보이는가"를 같은 함수로 따진다. /// public sealed class ValueFormatConverter : IMultiValueConverter { - public const string DefaultFormat = "{0:F0}"; + public const string DefaultFormat = ValueText.DefaultFormat; public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture) { if (values.Length == 0 || values[0] is not double value) return ""; - string format = values.Length > 1 && values[1] is string given && given.Length > 0 ? given : DefaultFormat; - return string.Format(CultureInfo.InvariantCulture, format, value); + return ValueText.Format(value, values.Length > 1 ? values[1] as string : null); } public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture) => throw new NotSupportedException(); From b20a6501a2be756802e6979c96055c190554a539 Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 15:30:02 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20Silero=20=EA=B0=80=20=EA=B9=A8?= =?UTF-8?q?=EC=A7=84=20=EA=B0=92=EC=9D=84=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=EB=82=B4=EB=8F=84=20=EB=A7=90=EC=86=8C=EB=A6=AC=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=EA=B0=80=20=EA=BA=BC=EC=A7=80?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확률이 유한하지 않거나 0~1 밖이면 SileroInference 가 예외를 던졌고, 미리보기 스레드는 그것을 모델 실패로 받아 끝났다. 그 뒤로는 장치가 바뀌거나 앱을 다시 켤 때까지 미리보기가 조용히 꺼져 있었다. 이제 값을 그대로 돌려받아, 밀린 소리를 버릴 때처럼 모델 상태와 걸린 후보를 비우고 다음 덩어리부터 이어 간다(Android #456). 모델을 불러오지 못하거나 ONNX Runtime 이 던지는 실패는 전처럼 미리보기만 끈다. --- SoundVisualizer.Core/Ai/SileroInference.cs | 20 ++++++-- SoundVisualizer.Core/Ai/SileroStreamWorker.cs | 28 +++++++++- .../RealtimeAiPipelineSpeechPreviewTests.cs | 51 ++++++++++++++++++- .../Ai/SileroInferenceTests.cs | 37 ++++++++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/SoundVisualizer.Core/Ai/SileroInference.cs b/SoundVisualizer.Core/Ai/SileroInference.cs index 68f6245..cf2f086 100644 --- a/SoundVisualizer.Core/Ai/SileroInference.cs +++ b/SoundVisualizer.Core/Ai/SileroInference.cs @@ -142,9 +142,13 @@ public void Reset() /// /// 16kHz 512샘플로 말소리일 확률(0~1)을 낸다. 상태와 앞 덩어리의 끝을 다음 덩어리로 넘긴다. 할당하지 않는다. + /// + /// 모델이 유한하지 않거나 0~1 밖의 값을 내도 던지지 않고 그대로 돌려준다( 로 가른다). 예전에는 여기서 + /// 던졌고, 미리보기 스레드가 그것을 모델 실패로 받아 캡처를 다시 시작할 때까지 미리보기가 조용히 꺼졌다. 깨진 값 하나는 지나가는 + /// 일이라, 부르는 쪽()이 상태를 비우고 이어 간다(Android #456). 지금 모델(v6.2.3)은 NaN·무한대가 + /// 섞인 소리에도 0~1 의 값을 내므로(SileroInferenceTests) 실제로는 드문 경우의 대비다. /// /// 닫은 뒤에 불렀다. - /// 모델이 0~1 밖이나 유한하지 않은 값을 냈다(Android 의 require 와 같다). public float Score(ReadOnlySpan block) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -157,11 +161,16 @@ public float Score(ReadOnlySpan block) float probability = _output[0]; _nextState.CopyTo(_state, 0); block[(BlockSize - ContextSize)..].CopyTo(_contextTail); - if (!float.IsFinite(probability) || probability < 0f || probability > 1f) - throw new InvalidDataException($"Silero returned an invalid speech probability {probability}"); return probability; } + /// + /// 가 낸 값이 확률로 쓸 만한지(유한하고 0~1 안). 아니면 그 값을 낸 순환 상태도 믿을 수 없으므로 + /// 부르는 쪽이 하고 그 덩어리를 증거로 세지 않는다. + /// + public static bool IsValidProbability(float probability) => + float.IsFinite(probability) && probability >= 0f && probability <= 1f; + /// 세션과 버퍼를 놓는다. 여러 번 불러도 된다. 와 겹쳐 부르면 안 된다(같은 스레드에서만 닫는다). public void Dispose() { @@ -218,7 +227,10 @@ internal interface ISileroModel : IDisposable /// void Reset(); - /// + /// + /// 16kHz 512샘플로 말소리일 확률(0~1)을 낸다. 깨진 값(유한하지 않거나 0~1 밖)은 던지지 않고 그대로 돌려준다 + /// (). 던지는 것은 모델을 더 쓸 수 없는 실패뿐이고, 그때는 미리보기가 꺼진다. + /// float Score(ReadOnlySpan block); } diff --git a/SoundVisualizer.Core/Ai/SileroStreamWorker.cs b/SoundVisualizer.Core/Ai/SileroStreamWorker.cs index 2df35df..414295a 100644 --- a/SoundVisualizer.Core/Ai/SileroStreamWorker.cs +++ b/SoundVisualizer.Core/Ai/SileroStreamWorker.cs @@ -15,6 +15,8 @@ namespace SoundVisualizer.Ai /// 모델 상태를 비우고 discontinuity 를 부른다. 정규 추론은 이 스레드를 기다리지 않는다. /// - 링의 세대가 바뀌면(캡처를 다시 시작) 처음부터 다시 센다. 덩어리를 본 뒤에도 세대가 그대로일 때만 결과를 넘긴다. /// - 모델은 이 스레드가 만들고 쓰고 닫는다. 만들거나 돌리다 실패하면 미리보기만 꺼지고() 정규 분류는 그대로 돈다. + /// - 모델이 깨진 값(유한하지 않거나 0~1 밖)을 내는 것은 실패가 아니다. 상태를 비우고 discontinuity 를 부른 뒤 다음 덩어리부터 + /// 이어 간다(). 예전에는 이것도 실패로 쳐서, 값 하나 때문에 캡처를 다시 시작할 때까지 미리보기가 꺼졌다. /// /// PC 에서 바꾼 것: 닫을 때 스레드가 끝나기를 기다린다(, 파이프라인이 1초까지). Android 는 기다리지 않고 스레드가 /// 스스로 끝나며 세션을 놓게 둔다. 여기서도 기다리다 시간이 다 되면 그렇게 둔다. @@ -44,11 +46,12 @@ internal sealed class SileroStreamWorker : IDisposable private int _failed; private long _calls; private long _resets; + private long _invalidScores; private long _next; /// 읽을 AI 링. 레이트는 만들 때 정해진다. /// 이 스레드 안에서 한 번 부른다. 만든 모델은 이 스레드가 닫는다. - /// 밀린 것을 버리고 건너뛸 때 이 스레드에서 부른다. 걸린 후보를 비우는 데 쓴다. + /// 밀린 것을 버리고 건너뛸 때와 모델이 깨진 값을 냈을 때 이 스레드에서 부른다. 걸린 후보를 비우는 데 쓴다. /// 덩어리 하나를 본 뒤 이 스레드에서 부른다. 오래 붙잡지 않는다. /// 단조 나노초. 덩어리를 본 시각을 잰다. public SileroStreamWorker( @@ -77,9 +80,12 @@ public SileroStreamWorker( /// Silero 를 돌린 덩어리 수(테스트와 진단용). public long Calls => Interlocked.Read(ref _calls); - /// 모델 상태를 비운 횟수. 세대가 바뀌거나(처음 한 번 포함) 밀린 것을 버릴 때 센다. + /// 모델 상태를 비운 횟수. 세대가 바뀌거나(처음 한 번 포함) 밀린 것을 버릴 때, 모델이 깨진 값을 냈을 때 센다. public long Resets => Interlocked.Read(ref _resets); + /// 모델이 깨진 값(유한하지 않거나 0~1 밖)을 낸 덩어리 수. 그 덩어리는 증거로 넘기지 않는다(테스트와 진단용). + public long InvalidScores => Interlocked.Read(ref _invalidScores); + /// 모델을 만들거나 돌리다 실패해 미리보기를 껐는지. 정규 분류는 상관없이 돈다. public bool Failed => Volatile.Read(ref _failed) != 0; @@ -167,6 +173,24 @@ private void Run() BeforeScoreForTest?.Invoke(); float probability = model.Score(output); Interlocked.Increment(ref _calls); + if (!SileroInference.IsValidProbability(probability)) + { + // 깨진 값이 한 번 나오면 이어받는 순환 상태도 믿을 수 없다. 그렇다고 스레드를 끝내면 캡처를 다시 시작할 때까지 + // 미리보기가 조용히 꺼진다. 밀린 것을 버릴 때처럼 상태를 비우고 모아 둔 증거(후보)를 버린 뒤, 이 덩어리는 건너뛰고 + // 다음 덩어리부터 이어 간다(Android #456). 값이 계속 깨져 나와도 미리보기가 받아들여지지 않을 뿐이다. + model.Reset(); + Interlocked.Increment(ref _resets); + // 값이 계속 깨져 나오면 덩어리마다(초당 31번) 오므로 처음 한 번만 남긴다. + if (Interlocked.Increment(ref _invalidScores) == 1) + { + AppLog.Warn(Tag, string.Create(System.Globalization.CultureInfo.InvariantCulture, + $"Silero returned an invalid speech probability {probability}; state reset, preview continues")); + } + _discontinuity(); + next += SileroInference.BlockSize; + Interlocked.Exchange(ref _next, next); + continue; + } // 보는 사이 캡처를 다시 시작했으면 옛 세대의 결과는 넘기지 않는다. if (_buffer.Cursor().Generation == epoch && !IsClosed) { diff --git a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs index 42c8baf..93309e2 100644 --- a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs +++ b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs @@ -308,10 +308,59 @@ public void Silero_를_만들지_못하면_미리보기만_꺼지고_정규_분 Assert.Same(pipeline.LastClassification(), pipeline.LastDisplayClassification()); } + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(-0.01f)] + [InlineData(1.01f)] + public void Silero_가_깨진_값을_한_번_내면_상태와_후보를_비우고_미리보기는_이어_간다(float broken) + { + // 깨진 값 하나를 모델 실패로 치면 캡처를 다시 시작할 때까지 미리보기가 조용히 꺼진다(Android #456). 지금 모델은 NaN 이 섞인 + // 소리에도 쓸 수 있는 값을 내지만(SileroInferenceTests), 값을 믿는 대신 여기서 막는다. + var silero = new FakeSileroModel(); + using var h = new Harness(16000, 1, silero: silero); + h.FirstSpeechCandidate(); + int before = silero.ScoreCalls; + int modelResets = silero.ResetCalls; + long workerResets = h.Worker.Resets; + long fresh = h.Frames; + // 정규 추론이 본 소리와 걸친 덩어리 하나 뒤의 두 번째 새 덩어리에서만 깨진 값을 낸다. + silero.Probability = call => call == before + 3 ? broken : 1f; + // 한꺼번에 넣으면 다섯 덩어리 넘게 밀려 건너뛸 수 있으므로 세 덩어리씩 넣고 따라잡기를 기다린다. + h.FeedFreshBlocks(3, fresh); + h.FeedFreshBlocks(6, fresh); + + // 스레드는 살아 있고, 이어받던 상태를 한 번 비웠다. 깨진 값은 증거로 넘기지 않는다(1 보다 큰 값을 말소리로 세지 않는다). + Assert.False(h.Worker.Failed); + Assert.True(h.Worker.IsAlive); + Assert.Equal(modelResets + 1, silero.ResetCalls); + Assert.Equal(workerResets + 1, h.Worker.Resets); + Assert.Equal(1, h.Worker.InvalidScores); + Assert.Equal(0, silero.DisposeCalls); + // 밀린 것을 버릴 때처럼 걸려 있던 후보도 버린다. 뒤의 덩어리 넷이 모두 말소리여도 이 후보로는 받아들이지 않는다. + Assert.False(h.Pipeline.LastDisplayClassification()?.SpeechPreview); + Assert.True(silero.ScoreCalls >= before + 6, "the worker kept scoring after the broken value"); + + // 다음 후보부터는 다시 받아들인다. 캡처를 다시 시작하지 않아도 된다(같은 스레드, 같은 모델). + h.Yamnet.TopClass = Names.IndexOf("Music"); + Assert.Equal(AiTickOutcome.Completed, h.Pipeline.RunTickOnce(250)); + h.Yamnet.TopClass = SpeechIndex; + h.Nanos.Set(500 * Ms); + h.WaitCaughtUp(); + Assert.Equal(AiTickOutcome.Completed, h.Pipeline.RunTickOnce(500)); + var regular = h.Pipeline.LastClassification(); + Assert.Equal(SoundType.Ambient, regular?.Coarse); + h.FeedFreshBlocks(3, h.Frames); + Assert.True(h.Pipeline.LastDisplayClassification()?.SpeechPreview); + Assert.Equal(1, h.Worker.InvalidScores); + Assert.Equal(0, silero.DisposeCalls); + } + [Fact] public void Silero_가_돌다_실패하면_미리보기만_꺼지고_정규_분류는_돈다() { - var silero = new FakeSileroModel { OnScore = _ => throw new InvalidDataException("bad probability") }; + // 모델을 더 쓸 수 없는 실패(ONNX Runtime 의 예외 등)다. 깨진 값 하나와 달리 미리보기를 끈다. + var silero = new FakeSileroModel { OnScore = _ => throw new InvalidOperationException("onnxruntime failed") }; using var h = new Harness(16000, 1, silero: silero); h.Feed(PipelineTestAudio.WindowFrames(16000)); SpinUntil(() => h.Worker.Failed); diff --git a/SoundVisualizer.Tests/Ai/SileroInferenceTests.cs b/SoundVisualizer.Tests/Ai/SileroInferenceTests.cs index cdb0b43..f5613c0 100644 --- a/SoundVisualizer.Tests/Ai/SileroInferenceTests.cs +++ b/SoundVisualizer.Tests/Ai/SileroInferenceTests.cs @@ -96,6 +96,43 @@ public void 한_바이트만_바뀌었거나_잘렸거나_없는_모델로는_ } } + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.MaxValue)] + public void 지금_모델은_깨진_소리에도_쓸_수_있는_확률을_내고_상태를_비우면_처음과_같은_값을_낸다(float sample) + { + // 루프백 캡처의 float PCM 에는 NaN 이나 무한대가 섞일 수 있다(소리를 내는 앱의 결함). 고정한 v6.2.3 모델은 그런 덩어리에도 + // 0~1 의 값을 내고 뒤의 덩어리까지 깨뜨리지 않는다. 그래서 깨진 확률을 막는 일(SileroStreamWorker)은 지금 모델에서는 일어나지 + // 않는 경우의 대비다. 모델을 바꿔 이 성질이 달라지면 여기서 먼저 알게 된다. + using var model = SileroInference.Create(AiTestData.ModelDirectory); + var chunk = new float[Block]; + for (int i = 0; i < chunk.Length; i++) chunk[i] = (float)Math.Sin(i * .07) * .4f; + float first = model.Score(chunk); + + var broken = new float[Block]; + Array.Fill(broken, sample); + Assert.True(SileroInference.IsValidProbability(model.Score(broken))); + for (int i = 0; i < 4; i++) Assert.True(SileroInference.IsValidProbability(model.Score(chunk))); + + model.Reset(); + Assert.Equal(first, model.Score(chunk)); + } + + [Theory] + [InlineData(0f, true)] + [InlineData(0.5f, true)] + [InlineData(1f, true)] + [InlineData(-0.0001f, false)] + [InlineData(1.0001f, false)] + [InlineData(float.NaN, false)] + [InlineData(float.PositiveInfinity, false)] + [InlineData(float.NegativeInfinity, false)] + public void 확률로_쓸_만한_값은_유한하고_0에서_1_사이다(float probability, bool expected) + { + Assert.Equal(expected, SileroInference.IsValidProbability(probability)); + } + [Fact] public void 덩어리_길이가_다르거나_닫은_뒤에는_부르지_않는다() { From 3cd2d9d9748250662ddfe806768e9f755ad539eb Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 15:36:43 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20AI=20=EA=B0=80=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20=EA=B2=8C=EC=8B=9C=ED=95=98=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20HUD=20=EA=B0=80=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=EB=A5=BC=20=EA=B8=B0=EB=8B=A4=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=B0=94=EB=A1=9C=20=EC=9D=BD?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HUD 와 결과 기록은 100ms 타이머로만 읽었다. 말소리 미리보기는 다음 정규 결과가 오면 사라져 100ms 도 못 사는 일이 있어, 화면에도 CSV 에도 한 번도 남지 않을 수 있었다(Android #412/#413). - RealtimeAiPipeline 이 정규 결과를 게시하거나 지운 틱, 미리보기를 게시한 미리보기 스레드에서 알린다. 추론 잠금 밖에서 부르고, 받는 쪽이 던져도 추론은 이어진다. - 오버레이는 HUD 를 읽는 동안에만 그 알림을 UI 스레드의 읽기 하나로 넘긴다(Core 의 AiHudRefresh). 몰려 온 알림은 하나로 합친다. - 타이머는 그대로 둔다. 결과가 오지 않는 동안의 나이와 소리 크기를 읽는다. --- SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs | 69 +++++- .../Diagnostics/AiDebugHud.cs | 2 + .../Diagnostics/AiHudRefresh.cs | 78 +++++++ ...altimeAiPipelineChangeNotificationTests.cs | 171 +++++++++++++++ .../RealtimeAiPipelineSpeechPreviewTests.cs | 48 +++- .../Diagnostics/AiHudRefreshTests.cs | 206 ++++++++++++++++++ SoundVisualizer.Tests/Ui/AppSource.cs | 35 +++ .../Ui/DebugHudSourceTests.cs | 46 ++++ SoundVisualizer/MainWindow.DebugHud.cs | 12 +- SoundVisualizer/MainWindow.xaml.cs | 6 +- 10 files changed, 659 insertions(+), 14 deletions(-) create mode 100644 SoundVisualizer.Core/Diagnostics/AiHudRefresh.cs create mode 100644 SoundVisualizer.Tests/Ai/RealtimeAiPipelineChangeNotificationTests.cs create mode 100644 SoundVisualizer.Tests/Diagnostics/AiHudRefreshTests.cs create mode 100644 SoundVisualizer.Tests/Ui/AppSource.cs create mode 100644 SoundVisualizer.Tests/Ui/DebugHudSourceTests.cs diff --git a/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs b/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs index e986569..f22a80b 100644 --- a/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs +++ b/SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs @@ -23,6 +23,11 @@ namespace SoundVisualizer.Ai /// 정규 위협음이나 다음 정규 결과가 늘 이기고, 사용자 지정이 하나라도 있으면 미리보기를 쓰지 않는다. Silero 를 불러오거나 돌리다 /// 실패해도 정규 분류는 그대로 돈다. /// + /// 결과 알림(Android 의 onClassificationChanged, #412): 정규 결과를 새로 게시하거나 지울 때(틱 스레드), 미리보기를 게시할 때(미리보기 + /// 스레드) 만들 때 받은 classificationChanged 를 부른다. 개발자 HUD 가 100ms 타이머를 기다리지 않고 바로 읽게 하는 깨우기일 뿐이고 + /// 값은 싣지 않는다. 받는 쪽은 을 다시 읽는다. 시작·멈춤·닫기와 미리보기 만료는 알리지 않는다 + /// (부르는 쪽이 아는 일이거나 시간이 지나 생기는 일이라 타이머가 읽는다). + /// /// PC 에서 바꾼 것: /// - 이 돌고 있는 틱을 기다리고 마지막 결과를 바로 숨기며, 는 앞 틱이 끝난 뒤에 비운다. /// Android 서비스는 늦게 끝난 추론이 비운 뒤의 결과를 덮지 않도록 읽는 쪽을 떼고 AI 재시작을 미뤘는데, 여기서는 그럴 필요가 없다. @@ -73,6 +78,8 @@ public sealed class RealtimeAiPipeline : IDisposable private readonly IYamnetModel _model; private readonly Func> _mappingOverrides; private readonly Action? _tickFailed; + private readonly Action? _classificationChanged; + private int _classificationChangedFailed; private readonly Func _clock; private readonly Func _waitForNextTick; private readonly bool _runScheduler; @@ -118,6 +125,7 @@ public sealed class RealtimeAiPipeline : IDisposable /// (이 생성자로 만드는 테스트의 기본). 앱은 가 넣는다. /// /// 미리보기 시각을 재는 단조 나노초. 테스트가 시각을 정한다. 기본은 . + /// 화면용 결과가 바뀌었을 때 틱 스레드나 미리보기 스레드에서 부른다( 참고). internal RealtimeAiPipeline( int captureSampleRate, int channels, @@ -129,7 +137,8 @@ internal RealtimeAiPipeline( Func? waitForNextTick = null, bool runScheduler = true, Func? speechModel = null, - Func? nanoClock = null) + Func? nanoClock = null, + Action? classificationChanged = null) { ValidateArguments(captureSampleRate, channels, classes); ArgumentNullException.ThrowIfNull(model); @@ -139,6 +148,7 @@ internal RealtimeAiPipeline( _model = model; _mappingOverrides = mappingOverrides ?? (static () => NoOverrides); _tickFailed = tickFailed; + _classificationChanged = classificationChanged; _clock = clock ?? (static () => Environment.TickCount64); _waitForNextTick = waitForNextTick ?? WaitForNextTick; _runScheduler = runScheduler; @@ -169,6 +179,11 @@ internal RealtimeAiPipeline( /// 말소리 미리보기를 켤지. 켜면 같은 폴더의 silero_vad.onnx 를 미리보기 스레드가 불러온다. 그 파일이 없거나 다르면 미리보기만 꺼지고 /// () 정규 분류는 돈다. /// + /// + /// 화면용 결과()가 바뀌었다는 깨우기. 정규 결과를 게시하거나 지운 틱 스레드, 미리보기를 게시한 + /// 미리보기 스레드에서 부른다. 값은 싣지 않으니 받는 쪽이 다시 읽는다. 추론 스레드를 붙잡지 않게 곧바로 돌아와야 한다 + /// (UI 스레드에 넘기기만 한다, Diagnostics.AiHudRefresh). 던지면 삼키고 처음 한 번만 으로 남긴다. + /// /// AI 가 받지 않는 캡처 레이트이거나 채널 수가 1 보다 작다. /// 모델 파일이 없다. /// 모델 파일이 배포한 것과 다르거나 입출력이 YAMNet 계약과 다르다. @@ -180,7 +195,8 @@ public static RealtimeAiPipeline Create( YamnetClassMap classes, Func>? mappingOverrides = null, Action? tickFailed = null, - bool speechPreview = SpeechPreviewEnabled) + bool speechPreview = SpeechPreviewEnabled, + Action? classificationChanged = null) { // 모델을 읽기 전에 거른다. 화면만 돌 레이트라면 15MB 를 해시하고 세션을 만들 까닭이 없다. ValidateArguments(captureSampleRate, channels, classes); @@ -189,7 +205,8 @@ public static RealtimeAiPipeline Create( { string directory = Path.GetFullPath(modelDirectory); return new RealtimeAiPipeline(captureSampleRate, channels, model, classes, mappingOverrides, tickFailed, - speechModel: speechPreview ? () => SileroInference.Create(directory) : null); + speechModel: speechPreview ? () => SileroInference.Create(directory) : null, + classificationChanged: classificationChanged); } catch { @@ -373,7 +390,7 @@ internal AiTickOutcome RunTickOnce(long nowMs) // 앞 틱이 아직 돌고 있으면 기다리지 않고 건너뛴다(Kotlin 의 tryLock). if (!Monitor.TryEnter(_inferLock)) return AiTickOutcome.Busy; - (AiTickOutcome Outcome, Exception? Failure) tick; + (AiTickOutcome Outcome, Exception? Failure, bool Changed) tick; try { tick = RunTickLocked(nowMs); @@ -386,6 +403,8 @@ internal AiTickOutcome RunTickOnce(long nowMs) } if (tick.Failure is not null) ReportTickFailure(tick.Failure); + // 추론 잠금을 놓은 뒤에 알린다. 받는 쪽이 느리거나 이 파이프라인을 다시 불러도 다음 틱과 닫기가 잠금에서 기다리지 않는다. + if (tick.Changed && !IsClosed) NotifyClassificationChanged(); return tick.Outcome; } @@ -410,28 +429,34 @@ internal AiTickOutcome RunTickOnce(long nowMs) /// 말소리 미리보기 스레드. 끄면 null. internal SileroStreamWorker? SpeechWorkerForTest => _speechWorker; - private (AiTickOutcome Outcome, Exception? Failure) RunTickLocked(long nowMs) + /// Changed 는 이 틱이 정규 결과를 새로 게시했거나(완료) 매핑이 바뀌어 지웠는지다. 잠금을 놓은 뒤에 알린다. + private (AiTickOutcome Outcome, Exception? Failure, bool Changed) RunTickLocked(long nowMs) { // 정규 추론이 시작되면 미리보기를 새로 받아들이지 않는다. 이미 받아들인 것은 만료나 다음 정규 결과까지 둔다(Android 와 같다). _speechGate.RegularStarted(); // Dispose 가 잠금을 잡기 직전에 위의 검사를 지났을 수 있으므로 잠금 안에서 다시 본다. - if (IsClosed) return (AiTickOutcome.Closed, null); + if (IsClosed) return (AiTickOutcome.Closed, null, false); + bool cleared = false; try { // 무음이라 건너뛰는 틱에도 먼저 반영한다. 이전 매핑에서 확정한 위협음이 새 매핑으로 넘어가지 않게 한다. // 매핑이 바뀌어 마지막 결과를 지웠으면 그 결과의 미리보기도 지운다. - if (_stage.UpdateMapping(CurrentOverrides())) ClearSpeechPreview(); + if (_stage.UpdateMapping(CurrentOverrides())) + { + ClearSpeechPreview(); + cleared = true; + } } catch (Exception ex) { - return (AiTickOutcome.Failed, ex); + return (AiTickOutcome.Failed, ex, false); } if (!_gate.IsInferenceOpen(nowMs)) { Interlocked.Increment(ref _silenceSkippedTicks); - return (AiTickOutcome.SkippedForSilence, null); + return (AiTickOutcome.SkippedForSilence, null, cleared); } try @@ -441,12 +466,12 @@ internal AiTickOutcome RunTickOnce(long nowMs) catch (Exception ex) { _gate.OnInferenceFailed(nowMs); - return (AiTickOutcome.Failed, ex); + return (AiTickOutcome.Failed, ex, cleared); } // 결과를 게시한 뒤에만 완료로 센다. 닫기 경합 테스트가 잠깐 보이는 결과 대신 완료 수로 확인한다(Kotlin 과 같다). Interlocked.Increment(ref _inferenceCompletions); _gate.OnInferenceCompleted(nowMs); - return (AiTickOutcome.Completed, null); + return (AiTickOutcome.Completed, null, true); } private void InferLocked() @@ -507,6 +532,28 @@ private void OnSpeechBlock(long generation, long startFrame, long endFrame, floa if (_nanoClock() < accepted.AcceptBeforeNs) { Volatile.Write(ref _displayPreview, new DisplayPreview(parent.Result, preview, accepted.ExpiresNs)); + // 미리보기는 다음 정규 결과가 오면 사라져 100ms 도 못 사는 일이 있다. 타이머만으로는 HUD 와 기록이 그것을 통째로 놓친다. + NotifyClassificationChanged(); + } + } + + /// + /// 화면용 결과가 바뀌었다고 알린다. 틱 스레드(추론 잠금 밖)와 미리보기 스레드에서 부른다. 받는 쪽의 결함이 추론이나 미리보기를 + /// 멈추면 안 되므로 예외는 삼키고, 틱마다 같은 줄이 쌓이지 않게 처음 한 번만 남긴다. + /// + private void NotifyClassificationChanged() + { + if (_classificationChanged is null) return; + try + { + _classificationChanged(); + } + catch (Exception ex) + { + if (Interlocked.Exchange(ref _classificationChangedFailed, 1) == 0) + { + AppLog.Warn(Tag, "Classification-changed callback threw", ex); + } } } diff --git a/SoundVisualizer.Core/Diagnostics/AiDebugHud.cs b/SoundVisualizer.Core/Diagnostics/AiDebugHud.cs index ca13557..64de0bb 100644 --- a/SoundVisualizer.Core/Diagnostics/AiDebugHud.cs +++ b/SoundVisualizer.Core/Diagnostics/AiDebugHud.cs @@ -9,6 +9,8 @@ namespace SoundVisualizer.Diagnostics /// 오버레이 하나의 개발자 모드 HUD 와 결과 기록을 언제 돌리고 멈출지 정한다(모바일 AiDebugOverlay 의 produceState 와 DisposableEffect). /// /// - HUD 는 오버레이가 열려 있고 개발자 모드가 켜져 있는 동안만 마다 읽는다(). + /// 그동안 AI 가 결과를 게시하면 타이머를 기다리지 않고 한 번 더 읽는다(). 타이머보다 짧게 사는 결과 + /// (말소리 미리보기)도 화면과 기록에 남는다. /// - 기록은 거기에 기록 스위치까지 켜져 있을 때만 돈다(). 셋 가운데 하나라도 바뀌면 그 자리에서 켜고 끈다. /// 개발자 모드를 꺼도 기록 스위치의 값은 남아, 다시 켜면 새 파일에 기록한다(모바일과 같다). /// - 읽을 때마다 결과를 기록기에 넘긴다. 같은 결과를 거듭 넘겨도 기록기가 한 줄만 남긴다(). diff --git a/SoundVisualizer.Core/Diagnostics/AiHudRefresh.cs b/SoundVisualizer.Core/Diagnostics/AiHudRefresh.cs new file mode 100644 index 0000000..46093c3 --- /dev/null +++ b/SoundVisualizer.Core/Diagnostics/AiHudRefresh.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.Diagnostics +{ + /// + /// AI 가 새 결과를 게시했다는 알림을 개발자 HUD 의 "지금 한 번 읽기"로 바꾼다(모바일 AiHudRefresh.kt, #412). + /// + /// HUD 는 100ms 타이머로 읽는다. 그런데 말소리 미리보기는 다음 정규 결과가 오면 사라져 100ms 도 못 사는 일이 있어, 타이머만으로는 + /// 화면에도 기록(CSV)에도 한 번도 남지 않을 수 있다. 그래서 결과가 게시되면 타이머를 기다리지 않고 바로 읽는다. 타이머는 그대로 둔다. + /// 결과가 오지 않는 동안(무음)의 나이와 소리 크기는 타이머가 읽는다. + /// + /// - 는 추론 스레드(틱, 미리보기)에서 불린다. 읽는 일을 UI 스레드에 넘기기만 하고 바로 돌아온다. 넘기다 실패해도 + /// 던지지 않는다. + /// - 넘긴 읽기가 아직 돌지 않았으면 다시 넘기지 않는다. 알림이 몰려도 UI 스레드에는 하나만 쌓이고, 읽을 때는 가장 새 결과를 읽는다 + /// (알림은 값을 싣지 않는 깨우기다). 읽기 시작하면서 표시를 내리므로, 읽는 사이에 온 알림은 한 번 더 넘겨져 새 결과를 놓치지 않는다. + /// - HUD 를 읽지 않는 동안( 가 false: 개발자 모드가 꺼졌거나 오버레이가 닫혔다)에는 아무것도 넘기지 않는다. + /// 일반 사용자에게는 추론마다 UI 스레드를 깨우는 일이 생기지 않는다. + /// + public sealed class AiHudRefresh + { + private const string Tag = nameof(AiHudRefresh); + + private readonly Action _post; + private readonly Action _read; + private readonly Action _run; + private volatile bool _active; + private int _queued; + private int _postFailed; + + /// 받은 일을 UI 스레드에서 돌게 넘기고 기다리지 않고 돌아온다(오버레이는 Dispatcher.InvokeAsync). + /// HUD 를 한 번 읽는다. 가 넘긴 UI 스레드에서 불린다. + public AiHudRefresh(Action post, Action read) + { + ArgumentNullException.ThrowIfNull(post); + ArgumentNullException.ThrowIfNull(read); + _post = post; + _read = read; + _run = Run; + } + + /// + /// HUD 를 읽는 중인지(). 기록은 HUD 를 읽는 동안에만 돌므로 이것 하나로 둘을 모두 가린다. + /// UI 스레드가 바꾸고 추론 스레드가 읽는다. + /// + public bool Active + { + get => _active; + set => _active = value; + } + + /// 새 결과가 게시됐다. 어느 스레드에서나 부른다. 기다리지 않고, 던지지 않는다. + public void Notify() + { + if (!_active) return; + // 이미 넘긴 읽기가 아직 돌지 않았다. 그 읽기가 이 결과도 읽는다. + if (Interlocked.Exchange(ref _queued, 1) != 0) return; + try + { + _post(_run); + } + catch (Exception ex) + { + // 창이 닫히는 중이거나 넘길 곳이 사라졌다. 추론 스레드로 예외가 새면 추론이 멈춘다. 다음 알림이 다시 넘겨 보게 표시를 내린다. + Volatile.Write(ref _queued, 0); + if (Interlocked.Exchange(ref _postFailed, 1) == 0) AppLog.Warn(Tag, "HUD 읽기를 UI 스레드에 넘기지 못했습니다.", ex); + } + } + + private void Run() + { + // 읽기 전에 내린다. 읽고 나서 내리면 읽는 사이에 게시된 결과의 알림이 "이미 넘겼다"에 걸려 버려진다. + Volatile.Write(ref _queued, 0); + if (!_active) return; + _read(); + } + } +} diff --git a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineChangeNotificationTests.cs b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineChangeNotificationTests.cs new file mode 100644 index 0000000..e26e792 --- /dev/null +++ b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineChangeNotificationTests.cs @@ -0,0 +1,171 @@ +// 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 의 onClassificationChanged, #412). 개발자 HUD 가 100ms 타이머를 기다리지 않고 새 결과를 바로 읽게 하는 +/// 깨우기다. 정규 결과를 게시하거나 지운 틱에서만 알리고, 받는 쪽이 던져도 추론은 이어진다. 미리보기를 게시할 때의 알림은 +/// RealtimeAiPipelineSpeechPreviewTests 에 있다. +/// +public class RealtimeAiPipelineChangeNotificationTests +{ + private static readonly YamnetClassMap Names = AiTestData.ClassMap; + private static readonly int SpeechIndex = Names.IndexOf("Speech"); + private static readonly int GunshotIndex = Names.IndexOf("Gunshot, gunfire"); + + [Fact] + public void 정규_결과를_게시한_틱은_그_결과가_보이는_뒤에_한_번_알린다() + { + var model = new FakeYamnetModel { TopClass = GunshotIndex }; + var clock = new ManualClock(); + RealtimeAiPipeline? pipeline = null; + var seenAtNotify = new List(); + using var created = pipeline = new RealtimeAiPipeline(16000, 1, model, Names, clock: clock.Now, runScheduler: false, + classificationChanged: () => seenAtNotify.Add(pipeline!.LastDisplayClassification())); + pipeline.Start(); + Assert.Empty(seenAtNotify); // 시작은 알리지 않는다(부르는 쪽이 아는 일이다) + + pipeline.Ingest(PipelineTestAudio.Sine(PipelineTestAudio.WindowFrames(16000))); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); + + // 알림은 값을 싣지 않는다. 받는 쪽이 다시 읽으면 방금 게시한 결과가 이미 보여야 한다. + var first = pipeline.LastClassification(); + Assert.NotNull(first); + Assert.Equal(new[] { first }, seenAtNotify); + + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(250)); + Assert.Equal(2, seenAtNotify.Count); + Assert.Same(pipeline.LastClassification(), seenAtNotify[1]); + } + + [Fact] + public void 결과가_바뀌지_않은_틱은_알리지_않는다() + { + var model = new FakeYamnetModel { TopClass = SpeechIndex }; + var clock = new ManualClock(10_000); + int notified = 0; + using var pipeline = new RealtimeAiPipeline(16000, 1, model, Names, clock: clock.Now, runScheduler: false, + classificationChanged: () => notified++); + pipeline.Start(); + + // 한 창이 모이지 않았다. + Assert.Equal(AiTickOutcome.NotEnoughAudio, pipeline.RunTickOnce(10_000)); + Assert.Equal(0, notified); + + pipeline.Ingest(PipelineTestAudio.Sine(PipelineTestAudio.WindowFrames(16000))); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(10_000)); + Assert.Equal(1, notified); + + // 추론이 실패하면 결과는 그대로다. + model.OnInfer = _ => throw new InvalidOperationException("tick failed"); + Assert.Equal(AiTickOutcome.Failed, pipeline.RunTickOnce(10_250)); + Assert.Equal(1, notified); + model.OnInfer = null; + + // 소리가 그쳐 추론을 건너뛰는 틱은 마지막 결과를 그대로 든다. 그동안의 나이는 HUD 의 타이머가 읽는다. + int completed = 0; + long now = 10_500; + while (pipeline.RunTickOnce(now) == AiTickOutcome.Completed) + { + completed++; + now += 250; + Assert.True(completed < 20, "the silence gate never closed"); + } + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(now)); + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(now + 250)); + Assert.Equal(1 + completed, notified); + + // 멈춤과 닫기도 알리지 않는다. + pipeline.Stop(); + pipeline.Dispose(); + Assert.Equal(AiTickOutcome.Closed, pipeline.RunTickOnce(now + 500)); + Assert.Equal(1 + completed, notified); + } + + [Fact] + public void 사용자_지정이_바뀌어_결과를_지운_틱은_추론을_건너뛰어도_알린다() + { + IReadOnlyDictionary overrides = new Dictionary(); + var model = new FakeYamnetModel { TopClass = GunshotIndex }; + var clock = new ManualClock(10_000); + int notified = 0; + using var pipeline = new RealtimeAiPipeline(16000, 1, model, Names, () => overrides, clock: clock.Now, runScheduler: false, + classificationChanged: () => notified++); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(PipelineTestAudio.WindowFrames(16000))); + long now = 10_000; + while (pipeline.RunTickOnce(now) == AiTickOutcome.Completed) + { + now += 250; + Assert.True(now < 20_000, "the silence gate never closed"); + } + int before = notified; + Assert.NotNull(pipeline.LastClassification()); + + // 무음으로 건너뛰는 틱에도 새 매핑을 반영해 옛 결과를 지운다. 화면용 결과가 바뀌었으므로 알린다. + overrides = new Dictionary { ["Gunshot, gunfire"] = "ambient" }; + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(now + 250)); + Assert.Null(pipeline.LastClassification()); + Assert.Equal(before + 1, notified); + + // 같은 지정으로는 다시 지우지 않으므로 알리지도 않는다. + Assert.Equal(AiTickOutcome.SkippedForSilence, pipeline.RunTickOnce(now + 500)); + Assert.Equal(before + 1, notified); + } + + [Fact] + public void 받는_쪽이_던져도_틱은_결과를_게시하고_예외를_내지_않는다() + { + var model = new FakeYamnetModel { TopClass = GunshotIndex }; + var clock = new ManualClock(); + int notified = 0; + using var pipeline = new RealtimeAiPipeline(16000, 1, model, Names, clock: clock.Now, runScheduler: false, + classificationChanged: () => + { + notified++; + throw new InvalidOperationException("HUD is broken"); + }); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(PipelineTestAudio.WindowFrames(16000))); + + // HUD 의 결함이 추론 스레드를 끝내면 화면의 색 구분까지 멈춘다. + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(250)); + + Assert.Equal(2, notified); + Assert.Equal(SoundType.Danger, pipeline.LastClassification()?.Coarse); + Assert.Equal(new RealtimeAiPipeline.InferenceStats(2, 2, 0), pipeline.Stats); + } + + [Fact] + public void 알림은_추론_잠금을_놓은_뒤에_온다() + { + // 받는 쪽이 알림 안에서 파이프라인을 닫아도(오버레이가 닫히는 중) 추론 잠금에서 서로 기다리지 않는다. 잠금 안에서 알리면 + // 닫기가 "돌고 있는 추론"을 1초 기다린 뒤 포기한다. + var model = new FakeYamnetModel { TopClass = GunshotIndex }; + var clock = new ManualClock(); + RealtimeAiPipeline? pipeline = null; + double closeMs = -1; + pipeline = new RealtimeAiPipeline(16000, 1, model, Names, clock: clock.Now, runScheduler: false, + classificationChanged: () => + { + // 다른 스레드에서 닫는다. 같은 스레드에서는 잠금을 다시 잡을 수 있어 잠금 안팎을 가르지 못한다. + long started = System.Diagnostics.Stopwatch.GetTimestamp(); + var close = new Thread(pipeline!.Dispose); + close.Start(); + close.Join(); + closeMs = System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds; + }); + pipeline.Start(); + pipeline.Ingest(PipelineTestAudio.Sine(PipelineTestAudio.WindowFrames(16000))); + + Assert.Equal(AiTickOutcome.Completed, pipeline.RunTickOnce(0)); + + Assert.InRange(closeMs, 0, RealtimeAiPipeline.CloseWaitMs / 2); + Assert.Equal(1, model.DisposeCalls); + Assert.False(model.DisposedDuringInference); + } +} diff --git a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs index 93309e2..cfcb4a5 100644 --- a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs +++ b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs @@ -64,6 +64,49 @@ public void 첫_대화음_후보_뒤의_새_소리를_Silero_가_확인하면_ Assert.Same(confirmed, h.Pipeline.LastDisplayClassification()); } + [Fact] + public void 미리보기를_게시하면_그_자리에서_알려_HUD_가_타이머를_기다리지_않고_읽는다() + { + // 미리보기는 다음 정규 결과가 오면 사라진다. 받아들인 때가 늦으면 100ms 도 못 살아, 100ms 타이머로만 읽는 HUD 와 기록에는 + // 한 번도 남지 않을 수 있다(Android #412). 게시한 미리보기 스레드가 바로 알리고, 받는 쪽이 읽으면 미리보기가 이미 보인다. + using var h = new Harness(16000, 1); + var seen = new System.Collections.Concurrent.ConcurrentQueue<(string? Thread, bool Preview)>(); + h.FirstSpeechCandidate(); + h.OnChanged = () => seen.Enqueue((Thread.CurrentThread.Name, h.Pipeline.LastDisplayClassification()?.SpeechPreview == true)); + + long fresh = h.Frames; + h.FeedFreshBlocks(2, fresh); + Assert.Empty(seen); // 덩어리마다 알리지 않는다. 화면용 결과가 바뀔 때만이다 + h.FeedFreshBlocks(3, fresh); + + var notified = Assert.Single(seen); + Assert.Equal("SV-SileroPreview", notified.Thread); + Assert.True(notified.Preview); + + // 다음 정규 결과가 미리보기를 대신할 때도 알린다(틱을 돌린 이 스레드에서). + Assert.Equal(AiTickOutcome.Completed, h.Pipeline.RunTickOnce(250)); + Assert.Equal(2, seen.Count); + Assert.False(seen.Last().Preview); + } + + [Fact] + public void 알림을_받는_쪽이_던져도_미리보기_스레드는_끝나지_않는다() + { + using var h = new Harness(16000, 1); + h.OnChanged = () => throw new InvalidOperationException("HUD is broken"); + h.FirstSpeechCandidate(); + h.FeedFreshBlocks(3, h.Frames); + + // HUD 의 결함이 미리보기를 끄면 안 된다. 미리보기는 게시됐고 스레드는 다음 덩어리를 계속 본다. + Assert.True(h.Pipeline.LastDisplayClassification()?.SpeechPreview); + Assert.False(h.Worker.Failed); + long calls = h.Worker.Calls; + h.Feed(Block * 2); + h.WaitCaughtUp(); + Assert.True(h.Worker.IsAlive); + Assert.True(h.Worker.Calls > calls); + } + [Fact] public void 미리보기는_PC_의_실제_틱_주기에서도_다음_정규_결과까지_이어진다() { @@ -599,10 +642,13 @@ public Harness(int rate, int channels, Func> Channels = channels; Silero = silero ?? new FakeSileroModel(); Pipeline = new RealtimeAiPipeline(rate, channels, Yamnet, Names, overrides, clock: Clock.Now, runScheduler: false, - speechModel: () => Silero, nanoClock: Nanos.Now); + speechModel: () => Silero, nanoClock: Nanos.Now, classificationChanged: () => OnChanged?.Invoke()); Pipeline.Start(); } + /// 파이프라인이 결과 알림을 보낼 때 그 스레드(틱을 돌린 테스트 스레드나 미리보기 스레드)에서 부른다. + public Action? OnChanged { get; set; } + public int Rate { get; } public int Channels { get; } diff --git a/SoundVisualizer.Tests/Diagnostics/AiHudRefreshTests.cs b/SoundVisualizer.Tests/Diagnostics/AiHudRefreshTests.cs new file mode 100644 index 0000000..71b100f --- /dev/null +++ b/SoundVisualizer.Tests/Diagnostics/AiHudRefreshTests.cs @@ -0,0 +1,206 @@ +// 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.Diagnostics; + +namespace SoundVisualizer.Tests.Diagnostics; + +/// +/// AI 의 결과 알림을 개발자 HUD 의 읽기로 바꾸는 규칙(모바일 AiHudRefreshTest, #412/#413). 알림은 타이머를 기다리지 않고, 몰려 와도 +/// UI 스레드에 하나만 쌓이며, HUD 를 읽지 않는 동안에는 아무것도 넘기지 않는다. UI 스레드는 넘겨받은 일을 담아 두었다 돌리는 줄로 흉내 낸다. +/// +public class AiHudRefreshTests +{ + /// 넘겨받은 일을 담아 두는 UI 스레드 흉내. 테스트가 원하는 때에 하나씩 돌린다. + private sealed class FakeUiThread + { + private readonly ConcurrentQueue _posted = new(); + + public int Pending => _posted.Count; + + public void Post(Action action) => _posted.Enqueue(action); + + public bool RunOne() + { + if (!_posted.TryDequeue(out var action)) return false; + action(); + return true; + } + + public int RunAll() + { + int ran = 0; + while (RunOne()) ran++; + return ran; + } + } + + [Fact] // notificationDoesNotWaitForHeartbeat + public void 알림은_타이머를_기다리지_않고_바로_읽기를_넘긴다() + { + var ui = new FakeUiThread(); + int reads = 0; + var refresh = new AiHudRefresh(ui.Post, () => reads++) { Active = true }; + + refresh.Notify(); + + // 알린 스레드에서는 읽지 않는다(추론 스레드가 HUD 의 일을 하지 않는다). UI 스레드가 돌릴 때 읽는다. + Assert.Equal(0, reads); + Assert.Equal(1, ui.Pending); + ui.RunAll(); + Assert.Equal(1, reads); + } + + [Fact] // slowHudCoalescesBurstsAndReadsNewestValue + public void 몰려_온_알림은_하나로_합치고_읽을_때는_가장_새_값을_읽는다() + { + var ui = new FakeUiThread(); + int latest = 0; + var seen = new List(); + var refresh = new AiHudRefresh(ui.Post, () => seen.Add(latest)) { Active = true }; + + for (int i = 1; i <= 1000; i++) + { + latest = i; + refresh.Notify(); + } + + // UI 스레드가 바쁜 동안 결과 천 개가 지나가도 쌓인 읽기는 하나다. + Assert.Equal(1, ui.Pending); + ui.RunAll(); + Assert.Equal(new[] { 1000 }, seen); + } + + [Fact] + public void 읽는_사이에_온_알림은_한_번_더_넘겨_새_결과를_놓치지_않는다() + { + var ui = new FakeUiThread(); + int latest = 1; + var seen = new List(); + AiHudRefresh? refresh = null; + refresh = new AiHudRefresh(ui.Post, () => + { + seen.Add(latest); + if (latest == 1) + { + // 값을 읽은 바로 뒤에 다음 결과가 게시됐다. 이 알림을 "이미 넘겼다"로 버리면 2 는 다음 타이머까지 보이지 않는다. + latest = 2; + refresh!.Notify(); + refresh.Notify(); + } + }) + { Active = true }; + + refresh.Notify(); + Assert.Equal(2, ui.RunAll()); + + Assert.Equal(new[] { 1, 2 }, seen); + Assert.Equal(0, ui.Pending); + } + + [Fact] // pauseUnsubscribesAndResumeReadsCurrentSource + public void HUD_를_읽지_않는_동안에는_넘기지_않고_넘겨_둔_읽기도_하지_않는다() + { + var ui = new FakeUiThread(); + int reads = 0; + var refresh = new AiHudRefresh(ui.Post, () => reads++); + + // 개발자 모드가 꺼져 있으면 추론마다 UI 스레드를 깨우지 않는다. + Assert.False(refresh.Active); + refresh.Notify(); + Assert.Equal(0, ui.Pending); + + // 넘긴 뒤 돌기 전에 꺼졌으면(개발자 모드를 끄거나 오버레이를 닫았다) 읽지 않는다. + refresh.Active = true; + refresh.Notify(); + refresh.Active = false; + ui.RunAll(); + Assert.Equal(0, reads); + + // 다시 켜면 다음 알림부터 넘긴다. 꺼져 있던 동안의 표시가 남아 막지 않는다. + refresh.Active = true; + refresh.Notify(); + Assert.Equal(1, ui.Pending); + ui.RunAll(); + Assert.Equal(1, reads); + } + + [Fact] + public void 넘기다_실패해도_알린_스레드로_던지지_않고_다음_알림은_다시_넘긴다() + { + var ui = new FakeUiThread(); + bool broken = true; + int reads = 0; + var refresh = new AiHudRefresh( + action => + { + if (broken) throw new InvalidOperationException("dispatcher is gone"); + ui.Post(action); + }, + () => reads++) + { Active = true }; + + // 추론 스레드로 예외가 새면 그 틱이나 미리보기 스레드가 끝난다. + refresh.Notify(); + refresh.Notify(); + Assert.Equal(0, ui.Pending); + + broken = false; + refresh.Notify(); + Assert.Equal(1, ui.Pending); + ui.RunAll(); + Assert.Equal(1, reads); + } + + [Fact] + public void 여러_스레드가_한꺼번에_알려도_마지막_결과는_반드시_읽힌다() + { + // 틱 스레드와 미리보기 스레드가 함께 알린다. 어느 알림이든 돌아온 뒤에는 그 뒤에 도는 읽기가 하나는 남아 있어야 한다. + var ui = new FakeUiThread(); + long latest = 0; + long seen = 0; + int reads = 0; + var refresh = new AiHudRefresh(ui.Post, () => + { + Interlocked.Exchange(ref seen, Interlocked.Read(ref latest)); + Interlocked.Increment(ref reads); + }) + { Active = true }; + + const int perThread = 20_000; + using var stop = new CancellationTokenSource(); + var uiThread = new Thread(() => + { + while (!stop.IsCancellationRequested) + { + if (!ui.RunOne()) Thread.Yield(); + } + }); + uiThread.Start(); + var notifiers = Enumerable.Range(0, 2).Select(_ => new Thread(() => + { + for (int i = 0; i < perThread; i++) + { + Interlocked.Increment(ref latest); + refresh.Notify(); + } + })).ToArray(); + foreach (var thread in notifiers) thread.Start(); + foreach (var thread in notifiers) thread.Join(); + stop.Cancel(); + uiThread.Join(); + ui.RunAll(); + + Assert.Equal(2L * perThread, Interlocked.Read(ref seen)); + // 알림 4만 번이 읽기 4만 번이 되지 않는다(합쳐진다). 몇 번인지는 실행마다 다르다. + Assert.InRange(Volatile.Read(ref reads), 1, 2 * perThread); + } + + [Fact] + public void 넘길_곳과_읽을_것이_없으면_만들지_않는다() + { + Assert.Throws(() => new AiHudRefresh(null!, () => { })); + Assert.Throws(() => new AiHudRefresh(_ => { }, null!)); + } +} diff --git a/SoundVisualizer.Tests/Ui/AppSource.cs b/SoundVisualizer.Tests/Ui/AppSource.cs new file mode 100644 index 0000000..70bd3fa --- /dev/null +++ b/SoundVisualizer.Tests/Ui/AppSource.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.Tests.Ui; + +/// +/// 앱(SoundVisualizer 프로젝트)의 소스를 읽는 시험이 같이 쓰는 도우미. 런처와 오버레이는 WPF 창이라 단위 시험에서 띄우지 않고, +/// 화면과 규칙을 잇는 배선은 소스를 읽어 지킨다. +/// +internal static class AppSource +{ + /// SoundVisualizer 프로젝트 안의 파일 하나. + public static string Read(params string[] parts) => + File.ReadAllText(Path.Combine(new[] { RepositoryPaths.Root, "SoundVisualizer" }.Concat(parts).ToArray())); + + /// + /// 에서 이름이 인 void 메서드의 본문(여는 중괄호부터 짝이 맞는 닫는 중괄호까지). + /// 부르는 곳이 아니라 정의를 찾도록 "void 이름(" 으로 찾는다. 문자열이나 주석 안의 중괄호는 가리지 않으므로, 본문에 짝이 맞지 않는 + /// 중괄호 글자가 든 메서드에는 쓰지 않는다. + /// + public static string MethodBody(string source, string name) + { + 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/DebugHudSourceTests.cs b/SoundVisualizer.Tests/Ui/DebugHudSourceTests.cs new file mode 100644 index 0000000..f4fe3bf --- /dev/null +++ b/SoundVisualizer.Tests/Ui/DebugHudSourceTests.cs @@ -0,0 +1,46 @@ +// 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; + +/// +/// 오버레이가 AI 의 결과 알림을 개발자 HUD 의 읽기로 잇는 배선(MainWindow.xaml.cs, MainWindow.DebugHud.cs)을 소스로 확인한다. +/// 알림을 합치는 규칙은 AiHudRefreshTests 가, 파이프라인이 언제 알리는지는 RealtimeAiPipelineChangeNotificationTests 가 본다. +/// 오버레이는 화면 전체를 덮는 WPF 창이고 출력 장치가 있어야 떠서 단위 시험에서 띄우지 않는다. +/// +public class DebugHudSourceTests +{ + [Fact] + public void 오버레이는_파이프라인을_만들_때_결과_알림을_HUD_읽기에_잇는다() + { + // 100ms 타이머만으로는 그보다 짧게 사는 말소리 미리보기가 화면과 기록에서 통째로 빠진다(안드로이드 #412). + string main = AppSource.Read("MainWindow.xaml.cs"); + int create = main.IndexOf("RealtimeAiPipeline.Create(", StringComparison.Ordinal); + Assert.True(create >= 0, "RealtimeAiPipeline.Create 호출을 찾지 못했습니다."); + string call = main.Substring(create, main.IndexOf(";", create, StringComparison.Ordinal) - create); + Assert.Contains("classificationChanged: _hudRefresh.Notify", call); + + // 추론 스레드는 UI 스레드를 기다리지 않는다(Invoke 가 아니라 InvokeAsync). 읽는 일은 타이머와 같은 PollDebugHud 다. + Assert.Contains("_hudRefresh = new AiHudRefresh(read => Dispatcher.InvokeAsync(read), PollDebugHud);", main); + // AI 는 BootSequence 가 배경에서 만든다. 그 전에 채워 두어야 한다. + int assigned = main.IndexOf("_hudRefresh = new AiHudRefresh(", StringComparison.Ordinal); + int boot = main.IndexOf("BootSequence();", StringComparison.Ordinal); + Assert.True(assigned >= 0 && assigned < boot, "AI 를 만들기 전에 _hudRefresh 를 채워야 합니다."); + } + + [Fact] + public void 결과_알림은_HUD_를_읽는_동안에만_받고_타이머는_그대로_둔다() + { + string hud = AppSource.Read("MainWindow.DebugHud.cs"); + + // 개발자 모드가 꺼져 있으면 추론마다 UI 스레드를 깨우지 않는다. 기록은 HUD 를 읽는 동안에만 돈다. + string sync = AppSource.MethodBody(hud, "SyncDebugHud"); + Assert.Contains("_hudRefresh.Active = _debugHud.IsPolling;", sync); + // 결과가 오지 않는 동안(무음)의 나이와 소리 크기는 타이머가 읽는다. + Assert.Contains("_debugHudTimer.Start();", sync); + Assert.Contains("_debugHudTimer.Tick += (s, e) => PollDebugHud();", hud); + + // 창을 닫으면 더 받지 않는다. + Assert.Contains("_hudRefresh.Active = false;", AppSource.MethodBody(hud, "InitDebugHud")); + } +} diff --git a/SoundVisualizer/MainWindow.DebugHud.cs b/SoundVisualizer/MainWindow.DebugHud.cs index fa0bfb6..891bccb 100644 --- a/SoundVisualizer/MainWindow.DebugHud.cs +++ b/SoundVisualizer/MainWindow.DebugHud.cs @@ -14,12 +14,17 @@ namespace SoundVisualizer // 개발자 모드 HUD(안드로이드 AiDebugOverlay): 오버레이 왼쪽 위에 AI 가 말한 것을 그대로 띄우고, 켜 두었으면 결과를 CSV 로 남긴다. // 렌더 루프와 따로 100ms 타이머로 돈다. 그래서 보이는 것이 없어 오버레이가 쉬는 동안에도 HUD 는 멈추지 않는다(나이가 자라는 것이 // "지금 추론이 돌지 않는다" 는 신호다). 언제 읽고 언제 기록할지는 Core 의 AiDebugHud 가 정하고, 여기는 화면과 타이머만 맡는다. + // AI 가 결과를 게시하면 타이머를 기다리지 않고 바로 한 번 더 읽는다(_hudRefresh, 안드로이드 #412). 100ms 도 못 사는 말소리 미리보기가 + // 화면과 기록에서 통째로 빠지지 않게 한다. public partial class MainWindow { // 지금 소리 크기. 캡처 스레드가 버퍼마다 넣고(HandleAudioDataAsync) HUD 가 읽는다. private readonly CaptureLevel _level = new(); private readonly AiDebugHud _debugHud = new(); private readonly DispatcherTimer _debugHudTimer = new() { Interval = TimeSpan.FromMilliseconds(AiDebugHud.PollIntervalMs) }; + // AI 파이프라인이 결과를 게시할 때 추론 스레드에서 Notify 를 부른다. HUD 를 읽는 동안에만 UI 스레드에 읽기 하나를 넘긴다. + // 생성자가 AI 를 만들기(BootSequence) 전에 채운다. + private readonly AiHudRefresh _hudRefresh; // 지금 HUD 에 넣어 둔 글자. 같으면 다시 넣지 않는다. private AiDebugLines? _debugHudLines; @@ -29,6 +34,7 @@ private void InitDebugHud() _debugHudTimer.Tick += (s, e) => PollDebugHud(); Closed += (s, e) => { + _hudRefresh.Active = false; _debugHudTimer.Stop(); // 기록 파일을 닫는다. 남은 줄을 쓰기를 1초까지 기다린다. 런처를 닫을 때도 이 창을 먼저 닫으므로, 앱이 끝나며 받아 둔 줄을 잃지 않는다. _debugHud.Dispose(); @@ -43,6 +49,9 @@ private void InitDebugHud() private void SyncDebugHud() { if (!_debugHud.Update(AppSettings.IsAdminMode, AppSettings.DeveloperRecord)) return; + // HUD 를 읽는 동안에만 결과 알림을 받는다. 기록은 HUD 를 읽는 동안에만 돌므로 이것으로 둘 다 가린다. 타이머는 그대로 돈다. + // 결과가 오지 않는 동안(무음)의 나이와 소리 크기는 타이머가 읽는다. + _hudRefresh.Active = _debugHud.IsPolling; if (_debugHud.IsPolling) { PollDebugHud(); // 첫 타이머를 기다리지 않고 지금 값으로 채워 빈 판이 잠깐 보이지 않게 한다 @@ -57,7 +66,8 @@ private void SyncDebugHud() } /// - /// HUD 를 한 번 읽는다(100ms 마다). 결과·소리 크기·표시 여부·AI 상태를 이 순간 한 번씩만 읽어 화면과 기록이 같은 값을 쓴다. + /// HUD 를 한 번 읽는다(100ms 마다, 그리고 AI 가 결과를 게시할 때마다). 결과·소리 크기·표시 여부·AI 상태를 이 순간 한 번씩만 읽어 + /// 화면과 기록이 같은 값을 쓴다. 같은 결과를 거듭 읽어도 기록에는 한 줄만 남는다(AiDebugRecorder). /// private void PollDebugHud() { diff --git a/SoundVisualizer/MainWindow.xaml.cs b/SoundVisualizer/MainWindow.xaml.cs index 8e08696..adab5a3 100644 --- a/SoundVisualizer/MainWindow.xaml.cs +++ b/SoundVisualizer/MainWindow.xaml.cs @@ -16,6 +16,7 @@ using SoundVisualizer.CoreAudio; using SoundVisualizer.DSP; using SoundVisualizer.Ai; +using SoundVisualizer.Diagnostics; using SoundVisualizer.Localization; using SoundVisualizer.Rendering; using SoundVisualizer.Ui; @@ -121,6 +122,8 @@ public MainWindow() { _pollIdleNow = PollIdle; _idleTimer.Tick += (s, e) => PollIdle(); + // AI 의 결과 알림을 HUD 읽기로 바꾼다(MainWindow.DebugHud.cs). 추론 스레드는 UI 스레드를 기다리지 않는다. + _hudRefresh = new AiHudRefresh(read => Dispatcher.InvokeAsync(read), PollDebugHud); InitializeComponent(); LanguagePicker.Fill(CmbLanguage); ColorButtonNames.Apply(EditPanelAmbientColorBtn, EditPanelSpeechColorBtn, EditPanelDangerColorBtn); @@ -630,7 +633,8 @@ private void RequestAiPipeline(int rate, int channels) 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)); + tickFailed: ex => AppLog.Warn("ai", "AI 추론 한 번이 실패했습니다.", ex), + classificationChanged: _hudRefresh.Notify); RealtimeAiPipeline? replaced = null; bool discard; lock (_aiGate) From 48e0604ab23b571738ac06555724aad286a619c2 Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 15:39:17 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EC=89=AC=EC=9A=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=97=90=EC=84=9C=EB=8A=94=20=ED=99=88=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EA=B0=80=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8A=94=20=EC=8A=A4=EC=9C=84=EC=B9=98=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=84=20=EC=9D=B8=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 를 쓸 수 없고 환경음 표시도 꺼 둔 때의 안내는 "설정 탭에서 ‘환경음 표시’ 스위치를 켜세요"라고 했다. 쉬운 설정의 줄에는 종류 이름 "환경음"만 적혀 있어, 그 화면을 쓰는 사람은 화면에 없는 이름을 찾아야 했다. 설정 탭이 쉬운 설정이면 종류 이름을 인용하고, 두 화면을 오가면 떠 있는 안내도 새 화면의 이름으로 바꾼다. 문자열 표는 그대로다. --- SoundVisualizer.Core/Status/RunStatus.cs | 10 ++ .../Status/HomeNoticeSwitchNameTests.cs | 96 +++++++++++++++++++ .../LauncherWindow.EasySettings.cs | 3 + SoundVisualizer/LauncherWindow.Status.cs | 4 +- 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 SoundVisualizer.Tests/Status/HomeNoticeSwitchNameTests.cs diff --git a/SoundVisualizer.Core/Status/RunStatus.cs b/SoundVisualizer.Core/Status/RunStatus.cs index a716215..c9abc9d 100644 --- a/SoundVisualizer.Core/Status/RunStatus.cs +++ b/SoundVisualizer.Core/Status/RunStatus.cs @@ -78,5 +78,15 @@ public HomeNotice Notice(bool showAmbient) if (IsRunning) return AiAvailable ? HomeNotice.None : showAmbient ? HomeNotice.AiUnavailable : HomeNotice.AiUnavailableHidden; return LastUnexpectedStop != null ? HomeNotice.Stopped : HomeNotice.None; } + + /// + /// 안내가 "설정 탭에서 ‘…’ 스위치를 켜세요"라고 인용할 스위치 이름의 문자열 키. + /// 설정 탭에 지금 보이는 화면의 스위치에 적힌 글자여야 찾아 누를 수 있다. 자세한 설정의 스위치는 "환경음 표시" + /// (settings.show_ambient)이고, 쉬운 설정의 줄에는 종류 이름 "환경음"(sounds.type.ambient)만 적혀 있다. 예전에는 늘 자세한 설정의 + /// 이름을 인용해, 쉬운 설정을 쓰는 사람은 화면에 없는 이름의 스위치를 찾아야 했다. + /// + /// 설정 탭이 쉬운 설정 화면인지(AppSettings.EasySettings). + public static string AmbientSwitchLabelKey(bool easySettings) => + easySettings ? "sounds.type.ambient" : "settings.show_ambient"; } } diff --git a/SoundVisualizer.Tests/Status/HomeNoticeSwitchNameTests.cs b/SoundVisualizer.Tests/Status/HomeNoticeSwitchNameTests.cs new file mode 100644 index 0000000..779d9dd --- /dev/null +++ b/SoundVisualizer.Tests/Status/HomeNoticeSwitchNameTests.cs @@ -0,0 +1,96 @@ +// 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.Xml.Linq; +using SoundVisualizer.Localization; +using SoundVisualizer.Status; +using SoundVisualizer.Tests.Ui; + +namespace SoundVisualizer.Tests.Status; + +/// +/// 홈 안내 "…설정 탭에서 ‘…’ 스위치를 켜세요"(notice.ai_unavailable_hidden)가 인용하는 스위치 이름. 인용한 글자가 설정 탭에 지금 보이는 +/// 화면에 그대로 적혀 있어야 찾아 누를 수 있다. 자세한 설정의 스위치는 "환경음 표시", 쉬운 설정의 줄은 "환경음"이다. +/// 런처는 WPF 창이라 배선은 소스를 읽어 확인한다. +/// +public class HomeNoticeSwitchNameTests +{ + private const string NoticeKey = "notice.ai_unavailable_hidden"; + + private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml"; + + public static TheoryData Codes() + { + var data = new TheoryData(); + foreach (var code in StringTables.AvailableCodes) data.Add(code); + return data; + } + + private static string Get(string code, string key) + { + Assert.True(StringTables.Get(code)!.TryGet(key, out var text), $"{code}: '{key}' 가 없습니다."); + return text; + } + + private static string? ContentOf(string xaml, string name) => + (string?)XDocument.Parse(xaml).Descendants().Single(e => (string?)e.Attribute(Xaml + "Name") == name).Attribute("Content"); + + [Fact] + public void 인용하는_이름은_설정_탭에_지금_보이는_화면의_스위치에_적힌_글자다() + { + string detailedKey = RunStatus.AmbientSwitchLabelKey(easySettings: false); + string easyKey = RunStatus.AmbientSwitchLabelKey(easySettings: true); + + // 자세한 설정의 스위치와 쉬운 설정의 줄에 실제로 적힌 글자의 키와 같아야 한다. + Assert.Equal("{l:Loc " + detailedKey + "}", ContentOf(AppSource.Read("LauncherWindow.xaml"), "ChkShowAmbient")); + Assert.Equal("{l:Loc " + easyKey + "}", ContentOf(AppSource.Read("Ui", "EasySettingsView.xaml"), "ChkAmbient")); + Assert.NotEqual(detailedKey, easyKey); + } + + [Theory] + [MemberData(nameof(Codes))] + public void 안내는_어느_언어에서나_그_화면에_보이는_이름을_그대로_인용한다(string code) + { + string notice = Get(code, NoticeKey); + // 이름은 문장에 한 번만 끼운다(따옴표 안의 {0}). 문구를 고칠 때 자리가 사라지지 않게 한다. + Assert.Single(System.Text.RegularExpressions.Regex.Matches(notice, @"\{0\}")); + + foreach (bool easySettings in new[] { false, true }) + { + string label = Get(code, RunStatus.AmbientSwitchLabelKey(easySettings)); + string sentence = string.Format(CultureInfo.InvariantCulture, notice, label); + Assert.Contains(label, sentence); + } + } + + [Fact] + public void 한국어와_영어에서는_쉬운_설정의_안내가_종류_이름만_인용한다() + { + // 독일어·이탈리아어·스페인어처럼 두 화면의 이름이 같은 언어도 있다. 이름이 다른 언어에서 쉬운 설정의 안내가 달라지는지를 + // 두 언어의 실제 문장으로 본다. + string Sentence(string code, bool easySettings) => + string.Format(CultureInfo.InvariantCulture, Get(code, NoticeKey), Get(code, RunStatus.AmbientSwitchLabelKey(easySettings))); + + Assert.Contains("‘환경음 표시’ 스위치", Sentence("ko", easySettings: false)); + Assert.Contains("‘환경음’ 스위치", Sentence("ko", easySettings: true)); + Assert.DoesNotContain("환경음 표시’", Sentence("ko", easySettings: true)); + Assert.Contains("“Show ambient sounds”", Sentence("en", easySettings: false)); + Assert.Contains("“Ambient”", Sentence("en", easySettings: true)); + } + + [Fact] + public void 런처는_지금_화면의_이름을_인용하고_화면을_바꾸면_안내를_다시_넣는다() + { + string notices = AppSource.MethodBody(AppSource.Read("LauncherWindow.Status.cs"), "UpdateNotices"); + Assert.Contains("Loc.Get(RunStatus.AmbientSwitchLabelKey(AppSettings.EasySettings))", notices); + // 예전에는 늘 자세한 설정의 이름을 인용했다. 쉬운 설정을 쓰는 사람은 화면에 없는 "환경음 표시" 스위치를 찾아야 했다. + Assert.DoesNotContain("Loc.Get(\"settings.show_ambient\")", notices); + + // 안내가 떠 있는 채로 쉬운 설정과 자세한 설정을 오가면 인용한 이름도 새 화면의 것으로 바뀌어야 한다. + string setView = AppSource.MethodBody(AppSource.Read("LauncherWindow.EasySettings.cs"), "SetSettingsView"); + int show = setView.IndexOf("ShowSettingsView();", StringComparison.Ordinal); + int update = setView.IndexOf("UpdateNotices();", StringComparison.Ordinal); + Assert.True(show >= 0 && update > show, "화면을 바꾼 뒤에 UpdateNotices 를 불러야 합니다."); + } +} diff --git a/SoundVisualizer/LauncherWindow.EasySettings.cs b/SoundVisualizer/LauncherWindow.EasySettings.cs index c276656..7cc2c76 100644 --- a/SoundVisualizer/LauncherWindow.EasySettings.cs +++ b/SoundVisualizer/LauncherWindow.EasySettings.cs @@ -47,6 +47,9 @@ private void SetSettingsView(bool easy) AppSettings.Save(); } ShowSettingsView(); + // 홈 안내가 인용하는 스위치 이름은 화면마다 다르다(RunStatus.AmbientSwitchLabelKey). 안내가 떠 있으면 새 화면의 이름으로 바꾼다. + // 안내의 종류는 그대로라 화면 읽어주기가 다시 읽지는 않는다. + UpdateNotices(); } /// 쉬운 설정에서 값을 바꿨다(이미 AppSettings 에 쓰고 저장을 예약했다). 자세한 설정의 칸과 소리 탭, 홈 안내를 맞춘다. diff --git a/SoundVisualizer/LauncherWindow.Status.cs b/SoundVisualizer/LauncherWindow.Status.cs index 1990d01..9f22188 100644 --- a/SoundVisualizer/LauncherWindow.Status.cs +++ b/SoundVisualizer/LauncherWindow.Status.cs @@ -153,7 +153,9 @@ private void UpdateNotices() string aiText = notice switch { HomeNotice.AiUnavailable => Loc.Get("notice.ai_unavailable"), - HomeNotice.AiUnavailableHidden => Loc.Format("notice.ai_unavailable_hidden", Loc.Get("settings.show_ambient")), + // 설정 탭에 지금 보이는 화면의 스위치 이름을 인용한다. 쉬운 설정의 줄에는 "환경음 표시"가 아니라 "환경음"만 적혀 있다. + HomeNotice.AiUnavailableHidden => Loc.Format("notice.ai_unavailable_hidden", + Loc.Get(RunStatus.AmbientSwitchLabelKey(AppSettings.EasySettings))), _ => "", }; TxtAiNotice.Text = aiText; From 04f4f091bc309d19acb5f237ba11deea18f3c6b9 Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 15:42:39 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=EC=89=AC=EC=9A=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=B4=20=EB=B3=B4=EC=9D=B4=EA=B2=8C=20=EB=90=98?= =?UTF-8?q?=EB=A9=B4=20=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B7=B8=20=EC=9E=90=EB=A6=AC=EC=97=90=EC=84=9C=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 보이게 될 때 값을 다시 읽는 일을 모두 배치 뒤(Loaded)로 미뤄서, 자세한 설정에서 모양·크기·소리 종류를 바꾸고 넘어오면 한 프레임 동안 이전 선택이 보였다. Loaded 는 그리기보다 늦다. 선택 상태(이름 붙인 요소의 속성)는 보이게 된 그때 맞추고, 배치가 있어야 하는 카드 그림 다시 그리기·움직이기와 화면 읽어주기 알림만 미룬다. 서식이 붙지 않은 카드의 그림은 전처럼 건너뛴다. --- .../Ui/EasySettingsSourceTests.cs | 40 +++++++++ SoundVisualizer/Ui/EasySettingsView.xaml.cs | 81 ++++++++++++++----- 2 files changed, 99 insertions(+), 22 deletions(-) diff --git a/SoundVisualizer.Tests/Ui/EasySettingsSourceTests.cs b/SoundVisualizer.Tests/Ui/EasySettingsSourceTests.cs index 6403faa..7969b04 100644 --- a/SoundVisualizer.Tests/Ui/EasySettingsSourceTests.cs +++ b/SoundVisualizer.Tests/Ui/EasySettingsSourceTests.cs @@ -84,6 +84,46 @@ public void 모양_카드는_Windows_애니메이션_효과가_켜져_있고_보 Assert.Contains("FlowDirection = FlowDirection.LeftToRight;", preview); } + [Fact] + public void 보이게_되면_선택_상태는_그_자리에서_맞추고_그림과_움직임만_배치_뒤로_미룬다() + { + // 예전에는 모두 배치 뒤(Loaded)로 미뤘다. Loaded 는 그리기보다 늦어, 자세한 설정에서 모양이나 크기를 바꾸고 넘어오면 + // 한 프레임 동안 이전 선택이 보였다. + string code = Source("Ui", "EasySettingsView.xaml.cs"); + Assert.Contains("IsVisibleChanged += (s, e) => OnVisibleChanged();", code); + + string shown = AppSource.MethodBody(code, "OnVisibleChanged"); + int immediate = shown.IndexOf("ReloadSelection();", StringComparison.Ordinal); + int deferred = shown.IndexOf("Dispatcher.InvokeAsync(", StringComparison.Ordinal); + Assert.True(immediate >= 0 && deferred > immediate, "선택 상태는 미루기 전에 그 자리에서 맞춥니다."); + + // 배치가 있어야 하는 일만 미룬다. 카드의 그림은 서식이 붙은 뒤에야 찾을 수 있다. + string later = shown[deferred..]; + Assert.Contains("ShowPreviews();", later); + Assert.Contains("PlaySelected();", later); + Assert.Contains("DispatcherPriority.Loaded", later); + Assert.DoesNotContain("Reload", later); + + // 그 자리에서 하는 쪽은 서식이 붙기 전에 불려도 되어야 한다. 서식 안쪽(그림)을 찾지 않고, 화면 읽어주기 알림도 보내지 않는다. + string selection = AppSource.MethodBody(code, "ReloadSelection"); + Assert.Contains("ModeCards.SelectedIndex = mode;", selection); + Assert.Contains("SizeChips.SelectedIndex = (int)s;", selection); + Assert.Contains("ChkAmbient.IsChecked = AppSettings.ShowAmbient;", selection); + Assert.DoesNotContain("PreviewAt(", selection); + Assert.DoesNotContain("FindName(", selection); + Assert.DoesNotContain("RaiseAutomationEvent", selection); + Assert.DoesNotContain("RaiseAutomationEvent", AppSource.MethodBody(code, "ShowCustomNote")); + // 그림을 그리는 쪽은 서식이 아직 없는 카드를 건너뛴다. + Assert.Contains("if (ModeCards.Items[index] is not ListBoxItem item || item.Template == null) return null;", code); + Assert.Contains("PreviewAt(i)?.Show(", AppSource.MethodBody(code, "ShowPreviews")); + + // 런처가 부르는 Reload 는 전처럼 선택과 그림을 모두 맞춘다. + string reload = AppSource.MethodBody(code, "Reload"); + Assert.Contains("ReloadSelection();", reload); + Assert.Contains("ShowPreviews();", reload); + Assert.Contains("AnnounceCustomNote();", reload); + } + [Fact] public void 체크_표시는_오른쪽에서_왼쪽으로_쓰는_언어에서도_뒤집지_않는다() { diff --git a/SoundVisualizer/Ui/EasySettingsView.xaml.cs b/SoundVisualizer/Ui/EasySettingsView.xaml.cs index 846a5dc..bbb9af9 100644 --- a/SoundVisualizer/Ui/EasySettingsView.xaml.cs +++ b/SoundVisualizer/Ui/EasySettingsView.xaml.cs @@ -38,18 +38,27 @@ public partial class EasySettingsView : UserControl public EasySettingsView() { InitializeComponent(); - // 설정 탭에 들어오거나 자세한 설정에서 넘어와 보이게 되면, 그동안 바뀐 값(오버레이 편집, 자세한 설정)을 다시 읽고 고른 카드를 움직인다. - // 배치가 끝난 뒤에 한다. 그 전에는 카드의 서식이 아직 붙지 않아 그림(ModePreview)을 찾지 못한다. - IsVisibleChanged += (s, e) => + IsVisibleChanged += (s, e) => OnVisibleChanged(); + } + + /// + /// 설정 탭에 들어오거나 자세한 설정에서 넘어와 보이게 됐다. 그동안 바뀐 값(오버레이 편집, 자세한 설정)을 다시 읽고 고른 카드를 움직인다. + /// + /// 선택 상태(모양·크기 칸, 소리 종류 줄)는 그 자리에서 맞춘다. 예전에는 모두 배치 뒤(Loaded)로 미뤘는데, Loaded 는 그리기보다 늦어 + /// 자세한 설정에서 값을 바꾸고 넘어오면 한 프레임 동안 이전 선택이 보였다. 배치 뒤로 미루는 것은 배치가 있어야 하는 일뿐이다. + /// 카드의 그림(ModePreview)은 서식이 붙은 뒤에야 찾을 수 있고, 화면 읽어주기 알림은 요소가 놓인 뒤에 보낸다. + /// + private void OnVisibleChanged() + { + if (!IsVisible) return; + ReloadSelection(); + Dispatcher.InvokeAsync(() => { if (!IsVisible) return; - Dispatcher.InvokeAsync(() => - { - if (!IsVisible) return; - Reload(); - PlaySelected(); - }, DispatcherPriority.Loaded); - }; + ShowPreviews(); + AnnounceCustomNote(); + PlaySelected(); + }, DispatcherPriority.Loaded); } /// 쉬운 설정에서 값을 바꿨다. 런처가 자세한 설정의 칸과 다른 탭을 맞춘다. @@ -70,6 +79,18 @@ public int CurrentMode /// 움직이던 그림은 설정이 바뀌었을 때만 멈춘다. /// public void Reload() + { + ReloadSelection(); + ShowPreviews(); + AnnounceCustomNote(); + } + + /// + /// 선택 상태만 맞춘다: 고른 모양 카드와 크기 칸, 단계와 다른 크기라는 안내, 소리 종류 줄의 스위치와 색, 화면 읽어주기 이름. + /// 이 화면의 XAML 에 이름 붙여 둔 요소의 속성만 바꾸고 카드의 서식 안쪽(그림)은 찾지 않으므로, 서식이 붙기 전(보이게 된 바로 그때)에 + /// 불러도 된다. + /// + private void ReloadSelection() { _loading = true; try @@ -90,17 +111,9 @@ public void Reload() ChkSpeech.IsChecked = AppSettings.ShowSpeech; ChkAmbient.IsChecked = AppSettings.ShowAmbient; - Color ambient = ParseColor(AppSettings.ColorAmbient); - Color speech = ParseColor(AppSettings.ColorSpeech); - Color danger = ParseColor(AppSettings.ColorDanger); - ChkDanger.Background = Frozen(danger); - ChkSpeech.Background = Frozen(speech); - ChkAmbient.Background = Frozen(ambient); - - for (int i = 0; i < ModeCards.Items.Count; i++) - { - PreviewAt(i)?.Show(ModeSettings(i), ambient, speech, danger); - } + ChkDanger.Background = Frozen(ParseColor(AppSettings.ColorDanger)); + ChkSpeech.Background = Frozen(ParseColor(AppSettings.ColorSpeech)); + ChkAmbient.Background = Frozen(ParseColor(AppSettings.ColorAmbient)); ApplyCodeTexts(); } @@ -110,6 +123,21 @@ public void Reload() } } + /// + /// 모양 카드의 그림을 지금 설정과 색으로 다시 그린다. 서식이 아직 붙지 않은 카드는 건너뛴다( 이 null). + /// 그런 카드는 이 화면이 보이게 된 뒤 배치가 끝나면 그린다(). + /// + private void ShowPreviews() + { + Color ambient = ParseColor(AppSettings.ColorAmbient); + Color speech = ParseColor(AppSettings.ColorSpeech); + Color danger = ParseColor(AppSettings.ColorDanger); + for (int i = 0; i < ModeCards.Items.Count; i++) + { + PreviewAt(i)?.Show(ModeSettings(i), ambient, speech, danger); + } + } + /// /// 바인딩이 아니라 코드가 넣는 화면 읽어주기 이름: 크기 제목("크기, 원형")과 소리 종류 줄("위협음 표시"). 언어가 바뀌면 런처가 부른다. /// @@ -221,11 +249,20 @@ private void SizeChips_SelectionChanged(object sender, SelectionChangedEventArgs PlaySelected(); } - /// 단계와 다른 크기면 한 줄 안내를 보이고 '자세한 설정' 칸을 고른 칸처럼 보인다. 새로 나타날 때만 화면 읽어주기가 읽는다. + /// 단계와 다른 크기면 한 줄 안내를 보이고 '자세한 설정' 칸을 고른 칸처럼 보인다. private void ShowCustomNote(bool custom) { BtnSizeDetailed.IsCurrent = custom; TxtSizeCustom.Visibility = custom ? Visibility.Visible : Visibility.Collapsed; + } + + /// + /// 단계와 다른 크기라는 안내가 새로 나타났으면 화면 읽어주기가 읽게 한다(이미 보이던 안내는 다시 읽지 않는다). + /// 보이게 된 바로 그때가 아니라 배치 뒤에 부른다(). + /// + private void AnnounceCustomNote() + { + bool custom = TxtSizeCustom.Visibility == Visibility.Visible; if (custom && !_customShown && IsVisible && AutomationPeer.ListenerExists(AutomationEvents.LiveRegionChanged)) { UIElementAutomationPeer.CreatePeerForElement(TxtSizeCustom)?.RaiseAutomationEvent(AutomationEvents.LiveRegionChanged); From 2c95fbd4c9d0d2ec02c518da54a5f6ebd64654b3 Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 15:48:39 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=EB=9F=B0=EC=B2=98=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EB=B0=94=EA=BE=B8?= =?UTF-8?q?=EB=A9=B4=20=EC=97=B4=EB=A0=A4=20=EC=9E=88=EB=8A=94=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=ED=8E=B8=EC=A7=91=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EB=8F=84=20=EC=83=88=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 편집 패널(F4)을 연 채 런처의 자세한 설정이나 쉬운 설정에서 모드·크기·스위치·색을 바꾸면 패널의 칸은 옛 값을 보였다. 그 슬라이더를 움직이면 옛 값이 새 모드에 쓰였다. 오버레이 → 런처 방향만 맞추고 있었다. - 런처가 설정을 쓰고 저장하는 곳(칸, 색, 초기화, 단축키, 쉬운 설정, 출력 장치의 채널 수)마다 오버레이에 알린다. 오버레이가 알려 온 변경을 런처의 칸에 옮기는 중에는 되돌려 알리지 않는다. - 오버레이는 패널이 열려 있을 때만 다시 채운다. 사용자가 패널에서 무언가를 끌고 있으면(마우스를 잡은 요소가 있으면) 건드리지 않고 놓은 뒤에 채운다(Core 의 EditPanelSync). 채우는 동안에는 칸의 처리기가 저장하거나 런처에 알리지 않는다. - 오버레이의 캡처 엔진이 채널 수를 먼저 바꾼 경우에도 패널의 사운드 모드 칸을 맞춘다. - 단축키 입력을 기다리는 버튼의 안내 글자는 패널을 다시 채워도 남긴다. --- SoundVisualizer.Core/EditPanelSync.cs | 50 ++++++ .../Ui/EditPanelSourceTests.cs | 148 ++++++++++++++++-- .../Ui/EditPanelSyncTests.cs | 91 +++++++++++ .../LauncherWindow.EasySettings.cs | 6 +- SoundVisualizer/LauncherWindow.Status.cs | 29 +++- SoundVisualizer/LauncherWindow.xaml.cs | 10 ++ SoundVisualizer/MainWindow.xaml.cs | 54 ++++++- 7 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 SoundVisualizer.Core/EditPanelSync.cs create mode 100644 SoundVisualizer.Tests/Ui/EditPanelSyncTests.cs diff --git a/SoundVisualizer.Core/EditPanelSync.cs b/SoundVisualizer.Core/EditPanelSync.cs new file mode 100644 index 0000000..d5dab00 --- /dev/null +++ b/SoundVisualizer.Core/EditPanelSync.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 +{ + /// + /// 오버레이의 편집 패널(F4)을 패널 밖에서 바뀐 설정으로 언제 다시 채울지 정한다. + /// + /// 편집 패널을 연 채로 런처(자세한 설정, 쉬운 설정)에서 모드·크기·색을 바꾸면 패널의 칸은 옛 값을 보인다. 그대로 두면 사용자가 + /// 옛 값을 보이는 슬라이더를 움직일 때 그 값이 새 모드에 쓰인다. 그래서 런처가 설정을 바꿀 때마다 패널을 다시 채워 달라고 한다 + /// (). + /// + /// 다만 사용자가 패널에서 무언가를 끌고 있는 동안에는 칸을 건드리지 않는다. 끄는 슬라이더의 값이 손 밑에서 바뀌면 다음 움직임이 + /// 엉뚱한 값을 쓴다. 그때는 기억해 두었다가 놓은 뒤에 채운다(). 패널이 닫혀 있으면 아무것도 하지 않는다. + /// 패널은 열 때마다 스스로 채운다. + /// + /// 화면은 오버레이(MainWindow)가 맡고, 여기는 채울 차례인지만 정해 창 없이 검사한다(EditPanelSyncTests). UI 스레드에서만 쓴다. + /// + public sealed class EditPanelSync + { + /// 끌기가 끝나면 채우려고 미뤄 둔 요청이 있는지. + public bool IsPending { get; private set; } + + /// 패널 밖에서 설정이 바뀌었다. + /// 편집 패널이 열려 있는지. + /// 사용자가 패널에서 무언가를 끌고 있는지(슬라이더, 가이드라인, 크기 손잡이, 펼친 목록). + /// 지금 다시 채울 차례면 true. 끄는 중이면 미뤄 두고 false. + public bool Request(bool panelOpen, bool dragging) + { + if (!panelOpen) + { + IsPending = false; + return false; + } + IsPending = dragging; + return !dragging; + } + + /// 사용자가 끌던 것을 놓았다. + /// 미뤄 둔 요청이 있고 지금 채울 수 있으면 true. 아직 다른 것을 끌고 있으면 계속 미룬다. + public bool Released(bool panelOpen, bool dragging) + { + if (!IsPending) return false; + return Request(panelOpen, dragging); + } + + /// 패널이 스스로 다시 채웠거나(열 때) 닫혔다. 미뤄 둔 요청은 버린다. + public void Clear() => IsPending = false; + } +} diff --git a/SoundVisualizer.Tests/Ui/EditPanelSourceTests.cs b/SoundVisualizer.Tests/Ui/EditPanelSourceTests.cs index 5075b48..82a9fb8 100644 --- a/SoundVisualizer.Tests/Ui/EditPanelSourceTests.cs +++ b/SoundVisualizer.Tests/Ui/EditPanelSourceTests.cs @@ -1,36 +1,160 @@ // 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; + namespace SoundVisualizer.Tests.Ui; /// -/// 오버레이 편집 패널의 코드 숨김(MainWindow.xaml.cs)을 읽어 확인한다. MainWindow 는 화면 전체를 덮는 WPF 창이라 단위 시험에서 띄우지 않는다. +/// 오버레이 편집 패널의 코드 숨김(MainWindow.xaml.cs)과, 런처가 그 패널을 맞추는 배선(LauncherWindow*.cs)을 읽어 확인한다. +/// MainWindow 는 화면 전체를 덮는 WPF 창이라 단위 시험에서 띄우지 않는다. 언제 다시 채울지의 규칙은 EditPanelSyncTests 가 본다. /// public class EditPanelSourceTests { + private static string Overlay => AppSource.Read("MainWindow.xaml.cs"); + + private static string OverlayMethod(string name) => AppSource.MethodBody(Overlay, name); + [Fact] public void 편집_패널에서_모드_칸을_바꾸면_슬라이더를_새_모드의_값으로_다시_채운다() { // 크기·진하기·속도 같은 값은 모드마다 따로 저장된다(AppSettings 의 모드별 접근자). 모드를 바꾼 뒤 다시 채우지 않으면 // 슬라이더가 이전 모드의 값을 보이고, 그 상태로 움직이면 새 모드에 엉뚱한 값이 들어간다. F3 단축키로 바꿀 때는 이미 다시 채운다. - string body = MethodBody("EditPanelVisualMode_SelectionChanged"); + string body = OverlayMethod("EditPanelVisualMode_SelectionChanged"); Assert.Contains("LoadSettingsToEditPanel();", body); } - private static string MethodBody(string name) + [Fact] + public void 런처에서_설정을_쓰고_저장하는_곳은_모두_열린_편집_패널을_맞춘다() + { + // 편집 패널을 연 채 런처에서 모드·크기·스위치·색을 바꾸면 패널의 칸이 옛 값을 보였고, 그 슬라이더를 움직이면 옛 값이 새 모드에 + // 쓰였다. 반대 방향(오버레이 → 런처)만 맞추고 있었다. + string launcher = AppSource.Read("LauncherWindow.xaml.cs"); + var member = new Regex(@"\n (private|public|protected|internal) "); + var saves = Regex.Matches(launcher, @"AppSettings\.Save(Now)?\(\);"); + // 자세한 설정의 칸, 색, 단축키, 모드별 초기화 넷, 소리 종류 초기화, 전체 초기화, 출력 장치의 채널 수 + Assert.True(saves.Count >= 10, "런처가 설정을 저장하는 곳을 찾지 못했습니다: " + saves.Count); + foreach (Match save in saves) + { + // 저장한 그 메서드 안(다음 멤버 선언 전)에서 알려야 한다. + var next = member.Match(launcher, save.Index); + string rest = launcher[save.Index..(next.Success ? next.Index : launcher.Length)]; + int line = launcher[..save.Index].Count(ch => ch == '\n') + 1; + Assert.True(rest.Contains("SyncOverlayEditPanel();", StringComparison.Ordinal), + $"LauncherWindow.xaml.cs:{line} 에서 저장한 뒤 SyncOverlayEditPanel 을 부르지 않습니다."); + } + + // 손으로 바꾸는 칸, 색, 초기화는 이름으로도 확인한다. + foreach (string handler in new[] + { + "Setting_Changed", "BtnColor_Click", "Window_PreviewKeyUp", "CheckAndApplyDeviceChannels", + "BtnResetWave_Click", "BtnResetPad_Click", "BtnResetCircle_Click", "BtnResetOutline_Click", + "BtnResetSoundClassification_Click", "BtnReset_Click", + }) + { + Assert.Contains("SyncOverlayEditPanel();", AppSource.MethodBody(launcher, handler)); + } + + // 쉬운 설정은 스스로 저장하고 알려 온다. 런처가 자세한 설정의 칸을 맞춘 뒤 편집 패널도 맞춘다. + string easy = AppSource.MethodBody(AppSource.Read("LauncherWindow.EasySettings.cs"), "EasyView_SettingsChanged"); + int load = easy.IndexOf("LoadSettingsToUI();", StringComparison.Ordinal); + int sync = easy.IndexOf("SyncOverlayEditPanel();", StringComparison.Ordinal); + Assert.True(load >= 0 && sync > load, "쉬운 설정의 변경도 편집 패널에 알려야 합니다."); + } + + [Fact] + public void 오버레이가_알려_온_변경을_런처에_옮기는_중에는_편집_패널로_되돌려_알리지_않는다() + { + // 편집 패널에서 슬라이더를 끌면 오버레이가 런처에 알리고, 런처는 칸을 맞추며 Setting_Changed 를 다시 받는다. 그것이 편집 패널로 + // 되돌아가면 사용자가 끌고 있는 패널을 런처가 다시 채우게 된다. + string status = AppSource.Read("LauncherWindow.Status.cs"); + string start = AppSource.MethodBody(status, "StartOverlay"); + int callback = start.IndexOf("overlay.OnSettingsChangedFromHotkey = () =>", StringComparison.Ordinal); + Assert.True(callback >= 0, "오버레이의 변경 알림을 받는 곳을 찾지 못했습니다."); + string received = start[callback..start.IndexOf("overlay.AiAvailabilityChanged", callback, StringComparison.Ordinal)]; + int guardOn = received.IndexOf("_applyingOverlayChange = true;", StringComparison.Ordinal); + int load = received.IndexOf("LoadSettingsToUI();", StringComparison.Ordinal); + int guardOff = received.IndexOf("_applyingOverlayChange = false;", StringComparison.Ordinal); + Assert.True(guardOn >= 0 && guardOn < load && load < guardOff, "칸을 맞추는 동안 _applyingOverlayChange 를 켜 둡니다."); + Assert.Contains("finally", received[load..guardOff]); + + string sync = AppSource.MethodBody(status, "SyncOverlayEditPanel"); + int skip = sync.IndexOf("if (_applyingOverlayChange) return;", StringComparison.Ordinal); + int reload = sync.IndexOf("_overlayWindow?.ReloadEditPanelIfOpen();", StringComparison.Ordinal); + Assert.True(skip >= 0 && reload > skip); + + // 칸을 맞추는 일 자체(LoadSettingsToUI)는 두 방향이 함께 쓰므로 거기서는 알리지 않는다. + Assert.DoesNotContain("SyncOverlayEditPanel", AppSource.MethodBody(AppSource.Read("LauncherWindow.xaml.cs"), "LoadSettingsToUI")); + } + + [Fact] + public void 편집_패널은_열려_있을_때만_다시_채우고_끄는_중이면_놓은_뒤에_채운다() + { + string reloadIfOpen = OverlayMethod("ReloadEditPanelIfOpen"); + int request = reloadIfOpen.IndexOf("if (!_editPanelSync.Request(_isEditMode, IsDraggingInEditPanel)) return;", StringComparison.Ordinal); + int load = reloadIfOpen.IndexOf("LoadSettingsToEditPanel();", StringComparison.Ordinal); + Assert.True(request >= 0 && load > request, "다시 채우기 전에 EditPanelSync 에 물어야 합니다."); + Assert.Contains("if (_isUpdatingEditPanelSliders) return;", reloadIfOpen[..request]); + + // 슬라이더 손잡이, 크기 손잡이, 가이드라인, 펼친 목록은 끄는 동안 마우스를 잡아 둔다. + Assert.Contains("private bool IsDraggingInEditPanel => IsMouseCaptureWithin;", Overlay); + + // 놓으면(잡았던 마우스를 놓으면) 미뤄 둔 것을 채운다. 놓는 처리가 끝난 뒤에 한다. + Assert.Contains("AddHandler(System.Windows.Input.Mouse.LostMouseCaptureEvent,", Overlay); + Assert.Contains("new System.Windows.Input.MouseEventHandler(OnEditPanelMouseReleased), handledEventsToo: true);", Overlay); + string released = OverlayMethod("OnEditPanelMouseReleased"); + int later = released.IndexOf("Dispatcher.InvokeAsync(", StringComparison.Ordinal); + int ask = released.IndexOf("if (!_editPanelSync.Released(_isEditMode, IsDraggingInEditPanel)) return;", StringComparison.Ordinal); + int fill = released.IndexOf("LoadSettingsToEditPanel();", StringComparison.Ordinal); + Assert.True(later >= 0 && ask > later && fill > ask); + + // 패널을 열면 스스로 채우고 닫으면 채울 것이 없으므로 미뤄 둔 것을 버린다. + Assert.Contains("_editPanelSync.Clear();", OverlayMethod("ToggleEditMode")); + } + + [Fact] + public void 편집_패널을_다시_채우는_동안에는_칸의_처리기가_설정에_다시_쓰거나_런처에_알리지_않는다() { - 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 + " 를 찾지 못했습니다."); + // 런처 → 패널 다시 채우기 → 칸의 바뀜 알림 → 저장 → 런처로 되돌아오는 고리가 생기지 않게 한다. + string load = OverlayMethod("LoadSettingsToEditPanel"); + Assert.StartsWith("_isUpdatingEditPanelSliders = true;", load.TrimStart('{').TrimStart()); + Assert.EndsWith("_isUpdatingEditPanelSliders = false;", load.TrimEnd('}').TrimEnd()); + Assert.DoesNotContain("OnSettingsChangedFromHotkey", load); + Assert.DoesNotContain("AppSettings.Save", load); - int open = source.IndexOf('{', start); - int depth = 0; - for (int i = open; i < source.Length; i++) + // 패널의 값 칸에 이어 둔 처리기는 모두 채우는 중이면 바로 돌아간다. XAML 에서 찾으므로 칸을 더해도 빠지지 않는다. + var xaml = XDocument.Parse(AppSource.Read("MainWindow.xaml")); + var handlers = xaml.Descendants().Attributes() + .Where(attribute => attribute.Name.LocalName is "ValueChanged" or "SelectionChanged" or "Checked" or "Unchecked") + .Select(attribute => attribute.Value) + .Distinct() + .ToArray(); + Assert.True(handlers.Length >= 8, "편집 패널의 처리기를 찾지 못했습니다: " + string.Join(", ", handlers)); + foreach (string handler in handlers) { - if (source[i] == '{') depth++; - else if (source[i] == '}' && --depth == 0) return source.Substring(open, i - open + 1); + Assert.StartsWith("if (_isUpdatingEditPanelSliders) return;", OverlayMethod(handler).TrimStart('{').TrimStart()); } - throw new InvalidOperationException(name + " 의 끝을 찾지 못했습니다."); + } + + [Fact] + public void 단축키_입력을_기다리는_버튼의_안내_글자는_패널을_다시_채워도_남는다() + { + // 입력을 기다리는 중에 런처에서 설정이 바뀌어 패널을 다시 채워도, 버튼이 옛 키 이름으로 돌아가면 기다리는 중인지 알 수 없다. + string load = OverlayMethod("LoadSettingsToEditPanel"); + Assert.Contains("if (_bindingTarget != \"Visual\") BtnEditPanelVisualHotkey.Content = GetKeysName(AppSettings.VisualModeKeyBind);", load); + Assert.Contains("if (_bindingTarget != \"Sound\") BtnEditPanelSoundModeHotkey.Content = GetKeysName(AppSettings.StereoUpmixKeyBind);", load); + Assert.Contains("if (_bindingTarget != \"Edit\") BtnEditPanelEditHotkey.Content = GetKeysName(AppSettings.EditModeKeyBind);", load); + } + + [Fact] + public void 출력_장치의_채널_수가_바뀌면_열려_있는_편집_패널의_사운드_모드_칸도_맞춘다() + { + // 오버레이의 캡처 엔진이 런처보다 먼저 사운드 모드를 바꾸면 런처는 바뀐 것이 없다고 보고 알리지 않는다. + string boot = OverlayMethod("BootSequence"); + int changed = boot.IndexOf("AppSettings.SoundMode = autoSoundMode;", StringComparison.Ordinal); + int reload = boot.IndexOf("ReloadEditPanelIfOpen();", StringComparison.Ordinal); + Assert.True(changed >= 0 && reload > changed); } } diff --git a/SoundVisualizer.Tests/Ui/EditPanelSyncTests.cs b/SoundVisualizer.Tests/Ui/EditPanelSyncTests.cs new file mode 100644 index 0000000..dbdabd8 --- /dev/null +++ b/SoundVisualizer.Tests/Ui/EditPanelSyncTests.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. +namespace SoundVisualizer.Tests.Ui; + +/// +/// 런처에서 설정이 바뀌었을 때 오버레이의 편집 패널을 언제 다시 채울지(Core 의 EditPanelSync). 열려 있으면 바로 채우고, 사용자가 +/// 패널에서 무언가를 끌고 있으면 놓을 때까지 미루며, 닫혀 있으면 아무것도 하지 않는다. 배선은 EditPanelSourceTests 가 본다. +/// +public class EditPanelSyncTests +{ + [Fact] + public void 패널이_열려_있고_끌고_있지_않으면_바로_다시_채운다() + { + var sync = new EditPanelSync(); + + Assert.True(sync.Request(panelOpen: true, dragging: false)); + Assert.False(sync.IsPending); + } + + [Fact] + public void 패널이_닫혀_있으면_채우지_않고_미뤄_두지도_않는다() + { + // 패널은 열 때마다 스스로 채운다. 닫힌 동안의 요청을 기억해 두면 열고 나서 한 번 더 채우게 된다. + var sync = new EditPanelSync(); + + Assert.False(sync.Request(panelOpen: false, dragging: false)); + Assert.False(sync.IsPending); + Assert.False(sync.Released(panelOpen: true, dragging: false)); + } + + [Fact] + public void 끄는_중에는_칸을_건드리지_않고_놓은_뒤에_한_번_채운다() + { + // 끄는 슬라이더의 값이 손 밑에서 바뀌면 다음 움직임이 엉뚱한 값을 설정에 쓴다. + var sync = new EditPanelSync(); + + Assert.False(sync.Request(panelOpen: true, dragging: true)); + Assert.False(sync.Request(panelOpen: true, dragging: true)); // 끄는 동안 런처에서 여러 번 바뀌어도 + Assert.True(sync.IsPending); + + Assert.True(sync.Released(panelOpen: true, dragging: false)); // 놓으면 한 번만 채운다 + Assert.False(sync.IsPending); + Assert.False(sync.Released(panelOpen: true, dragging: false)); + } + + [Fact] + public void 미뤄_둔_것이_없으면_놓아도_채우지_않는다() + { + // 패널에서 슬라이더를 끌었다 놓을 때마다 다시 채우면 안 된다. 런처가 바꾼 것이 없으면 칸은 이미 맞다. + var sync = new EditPanelSync(); + + Assert.False(sync.Released(panelOpen: true, dragging: false)); + Assert.True(sync.Request(panelOpen: true, dragging: false)); + Assert.False(sync.Released(panelOpen: true, dragging: false)); + } + + [Fact] + public void 하나를_놓았어도_아직_다른_것을_끌고_있으면_계속_미룬다() + { + var sync = new EditPanelSync(); + sync.Request(panelOpen: true, dragging: true); + + Assert.False(sync.Released(panelOpen: true, dragging: true)); + Assert.True(sync.IsPending); + Assert.True(sync.Released(panelOpen: true, dragging: false)); + } + + [Fact] + public void 끄는_사이에_패널이_닫히거나_스스로_채웠으면_미뤄_둔_것을_버린다() + { + var sync = new EditPanelSync(); + sync.Request(panelOpen: true, dragging: true); + Assert.False(sync.Released(panelOpen: false, dragging: false)); // 놓기 전에 닫혔다 + Assert.False(sync.IsPending); + + sync.Request(panelOpen: true, dragging: true); + sync.Clear(); // 패널을 다시 열어 스스로 채웠다 + Assert.False(sync.Released(panelOpen: true, dragging: false)); + } + + [Fact] + public void 끄는_중에_미뤘어도_다음_요청_때_끌고_있지_않으면_그때_채운다() + { + var sync = new EditPanelSync(); + sync.Request(panelOpen: true, dragging: true); + + Assert.True(sync.Request(panelOpen: true, dragging: false)); + Assert.False(sync.IsPending); + } +} diff --git a/SoundVisualizer/LauncherWindow.EasySettings.cs b/SoundVisualizer/LauncherWindow.EasySettings.cs index 7cc2c76..d2c73c4 100644 --- a/SoundVisualizer/LauncherWindow.EasySettings.cs +++ b/SoundVisualizer/LauncherWindow.EasySettings.cs @@ -52,13 +52,17 @@ private void SetSettingsView(bool easy) UpdateNotices(); } - /// 쉬운 설정에서 값을 바꿨다(이미 AppSettings 에 쓰고 저장을 예약했다). 자세한 설정의 칸과 소리 탭, 홈 안내를 맞춘다. + /// + /// 쉬운 설정에서 값을 바꿨다(이미 AppSettings 에 쓰고 저장을 예약했다). 자세한 설정의 칸과 소리 탭, 홈 안내, 열려 있는 오버레이 + /// 편집 패널을 맞춘다. + /// private void EasyView_SettingsChanged(object? sender, EventArgs e) { bool wasInitializing = _isInitializing; _isInitializing = true; // 칸을 맞추며 생기는 바뀜 알림이 다시 저장하지 않게 LoadSettingsToUI(); _isInitializing = wasInitializing; + SyncOverlayEditPanel(); } /// diff --git a/SoundVisualizer/LauncherWindow.Status.cs b/SoundVisualizer/LauncherWindow.Status.cs index 9f22188..8298f69 100644 --- a/SoundVisualizer/LauncherWindow.Status.cs +++ b/SoundVisualizer/LauncherWindow.Status.cs @@ -17,6 +17,9 @@ public partial class LauncherWindow private readonly RunStatus _runStatus = new(); private TrayIcon? _tray; + // 오버레이가 알려 온 변경(편집 패널, 단축키)을 런처의 칸에 옮기는 중이다. 그동안에는 오버레이에 되돌려 알리지 않는다. + private bool _applyingOverlayChange; + // 화면 읽어주기에 마지막으로 알린 안내. 언어만 바뀐 때는 다시 읽지 않는다. private HomeNotice _announcedNotice = HomeNotice.None; private StopReason? _announcedStop; @@ -86,7 +89,17 @@ private void StartOverlay() overlay.OnSettingsChangedFromHotkey = () => { // UI 스레드에서 LoadSettingsToUI 호출 (MainWindow는 이미 UI 스레드에서 이벤트를 발생시킴) - LoadSettingsToUI(); + // 이 변경은 오버레이에서 왔다. 칸을 맞추며 생기는 바뀜 알림(Setting_Changed)이 SyncOverlayEditPanel 로 되돌아가면, + // 사용자가 지금 끌고 있는 편집 패널을 런처가 다시 채우게 된다. + _applyingOverlayChange = true; + try + { + LoadSettingsToUI(); + } + finally + { + _applyingOverlayChange = false; + } }; overlay.AiAvailabilityChanged += (s, available) => { @@ -121,6 +134,20 @@ private void StartOverlay() /// 오버레이를 닫는다. 홈의 실행 종료 버튼과 트레이 메뉴가 부른다. 나머지는 오버레이의 Closed 가 한다. private void StopOverlay() => _overlayWindow?.Close(); + /// + /// 런처에서 설정을 바꿨다(자세한 설정, 쉬운 설정, 색, 초기화, 단축키, 출력 장치의 채널 수). 오버레이의 편집 패널(F4)이 열려 있으면 + /// 그 칸도 새 값으로 맞춘다. 예전에는 반대 방향(오버레이 → 런처)만 맞춰서, 패널을 연 채 런처에서 모드나 크기를 바꾸면 패널이 옛 값을 + /// 보였고 그 슬라이더를 움직이면 옛 값이 새 모드에 쓰였다. + /// + /// 런처에서 설정을 쓰고 저장하는 곳은 모두 이것을 부른다(EditPanelSourceTests 가 지킨다). 오버레이가 알려 온 변경을 옮기는 중에는 + /// 부르지 않는다. 오버레이는 패널이 닫혀 있으면 아무것도 하지 않고, 사용자가 패널에서 무언가를 끌고 있으면 놓은 뒤에 채운다. + /// + private void SyncOverlayEditPanel() + { + if (_applyingOverlayChange) return; + _overlayWindow?.ReloadEditPanelIfOpen(); + } + /// /// 오버레이가 닫혔거나 시작하지 못했다. 사용자가 끈 게 아니면 홈에 이유를 남기고, 런처를 보고 있지 않으면 /// Windows 알림으로도 알린다(안드로이드의 "꺼졌습니다" 알림). 게임 중이면 알림이 처음 보는 안내다. diff --git a/SoundVisualizer/LauncherWindow.xaml.cs b/SoundVisualizer/LauncherWindow.xaml.cs index 3aa4a71..0f5d0a3 100644 --- a/SoundVisualizer/LauncherWindow.xaml.cs +++ b/SoundVisualizer/LauncherWindow.xaml.cs @@ -460,6 +460,7 @@ private void Window_PreviewKeyUp(object sender, System.Windows.Input.KeyEventArg _currentlyHeldKeys.Clear(); _maxKeysInCurrentBinding.Clear(); LoadSettingsToUI(); + SyncOverlayEditPanel(); // 편집 패널의 단축키 이름 } e.Handled = true; } @@ -513,6 +514,7 @@ private void Setting_Changed(object sender, RoutedEventArgs e) } AppSettings.Save(); + SyncOverlayEditPanel(); } // 선택지 칸(ChoiceChipList)은 이 앱에 있는 컨트롤이라 XAML 이 이벤트를 실행 중에 이름으로 잇는다. 그때는 인자 형식이 @@ -552,6 +554,7 @@ private void BtnColor_Click(object sender, RoutedEventArgs e) LoadSettingsToUI(); AppSettings.Save(); + SyncOverlayEditPanel(); } } } @@ -568,6 +571,7 @@ private void BtnResetWave_Click(object sender, RoutedEventArgs e) LoadSettingsToUI(); _isInitializing = false; AppSettings.Save(); + SyncOverlayEditPanel(); } private void BtnResetPad_Click(object sender, RoutedEventArgs e) @@ -578,6 +582,7 @@ private void BtnResetPad_Click(object sender, RoutedEventArgs e) LoadSettingsToUI(); _isInitializing = false; AppSettings.Save(); + SyncOverlayEditPanel(); } private void BtnResetCircle_Click(object sender, RoutedEventArgs e) @@ -588,6 +593,7 @@ private void BtnResetCircle_Click(object sender, RoutedEventArgs e) LoadSettingsToUI(); _isInitializing = false; AppSettings.Save(); + SyncOverlayEditPanel(); } private void BtnResetOutline_Click(object sender, RoutedEventArgs e) @@ -598,6 +604,7 @@ private void BtnResetOutline_Click(object sender, RoutedEventArgs e) LoadSettingsToUI(); _isInitializing = false; AppSettings.Save(); + SyncOverlayEditPanel(); } private void BtnResetSoundClassification_Click(object sender, RoutedEventArgs e) @@ -613,6 +620,7 @@ private void BtnResetSoundClassification_Click(object sender, RoutedEventArgs e) LoadSettingsToUI(); _isInitializing = false; AppSettings.Save(); + SyncOverlayEditPanel(); } private void BtnReset_Click(object sender, RoutedEventArgs e) @@ -628,6 +636,7 @@ private void BtnReset_Click(object sender, RoutedEventArgs e) AppSettings.SaveNow(); _isInitializing = false; + SyncOverlayEditPanel(); } private void Window_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) @@ -677,6 +686,7 @@ private async void CheckAndApplyDeviceChannels() ChipsSoundMode.SelectedIndex = newSoundMode; _isInitializing = wasInitializing; + SyncOverlayEditPanel(); } } } diff --git a/SoundVisualizer/MainWindow.xaml.cs b/SoundVisualizer/MainWindow.xaml.cs index adab5a3..4ea69ed 100644 --- a/SoundVisualizer/MainWindow.xaml.cs +++ b/SoundVisualizer/MainWindow.xaml.cs @@ -128,6 +128,9 @@ public MainWindow() LanguagePicker.Fill(CmbLanguage); ColorButtonNames.Apply(EditPanelAmbientColorBtn, EditPanelSpeechColorBtn, EditPanelDangerColorBtn); Loc.Instance.LanguageChanged += OnLanguageChanged; + // 편집 패널에서 끌던 것을 놓으면, 끄는 동안 미뤄 둔 다시 채우기를 한다(ReloadEditPanelIfOpen). 손잡이가 처리한 놓기도 받는다. + AddHandler(System.Windows.Input.Mouse.LostMouseCaptureEvent, + new System.Windows.Input.MouseEventHandler(OnEditPanelMouseReleased), handledEventsToo: true); LoadSettingsToEditPanel(); // UI 초기화 직후 설정값을 미리 반영하여, 지연된 레이아웃 패스에서 기본값(50)이 이벤트를 발생시켜도 올바른 값이 유지되도록 함 _isUpdatingEditPanelSliders = false; ApplyClickThroughMagic(); @@ -186,6 +189,7 @@ private void BootSequence() AppSettings.SoundMode = autoSoundMode; AppSettings.Save(); OnSettingsChangedFromHotkey?.Invoke(); // UI 업데이트 트리거 + ReloadEditPanelIfOpen(); // 편집 패널이 열려 있으면 사운드 모드 칸도 맞춘다 } }); }; @@ -825,6 +829,8 @@ private string GetKeysName(System.Collections.Generic.List codes) private void ToggleEditMode(bool enable) { _isEditMode = enable; + // 열면 아래에서 스스로 채우고, 닫으면 채울 것이 없다. 끄는 동안 미뤄 둔 다시 채우기는 버린다. + _editPanelSync.Clear(); var hwnd = new WindowInteropHelper(this).Handle; int extendedStyle = GetWindowLong(hwnd, GWL_EXSTYLE); @@ -861,6 +867,45 @@ private void ToggleEditMode(bool enable) private bool _isUpdatingEditPanelSliders = true; + // 패널 밖(런처, 출력 장치)에서 설정이 바뀌었을 때 패널을 언제 다시 채울지 정한다. 끄는 중이면 놓을 때까지 미룬다. + private readonly EditPanelSync _editPanelSync = new(); + + /// + /// 설정이 편집 패널 밖에서 바뀌었다(런처의 자세한 설정·쉬운 설정·색·초기화·단축키, 출력 장치의 채널 수). 패널이 열려 있으면 칸을 + /// 새 값으로 다시 채운다. 그대로 두면 칸이 옛 값을 보이고, 그 슬라이더를 움직이면 옛 값이 새 모드에 쓰인다. + /// + /// 사용자가 패널에서 무언가를 끌고 있으면 칸을 건드리지 않고 놓은 뒤에 채운다(). + /// 채우는 동안에는 칸의 처리기가 설정에 다시 쓰지 않으므로(_isUpdatingEditPanelSliders) 저장도, 런처로 되돌아가는 알림도 없다. + /// + internal void ReloadEditPanelIfOpen() + { + // 패널이 스스로 채우는 중이면 다시 들어오지 않는다. 채우는 쪽이 같은 설정을 읽고 있다. + if (_isUpdatingEditPanelSliders) return; + if (!_editPanelSync.Request(_isEditMode, IsDraggingInEditPanel)) return; + LoadSettingsToEditPanel(); + UpdateGuidelinePositions(); + } + + /// + /// 사용자가 편집 패널에서 무언가를 끌고 있는지. 슬라이더 손잡이, 크기 손잡이, 가이드라인(GuidelineCanvas), 펼친 언어 목록은 모두 + /// 그동안 마우스를 잡아 둔다. + /// + private bool IsDraggingInEditPanel => IsMouseCaptureWithin; + + /// 이 창의 요소가 잡았던 마우스를 놓았다. 끄는 동안 미뤄 둔 다시 채우기가 있으면 놓는 처리가 끝난 뒤에 한다. + private void OnEditPanelMouseReleased(object sender, System.Windows.Input.MouseEventArgs e) + { + if (!_editPanelSync.IsPending) return; + // 지금은 놓는 중이다(슬라이더가 끌기를 마무리하고 가이드라인이 저장하기 전이다). 그 처리가 끝난 뒤에 채운다. + Dispatcher.InvokeAsync(() => + { + if (_isClosed || _isUpdatingEditPanelSliders) return; + if (!_editPanelSync.Released(_isEditMode, IsDraggingInEditPanel)) return; + LoadSettingsToEditPanel(); + UpdateGuidelinePositions(); + }); + } + private void LoadSettingsToEditPanel() { _isUpdatingEditPanelSliders = true; @@ -900,10 +945,11 @@ private void LoadSettingsToEditPanel() EditPanelSpeechColorBtn.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(AppSettings.ColorSpeech)); EditPanelDangerColorBtn.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(AppSettings.ColorDanger)); - // 단축키 설정 바인딩 - BtnEditPanelVisualHotkey.Content = GetKeysName(AppSettings.VisualModeKeyBind); - BtnEditPanelSoundModeHotkey.Content = GetKeysName(AppSettings.StereoUpmixKeyBind); - BtnEditPanelEditHotkey.Content = GetKeysName(AppSettings.EditModeKeyBind); + // 단축키 설정 바인딩. 입력을 기다리는 버튼의 안내 글자는 그대로 둔다(OnLanguageChanged 와 같다). 기다리는 중에 런처에서 + // 설정이 바뀌어 패널을 다시 채워도, 버튼이 옛 키 이름으로 돌아가 "기다리는 중"이 보이지 않게 되지 않는다. + if (_bindingTarget != "Visual") BtnEditPanelVisualHotkey.Content = GetKeysName(AppSettings.VisualModeKeyBind); + if (_bindingTarget != "Sound") BtnEditPanelSoundModeHotkey.Content = GetKeysName(AppSettings.StereoUpmixKeyBind); + if (_bindingTarget != "Edit") BtnEditPanelEditHotkey.Content = GetKeysName(AppSettings.EditModeKeyBind); EditPanelHotkeyText.Text = GetKeysName(AppSettings.EditModeKeyBind); // 고급 설정 바인딩 From 5cc05406b2edd2f587c1f45bafce659d9dc4b2f6 Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 16:01:40 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EB=A7=90=EC=86=8C=EB=A6=AC=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EC=97=90=20=EA=B8=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=B4=20=EC=8B=A4=EB=A0=A4=EB=8F=84=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20HUD=20=EC=B2=AB=20=EC=A4=84?= =?UTF-8?q?=EC=9D=B4=20=ED=95=9C=20=EC=A4=84=EC=97=90=20=EB=93=A4=EA=B2=8C?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=EC=A4=84=EC=9D=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미리보기의 이름은 그 프레임의 1위 이름이라 긴 환경음 이름이 실릴 수 있고, 종류 칸이 'SPEECH ~PREVIEW' 로 길어져 첫 줄이 59~63자가 됐다. 58자를 넘으면 이름 끝을 말줄임표로 줄인다(Android #461 과 같다). 정규 결과의 이름은 줄지 않고 기록에는 온전한 이름이 남는다. 시험을 521개 이름 모두로 넓혔다. --- .../Diagnostics/AiDebugText.cs | 23 ++++++-- .../Diagnostics/AiDebugTextTests.cs | 54 ++++++++++++------- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/SoundVisualizer.Core/Diagnostics/AiDebugText.cs b/SoundVisualizer.Core/Diagnostics/AiDebugText.cs index 94c718e..679a14f 100644 --- a/SoundVisualizer.Core/Diagnostics/AiDebugText.cs +++ b/SoundVisualizer.Core/Diagnostics/AiDebugText.cs @@ -114,12 +114,14 @@ public static AiDebugLines Format(AiClassificationResult? result, long nowMs, fl top5[i] = string.Create(CultureInfo.InvariantCulture, $"{i + 1} {FitName(hit.Name)} {TwoDecimals(hit.Probability)}"); } + string coarse = result.Coarse.ToLabel().ToUpperInvariant() + (result.SpeechPreview ? PreviewMarker : ""); + string confidence = TwoDecimals(result.Confidence); return new AiDebugLines( ColorType: result.Coarse, - Coarse: result.Coarse.ToLabel().ToUpperInvariant() + (result.SpeechPreview ? PreviewMarker : ""), + Coarse: coarse, // 확정된 이름이 아직 없으면 빈 문자열이 온다. 임계값을 한 번도 넘지 못한 상태다. - Label: result.Display.Length == 0 ? "(none)" : result.Display, - Confidence: TwoDecimals(result.Confidence), + Label: FitHeaderLabel(result.Display.Length == 0 ? "(none)" : result.Display, coarse, confidence), + Confidence: confidence, Detail: $"thr {YesNo(result.MeetsThreshold)} age {AgeText(result.TimestampMs, nowMs)}", // 투표와 따로, YAMNet 의 강한 위험 단서로 위협음을 지켰는지. Verdict: $"cue {YesNo(result.DangerCuePromoted)}", @@ -127,6 +129,21 @@ public static AiDebugLines Format(AiClassificationResult? result, long nowMs, fl Top5: top5); } + /// 첫 줄에서 종류와 이름 사이의 점이 차지하는 칸 수. 점과 그 양옆 여백이다(모바일 6dp + 8dp + 6dp). + public const int HeaderDotColumns = 3; + + /// + /// 첫 줄("종류 ● 이름 확신도")이 를 넘지 않게 이름을 줄인다. 정규 결과는 가장 긴 이름(40자)도 들어가므로 + /// 줄지 않는다. 말소리 미리보기는 종류 칸이 "SPEECH ~PREVIEW" 로 여덟 자 길고, 이름은 그 프레임의 1위 이름이라(종류는 상위 3개의 + /// 투표) 긴 환경음 이름이 실릴 수 있다. 그때 35자를 넘는 이름만 끝을 말줄임표로 줄인다. 그대로 두면 폰에서 첫 줄이 두 줄로 꺾여 + /// HUD 높이가 흔들린다(#165). 기록(CSV)에는 온전한 이름이 남는다. + /// + private static string FitHeaderLabel(string label, string coarse, string confidence) + { + int room = HudMaxColumns - coarse.Length - HeaderDotColumns - 1 - confidence.Length; + return label.Length <= room ? label : string.Concat(label.AsSpan(0, room - Ellipsis.Length), Ellipsis); + } + /// 고정폭 칸에 맞춘 이름. 짧으면 공백으로 채워 확률이 세로 한 열에 선다. private static string FitName(string name) => name.Length > Top5NameWidth diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs index 6cc9a8b..51cba56 100644 --- a/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs +++ b/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs @@ -204,40 +204,58 @@ public void 말소리_미리보기는_종류_뒤에_PREVIEW_를_붙여_정규_ } [Fact] - public void 말소리_미리보기의_첫_줄은_기본_종류가_대화음인_모든_이름에서_58자_안에_든다() + public void 말소리_미리보기의_첫_줄은_어느_이름이_실려도_58자_안에_든다() { // 미리보기의 종류 칸은 "SPEECH ~PREVIEW"(15자)로 정규 결과의 가장 긴 "AMBIENT"(7자)보다 여덟 자 길다. 위의 시험은 정규 결과만 본다. - // 미리보기는 사용자 지정이 하나도 없을 때만 생기므로(AiClassificationStage.SpeechOnset) 기본 매핑으로 대화음인 이름을 모두 넣어 본다. - // 지금 가장 긴 "Hubbub, speech noise, speech babble"(35자)이 꼭 58자라 여유가 없다. 표시를 한 글자라도 늘리면 여기서 걸린다. - // - // 이 시험이 지키지 않는 경우: 미리보기의 이름은 그 프레임의 1위 이름이고 종류는 상위 3개의 투표라, 1위가 환경음 이름이어도 - // 2·3위의 대화음이 합쳐 이기면 그 환경음 이름이 실린다. 35자를 넘는 이름 셋("Livestock, farm animals, working animals", - // "Bird vocalization, bird call, bird song", "Wind instrument, woodwind instrument")이 그렇게 실리면 59~63자가 된다. - // 줄을 줄이려면 두 판(모바일 AiDebugText)이 함께 정해야 해서 여기서는 고치지 않았다. PC HUD 는 폭을 내용에 맞추므로 꺾이지 않는다. + // 미리보기의 이름은 그 프레임의 1위 이름이고 종류는 상위 3개의 투표라, 1위가 환경음 이름이어도 2·3위의 사람 소리가 합쳐 이기면 + // 그 환경음 이름이 실린다. 그래서 521개 이름을 모두 넣어 본다. 35자를 넘는 이름은 끝을 말줄임표로 줄여 한 줄에 맞춘다. + // 줄이지 않으면 "Livestock, farm animals, working animals" 가 63자가 되어 폰에서 첫 줄이 두 줄로 꺾인다(모바일 #165, #461). var classes = AiTestData.ClassMap; - var speechNames = Enumerable.Range(0, classes.Count) - .Where(i => classes.DefaultCoarse(i) == SoundType.Speech) - .Select(i => classes[i]) - .ToArray(); - Assert.Contains("Speech", speechNames); - Assert.Contains("Hubbub, speech noise, speech babble", speechNames); - var parent = Result(SoundType.Ambient, "Music", 0.6f); - int longest = 0; - foreach (string name in speechNames) + int longest = 0, shortened = 0; + for (int i = 0; i < classes.Count; i++) { + string name = classes[i]; var lines = Format(parent.AsSpeechPreview(name, 0.99f, timestampMs: 1_100L)); Assert.Equal("SPEECH" + AiDebugText.PreviewMarker, lines.Coarse); int header = HeaderColumns(lines); Assert.True(header <= AiDebugText.HudMaxColumns, $"{header}: {lines.Coarse} ● {lines.Label} {lines.Confidence}"); longest = Math.Max(longest, header); + if (lines.Label != name) + { + shortened++; + Assert.EndsWith(AiDebugText.Ellipsis, lines.Label); + Assert.StartsWith(lines.Label[..^AiDebugText.Ellipsis.Length], name); + } } Assert.Equal(AiDebugText.HudMaxColumns, longest); + Assert.True(shortened > 0, "줄인 이름이 하나도 없다"); + // 기본 종류가 사람 소리인 이름은 가장 긴 것(35자)도 줄지 않는다. 미리보기에 가장 자주 실리는 이름들이다. + Assert.Equal("Hubbub, speech noise, speech babble", + Format(parent.AsSpeechPreview("Hubbub, speech noise, speech babble", 0.99f, timestampMs: 1_100L)).Label); + Assert.Equal("Livestock, farm animals, working a" + AiDebugText.Ellipsis, + Format(parent.AsSpeechPreview("Livestock, farm animals, working animals", 0.99f, timestampMs: 1_100L)).Label); + } + + [Fact] + public void 정규_결과의_첫_줄은_어느_이름도_줄이지_않는다() + { + // 정규 결과는 종류 칸이 짧아 가장 긴 이름(40자)도 한 줄에 든다. 채점할 때 보는 이름이 잘리면 안 된다. + var classes = AiTestData.ClassMap; + foreach (var type in new[] { SoundType.Ambient, SoundType.Speech, SoundType.Danger }) + { + for (int i = 0; i < classes.Count; i++) + { + var lines = Format(Result(type, classes[i], 0.99f)); + Assert.Equal(classes[i], lines.Label); + Assert.True(HeaderColumns(lines) <= AiDebugText.HudMaxColumns); + } + } } /// 첫 줄의 글자 수. 오버레이가 "종류 ● 이름 확신도" 로 붙인다. 점과 그 양옆 여백을 세 칸으로 센다(모바일 6dp + 8dp + 6dp). private static int HeaderColumns(AiDebugLines lines) => - lines.Coarse.Length + 3 + lines.Label.Length + 1 + lines.Confidence.Length; + lines.Coarse.Length + AiDebugText.HeaderDotColumns + lines.Label.Length + 1 + lines.Confidence.Length; [Fact] public void 같은_입력이면_같은_줄로_본다() From 79ac9587ea424ab73f4b374c0ebb370afc7de193 Mon Sep 17 00:00:00 2001 From: amophi Date: Wed, 11 Nov 2026 16:01:46 +0900 Subject: [PATCH 10/10] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=EC=9D=84=202.?= =?UTF-8?q?1.1=20=EB=A1=9C=20=EC=98=AC=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SoundVisualizer/SoundVisualizer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SoundVisualizer/SoundVisualizer.csproj b/SoundVisualizer/SoundVisualizer.csproj index d1ea99b..8714e39 100644 --- a/SoundVisualizer/SoundVisualizer.csproj +++ b/SoundVisualizer/SoundVisualizer.csproj @@ -9,7 +9,7 @@ true SoundVisualizer.ico - 2.1.0 + 2.1.1