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%"; }); } }