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