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() {