From f46919c2952ad51cdce9b6b9c29564028b8c1d36 Mon Sep 17 00:00:00 2001 From: revtex Date: Fri, 4 Sep 2026 11:53:44 -0400 Subject: [PATCH] Say what a file's audio actually is, on the page built to repair it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Metadata page reviews a folder of files and repairs what their tags say. It never said anything about the audio underneath those tags, so a 96 kbps download and a lossless rip presented identically on the one page where a whole library passes under review — the place where noticing would cost nothing, because the user is already looking at every row. The obvious way to answer this is the wrong size for the question. A recorder in the same space does it by decoding the samples and running an FFT over them, hunting the brick-wall low-pass a lossy encoder leaves behind, and it earns that: a spectral cut-off is the only thing that catches a lossy file rewrapped as FLAC, where the container is lying. That is a real feature and a much larger one — a DSP pass per file, a confidence model, and a verdict that can be wrong. What is wanted here is the smaller, honest half: what does the file itself claim. TagLib# already has it. The scan opens every file to read its tags, and the same File object carries Properties — bitrate, sample rate, bit depth — so the answer costs one more open per row and no new package, no ffprobe, and no decoding. AudioQuality holds those three numbers and nothing else, and the tier falls out of them. Lossless is decided by bit depth rather than by codec name: TagLib# populates BitsPerSample only for codecs that have a fixed one, which is exactly the lossless set, and reports zero for MP3, AAC, Opus and Vorbis, none of which have one. That is a free and reliable discriminator, and it beats matching a list of codec strings that has to stay right about formats nobody here has seen. The reader is its own seam rather than an extra return value on ILibraryTagStore. That interface is heavily covered and exists so a scan can be tested without a real audio file for every case; widening Read to return a pair would have rewritten those tests to carry a value none of them care about. A second interface costs one more TagLib# open per file during a scan the user asked for, on a folder measured in thousands, and buys leaving that seam alone. LibraryTrack takes the quality as an optional third argument for the same reason — every existing two-argument construction, nearly all of it in tests, keeps compiling untouched. Only the low tier is coloured. A row that announces itself in amber for being 320 kbps is a warning about nothing, and a page where most rows are coloured has taught the user to stop reading the colour. Lossy under 128 kbps is the one reading worth a second look, so it is the only one that gets the caution brush; the rest is stated and left alone. The label goes under the file name in the existing hint style rather than into a pill beside the "Will change" badge, because it describes the file the row came from, not something Save is going to do about it. The .m4a label is the one place the extension cannot answer on its own — Offstream only ever writes AAC into it, but a file from elsewhere can be ALAC. The tier already knows, so a lossless .m4a is labelled ALAC rather than guessed wrong. TagLibAudioQualityReaderTests encodes with BitrateMode.Constant rather than the averaging default, which is not incidental. The shared fixture's source is a two-second 440 Hz sine — trivially compressible — and ABR allocates on spectral demand, so a request for 320 kbps came back under 128 and the tier assertion failed on encoder behaviour that was entirely correct. The plan already records the neighbouring version of this trap, that pink noise is the wrong thing to measure ABR on. CBR pins every frame to the request and makes the reading deterministic. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 ++ src/Offstream.App/Resources/Strings.fr.resx | 6 ++ src/Offstream.App/Resources/Strings.resx | 8 ++ src/Offstream.App/Services/AppServices.cs | 1 + .../ViewModels/LibraryTrackViewModel.cs | 60 +++++++++++ .../Views/Pages/MetadataPage.xaml | 22 ++++ .../Metadata/Library/AudioQuality.cs | 100 ++++++++++++++++++ .../Metadata/Library/LibraryScanner.cs | 7 +- .../Metadata/Library/LibraryTrack.cs | 11 +- .../Metadata/Library/AudioQualityTests.cs | 33 ++++++ .../Metadata/Library/LibraryScannerTests.cs | 41 ++++++- .../Library/TagLibAudioQualityReaderTests.cs | 90 ++++++++++++++++ .../LibraryTrackViewModelTests.cs | 49 +++++++++ 13 files changed, 429 insertions(+), 8 deletions(-) create mode 100644 src/Offstream.Core/Metadata/Library/AudioQuality.cs create mode 100644 tests/Offstream.Core.Tests/Metadata/Library/AudioQualityTests.cs create mode 100644 tests/Offstream.Core.Tests/Metadata/Library/TagLibAudioQualityReaderTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9d16d..2d3e973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,15 @@ phase plan these entries follow. and why — with three commands for checking a finished recording, because the failure this catches produces a file that plays perfectly and measures wrong. +- **The Metadata page now shows what a file's own bitrate and codec actually are, not just its + tags.** Repairing a library's metadata never said anything about the audio underneath it — a + file downloaded at 96 kbps and a lossless rip looked identical on the one page built to review a + folder of them. Each row now carries a line under its name — `MP3 · 320 kbps`, `FLAC · + Lossless` — read straight from the file's own container properties, so it costs one more file + open per row and nothing else: no ffprobe, no decoding, no spectral analysis, no guessing beyond + what the header already claims. Only the one tier worth a second look, a lossy file under 128 + kbps, gets the caution colour; everything else is background information. + ### Fixed - **One notch of the wheel jumped three tracks in the session list.** The list of what has been diff --git a/src/Offstream.App/Resources/Strings.fr.resx b/src/Offstream.App/Resources/Strings.fr.resx index e347c72..3be534d 100644 --- a/src/Offstream.App/Resources/Strings.fr.resx +++ b/src/Offstream.App/Resources/Strings.fr.resx @@ -562,6 +562,12 @@ Sera modifié + + {0} · Sans perte + + + {0} · {1} kbit/s + Titre diff --git a/src/Offstream.App/Resources/Strings.resx b/src/Offstream.App/Resources/Strings.resx index 6d17934..48395f5 100644 --- a/src/Offstream.App/Resources/Strings.resx +++ b/src/Offstream.App/Resources/Strings.resx @@ -718,6 +718,14 @@ Will change Marks a row whose save would alter the file, so those rows can be found without opening each one. + + {0} · Lossless + {0} is the codec name, e.g. "FLAC". Shown under a row for a lossless file. + + + {0} · {1} kbps + {0} is the codec name, e.g. "MP3"; {1} is the bitrate the file reports. + Title diff --git a/src/Offstream.App/Services/AppServices.cs b/src/Offstream.App/Services/AppServices.cs index ab7c66b..643613f 100644 --- a/src/Offstream.App/Services/AppServices.cs +++ b/src/Offstream.App/Services/AppServices.cs @@ -84,6 +84,7 @@ public static IServiceCollection AddOffstream( // independent of the recording pipeline's: a scanner over the file system, a TagLib# // store, and a provider chain rebuilt per run so signing in takes effect immediately. services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Offstream.App/ViewModels/LibraryTrackViewModel.cs b/src/Offstream.App/ViewModels/LibraryTrackViewModel.cs index dc69c98..4aa3892 100644 --- a/src/Offstream.App/ViewModels/LibraryTrackViewModel.cs +++ b/src/Offstream.App/ViewModels/LibraryTrackViewModel.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; +using System.Text; using System.Windows.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using Offstream.App.Resources; @@ -27,6 +28,12 @@ namespace Offstream.App.ViewModels; /// public sealed partial class LibraryTrackViewModel : ObservableValidator { + private static readonly CompositeFormat QualityLosslessFormat = + CompositeFormat.Parse(Strings.MetadataQualityLossless); + + private static readonly CompositeFormat QualityBitrateFormat = + CompositeFormat.Parse(Strings.MetadataQualityBitrate); + private readonly LibraryTrack _track; private readonly string? _existingTitle; private readonly string? _existingArtist; @@ -87,6 +94,9 @@ public LibraryTrackViewModel(LibraryTrack track) CoverArt = LoadCoverArt(track.Existing.AlbumArtImage); ExistingCoverArt = CoverArt; + + QualityLabel = FormatQuality(track.Quality, FileName); + IsQualityLow = track.Quality.Tier == AudioQualityTier.Low; } /// The file's own name, which is what identifies the row. @@ -133,6 +143,21 @@ public LibraryTrackViewModel(LibraryTrack track) /// public string CurrentCopyright { get; } + /// + /// What the file's own audio properties say, e.g. "MP3 · 128 kbps" or "FLAC · Lossless", or + /// null when the file reported nothing usable. + /// + public string? QualityLabel { get; } + + /// Whether has anything to show. + public bool HasQuality => QualityLabel is not null; + + /// + /// Whether the file is lossy at under 128 kbps — audibly compressed on most material, and the + /// one tier worth calling out rather than just stating. + /// + public bool IsQualityLow { get; } + /// The scanned track this row edits. internal LibraryTrack Track => _track; @@ -538,6 +563,41 @@ public void SeedMatchQuery() MatchQuery = string.Join(' ', new[] { Artist, Title }.Where(part => !string.IsNullOrWhiteSpace(part))); } + /// + /// Turns a container-property read into the one line the row shows. Deliberately just a + /// codec name and a number — no spectral analysis, no transcode detection, just what the + /// file's own header already says. + /// + private static string? FormatQuality(AudioQuality quality, string fileName) + { + var codec = CodecName(System.IO.Path.GetExtension(fileName), quality.Tier); + + if (quality.Tier == AudioQualityTier.Lossless) + { + return string.Format(CultureInfo.CurrentCulture, QualityLosslessFormat, codec); + } + + return quality.BitrateKbps is > 0 + ? string.Format(CultureInfo.CurrentCulture, QualityBitrateFormat, codec, quality.BitrateKbps) + : null; + } + + /// A short codec name from the file's extension, which the scanner already filtered on. + /// + /// .m4a is ambiguous by extension alone — Offstream only ever writes AAC into it, but a + /// file from elsewhere can be ALAC. already tells the two + /// apart, so a lossless .m4a is labelled ALAC rather than guessed wrong. + /// + private static string CodecName(string extension, AudioQualityTier tier) => extension.ToUpperInvariant() switch + { + ".M4A" => tier == AudioQualityTier.Lossless ? "ALAC" : "AAC", + ".MP3" => "MP3", + ".FLAC" => "FLAC", + ".OPUS" => "Opus", + ".OGG" => "Vorbis", + _ => extension.TrimStart('.').ToUpperInvariant(), + }; + private static string Or(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value; diff --git a/src/Offstream.App/Views/Pages/MetadataPage.xaml b/src/Offstream.App/Views/Pages/MetadataPage.xaml index b8f8b43..df9409c 100644 --- a/src/Offstream.App/Views/Pages/MetadataPage.xaml +++ b/src/Offstream.App/Views/Pages/MetadataPage.xaml @@ -107,6 +107,28 @@ TextTrimming="CharacterEllipsis" TextWrapping="NoWrap" /> + + + + + + +