Files
HearingProtector/MainPage.xaml.cs
T
2026-09-26 12:11:20 +07:00

222 lines
9.0 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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%";
});
}
}