From 13e85ab916d4359f02237e067a8d2e1fb3379b7a Mon Sep 17 00:00:00 2001 From: revtex Date: Wed, 2 Sep 2026 12:50:02 -0400 Subject: [PATCH] Slow the session list down, and stop hiding the sample rate until Start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults on the Record page, all reported together. The session list scrolled by item. Its rows are two lines tall, so one wheel notch moved about 150 DIP — three finished tracks at a time, with no way to move through it slowly. The Metadata list was built with ScrollUnit="Pixel" and never had the problem; the session list simply never got the same line. The sample rate was withheld while idle. The remark explaining why said the capture endpoint's rate "is not knowable until the endpoint is open", and that is wrong: the rate is a property of the endpoint and MMDevice reports its mix format at any time. It is also the same endpoint capture opens — LoopbackAudioCapture resolves the identical id through AudioEndpoints.Resolve and takes its format from the device — so the idle figure is the one the recording will use, not a guess. Two thirds of the line read from settings and were always there while the third appeared on Start, which read as the display filling in rather than as a deliberate omission. A device that cannot be read still prints nothing: a wrong number on the line that describes the file is worse than a short one. Fixing that exposed the third. FormatSummary always read settings fresh, but Sync ran only at construction, on start and on stop, so changing the format, the bitrate or the capture device while idle left the page describing the file the previous settings would have produced. The controller now listens to the settings document and raises OutputChanged, which is separate from StateChanged because StateChanged promises to mean "IsRunning changed" and would stop meaning it if settings borrowed it. Disposal unsubscribes, and there is a test for that as well as for the stale bitrate and the event itself. --- CHANGELOG.md | 21 +++++ .../Services/RecordingController.cs | 84 ++++++++++++++++--- .../ViewModels/RecordViewModel.cs | 4 + src/Offstream.App/Views/Pages/RecordPage.xaml | 1 + .../RecordingControllerTests.cs | 54 ++++++++++++ 5 files changed, 154 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3c1dc..5769039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ phase plan these entries follow. ## [Unreleased] +### Fixed + +- **One notch of the wheel jumped three tracks in the session list.** The list of what has been + recorded scrolled by whole items, and its rows are two lines tall, so a single wheel notch moved + about 150 device-independent pixels — three finished tracks at once. There was no way to move + through it slowly. It scrolls by pixels now, like the list on the Metadata page, which had been + built that way from the start and did not have the problem. + +- **The sample rate appeared on the transport display only once recording started.** The line that + says what the file will be — `MP3 320K 48K` — showed its first two parts while idle and grew the + third on Start, which read as the display filling in rather than as the deliberate omission it + was. The reason recorded for it was wrong: the rate was thought to be unknowable until the + capture device was open, when it is a property of the device and readable at any time. It is the + same device the recording will open, so the figure shown while idle is the one the recording + will use. + +- **The transport display ignored settings changed while it was idle.** It re-read the format, + bitrate and device only when a session started or stopped, so changing any of them on Settings + or Advanced left the Record page describing the file the previous settings would have produced, + until something was recorded. It now follows a saved setting straight away. + ## [0.2.0] - 2026-08-31 **Upgrading from 0.1.0 resets one setting.** The three advertisement switches diff --git a/src/Offstream.App/Services/RecordingController.cs b/src/Offstream.App/Services/RecordingController.cs index 12b2422..00ba375 100644 --- a/src/Offstream.App/Services/RecordingController.cs +++ b/src/Offstream.App/Services/RecordingController.cs @@ -27,12 +27,23 @@ namespace Offstream.App.Services; /// dialog. /// /// -public sealed class RecordingController(IRecordingSessionFactory factory, SettingsDocument settings) - : IAsyncDisposable +public sealed class RecordingController : IAsyncDisposable { - private readonly IRecordingSessionFactory _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + private readonly IRecordingSessionFactory _factory; - private readonly SettingsDocument _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + private readonly SettingsDocument _settings; + + public RecordingController(IRecordingSessionFactory factory, SettingsDocument settings) + { + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + + // Everything on the format line is read from settings, so everything on it goes stale the + // moment a setting changes. Nothing else was telling the page: it re-read on start and on + // stop, which meant changing the format or the capture device while idle left the line + // describing the file the previous settings would have produced. + _settings.Changed += OnSettingsChanged; + } /// /// Serialises start against stop. @@ -53,6 +64,13 @@ public sealed class RecordingController(IRecordingSessionFactory factory, Settin /// Raised whenever changes. public event EventHandler? StateChanged; + /// Raised when may have changed under the page. + /// + /// Separate from , which promises to mean "IsRunning changed" and + /// would stop meaning it if settings borrowed it. + /// + public event EventHandler? OutputChanged; + /// A finished file landed in the library. public event EventHandler? TrackSaved; @@ -73,12 +91,26 @@ public sealed class RecordingController(IRecordingSessionFactory factory, Settin /// What the output is, as the display prints it — MP3 320K 48K. /// /// + /// /// Read from settings rather than remembered, so an idle page shows what pressing Start would - /// produce and a running one shows what it is producing. The sample rate only appears once a - /// session exists: it is the capture endpoint's rate, which is not knowable until the endpoint - /// is open, and printing a guess on the one line of the page that claims to describe the file - /// would be worse than printing nothing. Lossless formats omit the bitrate for the same - /// reason — the setting exists but does not apply. + /// produce and a running one shows what it is producing. Lossless formats omit the bitrate + /// — the setting exists but does not apply. + /// + /// + /// The sample rate is shown while idle too (2026-09-02). It used to appear only once a + /// session existed, on the grounds that the capture endpoint's rate is not knowable until the + /// endpoint is open. That is wrong: the rate is a property of the endpoint, and + /// reports its mix format at any time. Withholding + /// it made one third of this line behave unlike the other two, which read from settings and + /// are always there — so the line grew a word on Start for no reason the user could see. + /// + /// + /// It is the same endpoint capture will open — + /// resolves the identical id through and takes its format + /// from the device — so the idle figure is the one the recording will use, not a guess. A + /// device that cannot be read prints nothing rather than a placeholder: this line describes + /// the file, and a wrong number on it is worse than a short one. + /// /// public string FormatSummary { @@ -94,7 +126,7 @@ public string FormatSummary parts.Add(string.Create(CultureInfo.CurrentCulture, $"{recording.BitrateKbps}K")); } - if (_session?.Level.Format.SampleRate is { } hertz) + if (SampleRateHertz() is { } hertz) { parts.Add(string.Create(CultureInfo.CurrentCulture, $"{hertz / 1000d:0.#}K")); } @@ -103,6 +135,32 @@ public string FormatSummary } } + /// + /// The rate audio is being captured at, or would be captured at from a standing start. + /// + /// + /// A running session already knows, and is asked first: its format came from the device when + /// the capture opened, and re-reading the endpoint could disagree with the file being written + /// if the default endpoint moved underneath us. Idle, the endpoint is asked directly. + /// + private int? SampleRateHertz() + { + if (_session?.Level.Format.SampleRate is { } running) return running; + + try + { + using var device = AudioEndpoints.Resolve(_settings.Current.Recording.AudioEndpointDeviceId); + return device.AudioClient.MixFormat.SampleRate; + } + catch (Exception ex) + { + // Nothing here is worth interrupting anyone over: the line simply comes up one word + // short, and Start reports a missing endpoint properly when it matters. + Log.Debug(ex, "Could not read the capture endpoint's sample rate for the format line"); + return null; + } + } + /// The library root, so paths can be shown relative to it rather than in full. public string? OutputPath => _settings.Current.Output.Path; @@ -197,6 +255,8 @@ public async ValueTask DisposeAsync() if (_disposed) return; _disposed = true; + _settings.Changed -= OnSettingsChanged; + await StopAsync(); _gate.Dispose(); @@ -259,6 +319,10 @@ private async ValueTask Release(RecordingSession session) await session.DisposeAsync(); } + /// A setting was saved, so may no longer be current. + private void OnSettingsChanged(object? sender, EventArgs e) => + OutputChanged?.Invoke(this, EventArgs.Empty); + /// /// Releases a session that stopped by itself — the recording timer elapsed, or the audio /// endpoint went away mid-recording. diff --git a/src/Offstream.App/ViewModels/RecordViewModel.cs b/src/Offstream.App/ViewModels/RecordViewModel.cs index 07f061c..215a969 100644 --- a/src/Offstream.App/ViewModels/RecordViewModel.cs +++ b/src/Offstream.App/ViewModels/RecordViewModel.cs @@ -218,6 +218,7 @@ public RecordViewModel(InMemoryLogSink logSink, RecordingController controller) controller.StateChanged += OnStateChanged; controller.TrackSaved += OnTrackSaved; controller.TrackEnriched += OnTrackEnriched; + controller.OutputChanged += OnOutputChanged; // Seeds the format line, which describes what pressing Start would produce and so has // something to say before anything is running. @@ -546,6 +547,9 @@ private void OnProgress(object? sender, RecordingProgress progress) => Dispatch( private void OnStateChanged(object? sender, EventArgs e) => Dispatch(Sync); + /// A setting changed, so the format line may no longer describe what Start would do. + private void OnOutputChanged(object? sender, EventArgs e) => Dispatch(Sync); + /// Adds a finished file to the session list and the totals. private void OnTrackSaved(object? sender, TrackSavedEventArgs e) => Dispatch(() => { diff --git a/src/Offstream.App/Views/Pages/RecordPage.xaml b/src/Offstream.App/Views/Pages/RecordPage.xaml index 936dee0..03806fd 100644 --- a/src/Offstream.App/Views/Pages/RecordPage.xaml +++ b/src/Offstream.App/Views/Pages/RecordPage.xaml @@ -497,6 +497,7 @@ ItemsSource="{Binding Saved, Mode=OneWay}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" VirtualizingPanel.IsVirtualizing="True" + VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.VirtualizationMode="Recycling">