Install it with Offstream-0.1.0-setup.exe — a per-user install that never asks
for administrator rights — or take the zip and run it from wherever you unpack it.
Both hold the same build.
This build is not code-signed. Windows SmartScreen will warn the first time you
run it: choose More info and then Run anyway. Verify the download against its
SHA-256 below before you do.
VB-CABLE is not included. Offstream detects it and tells you if it is missing;
install it yourself from https://vb-audio.com/Cable/ if you want to record Spotify
alone rather than everything the machine plays.
ffmpeg n8.1.2-40-g852b0552f0 is included (LGPL-3.0-or-later), unmodified and run as a
separate process. Its source is attached to this release as required, and its licence
travels in the ffmpeg folder beside it.
99253ab73bca124cdec71a9fc2fe4a66d1dbb3ac90217742953dfdf9530fdea2 Offstream-0.1.0-setup.exe
7beafde656b7050c2436a9318d66b8272625a6cd4a329548f20b714ae71b463a Offstream-0.1.0-win-x64.zip
The first release. Everything below is the whole of Offstream rather than a change to it:
phases 0–8 of docs/MODERNIZATION-PLAN.md, from the .NET 10 retarget through to a per-user
installer.
Added
-
The licences are readable from inside the app, not just present as files in the folder.
"Licence and notices" on the Advanced page's Application section opens Offstream's own MIT
licence, the predecessor's copyright notice it carries, the bundled LGPL ffmpeg and its source
offer, TagLib#, and VB-CABLE's origin and donationware terms — with the running build's version
and commit at the top, so a bug report can name the build it came from. The text is embedded in
the executable rather than read from beside it: the MIT and LGPL notices are obliged to travel
with the software, and a loose file does not survive a zip unpacked selectively or a copy of just
the.exe. A test asserts the window still shows whatLICENSEandNOTICEactually say, so the
two cannot drift. -
Credit for the track details, beside the provider that supplies them. Choosing Spotify or
Last.fm now says whose data is being written into the recordings — an attribution Spotify's
Developer Terms require and that the app had never carried anywhere. It is empty when no provider
is selected, because crediting a service the app is not calling would be a false statement. -
The Settings page names which Spotify account is signed in, as display name and account id
— not just that one exists. Recordings go untagged when the signed-in account is not the one
the music plays on, and nothing on screen says which it is, so the only way to find out was to
read the logs. Costs one scope,user-read-private. The id is there because a display name is
not an identifier: two accounts can share one, and telling exactly those apart is what this is
for.user-read-emailwould be the obvious way to do that and is deliberately not requested —
Spotify removed theemailfield in its late-2024 cull, so that permission now covers data the
endpoint no longer returns. -
A release pipeline, with the git tag as the only place a version number lives. Pushing
v1.2.3
builds, tests, publishes, signs and attaches a self-containedwin-x64zip and its SHA-256 to a
GitHub release; the changelog becomes the release notes. Nothing in the repository records a
released version, so a build cannot claim a number the tag disagrees with — a file that has to be
bumped in lockstep with a tag is a file that eventually is not. A malformed tag is rejected before
anything is built, because a tag stops being editable the moment anyone fetches it, and a
prerelease suffix (v1.2.3-rc.1) marks the GitHub release as one so it does not become what
"latest" resolves to. Unreleased builds call themselves0.1.0-devrather than borrowing the last
release's number, which is what turns "which build is this?" into a question with an answer. The
same workflow runs from the Actions tab to exercise the pipeline without spending a version.
Cutting a release is two steps — close## [Unreleased]into## [1.2.3]in a pull request, then
tag — and a tag with no matching section fails in seconds rather than at the end of the pipeline.
Falling back to[Unreleased]would have worked exactly once, and every release after that would
republish the previous one's entries with no fix short of amending a release people had read. -
A per-user installer that never asks for administrator rights. Offstream needs no elevation to
run — routing, session mute and loopback capture were all verified unelevated — so the thing that
installs it asks for none either. An elevation prompt is a decision the user has to make about
software they have not run yet. It installs into%LOCALAPPDATA%\Programs\Offstream, refuses
anything below Windows 11 with a sentence during setup rather than a crash on first run, notices a
running copy through the app's own single-instance mutex instead of failing halfway through
replacing a locked file, and is offered in English and French to match the app.
It is built from the same staged folder the portable zip is made from, so the two downloads
cannot hold different software under one version number, and the installer script is compiled on
every pull request — one that is only compiled when a tag is pushed is one that breaks when a tag
is pushed.
Uninstalling asks about settings and logs rather than guessing. Deleting them silently loses a
Last.fm API key and a Spotify sign-in; keeping them silently is wrong for someone uninstalling
because they are done. Recordings are never in scope: they live outside the install folder and no
uninstaller should reach them. -
ffmpeg travels with the app. Releases carry an unmodified LGPL-3.0 build of ffmpeg in an
ffmpegfolder beside the executable, which is where the locator already looked, so a download
records audio without the user installing anything first. A copy onPATH, or one named on the
Settings page, still wins — bundling is a floor, not a preference. It costs about 45 MB of the
download, which is the price of the app working when it is opened.
The build is pinned by SHA-256, not fetched by name from a moving tag: an encoder that changes
between two builds of the same Offstream version turns a reproducible bug into an unreproducible
one, and a release asset can be replaced after the fact. A mismatch fails the release rather than
quietly shipping something else. The binaries are not committed — 108 MB of someone else's build
does not belong in a git history — sobuild/windows/ffmpeg.jsonrecords what to fetch and
fetch-ffmpeg.ps1fetches it, for the pipeline and forbuild.ps1 -Publish -BundleFfmpegalike.
Onlyffmpeg.exeships;ffprobeis used by the integration tests and by nothing in the app, and
would have added another 108 MB.
The LGPL obligation is met by attaching the source, not by offering it. Every release carries
the FFmpeg source archive for the exact commit the binary was built from, because a written offer
has to outlive whatever was going to host it and this one does not have to. The bundle also carries
ffmpeg's own licence text and aSOURCE.txtnaming that commit, and the vendor binary is never
re-signed or otherwise altered on its way in. -
Signing, wired and waiting.
build/windows/sign.ps1Authenticode-signs whatever it is given,
and when no certificate is configured it says so and exits 0 rather than failing the build.
Offstream has no certificate yet, so every artefact is currently unsigned and Windows SmartScreen
will warn on first run — the release notes say this outright instead of letting users find out,
and ship a SHA-256 as the only integrity check available in the meantime. Building the step now
means acquiring a certificate later is two repository secrets rather than a pipeline change, and it
gets reviewed while nothing depends on it. Timestamping is on by default: without it every
signature stops verifying the day the certificate expires, including on copies installed years
earlier. -
A security policy, and the reporting channel it points at.
SECURITY.mdsays where to send a
vulnerability and what the app actually handles that is worth attention — track metadata being
untrusted input that reaches ffmpeg arguments and file paths, the PKCE sign-in, DPAPI token
storage, and the hand-marshalled COM interop — along with what is deliberate, such as the Last.fm
key sitting in plain text next to a DPAPI-protected refresh token. Private vulnerability
reporting, Dependabot alerts and security updates, and secret scanning with push protection are
enabled on the repository, so a reporter has somewhere private to go and a committed credential
is refused at push time rather than found later. -
CodeQL code scanning on pushes to
main, on pull requests, and weekly, over C#, the workflows
themselves and the one Python script. It runs on Ubuntu withbuild-mode: none: the analysis
reads C# without compiling it, which is what makes scanning a Windows-only WPF app on a Linux
runner possible at all. -
Solution scaffold on .NET 10. SDK-style projects (
Offstream.Core,Offstream.App,
Offstream.Core.Tests,Offstream.UI.Tests,Offstream.FakeSpotify) under an.slnx
solution, central package management, nullable reference types, and analyzers as errors. -
GitHub Actions CI on
windows-latest: build,dotnet format --verify-no-changes,
analyzers as errors, and the test suite with ffmpeg pinned. A parallel job fails any pull
request that does not update this file, since a stale changelog reads as a current one; a
user-invisible change skips it with theno-changeloglabel rather than by staying silent. -
build.ps1— restore, build, test, format, publish and run from an ordinary PowerShell
prompt, with no Developer PowerShell or MSBuild discovery needed. -
Per-application audio routing on .NET 10 (
Offstream.Core.Interop.Routing), over the
undocumentedIAudioPolicyConfiginterface. Routing, session mute and loopback capture are
all verified working unelevated. -
WASAPI loopback capture (
Audio.LoopbackAudioCapture) behindIAudioCaptureSource, with
a silence keep-alive so idle gaps are still captured, andAudio.AudioCaptureBufferpacing
reads between capture and the recorder. -
Spotify track detection — window-title parsing, process discovery, and a
cancellation-driven poller that reports track, play-state and elapsed-time changes. -
The recording pipeline —
Recording.TrackRecordercaptures one track to a temporary WAV
and decides whether it is worth keeping;Recording.RecordingSessionjoins capture,
detection, the recording rules, encoding and file naming, and reports progress through
IProgress<RecordingProgress>. -
ffmpeg encoding boundary (
Offstream.Core.Encoding) — declarative format profiles, an
ArgumentList-based runner with a deadline and stderr draining, anFFmpegLocatorthat
resolves configured → bundled →PATH, a startup version assertion, and a single-consumer
EncodeBacklogso capture never waits on encoding. -
FLAC and AAC output, alongside MP3, WAV and Opus.
-
Cover art for every container that can carry it — attached as a second ffmpeg input for
MP3, FLAC and M4A, and written by TagLib# for Ogg/Opus, where ffmpeg's
METADATA_BLOCK_PICTUREsupport is unreliable. -
Filename templates with folder levels, a counter, and 260-character path budgeting.
-
Last.fm metadata mapping, driven by fixtures rather than the live API.
-
ffmpeg argument golden tests and encode-integration tests that assert with
ffprobe, plus
a naming-hygiene test that fails the build on an identifier inherited from the predecessor. -
Spotify Web API metadata, on PKCE.
SpotifyTrackMappermapsFullTrack/FullAlbumonto
aTrack;SpotifyMetadataProviderfetches the currently-playing track and its album and
applies the mapping, guarded by the same title-match check the predecessor used to stop a
race between detection and enrichment from tagging the wrong track. The PKCE sign-in itself —
login URL, code exchange, refresh — isSpotify/Auth/*, orchestrated by
SpotifyAuthenticatoragainstSpotifyAPI.Web7.4.2. -
A loopback redirect listener with no EmbedIO dependency.
SpotifyLoopbackListenercatches
the PKCE authorization code onSystem.Net.HttpListener, bound to a literal loopback address
so it runs without elevation. -
tools/Offstream.SpotifyAuthProbe, a console tool for the one verification step nothing
automated can do: running the real PKCE flow against a real Spotify app registration in an
actual browser. -
IHttpClientFactory-backed DI wiring for the Spotify OAuth client, and the options
pattern (SpotifyAuthOptions) plan §10 Phase 4 asks for. -
Settings persistence at
%APPDATA%\Offstream\settings.json— a schema grouped into
output,recording,metadataandappsections, with aschemaVersion, validation on
load, and first-run defaults chosen so the app is usable before Settings is ever opened. -
Atomic settings writes. The file is written to a sibling temp file and moved over the
destination, so a crash mid-save leaves either the old file or the new one — never a
half-written one. -
DPAPI protection for the Spotify refresh token (
CurrentUserscope) before it reaches
disk. A token that will not decrypt — a copied file, a different Windows user — is treated as
"sign in again" rather than as a corrupt settings file, so every other preference survives. -
The WPF shell — a Fluent
NavigationViewwith Record, Settings and Advanced tabs, pages
and ViewModels resolved from the DI container, dark mode following the system theme, and
high-contrast schemes mapped rather than flattened to light. -
A live waveform on the Record page. Nothing else on that page proves audio is arriving:
status, track and elapsed all come from Spotify's window title, which keeps changing whether
or not a sample reaches the encoder. The meter is drained by the UI at a fixed 30 Hz rather
than pushed from capture, so a decoration costs the capture thread nothing. -
An activity log on the Record page with a level filter and copy-what-you-see, replayed
from the in-memory sink so it is populated before the page is first shown. -
Settings and Advanced pages covering output folder, device, format, bitrate, minimum
length, metadata provider, filename template with a token reference and a live preview
rendered by the recorder's own naming code, existing-file policy, counter, detection options,
tag options, timer, language and the ffmpeg path. -
Inline validation via
INotifyDataErrorInfo, and no OK button. A valid edit is saved when
the field commits; an invalid one is refused next to the field and never reaches the disk. -
English and French resources with a key-parity test, and a test that fails the build if a
resource key carries an identifier inherited from the predecessor. -
A tray icon with a menu, shown only while the window is hidden there, coloured red while a
recording is running — the one state the user cannot otherwise see. -
A single-instance guard that surfaces the running window instead of exiting silently. The
claim is per logon session and per data directory, so a second Windows user gets their own
Offstream, andOFFSTREAM_HOMErelocates settings for portable use and for the UI suite. -
Metadata actually reaches the file. A
Last.fmprovider (LastFmMetadataProvider) and the
existing Spotify one are selected from the settings, run against each track as it starts
recording, and joined immediately before the encode is queued — so album, track number, disc,
year, genre and album artists reach ffmpeg's-metadataarguments, and the cover art is fetched
to a temp file and embedded. Enrichment overlaps the recording rather than following it, is
bounded by a deadline, and can never fail a recording. -
Spotify sign-in, on the Settings page. The Client ID identifies an app and grants nothing;
this is what produces the refresh token a recording session presents. Spotify rotates that token
on every renewal, and the replacement is written back. -
A Last.fm API key setting (
metadata.lastFmApiKey), the user's own. The predecessor shipped
three of its own keys hard-coded in its source and picked one at random per run. -
Tags the predecessor never wrote. Genres from Last.fm's top tags (the three most-applied,
since the tail of a tag cloud is listener bookkeeping rather than genre); the release date at
Spotify's own precision, alongside the year the{year}token needs; the album's track total,
so the track tag reads4/12and a player can tell a partial rip from a complete album; and
the album's copyright line, preferring the recording's over the composition's. -
Audio endpoints appearing and disappearing are handled. Losing the endpoint mid-recording
was silent — WASAPI simply stops delivering. -
Extended-length path support. Offstream does not write the output file, ffmpeg does, in a
separate process with its own manifest, so alongPathAwareopt-in here would not reach it.
The\\?\prefix travels with the path throughArgumentListinstead. It is applied only to
fully-qualified, already-normalised paths, because the prefix turns path normalisation off —
Windows stops resolving.and.., converting/, and trimming trailing dots and spaces,
so an untidy path becomes one the filesystem rejects. Per-level allowances stay clamped to
255: extended paths raise the total length, not the component length. -
VB-CABLE detection, reported beside the device picker. Detection only, and that is a
licence decision rather than an unfinished one — the package is donationware whose readme
forbids integrating it into another installation procedure without the author's agreement.
Offstream carries no vendor binaries and links to vb-audio.com when the cable is absent.
Plan open question 9 has to be answered before any other form ships. -
The activity log is a tab of its own, with a level filter and copy-what-you-see. Third
home in three attempts: a log wants either the whole surface or none of it, and a shared page
offers neither. -
The Record page shows what it has done — tracks saved this session with a reveal button
per row, and the cover art, album and destination for the track being recorded. All of it was
already known and thrown away: the enricher fetches the art to embed it, and the destination
is rendered to decide where to write. -
Genre from Spotify's artists, with Last.fm behind it. Spotify has no genre on a track at
any endpoint — it models genre as an attribute of the artist — so/v1/artists/{id}is the
one place the data still lives. It needs no new scope, since artist data is public, and is
cached per session by artist id, misses included. Artist genres describe a body of work rather
than a recording, which is the honest limitation and the reason for a second rung: Last.fm's
tags are per track, then per artist, then empty. -
The album's track total from the Windows media session. Spotify reports it alongside the
track number and only the second was being read, so the5/12track tag needed a configured
provider even though the client had volunteered both halves. -
A summary of what each metadata provider contributes, under the Settings dropdown. It
named three providers and said nothing about how they differ, leaving the one question worth
asking — what do I lose by picking this one — answerable only by recording something and
runningffprobeover it. Phrased as what each provider adds, because none of them is the
floor; only Spotify carries a release date, so{year}is empty under either of the others. -
1051 tests (877 Core, 174 UI).
Changed
LICENSEis now unbroken MIT text, and the attribution moved toNOTICE. The predecessor's
copyright notice sat mid-licence, between the header and the permission grant, which is enough to
stop GitHub's detector matching the file — so a repository under a perfectly ordinary MIT licence
advertised itself as "Other", which invites exactly the doubt a licence file exists to remove.
Both copyright lines are still inLICENSE, as the licence requires;NOTICEcarries the prose
about what was ported, and now also lists every third-party component with its licence —
including that TagLibSharp is LGPL-2.1-only, which is a packaging constraint for phase 8 and
was written down nowhere.- The README now explains the app before it explains the build. It opened on a phase list and
a retarget spike, so someone arriving at the repository could read half of it without learning
what Offstream does, that it splits a listening session into one tagged file per song, or what
the window looks like — while the two facts they most needed were absent: there is no installer
yet, and recording an ordinary output device captures every sound the machine makes, not only
Spotify. Screenshots of the Record, Settings and Advanced pages now sit beside a walkthrough
from install to first recording, and the developer material — setup,build.ps1, IDEs, testing,
the naming rule, the relationship to the predecessor — is intact below, in collapsed sections.
The status line was two phases out of date. - SpotifyAPI.Web upgraded 5.1.1 → 7.4.2.
SpotifyWebAPIandAuthorizationCodeAuthare
gone; auth is PKCE end to end, which needs no client secret at all — a public desktop app was
never able to keep one confidential regardless of how it was stored. - The PKCE sign-in validates its
stateparameter. The predecessor did not; skipping that
check is how a stray or forged redirect could complete a sign-in it did not originate. - All audio conversion goes through ffmpeg as an external process, replacing in-process
encoding. - ffmpeg arguments are passed as an argument vector, never a command string. Track metadata
comes from Spotify window titles and is untrusted; passing argv elements individually removes
the argument-injection class of bug structurally, where the predecessor needed hand-written
CommandLineToArgvWescaping. - Encoding runs on a background queue instead of on the recording thread, so a slow encode
can no longer clip the start of the following track. - A recording's length is measured from the audio captured, not from a timer tick, so a
stalled capture can no longer pass the minimum-length rule with a near-empty file. - The destination filename is claimed after encoding, immediately before the file is moved
into place, so a name reserved while an encode was queued cannot be taken in the meantime. - A failed encode keeps its captured WAV and reports the path, rather than discarding the
one part of the recording that cannot be recreated. - The recording rules are separated from the orchestration, and the orchestration reports
progress through events andIProgress<T>rather than holding a reference to the UI. - Capture pacing is separated from the capture device, so it can be tested without an audio
endpoint. - The silence keep-alive targets the endpoint being captured rather than the default output
device, which is the wrong one whenever a user records anything else. - Polling is cancellation-driven (
PeriodicTimerplus aCancellationToken) instead of
usingSystem.Timers.Timerwith anasync voidhandler, a fire-and-forget event raise, and a
boolre-entrancy guard. - Windows 11 (build 22000) is the floor. Windows 10 is no longer supported.
- Settings are validated on load and rejected loudly. Malformed JSON, an unknown
schemaVersion, or an out-of-range value produces a message naming the offending field rather
than silently reverting to defaults and discarding the user's configuration. - Settings live at
%APPDATA%\Offstream\settings.jsonas JSON, replacing the .NET
user.configmechanism. - The target framework is
net10.0-windows10.0.22621.0. SMTC lives in
Windows.Media.Control, which only exists to bind against when the CsWinRT projections are
on, and those are switched on by the TFM carrying a version. 22621 rather than Windows 11's
22000 floor: it is the oldest build still in support. The hand-rolledIAudioPolicyConfig
interop is unaffected by construction — it names its runtime class only as a string and
otherwise usesComImportand raw P/Invoke — and was re-verified on build 26200 regardless. - The Windows media session is the primary track source, with the window title as fallback.
The title is only readable while Spotify has a window; minimised to the tray it has none, and
detection stopped dead — the recorder could sit idle through a whole session because the user
tidied the taskbar. It is also better information: the title is one string that has to be split
on a separator that can legitimately appear inside either half, while the media session hands
over separate fields and an album with them. - Metadata precedence is the provider first, the media session underneath. Not "client first
with the API as fallback", which sounds equivalent and is not — the two have asymmetric
coverage, and the API is the only source for half the tag set. The provider wins wherever it
answers; what the client already knew fills the gaps. - The file-name template and its preview take the card's full width, in the order they are
used, with the preview in a recessed well of its own — it is the one thing in that card that
is output rather than input. - Last.fm genres come from the artist, never the recording. Last.fm does carry a per-track
tag cloud, and it used to be asked first on the reasoning that a tag describing this recording
beats one describing its performer. That is not the trade actually being made: Last.fm has no
track tags at all for a great many recordings and returns an empty cloud rather than an error,
so the first rung answered for some tracks on an album and not others — and a library where one
track is "trance" and the next is untagged sorts worse than one where every track carries the
artist's genre. It also puts the two providers on the same footing, since Spotify has no
track-level genre to offer at all. One request per artist instead of two per track, out of the
cache that was already there. - The recording display is backlit rather than reflective, and no longer carries the track
name. Pale grey with near-black segments was a calculator face and the palest object in a
nearly black window. The track name moved to the card with the art and album it belongs to.
Removed
- NAudio.Lame and the bundled LAME DLLs — ffmpeg encodes MP3.
- Settings migration. Offstream does not read the predecessor's
user.configand ships no
importer; it starts from a clean slate with first-run defaults. - MetroFramework, EmbedIO and DotNetZip, along with
packages.configand the manual
<Compile Include>lists that silently omitted files added on disk. - Every identifier inherited from the predecessor. No namespace, type, file, project,
resource key, setting or path carries the old naming, and a test fails the build if one
reappears. - The Phase 0 spike project (
spike/Offstream.Spike). It was scratch scaffolding for proving
the risky parts of the retarget — WASAPI loopback,IAudioPolicyConfigrouting, session mute —
before there was anywhere else to put them, and everything it proved has lived in
Offstream.Corewith tests of its own since Phase 2. What remained was a hand-run console
harness nothing built on, carried in the solution and the layout docs as though it were part of
the app.docs/decisions/0001-phase-0-retarget-spike.mdkeeps the findings.
Fixed
- Pressing record with Spotify paused, then pressing play, recorded nothing. The level meter
moved, the counter ran and the page named the song, but no file was ever written until the user
pressed stop and start again. Being played is half of what makes a track recordable, and that
check ran only when the track changed — while two observations of the same song count as the
same track whether or not it is playing, by design, so that a pause mid-song does not read as a
new one. A song that started playing therefore raised nothing the session was listening for: it
had already passed the track over once as not recordable, and went on waiting for a change that
had happened. The predecessor never met this because it waited for Spotify to produce audio
before it began watching at all; Offstream starts listening the moment record is pressed, which
is worth keeping. The check now also runs when playback starts, for the track already showing
and only when nothing is being recorded — a pause mid-song still leaves the recorder alone, and
starting with music already playing still produces exactly one recording rather than one that is
immediately torn down and discarded as too short. - Every track after the first showed nothing but the artist and title. The album, cover art
and destination arrived a second into each song and were gone a moment later, on every track
except the first one of a session — while the files themselves were tagged correctly, so the
lookup was plainly working and only the page was wrong. The cause was that a report from the
pipeline named the track it was about, and the shell read that as the track playing. Encoding,
tagging and saving happen to the previous song while the next one is already recording, so those
reports named a track that had finished: the now-playing line snapped back to it, which was the
signal to drop the album, art and path of the song that was recording, and the next elapsed tick
a seventieth of a second later snapped it forward again and dropped them a second time. Nothing
re-runs a lookup for a track already under way, so the card stayed bare for the rest of it. The
first track escaped only because nothing was finishing behind it. Reports now carry what is
playing separately from what they are about, and the page takes its now-playing line, elapsed
counter and transport state from the former — which also stops the counter jumping to the
previous track's length and the transport flickering out of Recording each time a file lands.
While it was there: the page and the tray no longer announce a song called "Spotify". The poller
seeds an empty track when it starts listening, so that whatever is already playing counts as a
change, and Spotify's own idle window title parses to the same empty thing — both of which
render as the bare application name. A placeholder for the absence of a track is now reported
as no track. - Unplugging the device being recorded left the recording running against nothing. The
detection for this was built and correct — it notices both ways of losing an endpoint, the
pinned device disappearing and Windows moving the default elsewhere — and nothing was listening
to it, so the session never heard. What the user saw was a recording that went on, an elapsed
counter that stopped counting, and a track that was never written, with the reason in no log.
The session now ends when its capture does: the track in flight is finished and saved on the
same terms as pressing stop mid-song, since what was captured before the device went is as much
of it as will ever exist, and the endpoint's name is reported so it is clear which device to
plug back in. Recording does not follow the audio to the new device — a replacement endpoint can
have a different sample rate and channel count, and continuing into one would put a seam in the
middle of the file — so plugging it back in and pressing record is the recovery. - A recording that stopped itself left the page offering to stop it. The recording timer
elapsing ended the session, but nothing told the window, so the transport went on showing Stop
for a session that had already finished, the file counter that run reached was not written back
until the user pressed it, and the audio device stayed open in the meantime. A session that ends
on its own now says so, and is released exactly as a stopped one is. - Starting and stopping recording a few times crashed the app outright. No dialog, no log
line, no managed exception — the process disappeared and left an access violation in the
Windows event log, because the fault was in the audio service calling back into the app rather
than in anything the app was running. Each recording session registers an endpoint-notification
callback with Windows so that unplugging the device being recorded ends the recording rather
than silently capturing nothing; stopping a session closed the capture but never withdrew that
registration, and the garbage collector cannot withdraw it either. Every cycle therefore left
another live registration pointing at an object nothing was keeping alive, and the next time an
endpoint changed — which is exactly what opening the next session's audio client does — the
audio service called into freed interop memory. A session now disposes the capture it was
given, which is what withdraws the registration, and a watcher holds a reference to itself for
as long as Windows can still call it: registering deliberately does not count as a reference,
which Windows documents and leaves to the caller, so a missed disposal now costs a few bytes
instead of the app. Three start/stop cycles were enough to crash the old build; the fixed one
went twenty-five without it. Two smaller leaks of the same shape went with it: a failed start —
no ffmpeg, most often — no longer abandons the capture it had already opened, and the endpoint
lookup no longer leaves a device enumerator behind on a path that runs several times a second
while recording. - Losing the recorded device could have wedged the audio service rather than ending the
recording. Endpoint notifications ran their handlers on the thread Windows delivers them on,
which is forbidden from blocking, from waiting, and from closing an audio object — and the
handler for a lost endpoint does all three, against the endpoint that just disappeared, while
the audio service waits for the call to return. Handlers now run on a pool thread, and the
notification returns immediately. - An advertisement showed the previous song's cover art, album and save path. The three
details describing the track being written arrive together from the metadata lookup and were
cleared only when the session stopped, so anything never enriched — an advertisement above
all, but equally a lookup that found nothing — inherited the last song's and displayed them
under its own name. Not a cosmetic fault: the card named a file path that nothing was being
written to. They are now dropped the moment the displayed track changes. - The Spotify sign-in button said "Sign in to Spotify" beside the words "Signed in". It
offered to do something already done. Pressing it does have a use once an account is
stored — it is the only way to move the install to a different account, which is exactly what
someone whose recordings are coming back untagged needs — so it now says "Sign in as a
different account" instead of being disabled or hidden. The button and its status line are
also stacked rather than side by side: the row sized itself off whichever label was current
and clipped the sentence beside it as soon as either grew, which the longer label and the
French translation both do. - Spotify's own error message reached the log as
Exception of type 'SpotifyAPI.Web.APIException' was thrown. The SDK parses the error body into
Exception.Messageonly when it recognises the shape and leaves .NET's placeholder there
otherwise, so the one string that could explain a failure was replaced by one that explains
nothing — and a 403 naming the rejected account arrived as a warning listing two possible
causes and confirming neither. The reason is now read off the response body, in both the
shape the Web API sends and the shape the accounts service sends, and quoted. - A session Spotify will never answer for cost every recording thirty seconds. The long
retry budget was added so a free account's advertisement break would not cost the following
track its tags, on the assumption that an answer carrying no track always resolves by itself.
It does not: when the account signed in to Offstream is not the account playing the music, or
playback is in a private session, the player endpoint answers 204 forever. The budget now
stands down after two tracks in a row exhaust it, and a successful lookup restores it — and
because everything on that path was logged at Debug, which the activity log does not show, it
now says so once, atWarning, naming both causes. - A data race in the audio ring buffer. The read position and byte count were captured
before the lock was taken, so a concurrent write could move them underneath a read and produce
torn audio. Reads are also span-based now, instead of allocating a fresh buffer on every read
in a path that runs continuously for hours. - The last chunk of every track was dropped. The write into the WAV honoured the stop token,
and that token is cancelled at exactly the moment a track ends — with a chunk already taken out
of the ring buffer and therefore unrecoverable. - The track already playing when a session started was never recorded. With no previous
track to compare against, the first observation counted as "nothing changed", so only the song
after it was captured. - The file counter could exceed the width its template allows, rendering
10000for a
{count:0000}mask and breaking both sort order and the "have I already recorded this?"
check. It now saturates so the ceiling can be detected. - A test that called the live Last.fm API and failed with no network is now fixture-driven.
- Spotify track/disc numbers of 0 no longer get written as a literal zeroth track. The SDK's
FullTrack.TrackNumber/DiscNumberdefault to 0 (a non-nullableint) when a track could not
be fully populated; the predecessor wrote that 0 straight into the tag. Now mapped to "unknown"
instead, since Spotify numbers both from 1. - A bare release year no longer silently drops the year.
DateTime.TryParserejects a
four-digit year with nothing else on it, which the predecessor's mapping also relied on —
dropping the year for every album whose Spotify precision is year-only rather than a full date. - A hand-edited settings file with a section or key removed no longer loads as zeroes.
System.Text.Json's source generator does not run property initializers for properties absent
from the JSON, so an omittedbitrateKbpsread back as0and an omitted section asnull.
The schema now declares its defaults as constructor parameter defaults, which the generator
does honour, and a test suite pins the behaviour. - The last chunk of a recording could be lost on a fast track change. Two recorders share one
capture buffer across a change, and the incoming one discarded its contents without waiting for
the outgoing one to finish reading its own tail out of it. - No metadata was written to any recording. The provider dropdown was read, validated and
saved, and then consulted by nobody: the recorder built its encode request straight from the
Trackscraped from the Spotify window title, so every file carried an artist and a title and
nothing else.SpotifyMetadataProviderexisted but nothing resolved it, no Last.fm provider
class existed at all, and the cover-art path was always null because nothing fetched the art. - The Spotify provider could never have worked, whatever was configured. Nothing resolved
SpotifyAuthenticator, so no refresh token was ever obtained, so a session had nothing to
present to the API. - AAC output was named
.aacbut was an m4a file. The file name came from the lower-cased
enum member while the encoding profile writes an MP4 container, producing a file Windows and
most players refuse to open. The extension now comes from the profile. - The file counter restarted at 1 on every run. The session increments it as recordings land,
on its own working copy of the settings, and nothing wrote it back — so each night's recordings
landed on the previous night's names and the "have I already recorded this?" check answered for
the wrong file. - Album art was invisible everywhere except VLC. MP3s were tagged as ID3v2.4, ffmpeg's
default; Windows Explorer's thumbnail handler and Windows Media Player have never read v2.4
cover art, so the picture was in the file and neither showed it. The predecessor tagged with
TagLib#, which writes v2.3 — hence the regression. MP3 output now pins-id3v2_version 3.
Nothing is lost by it: the full release date and4/12track numbers both survive. - The
artisttag repeated the album artist and dropped featured performers. It was built
fromTrack.Artists, which returns the album artists whenever a provider has supplied them —
soartistandalbum_artistwere identical on every enriched file. It now credits the
track's own performers, as the predecessor's TPE1/TPE2 split did. File names are unaffected;
the{artist}template token still renders fromTrack.Artists. - The elapsed counter drifted behind Spotify and never caught up. It added one second per
timer tick observed, andPeriodicTimerschedules the next tick from when the previous one was
consumed rather than making up a late one — so every delayed tick lost a second permanently.
The counter is now sampled from a monotonic clock anchored at the track's start, with pauses
banked rather than counted, so a late tick reports the truth and the drift corrects itself. - The Record page lagged while recording. The waveform emitted a separate
DrawRectangleper
bar — several hundred drawing instructions rebuilt thirty times a second — and drove itself
fromCompositionTarget.Rendering, which wakes the UI thread at the display's refresh rate and
keeps WPF composing rather than idling. The bars are now one frozen geometry in a single draw
call, sampled by a timer at exactly the rate the scroll needs. - Spotify tagged nothing on tracks whose boundary caught its backend mid-change. The window
title advances the instant the desktop client does, while/v1/me/player/currently-playingis
served from player state that trails it by a second or more — so asking once at the boundary
returns the previous track, the title-match guard correctly refuses it, and the recording is
saved bare. The predecessor waited before its first poll and retried a second later; the port
kept the guard and dropped the retry. Both are back, with a momentary empty answer treated as
the same race rather than as "nothing is playing". A podcast episode still fails immediately —
retrying could not change that answer. - The Record page grew taller than its window, so the log never scrolled. WPF-UI's
NavigationViewContentPresenterwraps a page in aDynamicScrollViewerwhenever its
ScrollViewer.CanContentScrollis true — which that control's own static constructor makes the
default — and inside it the page is measured with infinite height. The log's star row resolved
to its content's size instead of the viewport, so the list realised every retained line and ran
off the bottom of the window, with tail-following silently doing nothing. - The activity log grew for the life of the session instead of scrolling. The in-memory sink
keeps the last 2000 lines and the page's backing buffer trimmed with it, but the collection
actually bound to the list never dropped its oldest entry — so an overnight session ended with
a pane holding far more lines than had been retained. Both now trim in lockstep, and a line the
filter hides no longer evicts one it shows. - A single day's log file had no size ceiling. The daily roll bounds the file count, not the
size of any one file. It now rolls at 16 MB as well, which caps the log directory at seven
files rather than seven days of unbounded writing. - Nothing tagged from Last.fm ever carried a genre. The mapper hard-coded an empty array and
never read thetoptagsnode. Since Spotify has also stopped returning album genres for most
of its catalogue, the genre tag was empty whichever provider was chosen. - The "write the counter to the track number" setting did nothing. It is now applied to the
tag, without disturbing the{track}filename token, which keeps meaning the position within
the album. - Tagging stopped an hour into every session. The SDK's
PKCEAuthenticatorstores exactly
one token and renews by writing the response's fields onto it in place — but Spotify's PKCE
renewal is not obliged to return a newrefresh_token, and when it omits one that null lands
on the good value. The first renewal still succeeds, so nothing looks wrong; the next throws
from inside the SDK and every lookup for the rest of the session fails identically.
ResilientPkceAuthenticatorremembers the last refresh token Spotify actually sent and puts
it back before each request, leaving renewal itself to the SDK. - Every logged cause was dropped on the way to the Record page.
InMemoryLogSinkrendered
the message template and discardedLogEvent.Exception, so the answer to the bug above sat in
the log file for hours while the pane said only that something failed. It now appends the
exception's type and message — not the stack trace, which would swamp a one-line-per-entry
list. - "Keep the one on disk" behaved as "overwrite" for anyone whose template was more than
artist and title. The policy was checked the instant a track changed, before the metadata
lookup had returned anything, so a template naming an album rendered with album, year and
track number still empty — the check looked somewhere nothing is ever written, found nothing
every time, and let the rename replace the real file without a word. The authoritative check
moved after enrichment, where the destination is finally knowable, and all three outcomes now
name themselves and the file. - Spotify metadata matched nothing the media session reported. The guard compared Spotify's
bare track name against the detected title after running only Spotify's side through the
window-title splitter — a parsed string against an unparsed one, for every track the media
session found, which has been the common case since it became the primary source. Four
attempts, several seconds, then an untagged recording.DetectedTrackMatchreduces both sides
to a common form and compares them in both shapes. Normalisation stops well short of fuzzy:
the wrong answer here is not "no metadata" but a file tagged as a different song. - Last.fm accepted any release its database happened to associate. It had no equivalent of
the guard above, so whatever came back was written — and for a well-known track that is
regularly a DJ set or radio show it once appeared on.DetectedTrackMatch.AlbumAgreesnow
checks the reported album against the one the client is playing the track out of, treating an
edition suffix as more said about the same record. A rejected release still yields its genre,
since tags describe the recording rather than the release. - Last.fm reported no genre for a great many tracks it has tags for. The lookup stopped at
the empty per-track tag cloud without asking about the artist, and as the chosen provider it
mapped nothing at all — genres included — unless an album came back too, which is the wrong
gate for a question that never asked about albums. - What the media session already knew was discarded whenever the provider could not help.
With the match guard rejecting every attempt, no provider configured, or Spotify down, the
file was written bare while artist, title, album, album artist and position had all been
reported for that exact track. Both mappers now fill rather than clear. - Closing the window mid-recording left a process running with no window and no tray icon,
killable only from Task Manager.OnExitwasasync void, which WPF does not await: it ran
to its first await, returned, and letApplication.Runtear the Dispatcher down, so the
continuation carrying the rest of the shutdown was posted to a Dispatcher that would never run
it — the host was never stopped, the capture client never closed, the encode backlog never
drained, and the log never flushed, which is why the failure left nothing to read. The
container was then disposed synchronously, which throws on a singleton implementing
IAsyncDisposableonly, and nothing bounded the drain. Shutdown is now synchronous, off the
Dispatcher, asynchronous in disposal, and capped at thirty seconds. - Every recording failure was printed twice, once at
Errorand once atInformationwith
identical text — one colour meaning act on this and one meaning carry on. - A cover-art failure was filed as news. It went out as a progress message, which lands at
Information, below the Problems filter and so invisible to anyone who went looking for
exactly this. - The session total read "0 saved" forever. The count was right the whole time; only the
derived string was never refreshed. - A break between tracks cost the track after it every tag. Spotify's
currently-playing
answers a free account's advertisement, or 204 No Content, rather than the track about to
start — and the lookup counted each such answer as a failed match, so the four attempts it
allows were spent inside a break that runs far longer and the recording was saved bare. An
answer carrying no track at all is now a different failure from an answer carrying the wrong
one: something is playing, since a recording is running, so it resolves on its own and gets a
budget of its own — thirty polls rather than four. Bounded rather than open-ended, because a
track the API will never report should delay one recording and not every one. The enrichment
deadline moves 20s to 45s to leave room for it; the recording it runs alongside is unaffected
either way, since enrichment starts when the track does. - The log could not tell a 204 from an advertisement. Both printed "nothing playing", which
is the one distinction worth having when a lookup keeps missing — so the line naming what
Spotify answered named the shape of the answer rather than the absence of a track. - One song could be recorded twice, and enriched twice. The Windows media session fills its
fields as they arrive, so the first read after it becomes available can carry a title and no
artist. That counted as a track change: the take in progress ended, was reported as an
advertisement, was discarded for falling under the minimum length, and a second recording of
the same song started a moment later when the artist arrived. A title without an artist is now
read as a source part-way through reading — which is the only thing it can mean, since the
window-title parser puts an unsplittable title in the artist field instead.
Security
- Untrusted track metadata can no longer influence an ffmpeg command line — see the argument
vector note under Changed. - The PKCE
stateparameter is validated — see Changed. - The Spotify refresh token is encrypted at rest with DPAPI, scoped to the current Windows
user, so a copiedsettings.jsonis useless to anyone else.