Bump to v1.2.0: recording, sound cues, drift compensation, diagnostics

New user-facing features:

* Recording. Dedicated Record menu (Alt+O — moved from Alt+R to
  avoid clashing with the Receive audio checkbox), Start/Stop on
  Ctrl+R, settings dialog, per-profile source / format / bit-depth /
  channel-mode / folder. Three source modes (received only, sent
  only, both). Files are crash-resilient — a process crash
  mid-recording leaves a playable file containing everything up to
  the last header refresh (~5 seconds).

* Four output formats, all functional:
    - WAV: 16/24-bit PCM or 32-bit float, custom writer with
      periodic RIFF re-patching.
    - MP3: LAME 128–320 kbps CBR (via NAudio.Lame).
    - OGG-Opus: 96–256 kbps VBR (via Concentus.Oggfile, reusing the
      Concentus encoder from the wire path).
    - FLAC: 16/24-bit lossless (via CUETools.Codecs.FLAKE — pure
      managed, no native DLL).

* Recording start/stop sound cues. record start.wav and
  record stop.wav play around the recording transition. Played via
  System.Media.SoundPlayer to the default Windows output, separate
  from the recording pipeline so a normal recording does not contain
  the cue.

* Per-cue Preferences. The old single "Mute connect/disconnect
  sounds" checkbox is replaced by a CheckedListBox: Connect /
  Disconnect / Recording start / Recording stop. Old profiles with
  the legacy MuteConnectionCues=true are honoured on first load via
  a migration path in the new Load* helpers.

* Receiver-side drift compensation switched from discrete
  single-frame splices to a continuous WdlResampler at a smoothed
  rate ratio. SessionPlayout.cs rewrite.

Diagnostics (only active with Enable logs ticked):

* Per-stage discontinuity probes — sender raw capture (per backend,
  PushModeWasapi + Asio both wired), sender pre-encode (now per
  lane in BothIndependent, fixing a cross-stream artefact), receiver
  post-decode, post-ring, post-resampler.

* Wire-level packet sequence tracking on each PCM stream — in-order
  / missed / reordered / duplicated counts in the diag log.

* Clipped-sample delta in the diag log.

* New AudioStepProbe in RemSound.Core with per-channel scan helper.

UI changes:

* Record menu uses Alt+O (Rec&ord). Inside the menu, item mnemonics
  unchanged (S / T / O / C).

* Auto-tune interval combo label is mode-aware: "Auto-tune latency
  interval" in classic modes, "Auto-tune interval — WASAPI and ASIO"
  in BothIndependent. The combo's Enabled state now follows EITHER
  lane's auto-tune checkbox (was only the WASAPI one — bug).

Wire format and audio pipeline unchanged from v1.1 — v1.1 and v1.2
peers interoperate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-15 12:40:01 +01:00
co-authored by Claude Opus 4.7
parent a0fe8070ed
commit c59e413c1f
27 changed files with 3348 additions and 350 deletions
+5
View File
@@ -28,6 +28,11 @@ PLAN-*.md
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# Audio-editor sidecar files. Sony / Magix Sound Forge writes a `.sfk` peak-cache file
# next to every `.wav` it touches; these are pure metadata, regenerated on demand by the
# editor, and shouldn't be in source control alongside the cue-sound wavs themselves.
*.sfk
# Misc # Misc
*.log *.log
*.tmp *.tmp
+23 -10
View File
@@ -1,20 +1,33 @@
# RemSound v1.0 # RemSound v1.2
Initial public release. Recording, sound cues, and receiver-side drift compensation. The wire format and audio pipeline are unchanged from v1.1, so v1.1 and v1.2 peers interoperate.
## Highlights ## Highlights
- Low-latency peer-to-peer audio over UDP. WASAPI for any Windows audio device, with a parallel ASIO lane for pro audio interfaces (Audient, Komplete Audio, Focusrite, RME). Each lane keeps its own native callback latency. - **Recording to disk.** Dedicated Record menu (Alt+O) with Start/Stop on Ctrl+R, settings dialog, per-profile source / format / bit-depth / channel-mode / folder. Pick **received only**, **sent only**, or **both** as the recording source. Files are crash-resilient — a process crash mid-recording leaves a playable file containing everything up to the last header refresh (~5 seconds).
- Pick an ASIO driver from the dropdown at the top of the Audio inputs and outputs tab to bring ASIO into the pipeline; select **(none)** to run WASAPI-only. - **Four output formats, all functional.** WAV (16/24-bit PCM or 32-bit float), MP3 (LAME, 128320 kbps CBR), **OGG-Opus** (96256 kbps VBR, reuses the Concentus encoder from the wire path), **FLAC** (pure-managed CUETools FLAKE, lossless ~50% the size of WAV). All four record at 48 kHz; labels make the rate explicit.
- Profile system. Save your entire setup — device ticks, peers, codec, latency targets, hotkeys, ASIO driver choice — into a JSON file. Pick which profile to load at every launch. - **Recording start / stop sound cues.** Short audible confirmation when recording transitions on or off. Played via the default Windows output device, separate from the recording pipeline, so a normal recording does not include the cue.
- Continuous auto-tune on either lane. Watches receive jitter and nudges the latency target up or down to stay click-free without forcing you to overshoot. - **Per-cue sound preferences.** The single "Mute connect/disconnect sounds" checkbox in Preferences is replaced by a per-cue CheckedListBox: Connect / Disconnect / Recording start / Recording stop. Old profiles with the legacy mute on are honoured automatically on first load.
- Opus inband FEC. Single-packet losses recover transparently in both Opus modes; you don't hear them at all. PCM is also available for clean LAN connections. - **Receiver-side drift compensation upgraded.** Continuous `WdlResampler` running at a slowly-updated rate ratio replaces v1.1's discrete single-frame splice corrector. Smooths long-session sender-vs-receiver clock drift without the occasional 21 µs splice.
- Remote control. Configurable global hotkeys can nudge a peer's RemSound volume or their Windows default-output-device master volume, opt-in on the receiver.
- Built-in self-updater. Optionally polls GitHub for newer releases on a schedule you set; can install them silently if you want. ## UI changes
- **Record menu moved to Alt+O.** The old `Alt+R` chord collided with the **Receive audio (Alt+R)** checkbox on the main form. Inside the menu the item mnemonics are unchanged (S / T / O / C).
- **Auto-tune interval label is mode-aware.** In BothIndependent mode the interval combo's label reads "Auto-tune interval — WASAPI and ASIO" so it's clear the same combo drives both lanes' tick cadence — each lane still independently tunes to its own latency target.
## Diagnostics (only active with Enable logs ticked)
- Per-stage discontinuity probes: sender raw-capture, sender pre-encode (per-lane in BothIndependent), receiver post-decode, post-ring-buffer, post-resampler. Lets log inspection localise where in the chain an audio click was introduced.
- Wire-level sequence tracking on each PCM stream: in-order / missed / reordered / duplicated packet counts in the diag log.
- Clipped-sample delta in the diag log.
## Bug fixes
- Auto-tune interval combo no longer greys out when only the ASIO lane's auto-tune is ticked in BothIndependent mode. Previously the combo's enabled state followed only the WASAPI checkbox.
## Install ## Install
1. Download `RemSound-v1.0.zip` from this release. 1. Download `RemSound-v1.2.zip` from this release.
2. Extract somewhere with write permission (e.g. `C:\RemSound\`, `Documents\RemSound\`, etc.). Avoid `Program Files` unless you grant write permission so the self-updater can replace files in place. 2. Extract somewhere with write permission (e.g. `C:\RemSound\`, `Documents\RemSound\`, etc.). Avoid `Program Files` unless you grant write permission so the self-updater can replace files in place.
3. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts. 3. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts.
4. Press F1 (or use the Help menu) for the user manual. 4. Press F1 (or use the Help menu) for the user manual.
+94 -8
View File
@@ -31,7 +31,7 @@ ul, ol { padding-left: 1.4em; }
<li><a href="#quick-start">Quick start</a></li> <li><a href="#quick-start">Quick start</a></li>
<li><a href="#profiles">Profiles</a></li> <li><a href="#profiles">Profiles</a></li>
<li><a href="#main-window">The main window: menu bar + three tabs</a></li> <li><a href="#main-window">The main window: menu bar + three tabs</a></li>
<li><a href="#menu-bar">Menus (File and Help)</a></li> <li><a href="#menu-bar">Menus (File, Record (Alt+O), and Help)</a></li>
<li><a href="#tab-connectivity">Connectivity tab</a></li> <li><a href="#tab-connectivity">Connectivity tab</a></li>
<li><a href="#tab-io">Audio inputs and outputs tab</a></li> <li><a href="#tab-io">Audio inputs and outputs tab</a></li>
<li><a href="#tab-profile">Audio profile tab</a></li> <li><a href="#tab-profile">Audio profile tab</a></li>
@@ -44,6 +44,7 @@ ul, ol { padding-left: 1.4em; }
<li><a href="#remote-control">Remote control: adjusting a peer's listening volume from your end</a></li> <li><a href="#remote-control">Remote control: adjusting a peer's listening volume from your end</a></li>
<li><a href="#startup">Startup behaviour</a></li> <li><a href="#startup">Startup behaviour</a></li>
<li><a href="#updates">Updating RemSound</a></li> <li><a href="#updates">Updating RemSound</a></li>
<li><a href="#recording">Recording to a file</a></li>
<li><a href="#logs">Logs and diagnostics</a></li> <li><a href="#logs">Logs and diagnostics</a></li>
<li><a href="#troubleshooting">Troubleshooting</a></li> <li><a href="#troubleshooting">Troubleshooting</a></li>
<li><a href="#glossary">Glossary</a></li> <li><a href="#glossary">Glossary</a></li>
@@ -169,7 +170,9 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Audio profile</strong></td><td>Codec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into <em>Audio send parameters</em> and <em>Audio receive parameters</em> sections so NVDA announces which group you&rsquo;ve entered as you tab through.</td></tr> <tr><td><strong>Audio profile</strong></td><td>Codec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into <em>Audio send parameters</em> and <em>Audio receive parameters</em> sections so NVDA announces which group you&rsquo;ve entered as you tab through.</td></tr>
</table> </table>
<h2 id="menu-bar">5. Menus (File and Help)</h2> <h2 id="menu-bar">5. Menus (File, Record, and Help)</h2>
<p>Three top-level menus on the main window: <strong>File (Alt+F)</strong>, <strong>Record (Alt+O)</strong>, and <strong>Help (Alt+H)</strong>. The Record menu uses Alt+O rather than Alt+R so it doesn&rsquo;t clash with the <strong>Receive audio (Alt+R)</strong> checkbox on the main form.</p>
<h3>File menu</h3> <h3>File menu</h3>
@@ -183,10 +186,22 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Rename current profile&hellip;</strong></td><td>Alt+F, R</td><td>Renames the JSON file on disk and updates the window title. No-op on Blank template (no profile to rename).</td></tr> <tr><td><strong>Rename current profile&hellip;</strong></td><td>Alt+F, R</td><td>Renames the JSON file on disk and updates the window title. No-op on Blank template (no profile to rename).</td></tr>
<tr><td><strong>Minimise to tray</strong></td><td>Alt+F, M</td><td>Hides the window to the system tray. Reachable via the File menu chain (Alt+F to open the menu, then M for Minimise). To bring the window back, click the tray icon or use the configurable "Show or hide window" global hotkey (Keyboard shortcuts dialog, default Ctrl+Shift+F10).</td></tr> <tr><td><strong>Minimise to tray</strong></td><td>Alt+F, M</td><td>Hides the window to the system tray. Reachable via the File menu chain (Alt+F to open the menu, then M for Minimise). To bring the window back, click the tray icon or use the configurable "Show or hide window" global hotkey (Keyboard shortcuts dialog, default Ctrl+Shift+F10).</td></tr>
<tr><td><strong>Keyboard shortcuts&hellip;</strong></td><td>Ctrl+K</td><td>Opens the global hotkey configuration dialog (mute, volume, tray show/hide, remote-control sends).</td></tr> <tr><td><strong>Keyboard shortcuts&hellip;</strong></td><td>Ctrl+K</td><td>Opens the global hotkey configuration dialog (mute, volume, tray show/hide, remote-control sends).</td></tr>
<tr><td><strong>Preferences&hellip;</strong></td><td>Ctrl+P</td><td>Opens the Preferences dialog. Sticky machine-local choices live here: profile folder, mute connect/disconnect sounds, accept remote volume commands, startup behaviour, update-check frequency, manual check-for-updates button, silently install updates, enable logs, write logs now. Esc or the Close button dismisses.</td></tr> <tr><td><strong>Preferences&hellip;</strong></td><td>Ctrl+P</td><td>Opens the Preferences dialog. Sticky machine-local choices live here: profile folder, the <strong>Cue sounds (Alt+N)</strong> checklist (one tickable item per cue: connect, disconnect, recording start, recording stop), accept remote volume commands, startup behaviour, update-check frequency, manual check-for-updates button, silently install updates, enable logs, write logs now. Esc or the Close button dismisses.</td></tr>
<tr><td><strong>Exit</strong></td><td>Alt+F, X (or Alt+F4)</td><td>Closes RemSound. If there are unsaved profile changes you&rsquo;ll be prompted first.</td></tr> <tr><td><strong>Exit</strong></td><td>Alt+F, X (or Alt+F4)</td><td>Closes RemSound. If there are unsaved profile changes you&rsquo;ll be prompted first.</td></tr>
</table> </table>
<h3>Record menu</h3>
<p>Press <strong>Alt+O</strong> to open the menu, then the underlined letter for the item you want. (Alt+O rather than Alt+R because Alt+R is taken on the main form by the <strong>Receive audio</strong> checkbox.) The recording feature taps the audio path at the right place to capture either what you&rsquo;re sending, what you&rsquo;re receiving, or both, and writes it to disk as a WAV, MP3, OGG-Opus, or FLAC file. See <a href="#recording">Recording to a file</a> for the full chapter; this is the menu summary.</p>
<table>
<tr><th>Item</th><th>Shortcut</th><th>What it does</th></tr>
<tr><td><strong>Start recording / Stop recording</strong></td><td>Ctrl+R, or Alt+O, S</td><td>Toggle. The label flips between the two states so NVDA announces whichever action the next press would perform. Each press of Start plays a short cue sound (if enabled in Preferences), then creates a new file in your recordings folder named <code>RemSound-yyyy-MM-dd_HH-mm-ss.&lt;ext&gt;</code>. Stop closes the file then plays the stop cue. Ctrl+R works from anywhere in the main window.</td></tr>
<tr><td><strong>Recording settings&hellip;</strong></td><td>Alt+O, T</td><td>Opens the Recording settings dialog. Three listboxes side-by-side: <em>Recording source</em> (Alt+S), <em>File format</em> (Alt+F), <em>Audio format attributes</em> (Alt+A). The attributes list repopulates based on the selected format. OK saves to the current profile; Cancel discards.</td></tr>
<tr><td><strong>Open current recordings folder</strong></td><td>Alt+O, O</td><td>Opens your current recordings folder in Windows Explorer. Creates the folder if it doesn&rsquo;t exist yet (first launch after a fresh install).</td></tr>
<tr><td><strong>Change recordings folder&hellip;</strong></td><td>Alt+O, C</td><td>Folder picker. Pick a different folder for future recordings. The choice is saved on the current profile, so different profiles can record to different places.</td></tr>
</table>
<h3>Help menu</h3> <h3>Help menu</h3>
<p>Press <strong>Alt+H</strong> to open the menu, then the underlined letter for the item you want.</p> <p>Press <strong>Alt+H</strong> to open the menu, then the underlined letter for the item you want.</p>
@@ -308,7 +323,7 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>ASIO latency in milliseconds</strong></td><td>Alt+L</td><td>(Only when an ASIO driver is selected.) Spinner. Target receive buffer for the ASIO lane. Default 10 ms. ASIO&rsquo;s native pipeline can sustain very low targets, but values below the platform&rsquo;s real-world jitter floor (typically 15&ndash;25 ms) cause constant micro-corrections that you can hear &mdash; pick 25 ms as a safe floor unless you&rsquo;re on localhost or wired LAN.</td></tr> <tr><td><strong>ASIO latency in milliseconds</strong></td><td>Alt+L</td><td>(Only when an ASIO driver is selected.) Spinner. Target receive buffer for the ASIO lane. Default 10 ms. ASIO&rsquo;s native pipeline can sustain very low targets, but values below the platform&rsquo;s real-world jitter floor (typically 15&ndash;25 ms) cause constant micro-corrections that you can hear &mdash; pick 25 ms as a safe floor unless you&rsquo;re on localhost or wired LAN.</td></tr>
<tr><td><strong>Continuous auto-tune ASIO latency</strong></td><td>Alt+T</td><td>(Only when an ASIO driver is selected.) Checkbox. Nudges the ASIO latency target as ASIO-lane jitter changes. Independent of the WASAPI toggle.</td></tr> <tr><td><strong>Continuous auto-tune ASIO latency</strong></td><td>Alt+T</td><td>(Only when an ASIO driver is selected.) Checkbox. Nudges the ASIO latency target as ASIO-lane jitter changes. Independent of the WASAPI toggle.</td></tr>
<tr><td><strong>WASAPI latency in milliseconds</strong> (called just &ldquo;Audio latency&rdquo; with no ASIO driver)</td><td>Alt+W (Alt+L when no ASIO driver)</td><td>Spinner. Target receive buffer for the WASAPI lane (or the only lane in WASAPI-only setups). Smaller = less delay, more clicks. Most people want 20&ndash;80 ms.</td></tr> <tr><td><strong>WASAPI latency in milliseconds</strong> (called just &ldquo;Audio latency&rdquo; with no ASIO driver)</td><td>Alt+W (Alt+L when no ASIO driver)</td><td>Spinner. Target receive buffer for the WASAPI lane (or the only lane in WASAPI-only setups). Smaller = less delay, more clicks. Most people want 20&ndash;80 ms.</td></tr>
<tr><td><strong>Continuous auto-tune WASAPI latency</strong> (called &ldquo;Continuous auto-tune latency&rdquo; with no ASIO driver)</td><td>Alt+Y (Alt+T when no ASIO driver)</td><td>Checkbox. When on, RemSound nudges the WASAPI lane latency value automatically as the network changes. The accompanying <strong>Auto-tune latency interval (Alt+I)</strong> combo sets how often it re-evaluates: 3, 5, 10, 15, or 30 seconds. The interval is shared between the WASAPI and ASIO lanes &mdash; one tick rate, one combo.</td></tr> <tr><td><strong>Continuous auto-tune WASAPI latency</strong> (called &ldquo;Continuous auto-tune latency&rdquo; with no ASIO driver)</td><td>Alt+Y (Alt+T when no ASIO driver)</td><td>Checkbox. When on, RemSound nudges the WASAPI lane latency value automatically as the network changes. The accompanying interval combo (<strong>Alt+I</strong>) sets how often it re-evaluates: 3, 5, 10, 15, or 30 seconds. The combo&rsquo;s label is &ldquo;Auto-tune latency interval&rdquo; in WASAPI-only mode and &ldquo;Auto-tune interval &mdash; WASAPI and ASIO&rdquo; when an ASIO driver is selected, because the single tick rate drives both lanes&rsquo; auto-tunes when ASIO is in play. Each lane still independently lands at whatever target its own algorithm chooses; only the cadence is shared.</td></tr>
<tr><td><strong>Buffer smoothness</strong></td><td>Alt+B</td><td>Listbox, 1 to 10. Controls how patient the receiver is with late-arriving audio on either lane. Higher = more click protection, longer steady-state delay. Default 3.</td></tr> <tr><td><strong>Buffer smoothness</strong></td><td>Alt+B</td><td>Listbox, 1 to 10. Controls how patient the receiver is with late-arriving audio on either lane. Higher = more click protection, longer steady-state delay. Default 3.</td></tr>
<tr><td><strong>Artefact sound type</strong></td><td>Alt+A</td><td>Listbox. <em>Noise burst</em> (default) replaces an empty-buffer moment with a brief broadband shhh, which blends into music. <em>Click</em> uses raw zero-fill (no concealment) for diagnostic purposes &mdash; you hear an obvious click on every buffer underrun.</td></tr> <tr><td><strong>Artefact sound type</strong></td><td>Alt+A</td><td>Listbox. <em>Noise burst</em> (default) replaces an empty-buffer moment with a brief broadband shhh, which blends into music. <em>Click</em> uses raw zero-fill (no concealment) for diagnostic purposes &mdash; you hear an obvious click on every buffer underrun.</td></tr>
</table> </table>
@@ -585,7 +600,7 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<tr><td>Alt+T</td><td>Toggle continuous auto-tune &mdash; ASIO lane when an ASIO driver is selected, otherwise the single Continuous auto-tune toggle</td></tr> <tr><td>Alt+T</td><td>Toggle continuous auto-tune &mdash; ASIO lane when an ASIO driver is selected, otherwise the single Continuous auto-tune toggle</td></tr>
<tr><td>Alt+W</td><td>(Only when an ASIO driver is selected.) Focus the WASAPI-lane latency spinner</td></tr> <tr><td>Alt+W</td><td>(Only when an ASIO driver is selected.) Focus the WASAPI-lane latency spinner</td></tr>
<tr><td>Alt+Y</td><td>(Only when an ASIO driver is selected.) Toggle the WASAPI-lane continuous auto-tune</td></tr> <tr><td>Alt+Y</td><td>(Only when an ASIO driver is selected.) Toggle the WASAPI-lane continuous auto-tune</td></tr>
<tr><td>Alt+I</td><td>Focus Auto-tune latency interval</td></tr> <tr><td>Alt+I</td><td>Focus Auto-tune latency interval combo. Drives the tick rate for the WASAPI auto-tune and (when an ASIO driver is selected) the ASIO auto-tune as well &mdash; one combo, both lanes. Each lane still independently lands at whatever latency its own algorithm decides; only the cadence is shared. The label changes from &ldquo;Auto-tune latency interval&rdquo; in WASAPI-only mode to &ldquo;Auto-tune interval &mdash; WASAPI and ASIO&rdquo; once an ASIO driver is in play, to make the dual-lane scope visible.</td></tr>
<tr><td>Alt+B</td><td>Focus Buffer smoothness</td></tr> <tr><td>Alt+B</td><td>Focus Buffer smoothness</td></tr>
<tr><td>Alt+A</td><td>Focus Artefact sound type</td></tr> <tr><td>Alt+A</td><td>Focus Artefact sound type</td></tr>
</table> </table>
@@ -596,6 +611,11 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<tr><td>Ctrl+S</td><td>Save active profile (or Save as if on Blank template)</td></tr> <tr><td>Ctrl+S</td><td>Save active profile (or Save as if on Blank template)</td></tr>
<tr><td>Ctrl+K</td><td>Open Keyboard shortcuts dialog</td></tr> <tr><td>Ctrl+K</td><td>Open Keyboard shortcuts dialog</td></tr>
<tr><td>Ctrl+P</td><td>Open Preferences dialog</td></tr> <tr><td>Ctrl+P</td><td>Open Preferences dialog</td></tr>
<tr><td>Ctrl+R</td><td>Start or stop recording (toggles)</td></tr>
<tr><td>Alt+O, S</td><td>Start or stop recording (via menu &mdash; Record menu is Alt+O, item is S)</td></tr>
<tr><td>Alt+O, T</td><td>Recording settings dialog</td></tr>
<tr><td>Alt+O, O</td><td>Open current recordings folder</td></tr>
<tr><td>Alt+O, C</td><td>Change recordings folder</td></tr>
<tr><td>Alt+F, O</td><td>Open profile</td></tr> <tr><td>Alt+F, O</td><td>Open profile</td></tr>
<tr><td>Alt+F, A</td><td>Save profile as</td></tr> <tr><td>Alt+F, A</td><td>Save profile as</td></tr>
<tr><td>Alt+F, R</td><td>Rename current profile</td></tr> <tr><td>Alt+F, R</td><td>Rename current profile</td></tr>
@@ -750,7 +770,73 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down
<p>To see what version you&rsquo;re currently on without checking for updates, open <strong>Help &rarr; About RemSound</strong> (Alt+H, A). The dialog shows the version number and the release notes for the build you&rsquo;re running, in a scrollable read-only text box you can tab into and arrow through under NVDA. Close (or Esc) dismisses.</p> <p>To see what version you&rsquo;re currently on without checking for updates, open <strong>Help &rarr; About RemSound</strong> (Alt+H, A). The dialog shows the version number and the release notes for the build you&rsquo;re running, in a scrollable read-only text box you can tab into and arrow through under NVDA. Close (or Esc) dismisses.</p>
<h2 id="logs">18. Logs and diagnostics</h2> <h2 id="recording">18. Recording to a file</h2>
<p>RemSound can write the audio passing through it to a file on disk &mdash; useful for keeping a copy of a music session, capturing a long jam for later editing, or just saving a one-off voice exchange you want to refer back to.</p>
<h3>What gets recorded</h3>
<p>Recording taps the audio path at fully-mixed, fully-processed points: for the received side, after volume / mute / limiter have been applied (so the file mirrors what you hear); for the sent side, the raw captured float audio just before it&rsquo;s encoded for the wire (so the file is independent of which codec you chose). The three source options:</p>
<ul>
<li><strong>Record all received audio</strong> &mdash; the full mix of everything coming in from connected peers. Default.</li>
<li><strong>Record all sent audio</strong> &mdash; what your microphones, loopbacks and ASIO inputs are sending out. Useful for confirming what your collaborators are actually hearing from your end.</li>
<li><strong>Record both sent and received audio</strong> &mdash; a single file containing both directions, soft-mixed and limiter-protected. The right pick for capturing a complete two-way exchange.</li>
</ul>
<h3>File formats</h3>
<p>All four formats record at 48 kHz &mdash; that&rsquo;s the engine&rsquo;s internal mix rate &mdash; and labels in the attributes list make the rate explicit so it&rsquo;s never ambiguous.</p>
<table>
<tr><th>Format</th><th>What you get</th><th>When to pick it</th></tr>
<tr><td><strong>WAV</strong> (default)</td><td>Uncompressed RIFF WAVE. Lossless and large &mdash; about 17 MB / minute at 24-bit stereo. Bit-depth choices: 16-bit PCM, 24-bit PCM (default, matches RemSound&rsquo;s on-wire format with no extra quantisation), or 32-bit IEEE float (no quantisation at all). Plus stereo or mono.</td><td>Archival, editing in a DAW, anything where you might re-master later.</td></tr>
<tr><td><strong>MP3</strong></td><td>MPEG Layer III at one of four CBR bitrates: 128 / 192 / 256 / 320 kbps. Stereo or mono. Encoder is LAME via the NAudio.Lame package &mdash; the same library every other Windows MP3-encoding app uses, so output should play in everything.</td><td>Long sessions where filesize matters; quick distribution to people who just want a listen-once file.</td></tr>
<tr><td><strong>OGG-Opus</strong></td><td>OGG container holding Opus-encoded audio at one of four target VBR bitrates: 96 / 128 / 192 / 256 kbps. Stereo or mono. File extension is <code>.opus</code>. Encoder is Concentus &mdash; the same managed Opus implementation the wire path uses, so the encoded result is byte-identical to what you&rsquo;d transmit on a live Opus session at the same bitrate.</td><td>Smaller files than MP3 at comparable quality; broad modern playback support (VLC, mpv, web browsers).</td></tr>
<tr><td><strong>FLAC</strong></td><td>Free Lossless Audio Codec, integer-PCM only. Bit-depth choices: 16-bit or 24-bit (default, matches RemSound&rsquo;s on-wire format). Stereo or mono. Files are typically ~50% the size of equivalent WAV with zero quality loss. Encoder is pure managed (CUETools FLAKE) &mdash; no native DLL ships with RemSound.</td><td>Lossless archival when you also want a sensible file size. Picks the WAV-vs-MP3 dilemma squarely &mdash; the file plays back bit-identical to WAV but is half the size.</td></tr>
</table>
<p><strong>Crash resilience.</strong> All four formats are designed to leave a playable file behind even if RemSound crashes mid-recording. WAV re-patches its RIFF / data-chunk size fields every 5 seconds; MP3, OGG-Opus, and FLAC all use stream formats where every frame is self-decoding, so a truncated file is automatically a valid shorter file of the same format. You lose at most ~5 seconds of recently-captured audio on a crash, never the whole session.</p>
<h3>Start / stop sound cues</h3>
<p>RemSound plays a short ding when a recording starts and another when it stops, so you have an audible confirmation that the toggle actually took effect. The cues are <code>record start.wav</code> and <code>record stop.wav</code> in the RemSound install folder &mdash; you can drop your own files in to customise them, as long as they keep the same filenames.</p>
<p>Each cue can be turned off independently via <strong>Preferences &rarr; Cue sounds (Alt+N)</strong>. The same checklist also controls the older connect / disconnect cues. The cues play through the default Windows output device using <code>System.Media.SoundPlayer</code>, which sits outside the recording pipeline &mdash; so a normal RemSound recording does not contain the cue. The exception is if your sender side is using WASAPI loopback of the very same output device the cue plays to as one of its capture sources, in which case the loopback grabs the cue alongside the rest of the device&rsquo;s output; in that case you&rsquo;ll have to live with the start cue appearing in the file unless you disable it.</p>
<h3>Where recordings go</h3>
<p>By default, recordings live in <code>&lt;RemSound install folder&gt;\recordings\&lt;machine name&gt;\</code>. Each recording session creates a new file named <code>RemSound-yyyy-MM-dd_HH-mm-ss.&lt;ext&gt;</code>, so files never collide.</p>
<p>You can change the folder via <strong>Record &rarr; Change recordings folder</strong>. Picking a different folder writes that path to your current profile, so the choice rides along with the rest of your settings &mdash; switching profiles can switch recording destinations too. If a saved profile points at a folder that doesn&rsquo;t exist on the loading machine, the recorder silently falls back to the default location for the current machine.</p>
<p><strong>Record &rarr; Open current recordings folder</strong> launches Windows Explorer on whatever the configured folder currently is, creating it on the fly if a recording hasn&rsquo;t been made there yet.</p>
<h3>Starting and stopping</h3>
<p>Three ways to start or stop a recording:</p>
<ul>
<li><strong>Ctrl+R</strong> from anywhere in the main window &mdash; toggle. The menu item text flips between &ldquo;Start recording&rdquo; and &ldquo;Stop recording&rdquo; so NVDA announces the current state when you walk into the menu.</li>
<li><strong>Record &rarr; Start recording</strong> (or Stop, when one is in progress).</li>
<li>Closing RemSound while a recording is running cleanly finalises the file &mdash; you don&rsquo;t lose data if you forget to stop manually.</li>
</ul>
<p>The recorder runs on its own background thread with a generous queue, so neither the audio nor the network path is affected by disk speed. If the disk ever can&rsquo;t keep up with the audio rate, the recorder drops the oldest queued audio (never the newest) and notes it in the log; in practice you&rsquo;ll only see that on a fully-saturated USB stick or a very slow network drive.</p>
<h3>Recording settings dialog</h3>
<p>Reached via <strong>Record &rarr; Recording settings</strong>. Three keyboard-navigable listboxes:</p>
<table>
<tr><th>List</th><th>Shortcut</th><th>What goes in it</th></tr>
<tr><td><strong>Recording source</strong></td><td>Alt+S</td><td>Receive only / Send only / Both. See the source explainer above.</td></tr>
<tr><td><strong>File format</strong></td><td>Alt+F</td><td>WAV / MP3 / Ogg-Opus / FLAC. The attributes list to the right repopulates whenever you change this.</td></tr>
<tr><td><strong>Audio format attributes</strong></td><td>Alt+A</td><td>Repopulates per format, with the 48 kHz sample rate stated on every row so there&rsquo;s no ambiguity. WAV: six rows pairing bit-depth (16 / 24 / 32-bit float) with channel mode (stereo / mono). MP3: eight rows pairing CBR bitrate (128 / 192 / 256 / 320 kbps) with channel mode. OGG-Opus: eight rows pairing VBR bitrate (96 / 128 / 192 / 256 kbps) with channel mode. FLAC: four rows pairing bit-depth (16 / 24-bit) with channel mode &mdash; FLAC is integer-PCM only, so no 32-bit-float row. Compression level for FLAC is fixed at 5 (the libFLAC reference default).</td></tr>
</table>
<p>OK saves the choices to the current profile. Cancel (or Esc) discards. Settings persist via the normal profile-save mechanism &mdash; changes here flag the profile as having unsaved changes and you&rsquo;ll be prompted on exit if you haven&rsquo;t saved.</p>
<h2 id="logs">19. Logs and diagnostics</h2>
<p>If logging is enabled (the <strong>Enable logs</strong> checkbox in the Preferences dialog &mdash; File → Preferences, or Ctrl+P &mdash; default on), RemSound writes a tab-separated log file per session into <code>publish\logs\</code> (relative to wherever <code>RemSound.exe</code> lives). One file per launch, named <code>RemSound-&lt;machine&gt;-&lt;PID&gt;-&lt;date&gt;-&lt;time&gt;.log</code>.</p> <p>If logging is enabled (the <strong>Enable logs</strong> checkbox in the Preferences dialog &mdash; File → Preferences, or Ctrl+P &mdash; default on), RemSound writes a tab-separated log file per session into <code>publish\logs\</code> (relative to wherever <code>RemSound.exe</code> lives). One file per launch, named <code>RemSound-&lt;machine&gt;-&lt;PID&gt;-&lt;date&gt;-&lt;time&gt;.log</code>.</p>
@@ -774,7 +860,7 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down
<li><strong>OpusUnrecoveredGaps</strong> &mdash; running total of multi-packet drops that FEC couldn&rsquo;t repair. Each one corresponds to an audible click. Stays at 0 on a clean line; small numbers are normal on WAN.</li> <li><strong>OpusUnrecoveredGaps</strong> &mdash; running total of multi-packet drops that FEC couldn&rsquo;t repair. Each one corresponds to an audible click. Stays at 0 on a clean line; small numbers are normal on WAN.</li>
</ul> </ul>
<h2 id="troubleshooting">19. Troubleshooting</h2> <h2 id="troubleshooting">20. Troubleshooting</h2>
<h3>I don&rsquo;t hear my friend</h3> <h3>I don&rsquo;t hear my friend</h3>
<ol> <ol>
@@ -828,7 +914,7 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down
<h3>Audio device list shows old devices that are no longer plugged in</h3> <h3>Audio device list shows old devices that are no longer plugged in</h3>
<p>RemSound refreshes its lists every second. If a device has truly been unplugged it should disappear within a few seconds. If it&rsquo;s lingering, restart RemSound &mdash; Windows&rsquo; device cache occasionally needs poking.</p> <p>RemSound refreshes its lists every second. If a device has truly been unplugged it should disappear within a few seconds. If it&rsquo;s lingering, restart RemSound &mdash; Windows&rsquo; device cache occasionally needs poking.</p>
<h2 id="glossary">20. Glossary</h2> <h2 id="glossary">21. Glossary</h2>
<table> <table>
<tr><th>Term</th><th>Meaning</th></tr> <tr><th>Term</th><th>Meaning</th></tr>
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+68
View File
@@ -20,6 +20,74 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary> /// updates" path.</summary>
private const string ReleaseNotes = private const string ReleaseNotes =
""" """
RemSound v1.2
Recording, sound cues, and receiver-side drift compensation.
This release is mostly about features that sit on top of the
v1.1 transport the wire format and audio pipeline are
unchanged, so v1.1 and v1.2 peers interoperate.
What's new:
* Recording. New Record menu (Alt+O) Start / Stop with
Ctrl+R, dedicated settings dialog, per-profile choice of
source (received only, sent only, or both), file format
(WAV, MP3, OGG-Opus, FLAC), bit depth or bitrate, mono or
stereo, and recordings folder. Files are crash-resilient:
WAV re-patches its RIFF header every 5 seconds, MP3 / FLAC
/ OGG-Opus all produce well-formed truncated files if the
app crashes mid-recording.
* OGG-Opus and FLAC encoders now wired up they were stubs
in earlier builds. OGG-Opus reuses the same Concentus
encoder as the wire path; FLAC uses pure-managed CUETools
FLAKE (no native DLL).
* Recording start / stop sound cues. Plays a short ding
when recording transitions on or off. Played via the
default Windows output device, separate from the
recording pipeline, so a normal recording does not include
the cue.
* Sound-cue Preferences. The old single "Mute connect /
disconnect sounds" checkbox is replaced by a per-cue
CheckedListBox: Connect / Disconnect / Recording start /
Recording stop, each independently toggleable. Old profile
settings that had the legacy mute on are honoured on first
load.
* Receiver-side drift compensation switched from discrete
single-frame splices to a continuous WdlResampler running
at a slowly-updated rate ratio. Smooths out long-session
clock drift between sender and receiver without the
occasional 21 µs splice the v1.1 corrector emitted.
UI changes:
* Record menu moved to Alt+O (Rec&ord). The old Alt+R chord
conflicted with the Receive audio checkbox on the main
form. Inside the menu the item mnemonics are unchanged
(S / T / O / C for Start, settings, Open folder, Change
folder).
* Auto-tune interval combo label and accessible name are now
mode-aware. In BothIndependent mode it reads "Auto-tune
interval WASAPI and ASIO" so it's clear the same combo
drives ticks for both lanes; each lane still independently
tunes to its own target latency. Earlier builds also had
a bug where ticking ASIO auto-tune alone left this combo
greyed out fixed.
Diagnostics (only active with Enable logs ticked):
* Per-stage discontinuity probes sender raw capture,
sender pre-encode (now per lane in BothIndependent),
receiver post-decode, receiver post-ring, receiver
post-resampler. Lets a log inspection localise where a
click was introduced (capture / wire / decode / playout).
* Wire-level sequence tracking on each PCM stream:
in-order / missed / reordered / duplicated packet counts
in the diag log. Healthy LAN should show all-zero except
in-order; non-zero on the others points to transport
issues rather than software.
* Clipped-sample delta in the diag log.
Bug fixes:
* Auto-tune interval combo no longer greys out when only
ASIO auto-tune is ticked in BothIndependent.
RemSound v1.1 RemSound v1.1
Priority and performance hardening, plus always-on network Priority and performance hardening, plus always-on network
+864
View File
@@ -0,0 +1,864 @@
using System.Diagnostics;
using Concentus;
using Concentus.Enums;
using Concentus.Oggfile;
using CUETools.Codecs;
using CUETools.Codecs.FLAKE;
using NAudio.Lame;
using NAudio.Wave;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Background recorder that writes float audio to disk as WAV (custom PCM writer with
/// crash-resilient header refresh), MP3 (LAME via NAudio.Lame), OGG-Opus (Concentus +
/// Concentus.Oggfile), or FLAC (CUETools.Codecs.FLAKE — pure managed lossless).
///
/// Pipeline:
/// 1. Sender / receiver audio threads call <see cref="WriteSent"/> /
/// <see cref="WriteReceived"/> — each appends to a pre-allocated lock-free SPSC ring
/// buffer (one per direction) using nothing but a memcpy, an atomic add on the write
/// head, and an event Set. Zero allocations, zero locks, zero signaling primitives
/// that could contend with disk I/O. Audio threads NEVER touch the disk and never
/// touch the file writers.
/// 2. A single background writer thread waits on the wake-up event, drains both rings,
/// mixes the two directions when source mode is "Both", and feeds the resulting
/// samples to the format writer.
/// 3. <see cref="Stop"/> drains anything still in the rings, closes the file, and
/// signals the caller with the final path and byte count.
///
/// This shape replaced an earlier BlockingCollection + ArrayPool design (2026-05-14)
/// that exhibited intermittent pops under priority mode + recording. The semaphore
/// signaling inside BlockingCollection and the per-call ArrayPool rents were both
/// occasional sources of multi-hundred-microsecond audio-thread spikes; with a
/// 32-sample ASIO buffer (0.67 ms callback budget) that was enough to miss deadlines.
/// The lock-free ring keeps audio-thread work bounded to a handful of nanoseconds.
///
/// "Both" source mode: when both rings have audio, the writer thread drains
/// min(sent_avail, received_avail) frames and sum-mixes them. When only one side has
/// data (e.g. the user has Send Audio off, or no peer is connected), that side is
/// drained solo with the other treated as silence — the recording never stalls because
/// of a quiet direction.
///
/// Channel-mode downmix happens at the writer-thread layer (one place to do it cleanly)
/// rather than at each enqueue point.
///
/// Lifecycle: one AudioRecorder per recording session. The MainForm creates a fresh one
/// on Start and disposes it on Stop. Reconfiguring mid-session is not supported — the user
/// stops, edits settings, and starts again.
/// </summary>
internal sealed class AudioRecorder : IDisposable
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
/// <summary>Per-direction ring capacity in floats. 5 s of stereo float @ 48 kHz =
/// 480 000 floats ≈ 1.9 MB. Sized to cover any reasonable disk hiccup; in steady
/// state the rings hover near empty because the writer drains continuously. Two
/// rings means ~3.8 MB of fixed-cost memory per running recording — modest.</summary>
private const int RingCapacityFloats = MixSampleRate * MixChannels * 5;
/// <summary>Minimum frames the writer waits for before doing a drain pass. 480 frames
/// = 10 ms of audio. Below this, signaling overhead dominates; above this, the
/// chunks are big enough that a single Write to the file format writer is efficient.
/// Also caps the latency between an audio thread's tap and the disk write at ~10 ms.</summary>
private const int DrainChunkFrames = 480;
/// <summary>Maximum frames the writer drains in a single Process call. Caps the
/// CPU burst on the writer thread when the rings have been allowed to fill (e.g.
/// after a brief disk stall). At 4800 frames = 100 ms of audio per Process, the
/// writer can still keep up with a 5 s ring (50 Process calls to drain it fully).</summary>
private const int DrainChunkMaxFrames = 4800;
private readonly RecordingSettings settings;
private readonly string resolvedPath;
private readonly Action<string>? onDiagnostic;
private readonly Action<string, long>? onFinished;
// === Lock-free SPSC rings, one per direction ===
// Write head is monotonically increasing (NOT wrapped). Ring index = head % capacity.
// This avoids the ABA problem on wraparound and means the audio thread only needs an
// atomic add (not a CAS) to publish a write. The writer thread holds the read head
// (no atomic needed; single consumer).
private readonly float[] sentRing = new float[RingCapacityFloats];
private readonly float[] receivedRing = new float[RingCapacityFloats];
private long sentWriteHead; // updated atomically from audio thread
private long sentReadHead; // owned by writer thread
private long receivedWriteHead; // updated atomically from audio thread
private long receivedReadHead; // owned by writer thread
private long droppedSampleFrames;
// Wake-up event. Audio threads Set after appending to a ring; writer thread Waits.
// ManualResetEventSlim has a Spin phase before falling back to a kernel wait, so
// light contention stays in user-mode and is cheap.
private readonly ManualResetEventSlim wakeup = new(initialState: false, spinCount: 32);
private readonly Thread writerThread;
private readonly CancellationTokenSource cts = new();
private long writtenSampleFrames;
private long writtenBytes;
private volatile bool stopped;
public string FilePath => resolvedPath;
public RecordingSettings Settings => settings;
public long WrittenSampleFrames => Interlocked.Read(ref writtenSampleFrames);
/// <summary>Total stereo frames the audio thread had to drop because its ring was
/// full. Non-zero indicates the writer can't keep up with the audio rate — usually
/// a sign of a stalled disk. Surfaced in the on-stop diagnostic line.</summary>
public long DroppedSampleFrames => Interlocked.Read(ref droppedSampleFrames);
/// <summary>Constructs the recorder, opens the output file, and starts the writer
/// thread. If anything fails the constructor throws and no cleanup is needed (no
/// file has been opened yet).</summary>
public AudioRecorder(RecordingSettings settings, Action<string>? onDiagnostic, Action<string, long>? onFinished)
{
this.settings = settings.Clone();
this.onDiagnostic = onDiagnostic;
this.onFinished = onFinished;
var folder = settings.ResolvedFolder();
if (string.IsNullOrWhiteSpace(folder)) folder = RecordingSettings.DefaultFolder();
Directory.CreateDirectory(folder);
var ext = ExtensionFor(settings.FileFormat);
var stamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
resolvedPath = Path.Combine(folder, $"RemSound-{stamp}.{ext}");
// Writer creation happens on the constructor thread so any open errors are surfaced
// synchronously to the caller.
formatWriter = CreateWriter(settings.FileFormat, resolvedPath, settings);
// Writer thread at Normal priority. Previously AboveNormal, lowered 2026-05-14:
// there's no reason for the writer to compete with audio threads (which run at
// MMCSS Pro Audio priority anyway, well above any "Normal" worker). Keeping the
// writer at Normal lets the OS scheduler push it out of the way whenever the
// audio thread needs the CPU.
writerThread = new Thread(WriterLoop)
{
IsBackground = true,
Name = "RemSound.Recorder",
Priority = ThreadPriority.Normal,
};
writerThread.Start();
}
// === Audio-thread side: bounded to a memcpy + atomic add + event-set ===
/// <summary>Tap target for sender-side audio. Discarded silently if this recorder's
/// source mode is "received only". Lock-free, allocation-free; safe to call from
/// the audio thread.</summary>
public void WriteSent(ReadOnlyMemory<float> stereoFloats)
{
if (stopped) return;
if (settings.Source == RecordingSource.ReceivedOnly) return;
AppendToRing(stereoFloats.Span, sentRing, ref sentWriteHead, ref sentReadHead);
}
/// <summary>Tap target for receiver-side audio. Discarded silently if this recorder's
/// source mode is "sent only". Lock-free, allocation-free; safe to call from the
/// render thread.</summary>
public void WriteReceived(ReadOnlyMemory<float> stereoFloats)
{
if (stopped) return;
if (settings.Source == RecordingSource.SentOnly) return;
AppendToRing(stereoFloats.Span, receivedRing, ref receivedWriteHead, ref receivedReadHead);
}
/// <summary>Lock-free, allocation-free append to a single-producer-single-consumer
/// ring buffer. The producer (audio thread) owns the write head; the consumer (writer
/// thread) owns the read head. The producer reads BOTH heads (Volatile.Read) to
/// compute available space; the consumer reads BOTH heads similarly. Cross-thread
/// visibility is provided by Volatile.Read/Write — sufficient for x86/x64 memory
/// model on Windows and the only platform we target.</summary>
private void AppendToRing(ReadOnlySpan<float> samples, float[] ring, ref long writeHeadRef, ref long readHeadRef)
{
var len = samples.Length;
if (len == 0) return;
var cap = ring.Length;
var write = Volatile.Read(ref writeHeadRef);
var read = Volatile.Read(ref readHeadRef);
var used = (int)(write - read);
var free = cap - used;
if (free < len)
{
// Ring is full. Audio thread can't block (deadline-bound); we drop these
// samples and bump the counter. In practice this fires only if the writer
// thread is genuinely stuck (very slow disk, OS hang).
Interlocked.Add(ref droppedSampleFrames, len / MixChannels);
return;
}
var pos = (int)(write % cap);
var part1 = Math.Min(len, cap - pos);
samples.Slice(0, part1).CopyTo(ring.AsSpan(pos));
if (part1 < len)
{
// Wrap-around: copy the tail into the start of the ring.
samples.Slice(part1).CopyTo(ring.AsSpan(0));
}
// Publish the write — Volatile.Write ensures the sample writes above are visible
// to the consumer BEFORE it sees the advanced write head.
Volatile.Write(ref writeHeadRef, write + len);
// Wake the writer. ManualResetEventSlim.Set is a single Interlocked.CompareExchange
// on the fast path; subsequent Sets while the event is already set are essentially
// free.
wakeup.Set();
}
// === Writer thread: drains both rings, mixes if "Both", writes to file ===
private void WriterLoop()
{
try
{
while (!cts.IsCancellationRequested)
{
// Block until the audio thread signals data OR we time out (the timeout is
// a backstop so periodic format-writer flushes still happen during a long
// silent stretch with no incoming audio).
wakeup.Wait(50, cts.Token);
wakeup.Reset();
// Drain as much as is available, in chunks of up to DrainChunkMaxFrames.
while (!cts.IsCancellationRequested && HasEnoughData())
{
Process();
}
}
}
catch (OperationCanceledException) { /* normal shutdown */ }
catch (Exception ex)
{
onDiagnostic?.Invoke($"recording: writer-thread error: {ex.GetType().Name}: {ex.Message}");
}
// Final drain on shutdown: anything still queued in the rings goes to disk before
// we close the file.
try
{
while (HasEnoughData(minFrames: 1)) Process();
}
catch { /* shutdown drain is best-effort */ }
}
private bool HasEnoughData(int minFrames = DrainChunkFrames)
{
var sentAvail = (Volatile.Read(ref sentWriteHead) - sentReadHead) / MixChannels;
var recvAvail = (Volatile.Read(ref receivedWriteHead) - receivedReadHead) / MixChannels;
return settings.Source switch
{
RecordingSource.SentOnly => sentAvail >= minFrames,
RecordingSource.ReceivedOnly => recvAvail >= minFrames,
RecordingSource.Both => sentAvail >= minFrames || recvAvail >= minFrames,
_ => false,
};
}
private void Process()
{
var sentAvailFrames = (int)((Volatile.Read(ref sentWriteHead) - sentReadHead) / MixChannels);
var recvAvailFrames = (int)((Volatile.Read(ref receivedWriteHead) - receivedReadHead) / MixChannels);
int framesThisCall;
switch (settings.Source)
{
case RecordingSource.SentOnly:
framesThisCall = Math.Min(sentAvailFrames, DrainChunkMaxFrames);
if (framesThisCall <= 0) return;
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
EmitMixBuffer(framesThisCall);
break;
case RecordingSource.ReceivedOnly:
framesThisCall = Math.Min(recvAvailFrames, DrainChunkMaxFrames);
if (framesThisCall <= 0) return;
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(receivedRing, ref receivedReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
EmitMixBuffer(framesThisCall);
break;
case RecordingSource.Both:
// Mix the two sides. Drain min(sent, received) frames so both sides
// advance together. If one side has zero (e.g. peer disconnected, or
// local capture is off), drain the other side alone — treat the silent
// side as zero for those frames. This prevents permanent stalls in
// "Both" mode when one direction has no traffic.
if (sentAvailFrames > 0 && recvAvailFrames > 0)
{
framesThisCall = Math.Min(Math.Min(sentAvailFrames, recvAvailFrames), DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
EnsureSecondaryScratchSize(framesThisCall * MixChannels);
var dst = mixScratch.AsSpan(0, framesThisCall * MixChannels);
var aux = mixScratchAux.AsSpan(0, framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, dst);
CopyFromRing(receivedRing, ref receivedReadHead, aux);
// Sum-mix. Soft-tanh limiter on the sum keeps two simultaneously
// hot inputs from clipping.
for (var i = 0; i < dst.Length; i++)
{
var s = dst[i] + aux[i];
if (s > 1f) s = 1f - MathF.Tanh(s - 1f);
else if (s < -1f) s = -1f + MathF.Tanh(-1f - s);
dst[i] = s;
}
}
else if (sentAvailFrames > 0)
{
framesThisCall = Math.Min(sentAvailFrames, DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
}
else if (recvAvailFrames > 0)
{
framesThisCall = Math.Min(recvAvailFrames, DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(receivedRing, ref receivedReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
}
else
{
return;
}
EmitMixBuffer(framesThisCall);
break;
default:
return;
}
}
/// <summary>Copy <paramref name="dst"/>.Length floats from <paramref name="ring"/>
/// starting at <paramref name="readHeadRef"/>, advancing the head atomically.</summary>
private static void CopyFromRing(float[] ring, ref long readHeadRef, Span<float> dst)
{
var len = dst.Length;
var cap = ring.Length;
var read = readHeadRef;
var pos = (int)(read % cap);
var part1 = Math.Min(len, cap - pos);
ring.AsSpan(pos, part1).CopyTo(dst);
if (part1 < len)
{
ring.AsSpan(0, len - part1).CopyTo(dst.Slice(part1));
}
// Publish the consumed bytes — Volatile.Write so the producer (audio thread)
// sees the freed slots before its next free-space calculation.
Volatile.Write(ref readHeadRef, read + len);
}
private void EmitMixBuffer(int frames)
{
var src = mixScratch.AsSpan(0, frames * MixChannels);
if (settings.ChannelMode == RecordingChannelMode.Mono)
{
EnsureMonoScratchSize(frames);
for (var i = 0; i < frames; i++)
{
monoScratch[i] = (src[i * 2] + src[i * 2 + 1]) * 0.5f;
}
formatWriter?.Write(monoScratch.AsSpan(0, frames));
Interlocked.Add(ref writtenSampleFrames, frames);
}
else
{
formatWriter?.Write(src);
Interlocked.Add(ref writtenSampleFrames, frames);
}
}
private void EnsureScratchSize(int floats)
{
if (mixScratch.Length < floats) mixScratch = new float[floats];
}
private void EnsureSecondaryScratchSize(int floats)
{
if (mixScratchAux.Length < floats) mixScratchAux = new float[floats];
}
private void EnsureMonoScratchSize(int frames)
{
if (monoScratch.Length < frames) monoScratch = new float[frames];
}
/// <summary>Stops the recorder. Drains any audio still in the rings, closes the file,
/// and signals the finish callback with the path + byte count. Safe to call multiple
/// times.</summary>
public void Stop()
{
if (stopped) return;
stopped = true;
cts.Cancel();
wakeup.Set();
try
{
writerThread?.Join(TimeSpan.FromSeconds(3));
}
catch { /* don't propagate join failures */ }
try
{
formatWriter?.Dispose();
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"recording: format-writer close failed: {ex.GetType().Name}: {ex.Message}");
}
formatWriter = null;
try
{
var fi = new FileInfo(resolvedPath);
if (fi.Exists)
{
writtenBytes = fi.Length;
}
}
catch { /* file-size lookup failure is benign */ }
if (DroppedSampleFrames > 0)
{
onDiagnostic?.Invoke($"recording: dropped {DroppedSampleFrames} stereo frames due to writer back-pressure");
}
onFinished?.Invoke(resolvedPath, writtenBytes);
}
public void Dispose()
{
try { Stop(); } catch { /* shutdown is best-effort */ }
cts.Dispose();
wakeup.Dispose();
}
// === format-writer plumbing ===
private IFormatWriter? formatWriter;
private float[] mixScratch = new float[DrainChunkFrames * MixChannels];
private float[] mixScratchAux = new float[DrainChunkFrames * MixChannels];
private float[] monoScratch = new float[DrainChunkFrames];
private static string ExtensionFor(RecordingFileFormat format) => format switch
{
RecordingFileFormat.Wav => "wav",
RecordingFileFormat.Mp3 => "mp3",
RecordingFileFormat.Ogg => "opus", // OGG container, Opus codec — ".opus" is the conventional ext
RecordingFileFormat.Flac => "flac",
_ => "wav",
};
private static IFormatWriter CreateWriter(RecordingFileFormat format, string path, RecordingSettings settings)
{
var channels = settings.ChannelMode == RecordingChannelMode.Mono ? 1 : MixChannels;
return format switch
{
RecordingFileFormat.Wav => new WavFormatWriter(path, MixSampleRate, channels, settings.WavBitsPerSample),
RecordingFileFormat.Mp3 => new Mp3FormatWriter(path, MixSampleRate, channels, settings.Mp3BitrateKbps),
RecordingFileFormat.Ogg => new OggOpusFormatWriter(path, MixSampleRate, channels, settings.OggOpusBitrateKbps),
RecordingFileFormat.Flac => new FlacFormatWriter(path, MixSampleRate, channels, settings.FlacBitsPerSample, settings.FlacCompressionLevel),
// Defensive: unknown format → WAV (shouldn't happen since all enum members are
// handled above, but keeps the switch exhaustive).
_ => new WavFormatWriter(path, MixSampleRate, channels, settings.WavBitsPerSample),
};
}
private interface IFormatWriter : IDisposable
{
void Write(ReadOnlySpan<float> samples);
}
/// <summary>WAV writer with crash-resilient periodic header updates.
///
/// NAudio's stock WaveFileWriter writes the RIFF / data-chunk size fields ONCE at file
/// close (in Dispose), with placeholder zeros up until then. A process crash before
/// Dispose runs leaves the file with header-says-zero-samples, which most players
/// either refuse or stop after the first audio frame — meaning an hour-long crashed
/// session is unrecoverable. This implementation owns the FileStream directly and
/// re-patches the two size fields every <see cref="HeaderRefreshSeconds"/> seconds
/// PLUS on Dispose. A crash any time after the first refresh leaves a playable WAV
/// containing all the audio captured up to the last refresh.
///
/// Header layout (PCM 16/24-bit):
/// offset 0 "RIFF"
/// offset 4 uint32 (file size - 8) ← patched periodically
/// offset 8 "WAVE"
/// offset 12 "fmt "
/// offset 16 uint32 16 (PCM fmt chunk size)
/// offset 20 uint16 1 (PCM format code)
/// offset 22 uint16 channels
/// offset 24 uint32 sample rate
/// offset 28 uint32 byte rate
/// offset 32 uint16 block align
/// offset 34 uint16 bits per sample
/// offset 36 "data"
/// offset 40 uint32 data chunk size ← patched periodically
/// offset 44 audio samples...
///
/// For 32-bit IEEE float we use the slightly-longer 18-byte fmt chunk variant with
/// format code 3 and a trailing cbSize=0 field, so the data chunk starts at offset 46.
/// </summary>
private sealed class WavFormatWriter : IFormatWriter
{
private const int HeaderRefreshSeconds = 5;
private readonly FileStream stream;
private readonly int bitsPerSample;
private readonly bool isFloat;
private readonly long dataChunkSizeFieldPos;
private readonly long dataStartPos;
private long dataBytesWritten;
private DateTime lastHeaderRefreshUtc;
private byte[] scratchBytes = new byte[4096];
public WavFormatWriter(string path, int sampleRate, int channels, int bitsPerSample)
{
this.bitsPerSample = bitsPerSample is 16 or 24 or 32 ? bitsPerSample : 24;
isFloat = this.bitsPerSample == 32;
// FileShare.Read lets the user open the WAV in a player mid-recording to check
// progress. ReadWrite access is required because we seek back to patch the
// header. 8 KB stream buffer balances responsiveness (small enough that a
// crash loses at most ~50 ms at 48 kHz / 16-bit stereo) with throughput.
stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 8192, useAsync: false);
WriteInitialHeader(sampleRate, channels);
dataStartPos = stream.Position;
dataChunkSizeFieldPos = dataStartPos - 4;
lastHeaderRefreshUtc = DateTime.UtcNow;
}
private void WriteInitialHeader(int sampleRate, int channels)
{
var formatCode = (ushort)(isFloat ? 3 : 1);
var byteRate = (uint)(sampleRate * channels * bitsPerSample / 8);
var blockAlign = (ushort)(channels * bitsPerSample / 8);
// PCM fmt chunk is 16 bytes; IEEE-float adds a 2-byte cbSize trailer (zero,
// meaning no extension data) for a total of 18 bytes.
var fmtChunkSize = (uint)(isFloat ? 18 : 16);
using var bw = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true);
bw.Write(System.Text.Encoding.ASCII.GetBytes("RIFF"));
bw.Write((uint)36); // placeholder RIFF size — patched in FlushHeader
bw.Write(System.Text.Encoding.ASCII.GetBytes("WAVE"));
bw.Write(System.Text.Encoding.ASCII.GetBytes("fmt "));
bw.Write(fmtChunkSize);
bw.Write(formatCode);
bw.Write((ushort)channels);
bw.Write((uint)sampleRate);
bw.Write(byteRate);
bw.Write(blockAlign);
bw.Write((ushort)bitsPerSample);
if (isFloat) bw.Write((ushort)0); // cbSize: no extra extension fields
bw.Write(System.Text.Encoding.ASCII.GetBytes("data"));
bw.Write((uint)0); // placeholder data chunk size — patched in FlushHeader
}
public void Write(ReadOnlySpan<float> samples)
{
if (samples.IsEmpty) return;
int bytesAppended;
switch (bitsPerSample)
{
case 32:
bytesAppended = samples.Length * sizeof(float);
if (scratchBytes.Length < bytesAppended) scratchBytes = new byte[bytesAppended];
System.Runtime.InteropServices.MemoryMarshal.AsBytes(samples).CopyTo(scratchBytes);
stream.Write(scratchBytes, 0, bytesAppended);
break;
case 24:
bytesAppended = samples.Length * 3;
if (scratchBytes.Length < bytesAppended) scratchBytes = new byte[bytesAppended];
PcmPack.FloatToInt24LE(samples, scratchBytes.AsSpan(0, bytesAppended));
stream.Write(scratchBytes, 0, bytesAppended);
break;
default: // 16
bytesAppended = samples.Length * 2;
if (scratchBytes.Length < bytesAppended) scratchBytes = new byte[bytesAppended];
var dst = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, short>(scratchBytes.AsSpan(0, bytesAppended));
for (var i = 0; i < samples.Length; i++)
{
var v = Math.Clamp(samples[i], -1f, 1f);
dst[i] = (short)(v * 32767f);
}
stream.Write(scratchBytes, 0, bytesAppended);
break;
}
dataBytesWritten += bytesAppended;
// Periodic header refresh — every HeaderRefreshSeconds. We seek back, patch the
// two size fields, seek forward to the data tail, and flush all the way to disk.
// The seek + write is cheap (a few bytes); the flush is the expensive part but
// it's only every ~5 s. A crash any time after the first refresh leaves a
// playable WAV containing all audio captured up to that refresh.
if ((DateTime.UtcNow - lastHeaderRefreshUtc).TotalSeconds >= HeaderRefreshSeconds)
{
FlushHeader();
lastHeaderRefreshUtc = DateTime.UtcNow;
}
}
private void FlushHeader()
{
var tailPos = stream.Position;
stream.Position = 4;
using (var bw = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true))
{
bw.Write((uint)(tailPos - 8)); // RIFF chunk size = total file size - 8
}
stream.Position = dataChunkSizeFieldPos;
using (var bw = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true))
{
bw.Write((uint)dataBytesWritten); // data chunk size
}
stream.Position = tailPos;
// Flush forces the OS to push our user-space buffer to the disk cache; FlushFileBuffers
// (via Flush(true)) would force the disk cache to platter, but that's expensive enough
// to skip — a kernel crash that loses the disk cache is rare enough not to plan for.
stream.Flush();
}
public void Dispose()
{
try { FlushHeader(); } catch { /* best-effort final header patch */ }
try { stream.Dispose(); } catch { /* best-effort stream close */ }
}
}
/// <summary>MP3 writer. NAudio.Lame's LameMP3FileWriter takes a 16-bit PCM WaveFormat
/// input and an int kbps for CBR. MP3 is naturally crash-resilient — every encoded
/// frame is self-contained and the file-on-disk is always a valid (truncated) MP3
/// representing everything LAME has emitted so far — but LAME and the OS both buffer
/// internally, so we Flush every <see cref="FlushIntervalSeconds"/> seconds to bound
/// the loss-on-crash to a couple of seconds rather than however-much fit in the
/// kernel file cache.</summary>
private sealed class Mp3FormatWriter : IFormatWriter
{
private const int FlushIntervalSeconds = 5;
private readonly LameMP3FileWriter writer;
private byte[] scratchBytes = new byte[4096];
private readonly int channels;
private DateTime lastFlushUtc;
public Mp3FormatWriter(string path, int sampleRate, int channels, int bitrateKbps)
{
this.channels = channels;
var pcmFormat = new WaveFormat(sampleRate, 16, channels);
// Direct kbps constructor — NAudio.Lame accepts a plain int and configures LAME
// for CBR at that rate. Clamp to the LAME range (8..320 for MPEG-1 layer 3 at
// 48 kHz). Values from our dialog are 128/192/256/320 so no clamping fires in
// practice; the guard is for future-proofing if the UI gains finer steps.
var clamped = Math.Clamp(bitrateKbps, 8, 320);
writer = new LameMP3FileWriter(path, pcmFormat, clamped);
lastFlushUtc = DateTime.UtcNow;
}
public void Write(ReadOnlySpan<float> samples)
{
if (samples.IsEmpty) return;
var byteLength = samples.Length * 2;
if (scratchBytes.Length < byteLength) scratchBytes = new byte[byteLength];
var dst = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, short>(scratchBytes.AsSpan(0, byteLength));
for (var i = 0; i < samples.Length; i++)
{
var v = Math.Clamp(samples[i], -1f, 1f);
dst[i] = (short)(v * 32767f);
}
writer.Write(scratchBytes, 0, byteLength);
if ((DateTime.UtcNow - lastFlushUtc).TotalSeconds >= FlushIntervalSeconds)
{
try { writer.Flush(); } catch { /* flush is best-effort */ }
lastFlushUtc = DateTime.UtcNow;
}
}
public void Dispose() => writer.Dispose();
}
/// <summary>OGG-Opus writer. Reuses the Concentus encoder that the wire path uses, wrapped
/// in the Concentus.Oggfile OGG container writer so the result is a standard .opus file
/// playable in VLC / mpv / browsers.
///
/// Opus operates on fixed-size frames (we use 20 ms = 960 samples per channel at 48 kHz).
/// The writer buffers incoming float samples, converts to int16, and emits one frame to
/// the Ogg writer per accumulated chunk. Any partial frame at Dispose is zero-padded and
/// flushed so no audio is lost.
///
/// Crash resilience: the OGG container is a stream of self-contained packets, so the file
/// on disk is always a valid (truncated) Opus file representing everything written so far.
/// We Flush the underlying FileStream every <see cref="FlushIntervalSeconds"/> seconds to
/// bound loss-on-crash to that window.</summary>
private sealed class OggOpusFormatWriter : IFormatWriter
{
private const int FlushIntervalSeconds = 5;
private const int OpusFrameSamplesPerChannel = 960; // 20 ms at 48 kHz
private readonly FileStream fileStream;
private readonly IOpusEncoder encoder;
private readonly OpusOggWriteStream writer;
private readonly int channels;
private readonly short[] frameScratch;
private int frameScratchWritten; // interleaved shorts buffered toward the next frame
private DateTime lastFlushUtc;
public OggOpusFormatWriter(string path, int sampleRate, int channels, int bitrateKbps)
{
this.channels = channels;
// Frame scratch holds one full Opus frame of interleaved shorts.
frameScratch = new short[OpusFrameSamplesPerChannel * channels];
encoder = OpusCodecFactory.CreateEncoder(sampleRate, channels, OpusApplication.OPUS_APPLICATION_AUDIO);
encoder.Bitrate = Math.Clamp(bitrateKbps, 6, 510) * 1000;
// VBR mode unconstrained — Opus's default for music. Good music quality at the
// bitrates we expose (96..256 kbps).
encoder.UseVBR = true;
encoder.UseConstrainedVBR = false;
fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, 8192, useAsync: false);
writer = new OpusOggWriteStream(encoder, fileStream, null, sampleRate);
lastFlushUtc = DateTime.UtcNow;
}
public void Write(ReadOnlySpan<float> samples)
{
if (samples.IsEmpty) return;
// Convert float → int16 inline as we copy into the per-frame scratch. Flush a
// complete Opus frame to the OGG writer each time the scratch is full.
for (var i = 0; i < samples.Length; i++)
{
var v = samples[i];
if (v > 1f) v = 1f; else if (v < -1f) v = -1f;
frameScratch[frameScratchWritten++] = (short)(v * 32767f);
if (frameScratchWritten >= frameScratch.Length)
{
writer.WriteSamples(frameScratch, 0, frameScratch.Length);
frameScratchWritten = 0;
}
}
if ((DateTime.UtcNow - lastFlushUtc).TotalSeconds >= FlushIntervalSeconds)
{
try { fileStream.Flush(); } catch { /* flush is best-effort */ }
lastFlushUtc = DateTime.UtcNow;
}
}
public void Dispose()
{
// Final partial frame: pad with zeros so the encoder has a full frame to encode,
// then call Finish() to write the OGG end-of-stream packet so the file is well-formed.
try
{
if (frameScratchWritten > 0)
{
Array.Clear(frameScratch, frameScratchWritten, frameScratch.Length - frameScratchWritten);
writer.WriteSamples(frameScratch, 0, frameScratch.Length);
frameScratchWritten = 0;
}
writer.Finish();
}
catch { /* best-effort final flush */ }
try { fileStream.Dispose(); } catch { /* best-effort close */ }
}
}
/// <summary>FLAC writer using CUETools.Codecs.FLAKE — pure-managed FLAC encoder, no
/// native DLL. Lossless at every compression level; level 5 (default) matches the
/// libFLAC reference encoder's default speed/size compromise.
///
/// FLAC is integer-PCM only — 16 or 24 bit. Float input is scaled to the configured bit
/// depth with hard clamping at the rails.
///
/// Crash resilience: FLAC's stream format is self-framing — every frame is independently
/// decodable. A truncated file remains a valid (shorter) FLAC representing everything
/// Flake emitted so far. We Flush the underlying stream every <see cref="FlushIntervalSeconds"/>
/// seconds to bound the OS-cache-loss window.</summary>
private sealed class FlacFormatWriter : IFormatWriter
{
private const int FlushIntervalSeconds = 5;
private readonly FileStream fileStream;
private readonly FlakeWriter writer;
private readonly AudioPCMConfig config;
private readonly int channels;
private readonly int bitsPerSample;
private readonly int bytesPerSample;
private readonly int scaleFactor;
// Reused per-Write byte buffer in the packed PCM layout the AudioBuffer constructor
// accepts. Interleaved [L0 R0 L1 R1 ...], with each sample serialised as
// signed little-endian using <see cref="bytesPerSample"/> bytes.
private byte[] packedBytes = new byte[4096];
private DateTime lastFlushUtc;
public FlacFormatWriter(string path, int sampleRate, int channels, int bitsPerSample, int compressionLevel)
{
this.channels = channels;
// FLAC accepts 16 or 24 here. Anything else (e.g. WAV's 32-bit-float leaking
// through) coerces to 24, which matches the wire bit depth.
this.bitsPerSample = bitsPerSample is 16 or 24 ? bitsPerSample : 24;
bytesPerSample = this.bitsPerSample / 8;
scaleFactor = (1 << (this.bitsPerSample - 1)) - 1;
config = new AudioPCMConfig(this.bitsPerSample, channels, sampleRate);
fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, 8192, useAsync: false);
writer = new FlakeWriter(path, fileStream, config)
{
CompressionLevel = Math.Clamp(compressionLevel, 0, 8),
};
lastFlushUtc = DateTime.UtcNow;
}
public void Write(ReadOnlySpan<float> samples)
{
if (samples.IsEmpty) return;
var frames = samples.Length / channels;
if (frames <= 0) return;
// Pack interleaved float → signed little-endian PCM (2 or 3 bytes per sample).
var byteLen = samples.Length * bytesPerSample;
if (packedBytes.Length < byteLen) packedBytes = new byte[byteLen];
if (bitsPerSample == 16)
{
for (var i = 0; i < samples.Length; i++)
{
var v = samples[i];
if (v > 1f) v = 1f; else if (v < -1f) v = -1f;
var s = (short)(v * 32767f);
var off = i * 2;
packedBytes[off] = (byte)(s & 0xFF);
packedBytes[off + 1] = (byte)((s >> 8) & 0xFF);
}
}
else // 24
{
for (var i = 0; i < samples.Length; i++)
{
var v = samples[i];
if (v > 1f) v = 1f; else if (v < -1f) v = -1f;
var s = (int)(v * 8388607f); // 2^23 - 1
var off = i * 3;
packedBytes[off] = (byte)(s & 0xFF);
packedBytes[off + 1] = (byte)((s >> 8) & 0xFF);
packedBytes[off + 2] = (byte)((s >> 16) & 0xFF);
}
}
// AudioBuffer(config, byte[], frameCount) wraps the packed bytes without copying.
// FlakeWriter encodes one block per Write call; block size adapts to the supplied
// frame count.
var buf = new AudioBuffer(config, packedBytes, frames);
writer.Write(buf);
if ((DateTime.UtcNow - lastFlushUtc).TotalSeconds >= FlushIntervalSeconds)
{
try { fileStream.Flush(); } catch { /* flush is best-effort */ }
lastFlushUtc = DateTime.UtcNow;
}
}
public void Dispose()
{
try { writer.Close(); } catch { /* best-effort final flush */ }
try { fileStream.Dispose(); } catch { /* best-effort close */ }
}
}
}
+359 -14
View File
@@ -37,6 +37,11 @@ public sealed class MainForm : Form
private readonly System.Windows.Forms.Timer updateCheckTimer = new(); private readonly System.Windows.Forms.Timer updateCheckTimer = new();
private readonly MainFormHotkeyController hotkeyController; private readonly MainFormHotkeyController hotkeyController;
private readonly MainFormTrayController trayController; private readonly MainFormTrayController trayController;
private readonly RecordingController recordingController;
// Menu items for the Record menu kept as fields so RecordingStateChanged can flip
// the visible text + accessibility name between "Start recording" and "Stop recording"
// without rebuilding the menu.
private ToolStripMenuItem? startStopRecordingMenuItem;
// --- Main form controls --- // --- Main form controls ---
// Two standalone CheckBoxes for the Send / Receive toggles. Modern .NET (.NET 10) raises // Two standalone CheckBoxes for the Send / Receive toggles. Modern .NET (.NET 10) raises
@@ -175,6 +180,13 @@ public sealed class MainForm : Form
// checkbox doing almost the same thing in a less convenient one-shot shape. // checkbox doing almost the same thing in a less convenient one-shot shape.
private readonly AccessibleCheckBox continuousTuneBox = new() { Text = "Continuous auto-tune latency", AutoSize = true }; private readonly AccessibleCheckBox continuousTuneBox = new() { Text = "Continuous auto-tune latency", AutoSize = true };
private readonly ComboBox continuousIntervalBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 90, AccessibleName = "Auto-tune latency interval (Alt+I)" }; private readonly ComboBox continuousIntervalBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 90, AccessibleName = "Auto-tune latency interval (Alt+I)" };
// Label for continuousIntervalBox. Held as a field (rather than a local in
// BuildAudioReceiveGroupContents) so UpdateBothIndependentVisibility can rewrite the
// text and mnemonic when the user flips audio mode — the interval governs both lanes'
// auto-tune ticks in BothIndependent, and the label needs to say so. Initialised in
// BuildAudioReceiveGroupContents alongside the other receive-side controls; visibility
// is shared with the WASAPI row (always shown when the row is shown).
private Label? continuousIntervalLabel;
// BothIndependent-mode companion controls. Created up front so SelectedIndexChanged // BothIndependent-mode companion controls. Created up front so SelectedIndexChanged
// handlers can be wired alongside the originals; they live in their own TableLayoutPanel // handlers can be wired alongside the originals; they live in their own TableLayoutPanel
// row that toggles Visible=true only when the audio mode is BothIndependent. The labels // row that toggles Visible=true only when the audio mode is BothIndependent. The labels
@@ -284,6 +296,14 @@ public sealed class MainForm : Form
private readonly Dictionary<string, PeerHealthState> previousPeerHealthStates = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, PeerHealthState> previousPeerHealthStates = new(StringComparer.OrdinalIgnoreCase);
private System.Media.SoundPlayer? connectSound; private System.Media.SoundPlayer? connectSound;
private System.Media.SoundPlayer? disconnectSound; private System.Media.SoundPlayer? disconnectSound;
// Recording start/stop cues. Played via SoundPlayer to the default Windows output —
// same path as connect/disconnect. They don't pass through our recording taps (those
// sit on the internal sender mix bus and receiver render path), so they don't appear
// in normal recordings. A user who has a WASAPI loopback of the same output device as
// a capture source would still get them, but that's their loopback configuration, not
// anything the recorder is doing.
private System.Media.SoundPlayer? recordStartSound;
private System.Media.SoundPlayer? recordStopSound;
// Labels for the three send/receive device lists, captured at layout time so they can be // Labels for the three send/receive device lists, captured at layout time so they can be
// re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode // re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode
// (driver channel pairs). null until BuildLayout has run. // (driver channel pairs). null until BuildLayout has run.
@@ -344,6 +364,30 @@ public sealed class MainForm : Form
private bool firstSenderPacketLogged; private bool firstSenderPacketLogged;
private bool firstReceiverPacketLogged; private bool firstReceiverPacketLogged;
// Previous-tick values for the per-second deltas surfaced in the diag log line. Each is
// the receiver-side cumulative counter snapshot at the previous SnapshotLogIfDue tick;
// subtracting from the current value gives "how many fired this second". Only read when
// DiagnosticsGate.Enabled (i.e. logs on); otherwise SnapshotLogIfDue early-outs before
// touching these.
private long prevDiagDriftDrops;
private long prevDiagDriftReps;
private long prevDiagConceal;
private long prevDiagShortRead;
private long prevDiagTrimFires;
// Wire-level packet-sequence tracking deltas. Detects packet reordering, loss, or
// duplication on the UDP path between sender and receiver. On a healthy LAN all three
// failure counters should stay at zero; any non-zero delta in the diag log is a smoking
// gun for transport-layer-induced pops.
private long prevDiagWireInOrder;
private long prevDiagWireMissed;
private long prevDiagWireReordered;
private long prevDiagWireDuplicated;
// Per-second delta for the sender's hard-clamp clipping counter. A non-zero clipΔ means
// the mix bus was producing samples whose magnitude exceeded 1.0 and got clamped. Clipping
// itself doesn't create steps but is a signal that the input is hot enough that something
// could be saturating.
private long prevDiagClippedSamples;
// Profile system (2026-05-02). The active profile (if any) was selected at app start and // Profile system (2026-05-02). The active profile (if any) was selected at app start and
// populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile // populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile
// below). Control-level state (device ticks, send/receive checkboxes, audio port, volume // below). Control-level state (device ticks, send/receive checkboxes, audio port, volume
@@ -481,6 +525,13 @@ public sealed class MainForm : Form
() => receiveAudioCheckbox.Checked = true, () => receiveAudioCheckbox.Checked = true,
Close); Close);
recordingController = new RecordingController(
sender,
receiver,
settings,
msg => logFile.Event($"recorder: {msg}"));
recordingController.RecordingStateChanged += UpdateStartStopRecordingMenuLabel;
// --- Set accessibility names --- // --- Set accessibility names ---
// For these four controls the keyboard shortcut is included explicitly in both the // For these four controls the keyboard shortcut is included explicitly in both the
// visible label (set in BuildLayout) and the AccessibleName, instead of relying on the // visible label (set in BuildLayout) and the AccessibleName, instead of relying on the
@@ -694,6 +745,8 @@ public sealed class MainForm : Form
// Files are deployed alongside the .exe (see RemSound.App.csproj Content rules). // Files are deployed alongside the .exe (see RemSound.App.csproj Content rules).
TryLoadCueSound("connect.wav", out connectSound); TryLoadCueSound("connect.wav", out connectSound);
TryLoadCueSound("disconnect.wav", out disconnectSound); TryLoadCueSound("disconnect.wav", out disconnectSound);
TryLoadCueSound("record start.wav", out recordStartSound);
TryLoadCueSound("record stop.wav", out recordStopSound);
LoadAudioDevices(); LoadAudioDevices();
// Apply persisted ASIO mode from settings — switches sender/receiver backends so the // Apply persisted ASIO mode from settings — switches sender/receiver backends so the
@@ -1065,11 +1118,123 @@ public sealed class MainForm : Form
aboutItem, aboutItem,
}); });
var recordMenu = BuildRecordMenu();
menu.Items.Add(fileMenu); menu.Items.Add(fileMenu);
menu.Items.Add(recordMenu);
menu.Items.Add(helpMenu); menu.Items.Add(helpMenu);
return menu; return menu;
} }
/// <summary>Build the Record menu — Start/stop recording (toggling label), recording
/// settings dialog, open the configured folder, and change the configured folder.
/// Ctrl+R is the global toggle so the user can start/stop without going through the
/// menu. Profile-dirty flag is set when the user changes the folder or the settings
/// inside the sub-dialog because both live on the profile.</summary>
private ToolStripMenuItem BuildRecordMenu()
{
// Record menu uses Alt+O (Rec&ord) rather than Alt+R. The form's "Receive audio
// (Alt+R)" checkbox lives on the main canvas alongside the menu bar and Alt+R was
// ambiguous between the two. Alt+O is unused elsewhere on the menu bar (File / Help
// / Record) and reads as "Recording" naturally enough for the mnemonic to stick.
var recordMenu = new ToolStripMenuItem("Rec&ord") { AccessibleName = "Record menu" };
startStopRecordingMenuItem = new ToolStripMenuItem("&Start recording")
{
ShortcutKeys = Keys.Control | Keys.R,
AccessibleName = "Start recording",
};
startStopRecordingMenuItem.Click += (_, _) => ToggleRecording();
var settingsItem = new ToolStripMenuItem("Recording se&ttings...")
{
AccessibleName = "Recording settings",
};
settingsItem.Click += (_, _) => OpenRecordingSettingsDialog();
var openFolderItem = new ToolStripMenuItem("&Open current recordings folder")
{
AccessibleName = "Open current recordings folder",
};
openFolderItem.Click += (_, _) => recordingController.OpenCurrentFolder(this);
var changeFolderItem = new ToolStripMenuItem("&Change recordings folder...")
{
AccessibleName = "Change recordings folder",
};
changeFolderItem.Click += (_, _) =>
{
if (recordingController.ChangeFolder(this)) MarkProfileDirty();
};
recordMenu.DropDownItems.AddRange(new ToolStripItem[]
{
startStopRecordingMenuItem,
new ToolStripSeparator(),
settingsItem,
new ToolStripSeparator(),
openFolderItem,
changeFolderItem,
});
return recordMenu;
}
/// <summary>Toggle the recording state. Single source of truth for both Ctrl+R and the
/// menu-item click — both paths route through here so the start/stop transition is
/// handled consistently. The state-change event fires UpdateStartStopRecordingMenuLabel
/// which rewrites the menu item text.</summary>
private void ToggleRecording()
{
if (recordingController.IsRecording)
{
// Stop the recorder FIRST, then play the cue. SoundPlayer goes through the
// default Windows output device — separate from the internal taps the recorder
// listens on — so the cue isn't in the file regardless of ordering, but
// stopping first means a user with a WASAPI-loopback-of-default-output capture
// source won't catch the tail of the cue either.
recordingController.Stop();
if (settings.LoadEnableRecordStopCue()) recordStopSound?.Play();
}
else
{
// Symmetric: play the start cue BEFORE the recorder turns on, for the same
// loopback-courtesy reason. The cue is short (~0.4 s), so any subjective lag
// between "I pressed Ctrl+R" and "audio starts being captured" is well under
// the cue itself.
if (settings.LoadEnableRecordStartCue()) recordStartSound?.Play();
recordingController.Start();
}
}
/// <summary>Reflect the recording state in the menu item label. NVDA reads the text +
/// AccessibleName, both flipped here so users on screen readers hear the new state
/// straight away. Marshalled to the UI thread because the recorder's finish callback
/// can fire from its writer thread when Stop() is called from there.</summary>
private void UpdateStartStopRecordingMenuLabel(bool nowRecording)
{
void Apply()
{
if (startStopRecordingMenuItem is null) return;
startStopRecordingMenuItem.Text = nowRecording ? "&Stop recording" : "&Start recording";
startStopRecordingMenuItem.AccessibleName = nowRecording ? "Stop recording" : "Start recording";
}
if (InvokeRequired) BeginInvoke(Apply);
else Apply();
}
/// <summary>Open the recording settings dialog. On OK, write the settings back through
/// <see cref="RemSoundSettingsStore"/> and flag the profile dirty if anything changed.
/// The dialog reads its initial state from the same store, so settings persist across
/// re-opens until the user explicitly saves the profile.</summary>
private void OpenRecordingSettingsDialog()
{
using var dialog = new RecordingSettingsDialog(settings.LoadRecordingSettings());
if (dialog.ShowDialog(this) != DialogResult.OK) return;
settings.SaveRecordingSettings(dialog.Result);
if (dialog.ChangedAnything) MarkProfileDirty();
}
/// <summary>Show a file-picker rooted at the profiles folder; on selection, schedule a /// <summary>Show a file-picker rooted at the profiles folder; on selection, schedule a
/// switch to that profile (same close-and-relaunch flow as the old Switch button).</summary> /// switch to that profile (same close-and-relaunch flow as the old Switch button).</summary>
private void OpenProfileFromPicker() private void OpenProfileFromPicker()
@@ -1749,8 +1914,17 @@ public sealed class MainForm : Form
continuousIntervalBox.Items.Clear(); continuousIntervalBox.Items.Clear();
continuousIntervalBox.Items.AddRange(new object[] { "3 seconds", "5 seconds", "10 seconds", "15 seconds", "30 seconds" }); continuousIntervalBox.Items.AddRange(new object[] { "3 seconds", "5 seconds", "10 seconds", "15 seconds", "30 seconds" });
continuousIntervalBox.SelectedIndex = continuousTuneIntervalSec switch { 3 => 0, 5 => 1, 15 => 3, 30 => 4, _ => 2 }; continuousIntervalBox.SelectedIndex = continuousTuneIntervalSec switch { 3 => 0, 5 => 1, 15 => 3, 30 => 4, _ => 2 };
continuousIntervalBox.Enabled = continuousTuneEnabled; // Enable the interval combo whenever EITHER lane's auto-tune is on — the single
var continuousIntervalLabel = new Label { Text = "Auto-tune latency interval (Alt+&I)", AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(8, 6, 0, 0) }; // interval value governs both lanes' tick rates (see comment at row-1 docstring).
// Previously this only followed the WASAPI checkbox, which made the combo grey out
// in BothIndependent mode when only ASIO auto-tune was ticked, even though the
// timer was running and the interval was being honoured for the ASIO lane.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
// Label text is set by UpdateBothIndependentVisibility — it differs between classic
// modes (single lane → "Auto-tune latency interval") and BothIndependent
// (two lanes → "Auto-tune interval (WASAPI + ASIO)") to make explicit that the same
// dropdown drives both lanes' tick cadence in the latter case.
continuousIntervalLabel = new Label { AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(8, 6, 0, 0) };
var delayContainer = new FlowLayoutPanel var delayContainer = new FlowLayoutPanel
{ {
AutoSize = true, AutoSize = true,
@@ -1769,7 +1943,7 @@ public sealed class MainForm : Form
{ {
continuousTuneEnabled = continuousTuneBox.Checked; continuousTuneEnabled = continuousTuneBox.Checked;
settings.SaveContinuousAutoTuneEnabled(continuousTuneEnabled); settings.SaveContinuousAutoTuneEnabled(continuousTuneEnabled);
continuousIntervalBox.Enabled = continuousTuneEnabled; continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
ApplyContinuousTuneTimer(); ApplyContinuousTuneTimer();
MarkProfileDirty(); MarkProfileDirty();
}; };
@@ -2694,6 +2868,11 @@ public sealed class MainForm : Form
continuousTuneAsioBox.CheckedChanged += (_, _) => continuousTuneAsioBox.CheckedChanged += (_, _) =>
{ {
settings.SaveContinuousAutoTuneAsioEnabled(continuousTuneAsioBox.Checked); settings.SaveContinuousAutoTuneAsioEnabled(continuousTuneAsioBox.Checked);
// The interval combo is shared between both lanes — keep it enabled whenever
// either lane's auto-tune is on. Without this, ticking ASIO auto-tune (in
// BothIndependent) left the interval combo greyed out and made the recheck
// cadence invisible to the user even though it was actively in effect.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
ApplyContinuousTuneTimer(); ApplyContinuousTuneTimer();
MarkProfileDirty(); MarkProfileDirty();
}; };
@@ -2720,12 +2899,27 @@ public sealed class MainForm : Form
asioDelayContainer.Visible = inBothIndependent; asioDelayContainer.Visible = inBothIndependent;
maxLatencyAsioBox.Visible = inBothIndependent; maxLatencyAsioBox.Visible = inBothIndependent;
continuousTuneAsioBox.Visible = inBothIndependent; continuousTuneAsioBox.Visible = inBothIndependent;
// Mode change may have changed which auto-tune flags count toward "any enabled":
// leaving BothIndependent drops the ASIO lane's checkbox from consideration, and
// entering it brings it back. Re-evaluate so the shared interval combo's Enabled
// state tracks reality after every mode flip.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
if (inBothIndependent) if (inBothIndependent)
{ {
wasapiLatencyLabel.Text = "WASAPI latency in milliseconds (Alt+&W)"; wasapiLatencyLabel.Text = "WASAPI latency in milliseconds (Alt+&W)";
maxLatencyBox.AccessibleName = "WASAPI latency in milliseconds (Alt+W)"; maxLatencyBox.AccessibleName = "WASAPI latency in milliseconds (Alt+W)";
continuousTuneBox.Text = "Continuous auto-tune WASAPI latency (Alt+&Y)"; continuousTuneBox.Text = "Continuous auto-tune WASAPI latency (Alt+&Y)";
continuousTuneBox.AccessibleName = "Continuous auto-tune WASAPI latency"; continuousTuneBox.AccessibleName = "Continuous auto-tune WASAPI latency";
// The interval combo drives ticks for BOTH lanes' auto-tunes — each lane
// independently lands wherever its own algorithm decides (40 ms WASAPI / 20 ms
// ASIO is fine), but the cadence dropdown is shared. Make that explicit in the
// label so a user looking at the WASAPI row doesn't assume the interval only
// applies there.
if (continuousIntervalLabel is not null)
{
continuousIntervalLabel.Text = "Auto-tune interval — WASAPI and ASIO (Alt+&I)";
}
continuousIntervalBox.AccessibleName = "Auto-tune interval for WASAPI and ASIO (Alt+I)";
} }
else else
{ {
@@ -2733,6 +2927,12 @@ public sealed class MainForm : Form
maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)"; maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)";
continuousTuneBox.Text = "Continuous auto-tune latency (Alt+&T)"; continuousTuneBox.Text = "Continuous auto-tune latency (Alt+&T)";
continuousTuneBox.AccessibleName = "Continuous auto-tune latency"; continuousTuneBox.AccessibleName = "Continuous auto-tune latency";
// Classic mode — single lane, original label is unambiguous.
if (continuousIntervalLabel is not null)
{
continuousIntervalLabel.Text = "Auto-tune latency interval (Alt+&I)";
}
continuousIntervalBox.AccessibleName = "Auto-tune latency interval (Alt+I)";
} }
} }
@@ -3410,6 +3610,26 @@ public sealed class MainForm : Form
// music; informational only). // music; informational only).
var driftDrops = receiver.DriftDropFrames; var driftDrops = receiver.DriftDropFrames;
var driftReps = receiver.DriftRepeatFrames; var driftReps = receiver.DriftRepeatFrames;
// Per-second deltas for the same counters — easier to read at a glance than
// ever-growing cumulative numbers. driftDropΔ + driftRepΔ tell us how fast
// the corrector is firing right now. concealΔ tells us how many real underruns
// fired this second (audible). shortReadΔ tracks the now-silent partial-read
// events for clock-phase diagnostics. Trim fires + delta gives us "is the
// click-trim safety net firing".
var concealNow = receiver.ConcealmentFires;
var shortReadNow = receiver.ShortReadFires;
var driftDropDelta = driftDrops - prevDiagDriftDrops; prevDiagDriftDrops = driftDrops;
var driftRepDelta = driftReps - prevDiagDriftReps; prevDiagDriftReps = driftReps;
var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow;
var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow;
var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires;
// Live state (not deltas) — current LP-filtered drift error and accumulator
// value. Both let us see "where the corrector thinks the buffer is" between
// explicit drop/repeat events. filtErr negative = buffer running below target
// on average; positive = above. driftAcc near 0 = corrector idle; near ±1 =
// about to fire.
var filteredErrorFrames = receiver.FilteredDriftErrorFrames;
var driftAccumulator = receiver.DriftAccumulator;
// 2026-05-11 added timing-split metrics: // 2026-05-11 added timing-split metrics:
// emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send) // emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send)
// sndCallMs = sender's worst time-in-udp.Client.SendTo (kernel send only) // sndCallMs = sender's worst time-in-udp.Client.SendTo (kernel send only)
@@ -3426,12 +3646,57 @@ public sealed class MainForm : Form
// samples that aren't reaching the audio output, i.e. extra perceived latency // samples that aren't reaching the audio output, i.e. extra perceived latency
// not visible in bufAvg. Always 0 in WasapiOnly (no FanOut). // not visible in bufAvg. Always 0 in WasapiOnly (no FanOut).
var fanCacheMs = receiver.TakeMaxFanOutCacheMs(); var fanCacheMs = receiver.TakeMaxFanOutCacheMs();
// Per-stage discontinuity probes. Compare these to localise where in the
// pipeline a click is introduced:
// stepPreEnc = sender's float buffer just before encoding. Non-zero =
// the input ALREADY has discontinuities (capture-side issue).
// stepPostDec = receiver's float buffer just after PCM/Opus decode. If this
// is significantly larger than stepPreEnc, the wire codec
// roundtrip introduced steps.
// stepPostRing = receiver's float buffer just out of the ring (before
// resampler). Roughly equal to stepPostDec in steady state;
// bigger here means the ring buffer is fishy.
// stepPostRsm = receiver's float buffer just out of the resampler. Bigger
// here than stepPostRing fingers the resampler integration.
// sampleStepMax= the final output buffer (after volume + limiter), the
// legacy spot the diag already tracked.
// Per-lane pre-encode probes (2026-05-15) — split so BothIndependent mode
// can show which lane is producing the discontinuity, free of the cross-
// stream artefact that the old shared probe registered when both lanes'
// callbacks interleaved into one probe's lastL/R carry.
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
var stepPostDec = receiver.TakeMaxPostDecodeStep();
var stepPostRing = receiver.TakeMaxPostRingReadStep();
var stepPostRsm = receiver.TakeMaxPostResamplerStep();
// Wire-level packet-sequence stats. wireInOrderΔ is the count of packets that
// arrived with the sequence we expected this second. wireMissΔ / wireReordΔ /
// wireDupΔ are the smoking-gun counters — any non-zero value here means the
// UDP path between sender and receiver dropped, reordered, or duplicated
// packets, and that on the PCM path translates directly into audible pops.
var wireInOrderNow = receiver.WireInOrderCount;
var wireMissedNow = receiver.WireMissedCount;
var wireReorderedNow = receiver.WireReorderedCount;
var wireDuplicatedNow = receiver.WireDuplicatedCount;
var wireInOrderDelta = wireInOrderNow - prevDiagWireInOrder; prevDiagWireInOrder = wireInOrderNow;
var wireMissedDelta = wireMissedNow - prevDiagWireMissed; prevDiagWireMissed = wireMissedNow;
var wireReorderedDelta = wireReorderedNow - prevDiagWireReordered; prevDiagWireReordered = wireReorderedNow;
var wireDuplicatedDelta = wireDuplicatedNow - prevDiagWireDuplicated; prevDiagWireDuplicated = wireDuplicatedNow;
logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " + logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " +
$"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " + $"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " +
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} fanCacheMs={fanCacheMs} " + $"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} fanCacheMs={fanCacheMs} " +
$"trimB={trimBytes} trimN={trimFires} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " + $"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"driftDrop={driftDrops} driftRep={driftReps} " + $"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " +
$"sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " + $"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
$"filtErr={filteredErrorFrames:0.0}f driftAcc={driftAccumulator:0.000} " +
$"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
$"clipΔ={clippedDelta} sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " +
$"wireOkΔ={wireInOrderDelta} wireMissΔ={wireMissedDelta} wireReordΔ={wireReorderedDelta} wireDupΔ={wireDuplicatedDelta} " +
$"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}"); $"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}");
} }
else if (sender.IsRunning) else if (sender.IsRunning)
@@ -3440,9 +3705,24 @@ public sealed class MainForm : Form
// sendCbGapMs is visible — that's the most important metric on a send-only box, // sendCbGapMs is visible — that's the most important metric on a send-only box,
// since it tells us whether THIS machine's capture path is stalling. Without // since it tells us whether THIS machine's capture path is stalling. Without
// this branch, send-only sessions logged zero diag info. // this branch, send-only sessions logged zero diag info.
// stepPreEnc included so the send-only machine's pre-encode discontinuity
// probe is visible — needed for the laptop→desktop direction where the laptop
// is the source and we want to see if the audio coming OUT of the capture
// already has steps before it touches the wire.
var emitMs = sender.TakeMaxEmitMs(); var emitMs = sender.TakeMaxEmitMs();
var sendCallMs = sender.TakeMaxSendCallMs(); var sendCallMs = sender.TakeMaxSendCallMs();
logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}"); // Per-lane pre-encode probes — see the full-diag comment above for the
// rationale (per-lane fixes the cross-stream artefact in BothIndependent).
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
// Raw-capture step: now per-backend (each backend owns its own probe). The
// accessor returns max across all backends. PushModeWasapiBackend has been
// wired to feed this probe as of 2026-05-15; pull-mode MixingEngine returns 0.
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
} }
// Synthesised end-to-end one-way latency estimate. Sums: // Synthesised end-to-end one-way latency estimate. Sums:
@@ -3521,13 +3801,60 @@ public sealed class MainForm : Form
/// </summary> /// </summary>
protected override bool ProcessCmdKey(ref Message msg, Keys keyData) protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{ {
// No ProcessCmdKey overrides currently — base class handles everything. The previous // Defensive gate for the global menu shortcuts that change state (Ctrl+R = toggle
// Alt+M tab-local gating became unnecessary once the Audio mode listbox was retired // recording, Ctrl+S = save profile). The default WinForms behaviour fires these
// (2026-05-11); minimise to tray is reachable via Alt+F → M (File menu mnemonic) or // shortcuts any time the form has keyboard focus — which technically includes the
// the configurable "Show or hide window" global hotkey (default Ctrl+Shift+F10). // case where another tool (NVDA Remote in send-keys mode, an automation script,
// etc.) calls SetForegroundWindow on us and then SendInput a keystroke a few
// milliseconds later. The form receives focus + the keystroke arrives + the menu
// shortcut fires, all without the user touching anything.
//
// The gate adds two extra requirements before we let these shortcuts run:
// 1. The OS-level foreground window must be us. Same check the base class
// effectively makes, but explicit so the intent is documented.
// 2. At least RecentActivationGuardMs must have elapsed since we last became
// activated. Programmatic SetForegroundWindow + SendInput typically runs in
// under 50 ms; a human Alt+Tabbing in then pressing Ctrl+R can't physically
// do it inside 250 ms.
// If the gate fails we consume the keystroke (return true) so the menu shortcut
// doesn't fire, log a diagnostic, and silently ignore it. The user can still drive
// the same actions via the Alt+R / Alt+F menu chord which inherently requires the
// multi-step menu-open interaction and isn't vulnerable to drive-by injection.
if (keyData == (Keys.Control | Keys.R) || keyData == (Keys.Control | Keys.S))
{
if (!IsWindowAvailableForGatedShortcut())
{
logFile.Event($"shortcut ignored (window not in interactive state): {keyData}");
return true; // consumed; don't let MenuStrip see it
}
}
return base.ProcessCmdKey(ref msg, keyData); return base.ProcessCmdKey(ref msg, keyData);
} }
// UTC time the form last became activated. Compared against UtcNow when a gated
// shortcut fires to reject keystrokes that arrive within the RecentActivationGuardMs
// window after a window-activation — the signature of a drive-by injection.
private DateTime lastActivatedAtUtc = DateTime.MinValue;
private const int RecentActivationGuardMs = 250;
protected override void OnActivated(EventArgs e)
{
lastActivatedAtUtc = DateTime.UtcNow;
base.OnActivated(e);
}
/// <summary>Defensive gate for global menu shortcuts that change state. See the comment
/// in <see cref="ProcessCmdKey"/> for the full rationale.</summary>
private bool IsWindowAvailableForGatedShortcut()
{
if (!Visible || WindowState == FormWindowState.Minimized) return false;
if ((DateTime.UtcNow - lastActivatedAtUtc).TotalMilliseconds < RecentActivationGuardMs) return false;
return GetForegroundWindow() == Handle;
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
// ===================== Profile system ===================== // ===================== Profile system =====================
@@ -3973,13 +4300,13 @@ public sealed class MainForm : Form
previousPeerHealthStates.TryGetValue(key, out var prior); previousPeerHealthStates.TryGetValue(key, out var prior);
if (ph.State == PeerHealthState.Healthy && prior != PeerHealthState.Healthy) if (ph.State == PeerHealthState.Healthy && prior != PeerHealthState.Healthy)
{ {
if (!settings.LoadMuteConnectionCues()) connectSound?.Play(); if (settings.LoadEnableConnectCue()) connectSound?.Play();
logFile.Event($"peer connected cue: {ph.AudioEndpoint} ({prior} → Healthy)"); logFile.Event($"peer connected cue: {ph.AudioEndpoint} ({prior} → Healthy)");
} }
else if (ph.State == PeerHealthState.Unreachable else if (ph.State == PeerHealthState.Unreachable
&& (prior == PeerHealthState.Healthy || prior == PeerHealthState.Stale)) && (prior == PeerHealthState.Healthy || prior == PeerHealthState.Stale))
{ {
if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play(); if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
logFile.Event($"peer disconnected cue: {ph.AudioEndpoint} ({prior} → Unreachable)"); logFile.Event($"peer disconnected cue: {ph.AudioEndpoint} ({prior} → Unreachable)");
} }
previousPeerHealthStates[key] = ph.State; previousPeerHealthStates[key] = ph.State;
@@ -3991,7 +4318,7 @@ public sealed class MainForm : Form
{ {
if (previousPeerHealthStates[key] == PeerHealthState.Healthy) if (previousPeerHealthStates[key] == PeerHealthState.Healthy)
{ {
if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play(); if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
logFile.Event($"peer disconnected cue: {key} (deselected while Healthy)"); logFile.Event($"peer disconnected cue: {key} (deselected while Healthy)");
} }
previousPeerHealthStates.Remove(key); previousPeerHealthStates.Remove(key);
@@ -4229,6 +4556,18 @@ public sealed class MainForm : Form
/// Mixed flag; in BothIndependent either WASAPI or ASIO being on is enough to keep the /// Mixed flag; in BothIndependent either WASAPI or ASIO being on is enough to keep the
/// timer running. The per-route filtering inside the tick gates which sliders actually /// timer running. The per-route filtering inside the tick gates which sliders actually
/// move.</summary> /// move.</summary>
/// <summary>True if either lane's continuous auto-tune is enabled. Used by the shared
/// interval combo's Enabled state — the combo governs both lanes' tick rates, so it
/// should be usable as long as at least one lane wants ticking. Reading from the live
/// checkbox states keeps this consistent with the lane's checkbox even before the
/// CheckedChanged handlers have updated the persisted setting.</summary>
private bool AnyAutoTuneEnabled()
{
var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent;
var asioOn = inBothIndependent && continuousTuneAsioBox.Checked;
return continuousTuneEnabled || asioOn;
}
private void ApplyContinuousTuneTimer() private void ApplyContinuousTuneTimer()
{ {
continuousTuneTimer.Stop(); continuousTuneTimer.Stop();
@@ -4704,6 +5043,12 @@ public sealed class MainForm : Form
} }
} }
// Stop any active recording before the engines tear down. The recorder will flush
// its queue and close the file cleanly. Done here (rather than in Dispose) because
// we want the on-disk file finalised before the form closes, so opening the
// recordings folder right after exit shows the file at its full size.
try { recordingController.Stop(); } catch { /* recording cleanup is best-effort */ }
base.OnFormClosing(e); base.OnFormClosing(e);
} }
} }
+78 -16
View File
@@ -3,15 +3,21 @@ using RemSound.Core;
namespace RemSound.App; namespace RemSound.App;
/// <summary> /// <summary>
/// Preferences dialog. Holds the three settings that used to live on the (now-removed) /// Preferences dialog. Holds settings that aren't profile-management actions in their own
/// Profiles and preferences tab and aren't profile-management actions in their own right: /// right:
/// * Mute connect/disconnect sounds — the small ding on peer state changes. /// * Browse for RemSound profiles folder — picks the directory the profile picker scans
/// next launch.
/// * Cue sounds — per-cue enable list (connect, disconnect, recording start/stop). One
/// CheckedListBox; ticked items play, unticked are silent. Replaced the old single
/// "Mute connect/disconnect sounds" toggle (2026-05-15) when recording start/stop cues
/// were added — a CheckedListBox scales to future cues without dialog re-layout.
/// * Accept remote volume commands from peers — opt-in for the remote-control feature. /// * Accept remote volume commands from peers — opt-in for the remote-control feature.
/// * Startup behaviour — opens the existing <see cref="StartupBehaviourDialog"/> sub-dialog. /// * Startup behaviour — opens the existing <see cref="StartupBehaviourDialog"/> sub-dialog.
/// * Update settings — frequency, manual check, silent-install toggle.
/// * Enable logs + Write logs now.
/// ///
/// Both checkboxes save through <see cref="RemSoundSettingsStore"/> on every change (so /// All settings save through <see cref="RemSoundSettingsStore"/> or <see cref="AppConfig"/>
/// the user doesn't need to re-confirm via an OK button). The Startup behaviour button /// on every change (no OK-to-commit). Esc or Close dismisses.
/// just opens the existing modal sub-dialog. Esc or the Close button dismisses.
/// ///
/// Reachable via the File → Preferences menu item or Ctrl+P from the main window. /// Reachable via the File → Preferences menu item or Ctrl+P from the main window.
/// </summary> /// </summary>
@@ -24,13 +30,36 @@ internal sealed class PreferencesDialog : Form
AutoSize = true, AutoSize = true,
}; };
private readonly AccessibleCheckBox muteCuesBox = new() // Per-cue enable list (2026-05-15). Replaces the single "mute connect/disconnect"
// checkbox with one item per cue sound, ticked = play, unticked = silent. Same
// CheckOnClick / mnemonic-via-label pattern as the audio device lists on the main
// form — visually familiar and NVDA-friendly. The Items collection order MUST match
// the CueIndex enum below so the ItemCheck handler can dispatch by index.
private readonly Label cueListLabel = new()
{ {
Text = "Mute connect/disconnect sounds (Alt+&M)", Text = "Cue sou&nds (Alt+N):",
AccessibleName = "Mute connect/disconnect sounds", AccessibleName = "Cue sounds",
AutoSize = true, AutoSize = true,
Padding = new Padding(0, 6, 0, 4),
}; };
private readonly CheckedListBox cueList = new()
{
CheckOnClick = true,
IntegralHeight = false,
Height = 100,
Width = 360,
AccessibleName = "Cue sounds",
};
private enum CueIndex
{
Connect = 0,
Disconnect = 1,
RecordStart = 2,
RecordStop = 3,
}
private readonly AccessibleCheckBox acceptRemoteVolumeBox = new() private readonly AccessibleCheckBox acceptRemoteVolumeBox = new()
{ {
Text = "Accept remote volume commands from peers (Alt+&A)", Text = "Accept remote volume commands from peers (Alt+&A)",
@@ -119,7 +148,7 @@ internal sealed class PreferencesDialog : Form
ShowInTaskbar = false; ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent; StartPosition = FormStartPosition.CenterParent;
KeyPreview = true; KeyPreview = true;
ClientSize = new Size(560, 440); ClientSize = new Size(560, 540);
// 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup // 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup
// ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory // ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory
@@ -154,10 +183,27 @@ internal sealed class PreferencesDialog : Form
"Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information); "Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information);
}; };
muteCuesBox.Checked = settings.LoadMuteConnectionCues(); // Populate the cue list — order must match CueIndex enum. Each item is ticked from
muteCuesBox.CheckedChanged += (_, _) => // its corresponding settings flag; the toggle handler dispatches by index so adding
// a future cue is just two lines (enum value + Items.Add + Save case).
cueList.Items.Clear();
cueList.Items.Add("Connect sound", settings.LoadEnableConnectCue());
cueList.Items.Add("Disconnect sound", settings.LoadEnableDisconnectCue());
cueList.Items.Add("Recording start sound", settings.LoadEnableRecordStartCue());
cueList.Items.Add("Recording stop sound", settings.LoadEnableRecordStopCue());
cueList.ItemCheck += (_, e) =>
{ {
settings.SaveMuteConnectionCues(muteCuesBox.Checked); // ItemCheck fires BEFORE the visual state actually flips; e.NewValue is what
// it's about to become. Use that for the persist call so the saved value
// matches what the user just clicked.
var nowEnabled = e.NewValue == CheckState.Checked;
switch ((CueIndex)e.Index)
{
case CueIndex.Connect: settings.SaveEnableConnectCue(nowEnabled); break;
case CueIndex.Disconnect: settings.SaveEnableDisconnectCue(nowEnabled); break;
case CueIndex.RecordStart: settings.SaveEnableRecordStartCue(nowEnabled); break;
case CueIndex.RecordStop: settings.SaveEnableRecordStopCue(nowEnabled); break;
}
ChangedAnyProfileSetting = true; ChangedAnyProfileSetting = true;
}; };
@@ -224,11 +270,11 @@ internal sealed class PreferencesDialog : Form
for (var i = 0; i < 9; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); for (var i = 0; i < 9; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
// Tab order top-to-bottom: browse, mute cues, accept remote, startup, update // Tab order top-to-bottom: browse, cue-sound list, accept remote, startup, update
// frequency, check-now, silent install, enable logs, write logs now, close. Updates // frequency, check-now, silent install, enable logs, write logs now, close. Updates
// sit above the log row so a user setting up the app meets them first. // sit above the log row so a user setting up the app meets them first.
browseProfilesFolderButton.TabIndex = 0; browseProfilesFolderButton.TabIndex = 0;
muteCuesBox.TabIndex = 1; cueList.TabIndex = 1;
acceptRemoteVolumeBox.TabIndex = 2; acceptRemoteVolumeBox.TabIndex = 2;
startupBehaviourButton.TabIndex = 3; startupBehaviourButton.TabIndex = 3;
updateFrequencyBox.TabIndex = 4; updateFrequencyBox.TabIndex = 4;
@@ -252,8 +298,24 @@ internal sealed class PreferencesDialog : Form
freqRow.Controls.Add(updateFrequencyLabel); freqRow.Controls.Add(updateFrequencyLabel);
freqRow.Controls.Add(updateFrequencyBox); freqRow.Controls.Add(updateFrequencyBox);
// Wrap label + list as one logical group so they share the same row in the
// top-level layout. The label's Alt+N mnemonic focuses the list when activated.
var cueGroup = new TableLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
ColumnCount = 1,
RowCount = 2,
};
cueGroup.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
cueGroup.Controls.Add(cueListLabel, 0, 0);
cueGroup.Controls.Add(cueList, 0, 1);
cueListLabel.Click += (_, _) => cueList.Focus();
panel.Controls.Add(browseProfilesFolderButton, 0, 0); panel.Controls.Add(browseProfilesFolderButton, 0, 0);
panel.Controls.Add(muteCuesBox, 0, 1); panel.Controls.Add(cueGroup, 0, 1);
panel.Controls.Add(acceptRemoteVolumeBox, 0, 2); panel.Controls.Add(acceptRemoteVolumeBox, 0, 2);
panel.Controls.Add(startupBehaviourButton, 0, 3); panel.Controls.Add(startupBehaviourButton, 0, 3);
panel.Controls.Add(freqRow, 0, 4); panel.Controls.Add(freqRow, 0, 4);
+152
View File
@@ -0,0 +1,152 @@
using RemSound.Core;
using RemSound.Receiver;
using RemSound.Sender;
namespace RemSound.App;
/// <summary>
/// Glue between MainForm's Record menu and the actual recording pipeline. Owns the
/// lifecycle of the currently-running <see cref="AudioRecorder"/> (if any) and wires
/// the sender / receiver taps to it. Reading the user's saved settings, persisting
/// changes after the settings dialog, opening / changing the recordings folder — all
/// flow through here so MainForm stays focused on UI wiring.
///
/// Threading: the public methods are called from the UI thread only. The recorder
/// itself runs on its own background thread (it owns a queue + writer); the controller
/// just constructs and disposes it.
/// </summary>
internal sealed class RecordingController
{
private readonly AudioSender sender;
private readonly AudioReceiver receiver;
private readonly RemSoundSettingsStore settings;
private readonly Action<string> diagnostic;
private AudioRecorder? active;
public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action<string> diagnostic)
{
this.sender = sender;
this.receiver = receiver;
this.settings = settings;
this.diagnostic = diagnostic;
}
public bool IsRecording => active is not null;
/// <summary>Optional callback fired when the user starts or stops a recording. The
/// MainForm hooks this to flip the menu item text "Start recording" ↔ "Stop recording"
/// and announce the change to NVDA.</summary>
public event Action<bool>? RecordingStateChanged;
/// <summary>Start a new recording using the currently-saved profile settings. If a
/// recording is already running this is a no-op (the menu shouldn't ever offer Start
/// while recording, but the guard is here for safety).</summary>
public void Start()
{
if (active is not null) return;
var s = settings.LoadRecordingSettings();
try
{
active = new AudioRecorder(s, diagnostic, OnRecorderFinished);
}
catch (Exception ex)
{
diagnostic($"recording: failed to start: {ex.GetType().Name}: {ex.Message}");
MessageBox.Show(
$"Could not start recording:\n\n{ex.Message}",
"RemSound — recording",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return;
}
// Wire taps. Each tap is independent — the recorder's source-mode filter decides
// whether to actually write the samples.
sender.OnSentSamples = active.WriteSent;
receiver.OnReceivedSamples = active.WriteReceived;
diagnostic($"recording: started → {active.FilePath} (source={s.Source}, format={s.FileFormat}, channels={s.ChannelMode})");
RecordingStateChanged?.Invoke(true);
}
/// <summary>Stop the currently-running recording. Unhooks taps, flushes the writer
/// queue, closes the file, and surfaces the resulting path in a brief MessageBox
/// so the user knows where the file landed.</summary>
public void Stop()
{
var recorder = active;
if (recorder is null) return;
// Unhook taps FIRST so no more audio gets queued during the drain.
sender.OnSentSamples = null;
receiver.OnReceivedSamples = null;
active = null;
try
{
recorder.Stop();
recorder.Dispose();
}
catch (Exception ex)
{
diagnostic($"recording: stop threw {ex.GetType().Name}: {ex.Message}");
}
RecordingStateChanged?.Invoke(false);
}
private void OnRecorderFinished(string path, long bytes)
{
diagnostic($"recording: finished → {path} ({bytes:N0} bytes)");
}
/// <summary>Open the currently-configured recordings folder in Windows Explorer.
/// Creates the folder if it doesn't yet exist (a fresh install hasn't recorded
/// anything, so the folder won't be there). Surfaces filesystem errors to the user
/// rather than swallowing them silently.</summary>
public void OpenCurrentFolder(IWin32Window? owner)
{
var s = settings.LoadRecordingSettings();
var folder = s.ResolvedFolder();
try
{
Directory.CreateDirectory(folder);
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = folder,
UseShellExecute = true,
});
}
catch (Exception ex)
{
diagnostic($"recording: open folder failed: {ex.GetType().Name}: {ex.Message}");
MessageBox.Show(owner,
$"Could not open recordings folder:\n\n{ex.Message}",
"RemSound — recordings folder",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
}
/// <summary>Show a folder-picker rooted at the current recordings folder. If the
/// user picks a different folder, save it on the profile and return true so the
/// caller can flag the profile dirty.</summary>
public bool ChangeFolder(IWin32Window? owner)
{
var s = settings.LoadRecordingSettings();
var startFolder = s.ResolvedFolder();
using var picker = new FolderBrowserDialog
{
Description = "Choose a folder for RemSound recordings",
UseDescriptionForTitle = true,
SelectedPath = Directory.Exists(startFolder) ? startFolder : RecordingSettings.DefaultFolder(),
ShowNewFolderButton = true,
};
if (picker.ShowDialog(owner) != DialogResult.OK) return false;
if (string.IsNullOrWhiteSpace(picker.SelectedPath)) return false;
if (string.Equals(picker.SelectedPath, startFolder, StringComparison.OrdinalIgnoreCase)) return false;
s.Folder = picker.SelectedPath;
settings.SaveRecordingSettings(s);
diagnostic($"recording: folder changed → {picker.SelectedPath}");
return true;
}
}
+432
View File
@@ -0,0 +1,432 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Recording settings dialog. Three listboxes laid out left-to-right:
/// * Recording &source (Alt+S) — what audio gets captured
/// * File &format (Alt+F) — WAV / MP3 / Ogg / FLAC
/// * Audio &attributes (Alt+A) — bit depth or bitrate, plus channel mode
///
/// The attributes list repopulates whenever the file-format selection changes, so the user
/// always sees only the choices that make sense for the format. Selecting a WAV-only
/// attribute then switching the format to MP3 doesn't carry forward — the format-attributes
/// list resets to a sensible default for the new format.
///
/// Settings are written back to the profile only when the user presses OK. Cancel / Esc
/// discards. The dialog also exposes <see cref="ChangedAnything"/> so the caller can
/// MarkProfileDirty after a successful OK.
///
/// Reachable from the Record menu → "Recording settings...".
/// </summary>
internal sealed class RecordingSettingsDialog : Form
{
private readonly RecordingSettings working; // mutated as the user interacts
private readonly Label sourceLabel = new()
{
Text = "Recording &source (Alt+S):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox sourceList = new()
{
AccessibleName = "Recording source",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 120,
};
private readonly Label formatLabel = new()
{
Text = "File &format (Alt+F):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox formatList = new()
{
AccessibleName = "File format",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 120,
};
private readonly Label attributesLabel = new()
{
Text = "Audio format &attributes (Alt+A):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox attributesList = new()
{
AccessibleName = "Audio format attributes",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 200,
};
private readonly Button okButton = new()
{
Text = "&OK",
AutoSize = true,
DialogResult = DialogResult.OK,
};
private readonly Button cancelButton = new()
{
Text = "&Cancel",
AutoSize = true,
DialogResult = DialogResult.Cancel,
};
/// <summary>True if the user pressed OK and any setting actually changed. The caller
/// uses this to mark the profile dirty.</summary>
public bool ChangedAnything { get; private set; }
/// <summary>The final settings (after OK). Equals the input settings if Cancel was
/// pressed — caller should ignore this on a non-OK DialogResult.</summary>
public RecordingSettings Result => working;
public RecordingSettingsDialog(RecordingSettings current)
{
working = current?.Clone() ?? new RecordingSettings();
var initialSnapshot = working.Clone();
Text = "Recording settings";
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
KeyPreview = true;
ClientSize = new Size(700, 360);
PopulateSourceList();
PopulateFormatList();
PopulateAttributesList();
SelectFromSource(working.Source);
SelectFromFormat(working.FileFormat);
SelectFromAttributes(working);
sourceList.SelectedIndexChanged += (_, _) =>
{
if (sourceList.SelectedIndex < 0) return;
working.Source = (RecordingSource)sourceList.SelectedIndex;
};
formatList.SelectedIndexChanged += (_, _) =>
{
if (formatList.SelectedIndex < 0) return;
var newFormat = (RecordingFileFormat)formatList.SelectedIndex;
if (newFormat == working.FileFormat) return;
working.FileFormat = newFormat;
PopulateAttributesList();
SelectFromAttributes(working);
};
attributesList.SelectedIndexChanged += (_, _) =>
{
if (attributesList.SelectedIndex < 0) return;
ApplyAttributesSelection();
};
okButton.Click += (_, _) =>
{
ChangedAnything = !SettingsEqual(initialSnapshot, working);
};
// Three columns side by side, OK/Cancel row beneath.
var grid = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 3,
RowCount = 2,
};
for (var i = 0; i < 3; i++) grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.3f));
grid.RowStyles.Add(new RowStyle(SizeType.AutoSize));
grid.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var sourceColumn = MakeColumn(sourceLabel, sourceList);
var formatColumn = MakeColumn(formatLabel, formatList);
var attributesColumn = MakeColumn(attributesLabel, attributesList);
grid.Controls.Add(sourceColumn, 0, 0);
grid.SetRowSpan(sourceColumn, 2);
grid.Controls.Add(formatColumn, 1, 0);
grid.SetRowSpan(formatColumn, 2);
grid.Controls.Add(attributesColumn, 2, 0);
grid.SetRowSpan(attributesColumn, 2);
var buttonRow = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(0, 0, 12, 12),
};
buttonRow.Controls.Add(cancelButton);
buttonRow.Controls.Add(okButton);
// Order: dialog body first (grid), then buttons docked beneath.
Controls.Add(grid);
Controls.Add(buttonRow);
AcceptButton = okButton;
CancelButton = cancelButton;
// Tab order top-to-bottom of the visible flow: source, format, attributes, OK, Cancel.
sourceList.TabIndex = 0;
formatList.TabIndex = 1;
attributesList.TabIndex = 2;
okButton.TabIndex = 3;
cancelButton.TabIndex = 4;
}
private static Control MakeColumn(Label label, ListBox list)
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
list.Dock = DockStyle.Fill;
panel.Controls.Add(label, 0, 0);
panel.Controls.Add(list, 0, 1);
return panel;
}
private void PopulateSourceList()
{
sourceList.BeginUpdate();
sourceList.Items.Clear();
// Order MUST match RecordingSource enum values 0/1/2.
sourceList.Items.Add("Record all received audio");
sourceList.Items.Add("Record all sent audio");
sourceList.Items.Add("Record both sent and received audio");
sourceList.EndUpdate();
}
private void PopulateFormatList()
{
formatList.BeginUpdate();
formatList.Items.Clear();
// Order MUST match RecordingFileFormat enum values 0..3.
formatList.Items.Add("WAV (uncompressed)");
formatList.Items.Add("MP3");
formatList.Items.Add("Ogg-Opus");
formatList.Items.Add("FLAC (lossless)");
formatList.EndUpdate();
}
// === Per-format attribute tables ===
// All four formats currently record at the engine's 48 kHz mix rate, so labels include
// "48 kHz" to make the sample rate explicit (it's not a choice — it's a statement of fact
// about what gets written, which removes a common surprise for users who expected to see
// a rate picker). Channel mode is part of every row because it determines file shape
// alongside the format-specific quality knob.
private static readonly (int Bits, RecordingChannelMode Mode, string Label)[] WavAttributes =
{
(16, RecordingChannelMode.Stereo, "16-bit PCM, 48 kHz, stereo"),
(16, RecordingChannelMode.Mono, "16-bit PCM, 48 kHz, mono"),
(24, RecordingChannelMode.Stereo, "24-bit PCM, 48 kHz, stereo"),
(24, RecordingChannelMode.Mono, "24-bit PCM, 48 kHz, mono"),
(32, RecordingChannelMode.Stereo, "32-bit float, 48 kHz, stereo"),
(32, RecordingChannelMode.Mono, "32-bit float, 48 kHz, mono"),
};
private static readonly (int Kbps, RecordingChannelMode Mode, string Label)[] Mp3Attributes =
{
(128, RecordingChannelMode.Stereo, "128 kbps, 48 kHz, stereo"),
(128, RecordingChannelMode.Mono, "128 kbps, 48 kHz, mono"),
(192, RecordingChannelMode.Stereo, "192 kbps, 48 kHz, stereo"),
(192, RecordingChannelMode.Mono, "192 kbps, 48 kHz, mono"),
(256, RecordingChannelMode.Stereo, "256 kbps, 48 kHz, stereo"),
(256, RecordingChannelMode.Mono, "256 kbps, 48 kHz, mono"),
(320, RecordingChannelMode.Stereo, "320 kbps, 48 kHz, stereo"),
(320, RecordingChannelMode.Mono, "320 kbps, 48 kHz, mono"),
};
// OGG-Opus is VBR — kbps numbers are the encoder's target average. Opus' music-quality
// sweet spot starts around 96 kbps; we expose 96 / 128 / 192 / 256 so users have a
// smaller-file option without it sounding obviously lossy on dense material.
private static readonly (int Kbps, RecordingChannelMode Mode, string Label)[] OggOpusAttributes =
{
(96, RecordingChannelMode.Stereo, "96 kbps, 48 kHz, stereo"),
(96, RecordingChannelMode.Mono, "96 kbps, 48 kHz, mono"),
(128, RecordingChannelMode.Stereo, "128 kbps, 48 kHz, stereo"),
(128, RecordingChannelMode.Mono, "128 kbps, 48 kHz, mono"),
(192, RecordingChannelMode.Stereo, "192 kbps, 48 kHz, stereo"),
(192, RecordingChannelMode.Mono, "192 kbps, 48 kHz, mono"),
(256, RecordingChannelMode.Stereo, "256 kbps, 48 kHz, stereo"),
(256, RecordingChannelMode.Mono, "256 kbps, 48 kHz, mono"),
};
// FLAC is lossless — quality knob is just bit depth (and silently, compression level,
// which we hard-fix at the reference encoder's default 5). 32-bit float isn't a FLAC
// option (FLAC stores integer PCM), so it's deliberately absent.
private static readonly (int Bits, RecordingChannelMode Mode, string Label)[] FlacAttributes =
{
(16, RecordingChannelMode.Stereo, "16-bit, 48 kHz, stereo"),
(16, RecordingChannelMode.Mono, "16-bit, 48 kHz, mono"),
(24, RecordingChannelMode.Stereo, "24-bit, 48 kHz, stereo"),
(24, RecordingChannelMode.Mono, "24-bit, 48 kHz, mono"),
};
private void PopulateAttributesList()
{
attributesList.BeginUpdate();
attributesList.Items.Clear();
switch (working.FileFormat)
{
case RecordingFileFormat.Wav:
foreach (var (_, _, label) in WavAttributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Mp3:
foreach (var (_, _, label) in Mp3Attributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Ogg:
foreach (var (_, _, label) in OggOpusAttributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Flac:
foreach (var (_, _, label) in FlacAttributes) attributesList.Items.Add(label);
break;
default:
attributesList.Items.Add("Default settings");
break;
}
attributesList.EndUpdate();
}
private void SelectFromSource(RecordingSource src)
{
var idx = (int)src;
if (idx >= 0 && idx < sourceList.Items.Count) sourceList.SelectedIndex = idx;
}
private void SelectFromFormat(RecordingFileFormat fmt)
{
var idx = (int)fmt;
if (idx >= 0 && idx < formatList.Items.Count) formatList.SelectedIndex = idx;
}
private void SelectFromAttributes(RecordingSettings s)
{
switch (s.FileFormat)
{
case RecordingFileFormat.Wav:
for (var i = 0; i < WavAttributes.Length; i++)
{
var (bits, mode, _) = WavAttributes[i];
if (bits == s.WavBitsPerSample && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 2; // 24-bit stereo default
break;
case RecordingFileFormat.Mp3:
for (var i = 0; i < Mp3Attributes.Length; i++)
{
var (kbps, mode, _) = Mp3Attributes[i];
if (kbps == s.Mp3BitrateKbps && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 6; // 320 kbps stereo default
break;
case RecordingFileFormat.Ogg:
for (var i = 0; i < OggOpusAttributes.Length; i++)
{
var (kbps, mode, _) = OggOpusAttributes[i];
if (kbps == s.OggOpusBitrateKbps && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 4; // 192 kbps stereo default
break;
case RecordingFileFormat.Flac:
for (var i = 0; i < FlacAttributes.Length; i++)
{
var (bits, mode, _) = FlacAttributes[i];
if (bits == s.FlacBitsPerSample && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 2; // 24-bit stereo default
break;
default:
if (attributesList.Items.Count > 0) attributesList.SelectedIndex = 0;
break;
}
}
private void ApplyAttributesSelection()
{
var idx = attributesList.SelectedIndex;
if (idx < 0) return;
switch (working.FileFormat)
{
case RecordingFileFormat.Wav:
if (idx < WavAttributes.Length)
{
var (bits, mode, _) = WavAttributes[idx];
working.WavBitsPerSample = bits;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Mp3:
if (idx < Mp3Attributes.Length)
{
var (kbps, mode, _) = Mp3Attributes[idx];
working.Mp3BitrateKbps = kbps;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Ogg:
if (idx < OggOpusAttributes.Length)
{
var (kbps, mode, _) = OggOpusAttributes[idx];
working.OggOpusBitrateKbps = kbps;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Flac:
if (idx < FlacAttributes.Length)
{
var (bits, mode, _) = FlacAttributes[idx];
working.FlacBitsPerSample = bits;
working.ChannelMode = mode;
}
break;
default:
break;
}
}
private static bool SettingsEqual(RecordingSettings a, RecordingSettings b) =>
a.Source == b.Source
&& a.FileFormat == b.FileFormat
&& a.ChannelMode == b.ChannelMode
&& a.WavBitsPerSample == b.WavBitsPerSample
&& a.Mp3BitrateKbps == b.Mp3BitrateKbps
&& a.OggOpusBitrateKbps == b.OggOpusBitrateKbps
&& a.FlacBitsPerSample == b.FlacBitsPerSample
&& a.FlacCompressionLevel == b.FlacCompressionLevel
&& string.Equals(a.Folder ?? string.Empty, b.Folder ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
+22 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. --> is what the About dialog and the updater both read. -->
<Version>1.1.0</Version> <Version>1.2.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -22,6 +22,17 @@
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" /> <ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" /> <ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
<PackageReference Include="NAudio" Version="2.3.0" /> <PackageReference Include="NAudio" Version="2.3.0" />
<!-- LAME wrapper for MP3 encoding. Pulled in for the recording feature. The native
libmp3lame.dll ships with the package and is copied to the output folder. -->
<PackageReference Include="NAudio.Lame" Version="2.1.0" />
<!-- OGG container writer that wraps Concentus-encoded Opus. Same Concentus the wire
path already uses (transitive dep), so the recording-side OGG-Opus output is
byte-for-byte the same encoder we'd send on a live Opus stream. -->
<PackageReference Include="Concentus.Oggfile" Version="1.0.7" />
<!-- Pure-managed FLAC encoder (Flake). Same encoder CUETools uses; no native DLL,
no P/Invoke. Lossless, ships as managed IL, smaller files than WAV (~50%) without
sample-data loss. -->
<PackageReference Include="CUETools.Codecs.FLAKE" Version="1.0.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -35,6 +46,16 @@
<Link>disconnect.wav</Link> <Link>disconnect.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<!-- Recording start/stop cues. Filenames contain a space — preserve on copy so the
load-by-filename path in TryLoadCueSound finds them exactly as written. -->
<Content Include="..\..\record start.wav">
<Link>record start.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\record stop.wav">
<Link>record stop.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- User manual. F1 anywhere in the app opens this via the user's default browser <!-- User manual. F1 anywhere in the app opens this via the user's default browser
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites (HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
the published copy whenever the source is newer; manually-edited copies inside the published copy whenever the source is newer; manually-edited copies inside
+129
View File
@@ -0,0 +1,129 @@
namespace RemSound.Core;
/// <summary>
/// Per-stage sample-step "discontinuity detector" for diagnosing where in the audio pipeline
/// pops are being introduced. A single-sample step magnitude is the absolute difference
/// between consecutive samples of the same channel; a typical "click" in real audio shows
/// up as a step well above what naturally occurs in music or speech content.
///
/// Each probe holds the maximum step observed across all calls to <see cref="ScanStereo"/>
/// since the last <see cref="TakeMax"/>. The diag log polls TakeMax once per second to
/// emit the worst step at that pipeline stage. Comparing the max across stages — sender
/// pre-encode, receiver post-decode, receiver post-ring-read, receiver post-resampler,
/// final output — reveals which stage introduces the click.
///
/// Thread model: writes are lock-free CAS-update of a long-encoded float bit pattern (so
/// one probe can be hit from multiple threads if needed). Read-and-reset is also atomic.
/// Buffers are scanned cheaply — one subtract + abs + compare per sample — and the whole
/// scan is gated by <see cref="DiagnosticsGate.Enabled"/> so it pays nothing in production
/// when logging is off.
/// </summary>
public sealed class AudioStepProbe
{
private long maxStepBits;
// Remember the last sample on each channel so the next scan can compute the cross-buffer
// step. Without this we'd miss any discontinuity at the buffer boundary (the most
// suspicious place — that's where copies, format conversions and resampler hand-offs
// happen).
private float lastL;
private float lastR;
private bool hasLast;
/// <summary>Scan a single-channel slice of an interleaved multi-channel float buffer and
/// update the max step magnitude. <paramref name="channelCount"/> is the total number of
/// interleaved channels; <paramref name="channelIndex"/> picks which one to scan. Used by
/// the ASIO capture probe to look at raw driver-delivered samples on individual channels
/// before any mixing or clamping happens. Cheap; safe to call from any thread; no-op when
/// diagnostics are disabled.</summary>
public void ScanInterleavedChannel(ReadOnlySpan<float> interleavedFloats, int channelCount, int channelIndex)
{
if (!DiagnosticsGate.Enabled) return;
if (interleavedFloats.IsEmpty) return;
if (channelCount <= 0 || channelIndex < 0 || channelIndex >= channelCount) return;
var max = ReadMax();
// Use lastL as the cross-buffer carry for single-channel scans. (We don't need a
// separate "lastSingle" — every probe is consumed by exactly one caller at a time, so
// reusing the field is fine. The cross-buffer step is what matters for buffer-boundary
// glitches.)
var prev = lastL;
var seedFromPrev = hasLast;
var samples = interleavedFloats.Length / channelCount;
for (var i = 0; i < samples; i++)
{
var s = interleavedFloats[i * channelCount + channelIndex];
if (i == 0 && !seedFromPrev) prev = s;
var step = s - prev;
if (step < 0f) step = -step;
if (step > max) max = step;
prev = s;
}
lastL = prev;
hasLast = true;
WriteMaxIfGreater(max);
}
/// <summary>Scan an interleaved stereo float span and update the max step. Cheap; safe
/// to call from any thread. No-op if diagnostics are disabled.</summary>
public void ScanStereo(ReadOnlySpan<float> stereoFloats)
{
if (!DiagnosticsGate.Enabled) return;
if (stereoFloats.IsEmpty) return;
var max = ReadMax();
var prevL = lastL;
var prevR = lastR;
var seedFromPrev = hasLast;
// Pair walk. For samples after the first, compare to the previous sample of the
// same channel from THIS buffer. For the first pair, compare to the saved
// last-sample-from-the-previous-buffer if available.
for (var i = 0; i + 1 < stereoFloats.Length; i += 2)
{
var l = stereoFloats[i];
var r = stereoFloats[i + 1];
float stepL, stepR;
if (i == 0)
{
if (!seedFromPrev) { prevL = l; prevR = r; }
stepL = l - prevL;
stepR = r - prevR;
}
else
{
stepL = l - stereoFloats[i - 2];
stepR = r - stereoFloats[i - 1];
}
var absL = stepL < 0f ? -stepL : stepL;
var absR = stepR < 0f ? -stepR : stepR;
if (absL > max) max = absL;
if (absR > max) max = absR;
}
// Save the last sample of this buffer for the next scan.
var lastIdx = stereoFloats.Length - 2;
lastL = stereoFloats[lastIdx];
lastR = stereoFloats[lastIdx + 1];
hasLast = true;
WriteMaxIfGreater(max);
}
/// <summary>Atomic snapshot of the current max + reset to zero. Returns the value as
/// a float in the same units as the input (i.e. 0.5 = a 0.5-magnitude single-sample
/// step, which is a 6 dB jump and definitely audible).</summary>
public float TakeMax()
{
var bits = Interlocked.Exchange(ref maxStepBits, 0);
return BitConverter.Int32BitsToSingle((int)bits);
}
private float ReadMax() => BitConverter.Int32BitsToSingle((int)Volatile.Read(ref maxStepBits));
private void WriteMaxIfGreater(float candidate)
{
var candidateBits = (long)BitConverter.SingleToInt32Bits(candidate);
long current;
do
{
current = Volatile.Read(ref maxStepBits);
var currentValue = BitConverter.Int32BitsToSingle((int)current);
if (candidate <= currentValue) return;
} while (Interlocked.CompareExchange(ref maxStepBits, candidateBits, current) != current);
}
}
+25 -1
View File
@@ -62,8 +62,23 @@ public sealed class Profile
/// profile (not in AppConfig) because the right answer genuinely differs between /// profile (not in AppConfig) because the right answer genuinely differs between
/// profiles.</summary> /// profiles.</summary>
public bool PriorityMode { get; set; } public bool PriorityMode { get; set; }
/// <summary>True suppresses the connect/disconnect sound cues. Off by default.</summary> /// <summary>Legacy combined "mute connect/disconnect sounds" toggle. True suppresses
/// both connect AND disconnect cues. Superseded 2026-05-15 by the four individual
/// <c>Enable*Cue</c> flags below — the new flags take precedence when set. This field
/// is preserved on the profile for backward compatibility with older builds that don't
/// know about the per-cue flags; on first load the per-cue flags inherit from this
/// (true → connect+disconnect cues disabled).</summary>
public bool MuteConnectionCues { get; set; } public bool MuteConnectionCues { get; set; }
/// <summary>Per-cue enable flags. Nullable so a missing entry in an older profile JSON
/// falls back to the legacy <see cref="MuteConnectionCues"/> migration path; once the
/// user touches the new UI we write a concrete <c>true</c>/<c>false</c> and the legacy
/// field stops mattering. Defaults to "play the sound" (true) for both cases — the
/// audio cues are part of the normal user feedback loop, not opt-in. 2026-05-15.</summary>
public bool? EnableConnectCue { get; set; }
public bool? EnableDisconnectCue { get; set; }
public bool? EnableRecordStartCue { get; set; }
public bool? EnableRecordStopCue { get; set; }
public int MaxLatencyMs { get; set; } = 80; public int MaxLatencyMs { get; set; } = 80;
public int Smoothness { get; set; } = 3; public int Smoothness { get; set; } = 3;
public bool ContinuousAutoTuneEnabled { get; set; } public bool ContinuousAutoTuneEnabled { get; set; }
@@ -95,6 +110,15 @@ public sealed class Profile
set => ConcealmentArtifactRaw = (int)value; set => ConcealmentArtifactRaw = (int)value;
} }
// === Recording ===
/// <summary>Recording source / format / attributes. The whole settings object is saved
/// per profile so different profiles can record different things (a "long session"
/// profile might record everything to MP3, a "monitoring" profile might not record at
/// all but keep the dialog defaults sensible). The recording isn't running until the
/// user explicitly triggers it via the Record menu; this just holds the configuration
/// the recorder picks up when it starts.</summary>
public RecordingSettings RecordingSettings { get; set; } = new();
// === Peers === // === Peers ===
public List<string> RememberedPeers { get; set; } = []; public List<string> RememberedPeers { get; set; } = [];
/// <summary>Peer addresses (IP or host[:port]) the user had ticked in the connected /// <summary>Peer addresses (IP or host[:port]) the user had ticked in the connected
+129
View File
@@ -0,0 +1,129 @@
namespace RemSound.Core;
/// <summary>What audio gets captured by the recorder. Selected in the Recording settings
/// dialog and saved per profile. Defaults to <see cref="ReceivedOnly"/> which is the most
/// common "I want a copy of what my collaborator just played me" case.</summary>
public enum RecordingSource
{
/// <summary>Record only audio coming from connected peers (everything that would play
/// out of the receiver's render path).</summary>
ReceivedOnly = 0,
/// <summary>Record only audio captured locally (everything this machine is sending to
/// peers — your own mics / loopback / ASIO inputs).</summary>
SentOnly = 1,
/// <summary>Record the sum of received + sent audio, soft-mixed and limiter-protected
/// just like the playback path. Useful for capturing a complete two-way exchange in a
/// single file.</summary>
Both = 2,
}
/// <summary>Output container format the recorder writes to disk. The format dictates the
/// shape of <see cref="RecordingSettings.AudioAttributes"/> — uncompressed formats take
/// bit-depth, compressed formats take a bitrate, mono/stereo applies to all of them.</summary>
public enum RecordingFileFormat
{
/// <summary>RIFF WAVE, PCM. Lossless, large. Writer: in-process custom WAV writer with
/// periodic header re-patching so a mid-session crash leaves a playable file.</summary>
Wav = 0,
/// <summary>MPEG Layer III. Lossy, small. Writer: NAudio.Lame (LAME library).</summary>
Mp3 = 1,
/// <summary>Ogg container with Opus codec. Lossy, very small at sane bitrates.
/// Reuses the Concentus Opus encoder the wire path uses, wrapped in an Ogg container
/// via the Concentus.Oggfile NuGet.</summary>
Ogg = 2,
/// <summary>FLAC — lossless, typically ~50 % the size of equivalent WAV.
/// Writer: CUETools.Codecs.FLAKE — pure-managed FLAC encoder, no native DLL.</summary>
Flac = 3,
}
/// <summary>Channel layout for the recording — independent of the format. Stereo preserves
/// the L/R as captured; Mono downmixes to (L + R) / 2 with a 3 dB headroom safety knock so
/// fully-correlated content doesn't clip.</summary>
public enum RecordingChannelMode
{
Stereo = 0,
Mono = 1,
}
/// <summary>All the user-selectable knobs for a recording. Stored on <see cref="Profile"/>.
///
/// The <see cref="AudioAttributes"/> field is a flat int that means different things per
/// format — <see cref="WavBitsPerSample"/> for WAV, <see cref="Mp3BitrateKbps"/> for MP3
/// — kept as one slot rather than a separate field per format because (a) only one is
/// active at a time and (b) it keeps the profile JSON narrow. The format enum decides
/// which interpretation applies.
///
/// Path policy: <see cref="Folder"/> is stored verbatim. When empty, recordings go to
/// the default location (<c>&lt;exe&gt;\recordings\&lt;machine&gt;\</c>). When set, it
/// IS the folder — no per-machine subfolder is appended. Each recording session creates
/// a new file inside the folder, named with a UTC timestamp and the format extension.
/// </summary>
public sealed class RecordingSettings
{
public RecordingSource Source { get; set; } = RecordingSource.ReceivedOnly;
public RecordingFileFormat FileFormat { get; set; } = RecordingFileFormat.Wav;
public RecordingChannelMode ChannelMode { get; set; } = RecordingChannelMode.Stereo;
/// <summary>WAV bit depth. 16 / 24 / 32. 32 means IEEE float; 16 and 24 are signed PCM.
/// Defaults to 24 which matches RemSound's on-wire PCM bit depth — no extra quantisation
/// happens on the way to disk. Ignored when <see cref="FileFormat"/> isn't WAV.</summary>
public int WavBitsPerSample { get; set; } = 24;
/// <summary>MP3 CBR bitrate in kbps. Common values: 128, 192, 256, 320. 320 is the
/// LAME maximum and the default here — the recording feature is for archival of audio
/// you cared enough to send over the network, not for a podcast feed, so the bias is
/// toward "make the file slightly bigger for an audibly cleaner result". Ignored when
/// <see cref="FileFormat"/> isn't MP3.</summary>
public int Mp3BitrateKbps { get; set; } = 320;
/// <summary>OGG-Opus VBR target bitrate in kbps. Opus' practical sweet spot for music
/// is 96256 kbps; below 96 starts to introduce audible artefacts on dense material,
/// above 256 is diminishing returns. Default 192 — same compromise as the MP3 default
/// "noticeably-larger file for noticeably-cleaner result". Ignored when
/// <see cref="FileFormat"/> isn't Ogg.</summary>
public int OggOpusBitrateKbps { get; set; } = 192;
/// <summary>FLAC bit depth. 16 or 24 — FLAC is integer-PCM only, no 32-bit float, so
/// the WAV "32-bit float" option doesn't carry over. 24-bit matches the wire PCM bit
/// depth and is the default. Ignored when <see cref="FileFormat"/> isn't FLAC.</summary>
public int FlacBitsPerSample { get; set; } = 24;
/// <summary>FLAC compression level, 08. Higher = smaller file, more CPU during encode;
/// all levels are losslessly identical on decode. Reference encoder default is 5; we
/// match that — the encode is comfortably real-time at level 5 on any modern CPU.
/// Ignored when <see cref="FileFormat"/> isn't FLAC.</summary>
public int FlacCompressionLevel { get; set; } = 5;
/// <summary>Absolute path to the folder recordings get written into. Empty / null
/// means "use the default <c>&lt;exe&gt;\recordings\&lt;machine&gt;\</c>". Persisted
/// verbatim — if a saved profile points at a folder that doesn't exist on the loading
/// machine, the recorder falls back to the default and notes it in the diagnostics.</summary>
public string? Folder { get; set; }
public RecordingSettings Clone() => new()
{
Source = Source,
FileFormat = FileFormat,
ChannelMode = ChannelMode,
WavBitsPerSample = WavBitsPerSample,
Mp3BitrateKbps = Mp3BitrateKbps,
OggOpusBitrateKbps = OggOpusBitrateKbps,
FlacBitsPerSample = FlacBitsPerSample,
FlacCompressionLevel = FlacCompressionLevel,
Folder = Folder,
};
/// <summary>Default folder path used when <see cref="Folder"/> is blank. Computed at
/// call time (not cached) so a launch from a different exe directory picks up that
/// directory rather than the first-load one. The per-machine subfolder lets two
/// machines sharing a Dropbox-backed RemSound install keep their recordings tidily
/// separated by sender identity.</summary>
public static string DefaultFolder() =>
Path.Combine(AppContext.BaseDirectory, "recordings", Environment.MachineName);
/// <summary>Returns the resolved folder this profile would record into right now —
/// either the explicit <see cref="Folder"/> if set, or <see cref="DefaultFolder"/>.
/// Does not create the folder on disk.</summary>
public string ResolvedFolder() =>
string.IsNullOrWhiteSpace(Folder) ? DefaultFolder() : Folder!;
}
@@ -313,6 +313,79 @@ public sealed class RemSoundSettingsStore
Save(s); Save(s);
} }
// === Per-cue enable flags (2026-05-15) ===
// Each cue sound has its own enable toggle, surfaced in the Preferences dialog as a
// CheckedListBox. The legacy MuteConnectionCues above used to gate both connect AND
// disconnect — when the new flags are absent (null cache + null profile), the load
// helpers fall back to the legacy value as a migration step. Once the user touches
// any per-cue toggle, that flag's load returns the explicit value directly and the
// legacy field becomes irrelevant for that cue.
public bool LoadEnableConnectCue()
{
var s = Load();
if (s?.EnableConnectCue is bool v) return v;
// Legacy fallback: an older profile with MuteConnectionCues=true was muting both
// connect AND disconnect at once. Honour that intent on first load.
if (s?.MuteConnectionCues == true) return false;
return true;
}
public void SaveEnableConnectCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableConnectCue = value;
Save(s);
}
public bool LoadEnableDisconnectCue()
{
var s = Load();
if (s?.EnableDisconnectCue is bool v) return v;
if (s?.MuteConnectionCues == true) return false;
return true;
}
public void SaveEnableDisconnectCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableDisconnectCue = value;
Save(s);
}
public bool LoadEnableRecordStartCue() =>
Try(() => Load()?.EnableRecordStartCue) ?? true;
public void SaveEnableRecordStartCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableRecordStartCue = value;
Save(s);
}
public bool LoadEnableRecordStopCue() =>
Try(() => Load()?.EnableRecordStopCue) ?? true;
public void SaveEnableRecordStopCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableRecordStopCue = value;
Save(s);
}
/// <summary>The whole recording-settings bag for the current profile. Loaded as a
/// CLONE so callers can mutate the returned object without inadvertently writing
/// back to the cache. Save flushes the object atomically.</summary>
public RecordingSettings LoadRecordingSettings() =>
(Try(() => Load()?.RecordingSettings) ?? new RecordingSettings()).Clone();
public void SaveRecordingSettings(RecordingSettings value)
{
var s = Load() ?? new Settings();
s.RecordingSettings = value?.Clone() ?? new RecordingSettings();
Save(s);
}
/// <summary>How aggressively the receiver pulls the playout queue back to the user's /// <summary>How aggressively the receiver pulls the playout queue back to the user's
/// target latency under network jitter. 1 = stupid aggressive (~10 % playback rate change, /// target latency under network jitter. 1 = stupid aggressive (~10 % playback rate change,
/// audible pitch shift on drift, sub-second recovery). 10 = perfectly smooth (gentle /// audible pitch shift on drift, sub-second recovery). 10 = perfectly smooth (gentle
@@ -418,6 +491,11 @@ public sealed class RemSoundSettingsStore
Smoothness = profile.Smoothness, Smoothness = profile.Smoothness,
ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw, ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw,
MuteConnectionCues = profile.MuteConnectionCues, MuteConnectionCues = profile.MuteConnectionCues,
EnableConnectCue = profile.EnableConnectCue,
EnableDisconnectCue = profile.EnableDisconnectCue,
EnableRecordStartCue = profile.EnableRecordStartCue,
EnableRecordStopCue = profile.EnableRecordStopCue,
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
}; };
} }
@@ -459,6 +537,14 @@ public sealed class RemSoundSettingsStore
if (s.Smoothness is int sm) profile.Smoothness = sm; if (s.Smoothness is int sm) profile.Smoothness = sm;
if (s.ConcealmentArtifact is ConcealmentArtifact ca) profile.ConcealmentArtifactRaw = (int)ca; if (s.ConcealmentArtifact is ConcealmentArtifact ca) profile.ConcealmentArtifactRaw = (int)ca;
if (s.MuteConnectionCues is bool mc) profile.MuteConnectionCues = mc; if (s.MuteConnectionCues is bool mc) profile.MuteConnectionCues = mc;
// Per-cue enable flags — copy through verbatim (nullable on both sides, so an
// unset flag in the cache stays unset on the profile, letting the legacy
// MuteConnectionCues path govern that cue on the next load).
profile.EnableConnectCue = s.EnableConnectCue;
profile.EnableDisconnectCue = s.EnableDisconnectCue;
profile.EnableRecordStartCue = s.EnableRecordStartCue;
profile.EnableRecordStopCue = s.EnableRecordStopCue;
if (s.RecordingSettings is RecordingSettings rs) profile.RecordingSettings = rs.Clone();
} }
private static HotkeySetting HotkeySettingFromRecord(HotkeyRecord r) => new() private static HotkeySetting HotkeySettingFromRecord(HotkeyRecord r) => new()
@@ -513,6 +599,13 @@ public sealed class RemSoundSettingsStore
public int? Smoothness { get; set; } public int? Smoothness { get; set; }
public ConcealmentArtifact? ConcealmentArtifact { get; set; } public ConcealmentArtifact? ConcealmentArtifact { get; set; }
public bool? MuteConnectionCues { get; set; } public bool? MuteConnectionCues { get; set; }
// Per-cue enable flags (2026-05-15). Nullable so an absent value in the loaded
// profile falls back to the legacy MuteConnectionCues migration path.
public bool? EnableConnectCue { get; set; }
public bool? EnableDisconnectCue { get; set; }
public bool? EnableRecordStartCue { get; set; }
public bool? EnableRecordStopCue { get; set; }
public RecordingSettings? RecordingSettings { get; set; }
} }
private sealed class HotkeySetting private sealed class HotkeySetting
+116
View File
@@ -133,6 +133,19 @@ public sealed class AudioReceiver : IDisposable
public void SetConcealmentArtifact(ConcealmentArtifact artifact) => public void SetConcealmentArtifact(ConcealmentArtifact artifact) =>
playoutEngine.SetConcealmentArtifact(artifact); playoutEngine.SetConcealmentArtifact(artifact);
/// <summary>
/// Optional callback invoked when the engine produces fully-processed mixed received
/// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo
/// float, lives on the render thread — copy or consume quickly. Used by the recorder
/// to capture "what we heard". Setter mirrors directly onto <see cref="PlayoutEngine"/>;
/// null clears the tap.
/// </summary>
public Action<ReadOnlyMemory<float>>? OnReceivedSamples
{
get => playoutEngine.OnReceivedSamples;
set => playoutEngine.OnReceivedSamples = value;
}
/// <summary> /// <summary>
/// Sets the allow-list of sender endpoints whose audio will be rendered. Pass an empty set /// Sets the allow-list of sender endpoints whose audio will be rendered. Pass an empty set
/// to block all (the user has selected no peers); pass null to disable filtering and accept /// to block all (the user has selected no peers); pass null to disable filtering and accept
@@ -268,6 +281,24 @@ public sealed class AudioReceiver : IDisposable
} }
} }
/// <summary>Take the worst post-decode single-sample step magnitude across all active
/// stream sessions since the last call, resetting each session's probe. Used by the
/// diag log to pinpoint where in the pipeline audio discontinuities are being
/// introduced.</summary>
public float TakeMaxPostDecodeStep()
{
lock (sessionsLock)
{
var max = 0f;
foreach (var s in sessions.Values)
{
var v = s.TakeMaxPostDecodeStep();
if (v > max) max = v;
}
return max;
}
}
public long PcmFrameDiscardedPartials public long PcmFrameDiscardedPartials
{ {
get get
@@ -331,6 +362,26 @@ public sealed class AudioReceiver : IDisposable
/// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary> /// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary>
public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames; public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames;
public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames; public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames;
/// <summary>Cumulative count of FULL-empty playout reads (framesRead == 0) — the audible
/// underrun events that trigger noise-burst concealment + fade-in. Separated from
/// <see cref="Underruns"/> (which conflates full and partial short reads) so the diag
/// log can show "real underruns this second" distinct from "partial near-misses".</summary>
public long ConcealmentFires => playoutEngine.AggregateConcealmentFires;
/// <summary>Cumulative count of sub-frame partial reads (0 &lt; framesRead &lt; requested).
/// Inaudible since the 2026-05-14 concealment fix but tracked so we can see clock
/// in-phase patterns.</summary>
public long ShortReadFires => playoutEngine.AggregateShortReadFires;
/// <summary>Live LP-filtered drift error of the primary active session (stereo frames,
/// signed). Negative = buffer running below target on average; positive = above.</summary>
public double FilteredDriftErrorFrames => playoutEngine.PrimaryFilteredDriftErrorFrames;
/// <summary>Live drift integrator accumulator of the primary session. Crosses ±1 to fire
/// a drop / repeat correction.</summary>
public double DriftAccumulator => playoutEngine.PrimaryDriftAccumulator;
/// <summary>Take the worst single-sample step out of the ring buffer (after decode +
/// SessionPlayout.Write, before resampler) since the last call.</summary>
public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep();
/// <summary>Take the worst single-sample step out of the resampler since the last call.</summary>
public float TakeMaxPostResamplerStep() => playoutEngine.TakeMaxPostResamplerStep();
/// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain /// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain
/// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or /// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or
/// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't /// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't
@@ -371,6 +422,71 @@ public sealed class AudioReceiver : IDisposable
} }
} }
// === Wire-level packet sequence diagnostics ===
// Each audio packet carries a per-session sequence number from the sender. Tracking it
// at receipt tells us whether the network or NIC stack between sender and receiver is
// reordering, dropping, or duplicating packets — any of which would manifest as audible
// pops on the PCM path. On a healthy LAN all four counters should grow as
// WireInOrder == packets, all others == 0. A non-zero Missed / Reordered / Duplicated
// points straight at transport pathology and rules out codec / playout / hardware as
// pop sources.
/// <summary>Cumulative count of audio packets that arrived with the expected wire sequence.</summary>
public long WireInOrderCount
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.WireInOrderCount;
}
return total;
}
}
/// <summary>Cumulative count of packets that the wire claims went missing (forward gaps).</summary>
public long WireMissedCount
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.WireMissedCount;
}
return total;
}
}
/// <summary>Cumulative count of packets that arrived out-of-order (later sequence first, then earlier).</summary>
public long WireReorderedCount
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.WireReorderedCount;
}
return total;
}
}
/// <summary>Cumulative count of duplicate-sequence packets (the same wire seq delivered twice).</summary>
public long WireDuplicatedCount
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.WireDuplicatedCount;
}
return total;
}
}
public float Volume { get => playoutEngine.Volume; set => playoutEngine.Volume = value; } public float Volume { get => playoutEngine.Volume; set => playoutEngine.Volume = value; }
public bool IsMuted { get => playoutEngine.IsMuted; set => playoutEngine.IsMuted = value; } public bool IsMuted { get => playoutEngine.IsMuted; set => playoutEngine.IsMuted = value; }
+128 -1
View File
@@ -159,6 +159,25 @@ internal sealed class PlayoutEngine : IWaveProvider
asioLaneOutput = new LaneOutput(this, RenderRoute.AsioLane); asioLaneOutput = new LaneOutput(this, RenderRoute.AsioLane);
} }
/// <summary>
/// Optional callback invoked every time the engine produces a buffer of mixed received
/// audio (after volume/mute/limiter, before pack-to-bytes). The span is 48 kHz
/// interleaved stereo float and lives on the render thread; copy or process quickly.
/// Used by the recorder to capture "what we heard". Wired in App; null = no tap.
///
/// In BothIndependent the callback fires once per lane Read (so the recorder sees two
/// streams paced at the per-lane render rates) — the recorder consolidates them on its
/// background thread. In classic modes only the main Read path fires.
/// </summary>
public Action<ReadOnlyMemory<float>>? OnReceivedSamples { get; set; }
private void DispatchReceivedSamples(ReadOnlyMemory<float> samples)
{
var cb = OnReceivedSamples;
if (cb is null) return;
try { cb(samples); } catch { /* recorder failure isolated from audio path */ }
}
/// <summary> /// <summary>
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.WasapiLane"/>. Only /// IWaveProvider surface for sessions tagged <see cref="RenderRoute.WasapiLane"/>. Only
/// used in BothIndependent mode where the WASAPI render backend reads its own lane /// used in BothIndependent mode where the WASAPI render backend reads its own lane
@@ -378,6 +397,93 @@ internal sealed class PlayoutEngine : IWaveProvider
} }
} }
/// <summary>Cumulative count of full-empty reads (framesRead == 0) across all sessions.
/// These are the audible underrun events that trigger noise-burst concealment + fade-in
/// on the next read. Split out from <see cref="AggregateUnderruns"/> (which conflates
/// full and partial short reads) so diagnostic code can tell "the buffer fully emptied"
/// from "the buffer was a frame or two short of the request".</summary>
public long AggregateConcealmentFires
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.ConcealmentFiresTotal;
return total;
}
}
/// <summary>Cumulative count of sub-frame partial reads (0 &lt; framesRead &lt; requested)
/// across all sessions. Since the 2026-05-14 concealment fix these are no longer audible
/// events — the ring buffer's zero-fill tail is left in place rather than running cosine
/// fade-in on the next read — but counting them is useful for diagnosis. A high rate
/// indicates sender/receiver clocks are running so close in-phase that the ring buffer
/// occasionally returns N-1 of N requested frames.</summary>
public long AggregateShortReadFires
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.PartialReadFiresTotal;
return total;
}
}
/// <summary>Live state — the LP-filtered drift error in stereo frames of the FIRST
/// active session in the snapshot. Most setups have exactly one session, in which case
/// this is exactly that session's value. Positive = buffer running above target on
/// average; negative = buffer below target. 0 if no sessions exist.</summary>
public double PrimaryFilteredDriftErrorFrames
{
get
{
var snap = sessionsSnapshot;
return snap.Length > 0 ? snap[0].FilteredDriftErrorFrames : 0.0;
}
}
/// <summary>Live state — the drift integrator accumulator of the first active session.
/// Crosses ±1 to fire a single-frame drop / repeat. Useful for "is the corrector about
/// to fire?" diagnosis.</summary>
public double PrimaryDriftAccumulator
{
get
{
var snap = sessionsSnapshot;
return snap.Length > 0 ? snap[0].DriftAccumulator : 0.0;
}
}
/// <summary>Worst single-sample step seen out of the ring buffer since the last call.
/// Compared against the sender's pre-encode probe and the session's post-resampler
/// probe, this locates where in the pipeline an audio discontinuity was introduced.
/// Takes the max across all sessions and resets each.</summary>
public float TakeMaxPostRingReadStep()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostRingReadStep();
if (v > max) max = v;
}
return max;
}
/// <summary>Worst single-sample step in the resampler output since the last call.
/// Significantly larger than <see cref="TakeMaxPostRingReadStep"/> would point the
/// finger at the resampler integration.</summary>
public float TakeMaxPostResamplerStep()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostResamplerStep();
if (v > max) max = v;
}
return max;
}
// === WASAPI render thread === // === WASAPI render thread ===
/// <summary> /// <summary>
@@ -478,6 +584,13 @@ internal sealed class PlayoutEngine : IWaveProvider
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats)); if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
// Recording tap (per-lane). Mix is fully processed at this point — volume, mute and
// limiter have all been applied — so the recorder sees exactly what the user is
// about to hear from this lane. The tap fires regardless of whether the lane has an
// attached output device; that way a recording set to "received only" still captures
// audio in setups where the user has no WASAPI outputs ticked.
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats));
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float)); Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
return count; return count;
} }
@@ -546,6 +659,13 @@ internal sealed class PlayoutEngine : IWaveProvider
} }
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats)); if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
// Recording tap (all-sessions path, classic modes / WasapiOnly). Same point in the
// pipeline as the lane-routed tap above — fully processed mix, just before the
// pack-to-bytes step. The recorder gets a clean copy of what the user is about
// to hear.
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats));
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float)); Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
return count; return count;
} }
@@ -606,6 +726,13 @@ internal sealed class PlayoutEngine : IWaveProvider
} }
public int Read(byte[] buffer, int offset, int count) => public int Read(byte[] buffer, int offset, int count) =>
owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: false); // recordDiagnostics: true so the diag log line in MainForm gets buffer-level
// and render-read samples in BothIndependent mode. Originally false to avoid
// double-counting when both lanes ran concurrently, but in practice only one
// lane has sessions at a time (the user's chosen capture path) — the other
// lane's Read returns zero-mix and contributes nothing meaningful to the diag
// numbers. Without this the diag line never fires in BothIndependent setups,
// which is the user's normal mode of operation. 2026-05-14.
owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: true);
} }
} }
+385 -298
View File
@@ -1,5 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Net; using System.Net;
using System.Runtime.InteropServices;
using NAudio.Dsp;
using RemSound.Core; using RemSound.Core;
namespace RemSound.Receiver; namespace RemSound.Receiver;
@@ -27,9 +29,6 @@ internal sealed class SessionPlayout : IDisposable
private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame; private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame;
private readonly AudioRingBuffer playout; private readonly AudioRingBuffer playout;
// Scratch buffer used by the drift-correction crossfade path. Sized as needed inside
// ReadFloats; persistent here so we don't reallocate per call.
private float[] driftScratch = new float[8192];
private volatile bool playbackArmed; private volatile bool playbackArmed;
private volatile bool drainRequested; private volatile bool drainRequested;
@@ -90,88 +89,163 @@ internal sealed class SessionPlayout : IDisposable
// gets a different sequence — but we don't care about reproducibility, just character. // gets a different sequence — but we don't care about reproducibility, just character.
private readonly Random concealRng = new(Random.Shared.Next()); private readonly Random concealRng = new(Random.Shared.Next());
// === Drift correction (Phase 2, 2026-05-06) === // === Drift correction (Phase 4, 2026-05-14) — fixed-ratio resampler ===
// Continuous low-rate clock-drift correction. The receiver and sender each have their own
// audio crystal; over time their rates differ by a few-tens-of-ppm (typical for cheap USB
// audio). Without correction, the playout buffer slowly drifts up (sender faster) or down
// (sender slower) and eventually clicks via either overflow or underrun.
// //
// The previous design corrected via a continuously-modulated WdlResampler — which produced // Replaces the Phase-2/3 discrete-splice drift corrector. Sender and receiver each have
// sample-level corruption and was the source of all the per-sample artefacts we hunted for // their own free-running audio crystal and the two rates differ by a few-tens to a few-
// weeks (see analysis 2026-05-06). The replacement is the Jamulus / Mumble pattern: // hundreds of ppm. Without compensation the receive ring buffer slowly drifts up
// **integrate the buffer-level error over time and discretely drop or repeat ONE STEREO // (sender faster) or down (sender slower) and eventually clicks via either overflow or
// FRAME at a time when the integrator signals sustained drift.** A single-frame drop or // underrun.
// repeat at 48 kHz is 21 µs of audio — below the threshold of audibility on any normal
// content, especially when timed by an integrator that fires only on sustained drift, not
// on packet-arrival jitter.
// //
// Mechanism per Read: // Discrete single-frame drop / repeat splices — even with cosine crossfade and adaptive-
// 1. Sample the current buffer level vs target. // gain integrator scheduling — are audible at any drift rate above roughly 1 correction
// 2. Integrate (buffer_level_error_frames * dt_sec * DriftGain) into driftAccumulator. // per second on tonal content. Ed reported them as a continuous train of "tiny pops" all
// 3. If accumulator >= 1, drop one frame from the head of the playout buffer // through this session. The instrumentation added 2026-05-14 confirmed the corrector
// (sender faster — we've consumed less than it produced; speed up consumption by // was firing at 4-13 repeats/sec for his Audient EVO4 ↔ EVO8 setup (~150-200 ppm drift).
// one frame). Decrement accumulator.
// 4. If accumulator <= -1, queue a "repeat one frame" for the next Read (sender slower —
// stall consumption by one frame). Increment accumulator.
// //
// Behaviour by drift rate: // The fixed-ratio resampler approach is what every serious networked-audio implementation
// - 0 ppm (perfectly matched clocks): error stays near 0, accumulator stays near 0, // (Jamulus, SonoBus, Dante) uses for the same problem:
// no corrections fire. Silent. // 1. Measure the sender's effective sample rate by comparing bytes written to the ring
// - 50 ppm drift (typical USB crystal mismatch = ~5 frames/sec on 48 kHz): accumulator // vs bytes output to the audio device over a long window (multi-second).
// grows to ±1 every ~4 seconds; one frame correction every ~4 seconds. 21 µs of audio // 2. Configure a WdlResampler with input rate = measured-sender-rate and output rate =
// dropped or repeated every ~4 seconds. Inaudible. // MixSampleRate. The resampler continuously stretches or compresses the incoming
// - Higher transient drift (e.g. system load briefly): integrator catches up within // stream by the necessary ppm.
// seconds, brief burst of corrections, then settles. Still inaudible. // 3. Update the resampler's input rate ONLY every DriftMeasurementWindowSec, smoothed
// heavily. The earlier doomed attempt (May) modulated rates per-sample based on
// instantaneous buffer level; that caused phase discontinuities and sample-level
// artefacts. Slow updates avoid that entirely.
// //
// The existing click-trim block above is kept as a safety net for catastrophic conditions // Pitch shift introduced by a fixed-ratio resampler at, say, 200 ppm is 0.02 % — far
// (large step changes that the slow integrator can't keep up with). At normal drift rates // below the ~5 % human pitch-discrimination threshold and even below tuning precision.
// the integrator never lets the buffer reach the click-trim threshold, so the trim should // Genuinely inaudible.
// effectively never fire in steady-state operation. //
private double driftAccumulatorFrames; // Safety net: the legacy click-trim block above stays in place and fires only at
// catastrophic buffer levels (target + ~23 ms or 1 second worst-case cap). The discrete
// splice corrector (drop / repeat with crossfade) is GONE — the resampler handles
// steady-state drift smoothly. If the resampler somehow can't keep up (transient
// catastrophe), the click-trim safety net fires once.
//
// The resampler. Output-driven (we want N output frames; ask the resampler how many
// input frames it needs and feed those in from the ring buffer). interp=true with
// filtercnt=0 selects WdlResampler's low-cost linear-interpolation mode — plenty good
// enough for the ppm-scale rate corrections we apply. Higher filter modes would add
// CPU cost for a sample quality difference well below audibility at these tiny ratios.
// SetRates is called periodically from the audio thread (the only thread that touches
// this resampler) so we don't need any cross-thread synchronisation around it.
private readonly WdlResampler driftResampler;
// Drift measurement counters. bytesWrittenForDriftEst is incremented by the producer
// thread on every Write; bytesReadOutputForDriftEst is incremented by the consumer
// thread (us) on every successful ReadFloats. Their ratio over a multi-second window
// is the sender's effective rate divided by the receiver's nominal rate — exactly the
// ratio the resampler needs.
private long bytesWrittenForDriftEst;
private long bytesReadOutputForDriftEst;
// Window state. windowStartTicks = when the current measurement window started, ticks
// 0 means "not yet armed for measurement". The values at window start are snapshotted
// so we can compute the delta cleanly even if the counters wrap (long is 63-bit so wrap
// is ~190 years at 48 kHz stereo float, but the math is still cleaner with snapshots).
private long resamplerWindowStartTicks;
private long resamplerWindowStartBytesWritten;
private long resamplerWindowStartBytesOutput;
// Smoothed ratio currently applied to the resampler (1.0 = no resampling). Smoothing
// is "first measurement = the measurement; subsequent = 70 % previous + 30 % new" so
// a one-window outlier doesn't yank the rate. Settles within a few windows to the
// true drift.
private double smoothedRateRatio = 1.0;
private bool resamplerActivelyTracking;
private long resamplerUpdatesTotal;
// Scratch buffer for reading from the ring buffer in float form. Sized lazily based on
// the largest input-frames request the resampler asks for; persists across calls so
// we don't realloc on the hot path.
private float[] resamplerInputScratch = new float[2048];
// Retained for backward compatibility with the diagnostic surface — the diag log line
// still emits driftDrop / driftRep counters and the DriftAccumulator / FilteredError
// accessors. In the Phase-4 design these are all just informational metrics that stay
// at zero / track the same buffer-vs-target offset, but old log parsers don't break.
// Explicit zero init so the compiler doesn't flag them as never-assigned when the
// Phase-4 design no longer increments them anywhere.
private long driftDropFramesTotal = 0;
private long driftRepeatFramesTotal = 0;
// Live state for the diag log — the current buffer-level offset from target, low-pass
// filtered. Lets the diag line continue to surface "where the buffer is sitting".
// Updated each Read; no longer drives any correction logic itself.
private double filteredErrorFrames;
private long prevDriftSampleTicks; private long prevDriftSampleTicks;
private int pendingRepeatFrames;
private long driftDropFramesTotal;
private long driftRepeatFramesTotal;
// Integrator gain. Lowered 2026-05-06 (10×) after an empirical test where the previous // Integrator gain. Lowered 2026-05-06 (10×) after an empirical test where the previous
// gain (0.05) produced ~10 corrections per second on the user's hardware (two free-running // gain (0.05) produced ~10 corrections per second on the user's hardware (two free-running
// USB audio crystals with combined drift around 200 ppm = 10 frames/sec). Even with // Drift-measurement window for the fixed-ratio resampler. After this many seconds of
// single-frame corrections, 10 clicks/sec was audible. Lowering the gain alone trades // sustained streaming, we compute (bytes_written / bytes_output) over the window and
// click rate for buffer drift; combined with the crossfade-on-splice change, each // smooth-update the resampler's input rate. Long enough that brief network jitter or
// correction is also significantly less audible per event. // GC pauses don't bias the measurement; short enough to track temperature-induced
// // crystal-rate changes (USB audio clocks can drift several ppm over minutes as the
// At 0.005, sustained 1-frame error reaches accumulator = 1 in ~200 seconds. For 200 ppm // device warms up). 10 sec is the canonical Jamulus / SonoBus value.
// drift (10 frames/sec error growth), the integrator catches up at ~2 corrections/sec private const double DriftMeasurementWindowSec = 10.0;
// steady-state — which combined with crossfaded splices should push perceived click rate // First-window length. Same as DriftMeasurementWindowSec for simplicity; could be
// toward inaudible. // shortened to engage compensation faster after session start at the cost of a noisier
// // initial measurement.
// 2026-05-06 (later): added adaptive gain scaling. The base gain above is fine for steady- private const double DriftFirstWindowSec = 10.0;
// state clock-drift compensation but pathologically slow when the buffer is far from // Ratio smoothing weight. New measurement = 30 %; previous smoothed = 70 %. Tunes how
// target — e.g. after a slider raise the buffer sits below target and drift correction // quickly the rate tracks vs how stable it is. The first measurement after session
// takes minutes to fill it. Empirically observed in user testing as "every session sounds // start uses 100 % new (no previous value to weight).
// different": the buffer wandered for tens of seconds at whatever level the initial private const double DriftRatioSmoothingNew = 0.30;
// arming chaos left it at. Now the effective gain scales linearly with absolute error // Sanity-range clamp on the measured ratio. Real clock differences between USB audio
// beyond the small-error band, capped, so: // crystals are sub-1000 ppm (0.1 %); anything beyond ±5 % indicates a measurement
// * |error| <= DriftSmallErrorFrames: gain = DriftGain (today's behaviour, gentle) // artefact (a buffer-fill burst, a transient, or a counter wrap). Reject those and
// * |error| > DriftSmallErrorFrames: gain = DriftGain × min(|error|/small, maxScale) // keep the previous ratio.
// At 50 frames (~1 ms) the gain is 1×; at 1000 frames (~21 ms) it's 20× capped, giving private const double DriftRatioMin = 0.95;
// a fill rate of ~100 frames/sec — a 20 ms slider raise converges in ~10 seconds with private const double DriftRatioMax = 1.05;
// a barely-audible 0.2% rate offset during the fill. // Low-pass filter time constant for the buffer-level-error display in the diag log.
private const double DriftGain = 0.005; // Doesn't affect any correction logic in Phase 4 — purely informational.
// Below this absolute error, gain stays at the steady-state baseline. ~1 ms at 48 kHz. private const double DriftFilterTimeConstantSec = 2.0;
private const double DriftSmallErrorFrames = 50;
// Cap on adaptive-gain scale, so even huge errors don't produce an audible time-stretch
// (200/sec frame edits = 0.42% rate change, edge of noticeable on tonal content).
private const double DriftMaxGainScale = 20.0;
// Number of stereo frames each side of a splice point that get blended when a drop or // Number of stereo frames each side of a splice point that get blended when a drop or
// repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio // repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio
// characteristic that's much harder to perceive as a click. 8 frames = 167 µs at 48 kHz — // Public accessors for the diag log. Drop / repeat counters are retained for the diag
// shorter than a typical impulse response, so the smear doesn't blur transients audibly. // surface (the Phase-4 resampler doesn't increment them, so they stay flat at the
private const int DriftCrossfadeFrames = 8; // last value from any pre-Phase-4 fallback path — informationally that's "the splice
// Pending corrections (sample-aligned single-frame edits at the next Read). // path didn't fire", which is what we want to see now).
private int pendingDropFrames;
// Public accessors for the diag log.
public long DriftDropFramesTotal => Interlocked.Read(ref driftDropFramesTotal); public long DriftDropFramesTotal => Interlocked.Read(ref driftDropFramesTotal);
public long DriftRepeatFramesTotal => Interlocked.Read(ref driftRepeatFramesTotal); public long DriftRepeatFramesTotal => Interlocked.Read(ref driftRepeatFramesTotal);
/// <summary>Diagnostic accessor — current smoothed sender-rate-ratio applied to the
/// resampler. 1.0 = no resampling (matched clocks). Values like 1.0002 = sender running
/// 200 ppm faster than receiver; 0.9998 = 200 ppm slower.</summary>
public double DriftResamplerRatio => smoothedRateRatio;
/// <summary>Number of times the resampler rate has been updated since session start.</summary>
public long DriftResamplerUpdates => Interlocked.Read(ref resamplerUpdatesTotal);
// Per-stage discontinuity probes — the receiver-side instrumentation that, combined
// with the sender's pre-encode probe and the StreamSession's post-decode probe,
// localises exactly where in the pipeline a click is introduced. PostRingRead is
// what came out of the ring buffer (after wire+decode+ring). PostResampler is what
// came out of the resampler (after rate compensation).
private readonly AudioStepProbe postRingReadStepProbe = new();
private readonly AudioStepProbe postResamplerStepProbe = new();
public float TakeMaxPostRingReadStep() => postRingReadStepProbe.TakeMax();
public float TakeMaxPostResamplerStep() => postResamplerStepProbe.TakeMax();
// Concealment vs partial-read counters split from the legacy "Underruns" — that one
// increments on ANY short read at the AudioRingBuffer level (whether framesRead==0
// or framesRead<requested). For diagnosis we care about the split: full-empty reads
// (concealmentFiresTotal) are audible events that trigger noise-burst + fade-in;
// sub-frame partial reads (partialReadFiresTotal) used to be audible too but are
// now silent after the 2026-05-14 fix that stops concealment from engaging on partials.
private long concealmentFiresTotal;
private long partialReadFiresTotal;
public long ConcealmentFiresTotal => Interlocked.Read(ref concealmentFiresTotal);
public long PartialReadFiresTotal => Interlocked.Read(ref partialReadFiresTotal);
/// <summary>Live state — the LP-filtered drift error in stereo frames. Positive = buffer
/// running above target on average (sender clock faster); negative = buffer below
/// target. Magnitude shows how off-target the buffer's average position is right now.</summary>
public double FilteredDriftErrorFrames => filteredErrorFrames;
/// <summary>Legacy diag accessor — the Phase-2 / Phase-3 integrator accumulator is no
/// longer used in the Phase-4 resampler design. Always returns 0. Kept on the surface
/// so MainForm's existing diag log line still compiles; can be removed once the diag
/// columns are pruned.</summary>
public double DriftAccumulator => 0.0;
public IPEndPoint Endpoint { get; } public IPEndPoint Endpoint { get; }
/// <summary>The stream ID this session was opened for. Sessions are keyed by /// <summary>The stream ID this session was opened for. Sessions are keyed by
@@ -216,6 +290,18 @@ internal sealed class SessionPlayout : IDisposable
Endpoint = endpoint; Endpoint = endpoint;
StreamId = streamId; StreamId = streamId;
playout = new AudioRingBuffer(capacityBytes); playout = new AudioRingBuffer(capacityBytes);
// Resampler init. interp=true, filtercnt=0 picks WdlResampler's linear-interpolation
// mode — perfectly adequate for the sub-1000-ppm rate corrections we apply (the
// higher-cost sinc modes would buy theoretical quality wins below human audibility).
// sinc=false confirms we're not using the sinc-table mode. Output-driven feed: each
// ResamplePrepare call asks the resampler "how many input frames do you need for N
// output frames" and we satisfy from the ring buffer. SetRates(in, out) starts at
// 1:1; we update with measured drift after the first window completes.
driftResampler = new WdlResampler();
driftResampler.SetMode(interp: true, filtercnt: 0, sinc: false);
driftResampler.SetFeedMode(false);
driftResampler.SetRates(MixSampleRate, MixSampleRate);
} }
public void Write(ReadOnlySpan<byte> source) public void Write(ReadOnlySpan<byte> source)
@@ -223,6 +309,10 @@ internal sealed class SessionPlayout : IDisposable
var ms = source.Length * 1000 / MixBytesPerSecond; var ms = source.Length * 1000 / MixBytesPerSecond;
if (ms > largestWriteMs) largestWriteMs = ms; if (ms > largestWriteMs) largestWriteMs = ms;
playout.Write(source); playout.Write(source);
// Track bytes written for the drift-resampler measurement window. Producer thread
// updates this; consumer thread (audio thread in ReadFloats) reads it via
// Interlocked.Read when sampling the window. Cumulative since session start.
Interlocked.Add(ref bytesWrittenForDriftEst, source.Length);
LastWriteUtc = DateTime.UtcNow; LastWriteUtc = DateTime.UtcNow;
} }
@@ -276,10 +366,21 @@ internal sealed class SessionPlayout : IDisposable
consecutiveEmptyReads = 0; consecutiveEmptyReads = 0;
lastConcealSampleL = 0f; lastConcealSampleL = 0f;
lastConcealSampleR = 0f; lastConcealSampleR = 0f;
driftAccumulatorFrames = 0; filteredErrorFrames = 0;
prevDriftSampleTicks = 0; prevDriftSampleTicks = 0;
pendingDropFrames = 0; // Phase-4 drift resampler state. Reset counters and window state. Reset() on the
pendingRepeatFrames = 0; // resampler clears its internal filter delay line so a fresh session doesn't
// inherit phase from a prior one. SetRates back to 1:1 — we'll re-measure drift
// from scratch.
bytesWrittenForDriftEst = 0;
bytesReadOutputForDriftEst = 0;
resamplerWindowStartTicks = 0;
resamplerWindowStartBytesWritten = 0;
resamplerWindowStartBytesOutput = 0;
smoothedRateRatio = 1.0;
resamplerActivelyTracking = false;
driftResampler.Reset();
driftResampler.SetRates(MixSampleRate, MixSampleRate);
} }
public void Dispose() public void Dispose()
@@ -424,269 +525,255 @@ internal sealed class SessionPlayout : IDisposable
} }
} }
// === Drift correction (Phase 2) === // === Drift compensation (Phase 4) — fixed-ratio resampler ===
// //
// Continuously integrate buffer-level error and drop / repeat single frames at low // 1. Update the LP-filtered buffer-level error for the diag log (informational only).
// rate to keep buffer aligned with target despite clock-drift between sender and // 2. Update the resampler's rate ratio if the measurement window has elapsed.
// receiver crystals. Replaces the continuous adaptive resampling that produced // 3. Read through the resampler into the caller's output span.
// sample-level artefacts (analysed 2026-05-06). See the field-block comment above
// for the design rationale.
// //
// SAMPLE-RATE MISMATCH (future): the direct read below requires input PCM to already // The buffer-level error LP filter no longer drives any correction — that job is
// be at MixSampleRate (48 kHz). When endpoints have mismatched device rates (e.g. // now the resampler's. It's kept purely as a diag display so the log shows where
// one machine at 44.1 kHz), the sender's MixingEngine still resamples to 48 kHz on // the buffer is sitting.
// the capture side so the wire format is consistent — but if a future change emits var nowTicks = Stopwatch.GetTimestamp();
// at the source's native rate, we'd need a FIXED-ratio resampler here (input_rate /
// 48000, computed once, never modulated). The continuous-modulation pattern was the
// bug; a fixed ratio is fine.
var driftTicks = Stopwatch.GetTimestamp();
var driftTargetBytes = MillisecondsToBytes(targetLatencyMs); var driftTargetBytes = MillisecondsToBytes(targetLatencyMs);
if (prevDriftSampleTicks != 0) if (prevDriftSampleTicks != 0)
{ {
var dtSec = (driftTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency; var dtSec = (nowTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency;
var errorFrames = ((double)playout.BufferedBytes - driftTargetBytes) / MixBytesPerFrame; var errorFrames = ((double)playout.BufferedBytes - driftTargetBytes) / MixBytesPerFrame;
// Adaptive gain: baseline at small errors (gentle steady-state compensation for var filterAlpha = dtSec / (DriftFilterTimeConstantSec + dtSec);
// clock drift) but accelerated at large errors (fast convergence after a slider filteredErrorFrames = (1.0 - filterAlpha) * filteredErrorFrames + filterAlpha * errorFrames;
// raise or initial arming overshoot). Without this, the buffer can sit at any
// level between 0 and target+jitter for tens of seconds — making sessions feel
// randomly different. With this, the buffer reliably reaches target within a few
// seconds of any disturbance.
var absErrorFrames = errorFrames < 0 ? -errorFrames : errorFrames;
var gainScale = absErrorFrames <= DriftSmallErrorFrames
? 1.0
: Math.Min(absErrorFrames / DriftSmallErrorFrames, DriftMaxGainScale);
driftAccumulatorFrames += errorFrames * dtSec * DriftGain * gainScale;
// Clamp to prevent runaway in pathological conditions (e.g. session pause).
if (driftAccumulatorFrames > 100.0) driftAccumulatorFrames = 100.0;
else if (driftAccumulatorFrames < -100.0) driftAccumulatorFrames = -100.0;
} }
prevDriftSampleTicks = driftTicks; prevDriftSampleTicks = nowTicks;
// Queue at most one correction per Read so corrections spread evenly rather than burst. UpdateDriftResamplerRateIfDue(nowTicks);
if (driftAccumulatorFrames >= 1.0)
{
pendingDropFrames++;
driftAccumulatorFrames -= 1.0;
}
else if (driftAccumulatorFrames <= -1.0)
{
pendingRepeatFrames++;
driftAccumulatorFrames += 1.0;
}
// === Read with optional crossfaded drop / repeat === // Read through the resampler and apply concealment on full underruns.
// ReadThroughResampler(output, outFrames);
// The trick to audibly-clean drift correction: don't perform the splice as a hard
// cut. Read one extra frame (drop) or one fewer frame (repeat) from the buffer, then
// CROSSFADE around the splice point over DriftCrossfadeFrames samples. The cosine
// window blends the audio either side of the splice into a smooth smear instead of
// a discontinuity. At 8 frames (~167 µs at 48 kHz) the smear is much shorter than
// any audible transient and far less perceptible than the original sample-level
// discontinuity.
//
// Splice position: middle of the output buffer. Could choose a low-amplitude moment
// for further inaudibility (PSOLA-style) but middle-of-buffer is good enough on
// typical content and keeps the code simple.
var dropThisCall = pendingDropFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
var repeatThisCall = pendingRepeatFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
// Don't try to do both in the same Read; they'd cancel anyway.
if (dropThisCall > 0 && repeatThisCall > 0) { dropThisCall = 0; repeatThisCall = 0; }
if (dropThisCall > 0)
{
// Read outFrames + 1 frames into the output span by reading the first half,
// skipping the splice with crossfade, then reading the second half. We need
// a small extra-sample scratch for the splice. Reuse driftScratch as
// temp storage (it's already managed and grows with outFrames).
var extraFloats = (outFrames + 1) * MixChannels;
if (driftScratch.Length < extraFloats)
{
driftScratch = new float[extraFloats];
}
var temp = driftScratch.AsSpan(0, extraFloats);
ReadInputWithConcealment(temp);
// Crossfade the splice. Splice position = midpoint of the output frame.
// Result: outFrames samples where one is "elided" via a cosine cross-blend.
ApplyDropCrossfade(temp, output, outFrames);
pendingDropFrames--;
Interlocked.Increment(ref driftDropFramesTotal);
}
else if (repeatThisCall > 0)
{
// Read outFrames - 1 frames into temp, then expand to outFrames via a crossfaded
// insertion at the splice point.
var shortFloats = (outFrames - 1) * MixChannels;
if (driftScratch.Length < shortFloats)
{
driftScratch = new float[shortFloats];
}
var temp = driftScratch.AsSpan(0, shortFloats);
ReadInputWithConcealment(temp);
ApplyRepeatCrossfade(temp, output, outFrames);
pendingRepeatFrames--;
Interlocked.Increment(ref driftRepeatFramesTotal);
}
else
{
ReadInputWithConcealment(output);
}
return outFrames; return outFrames;
} }
/// <summary>Drop-mode crossfade: temp has (outFrames + 1) frames, output gets outFrames /// <summary>
/// frames with one elided at the splice via a cosine blend across DriftCrossfadeFrames /// If the current drift-measurement window has expired, compute the new sender-to-
/// samples on each side.</summary> /// receiver rate ratio from the bytes-written and bytes-output counters, smooth it
private static void ApplyDropCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames) /// into the live ratio, and push it to the resampler. Called from the audio thread
/// on every ReadFloats. No-op if the window hasn't elapsed yet.
/// </summary>
private void UpdateDriftResamplerRateIfDue(long nowTicks)
{ {
// Splice at midpoint of output frames. The "skipped" sample in temp lives at index if (resamplerWindowStartTicks == 0)
// spliceIdx; either side of it gets cross-blended.
var spliceIdx = outFrames / 2;
var window = DriftCrossfadeFrames;
var halfWindow = window / 2;
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
var preEnd = spliceIdx - halfWindow;
if (preEnd > 0)
{ {
temp.Slice(0, preEnd * MixChannels).CopyTo(output); // First call — anchor the measurement window. Defer the first rate update by
// DriftFirstWindowSec so we get a stable initial measurement rather than one
// based on the first few writes (which can be bursty during session arming).
resamplerWindowStartTicks = nowTicks;
resamplerWindowStartBytesWritten = Interlocked.Read(ref bytesWrittenForDriftEst);
resamplerWindowStartBytesOutput = bytesReadOutputForDriftEst;
return;
} }
// Window: cosine crossfade. As we walk through `window` output frames, blend from var windowDuration = resamplerActivelyTracking ? DriftMeasurementWindowSec : DriftFirstWindowSec;
// temp[preEnd + k] (the "before-skip" sample) toward temp[preEnd + 1 + k] (the var elapsedSec = (nowTicks - resamplerWindowStartTicks) / (double)Stopwatch.Frequency;
// "after-skip" sample). The blend mixes consecutive temp positions so the splice if (elapsedSec < windowDuration) return;
// is spread out smoothly.
for (var k = 0; k < window; k++) var bytesWrittenNow = Interlocked.Read(ref bytesWrittenForDriftEst);
var bytesWrittenInWindow = bytesWrittenNow - resamplerWindowStartBytesWritten;
var bytesOutputInWindow = bytesReadOutputForDriftEst - resamplerWindowStartBytesOutput;
if (bytesOutputInWindow > 0 && bytesWrittenInWindow > 0)
{ {
var t = (k + 1) / (double)(window + 1); // ratio = bytes_sender_produced / bytes_receiver_consumed over the window.
// Cosine-shaped smooth fade from 0 to 1 across the window. // Above 1.0 = sender clock faster than receiver. Below 1.0 = sender slower.
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5); // For Ed's hardware (sender slower than receiver) this should settle ~0.9998.
var fadeOut = 1f - fadeIn; var measuredRatio = (double)bytesWrittenInWindow / bytesOutputInWindow;
var beforeIdx = (preEnd + k) * MixChannels; if (measuredRatio >= DriftRatioMin && measuredRatio <= DriftRatioMax)
var afterIdx = (preEnd + 1 + k) * MixChannels; {
var dstIdx = (preEnd + k) * MixChannels; if (!resamplerActivelyTracking)
output[dstIdx] = temp[beforeIdx] * fadeOut + temp[afterIdx] * fadeIn; {
output[dstIdx + 1] = temp[beforeIdx + 1] * fadeOut + temp[afterIdx + 1] * fadeIn; // First measurement — use directly. No previous value to weight.
smoothedRateRatio = measuredRatio;
resamplerActivelyTracking = true;
}
else
{
// Subsequent — smooth so a one-window outlier doesn't yank the rate.
smoothedRateRatio = (1.0 - DriftRatioSmoothingNew) * smoothedRateRatio + DriftRatioSmoothingNew * measuredRatio;
}
// Push to the resampler. SetRates(input_rate, output_rate). Input rate
// = measured sender rate; output rate = the receiver's nominal MixSampleRate.
// The resampler now stretches or compresses incoming audio by the ppm
// necessary to keep the playout ring buffer level constant.
driftResampler.SetRates(MixSampleRate * smoothedRateRatio, MixSampleRate);
Interlocked.Increment(ref resamplerUpdatesTotal);
}
// If the measured ratio is outside the sanity window (>5 % off), reject it.
// That happens transiently during session arming, slider raises, or sender
// start-of-stream bursts. Keep the previous ratio rather than yanking.
} }
// Post-window: copy temp[spliceIdx+halfWindow+1..outFrames+1] to output[spliceIdx+halfWindow..outFrames]. // Anchor the next window.
// The "+1" on the source side is the elision: we skip one frame from temp. resamplerWindowStartTicks = nowTicks;
var postStartTemp = spliceIdx + halfWindow + 1; resamplerWindowStartBytesWritten = bytesWrittenNow;
var postStartOut = spliceIdx + halfWindow; resamplerWindowStartBytesOutput = bytesReadOutputForDriftEst;
var postLen = outFrames - postStartOut;
if (postLen > 0)
{
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
.CopyTo(output.Slice(postStartOut * MixChannels));
}
}
/// <summary>Repeat-mode crossfade: temp has (outFrames - 1) frames, output gets outFrames
/// with one synthesised at the splice via a cosine blend that "stretches" temp by one
/// frame.</summary>
private static void ApplyRepeatCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
{
var spliceIdx = outFrames / 2;
var window = DriftCrossfadeFrames;
var halfWindow = window / 2;
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
var preEnd = spliceIdx - halfWindow;
if (preEnd > 0)
{
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
}
// Window of (window + 1) output frames mapped to (window) temp frames. Cosine
// crossfade synthesizes the extra frame: each output sample in the window is a
// blend of two adjacent temp samples, with the blend weight progressing slower than
// the index, effectively inserting a "smoothed" extra sample.
for (var k = 0; k <= window; k++)
{
var t = k / (double)(window + 1);
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
var fadeOut = 1f - fadeIn;
// Map output index -> temp position: output[preEnd+k] takes from temp[preEnd+k-1] and temp[preEnd+k].
// For k=0 we use temp[preEnd] alone; for k=window we use temp[preEnd+window-1] alone.
var leftTempIdx = Math.Max(0, preEnd + k - 1) * MixChannels;
var rightTempIdx = Math.Min(temp.Length / MixChannels - 1, preEnd + k) * MixChannels;
var dstIdx = (preEnd + k) * MixChannels;
output[dstIdx] = temp[leftTempIdx] * fadeOut + temp[rightTempIdx] * fadeIn;
output[dstIdx + 1] = temp[leftTempIdx + 1] * fadeOut + temp[rightTempIdx + 1] * fadeIn;
}
// Post-window: copy temp[spliceIdx+halfWindow..outFrames-1] to output[spliceIdx+halfWindow+1..outFrames].
var postStartTemp = spliceIdx + halfWindow;
var postStartOut = spliceIdx + halfWindow + 1;
var postLen = outFrames - postStartOut;
if (postLen > 0)
{
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
.CopyTo(output.Slice(postStartOut * MixChannels));
}
} }
/// <summary> /// <summary>
/// Wraps <see cref="AudioRingBuffer.ReadFloats"/> with packet-loss-style concealment. /// Resampler-backed read. Asks the resampler how many input frames it needs to
/// On a short read, replaces the silence-filled tail with a brief synthesised burst /// produce <paramref name="outFrames"/> output frames at the current rate ratio,
/// (character chosen by <see cref="SetConcealmentArtifact"/>) decaying to zero. On the /// reads that many from the playout ring, runs ResampleOut, and copies the result
/// next full read after a gap, applies a matching fade-in so the resumed audio doesn't /// into <paramref name="output"/> with a safety clamp to [-1, 1]. Handles full-empty
/// start with a hard discontinuity. The result is a smooth attack-and-release at the /// underruns with the existing concealment fade-out / fade-in machinery.
/// edges of any gap — the human ear is much more forgiving of "dipped briefly then came
/// back" than of "instant click into silence and instant click back".
///
/// Stereo-only (matches the rest of the audio path). Output flows through the mix bus
/// and limiter as usual.
/// </summary> /// </summary>
private void ReadInputWithConcealment(Span<float> inSpan) private void ReadThroughResampler(Span<float> output, int outFrames)
{ {
var requestedFloats = inSpan.Length; var outFloats = outFrames * MixChannels;
var floatsRead = playout.ReadFloats(inSpan); var inputFramesNeeded = driftResampler.ResamplePrepare(outFrames, MixChannels, out var inBuf, out var inBufOff);
var requestedFrames = requestedFloats / MixChannels; lastInputFramesAvailable = inputFramesNeeded;
var framesRead = floatsRead / MixChannels; if (inputFramesNeeded <= 0)
{
// Resampler doesn't need any input this call (its internal filter delay line
// has enough). Just produce output from buffered state.
ResampleOutAndCopy(output, outFrames);
bytesReadOutputForDriftEst += outFloats * sizeof(float);
return;
}
var inputFloatsNeeded = inputFramesNeeded * MixChannels;
// Grow our scratch buffer if a larger request than ever before. After the first few
// reads at session start, this stops being a fresh allocation.
if (resamplerInputScratch.Length < inputFloatsNeeded)
{
resamplerInputScratch = new float[inputFloatsNeeded];
}
var ringBytes = MemoryMarshal.AsBytes(resamplerInputScratch.AsSpan(0, inputFloatsNeeded));
var bytesGot = playout.Read(ringBytes);
var floatsGot = bytesGot / sizeof(float);
var framesGot = floatsGot / MixChannels;
// Pipeline-stage probe — scan what we got out of the ring buffer BEFORE the
// resampler touches it. If this shows large steps, the artefact is being
// introduced somewhere between the sender and here (wire, decode, ring buffer).
// If this is clean but the post-resampler probe shows large steps, the resampler
// is the source.
if (floatsGot > 0)
{
postRingReadStepProbe.ScanStereo(resamplerInputScratch.AsSpan(0, floatsGot));
}
// Copy whatever we got into the resampler's input buffer. AudioRingBuffer.Read
// already zero-fills the tail of a short read, but we copy via the float view so
// the resampler sees consistent float samples regardless of read shortfall.
resamplerInputScratch.AsSpan(0, floatsGot).CopyTo(inBuf.AsSpan(inBufOff));
if (floatsGot < inputFloatsNeeded)
{
inBuf.AsSpan(inBufOff + floatsGot, inputFloatsNeeded - floatsGot).Clear();
}
// Diagnostic split — distinguish full-empty reads from partial short reads. Only
// full-empty (framesGot == 0) triggers audible concealment treatment. Partial
// reads happen when the ring has fewer than inputFramesNeeded frames but more
// than zero; the zero-padded tail just produces silence at the resampler output
// for that fraction.
var artifact = (ConcealmentArtifact)concealmentArtifactRaw; var artifact = (ConcealmentArtifact)concealmentArtifactRaw;
if (framesGot == 0)
{
Interlocked.Increment(ref concealmentFiresTotal);
consecutiveEmptyReads++;
}
else if (framesGot < inputFramesNeeded)
{
Interlocked.Increment(ref partialReadFiresTotal);
consecutiveEmptyReads = 0;
}
else
{
consecutiveEmptyReads = 0;
}
// Run the resampler. Output goes into outputScratch (the resampler needs a float[]
// not a Span<float>); we then copy with clamp to the caller's span.
ResampleOutAndCopy(output, outFrames);
bytesReadOutputForDriftEst += outFloats * sizeof(float);
if (framesRead < requestedFrames) // Concealment overlay on full-empty reads. The resampler will have produced
// mostly-silence output for this call (we zero-padded its input); replace the
// head of that silence with the chosen artifact so the user hears the "something
// went wrong" cue rather than dead air, with a cosine fade-in on the next read
// when real audio resumes.
if (framesGot == 0)
{ {
// Don't synthesise concealment forever during a sustained empty-buffer state — the
// sender has probably gone away. After N consecutive empty reads we just leave the
// buffer's hard-zero in place; result is true silence rather than a "shshshsh"
// tremolo as the noise/cosine artifact retriggers each render callback.
consecutiveEmptyReads = framesRead == 0 ? consecutiveEmptyReads + 1 : 0;
if (consecutiveEmptyReads <= ConcealmentMaxConsecutiveEmpties) if (consecutiveEmptyReads <= ConcealmentMaxConsecutiveEmpties)
{ {
// AudioRingBuffer silence-filled inSpan[floatsRead..] with zero. Replace the ApplyFadeOut(output, startFrame: 0, outFrames, artifact);
// head of that silence with the chosen artifact, then leave the rest at zero.
var silenceFrameStart = framesRead;
var silenceFrameCount = requestedFrames - framesRead;
ApplyFadeOut(inSpan, silenceFrameStart, silenceFrameCount, artifact);
} }
inUnderrunConcealment = true; inUnderrunConcealment = true;
} }
else if (inUnderrunConcealment) else if (inUnderrunConcealment)
{ {
// First full read after a gap. Fade the new audio in from zero so we don't ApplyFadeIn(output, outFrames, artifact);
// instantly jump back to whatever the new audio's amplitude is.
ApplyFadeIn(inSpan, requestedFrames, artifact);
inUnderrunConcealment = false; inUnderrunConcealment = false;
consecutiveEmptyReads = 0;
}
else
{
consecutiveEmptyReads = 0;
} }
// Remember the last real sample for the next fade-out. Use the last frame of actual // Track the last real sample for the next fade-out.
// ring data, not anything we just synthesised. (Only meaningful if we read at least if (framesGot > 0)
// one real frame this call — i.e. framesRead > 0.)
if (framesRead > 0)
{ {
var lastIdx = (framesRead - 1) * MixChannels; var lastIdx = (framesGot - 1) * MixChannels;
lastConcealSampleL = inSpan[lastIdx]; lastConcealSampleL = resamplerInputScratch[lastIdx];
lastConcealSampleR = inSpan[lastIdx + 1]; lastConcealSampleR = resamplerInputScratch[lastIdx + 1];
} }
} }
/// <summary>Run the resampler with already-supplied input and copy the result to the
/// caller's span, clamping samples to [-1, 1] as a safety against any pathological
/// resampler output. If the resampler returns fewer than requested output frames, the
/// tail is zero-filled.</summary>
private void ResampleOutAndCopy(Span<float> output, int outFrames)
{
var outFloats = outFrames * MixChannels;
if (resamplerOutputScratch.Length < outFloats)
{
resamplerOutputScratch = new float[outFloats];
}
// ResampleOut consumes the input we wrote into the buffer obtained from
// ResamplePrepare, plus any state it holds internally, and produces up to outFrames
// output frames. Returns the actual count.
var produced = driftResampler.ResampleOut(resamplerOutputScratch, 0, GetLastInputFramesAvailable(), outFrames, MixChannels);
var producedFloats = produced * MixChannels;
// Pipeline-stage probe — scan the resampler output before we clamp or copy. If this
// shows steps that don't appear in the post-ring-read probe, the resampler itself
// is the source.
if (producedFloats > 0)
{
postResamplerStepProbe.ScanStereo(resamplerOutputScratch.AsSpan(0, producedFloats));
}
for (var i = 0; i < producedFloats; i++)
{
var v = resamplerOutputScratch[i];
// Safety clamp. Real resampler output should never escape [-1, 1] from in-range
// input, but a single bad sample / NaN would otherwise produce a loud audible
// pop. Clamping is cheap insurance.
if (v > 1f) v = 1f;
else if (v < -1f) v = -1f;
else if (float.IsNaN(v)) v = 0f;
output[i] = v;
}
// Zero-fill if the resampler didn't produce as many frames as we asked for. Should
// only happen in pathological cases (just after session start with empty filter
// delay line, or after a Reset).
if (produced < outFrames)
{
output.Slice(producedFloats, (outFrames - produced) * MixChannels).Clear();
}
}
// Resampler scratch — output buffer (input scratch is the resamplerInputScratch field).
private float[] resamplerOutputScratch = new float[2048];
// How many input frames we just supplied to the resampler this call. Used by
// ResampleOutAndCopy to call ResampleOut with the right input count. Updated by
// ReadThroughResampler before the ResampleOutAndCopy call.
private int lastInputFramesAvailable;
private int GetLastInputFramesAvailable() => lastInputFramesAvailable;
/// <summary>Synthesises the fade-out burst for the chosen artifact into the silence /// <summary>Synthesises the fade-out burst for the chosen artifact into the silence
/// region starting at <paramref name="startFrame"/>. Click variant leaves the buffer's /// region starting at <paramref name="startFrame"/>. Click variant leaves the buffer's
/// hard-zero in place.</summary> /// hard-zero in place.</summary>
+94
View File
@@ -39,6 +39,39 @@ internal sealed class StreamSession : IDisposable
/// <summary>For PCM streams: number of partially-assembled frames discarded mid-flight.</summary> /// <summary>For PCM streams: number of partially-assembled frames discarded mid-flight.</summary>
public long PcmFrameDiscardedPartials => pcmAssembler.DiscardedPartialCount; public long PcmFrameDiscardedPartials => pcmAssembler.DiscardedPartialCount;
// Post-decode discontinuity probe. Scans the float buffer right after Int24LEToFloat
// (PCM) or short-to-float (Opus) so we can compare to the sender's pre-encode probe and
// detect any wire-level or decode-level artefacts. Same buffer is then handed to the
// session playout, so the post-ring-read probe in SessionPlayout sees the exact same
// samples a moment later (after riding through the ring buffer).
private readonly AudioStepProbe postDecodeStepProbe = new();
public float TakeMaxPostDecodeStep() => postDecodeStepProbe.TakeMax();
// === Wire-level sequence tracking (Phase 5, 2026-05-14) ===
// Every audio packet carries a wire sequence number that monotonically increases per
// session (audioSequence in SenderLane). The Opus path uses this for FEC recovery. The
// PCM path historically ignored it entirely. Now we track it to detect:
// * MISSING packets — sequence > expected (gap > 1 frames)
// * REORDERED packets — sequence < expected (a packet arrived after a later one)
// * DUPLICATE packets — sequence == previous (same packet delivered twice)
// * IN-ORDER packets — sequence == expected
//
// Any of MISSING / REORDERED / DUPLICATE on a healthy LAN would point straight at a
// transport-level issue (NIC offload bug, switch buffer overflow, RSS hash collision
// causing packets to take different queues). MISSING on PCM = silent audio drop at
// the packet boundary = audible click. REORDERED = the receiver processes audio in
// the wrong order = audible click. DUPLICATE = same audio played twice in a row =
// audible click.
private uint? expectedNextWireSequence;
private long wireInOrderTotal;
private long wireMissedTotal; // sum of missing-packet counts (sequence > expected by N → +N)
private long wireReorderedTotal; // count of times a sequence < expected arrived
private long wireDuplicatedTotal; // count of times a sequence == previous arrived
public long WireInOrderCount => Interlocked.Read(ref wireInOrderTotal);
public long WireMissedCount => Interlocked.Read(ref wireMissedTotal);
public long WireReorderedCount => Interlocked.Read(ref wireReorderedTotal);
public long WireDuplicatedCount => Interlocked.Read(ref wireDuplicatedTotal);
public StreamSession( public StreamSession(
IPEndPoint endpoint, IPEndPoint endpoint,
ushort streamId, ushort streamId,
@@ -74,6 +107,7 @@ internal sealed class StreamSession : IDisposable
public bool HandleAudioPayload(uint sequence, ReadOnlySpan<byte> payload) public bool HandleAudioPayload(uint sequence, ReadOnlySpan<byte> payload)
{ {
diagnostics.RecordPacketArrived(); diagnostics.RecordPacketArrived();
TrackWireSequence(sequence);
return Codec switch return Codec switch
{ {
AudioTransportCodec.Pcm => HandlePcm(payload), AudioTransportCodec.Pcm => HandlePcm(payload),
@@ -82,6 +116,60 @@ internal sealed class StreamSession : IDisposable
}; };
} }
/// <summary>
/// Classify each arriving packet against the expected next wire sequence:
/// IN-ORDER (== expected), MISSING (> expected, diff sample frames), REORDERED (< expected
/// but within a small sane window), DUPLICATE (== previous). On the very first packet we
/// just seed expected and bail. On a wild jump (huge gap) we treat it as a re-sync rather
/// than logging hundreds of thousands of "missing" packets — this can happen if the sender
/// restarts mid-session or a router drops a long burst.
/// All counters use Interlocked because the readers are on the UI thread.
/// </summary>
private void TrackWireSequence(uint sequence)
{
if (expectedNextWireSequence is not uint expected)
{
expectedNextWireSequence = sequence + 1U;
Interlocked.Increment(ref wireInOrderTotal);
return;
}
if (sequence == expected)
{
Interlocked.Increment(ref wireInOrderTotal);
expectedNextWireSequence = sequence + 1U;
return;
}
// Treat the gap as an unsigned forward gap. If it's small-ish (< 1M packets, well over
// 10 minutes of audio at our packet rates) treat as forward MISSING. If it's huge,
// assume sequence ran backwards (reorder or restart).
uint forwardGap = sequence - expected;
if (forwardGap < 1_000_000U)
{
// Forward jump → forwardGap packets we never saw at the expected slot.
Interlocked.Add(ref wireMissedTotal, forwardGap);
expectedNextWireSequence = sequence + 1U;
}
else
{
// Backward jump. Distance behind expected:
uint backwardDistance = expected - sequence;
if (backwardDistance == 1U)
{
// sequence == previous (the one just before expected) → duplicate.
Interlocked.Increment(ref wireDuplicatedTotal);
}
else
{
// Out-of-order arrival from further back.
Interlocked.Increment(ref wireReorderedTotal);
}
// Do NOT roll expectedNextWireSequence backwards — that would re-count the
// already-missing packets when the originally-expected packet arrives.
}
}
public void Dispose() { /* IOpusDecoder has no Dispose; nothing else to free */ } public void Dispose() { /* IOpusDecoder has no Dispose; nothing else to free */ }
// === PCM === // === PCM ===
@@ -106,6 +194,12 @@ internal sealed class StreamSession : IDisposable
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch); var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
PcmPack.Int24LEToFloat(assembled, floatSpan); PcmPack.Int24LEToFloat(assembled, floatSpan);
// Discontinuity probe — what does the audio look like right after we decode it?
// Compared to the sender's pre-encode probe, a higher value here would mean the
// wire codec roundtrip introduced steps. Same probe is also useful as a baseline
// for the post-ring-read probe in SessionPlayout.
postDecodeStepProbe.ScanStereo(floatSpan);
sessionPlayout.Write(floatScratch); sessionPlayout.Write(floatScratch);
onFramesQueued(sampleCount / Format.Channels); onFramesQueued(sampleCount / Format.Channels);
return true; return true;
+24
View File
@@ -37,6 +37,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
// open driver can keep running while routing changes between Mixed / AsioLane / no-op. // open driver can keep running while routing changes between Mixed / AsioLane / no-op.
// Volatile is sufficient for reference assignment on .NET (atomic, with memory barrier). // Volatile is sufficient for reference assignment on .NET (atomic, with memory barrier).
private volatile Action<ReadOnlyMemory<float>> onMixedSamples; private volatile Action<ReadOnlyMemory<float>> onMixedSamples;
// Raw-capture step probe — measures discontinuities in the ASIO buffer exactly as the
// driver delivered it, BEFORE our code sums the selected channel pairs or clamps to ±1.0.
// Each capture backend owns its own probe so BothIndependent mode (ASIO and WASAPI both
// capturing) can be diagnosed without the probes contaminating each other's state.
private readonly AudioStepProbe rawCaptureStepProbe = new();
private readonly Action<string>? onDiagnostic; private readonly Action<string>? onDiagnostic;
private readonly string driverName; private readonly string driverName;
public string DriverName => driverName; public string DriverName => driverName;
@@ -82,6 +87,8 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
public void SetCallback(Action<ReadOnlyMemory<float>> callback) => public void SetCallback(Action<ReadOnlyMemory<float>> callback) =>
onMixedSamples = callback; onMixedSamples = callback;
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
public bool IsRunning => asio is not null; public bool IsRunning => asio is not null;
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount); public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured); public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured);
@@ -264,6 +271,23 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
if (pairs.Count == 0) return; if (pairs.Count == 0) return;
// Diagnostic raw-capture probe — scans the FIRST active channel pair's L channel in
// the as-delivered-by-the-driver interleaved buffer. Fires BEFORE the mix/sum/clamp
// below so the probe sees the driver's data verbatim. If this probe goes non-zero
// on big steps while the post-mix probe also does, the discontinuity is upstream of
// our code (driver, USB transport, audio hardware). If it stays clean while the
// post-mix probe goes non-zero, something in the mix/clamp loop is creating the step.
if (frames > 0 && recordChannelCount > 0)
{
var firstPair = pairs[0];
var lCh = firstPair * 2;
if (lCh < recordChannelCount)
{
rawCaptureStepProbe.ScanInterleavedChannel(
new ReadOnlySpan<float>(interleavedScratch, 0, written), recordChannelCount, lCh);
}
}
for (var f = 0; f < frames; f++) for (var f = 0; f < frames; f++)
{ {
var srcBase = f * recordChannelCount; var srcBase = f * recordChannelCount;
+53 -1
View File
@@ -125,6 +125,37 @@ public sealed class AudioSender : IDisposable
public int TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency); public int TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency);
public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 0) * 1000 / Stopwatch.Frequency); public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 0) * 1000 / Stopwatch.Frequency);
// Pre-encode discontinuity probe — per-lane (each <see cref="SenderLane"/> owns its own).
// The aggregate accessor returns the max across both lanes since the last read; per-lane
// accessors expose them individually so BothIndependent mode can tell which lane is
// producing the artefact. Splitting the probe per-lane (2026-05-15) eliminates the
// cross-stream synthetic-step artefact that appeared when both lanes shared one probe and
// their interleaved callbacks fooled the cross-buffer step computation into recording a
// "step" between two unrelated audio streams.
public float TakeMaxSenderPreEncodeStep()
{
var a = defaultLane.TakeMaxPreEncodeStep();
var b = asioLane.TakeMaxPreEncodeStep();
return a > b ? a : b;
}
public float TakeMaxPreEncodeStepWasapiLane() => defaultLane.TakeMaxPreEncodeStep();
public float TakeMaxPreEncodeStepAsioLane() => asioLane.TakeMaxPreEncodeStep();
// Raw capture-side step probe — now lives inside each <see cref="ICaptureBackend"/>
// implementation so the ASIO path and the WASAPI path each measure their own buffers
// independently. The aggregate just asks the backend for the max since last read; in
// BothIndependent mode the composite backend forwards to both inners and returns the
// larger value.
public float TakeMaxSenderRawCaptureStep() => engine.TakeMaxRawCaptureStep();
// Snapshot the cumulative "hit the hard clamp" sample counter. The sender's mix path
// clamps any sample whose magnitude exceeds 1.0 (avoids producing samples the int24 path
// can't represent or that the resampler would treat as garbage). Per-second delta tells
// us whether the input signal is getting close enough to the rails that clipping is
// active — clipping itself produces no step, but a flat-topped sample plateau plus a
// following sharp drop can produce audible distortion that masquerades as a click.
public long ClippedSampleCount => engine.ClippedSampleCount;
// === inbound dispatch (relay-mode) === // === inbound dispatch (relay-mode) ===
// The send socket is normally write-only, but in relay-mode the same socket is what // The send socket is normally write-only, but in relay-mode the same socket is what
// catches return packets — the relay forwards traffic into our NAT pinhole, which lives on // catches return packets — the relay forwards traffic into our NAT pinhole, which lives on
@@ -144,6 +175,28 @@ public sealed class AudioSender : IDisposable
/// </summary> /// </summary>
public Action<byte[], int, IPEndPoint>? OnInboundPacket { get; set; } public Action<byte[], int, IPEndPoint>? OnInboundPacket { get; set; }
/// <summary>
/// Optional callback invoked every time a SenderLane is about to encode a buffer of
/// captured float audio. The span is 48 kHz interleaved stereo float, lives on the
/// audio thread, and must be processed quickly or copied — the buffer is reused on
/// the very next callback. The recorder uses this tap to capture "what we sent" with
/// zero impact on the wire path (no allocation, no extra encoder pass). Null = no tap.
/// </summary>
public Action<ReadOnlyMemory<float>>? OnSentSamples { get; set; }
/// <summary>
/// Internal helper for <see cref="SenderLane"/> to invoke <see cref="OnSentSamples"/>
/// without paying a delegate-invocation cost when no tap is wired. Catches and drops
/// any exception from the user callback — a misbehaving recorder must not crash the
/// audio thread.
/// </summary>
internal void DispatchSentSamples(ReadOnlyMemory<float> samples)
{
var cb = OnSentSamples;
if (cb is null) return;
try { cb(samples); } catch { /* recorder failure isolated from audio path */ }
}
public AudioSender() public AudioSender()
{ {
udp = new UdpClient(AddressFamily.InterNetwork); udp = new UdpClient(AddressFamily.InterNetwork);
@@ -357,7 +410,6 @@ public sealed class AudioSender : IDisposable
public int TakeMaxCaptureCallbackGapMs() => engine.TakeMaxCallbackGapMs(); public int TakeMaxCaptureCallbackGapMs() => engine.TakeMaxCallbackGapMs();
public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription; public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription;
public string? LastCaptureError => engine.FirstCaptureLastError; public string? LastCaptureError => engine.FirstCaptureLastError;
public long ClippedSampleCount => engine.ClippedSampleCount;
public AudioTransportCodec Codec => codec; public AudioTransportCodec Codec => codec;
public int OpusFrameMilliseconds => opusFrameMs; public int OpusFrameMilliseconds => opusFrameMs;
@@ -119,6 +119,16 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
} }
} }
/// <summary>Max raw-capture step across both inner backends since the last call. Has to
/// drain BOTH probes (so neither sits accumulating forever after we read one) and return
/// the larger value.</summary>
public float TakeMaxRawCaptureStep()
{
var w = wasapi?.TakeMaxRawCaptureStep() ?? 0f;
var a = asio?.TakeMaxRawCaptureStep() ?? 0f;
return w > a ? w : a;
}
public void Start(IReadOnlyList<CaptureSourceSpec> specs) public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{ {
lock (gate) lock (gate)
+7
View File
@@ -45,6 +45,13 @@ internal interface ICaptureBackend : IDisposable
/// support per-callback timing (e.g. trivial test backends) may return 0.</summary> /// support per-callback timing (e.g. trivial test backends) may return 0.</summary>
int TakeMaxCallbackGapMs(); int TakeMaxCallbackGapMs();
/// <summary>Worst single-sample step magnitude observed in the raw capture buffer since
/// the last call; resets on read. Each backend owns its own probe instance so the
/// cross-buffer step measurement doesn't get fooled by another backend's interleaved
/// callbacks (which is what produced spurious 0.4-0.5 readings in BothIndependent mode
/// before 2026-05-15). Backends that can't sensibly expose raw samples return 0.</summary>
float TakeMaxRawCaptureStep();
void Start(IReadOnlyList<CaptureSourceSpec> specs); void Start(IReadOnlyList<CaptureSourceSpec> specs);
/// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes /// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes
+8
View File
@@ -111,6 +111,14 @@ internal sealed class MixingEngine : ICaptureBackend
get { lock (gate) return active.Select(a => a.Source.Name).ToList(); } get { lock (gate) return active.Select(a => a.Source.Name).ToList(); }
} }
/// <summary>Multi-source pull-mode WASAPI doesn't yet feed the raw-capture probe — each
/// <see cref="CaptureSource"/> chain (BufferedWaveProvider → ToSampleProvider →
/// resampler → stereo-mixdown) would need a per-source probe to be useful, and during
/// the 2026-05-15 instrumentation push the user's tests have all been single-source on
/// <see cref="PushModeWasapiBackend"/> instead. Stays at zero here; if a future
/// multi-source WASAPI test needs the probe, add it per-source in CaptureSource.</summary>
public float TakeMaxRawCaptureStep() => 0f;
/// <summary> /// <summary>
/// Starts the mix loop with the given initial source set. If already running, the existing /// Starts the mix loop with the given initial source set. If already running, the existing
/// loop is stopped first. After Start, <see cref="UpdateSources"/> can be called to add/remove /// loop is stopped first. After Start, <see cref="UpdateSources"/> can be called to add/remove
@@ -67,6 +67,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
private long bytesCaptured; private long bytesCaptured;
private long clippedSampleCount; private long clippedSampleCount;
// Raw-capture step probe — scans the WASAPI source buffer as floats right after we
// reinterpret the byte buffer, BEFORE resampling / stereo-mixdown / clamp. This is the
// earliest float-form view of what the Windows audio engine handed us. Used together
// with the per-lane pre-encode probe to localise where discontinuities enter on the
// WASAPI path. Per-backend so BothIndependent doesn't cross-contaminate ASIO and WASAPI
// probes' cross-buffer state.
private readonly AudioStepProbe rawCaptureStepProbe = new();
// Resampling state — only allocated when source rate != MixSampleRate. // Resampling state — only allocated when source rate != MixSampleRate.
private WdlResampler? resampler; private WdlResampler? resampler;
private int sourceSampleRate; private int sourceSampleRate;
@@ -100,6 +108,8 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
/// where Ed has been hunting jitter.</summary> /// where Ed has been hunting jitter.</summary>
public int TakeMaxCallbackGapMs() => 0; public int TakeMaxCallbackGapMs() => 0;
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
public void Start(IReadOnlyList<CaptureSourceSpec> specs) public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{ {
if (specs.Count == 0) if (specs.Count == 0)
@@ -247,6 +257,21 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
Buffer.BlockCopy(e.Buffer, 0, sourceFloatScratch, 0, e.BytesRecorded); Buffer.BlockCopy(e.Buffer, 0, sourceFloatScratch, 0, e.BytesRecorded);
var sourceFrames = sourceFloatCount / sourceChannels; var sourceFrames = sourceFloatCount / sourceChannels;
// Raw-capture probe — scans the L channel of the source buffer in the form
// Windows handed it to us, before our resample / mixdown / clamp. Channel layout
// for WASAPI loopback is interleaved [L,R,...] for stereo or a single channel for
// mono; the probe walks every Nth sample where N=sourceChannels. If this probe
// shows steps that the per-lane pre-encode probe doesn't, our downstream
// processing is masking real source-side issues. If both show the same steps,
// the discontinuity arrived from Windows / the device driver.
if (sourceFrames > 0 && sourceChannels > 0)
{
rawCaptureStepProbe.ScanInterleavedChannel(
new ReadOnlySpan<float>(sourceFloatScratch, 0, sourceFloatCount),
sourceChannels,
0);
}
// 2. Resample to MixSampleRate if needed. The resampler is pull-mode; we drive the // 2. Resample to MixSampleRate if needed. The resampler is pull-mode; we drive the
// pull from our callback. Approximate output frames = input * outRate / inRate. // pull from our callback. Approximate output frames = input * outRate / inRate.
float[] working; float[] working;
+25
View File
@@ -52,6 +52,17 @@ internal sealed class SenderLane
private OpusEncoderState opusEncoder; private OpusEncoderState opusEncoder;
private int opusFrameStereoSamples; private int opusFrameStereoSamples;
// Per-lane pre-encode discontinuity probe. Moved here from AudioSender (2026-05-15) so
// each lane has its OWN probe state and the cross-buffer step measurement (which carries
// lastL/lastR across calls) only sees samples from one continuous audio stream. With the
// earlier shared-probe design, BothIndependent mode mixed two unrelated streams' samples
// into the same probe's cross-buffer carry, producing synthetic "steps" of arbitrary
// magnitude every time the two lanes' callbacks interleaved — making the diag log unable
// to tell a real capture glitch from instrumentation aliasing. Per-lane separation fixes
// that without changing what the probe measures.
private readonly AudioStepProbe preEncodeStepProbe = new();
public float TakeMaxPreEncodeStep() => preEncodeStepProbe.TakeMax();
// Which render route this lane announces in its format packets. The receiver reads the // Which render route this lane announces in its format packets. The receiver reads the
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine // Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine
// route the lane's audio to the corresponding per-route IWaveProvider surface (lane // route the lane's audio to the corresponding per-route IWaveProvider surface (lane
@@ -144,6 +155,20 @@ internal sealed class SenderLane
var emitStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; var emitStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
EnsureFormatPacketSent(); EnsureFormatPacketSent();
// Recording tap — the recorder gets the float audio about to be encoded. The lane
// doesn't know whether the recorder is running; the dispatcher early-outs when no
// callback is wired. Captured here (before encoding) so the recording is bit-clean
// float, independent of which codec the wire is using.
owner.DispatchSentSamples(stereoFloats);
// Discontinuity probe — what does the audio look like just before we encode it?
// Compared to the receiver's per-stage probes, this tells us whether artefacts are
// present at the sender side already (capture hardware glitch, mix-bus issue) or
// introduced somewhere in the wire / decode / playout chain. Per-lane probe — see
// <see cref="preEncodeStepProbe"/> field comment for why this isn't shared with the
// other lane in BothIndependent.
preEncodeStepProbe.ScanStereo(span);
switch (owner.Codec) switch (owner.Codec)
{ {
case AudioTransportCodec.Pcm: case AudioTransportCodec.Pcm: