0.9.0
This commit is contained in:
@@ -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%";
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user