From 31992699d47b46478f46d5880a4b25d33cb9374b Mon Sep 17 00:00:00 2001 From: revtex Date: Wed, 2 Sep 2026 13:15:09 -0400 Subject: [PATCH] Five readouts on the transport display, three of which catch a ruined recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display said what the file would be and how long it had been going. It said nothing about whether any of it was working, which is the question the page is actually there to answer. Recording an hour of silence is this app's signature failure. AudioLevelMeter's own remarks name it — audio routed to a device that is not being captured shows full volume in Windows and records nothing — and until now the only symptom was a flat meter nobody was watching. A SILENT lamp appears after six seconds and counts how long the quiet has run; six because the gap between two tracks is silent and a warning that flashes up between every song is one nobody reads by the third time. A CLIP lamp latches when the audio reaches full scale, because distortion is inaudible while you are not listening and permanent once encoded. Both are absent unless something is wrong, so the ordinary display is unchanged. The capture device's name now sits on the line that describes the file, which is the question the silence lamp raises and previously meant a trip to Settings to answer; the running size follows it for constant-bitrate formats; and a STOPS IN countdown appears whenever a recording timer is armed, which was set on Advanced and then wholly invisible on the page running it. The interesting constraint was where to detect any of it. Read drains the interval it reports, so polling it for silence or clipping would have taken samples away from the bars and left the meter reading low by an amount that varied with how often each side polled — a bug with no symptom either side would show. Both flags are folded in on the capture thread inside Write instead, where every sample passes once and nothing is consumed. Two things fell out of that. Clipping has to be tested on the peak sample, not on a reading: everything this meter publishes is RMS, which sits ten to twenty decibels under the peak on real music, so a lamp driven from LevelReading.Decibels would never have lit. And the threshold is 0.999 rather than 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. FormatText_IsPopulatedBeforeAnythingStarts asserted the whole line was upper case, which held while it was nothing but codes and numbers. It now covers the technical run only: the device name follows after a separator and keeps the capitalisation its maker gave it, since shouting SPEAKERS (REALTEK(R) AUDIO) to preserve a rule about codes would make the one part of the line anyone reads the hardest to read. --- CHANGELOG.md | 18 ++ CLAUDE.md | 13 ++ README.md | 8 +- docs/MODERNIZATION-PLAN.md | 27 +++ src/Offstream.App/Resources/Strings.fr.resx | 24 ++ src/Offstream.App/Resources/Strings.resx | 24 ++ .../Services/RecordingController.cs | 83 ++++++- src/Offstream.App/Themes/Tokens.xaml | 7 + .../ViewModels/RecordViewModel.cs | 107 ++++++++- src/Offstream.App/Views/Pages/RecordPage.xaml | 218 ++++++++++++------ src/Offstream.Core/Audio/AudioLevelMeter.cs | 94 +++++++- .../Audio/AudioLevelMeterTests.cs | 112 +++++++++ .../RecordViewModelTests.cs | 14 +- .../RecordingControllerTests.cs | 80 +++++++ 14 files changed, 741 insertions(+), 88 deletions(-) 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() {