Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 74 additions & 10 deletions src/Offstream.App/Services/RecordingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,23 @@ namespace Offstream.App.Services;
/// dialog.
/// </para>
/// </remarks>
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;
}

/// <summary>
/// Serialises start against stop.
Expand All @@ -53,6 +64,13 @@ public sealed class RecordingController(IRecordingSessionFactory factory, Settin
/// <summary>Raised whenever <see cref="IsRunning"/> changes.</summary>
public event EventHandler? StateChanged;

/// <summary>Raised when <see cref="FormatSummary"/> may have changed under the page.</summary>
/// <remarks>
/// Separate from <see cref="StateChanged"/>, which promises to mean "IsRunning changed" and
/// would stop meaning it if settings borrowed it.
/// </remarks>
public event EventHandler? OutputChanged;

/// <summary>A finished file landed in the library.</summary>
public event EventHandler<TrackSavedEventArgs>? TrackSaved;

Expand All @@ -73,12 +91,26 @@ public sealed class RecordingController(IRecordingSessionFactory factory, Settin
/// What the output is, as the display prints it — <c>MP3 320K 48K</c>.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>The sample rate is shown while idle too</b> (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
/// <see cref="NAudio.CoreAudioApi.MMDevice"/> 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.
/// </para>
/// <para>
/// It is the same endpoint capture will open — <see cref="Offstream.Core.Audio.LoopbackAudioCapture"/>
/// resolves the identical id through <see cref="AudioEndpoints.Resolve"/> 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.
/// </para>
/// </remarks>
public string FormatSummary
{
Expand All @@ -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"));
}
Expand All @@ -103,6 +135,32 @@ public string FormatSummary
}
}

/// <summary>
/// The rate audio is being captured at, or would be captured at from a standing start.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
}
}

/// <summary>The library root, so paths can be shown relative to it rather than in full.</summary>
public string? OutputPath => _settings.Current.Output.Path;

Expand Down Expand Up @@ -197,6 +255,8 @@ public async ValueTask DisposeAsync()
if (_disposed) return;
_disposed = true;

_settings.Changed -= OnSettingsChanged;

await StopAsync();

_gate.Dispose();
Expand Down Expand Up @@ -259,6 +319,10 @@ private async ValueTask Release(RecordingSession session)
await session.DisposeAsync();
}

/// <summary>A setting was saved, so <see cref="FormatSummary"/> may no longer be current.</summary>
private void OnSettingsChanged(object? sender, EventArgs e) =>
OutputChanged?.Invoke(this, EventArgs.Empty);

/// <summary>
/// Releases a session that stopped by itself — the recording timer elapsed, or the audio
/// endpoint went away mid-recording.
Expand Down
4 changes: 4 additions & 0 deletions src/Offstream.App/ViewModels/RecordViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -546,6 +547,9 @@ private void OnProgress(object? sender, RecordingProgress progress) => Dispatch(

private void OnStateChanged(object? sender, EventArgs e) => Dispatch(Sync);

/// <summary>A setting changed, so the format line may no longer describe what Start would do.</summary>
private void OnOutputChanged(object? sender, EventArgs e) => Dispatch(Sync);

/// <summary>Adds a finished file to the session list and the totals.</summary>
private void OnTrackSaved(object? sender, TrackSavedEventArgs e) => Dispatch(() =>
{
Expand Down
1 change: 1 addition & 0 deletions src/Offstream.App/Views/Pages/RecordPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@
ItemsSource="{Binding Saved, Mode=OneWay}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.ScrollUnit="Pixel"
VirtualizingPanel.VirtualizationMode="Recycling">
<ListBox.ItemContainerStyle>
<Style BasedOn="{StaticResource {x:Type ListBoxItem}}" TargetType="ListBoxItem">
Expand Down
54 changes: 54 additions & 0 deletions tests/Offstream.UI.Tests/RecordingControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,60 @@ private static async Task WaitFor(Func<bool> condition, string because)
Assert.Fail($"Timed out waiting for {because}.");
}

/// <summary>
/// The format line describes what pressing Start would produce, so it has to follow the
/// settings it is built from — it used to be re-read only on start and on stop, which left an
/// idle page describing the file the previous settings would have made.
/// </summary>
[Fact]
public async Task FormatSummary_FollowsABitrateChangedWhileIdle()
{
var document = RecordingFakes.Document();
await using var controller = new RecordingController(new FakeSessionFactory(), document);

Assert.StartsWith("MP3 320K", controller.FormatSummary, StringComparison.Ordinal);

document.Update(settings => settings with { Output = settings.Output with { BitrateKbps = 128 } });

Assert.StartsWith("MP3 128K", controller.FormatSummary, StringComparison.Ordinal);
}

/// <summary>
/// Saving a setting tells the page the format line may have moved. Separate from
/// <c>StateChanged</c>, which promises to mean "IsRunning changed".
/// </summary>
[Fact]
public async Task OutputChanged_FiresWhenASettingIsSaved()
{
var document = RecordingFakes.Document();
await using var controller = new RecordingController(new FakeSessionFactory(), document);

var raised = 0;
controller.OutputChanged += (_, _) => raised++;

document.Update(settings => settings with { Output = settings.Output with { BitrateKbps = 192 } });

Assert.Equal(1, raised);
}

/// <summary>
/// Disposal unsubscribes, so a document outliving the controller cannot keep raising into it.
/// </summary>
[Fact]
public async Task Dispose_StopsListeningToTheSettingsDocument()
{
var document = RecordingFakes.Document();
var controller = new RecordingController(new FakeSessionFactory(), document);

var raised = 0;
controller.OutputChanged += (_, _) => raised++;

await controller.DisposeAsync();
document.Update(settings => settings with { Output = settings.Output with { BitrateKbps = 192 } });

Assert.Equal(0, raised);
}

[Fact]
public void Constructor_RejectsNulls()
{
Expand Down
Loading