222 lines
9.0 KiB
C#
222 lines
9.0 KiB
C#
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%";
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|