diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5769039..d1d7229 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,24 @@ phase plan these entries follow.
## [Unreleased]
+### Added
+
+- **The transport display now says when a recording is going wrong, instead of leaving it to be
+ noticed.** Recording an hour of silence is this app's signature failure — audio routed to a
+ device Offstream is not capturing shows full volume in Windows and writes nothing — and the only
+ symptom was a flat meter nobody was watching. A **`SILENT`** lamp now appears after a few seconds
+ of nothing, counting how long the quiet has run, and a **`CLIP`** lamp latches if the audio
+ reached full scale during a track, which is distortion you can still do something about while the
+ track is playing. Both are absent unless something is wrong, so the ordinary display is
+ unchanged.
+
+- **The display also says what it is recording, how big the file is, and when the timer will stop
+ it.** The capture device's name sits on the line that describes the file, which is the question
+ the silence lamp raises and previously meant a trip to the Settings page to answer. The running
+ size follows it for constant-bitrate formats. And a **`STOPS IN`** countdown appears beside the
+ elapsed clock whenever a recording timer is armed — it is set on the Advanced page and was then
+ completely invisible on the page running it.
+
### Fixed
- **One notch of the wheel jumped three tracks in the session list.** The list of what has been
diff --git a/CLAUDE.md b/CLAUDE.md
index e7357f2..c555669 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,6 +93,19 @@ These are load-bearing; violating them breaks the app in ways that are not obvio
inequality made an emptied box light the **Will change** badge and then save nothing. Any field
added to the editor has to go through `Replaces`, which asks whether there is a new value at
all before asking whether it differs.
+- **`AudioLevelMeter.Read` drains what it reports, so the meter has exactly one reader**
+ (decided 2026-09-02). The draining is the point — it lets a slow reader see the loudness of its
+ whole interval instead of an instant — but it makes a second consumer a silent bug rather than a
+ compile error: two readers split the samples, and the bars end up reporting a fraction of the
+ audio while the other reader gets the rest, with neither side visibly broken. `LcdMeterView` is
+ that reader. Anything else that needs to know about the audio is folded in on the capture thread
+ inside `Write`, where every sample passes once and nothing is consumed — which is where
+ `HasClipped` and `SilentFor` live. Two consequences worth keeping: a clip test must use the
+ **peak** sample, because everything this meter publishes is RMS and RMS never reaches full scale
+ on real music, so a lamp driven from `LevelReading.Decibels` would never light; and the clip
+ threshold is 0.999, not 1.0, because a converter out of headroom pins to the largest value the
+ format holds and 16-bit's 32767/32768 never normalises to exactly one.
+
- **Use `ProcessStartInfo.ArgumentList`, never a command string.** Track metadata comes from Spotify window titles and is untrusted. The argv array prevents argument injection structurally; the old app needed hand-written `CommandLineToArgvW` escaping because .NET Framework lacked `ArgumentList`.
## Spotify Web API rules
diff --git a/README.md b/README.md
index bd6ec7d..54c20f2 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,11 @@ Press Stop when you're done. The part-recorded song is finished off and encoded
closes, rather than being dropped on the floor — unless it's shorter than your minimum length, in
which case it's discarded like any other fragment.
+The display above the meter watches the capture while you are not. It carries what the file will be —
+format, bitrate, sample rate, the device being recorded and the size so far — and lights a **`SILENT`**
+lamp if nothing has been heard for a few seconds, or **`CLIP`** if the audio hit full scale. With a timer
+set on the Advanced page, a **`STOPS IN`** countdown appears beside the elapsed clock.
+
## Better tags and cover art
With no provider configured, Offstream writes what Spotify itself reports to Windows: artist, title,
@@ -233,7 +238,8 @@ handy for testing against a clean profile without disturbing your real one.
| What you see | What's happening |
| --- | --- |
| **"Offstream can't find ffmpeg"** | ffmpeg isn't installed or isn't on `PATH`. Run the winget command above, then **reopen the terminal** and restart Offstream. |
-| **Nothing is recorded, and the meter is flat** | Offstream is listening to a different audio device than the one playing. Check **Record from** on the Settings page. |
+| **A `SILENT` lamp on the display, and the meter is flat** | Offstream is listening to a different audio device than the one playing — the commonest way to record an hour of nothing. Check **Record from** on the Settings page. |
+| **A `CLIP` lamp on the display** | The audio reached full scale, so this track may be distorted. Turn Spotify's own volume down a little and record it again; the lamp clears when the next track starts. |
| **Recordings include notification sounds, browser audio, everything** | Expected — you're recording the whole output device. Use VB-CABLE as described above to capture Spotify alone. |
| **Files have no cover art** | No metadata provider is selected, or its key is missing. See [Better tags and cover art](#better-tags-and-cover-art). |
| **Short files keep being thrown away** | That's the minimum-length setting doing its job. Lower it on the Settings page if you're recording something genuinely short. |
diff --git a/docs/MODERNIZATION-PLAN.md b/docs/MODERNIZATION-PLAN.md
index 5ea8dc8..be6c927 100644
--- a/docs/MODERNIZATION-PLAN.md
+++ b/docs/MODERNIZATION-PLAN.md
@@ -999,6 +999,33 @@ Three second-order notes from doing it:
Commands bound from inside the pane reach the page with `RelativeSource={RelativeSource AncestorType=UserControl}`; the row template's old `AncestorType=ListBox` has no such ancestor once the editor is out of the list.
+### Finding: a meter that reports has to be read exactly once (2026-09-02)
+
+`AudioLevelMeter.Read` drains the interval it reports. That is deliberate and documented — it is
+what lets a slow reader see the loudness of its whole interval rather than an instant — but it
+makes the meter a single-consumer object, and nothing said so where a second consumer would look.
+
+Adding the silence and clip lamps looked like a job for a second reader: poll `Read`, notice a
+run of quiet or a full-scale figure, light a lamp. That would have worked in a test and been
+wrong in the app. Two readers split the samples between them, so the bars would have reported a
+fraction of the audio and the lamps the rest, with neither obviously broken — the meter would
+simply have read low, by an amount that varied with how often each side happened to poll.
+
+Both flags are folded in on the capture thread inside `Write` instead, where every sample passes
+once, and neither is disturbed by `Read`. Two things fell out of doing it there. The clip flag
+has to come off the **peak** sample, not off a reading: everything this meter publishes is RMS,
+which for real music sits ten to twenty decibels below the peak, so a clip lamp driven from
+`LevelReading.Decibels` would never light at all. And the clip threshold is 0.999 rather than 1.0,
+because a converter out of headroom pins to the largest value the format holds — 32767/32768 for
+16-bit — which never quite normalises to full scale.
+
+The silence threshold is −80 dBFS rather than exact zero for the same class of reason: digital
+silence is zeroes, but a capture graph with any analogue stage in it idles on a dither floor, and
+calling that "not silent" would disable the warning on exactly the hardware that needs it.
+
+Verified live, and by accident: a staged profile recording the default endpoint while Spotify
+played to VB-CABLE reproduced the failure the lamp exists for, and the lamp lit at six seconds.
+
### Finding: a tag the recorder writes and the editor cannot reach is a tag nobody can fix (2026-08-31)
The page shipped with three editable fields — title, artist, album — and showed year, genre and artwork read-only beside them. The scope question "which fields belong here" has an answer that is not a matter of taste: **whatever the recording path writes**. `FFmpegArguments.MetadataArguments` writes title, artist, album, album artist, genre, date, track, disc and copyright, so a recording could carry a wrong disc number that no part of Offstream could correct. Composer, comment and BPM are in neither list, and that is what keeps this from drifting into a general-purpose tag editor.
diff --git a/src/Offstream.App/Resources/Strings.fr.resx b/src/Offstream.App/Resources/Strings.fr.resx
index 02ca2f5..88437ef 100644
--- a/src/Offstream.App/Resources/Strings.fr.resx
+++ b/src/Offstream.App/Resources/Strings.fr.resx
@@ -713,4 +713,28 @@
Saisissez un entier supérieur à zéro ou laissez le champ vide.
+
+ CRÊTE
+
+
+ La capture a atteint le niveau maximal pendant ce morceau : il peut être saturé. Baissez le volume de Spotify et réenregistrez-le.
+
+
+ SILENCE {0}
+
+
+ Rien n'a été entendu depuis un moment. Spotify joue probablement vers un autre périphérique que celui enregistré : vérifiez Source audio dans les Paramètres.
+
+
+ ARRÊT DANS
+
+
+ Temps restant sur la minuterie définie dans Avancé. Le morceau en cours se termine avant l'arrêt de l'enregistrement.
+
+
+ {0:0.#} Mo
+
+
+ {0:0.##} Go
+
diff --git a/src/Offstream.App/Resources/Strings.resx b/src/Offstream.App/Resources/Strings.resx
index d1056ed..c814251 100644
--- a/src/Offstream.App/Resources/Strings.resx
+++ b/src/Offstream.App/Resources/Strings.resx
@@ -873,4 +873,28 @@
Enter a whole number above zero, or leave the box empty.
+
+ CLIP
+
+
+ The capture reached full scale during this track, so it may be distorted. Turn Spotify's own volume down and record it again.
+
+
+ SILENT {0}
+
+
+ Nothing has been heard for a while. Spotify is most likely playing to a different device than the one being recorded — check Record from on the Settings page.
+
+
+ STOPS IN
+
+
+ Time left on the recording timer set on the Advanced page. The track playing when it runs out is finished before recording stops.
+
+
+ {0:0.#} MB
+
+
+ {0:0.##} GB
+
diff --git a/src/Offstream.App/Services/RecordingController.cs b/src/Offstream.App/Services/RecordingController.cs
index 00ba375..93a9ee3 100644
--- a/src/Offstream.App/Services/RecordingController.cs
+++ b/src/Offstream.App/Services/RecordingController.cs
@@ -56,6 +56,7 @@ public RecordingController(IRecordingSessionFactory factory, SettingsDocument se
private readonly SemaphoreSlim _gate = new(1, 1);
private RecordingSession? _session;
+ private DateTimeOffset? _startedAt;
private bool _disposed;
/// Progress from the running session, forwarded verbatim.
@@ -126,12 +127,19 @@ public string FormatSummary
parts.Add(string.Create(CultureInfo.CurrentCulture, $"{recording.BitrateKbps}K"));
}
- if (SampleRateHertz() is { } hertz)
+ var (hertz, name) = CaptureEndpoint();
+
+ if (hertz is { } rate)
{
- parts.Add(string.Create(CultureInfo.CurrentCulture, $"{hertz / 1000d:0.#}K"));
+ parts.Add(string.Create(CultureInfo.CurrentCulture, $"{rate / 1000d:0.#}K"));
}
- return string.Join(' ', parts);
+ var technical = string.Join(' ', parts);
+
+ // The device goes after a separator rather than into the space-joined run: it is the
+ // one part of this line that is a name rather than a number, and names have spaces in
+ // them. "MP3 320K 48K Speakers (Realtek(R) Audio)" reads as one long token.
+ return string.IsNullOrWhiteSpace(name) ? technical : $"{technical} · {name}";
}
}
@@ -143,24 +151,76 @@ public string FormatSummary
/// 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()
+ ///
+ /// Bytes a second the current format writes, or null when that cannot be known ahead.
+ ///
+ ///
+ /// Only meaningful for a constant-bitrate format. FLAC and WAV are excluded rather than
+ /// estimated: a FLAC's size depends on how compressible the music is, and a guess on the line
+ /// that describes the file would be wrong by a quarter either way on ordinary material.
+ ///
+ public int? BytesPerSecond
{
- if (_session?.Level.Format.SampleRate is { } running) return running;
+ get
+ {
+ var recording = _settings.Current.ToRecordingSettings();
+ var profile = EncodingProfiles.For(recording.MediaFormat);
+
+ return profile.SupportsBitrate ? recording.BitrateKbps * 1000 / 8 : null;
+ }
+ }
+ private (int? Hertz, string? Name) CaptureEndpoint()
+ {
+ // One activation for both facts. They come off the same device, and this used to be two
+ // calls a second on the path AudioEndpoints.Resolve already warns about.
try
{
using var device = AudioEndpoints.Resolve(_settings.Current.Recording.AudioEndpointDeviceId);
- return device.AudioClient.MixFormat.SampleRate;
+
+ // A running session's own format wins. It was taken from the device when the capture
+ // opened, and re-reading could disagree with the file actually being written if the
+ // system default moved underneath us mid-recording.
+ var hertz = _session?.Level.Format.SampleRate ?? device.AudioClient.MixFormat.SampleRate;
+
+ return (hertz, device.FriendlyName);
}
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;
+ // Nothing here is worth interrupting anyone over: the line simply comes up short, and
+ // Start reports a missing endpoint properly when it matters.
+ Log.Debug(ex, "Could not read the capture endpoint for the format line");
+ return (_session?.Level.Format.SampleRate, null);
}
}
+ ///
+ /// How long until the recording timer stops the session, or null when none is armed.
+ ///
+ ///
+ /// It reads "stops after the track playing when this runs out", not "stops at zero".
+ /// arms a one-shot timer at session
+ /// start that sets a flag; the session then finishes the track it is on before stopping, so
+ /// the real end is this figure plus whatever is left of the current song. Counting down to
+ /// zero and then continuing to record would look broken without that said somewhere, which is
+ /// what the label and its tooltip are for.
+ ///
+ public TimeSpan? TimerRemaining
+ {
+ get
+ {
+ if (_startedAt is not { } started) return null;
+
+ var recording = _settings.Current.ToRecordingSettings();
+ if (!recording.HasRecordingTimerEnabled) return null;
+
+ var left = recording.RecordingTimerDuration - (DateTimeOffset.UtcNow - started);
+
+ return left > TimeSpan.Zero ? left : TimeSpan.Zero;
+ }
+ }
+
+
/// The library root, so paths can be shown relative to it rather than in full.
public string? OutputPath => _settings.Current.Output.Path;
@@ -192,6 +252,7 @@ public string FormatSummary
}
_session = await BuildAsync(settings);
+ _startedAt = DateTimeOffset.UtcNow;
}
catch (FFmpegNotFoundException ex)
{
@@ -225,6 +286,7 @@ public async Task StopAsync()
var session = _session;
_session = null;
+ _startedAt = null;
try
{
@@ -355,6 +417,7 @@ private async Task ReleaseEndedSessionAsync()
if (session is null) return;
_session = null;
+ _startedAt = null;
CaptureRuntimeState(session);
await Release(session);
diff --git a/src/Offstream.App/Themes/Tokens.xaml b/src/Offstream.App/Themes/Tokens.xaml
index 6540b1e..7352339 100644
--- a/src/Offstream.App/Themes/Tokens.xaml
+++ b/src/Offstream.App/Themes/Tokens.xaml
@@ -75,6 +75,13 @@
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
+
+
+
+
+
+
+
+
diff --git a/src/Offstream.Core/Audio/AudioLevelMeter.cs b/src/Offstream.Core/Audio/AudioLevelMeter.cs
index bb26928..0f988d9 100644
--- a/src/Offstream.Core/Audio/AudioLevelMeter.cs
+++ b/src/Offstream.Core/Audio/AudioLevelMeter.cs
@@ -46,17 +46,45 @@ public sealed class AudioLevelMeter
///
private const double FloorDecibels = -60;
+ ///
+ /// Sample magnitude counted as clipping, as a fraction of full scale.
+ ///
+ ///
+ /// Just under 1 rather than at it: a converter that has run out of headroom pins samples to
+ /// the largest value the format can hold, and for 16-bit that is 32767/32768 — never quite
+ /// 1.0 once normalised. Testing for equality with full scale would miss every clipped
+ /// integer recording, which is most of them.
+ ///
+ private const float ClipMagnitude = 0.999f;
+
+ ///
+ /// Sample magnitude below which an interval counts as silent.
+ ///
+ ///
+ /// −80 dBFS, two decades below the meter's own floor. Digital silence is exact zeroes
+ /// and would justify testing for zero, but a capture graph with any analogue stage in it
+ /// idles at a dither floor instead, and calling that "not silent" would make the warning
+ /// this feeds useless on exactly the hardware that needs it.
+ ///
+ private const float SilenceMagnitude = 0.0001f;
+
private readonly SampleReader? _read;
private readonly Lock _gate = new();
+ private readonly TimeProvider _time;
private readonly double[] _sumOfSquares;
private readonly long[] _samples;
- public AudioLevelMeter(WaveFormat format)
+ private long _lastSoundAt;
+ private bool _clipped;
+
+ public AudioLevelMeter(WaveFormat format, TimeProvider? time = null)
{
ArgumentNullException.ThrowIfNull(format);
Format = format;
+ _time = time ?? TimeProvider.System;
+ _lastSoundAt = _time.GetTimestamp();
_read = SampleReaderFor(format);
IsSupported = _read is not null;
@@ -88,6 +116,8 @@ public void Write(ReadOnlySpan data)
// One capture thread and one reader, and the critical section is a handful of additions —
// the hundred acquisitions a second this takes are far cheaper than the lock-free dance a
// running sum would otherwise need.
+ var peak = 0f;
+
lock (_gate)
{
var channel = 0;
@@ -97,13 +127,68 @@ public void Write(ReadOnlySpan data)
// starting at channel zero each time keeps left and right from swapping over.
for (var offset = 0; offset + stride <= data.Length; offset += stride)
{
- double sample = _read(data.Slice(offset, stride));
+ float sample = _read(data.Slice(offset, stride));
- _sumOfSquares[channel] += sample * sample;
+ _sumOfSquares[channel] += (double)sample * sample;
_samples[channel]++;
+ var magnitude = Math.Abs(sample);
+ if (magnitude > peak) peak = magnitude;
+
if (++channel == ChannelCount) channel = 0;
}
+
+ // Both flags are folded in here, on the capture thread, and neither is disturbed by
+ // Read. They have to be: Read drains the interval it reports, so a second consumer
+ // calling it to look for silence or clipping would take samples away from the meter
+ // and leave the bars reporting a fraction of the audio.
+ if (peak >= ClipMagnitude) _clipped = true;
+
+ // One clock read per buffer rather than per sample. Write runs about a hundred times
+ // a second with a few thousand samples in each call.
+ if (peak > SilenceMagnitude) _lastSoundAt = _time.GetTimestamp();
+ }
+ }
+
+ ///
+ /// Whether any sample since the last reached full scale.
+ ///
+ ///
+ /// Latched, and taken from the peak sample rather than from a reading. The readings
+ /// this meter publishes are RMS, which for real music sits ten to twenty decibels under the
+ /// peak and so never reaches full scale even while the converter is clipping — a clip lamp
+ /// driven from would simply never light. Latching is the
+ /// point as well: clipping is a handful of samples, gone long before anyone looks at the
+ /// screen, and the damage it does is permanent once encoded.
+ ///
+ public bool HasClipped
+ {
+ get { lock (_gate) return _clipped; }
+ }
+
+ /// Clears , for the start of a new track.
+ public void ResetClip()
+ {
+ lock (_gate) _clipped = false;
+ }
+
+ ///
+ /// How long it has been since audio above arrived.
+ ///
+ ///
+ /// This is the meter's answer to the failure named in the class remarks — audio routed to a
+ /// device that is not being captured shows full volume in Windows and records nothing. The
+ /// bars already report it, by sitting flat; this reports it in a form that can be counted and
+ /// put into words, which is what makes it survive the user looking away.
+ ///
+ public TimeSpan SilentFor
+ {
+ get
+ {
+ long last;
+ lock (_gate) last = _lastSoundAt;
+
+ return _time.GetElapsedTime(last);
}
}
@@ -165,6 +250,9 @@ public void Reset()
{
Array.Clear(_sumOfSquares);
Array.Clear(_samples);
+
+ _clipped = false;
+ _lastSoundAt = _time.GetTimestamp();
}
}
diff --git a/tests/Offstream.Core.Tests/Audio/AudioLevelMeterTests.cs b/tests/Offstream.Core.Tests/Audio/AudioLevelMeterTests.cs
index 7c1549a..5de925b 100644
--- a/tests/Offstream.Core.Tests/Audio/AudioLevelMeterTests.cs
+++ b/tests/Offstream.Core.Tests/Audio/AudioLevelMeterTests.cs
@@ -366,4 +366,116 @@ public void ChannelCount_FollowsTheFormat()
[Fact]
public void Constructor_RejectsANullFormat() =>
Assert.Throws(() => new AudioLevelMeter(null!));
+
+ ///
+ /// Drives without waiting. Frequency of 1,000 makes
+ /// one tick a millisecond, so the arithmetic in the tests reads as time.
+ ///
+ private sealed class TestClock : TimeProvider
+ {
+ private long _timestamp;
+
+ public override long GetTimestamp() => _timestamp;
+
+ public override long TimestampFrequency => 1_000;
+
+ public void Advance(TimeSpan amount) => _timestamp += (long)(amount.TotalSeconds * TimestampFrequency);
+ }
+
+ ///
+ /// The clip flag has to come off the peak sample. Every reading this meter publishes is RMS,
+ /// which for real music sits well under the peak — so a lamp driven from a reading would stay
+ /// dark through a recording that is audibly clipped.
+ ///
+ [Fact]
+ public void HasClipped_IsSetByAPeakTheRmsReadingNeverShows()
+ {
+ var meter = new AudioLevelMeter(Float32);
+
+ // One sample at full scale among a run of quiet ones: clipping as it actually arrives.
+ meter.Write(BitConverter.GetBytes(1.0f));
+ for (var i = 0; i < 200; i++) meter.Write(BitConverter.GetBytes(0.02f));
+
+ Assert.True(meter.HasClipped);
+ Assert.True(meter.Read().Decibels < -20, "the RMS reading stays far below full scale");
+ }
+
+ ///
+ /// A converter out of headroom pins to the largest value the format holds, which for 16-bit
+ /// is 32767/32768 — never exactly full scale once normalised.
+ ///
+ [Fact]
+ public void HasClipped_CatchesAPinnedIntegerSample()
+ {
+ var meter = new AudioLevelMeter(Pcm16);
+
+ meter.Write(BitConverter.GetBytes(short.MaxValue));
+
+ Assert.True(meter.HasClipped);
+ }
+
+ [Fact]
+ public void HasClipped_IsNotSetByLoudAudioShortOfFullScale()
+ {
+ var meter = new AudioLevelMeter(Float32);
+
+ meter.Write(BitConverter.GetBytes(0.9f));
+
+ Assert.False(meter.HasClipped);
+ }
+
+ /// Reading the meter must not clear the latch — the two are independent.
+ [Fact]
+ public void HasClipped_SurvivesARead()
+ {
+ var meter = new AudioLevelMeter(Float32);
+
+ meter.Write(BitConverter.GetBytes(1.0f));
+ meter.Read();
+
+ Assert.True(meter.HasClipped);
+
+ meter.ResetClip();
+
+ Assert.False(meter.HasClipped);
+ }
+
+ [Fact]
+ public void SilentFor_GrowsWhileNothingArrives()
+ {
+ var clock = new TestClock();
+ var meter = new AudioLevelMeter(Float32, clock);
+
+ clock.Advance(TimeSpan.FromSeconds(30));
+
+ Assert.Equal(TimeSpan.FromSeconds(30), meter.SilentFor);
+ }
+
+ [Fact]
+ public void SilentFor_IsResetByAudio()
+ {
+ var clock = new TestClock();
+ var meter = new AudioLevelMeter(Float32, clock);
+
+ clock.Advance(TimeSpan.FromSeconds(30));
+ meter.Write(BitConverter.GetBytes(0.5f));
+
+ Assert.Equal(TimeSpan.Zero, meter.SilentFor);
+ }
+
+ ///
+ /// A dither floor is not audio. The threshold sits at −80 dBFS so that a capture graph idling
+ /// on noise still counts as silent — which is the case the warning exists for.
+ ///
+ [Fact]
+ public void SilentFor_TreatsADitherFloorAsSilence()
+ {
+ var clock = new TestClock();
+ var meter = new AudioLevelMeter(Float32, clock);
+
+ clock.Advance(TimeSpan.FromSeconds(10));
+ meter.Write(BitConverter.GetBytes(0.00001f));
+
+ Assert.Equal(TimeSpan.FromSeconds(10), meter.SilentFor);
+ }
}
diff --git a/tests/Offstream.UI.Tests/RecordViewModelTests.cs b/tests/Offstream.UI.Tests/RecordViewModelTests.cs
index 631db1d..9a68db6 100644
--- a/tests/Offstream.UI.Tests/RecordViewModelTests.cs
+++ b/tests/Offstream.UI.Tests/RecordViewModelTests.cs
@@ -586,13 +586,25 @@ await ReportAsync(
/// The format line describes what pressing Start would produce, so it has to say something
/// before anything is running - an empty field on an idle display reads as a fault.
///
+ ///
+ /// Only the technical run is asserted to be upper case. It used to be the whole string,
+ /// which held while the line was nothing but codes and numbers; the capture device's name
+ /// now follows them after a separator, and a name keeps the capitalisation its maker gave
+ /// it. Shouting "SPEAKERS (REALTEK(R) AUDIO)" to preserve a rule about codes would make the
+ /// one part of this line a person actually reads the hardest part to read.
+ ///
[Fact]
public void FormatText_IsPopulatedBeforeAnythingStarts()
{
var text = ViewModelFor().FormatText;
Assert.False(string.IsNullOrWhiteSpace(text));
- Assert.Equal(text, text.ToUpperInvariant());
+
+ // The device is absent on a machine with no render endpoint, which is what CI is.
+ var technical = text.Split(" · ", StringSplitOptions.None)[0];
+
+ Assert.False(string.IsNullOrWhiteSpace(technical));
+ Assert.Equal(technical, technical.ToUpperInvariant());
}
[Fact]
diff --git a/tests/Offstream.UI.Tests/RecordingControllerTests.cs b/tests/Offstream.UI.Tests/RecordingControllerTests.cs
index df10168..055a776 100644
--- a/tests/Offstream.UI.Tests/RecordingControllerTests.cs
+++ b/tests/Offstream.UI.Tests/RecordingControllerTests.cs
@@ -387,6 +387,86 @@ public async Task Dispose_StopsListeningToTheSettingsDocument()
Assert.Equal(0, raised);
}
+ ///
+ /// The countdown only exists while a session does. Reading it from an idle controller has to
+ /// be null rather than the full duration, or the display would show a timer counting down
+ /// before anything had started.
+ ///
+ [Fact]
+ public async Task TimerRemaining_IsNullBeforeAnythingStarts()
+ {
+ var document = RecordingFakes.Document();
+ document.Update(settings => settings with { Recording = settings.Recording with { Timer = "010000" } });
+
+ await using var controller = new RecordingController(new FakeSessionFactory(), document);
+
+ Assert.Null(controller.TimerRemaining);
+ }
+
+ [Fact]
+ public async Task TimerRemaining_IsNullWhileRunningWithNoTimerArmed()
+ {
+ await using var controller = new RecordingController(new FakeSessionFactory(), RecordingFakes.Document());
+
+ await controller.StartAsync();
+
+ Assert.Null(controller.TimerRemaining);
+ }
+
+ [Fact]
+ public async Task TimerRemaining_CountsDownFromTheArmedDuration()
+ {
+ var document = RecordingFakes.Document();
+ document.Update(settings => settings with { Recording = settings.Recording with { Timer = "010000" } });
+
+ await using var controller = new RecordingController(new FakeSessionFactory(), document);
+
+ await controller.StartAsync();
+
+ var left = controller.TimerRemaining;
+
+ Assert.NotNull(left);
+ Assert.InRange(left.Value, TimeSpan.FromMinutes(59), TimeSpan.FromMinutes(60));
+ }
+
+ /// Stopping puts the countdown away with the session that owned it.
+ [Fact]
+ public async Task TimerRemaining_IsNullAgainAfterStopping()
+ {
+ var document = RecordingFakes.Document();
+ document.Update(settings => settings with { Recording = settings.Recording with { Timer = "010000" } });
+
+ await using var controller = new RecordingController(new FakeSessionFactory(), document);
+
+ await controller.StartAsync();
+ await controller.StopAsync();
+
+ Assert.Null(controller.TimerRemaining);
+ }
+
+ ///
+ /// The running size is bitrate times elapsed, so it can only be offered for a format whose
+ /// bitrate is fixed. A FLAC's size depends on the music.
+ ///
+ [Fact]
+ public async Task BytesPerSecond_IsTheBitrateForALossyFormat()
+ {
+ await using var controller = new RecordingController(new FakeSessionFactory(), RecordingFakes.Document());
+
+ Assert.Equal(320 * 1000 / 8, controller.BytesPerSecond);
+ }
+
+ [Fact]
+ public async Task BytesPerSecond_IsNullForALosslessFormat()
+ {
+ var document = RecordingFakes.Document();
+ document.Update(settings => settings with { Output = settings.Output with { Format = Offstream.Core.Metadata.MediaFormat.Flac } });
+
+ await using var controller = new RecordingController(new FakeSessionFactory(), document);
+
+ Assert.Null(controller.BytesPerSecond);
+ }
+
[Fact]
public void Constructor_RejectsNulls()
{