diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1345b..3f9d16d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,9 @@ phase plan these entries follow. - **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. + size follows it for the formats that have a bitrate to go on. 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. - **How to record a copy that is identical to what Spotify sent.** Four things on the path between Spotify and a recording quietly change the audio, and every one of them is on by default: the @@ -80,6 +80,26 @@ Nothing else in `settings.json` changes meaning, and no recording already on dis ### Changed +- **MP3 spends its bitrate where the music needs it, instead of the same amount everywhere.** + Every MP3 Offstream wrote held each frame at exactly the rate you picked, whether the frame was + a dense chorus or the tail of a fade — bits spent on silence are bits not available to the loud + passage two seconds later. MP3 now averages towards the chosen rate across the recording, which + is what the encoder does well and what every other format here was already doing. The rate you + pick therefore describes the recording rather than each frame of it, so a file can come in under + the nominal figure; that is the point, not a shortfall. **320 kbps (constant)** is a rung of its + own at the foot of the bitrate list for anyone who wants the old behaviour, whether for a + hardware player that expects it or simply to be sure. It is remembered while you look at a + format that has no such choice — FLAC and Opus hide the rung, and picking MP3 again brings it + back rather than quietly resetting it. Existing settings files have no such preference recorded + and are read as averaged. + +- **The bitrate list says which rungs Spotify actually streams at.** Choosing between six numbers + meant guessing where the source stopped and the waste began. **160 kbps** and **320 kbps** are + now marked as the rates free and Premium accounts stream at, so recording no worse — and no + larger — than what arrived is a matter of reading the list rather than knowing it. The hint + beside the control adds the part the labels cannot: a lossless stream is above both, and FLAC is + what keeps it. + - **The three advertisement switches are one dropdown, because between them they only ever had three answers.** "Mute advertisements", "Record everything Spotify plays" and "Include advertisements" were three on/off settings over four combinations — two of which did exactly the diff --git a/README.md b/README.md index caf2592..3faca73 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,14 @@ Defaults are sensible if you'd rather not decide: recordings go to `%USERPROFILE MP3, the device is whatever Windows is currently playing through, and anything under 30 seconds is discarded. +The bitrate list marks the two rungs Spotify itself streams at — 160 kbps for free accounts, 320 +for Premium — so you can record no worse, and no larger, than what arrived. (A lossless stream is +above both; see [Recording a bit-exact copy](#recording-a-bit-exact-copy).) MP3 aims for the rate +you pick across the whole recording rather than holding it frame by frame, so quiet or simple +passages cost fewer bits and a finished file can come in a little under the number on the label. +If you need every frame at the full rate — some hardware players expect it — pick **320 kbps +(constant)** at the foot of the list. + **4. Press Start, then play something** Go back to **Record** and press Start. Play music in Spotify. Offstream waits for a track to begin, diff --git a/docs/MODERNIZATION-PLAN.md b/docs/MODERNIZATION-PLAN.md index 473962d..c9bc3f6 100644 --- a/docs/MODERNIZATION-PLAN.md +++ b/docs/MODERNIZATION-PLAN.md @@ -172,7 +172,7 @@ All conversion goes through ffmpeg. Capture writes raw PCM WAV to a temp file; f | Format | Args | Notes | | --- | --- | --- | -| MP3 | `-c:a libmp3lame -b:a {rate}k` | CBR; VBR (`-q:a`) can be exposed later | +| MP3 | `-c:a libmp3lame -b:a {rate}k [-abr 1]` | ABR by default, CBR on request — see the 2026-09-02 finding; true VBR (`-q:a`) can be exposed later | | WAV | `-c:a pcm_s16le` | Never a stream copy — the temp is float32, see the 2026-09-02 finding | | Opus | `-c:a libopus -b:a {rate}k` | Ogg container, `.opus` | | **FLAC** | `-c:a flac -compression_level 8` | New — near-free once ffmpeg owns conversion | @@ -410,7 +410,7 @@ Deliberate departures so far, each a consequence of a decision already taken: - **`FromLegacySettings` is not ported.** It rebuilt a template from the predecessor's pre-template checkbox settings; with no importer (§6) nothing can supply its inputs. Its six test cases go with it. - **The reference suite's idle-state test used the predecessor's product-name constant** as its negative case. §0 forbids that identifier, so the ported test asserts on a real track title instead — same property, stated more directly. - **`GetTempFileName` → `GetTempPath` + `GetRandomFileName`.** The framework method is obsolete on modern .NET: it creates the file eagerly and gives up after 65535 names in a directory, which is a real ceiling for an app that records unattended overnight. The `.tmp` extension is kept because `DeleteFile` uses it to tell a scratch file from a finished recording. -- **`RecordingSettings.BitrateKbps` replaces `LAMEPreset`.** Forced by removing NAudio.Lame (§8); ffmpeg takes `-b:a {rate}k`. +- **`RecordingSettings.BitrateKbps` replaces `LAMEPreset`.** Forced by removing NAudio.Lame (§8); ffmpeg takes `-b:a {rate}k`. Joined on 2026-09-02 by `BitrateMode`, which is the *other* half of what the preset bundled — a two-member enum beside a number, rather than one value meaning both (see the encoding-profile finding). - **Exceptions are typed.** The reference threw bare `Exception` for "no artist" and "empty file name"; those are now `UnrecognizedTrackException`, and invalid arguments are `ArgumentException`, so callers can distinguish them. Assertions changed from `Assert.Throws` to the specific type — the only test edits made so far beyond renaming. - **Title parsing is split from API enrichment.** The reference's `SpotifyStatus` both parsed the window title and called an `ExternalAPI.Instance` singleton to enrich the result, so no test could parse a title without stubbing a global. `SpotifyTitleParser` is now pure; enrichment moves to the metadata layer. This is the §3 "core must be testable" rule applied one level below the UI boundary, and it is why the ported process tests need no API stub at all. - **`ISpotifyPlaybackProbe`** replaces a direct dependency on the whole audio-session manager. Track detection needs exactly one bit — is Spotify making sound — so it depends on one method instead of the audio stack, and stays testable with no audio hardware. @@ -1042,13 +1042,18 @@ The tempting fix is worse than the bug: splitting the box on commas the way the The never-erase rule from the day before became one helper, `LibraryLookup`, rather than two hand-written copies, and grew from genre and year to all seven fields a lookup can leave empty. There are still two call sites, and both are still load-bearing — `MetadataViewModel.FetchOneAsync` for every automatic lookup and `SpotifyCatalogEnricher` for the manual **Use this** path, which does not pass through it — but the rule itself now exists once, so the next field cannot be added to one copy and forgotten in the other. That is precisely how the bug survived its first fix. -### Finding: three of the four ways the encoding profiles differ from a comparable recorder are ours to keep (2026-09-02) +### Finding: two of the four ways the encoding profiles differ from a comparable recorder are ours to keep (2026-09-02) The profiles were audited flag by flag against another Windows Spotify recorder's, on the working -assumption that any difference was a gap. One was. The other three are decisions this project had +assumption that any difference was a gap. Two were. The other two are decisions this project had already taken, and they are written down here rather than left to be rediscovered, because each one reads from the outside as an obvious improvement that nobody has got round to. +> This finding first recorded **three** of the four as ours to keep, on the reasoning below about +> MP3 rate mode. That reasoning was sound about the trade-off and wrong about the conclusion, and +> the MP3 paragraph now records what was actually built. It is left in place rather than deleted +> because the argument it makes is what shaped the fix. + **The real gap: the attached picture was typed `Other`.** `-disposition:v attached_pic` marks a stream as cover art and settles nothing else about it — in particular it leaves the picture type at 0, which is `Other` in both ID3's `APIC` frame and FLAC's `METADATA_BLOCK_PICTURE`. Every file @@ -1068,14 +1073,35 @@ argument alone, then a byte search of the output — is what separated them, and both arguments and stores neither: the mov muxer keeps a cover as a bare atom with nowhere to put a type or a description. -**MP3 stays constant-bitrate.** The other ladder passes `-abr 1` at every rung and keeps plain -`-b:a 320k` for a top rung of its own. Offstream's `-b:a {rate}k` with no `-abr` *is* that top -rung — the default output is already the best MP3 that ladder can produce. Taking the `-abr 1` -without also adding a constant-bitrate rung would move the default down a notch on the -most-used format; adding the rung means the bitrate stops being a plain kbps number, which is the -`LAMEPreset`-shaped setting §5.1 rejected on purpose. ABR is a genuine quality win at 96 and 128 -kbps, so this is worth reopening if those rungs turn out to be the ones people use. It buys -nothing at a default of 320. +**MP3 takes `-abr 1`, and the constant rung it would have cost is a rung of its own.** The other +ladder passes `-abr 1` at every rung and keeps plain `-b:a 320k` for a top rung beside them. +Offstream's `-b:a {rate}k` with no `-abr` *is* that top rung, which is why the first reading of +this was "already there": the default output was the best MP3 that ladder can produce, and taking +the `-abr 1` on its own would have moved the default down a notch on the most-used format. + +What that reading got wrong was treating the rung as unaffordable. The objection to adding it was +that the bitrate would stop being a plain kbps number, which is the `LAMEPreset`-shaped setting +§5.1 rejected on purpose — but a preset enum bundles the rate and the mode into one value, and +that is the part §5.1 rejected. Keeping them as two values, an `int` and a two-member +`BitrateMode`, is not that setting: `bitrateKbps` is still a number the file can be hand-edited +to, and validation still has a range to check it against. A single dropdown presents the pair as +one ladder, so the split costs the page nothing. + +The flag itself is **profile data, not a branch**: `EncodingProfile.AverageBitrateArguments` +holds `["-abr", "1"]` for MP3 and is empty everywhere else, so `Build` appends whatever the +profile declares and never asks what format it is looking at. Empty is the honest answer for the +other lossy profiles rather than an omission — libopus and the native AAC encoder both vary their +rate unasked, so there is no switch to throw and no constant rung to offer beside them. +`SupportsBitrateMode` is derived from that list being non-empty, which is what keeps the dropdown +and the encoder from disagreeing when a format is added. + +Two things worth knowing before measuring this. **ABR at a given rate produces a smaller file +than CBR at the same rate**, because the encoder is aiming for the number across the recording +instead of holding it — the nominal rate stops being a promise about the file's size. And **pink +noise is the wrong thing to measure it on**: two synthetic sources here, one at a steady level +and one with a slow 6 dB swell, came back within 0.1% of each other at ABR 320, because LAME +allocates on spectral demand and noise is equally easy to code at any level. Neither number says +anything about music, which is why none is quoted here or in the changelog. **WAV stays `-c:a pcm_s16le`, and the stream-copy §5.1 used to offer is off the table.** The other implementation copies the captured WAV instead of encoding it, which is the same idea. The diff --git a/src/Offstream.App/Resources/Strings.fr.resx b/src/Offstream.App/Resources/Strings.fr.resx index 88437ef..e347c72 100644 --- a/src/Offstream.App/Resources/Strings.fr.resx +++ b/src/Offstream.App/Resources/Strings.fr.resx @@ -172,6 +172,18 @@ {0} kbit/s + + {0} kbit/s (constant) + + + {0} kbit/s (Spotify Free) + + + {0} kbit/s (Spotify Premium) + + + Le débit est moyenné sur l'enregistrement : les passages calmes ou simples coûtent moins de bits qu'un passage dense. Le palier constant maintient chaque trame au débit maximal, pour les lecteurs qui l'exigent. Spotify diffuse jusqu'à 160 kbit/s pour les comptes gratuits et 320 pour Premium ; un flux sans perte dépasse les deux, et c'est le FLAC qui le conserve. + Ce format choisit lui-même son débit. diff --git a/src/Offstream.App/Resources/Strings.resx b/src/Offstream.App/Resources/Strings.resx index c814251..6d17934 100644 --- a/src/Offstream.App/Resources/Strings.resx +++ b/src/Offstream.App/Resources/Strings.resx @@ -213,6 +213,22 @@ {0} kbps One entry in the bitrate dropdown. {0} is the rate in kilobits per second. + + {0} kbps (constant) + Bitrate dropdown entry for the constant-rate rung. {0} is the rate in kilobits per second. + + + {0} kbps (Spotify Free) + Bitrate dropdown entry marking the rate a free Spotify account streams at. "Spotify Free" is a product name and is not translated. {0} is the rate in kilobits per second. + + + {0} kbps (Spotify Premium) + Bitrate dropdown entry marking the rate a Premium Spotify account streams at. "Spotify Premium" is a product name and is not translated. {0} is the rate in kilobits per second. + + + The rate is averaged across the recording, so quiet or simple passages cost fewer bits than a dense one. The constant rung holds every frame at the full rate instead, for players that expect it. Spotify streams at up to 160 kbps to free accounts and 320 to Premium; a lossless stream is above both, and FLAC is what keeps it. + Settings page: tooltip and screen-reader help for the bitrate dropdown. + This format sets its own rate. Shown next to the disabled bitrate control for FLAC and WAV. diff --git a/src/Offstream.App/Services/RecordingController.cs b/src/Offstream.App/Services/RecordingController.cs index 93a9ee3..681b04b 100644 --- a/src/Offstream.App/Services/RecordingController.cs +++ b/src/Offstream.App/Services/RecordingController.cs @@ -155,9 +155,17 @@ public string FormatSummary /// 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. + /// + /// The nominal rate, which is exact for a constant-rate encode and close for an averaged one + /// — averaging aims at the same number across the recording, so the estimate drifts within a + /// track and lands near the right place by the end of it. Close is what this line needs: it + /// says roughly how large the file is getting, beside a clock that is already approximate. + /// + /// + /// FLAC and WAV are excluded rather than estimated. A FLAC's size depends on how compressible + /// the music is, and a guess there would be wrong by a quarter either way on ordinary + /// material — which is a different kind of wrong from a rate the encoder is aiming for. + /// /// public int? BytesPerSecond { diff --git a/src/Offstream.App/ViewModels/SettingsViewModel.cs b/src/Offstream.App/ViewModels/SettingsViewModel.cs index 506b6cb..ffb8b77 100644 --- a/src/Offstream.App/ViewModels/SettingsViewModel.cs +++ b/src/Offstream.App/ViewModels/SettingsViewModel.cs @@ -24,6 +24,18 @@ public sealed record AudioDeviceOption(string? Id, string Name, bool IsAvailable /// One choice in a dropdown backed by an enum. public sealed record ChoiceOption(T Value, string Name); +/// One rung of the bitrate ladder: a rate, and how the encoder is to spend it. +/// +/// A struct rather than a class because the dropdown selects it by value: WPF's +/// SelectedValue compares the stored selection against the items with +/// , and a record struct compares field by field where a +/// record class of the same shape would too — but a plain class would not, and a rung that +/// never matches leaves the dropdown blank on load with nothing to point at. +/// +/// The rate in kilobits per second. +/// Whether the encoder holds that rate or averages towards it. +public readonly record struct BitrateChoice(int Kbps, BitrateMode Mode); + /// /// Backs the Settings page: where recordings go, what they sound like, and where their /// details come from. @@ -52,6 +64,22 @@ public sealed partial class SettingsViewModel : ObservableValidator /// private static readonly int[] StandardBitrates = [96, 128, 160, 192, 256, 320]; + /// The one rate offered as a constant-rate rung. + /// + /// Constant rate is worth offering where the ceiling makes the extra bits harmless, and + /// it is the rung a user picks to be certain every frame is full size — a guarantee some + /// hardware players and broadcast tools still want. Below the ceiling the choice is between + /// two ways of being short of bits, which is not a decision worth a second dropdown entry. + /// A stored setting that asks for constant rate at some other rate is honoured all the same + /// (see ); it is simply not offered. + /// + private const int ConstantRateKbps = 320; + + /// The bitrate rungs Spotify's own tiers correspond to. + private const int SpotifyFreeKbps = 160; + + private const int SpotifyPremiumKbps = 320; + /// /// The two resource strings with placeholders, parsed once. /// @@ -65,6 +93,32 @@ public sealed partial class SettingsViewModel : ObservableValidator private static readonly CompositeFormat MinimumLengthInvalidFormat = CompositeFormat.Parse(Strings.SettingsMinimumLengthInvalid); + private static readonly CompositeFormat BitrateItemFormat = + CompositeFormat.Parse(Strings.SettingsBitrateItem); + + private static readonly CompositeFormat BitrateItemConstantFormat = + CompositeFormat.Parse(Strings.SettingsBitrateItemConstant); + + private static readonly CompositeFormat BitrateItemSpotifyFreeFormat = + CompositeFormat.Parse(Strings.SettingsBitrateItemSpotifyFree); + + private static readonly CompositeFormat BitrateItemSpotifyPremiumFormat = + CompositeFormat.Parse(Strings.SettingsBitrateItemSpotifyPremium); + + /// + /// The rate mode the user chose, kept whatever format is selected. + /// + /// + /// Held apart from because the dropdown may not be able to + /// show it: FLAC has no rate mode and Opus has no constant one, so the rung on screen is + /// coerced to what the chosen format can honour. Persisting the coerced value instead would + /// mean that picking FLAC and picking MP3 again silently discarded the choice — and the + /// bitrate *number* survives that same detour, so losing only the mode would be the same + /// control giving two different answers to "does looking at another format cost me + /// anything". The device list keeps a disconnected device for the same reason. + /// + private BitrateMode _preferredBitrateMode; + private readonly SettingsDocument _document; private readonly IAudioDeviceCatalog _catalog; private readonly IFolderPicker _folderPicker; @@ -103,7 +157,9 @@ public sealed partial class SettingsViewModel : ObservableValidator private MediaFormat _format; [ObservableProperty] - private int _bitrateKbps; + [NotifyPropertyChangedFor(nameof(BitrateKbps))] + [NotifyPropertyChangedFor(nameof(BitrateMode))] + private BitrateChoice _selectedBitrate; [ObservableProperty] [NotifyDataErrorInfo] @@ -188,7 +244,13 @@ .. EncodingProfiles.Known public ObservableCollection Devices { get; } = []; /// Bitrates offered, including a hand-edited one that is not on the ladder. - public ObservableCollection Bitrates { get; } = []; + public ObservableCollection> Bitrates { get; } = []; + + /// The chosen rate, for the code that only cares about the number. + public int BitrateKbps => SelectedBitrate.Kbps; + + /// How the chosen rate is spent — the stored choice, not the coerced display. + public BitrateMode BitrateMode => _preferredBitrateMode; /// The output formats, in enum order so the list does not reshuffle per language. public IReadOnlyList> Formats { get; } @@ -371,7 +433,7 @@ private void Load() // The method swallows its own failures, so nothing is dropped by letting it run on. _ = RefreshSpotifyAccountNameAsync(); - LoadBitrates(settings.Output.BitrateKbps); + LoadBitrates(settings.Output.BitrateKbps, settings.Output.BitrateMode); LoadDevices(settings.Recording.AudioEndpointDeviceId); } finally @@ -384,15 +446,78 @@ private void Load() ValidateAllProperties(); } - private void LoadBitrates(int stored) + /// + /// Rebuilds the bitrate ladder for the current format and selects the stored rung. + /// + /// + /// + /// The constant-rate rung belongs to the formats whose encoder has a rate mode to switch — + /// MP3 alone today. Offering it beside Opus would be a control that changes nothing, since + /// libopus varies its rate whatever it is told, and the setting would then disagree with + /// the file. + /// + /// + /// The stored rung is added if the ladder does not already hold it, which covers both a + /// hand-edited rate and a constant-rate setting at a rate this list does not offer. Neither + /// is silently rounded to the nearest rung: the dropdown would otherwise open showing a + /// value the file does not contain, and the first change to any other setting would write + /// that misreading back. + /// + /// + /// A mode the chosen format cannot honour is coerced out of the rung on screen but kept in + /// , so it is there again when a format that can honour + /// it is chosen. + /// + /// + private void LoadBitrates(int stored, BitrateMode storedMode) { - Bitrates.Clear(); + // Rebuilding the list moves the selection, and a selection change persists. Suppressed + // so that reloading the ladder is not itself a settings write — the caller decides. + var reloading = _loading; + _loading = true; - foreach (var rate in StandardBitrates.Append(stored).Distinct().Order()) Bitrates.Add(rate); + try + { + _preferredBitrateMode = storedMode; - BitrateKbps = stored; + var supportsMode = EncodingProfiles.For(Format).SupportsBitrateMode; + var selected = new BitrateChoice(stored, supportsMode ? storedMode : BitrateMode.Average); + + var rungs = StandardBitrates.Select(rate => new BitrateChoice(rate, BitrateMode.Average)); + + if (supportsMode) rungs = rungs.Append(new BitrateChoice(ConstantRateKbps, BitrateMode.Constant)); + + Bitrates.Clear(); + + foreach (var rung in rungs.Append(selected).Distinct().OrderBy(r => r.Kbps).ThenBy(r => r.Mode)) + Bitrates.Add(new ChoiceOption(rung, BitrateName(rung))); + + SelectedBitrate = selected; + } + finally + { + _loading = reloading; + } } + /// What one rung reads as in the dropdown. + /// + /// The two annotated rungs are the rates Spotify's own tiers stream at, so a user who wants + /// a recording no worse and no larger than what arrived has somewhere obvious to land. They + /// describe the source, not the encode: a lossless stream is above both, which is what the + /// tooltip says and what FLAC is for. + /// + private static string BitrateName(BitrateChoice rung) => string.Format( + CultureInfo.CurrentCulture, + rung switch + { + { Mode: BitrateMode.Constant } => BitrateItemConstantFormat, + { Kbps: SpotifyFreeKbps } => BitrateItemSpotifyFreeFormat, + { Kbps: SpotifyPremiumKbps } => BitrateItemSpotifyPremiumFormat, + _ => BitrateItemFormat, + }, + rung.Kbps); + /// /// Rebuilds the device list, keeping a stored device that is not currently connected. /// @@ -467,9 +592,23 @@ private void RefreshDevices() partial void OnSelectedDeviceChanged(AudioDeviceOption? value) => Persist(); - partial void OnFormatChanged(MediaFormat value) => Persist(); + partial void OnFormatChanged(MediaFormat value) + { + // The ladder is format-dependent, so it is rebuilt before the new format is written: + // switching away from MP3 has to take the constant-rate rung with it. + LoadBitrates(BitrateKbps, BitrateMode); + Persist(); + } + + partial void OnSelectedBitrateChanged(BitrateChoice value) + { + // Only a format that offers both modes can be said to have chosen one. On the others + // every rung reads Average because that is all the list holds, and taking that as an + // answer would throw away the mode the user picked on MP3. + if (EncodingProfiles.For(Format).SupportsBitrateMode) _preferredBitrateMode = value.Mode; - partial void OnBitrateKbpsChanged(int value) => Persist(); + Persist(); + } partial void OnMinimumLengthSecondsChanged(string value) => Persist(); @@ -566,6 +705,7 @@ private void Persist() Path = OutputPath.Trim(), Format = Format, BitrateKbps = BitrateKbps, + BitrateMode = BitrateMode, }, Recording = settings.Recording with { diff --git a/src/Offstream.App/Views/Pages/SettingsPage.xaml b/src/Offstream.App/Views/Pages/SettingsPage.xaml index 38dba0a..813797e 100644 --- a/src/Offstream.App/Views/Pages/SettingsPage.xaml +++ b/src/Offstream.App/Views/Pages/SettingsPage.xaml @@ -98,14 +98,16 @@ SelectedValuePath="Value" /> - + + SelectedValue="{Binding SelectedBitrate, Mode=TwoWay}" + SelectedValuePath="Value" />