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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ phase plan these entries follow.

## [Unreleased]

### Added

- **The transport display now says when a recording is going wrong, instead of leaving it to be
noticed.** Recording an hour of silence is this app's signature failure — audio routed to a
device Offstream is not capturing shows full volume in Windows and writes nothing — and the only
symptom was a flat meter nobody was watching. A **`SILENT`** lamp now appears after a few seconds
of nothing, counting how long the quiet has run, and a **`CLIP`** lamp latches if the audio
reached full scale during a track, which is distortion you can still do something about while the
track is playing. Both are absent unless something is wrong, so the ordinary display is
unchanged.

- **The display also says what it is recording, how big the file is, and when the timer will stop
it.** The capture device's name sits on the line that describes the file, which is the question
the silence lamp raises and previously meant a trip to the Settings page to answer. The running
size follows it for constant-bitrate formats. And a **`STOPS IN`** countdown appears beside the
elapsed clock whenever a recording timer is armed — it is set on the Advanced page and was then
completely invisible on the page running it.

### Fixed

- **One notch of the wheel jumped three tracks in the session list.** The list of what has been
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ These are load-bearing; violating them breaks the app in ways that are not obvio
inequality made an emptied box light the **Will change** badge and then save nothing. Any field
added to the editor has to go through `Replaces`, which asks whether there is a new value at
all before asking whether it differs.
- **`AudioLevelMeter.Read` drains what it reports, so the meter has exactly one reader**
(decided 2026-09-02). The draining is the point — it lets a slow reader see the loudness of its
whole interval instead of an instant — but it makes a second consumer a silent bug rather than a
compile error: two readers split the samples, and the bars end up reporting a fraction of the
audio while the other reader gets the rest, with neither side visibly broken. `LcdMeterView` is
that reader. Anything else that needs to know about the audio is folded in on the capture thread
inside `Write`, where every sample passes once and nothing is consumed — which is where
`HasClipped` and `SilentFor` live. Two consequences worth keeping: a clip test must use the
**peak** sample, because everything this meter publishes is RMS and RMS never reaches full scale
on real music, so a lamp driven from `LevelReading.Decibels` would never light; and the clip
threshold is 0.999, not 1.0, because a converter out of headroom pins to the largest value the
format holds and 16-bit's 32767/32768 never normalises to exactly one.

- **Use `ProcessStartInfo.ArgumentList`, never a command string.** Track metadata comes from Spotify window titles and is untrusted. The argv array prevents argument injection structurally; the old app needed hand-written `CommandLineToArgvW` escaping because .NET Framework lacked `ArgumentList`.

## Spotify Web API rules
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ Press Stop when you're done. The part-recorded song is finished off and encoded
closes, rather than being dropped on the floor — unless it's shorter than your minimum length, in
which case it's discarded like any other fragment.

The display above the meter watches the capture while you are not. It carries what the file will be —
format, bitrate, sample rate, the device being recorded and the size so far — and lights a **`SILENT`**
lamp if nothing has been heard for a few seconds, or **`CLIP`** if the audio hit full scale. With a timer
set on the Advanced page, a **`STOPS IN`** countdown appears beside the elapsed clock.

## Better tags and cover art

With no provider configured, Offstream writes what Spotify itself reports to Windows: artist, title,
Expand Down Expand Up @@ -233,7 +238,8 @@ handy for testing against a clean profile without disturbing your real one.
| What you see | What's happening |
| --- | --- |
| **"Offstream can't find ffmpeg"** | ffmpeg isn't installed or isn't on `PATH`. Run the winget command above, then **reopen the terminal** and restart Offstream. |
| **Nothing is recorded, and the meter is flat** | Offstream is listening to a different audio device than the one playing. Check **Record from** on the Settings page. |
| **A `SILENT` lamp on the display, and the meter is flat** | Offstream is listening to a different audio device than the one playing — the commonest way to record an hour of nothing. Check **Record from** on the Settings page. |
| **A `CLIP` lamp on the display** | The audio reached full scale, so this track may be distorted. Turn Spotify's own volume down a little and record it again; the lamp clears when the next track starts. |
| **Recordings include notification sounds, browser audio, everything** | Expected — you're recording the whole output device. Use VB-CABLE as described above to capture Spotify alone. |
| **Files have no cover art** | No metadata provider is selected, or its key is missing. See [Better tags and cover art](#better-tags-and-cover-art). |
| **Short files keep being thrown away** | That's the minimum-length setting doing its job. Lower it on the Settings page if you're recording something genuinely short. |
Expand Down
27 changes: 27 additions & 0 deletions docs/MODERNIZATION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,33 @@ Three second-order notes from doing it:

Commands bound from inside the pane reach the page with `RelativeSource={RelativeSource AncestorType=UserControl}`; the row template's old `AncestorType=ListBox` has no such ancestor once the editor is out of the list.

### Finding: a meter that reports has to be read exactly once (2026-09-02)

`AudioLevelMeter.Read` drains the interval it reports. That is deliberate and documented — it is
what lets a slow reader see the loudness of its whole interval rather than an instant — but it
makes the meter a single-consumer object, and nothing said so where a second consumer would look.

Adding the silence and clip lamps looked like a job for a second reader: poll `Read`, notice a
run of quiet or a full-scale figure, light a lamp. That would have worked in a test and been
wrong in the app. Two readers split the samples between them, so the bars would have reported a
fraction of the audio and the lamps the rest, with neither obviously broken — the meter would
simply have read low, by an amount that varied with how often each side happened to poll.

Both flags are folded in on the capture thread inside `Write` instead, where every sample passes
once, and neither is disturbed by `Read`. Two things fell out of doing it there. The clip flag
has to come off the **peak** sample, not off a reading: everything this meter publishes is RMS,
which for real music sits ten to twenty decibels below the peak, so a clip lamp driven from
`LevelReading.Decibels` would never light at all. And the clip threshold is 0.999 rather than 1.0,
because a converter out of headroom pins to the largest value the format holds — 32767/32768 for
16-bit — which never quite normalises to full scale.

The silence threshold is −80 dBFS rather than exact zero for the same class of reason: digital
silence is zeroes, but a capture graph with any analogue stage in it idles on a dither floor, and
calling that "not silent" would disable the warning on exactly the hardware that needs it.

Verified live, and by accident: a staged profile recording the default endpoint while Spotify
played to VB-CABLE reproduced the failure the lamp exists for, and the lamp lit at six seconds.

### Finding: a tag the recorder writes and the editor cannot reach is a tag nobody can fix (2026-08-31)

The page shipped with three editable fields — title, artist, album — and showed year, genre and artwork read-only beside them. The scope question "which fields belong here" has an answer that is not a matter of taste: **whatever the recording path writes**. `FFmpegArguments.MetadataArguments` writes title, artist, album, album artist, genre, date, track, disc and copyright, so a recording could carry a wrong disc number that no part of Offstream could correct. Composer, comment and BPM are in neither list, and that is what keeps this from drifting into a general-purpose tag editor.
Expand Down
24 changes: 24 additions & 0 deletions src/Offstream.App/Resources/Strings.fr.resx
Original file line number Diff line number Diff line change
Expand Up @@ -713,4 +713,28 @@
<data name="MetadataNumberInvalid" xml:space="preserve">
<value>Saisissez un entier supérieur à zéro ou laissez le champ vide.</value>
</data>
<data name="RecordLampClip" xml:space="preserve">
<value>CRÊTE</value>
</data>
<data name="RecordLampClipHelp" xml:space="preserve">
<value>La capture a atteint le niveau maximal pendant ce morceau : il peut être saturé. Baissez le volume de Spotify et réenregistrez-le.</value>
</data>
<data name="RecordLampSilent" xml:space="preserve">
<value>SILENCE {0}</value>
</data>
<data name="RecordLampSilentHelp" xml:space="preserve">
<value>Rien n'a été entendu depuis un moment. Spotify joue probablement vers un autre périphérique que celui enregistré : vérifiez Source audio dans les Paramètres.</value>
</data>
<data name="RecordTimerLabel" xml:space="preserve">
<value>ARRÊT DANS</value>
</data>
<data name="RecordTimerHelp" xml:space="preserve">
<value>Temps restant sur la minuterie définie dans Avancé. Le morceau en cours se termine avant l'arrêt de l'enregistrement.</value>
</data>
<data name="RecordSizeMegabytes" xml:space="preserve">
<value>{0:0.#} Mo</value>
</data>
<data name="RecordSizeGigabytes" xml:space="preserve">
<value>{0:0.##} Go</value>
</data>
</root>
24 changes: 24 additions & 0 deletions src/Offstream.App/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -873,4 +873,28 @@
<data name="MetadataNumberInvalid" xml:space="preserve">
<value>Enter a whole number above zero, or leave the box empty.</value>
</data>
<data name="RecordLampClip" xml:space="preserve">
<value>CLIP</value>
</data>
<data name="RecordLampClipHelp" xml:space="preserve">
<value>The capture reached full scale during this track, so it may be distorted. Turn Spotify's own volume down and record it again.</value>
</data>
<data name="RecordLampSilent" xml:space="preserve">
<value>SILENT {0}</value>
</data>
<data name="RecordLampSilentHelp" xml:space="preserve">
<value>Nothing has been heard for a while. Spotify is most likely playing to a different device than the one being recorded — check Record from on the Settings page.</value>
</data>
<data name="RecordTimerLabel" xml:space="preserve">
<value>STOPS IN</value>
</data>
<data name="RecordTimerHelp" xml:space="preserve">
<value>Time left on the recording timer set on the Advanced page. The track playing when it runs out is finished before recording stops.</value>
</data>
<data name="RecordSizeMegabytes" xml:space="preserve">
<value>{0:0.#} MB</value>
</data>
<data name="RecordSizeGigabytes" xml:space="preserve">
<value>{0:0.##} GB</value>
</data>
</root>
83 changes: 73 additions & 10 deletions src/Offstream.App/Services/RecordingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public RecordingController(IRecordingSessionFactory factory, SettingsDocument se
private readonly SemaphoreSlim _gate = new(1, 1);

private RecordingSession? _session;
private DateTimeOffset? _startedAt;
private bool _disposed;

/// <summary>Progress from the running session, forwarded verbatim.</summary>
Expand Down Expand Up @@ -126,12 +127,19 @@ public string FormatSummary
parts.Add(string.Create(CultureInfo.CurrentCulture, $"{recording.BitrateKbps}K"));
}

if (SampleRateHertz() is { } hertz)
var (hertz, name) = CaptureEndpoint();

if (hertz is { } rate)
{
parts.Add(string.Create(CultureInfo.CurrentCulture, $"{hertz / 1000d:0.#}K"));
parts.Add(string.Create(CultureInfo.CurrentCulture, $"{rate / 1000d:0.#}K"));
}

return string.Join(' ', parts);
var technical = string.Join(' ', parts);

// The device goes after a separator rather than into the space-joined run: it is the
// one part of this line that is a name rather than a number, and names have spaces in
// them. "MP3 320K 48K Speakers (Realtek(R) Audio)" reads as one long token.
return string.IsNullOrWhiteSpace(name) ? technical : $"{technical} · {name}";
}
}

Expand All @@ -143,24 +151,76 @@ public string FormatSummary
/// the capture opened, and re-reading the endpoint could disagree with the file being written
/// if the default endpoint moved underneath us. Idle, the endpoint is asked directly.
/// </remarks>
private int? SampleRateHertz()
/// <summary>
/// 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.
/// </remarks>
public int? BytesPerSecond
{
if (_session?.Level.Format.SampleRate is { } running) return running;
get
{
var recording = _settings.Current.ToRecordingSettings();
var profile = EncodingProfiles.For(recording.MediaFormat);

return profile.SupportsBitrate ? recording.BitrateKbps * 1000 / 8 : null;
}
}

private (int? Hertz, string? Name) CaptureEndpoint()
{
// One activation for both facts. They come off the same device, and this used to be two
// calls a second on the path AudioEndpoints.Resolve already warns about.
try
{
using var device = AudioEndpoints.Resolve(_settings.Current.Recording.AudioEndpointDeviceId);
return device.AudioClient.MixFormat.SampleRate;

// A running session's own format wins. It was taken from the device when the capture
// opened, and re-reading could disagree with the file actually being written if the
// system default moved underneath us mid-recording.
var hertz = _session?.Level.Format.SampleRate ?? device.AudioClient.MixFormat.SampleRate;

return (hertz, device.FriendlyName);
}
catch (Exception ex)
{
// Nothing here is worth interrupting anyone over: the line simply comes up one word
// short, and Start reports a missing endpoint properly when it matters.
Log.Debug(ex, "Could not read the capture endpoint's sample rate for the format line");
return null;
// Nothing here is worth interrupting anyone over: the line simply comes up short, and
// Start reports a missing endpoint properly when it matters.
Log.Debug(ex, "Could not read the capture endpoint for the format line");
return (_session?.Level.Format.SampleRate, null);
}
}

/// <summary>
/// How long until the recording timer stops the session, or null when none is armed.
/// </summary>
/// <remarks>
/// <b>It reads "stops after the track playing when this runs out", not "stops at zero".</b>
/// <see cref="Offstream.Core.Recording.RecordingSession"/> arms a one-shot timer at session
/// start that sets a flag; the session then finishes the track it is on before stopping, so
/// the real end is this figure plus whatever is left of the current song. Counting down to
/// zero and then continuing to record would look broken without that said somewhere, which is
/// what the label and its tooltip are for.
/// </remarks>
public TimeSpan? TimerRemaining
{
get
{
if (_startedAt is not { } started) return null;

var recording = _settings.Current.ToRecordingSettings();
if (!recording.HasRecordingTimerEnabled) return null;

var left = recording.RecordingTimerDuration - (DateTimeOffset.UtcNow - started);

return left > TimeSpan.Zero ? left : TimeSpan.Zero;
}
}


/// <summary>The library root, so paths can be shown relative to it rather than in full.</summary>
public string? OutputPath => _settings.Current.Output.Path;

Expand Down Expand Up @@ -192,6 +252,7 @@ public string FormatSummary
}

_session = await BuildAsync(settings);
_startedAt = DateTimeOffset.UtcNow;
}
catch (FFmpegNotFoundException ex)
{
Expand Down Expand Up @@ -225,6 +286,7 @@ public async Task StopAsync()

var session = _session;
_session = null;
_startedAt = null;

try
{
Expand Down Expand Up @@ -355,6 +417,7 @@ private async Task ReleaseEndedSessionAsync()
if (session is null) return;

_session = null;
_startedAt = null;

CaptureRuntimeState(session);
await Release(session);
Expand Down
7 changes: 7 additions & 0 deletions src/Offstream.App/Themes/Tokens.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@
<SolidColorBrush x:Key="OffstreamLcdInkBrush" Color="#7C8B82" />
<SolidColorBrush x:Key="OffstreamLcdBezelBrush" Color="#333A35" />

<!--
The warning lamp's amber. Taken from the spectrum's own −12 dB stop rather than picked
fresh, so the one colour on the panel that means "look at this, it is not an error yet"
is a colour the meter is already using at the same point on its scale.
-->
<SolidColorBrush x:Key="OffstreamLcdWarnBrush" Color="#C2992F" />

<!--
The glass itself: a faint vertical gradient with scanlines over it. A flat fill reads as
a coloured rectangle; the sheen and the line structure are what make the eye accept it as
Expand Down
Loading
Loading