Skip to content
Merged
14 changes: 10 additions & 4 deletions SoundVisualizer.Core/Ai/RealtimeAiPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ namespace SoundVisualizer.Ai
/// - <see cref="Dispose"/> 가 1초 안에 추론을 기다리지 못하면 Android 는 세션을 버리지만(누수), 여기서는 그 추론이 끝나면서 닫는다.
/// Silero 스레드도 같은 1초 안에서 끝나기를 기다린다(Android 는 기다리지 않는다). 못 기다리면 그 스레드가 끝나며 세션을 닫는다.
/// - 미리보기 시각은 <see cref="Stopwatch"/> 의 단조 나노초로 잰다(Android 의 elapsedRealtimeNanos). 틱의 밀리초 시계(15.6ms 단위)로는
/// 250·275ms 창이 한 프레임씩 흔들린다.
/// 250·350ms 창이 한 프레임씩 흔들린다.
/// </summary>
public sealed class RealtimeAiPipeline : IDisposable
{
Expand All @@ -51,8 +51,14 @@ public sealed class RealtimeAiPipeline : IDisposable
/// <summary>정규 추론이 소리를 가져간 때부터 이 시간 안에(다음 틱 무렵까지) Silero 가 확인해야 미리보기를 받아들인다.</summary>
internal const long SpeechPreviewAcceptNs = 250_000_000L;

/// <summary>정규 추론이 소리를 가져간 때부터 이 시간이 지나면 미리보기가 사라진다. 다음 정규 결과가 오면 그 전에라도 사라진다.</summary>
internal const long SpeechPreviewExpiryNs = 275_000_000L;
/// <summary>
/// 정규 추론이 소리를 가져간 때부터 이 시간이 지나면 미리보기가 사라진다. 다음 정규 결과가 오면 그 전에라도 사라진다.
/// Android 는 275ms 다(정확한 250ms 주기 + 추론 시간). PC 의 틱은 15.6ms 단위 시계와 대기로 재서 실제 주기가 255~270ms 이고,
/// 275ms 로는 다음 정규 결과가 오기 전에 미리보기가 먼저 사라져 사람 소리 → 환경음 → 사람 소리로 깜빡인다(이 PC 에서 틱의 7~9%).
/// 그래서 주기 250ms 에 시계 오차 두 번(31ms)과 느린 추론·바쁜 CPU 의 여유를 더해 350ms 로 둔다. 다음 정규 결과·위협음·사용자 지정은
/// 부모 검사로 여전히 바로 이기므로, 달라지는 것은 다음 틱이 무음으로 건너뛰어질 때 미리보기가 75ms 더 남는 것뿐이다.
/// </summary>
internal const long SpeechPreviewExpiryNs = 350_000_000L;

private const string Tag = nameof(RealtimeAiPipeline);
private const long FailureLogIntervalMs = 2000;
Expand Down Expand Up @@ -446,7 +452,7 @@ internal AiTickOutcome RunTickOnce(long nowMs)
private void InferLocked()
{
Interlocked.Increment(ref _inferenceExecutions);
// 미리보기 창(250·275ms)은 정규 추론이 소리를 가져가기 직전부터 잰다(Android 의 snapshotBootNs).
// 미리보기 창(받아들임 250ms·만료 350ms)은 정규 추론이 소리를 가져가기 직전부터 잰다(Android 의 snapshotBootNs).
long snapshotNs = _speechWorker is null ? 0L : _nanoClock();
long started = Stopwatch.GetTimestamp();
_buffer.CopyTailRightPadded(_captureScratch);
Expand Down
5 changes: 3 additions & 2 deletions SoundVisualizer.Core/Ai/SileroStreamWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ public SileroStreamWorker(
{
IsBackground = true,
Name = "SV-SileroPreview",
// Android 의 THREAD_PRIORITY_BACKGROUND. 캡처·화면·정규 추론보다 뒤에 선다.
Priority = ThreadPriority.BelowNormal,
// 우선순위는 낮추지 않는다(Android 는 THREAD_PRIORITY_BACKGROUND). 이 스레드는 링 버퍼 잠금과 깨우기 신호를 캡처 스레드와
// 나눠 쓰는데, Windows 는 우선순위가 엄격하고 Monitor 에 우선순위 상속이 없다. 게임이 모든 코어를 채운 동안 낮은 우선순위로
// 잠금을 잡은 채 밀려나면 캡처 스레드(소리 내보내기와 화면 크기 계산도 한다)가 함께 멈춘다. 일의 양은 코어의 1% 남짓이다.
};
_thread.Start();
}
Expand Down
10 changes: 6 additions & 4 deletions SoundVisualizer.Core/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -565,10 +565,12 @@ private class SettingsData
// 설정 탭을 쉬운 설정으로 열지. 예전 설정 파일에는 없어 null 로 읽힌다(그때는 모드 설정을 보고 정한다).
public bool? EasySettings { get; set; }

public VisualModeSettings WaveMode { get; set; } = new VisualModeSettings();
public VisualModeSettings PadMode { get; set; } = new VisualModeSettings();
public VisualModeSettings CircleMode { get; set; } = new VisualModeSettings();
public VisualModeSettings OutlineMode { get; set; } = new VisualModeSettings();
// 모드별 설정. 초기값을 두지 않는다. 모드별 설정이 없던 아주 예전 파일은 null 로 읽혀야 Load 가 그때의 값(WaveIntensity 등)을
// 네 모드로 옮긴다. 초기값이 있으면 늘 '있음'으로 읽혀 옮기지 않고, 판을 올린 사람의 크기·진하기가 기본값으로 돌아간다.
public VisualModeSettings? WaveMode { get; set; }
public VisualModeSettings? PadMode { get; set; }
public VisualModeSettings? CircleMode { get; set; }
public VisualModeSettings? OutlineMode { get; set; }
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion SoundVisualizer.Core/Localization/Strings/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
"sounds.filter.all": "الكل",
"sounds.filter.changed": "المعدّلة",
"sounds.type.ambient": "الأصوات المحيطة",
"sounds.type.speech": "أصوات بشرية",
"sounds.type.speech": "الأصوات البشرية",
"sounds.type.danger": "أصوات الخطر",
"sounds.type.default": "{0} (تلقائي)",
"sounds.type.mixed": "مختلط",
Expand Down
2 changes: 1 addition & 1 deletion SoundVisualizer.Core/Localization/Strings/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@
"tutorial.types.title": "Setiap jenis suara punya warnanya sendiri",
"tutorial.types.body": "AI mengelompokkan suara menjadi tiga jenis dan menggambar setiap jenis dengan warna yang berbeda. Anda dapat mengubah warnanya di tampilan “Detail” pada tab Setelan.",
"tutorial.type.ambient_examples": "Musik, hujan, dan suara lainnya",
"tutorial.type.speech_examples": "Suara manusia",
"tutorial.type.speech_examples": "Percakapan",
"tutorial.type.danger_examples": "Tembakan, ledakan, sirene",
"tutorial.start.title": "Cara menyalakan dan mematikannya",
"tutorial.start.hotkeys": "Saat berjalan, tekan {0} untuk mengganti mode suara, {1} untuk mengganti mode visual, dan {2} untuk membuka setelan overlay."
Expand Down
2 changes: 1 addition & 1 deletion SoundVisualizer.Core/Localization/Strings/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@
"tutorial.types.title": "音の種類を色で区別します",
"tutorial.types.body": "AI が音を3種類に分類し、それぞれ違う色で描きます。色は「設定」タブの「詳細設定」で変更できます。",
"tutorial.type.ambient_examples": "音楽・雨音など、その他の音",
"tutorial.type.speech_examples": "人の声",
"tutorial.type.speech_examples": "話し声",
"tutorial.type.danger_examples": "銃声・爆発音・サイレン",
"tutorial.start.title": "オン/オフの方法",
"tutorial.start.hotkeys": "実行中は、{0} でサウンドモード、{1} で表示モードを切り替え、{2} でオーバーレイ設定を開きます。"
Expand Down
2 changes: 1 addition & 1 deletion SoundVisualizer.Core/Localization/Strings/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@
"help.pc.title": "PC 설정과 단축키",
"help.sound_mode.title": "사운드 모드",
"help.sound_mode.body": "앞뒤 소리까지 구분해서 보려면 Windows 소리 설정에서 출력 장치를 '5.1 서라운드'(6채널)나 '7.1 서라운드'(8채널)로 설정하세요. 일반 스테레오(2채널)에서는 좌우는 구분하지만 앞뒤는 구분하지 못합니다. 설정 탭 ‘자세한 설정’의 '사운드 모드'는 실행할 때와 출력 장치가 바뀔 때 그 장치에 맞춰지며, 직접 바꿀 수도 있습니다.",
"help.sound_mode.tip": "💡 만약 7.1 출력 장치가 없다면 {0}를 설치하고 출력장치를 7.1채널로 설정한다면 7.1 서라운드 사운드를 사용하여 오버레이를 표현할 수 있습니다.",
"help.sound_mode.tip": "💡 7.1 출력 장치가 없으면 {0}를 설치하고 출력 장치를 7.1채널로 설정해 보세요. 7.1 서라운드 사운드로 오버레이를 볼 수 있습니다.",
"help.sound_mode.tip_link": "VB-CABLE 가상 드라이버",
"help.hotkeys.title": "실시간 커스텀 단축키 제어",
"help.hotkeys.body": "설정 탭의 ‘자세한 설정’에서 핫키 버튼을 클릭한 뒤, 단일 키 또는 복합 키 조합(예: Ctrl + Shift + A)을 눌러 자유롭게 단축키를 변경할 수 있습니다. 백그라운드에서 지정된 단축키를 누르면 실시간으로 모드가 즉시 전환됩니다.",
Expand Down
2 changes: 1 addition & 1 deletion SoundVisualizer.Core/Localization/Strings/th.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@
"tutorial.types.title": "เสียงแต่ละประเภทมีสีของตัวเอง",
"tutorial.types.body": "AI จะแยกเสียงออกเป็น 3 ประเภท และวาดแต่ละประเภทด้วยสีของตัวเอง คุณเปลี่ยนสีได้ที่ “แบบละเอียด” ในแท็บการตั้งค่า",
"tutorial.type.ambient_examples": "เพลง เสียงฝน และเสียงอื่นทั้งหมด",
"tutorial.type.speech_examples": "เสียงคน",
"tutorial.type.speech_examples": "เสียงพูด",
"tutorial.type.danger_examples": "เสียงปืน เสียงระเบิด เสียงไซเรน",
"tutorial.start.title": "วิธีเปิดและปิด",
"tutorial.start.hotkeys": "ขณะทำงานอยู่ กด {0} เพื่อสลับโหมดเสียง กด {1} เพื่อสลับโหมดการแสดงผล และกด {2} เพื่อเปิดการตั้งค่าโอเวอร์เลย์"
Expand Down
2 changes: 1 addition & 1 deletion SoundVisualizer.Core/Localization/Strings/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@
"tutorial.types.title": "Her ses türünün kendi rengi var",
"tutorial.types.body": "Yapay zeka, sesleri üç türe ayırır ve her türü farklı bir renkte çizer. Renkleri “Ayarlar” sekmesindeki “Ayrıntılı” görünümünden değiştirebilirsiniz.",
"tutorial.type.ambient_examples": "Müzik, yağmur ve diğer tüm sesler",
"tutorial.type.speech_examples": "İnsan sesleri",
"tutorial.type.speech_examples": "Konuşma",
"tutorial.type.danger_examples": "Silah sesi, patlama, siren",
"tutorial.start.title": "Nasıl açılıp kapatılır",
"tutorial.start.hotkeys": "Çalışırken ses modunu değiştirmek için {0} tuşuna, görsel modu değiştirmek için {1} tuşuna, yer paylaşımı ayarlarını açmak için {2} tuşuna basın."
Expand Down
3 changes: 3 additions & 0 deletions SoundVisualizer.Core/Tutorial/TutorialDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ public TutorialDemo(TutorialScene scene, int visualMode, bool runToStill = true,
/// <summary>마지막으로 엔진에 흘린 대본 시각(초). 처음에는 멈춰 둘 장면의 시각이다.</summary>
public double TimeSec { get; private set; }

/// <summary>이 그림의 설정에 든 원형 고리 반지름(10~100). 그리는 쪽이 원형 시각화에 넘긴다(전역 설정이 아니라 이 그림의 값).</summary>
public double CircleRadius => _settings.CircleRadius;

/// <summary>지금 그리는 소리의 종류. 창이 이것으로 색을 고르고 종류 쪽의 범례를 밝힌다.</summary>
public SoundType Label => TutorialScript.Label(Scene, TimeSec);

Expand Down
19 changes: 18 additions & 1 deletion SoundVisualizer.Tests/Ai/RealtimeAiPipelineSpeechPreviewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,24 @@ public void 첫_대화음_후보_뒤의_새_소리를_Silero_가_확인하면_
}

[Fact]
public void 미리보기는_정규_스냅샷부터_275ms_에_사라지고_정규_결과로_돌아간다()
public void 미리보기는_PC_의_실제_틱_주기에서도_다음_정규_결과까지_이어진다()
{
// PC 의 틱은 15.6ms 단위 시계와 대기로 재서 다음 정규 결과가 스냅샷 뒤 255~280ms 에 온다(바쁜 PC 는 더 늦다). Android 의 275ms 로
// 만료하면 그 사이에 미리보기가 먼저 사라져 사람 소리 → 환경음 → 사람 소리로 깜빡였다(v2.1 을 내기 전 검토에서 틱의 7~9%).
using var h = new Harness(16000, 1);
var regular = h.FirstSpeechCandidate();
h.FeedFreshBlocks(3, h.Frames);

foreach (long ms in new long[] { 255, 266, 275, 281, 300, 330 })
{
h.Nanos.Set(ms * 1_000_000L);
Assert.True(h.Pipeline.LastDisplayClassification()?.SpeechPreview, ms + "ms 에 미리보기가 사라졌다");
}
Assert.Same(regular, h.Pipeline.LastClassification()); // 그동안에도 정규 결과는 그대로다
}

[Fact]
public void 미리보기는_만료_시각에_사라지고_정규_결과로_돌아간다()
{
using var h = new Harness(16000, 1);
var regular = h.FirstSpeechCandidate();
Expand Down
34 changes: 34 additions & 0 deletions SoundVisualizer.Tests/AppSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,40 @@ public void 키_목록이_없는_예전_설정은_예전_단축키_하나로_읽
Assert.Equal("de", AppSettings.Language); // 예전 판의 언어 이름은 코드로 옮겨 읽는다
}

[Fact]
public void 모드별_설정이_없는_아주_예전_파일은_그때의_값을_네_모드에_옮긴다()
{
// 모드별 설정이 생기기 전의 판은 값을 하나씩만 저장했다. 옮기지 않으면 판을 올린 사람의 크기·진하기가 기본값으로 돌아간다.
File.WriteAllText(_path, """{ "WaveIntensity": 80, "VisualOpacity": 35, "WavePositionSpeed": 60, "WaveSensitivity": 5, "CircleRadius": 70 }""");

AppSettings.Load();

Assert.All(new[] { AppSettings.WaveMode, AppSettings.PadMode, AppSettings.CircleMode, AppSettings.OutlineMode }, mode =>
{
Assert.Equal(80, mode.Intensity);
Assert.Equal(35, mode.VisualOpacity);
Assert.Equal(60, mode.PositionSpeed);
Assert.Equal(5, mode.Sensitivity);
Assert.Equal(70, mode.CircleRadius);
});
// 네 모드는 서로 다른 객체다. 하나를 바꿔도 다른 모드가 따라 바뀌지 않는다.
AppSettings.WaveMode.Intensity = 10;
Assert.Equal(80, AppSettings.PadMode.Intensity);
// 상세 값을 바꿔 쓰던 사람이라 자세한 설정으로 연다.
Assert.False(AppSettings.EasySettings);
}

[Fact]
public void 모드별_설정이_일부만_있는_파일은_있는_모드를_그대로_읽는다()
{
File.WriteAllText(_path, """{ "WaveIntensity": 80, "PadMode": { "Intensity": 33 } }""");

AppSettings.Load();

Assert.Equal(33, AppSettings.PadMode.Intensity);
Assert.Equal(80, AppSettings.WaveMode.Intensity);
}

[Fact]
public void 깨진_설정_파일은_기본값으로_시작한다()
{
Expand Down
23 changes: 23 additions & 0 deletions SoundVisualizer.Tests/Localization/EasySettingsStringsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ public void 소리_종류_줄의_읽는_이름에는_보이는_종류_이름이_
}
}

[Fact]
public void 아랍어의_사람_소리_줄은_같은_말을_두_번_읽지_않는다()
{
// 종류 이름에만 관사가 없으면 스위치 이름(إظهار الأصوات البشرية) 안에서 찾지 못해 "사람 소리, 사람 소리 표시" 꼴로 겹쳐 읽힌다.
// 환경음(الأصوات المحيطة)처럼 관사를 붙인 이름이어야 스위치 이름 하나로 읽힌다.
Assert.Equal(Get("ar", "settings.show_speech"),
EasyNames.TypeRow(Get("ar", "sounds.type.speech"), Get("ar", "settings.show_speech"), Get("ar", "easy.name_pair")));
}

[Theory]
[MemberData(nameof(Codes))]
public void 튜토리얼_범례의_종류_이름과_예시는_같은_글자가_아니다(string code)
{
// 범례는 이름과 예시를 나란히 보인다("사람 소리 사람 목소리"). 종류 이름을 바꾸면서 일본어·인도네시아어·태국어·터키어는
// 새 이름이 원래 예시와 같은 낱말이 되어 "人の声 人の声" 처럼 겹쳤다. 이름을 바꿀 때는 예시도 함께 본다.
foreach (var type in new[] { "ambient", "speech", "danger" })
{
Assert.False(
string.Equals(Get(code, "sounds.type." + type).Trim(), Get(code, "tutorial.type." + type + "_examples").Trim(), StringComparison.OrdinalIgnoreCase),
$"{code}: {type} 의 이름과 예시가 같습니다.");
}
}

[Fact]
public void 읽는_이름은_들어_있으면_자세한_설정의_스위치_이름_그대로이고_없으면_앞에_붙인다()
{
Expand Down
37 changes: 37 additions & 0 deletions SoundVisualizer.Tests/Ui/HotkeyBindingSourceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (C) 2026 amophi (SoundVisualizer Contributors)
// This file is part of SoundVisualizer.
// SoundVisualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3.
namespace SoundVisualizer.Tests.Ui;

/// <summary>
/// 단축키 입력 대기(LauncherWindow 의 _bindingTarget)는 창이 모든 키를 가로채 떼는 순간 단축키로 저장한다. 단축키 버튼은 자세한 설정에만
/// 있으므로, 버튼이 보이지 않게 될 때(쉬운 설정이나 다른 탭으로 넘어갈 때) 대기를 그만두지 않으면 그 뒤에 누른 Tab 이나 방향키가 먹지 않고
/// 단축키로 저장된다. 런처는 WPF 창이라 소스를 읽어 확인한다.
/// </summary>
public class HotkeyBindingSourceTests
{
private static string Read(string file) => File.ReadAllText(Path.Combine(RepositoryPaths.Root, "SoundVisualizer", file));

[Fact]
public void 쉬운_설정과_자세한_설정을_오갈_때_단축키_입력_대기를_그만둔다()
{
string source = Read("LauncherWindow.EasySettings.cs");
int method = source.IndexOf("private void SetSettingsView(bool easy)", StringComparison.Ordinal);
Assert.True(method >= 0, "SetSettingsView 를 찾지 못했습니다.");
int cancel = source.IndexOf("CancelHotkeyBinding();", method, StringComparison.Ordinal);
int show = source.IndexOf("ShowSettingsView();", method, StringComparison.Ordinal);

Assert.True(cancel >= 0 && cancel < show, "화면을 바꾸기 전에 CancelHotkeyBinding 을 불러야 합니다.");
}

[Fact]
public void 탭을_바꿀_때와_Esc_를_누를_때_같은_취소를_쓴다()
{
string source = Read("LauncherWindow.xaml.cs");

Assert.Contains("Tabs.SelectionChanged += (s, e) => { if (ReferenceEquals(e.OriginalSource, Tabs)) CancelHotkeyBinding(); };", source);
int escape = source.IndexOf("if (e.Key == System.Windows.Input.Key.Escape)", StringComparison.Ordinal);
Assert.True(escape >= 0);
Assert.Contains("CancelHotkeyBinding();", source.Substring(escape, 200));
}
}
Loading
Loading