This commit is contained in:
2026-09-26 12:11:20 +07:00
parent 7baeb0dfed
commit 9c4f8d748f
23 changed files with 965 additions and 74 deletions
+222
View File
@@ -0,0 +1,222 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives; // RangeBaseValueChangedEventArgs
using NAudio.CoreAudioApi;
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace HearingProtector;
public sealed partial class MainPage : Page
{
// ─── Константы ────────────────────────────────────────────────────────
private const int PollMs = 1000;
private const float UserChangeEps = 0.005f;
private const float WriteEps = 0.0005f;
// Технический пол для экспоненциальной формулы. Нужен только когда
// пользователь выбрал целевой минимум 0% (иначе exp не работает).
private const double FormulaFloor = 0.001; // 0.1%
// ─── Config ───────────────────────────────────────────────────────────
private sealed record Config(double MinPercent, double FullDecayMinutes)
{
public double MinVolume => MinPercent / 100.0; // реальная цель
public double FormulaMin => Math.Max(MinVolume, FormulaFloor);// >= 0.1%
public double TotalTicks => FullDecayMinutes * 60_000.0 / PollMs;
public double DecayPerTick => Math.Pow(FormulaMin, 1.0 / TotalTicks);
public double DbPerTick => 20.0 * Math.Log10(DecayPerTick);
}
// ─── Поля ─────────────────────────────────────────────────────────────
private volatile Config? _config;
private CancellationTokenSource? _cts;
private bool _initialized;
// ─── Конструктор ──────────────────────────────────────────────────────
public MainPage()
{
InitializeComponent();
ApplySettings(SettingsStore.Load());
UpdateLabels(); // слайдеры могут сами не дёрнуть ValueChanged после загрузки
_initialized = true;
Loaded += OnLoaded;
Unloaded += OnUnloaded;
}
// ─── Жизненный цикл ───────────────────────────────────────────────────
private void OnLoaded(object sender, RoutedEventArgs e)
{
_config ??= BuildConfig();
if (_cts is not null) return;
_cts = new CancellationTokenSource();
_ = RunAsync(_cts.Token);
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
// ─── Настройки ────────────────────────────────────────────────────────
private void ApplySettings(AppSettings s)
{
MinVolumeSlider.Value = s.MinPercent;
FullDecaySlider.Value = s.FullDecayMinutes;
}
private AppSettings CurrentSettings() =>
new(MinVolumeSlider.Value, FullDecaySlider.Value);
private void Config_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
{
if (!_initialized) return;
UpdateLabels();
_config = BuildConfig();
SettingsStore.Save(CurrentSettings());
}
private void UpdateLabels()
{
if (MinVolumeLabel is null || FullDecayLabel is null) return;
MinVolumeLabel.Text = $"{MinVolumeSlider.Value:F0} %";
int m = (int)FullDecaySlider.Value;
FullDecayLabel.Text = m switch
{
< 60 => $"{m} мин",
_ when m % 60 == 0 => $"{m / 60} ч",
_ => $"{m / 60} ч {m % 60} мин",
};
}
private Config BuildConfig()
{
var s = CurrentSettings();
double min = s.MinPercent;
double mins = s.FullDecayMinutes;
if (double.IsNaN(min) || min < 0) min = 1;
if (double.IsNaN(mins) || mins < 30) mins = 30;
return new Config(min, mins);
}
// ─── Рабочий цикл ─────────────────────────────────────────────────────
private async Task RunAsync(CancellationToken ct)
{
using var enumerator = new MMDeviceEnumerator();
using var device = enumerator.GetDefaultAudioEndpoint(
DataFlow.Render, Role.Multimedia);
var volume = device.AudioEndpointVolume;
float baseline = volume.MasterVolumeLevelScalar;
float current = baseline;
while (!ct.IsCancellationRequested)
{
var cfg = _config;
if (cfg is null)
{
try { await Task.Delay(PollMs, ct); }
catch (OperationCanceledException) { break; }
continue;
}
double etaSec = double.NaN;
bool muted = false;
bool atFloor = false;
double percent = current * 100;
try
{
float actual = volume.MasterVolumeLevelScalar;
// Ручное вмешательство → синхронизация
if (Math.Abs(actual - baseline) > UserChangeEps)
{
current = actual;
baseline = actual;
}
muted = volume.Mute;
atFloor = current <= cfg.MinVolume + 0.0005f;
if (!muted && !atFloor)
{
if (current > cfg.FormulaMin + 0.0005f)
// Экспоненциальный шаг до технического пола
current = (float)Math.Max(cfg.FormulaMin,
current * cfg.DecayPerTick);
else
// Пол достигнут, а цель ниже (только кейс MinPercent = 0)
current = (float)cfg.MinVolume;
if (Math.Abs(actual - current) > WriteEps)
volume.MasterVolumeLevelScalar = current;
baseline = current;
var logK = Math.Log(cfg.DecayPerTick);
if (logK < 0 && current > cfg.MinVolume)
etaSec = Math.Log(cfg.MinVolume / current)
/ logK * PollMs / 1000.0;
}
percent = current * 100;
}
catch { /* переживаем транзиентные COM-сбои */ }
UpdateStatus(percent, muted, atFloor, etaSec, cfg);
try { await Task.Delay(PollMs, ct); }
catch (OperationCanceledException) { break; }
}
}
// ─── UI ───────────────────────────────────────────────────────────────
private void UpdateStatus(double percent, bool muted, bool atFloor,
double etaSec, Config cfg)
{
DispatcherQueue.TryEnqueue(() =>
{
VolumeBar.Value = percent;
if (muted)
StatusText.Text = "🔇 Звук выключен — пауза";
else if (atFloor && cfg.MinPercent == 0)
StatusText.Text = "🔇 Достигнута тишина";
else if (atFloor)
StatusText.Text = $"Уровень достиг минимума ({cfg.MinPercent:F0}%)";
else
StatusText.Text = $"Громкость: {percent:F2}%";
if (muted)
EtaText.Text = "Снимите мьют — цикл продолжится";
else if (atFloor)
EtaText.Text = "Увеличьте громкость, чтобы возобновить цикл";
else if (!double.IsNaN(etaSec))
{
var t = TimeSpan.FromSeconds(Math.Max(0, etaSec));
EtaText.Text = t.TotalHours >= 1
? $"До минимума ≈ {(int)t.TotalHours}ч {t.Minutes:D2}м"
: $"До минимума ≈ {t.Minutes:D2}м {t.Seconds:D2}с";
}
else
EtaText.Text = "";
RateText.Text =
$"Темп: {cfg.DbPerTick:F4} дБ/тик · " +
$"период {PollMs} мс · цель {cfg.MinPercent:F0}% за " +
$"{cfg.FullDecayMinutes:F0} мин от 100%";
});
}
}