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/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.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.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/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.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.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/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.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.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/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 42c8baf..cfcb4a5 100644
--- a/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs
+++ b/SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs
@@ -64,6 +64,49 @@ public class RealtimeAiPipelineSpeechPreviewTests(ITestOutputHelper output)
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_의_실제_틱_주기에서도_다음_정규_결과까지_이어진다()
{
@@ -308,10 +351,59 @@ public class RealtimeAiPipelineSpeechPreviewTests(ITestOutputHelper output)
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);
@@ -550,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/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 class SileroInferenceTests(ITestOutputHelper output)
}
}
+ [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 덩어리_길이가_다르거나_닫은_뒤에는_부르지_않는다()
{
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 @@ private static List Cells(string line)
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 @@ private static List Cells(string line)
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));
}
diff --git a/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs b/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs
index c156119..51cba56 100644
--- a/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs
+++ b/SoundVisualizer.Tests/Diagnostics/AiDebugTextTests.cs
@@ -167,8 +167,7 @@ private static AiDebugLines Format(AiClassificationResult? result, long nowMs =
{
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,60 @@ private static AiDebugLines Format(AiClassificationResult? result, long nowMs =
Assert.Equal("thr Y age 0.1s", preview.Detail); // 나이는 미리보기를 만든 때부터
}
+ [Fact]
+ public void 말소리_미리보기의_첫_줄은_어느_이름이_실려도_58자_안에_든다()
+ {
+ // 미리보기의 종류 칸은 "SPEECH ~PREVIEW"(15자)로 정규 결과의 가장 긴 "AMBIENT"(7자)보다 여덟 자 길다. 위의 시험은 정규 결과만 본다.
+ // 미리보기의 이름은 그 프레임의 1위 이름이고 종류는 상위 3개의 투표라, 1위가 환경음 이름이어도 2·3위의 사람 소리가 합쳐 이기면
+ // 그 환경음 이름이 실린다. 그래서 521개 이름을 모두 넣어 본다. 35자를 넘는 이름은 끝을 말줄임표로 줄여 한 줄에 맞춘다.
+ // 줄이지 않으면 "Livestock, farm animals, working animals" 가 63자가 되어 폰에서 첫 줄이 두 줄로 꺾인다(모바일 #165, #461).
+ var classes = AiTestData.ClassMap;
+ var parent = Result(SoundType.Ambient, "Music", 0.6f);
+ 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 + AiDebugText.HeaderDotColumns + lines.Label.Length + 1 + lines.Confidence.Length;
+
[Fact]
public void 같은_입력이면_같은_줄로_본다()
{
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/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.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.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 @@ private static XElement Named(XDocument document, string name) =>
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.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 class EasySizesTests
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/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.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/LauncherWindow.EasySettings.cs b/SoundVisualizer/LauncherWindow.EasySettings.cs
index c276656..d2c73c4 100644
--- a/SoundVisualizer/LauncherWindow.EasySettings.cs
+++ b/SoundVisualizer/LauncherWindow.EasySettings.cs
@@ -47,15 +47,22 @@ private void SetSettingsView(bool easy)
AppSettings.Save();
}
ShowSettingsView();
+ // 홈 안내가 인용하는 스위치 이름은 화면마다 다르다(RunStatus.AmbientSwitchLabelKey). 안내가 떠 있으면 새 화면의 이름으로 바꾼다.
+ // 안내의 종류는 그대로라 화면 읽어주기가 다시 읽지는 않는다.
+ 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 1990d01..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 알림으로도 알린다(안드로이드의 "꺼졌습니다" 알림). 게임 중이면 알림이 처음 보는 안내다.
@@ -153,7 +180,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;
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.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..4ea69ed 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,10 +122,15 @@ 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);
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();
@@ -183,6 +189,7 @@ private void BootSequence()
AppSettings.SoundMode = autoSoundMode;
AppSettings.Save();
OnSettingsChangedFromHotkey?.Invoke(); // UI 업데이트 트리거
+ ReloadEditPanelIfOpen(); // 편집 패널이 열려 있으면 사운드 모드 칸도 맞춘다
}
});
};
@@ -630,7 +637,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)
@@ -821,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);
@@ -857,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;
@@ -896,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);
// 고급 설정 바인딩
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
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);
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();