Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 38 additions & 12 deletions docs/MODERNIZATION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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<Exception>` 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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/Offstream.App/Resources/Strings.fr.resx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,18 @@
<data name="SettingsBitrateItem" xml:space="preserve">
<value>{0} kbit/s</value>
</data>
<data name="SettingsBitrateItemConstant" xml:space="preserve">
<value>{0} kbit/s (constant)</value>
</data>
<data name="SettingsBitrateItemSpotifyFree" xml:space="preserve">
<value>{0} kbit/s (Spotify Free)</value>
</data>
<data name="SettingsBitrateItemSpotifyPremium" xml:space="preserve">
<value>{0} kbit/s (Spotify Premium)</value>
</data>
<data name="SettingsBitrateHelp" xml:space="preserve">
<value>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.</value>
</data>
<data name="SettingsBitrateNotApplicable" xml:space="preserve">
<value>Ce format choisit lui-même son débit.</value>
</data>
Expand Down
16 changes: 16 additions & 0 deletions src/Offstream.App/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,22 @@
<value>{0} kbps</value>
<comment>One entry in the bitrate dropdown. {0} is the rate in kilobits per second.</comment>
</data>
<data name="SettingsBitrateItemConstant" xml:space="preserve">
<value>{0} kbps (constant)</value>
<comment>Bitrate dropdown entry for the constant-rate rung. {0} is the rate in kilobits per second.</comment>
</data>
<data name="SettingsBitrateItemSpotifyFree" xml:space="preserve">
<value>{0} kbps (Spotify Free)</value>
<comment>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.</comment>
</data>
<data name="SettingsBitrateItemSpotifyPremium" xml:space="preserve">
<value>{0} kbps (Spotify Premium)</value>
<comment>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.</comment>
</data>
<data name="SettingsBitrateHelp" xml:space="preserve">
<value>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.</value>
<comment>Settings page: tooltip and screen-reader help for the bitrate dropdown.</comment>
</data>
<data name="SettingsBitrateNotApplicable" xml:space="preserve">
<value>This format sets its own rate.</value>
<comment>Shown next to the disabled bitrate control for FLAC and WAV.</comment>
Expand Down
14 changes: 11 additions & 3 deletions src/Offstream.App/Services/RecordingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,17 @@ public string FormatSummary
/// Bytes a second the current format writes, or null when that cannot be known ahead.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public int? BytesPerSecond
{
Expand Down
Loading
Loading