diff --git a/.gitignore b/.gitignore index 472c4d0..e602eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,11 @@ PLAN-*.md Thumbs.db .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 *.log *.tmp diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cba0e81..6648c29 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 -- 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. -- 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. -- 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. -- 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. -- 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. -- 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. +- **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). +- **Four output formats, all functional.** WAV (16/24-bit PCM or 32-bit float), MP3 (LAME, 128–320 kbps CBR), **OGG-Opus** (96–256 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. +- **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. +- **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. +- **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. + +## 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 -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. 3. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts. 4. Press F1 (or use the Help menu) for the user manual. diff --git a/readme.html b/readme.html index e5f6df0..0767212 100644 --- a/readme.html +++ b/readme.html @@ -31,7 +31,7 @@ ul, ol { padding-left: 1.4em; }
  • Quick start
  • Profiles
  • The main window: menu bar + three tabs
  • -
  • Menus (File and Help)
  • +
  • Menus (File, Record (Alt+O), and Help)
  • Connectivity tab
  • Audio inputs and outputs tab
  • Audio profile tab
  • @@ -44,6 +44,7 @@ ul, ol { padding-left: 1.4em; }
  • Remote control: adjusting a peer's listening volume from your end
  • Startup behaviour
  • Updating RemSound
  • +
  • Recording to a file
  • Logs and diagnostics
  • Troubleshooting
  • Glossary
  • @@ -169,7 +170,9 @@ ul, ol { padding-left: 1.4em; } Audio profileCodec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into Audio send parameters and Audio receive parameters sections so NVDA announces which group you’ve entered as you tab through. - + + +

    Three top-level menus on the main window: File (Alt+F), Record (Alt+O), and Help (Alt+H). The Record menu uses Alt+O rather than Alt+R so it doesn’t clash with the Receive audio (Alt+R) checkbox on the main form.

    File menu

    @@ -183,10 +186,22 @@ ul, ol { padding-left: 1.4em; } Rename current profile…Alt+F, RRenames the JSON file on disk and updates the window title. No-op on Blank template (no profile to rename). Minimise to trayAlt+F, MHides 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). Keyboard shortcuts…Ctrl+KOpens the global hotkey configuration dialog (mute, volume, tray show/hide, remote-control sends). -Preferences…Ctrl+POpens 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. +Preferences…Ctrl+POpens the Preferences dialog. Sticky machine-local choices live here: profile folder, the Cue sounds (Alt+N) 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. ExitAlt+F, X (or Alt+F4)Closes RemSound. If there are unsaved profile changes you’ll be prompted first. +

    Record menu

    + +

    Press Alt+O 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 Receive audio checkbox.) The recording feature taps the audio path at the right place to capture either what you’re sending, what you’re receiving, or both, and writes it to disk as a WAV, MP3, OGG-Opus, or FLAC file. See Recording to a file for the full chapter; this is the menu summary.

    + + + + + + + +
    ItemShortcutWhat it does
    Start recording / Stop recordingCtrl+R, or Alt+O, SToggle. 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 RemSound-yyyy-MM-dd_HH-mm-ss.<ext>. Stop closes the file then plays the stop cue. Ctrl+R works from anywhere in the main window.
    Recording settings…Alt+O, TOpens the Recording settings dialog. Three listboxes side-by-side: Recording source (Alt+S), File format (Alt+F), Audio format attributes (Alt+A). The attributes list repopulates based on the selected format. OK saves to the current profile; Cancel discards.
    Open current recordings folderAlt+O, OOpens your current recordings folder in Windows Explorer. Creates the folder if it doesn’t exist yet (first launch after a fresh install).
    Change recordings folder…Alt+O, CFolder picker. Pick a different folder for future recordings. The choice is saved on the current profile, so different profiles can record to different places.
    +

    Help menu

    Press Alt+H to open the menu, then the underlined letter for the item you want.

    @@ -308,7 +323,7 @@ ul, ol { padding-left: 1.4em; } ASIO latency in millisecondsAlt+L(Only when an ASIO driver is selected.) Spinner. Target receive buffer for the ASIO lane. Default 10 ms. ASIO’s native pipeline can sustain very low targets, but values below the platform’s real-world jitter floor (typically 15–25 ms) cause constant micro-corrections that you can hear — pick 25 ms as a safe floor unless you’re on localhost or wired LAN. Continuous auto-tune ASIO latencyAlt+T(Only when an ASIO driver is selected.) Checkbox. Nudges the ASIO latency target as ASIO-lane jitter changes. Independent of the WASAPI toggle. WASAPI latency in milliseconds (called just “Audio latency” with no ASIO driver)Alt+W (Alt+L when no ASIO driver)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–80 ms. -Continuous auto-tune WASAPI latency (called “Continuous auto-tune latency” with no ASIO driver)Alt+Y (Alt+T when no ASIO driver)Checkbox. When on, RemSound nudges the WASAPI lane latency value automatically as the network changes. The accompanying Auto-tune latency interval (Alt+I) combo sets how often it re-evaluates: 3, 5, 10, 15, or 30 seconds. The interval is shared between the WASAPI and ASIO lanes — one tick rate, one combo. +Continuous auto-tune WASAPI latency (called “Continuous auto-tune latency” with no ASIO driver)Alt+Y (Alt+T when no ASIO driver)Checkbox. When on, RemSound nudges the WASAPI lane latency value automatically as the network changes. The accompanying interval combo (Alt+I) sets how often it re-evaluates: 3, 5, 10, 15, or 30 seconds. The combo’s label is “Auto-tune latency interval” in WASAPI-only mode and “Auto-tune interval — WASAPI and ASIO” when an ASIO driver is selected, because the single tick rate drives both lanes’ auto-tunes when ASIO is in play. Each lane still independently lands at whatever target its own algorithm chooses; only the cadence is shared. Buffer smoothnessAlt+BListbox, 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. Artefact sound typeAlt+AListbox. Noise burst (default) replaces an empty-buffer moment with a brief broadband shhh, which blends into music. Click uses raw zero-fill (no concealment) for diagnostic purposes — you hear an obvious click on every buffer underrun. @@ -585,7 +600,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L Alt+TToggle continuous auto-tune — ASIO lane when an ASIO driver is selected, otherwise the single Continuous auto-tune toggle Alt+W(Only when an ASIO driver is selected.) Focus the WASAPI-lane latency spinner Alt+Y(Only when an ASIO driver is selected.) Toggle the WASAPI-lane continuous auto-tune -Alt+IFocus Auto-tune latency interval +Alt+IFocus 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 — 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 “Auto-tune latency interval” in WASAPI-only mode to “Auto-tune interval — WASAPI and ASIO” once an ASIO driver is in play, to make the dual-lane scope visible. Alt+BFocus Buffer smoothness Alt+AFocus Artefact sound type @@ -596,6 +611,11 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L Ctrl+SSave active profile (or Save as if on Blank template) Ctrl+KOpen Keyboard shortcuts dialog Ctrl+POpen Preferences dialog +Ctrl+RStart or stop recording (toggles) +Alt+O, SStart or stop recording (via menu — Record menu is Alt+O, item is S) +Alt+O, TRecording settings dialog +Alt+O, OOpen current recordings folder +Alt+O, CChange recordings folder Alt+F, OOpen profile Alt+F, ASave profile as Alt+F, RRename current profile @@ -750,7 +770,73 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down

    To see what version you’re currently on without checking for updates, open Help → About RemSound (Alt+H, A). The dialog shows the version number and the release notes for the build you’re running, in a scrollable read-only text box you can tab into and arrow through under NVDA. Close (or Esc) dismisses.

    -

    18. Logs and diagnostics

    +

    18. Recording to a file

    + +

    RemSound can write the audio passing through it to a file on disk — 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.

    + +

    What gets recorded

    + +

    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’s encoded for the wire (so the file is independent of which codec you chose). The three source options:

    + + + +

    File formats

    + +

    All four formats record at 48 kHz — that’s the engine’s internal mix rate — and labels in the attributes list make the rate explicit so it’s never ambiguous.

    + + + + + + + +
    FormatWhat you getWhen to pick it
    WAV (default)Uncompressed RIFF WAVE. Lossless and large — about 17 MB / minute at 24-bit stereo. Bit-depth choices: 16-bit PCM, 24-bit PCM (default, matches RemSound’s on-wire format with no extra quantisation), or 32-bit IEEE float (no quantisation at all). Plus stereo or mono.Archival, editing in a DAW, anything where you might re-master later.
    MP3MPEG Layer III at one of four CBR bitrates: 128 / 192 / 256 / 320 kbps. Stereo or mono. Encoder is LAME via the NAudio.Lame package — the same library every other Windows MP3-encoding app uses, so output should play in everything.Long sessions where filesize matters; quick distribution to people who just want a listen-once file.
    OGG-OpusOGG container holding Opus-encoded audio at one of four target VBR bitrates: 96 / 128 / 192 / 256 kbps. Stereo or mono. File extension is .opus. Encoder is Concentus — the same managed Opus implementation the wire path uses, so the encoded result is byte-identical to what you’d transmit on a live Opus session at the same bitrate.Smaller files than MP3 at comparable quality; broad modern playback support (VLC, mpv, web browsers).
    FLACFree Lossless Audio Codec, integer-PCM only. Bit-depth choices: 16-bit or 24-bit (default, matches RemSound’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) — no native DLL ships with RemSound.Lossless archival when you also want a sensible file size. Picks the WAV-vs-MP3 dilemma squarely — the file plays back bit-identical to WAV but is half the size.
    + +

    Crash resilience. 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.

    + +

    Start / stop sound cues

    + +

    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 record start.wav and record stop.wav in the RemSound install folder — you can drop your own files in to customise them, as long as they keep the same filenames.

    + +

    Each cue can be turned off independently via Preferences → Cue sounds (Alt+N). The same checklist also controls the older connect / disconnect cues. The cues play through the default Windows output device using System.Media.SoundPlayer, which sits outside the recording pipeline — 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’s output; in that case you’ll have to live with the start cue appearing in the file unless you disable it.

    + +

    Where recordings go

    + +

    By default, recordings live in <RemSound install folder>\recordings\<machine name>\. Each recording session creates a new file named RemSound-yyyy-MM-dd_HH-mm-ss.<ext>, so files never collide.

    + +

    You can change the folder via Record → Change recordings folder. Picking a different folder writes that path to your current profile, so the choice rides along with the rest of your settings — switching profiles can switch recording destinations too. If a saved profile points at a folder that doesn’t exist on the loading machine, the recorder silently falls back to the default location for the current machine.

    + +

    Record → Open current recordings folder launches Windows Explorer on whatever the configured folder currently is, creating it on the fly if a recording hasn’t been made there yet.

    + +

    Starting and stopping

    + +

    Three ways to start or stop a recording:

    + + +

    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’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’ll only see that on a fully-saturated USB stick or a very slow network drive.

    + +

    Recording settings dialog

    + +

    Reached via Record → Recording settings. Three keyboard-navigable listboxes:

    + + + + + + +
    ListShortcutWhat goes in it
    Recording sourceAlt+SReceive only / Send only / Both. See the source explainer above.
    File formatAlt+FWAV / MP3 / Ogg-Opus / FLAC. The attributes list to the right repopulates whenever you change this.
    Audio format attributesAlt+ARepopulates per format, with the 48 kHz sample rate stated on every row so there’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 — FLAC is integer-PCM only, so no 32-bit-float row. Compression level for FLAC is fixed at 5 (the libFLAC reference default).
    + +

    OK saves the choices to the current profile. Cancel (or Esc) discards. Settings persist via the normal profile-save mechanism — changes here flag the profile as having unsaved changes and you’ll be prompted on exit if you haven’t saved.

    + +

    19. Logs and diagnostics

    If logging is enabled (the Enable logs checkbox in the Preferences dialog — File → Preferences, or Ctrl+P — default on), RemSound writes a tab-separated log file per session into publish\logs\ (relative to wherever RemSound.exe lives). One file per launch, named RemSound-<machine>-<PID>-<date>-<time>.log.

    @@ -774,7 +860,7 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down
  • OpusUnrecoveredGaps — running total of multi-packet drops that FEC couldn’t repair. Each one corresponds to an audible click. Stays at 0 on a clean line; small numbers are normal on WAN.
  • -

    19. Troubleshooting

    +

    20. Troubleshooting

    I don’t hear my friend

      @@ -828,7 +914,7 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down

      Audio device list shows old devices that are no longer plugged in

      RemSound refreshes its lists every second. If a device has truly been unplugged it should disappear within a few seconds. If it’s lingering, restart RemSound — Windows’ device cache occasionally needs poking.

      -

      20. Glossary

      +

      21. Glossary

      diff --git a/record start.wav b/record start.wav new file mode 100644 index 0000000..966ea49 Binary files /dev/null and b/record start.wav differ diff --git a/record stop.wav b/record stop.wav new file mode 100644 index 0000000..e955761 Binary files /dev/null and b/record stop.wav differ diff --git a/src/RemSound.App/AboutDialog.cs b/src/RemSound.App/AboutDialog.cs index 2beaa11..7058057 100644 --- a/src/RemSound.App/AboutDialog.cs +++ b/src/RemSound.App/AboutDialog.cs @@ -20,6 +20,74 @@ internal sealed class AboutDialog : Form /// updates" path. 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 Priority and performance hardening, plus always-on network diff --git a/src/RemSound.App/AudioRecorder.cs b/src/RemSound.App/AudioRecorder.cs new file mode 100644 index 0000000..d6965c0 --- /dev/null +++ b/src/RemSound.App/AudioRecorder.cs @@ -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; + +/// +/// 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 / +/// — 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. 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. +/// +internal sealed class AudioRecorder : IDisposable +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + + /// 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. + private const int RingCapacityFloats = MixSampleRate * MixChannels * 5; + + /// 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. + private const int DrainChunkFrames = 480; + + /// 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). + private const int DrainChunkMaxFrames = 4800; + + private readonly RecordingSettings settings; + private readonly string resolvedPath; + private readonly Action? onDiagnostic; + private readonly Action? 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); + + /// 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. + public long DroppedSampleFrames => Interlocked.Read(ref droppedSampleFrames); + + /// 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). + public AudioRecorder(RecordingSettings settings, Action? onDiagnostic, Action? 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 === + + /// 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. + public void WriteSent(ReadOnlyMemory stereoFloats) + { + if (stopped) return; + if (settings.Source == RecordingSource.ReceivedOnly) return; + AppendToRing(stereoFloats.Span, sentRing, ref sentWriteHead, ref sentReadHead); + } + + /// 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. + public void WriteReceived(ReadOnlyMemory stereoFloats) + { + if (stopped) return; + if (settings.Source == RecordingSource.SentOnly) return; + AppendToRing(stereoFloats.Span, receivedRing, ref receivedWriteHead, ref receivedReadHead); + } + + /// 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. + private void AppendToRing(ReadOnlySpan 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; + } + } + + /// Copy .Length floats from + /// starting at , advancing the head atomically. + private static void CopyFromRing(float[] ring, ref long readHeadRef, Span 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]; + } + + /// 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. + 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 samples); + } + + /// 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 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. + /// + 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 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(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 */ } + } + } + + /// 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 seconds to bound + /// the loss-on-crash to a couple of seconds rather than however-much fit in the + /// kernel file cache. + 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 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(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(); + } + + /// 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 seconds to + /// bound loss-on-crash to that window. + 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 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 */ } + } + } + + /// 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 + /// seconds to bound the OS-cache-loss window. + 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 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 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 */ } + } + } +} diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index be1fe55..e787406 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -37,6 +37,11 @@ public sealed class MainForm : Form private readonly System.Windows.Forms.Timer updateCheckTimer = new(); private readonly MainFormHotkeyController hotkeyController; 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 --- // 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. 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)" }; + // 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 // 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 @@ -284,6 +296,14 @@ public sealed class MainForm : Form private readonly Dictionary previousPeerHealthStates = new(StringComparer.OrdinalIgnoreCase); private System.Media.SoundPlayer? connectSound; 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 // re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode // (driver channel pairs). null until BuildLayout has run. @@ -344,6 +364,30 @@ public sealed class MainForm : Form private bool firstSenderPacketLogged; 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 // populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile // below). Control-level state (device ticks, send/receive checkboxes, audio port, volume @@ -481,6 +525,13 @@ public sealed class MainForm : Form () => receiveAudioCheckbox.Checked = true, Close); + recordingController = new RecordingController( + sender, + receiver, + settings, + msg => logFile.Event($"recorder: {msg}")); + recordingController.RecordingStateChanged += UpdateStartStopRecordingMenuLabel; + // --- Set accessibility names --- // 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 @@ -694,6 +745,8 @@ public sealed class MainForm : Form // Files are deployed alongside the .exe (see RemSound.App.csproj Content rules). TryLoadCueSound("connect.wav", out connectSound); TryLoadCueSound("disconnect.wav", out disconnectSound); + TryLoadCueSound("record start.wav", out recordStartSound); + TryLoadCueSound("record stop.wav", out recordStopSound); LoadAudioDevices(); // Apply persisted ASIO mode from settings — switches sender/receiver backends so the @@ -1065,11 +1118,123 @@ public sealed class MainForm : Form aboutItem, }); + var recordMenu = BuildRecordMenu(); + menu.Items.Add(fileMenu); + menu.Items.Add(recordMenu); menu.Items.Add(helpMenu); return menu; } + /// 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. + 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; + } + + /// 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. + 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(); + } + } + + /// 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. + 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(); + } + + /// Open the recording settings dialog. On OK, write the settings back through + /// 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. + 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(); + } + /// 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). private void OpenProfileFromPicker() @@ -1749,8 +1914,17 @@ public sealed class MainForm : Form continuousIntervalBox.Items.Clear(); 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.Enabled = continuousTuneEnabled; - var continuousIntervalLabel = new Label { Text = "Auto-tune latency interval (Alt+&I)", AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(8, 6, 0, 0) }; + // Enable the interval combo whenever EITHER lane's auto-tune is on — the single + // 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 { AutoSize = true, @@ -1769,7 +1943,7 @@ public sealed class MainForm : Form { continuousTuneEnabled = continuousTuneBox.Checked; settings.SaveContinuousAutoTuneEnabled(continuousTuneEnabled); - continuousIntervalBox.Enabled = continuousTuneEnabled; + continuousIntervalBox.Enabled = AnyAutoTuneEnabled(); ApplyContinuousTuneTimer(); MarkProfileDirty(); }; @@ -2694,6 +2868,11 @@ public sealed class MainForm : Form continuousTuneAsioBox.CheckedChanged += (_, _) => { 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(); MarkProfileDirty(); }; @@ -2720,12 +2899,27 @@ public sealed class MainForm : Form asioDelayContainer.Visible = inBothIndependent; maxLatencyAsioBox.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) { wasapiLatencyLabel.Text = "WASAPI latency in milliseconds (Alt+&W)"; maxLatencyBox.AccessibleName = "WASAPI latency in milliseconds (Alt+W)"; continuousTuneBox.Text = "Continuous auto-tune WASAPI latency (Alt+&Y)"; 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 { @@ -2733,6 +2927,12 @@ public sealed class MainForm : Form maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)"; continuousTuneBox.Text = "Continuous auto-tune latency (Alt+&T)"; 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). var driftDrops = receiver.DriftDropFrames; 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: // emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send) // 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 // not visible in bufAvg. Always 0 in WasapiOnly (no FanOut). 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 " + $"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " + $"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} fanCacheMs={fanCacheMs} " + - $"trimB={trimBytes} trimN={trimFires} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " + - $"driftDrop={driftDrops} driftRep={driftReps} " + - $"sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " + + $"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " + + $"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " + + $"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}"); } 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, // since it tells us whether THIS machine's capture path is stalling. Without // 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 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: @@ -3521,13 +3801,60 @@ public sealed class MainForm : Form /// protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { - // No ProcessCmdKey overrides currently — base class handles everything. The previous - // Alt+M tab-local gating became unnecessary once the Audio mode listbox was retired - // (2026-05-11); minimise to tray is reachable via Alt+F → M (File menu mnemonic) or - // the configurable "Show or hide window" global hotkey (default Ctrl+Shift+F10). + // Defensive gate for the global menu shortcuts that change state (Ctrl+R = toggle + // recording, Ctrl+S = save profile). The default WinForms behaviour fires these + // shortcuts any time the form has keyboard focus — which technically includes the + // 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); } + // 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); + } + + /// Defensive gate for global menu shortcuts that change state. See the comment + /// in for the full rationale. + 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 ===================== @@ -3973,13 +4300,13 @@ public sealed class MainForm : Form previousPeerHealthStates.TryGetValue(key, out var prior); 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)"); } else if (ph.State == PeerHealthState.Unreachable && (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)"); } previousPeerHealthStates[key] = ph.State; @@ -3991,7 +4318,7 @@ public sealed class MainForm : Form { if (previousPeerHealthStates[key] == PeerHealthState.Healthy) { - if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play(); + if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play(); logFile.Event($"peer disconnected cue: {key} (deselected while Healthy)"); } 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 /// timer running. The per-route filtering inside the tick gates which sliders actually /// move. + /// 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. + private bool AnyAutoTuneEnabled() + { + var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent; + var asioOn = inBothIndependent && continuousTuneAsioBox.Checked; + return continuousTuneEnabled || asioOn; + } + private void ApplyContinuousTuneTimer() { 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); } } diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index a4c29ac..80a75d5 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -3,15 +3,21 @@ using RemSound.Core; namespace RemSound.App; /// -/// Preferences dialog. Holds the three settings that used to live on the (now-removed) -/// Profiles and preferences tab and aren't profile-management actions in their own right: -/// * Mute connect/disconnect sounds — the small ding on peer state changes. +/// Preferences dialog. Holds settings that aren't profile-management actions in their own +/// right: +/// * 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. /// * Startup behaviour — opens the existing sub-dialog. +/// * Update settings — frequency, manual check, silent-install toggle. +/// * Enable logs + Write logs now. /// -/// Both checkboxes save through on every change (so -/// the user doesn't need to re-confirm via an OK button). The Startup behaviour button -/// just opens the existing modal sub-dialog. Esc or the Close button dismisses. +/// All settings save through or +/// on every change (no OK-to-commit). Esc or Close dismisses. /// /// Reachable via the File → Preferences menu item or Ctrl+P from the main window. /// @@ -24,13 +30,36 @@ internal sealed class PreferencesDialog : Form 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)", - AccessibleName = "Mute connect/disconnect sounds", + Text = "Cue sou&nds (Alt+N):", + AccessibleName = "Cue sounds", 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() { Text = "Accept remote volume commands from peers (Alt+&A)", @@ -119,7 +148,7 @@ internal sealed class PreferencesDialog : Form ShowInTaskbar = false; StartPosition = FormStartPosition.CenterParent; KeyPreview = true; - ClientSize = new Size(560, 440); + ClientSize = new Size(560, 540); // 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup // 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); }; - muteCuesBox.Checked = settings.LoadMuteConnectionCues(); - muteCuesBox.CheckedChanged += (_, _) => + // Populate the cue list — order must match CueIndex enum. Each item is ticked from + // 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; }; @@ -224,11 +270,11 @@ internal sealed class PreferencesDialog : Form for (var i = 0; i < 9; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); 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 // sit above the log row so a user setting up the app meets them first. browseProfilesFolderButton.TabIndex = 0; - muteCuesBox.TabIndex = 1; + cueList.TabIndex = 1; acceptRemoteVolumeBox.TabIndex = 2; startupBehaviourButton.TabIndex = 3; updateFrequencyBox.TabIndex = 4; @@ -252,8 +298,24 @@ internal sealed class PreferencesDialog : Form freqRow.Controls.Add(updateFrequencyLabel); 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(muteCuesBox, 0, 1); + panel.Controls.Add(cueGroup, 0, 1); panel.Controls.Add(acceptRemoteVolumeBox, 0, 2); panel.Controls.Add(startupBehaviourButton, 0, 3); panel.Controls.Add(freqRow, 0, 4); diff --git a/src/RemSound.App/RecordingController.cs b/src/RemSound.App/RecordingController.cs new file mode 100644 index 0000000..7db8814 --- /dev/null +++ b/src/RemSound.App/RecordingController.cs @@ -0,0 +1,152 @@ +using RemSound.Core; +using RemSound.Receiver; +using RemSound.Sender; + +namespace RemSound.App; + +/// +/// Glue between MainForm's Record menu and the actual recording pipeline. Owns the +/// lifecycle of the currently-running (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. +/// +internal sealed class RecordingController +{ + private readonly AudioSender sender; + private readonly AudioReceiver receiver; + private readonly RemSoundSettingsStore settings; + private readonly Action diagnostic; + private AudioRecorder? active; + + public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action diagnostic) + { + this.sender = sender; + this.receiver = receiver; + this.settings = settings; + this.diagnostic = diagnostic; + } + + public bool IsRecording => active is not null; + + /// 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. + public event Action? RecordingStateChanged; + + /// 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). + 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); + } + + /// 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. + 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)"); + } + + /// 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. + 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); + } + } + + /// 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. + 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; + } +} diff --git a/src/RemSound.App/RecordingSettingsDialog.cs b/src/RemSound.App/RecordingSettingsDialog.cs new file mode 100644 index 0000000..f085cb4 --- /dev/null +++ b/src/RemSound.App/RecordingSettingsDialog.cs @@ -0,0 +1,432 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// +/// 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 so the caller can +/// MarkProfileDirty after a successful OK. +/// +/// Reachable from the Record menu → "Recording settings...". +/// +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, + }; + + /// True if the user pressed OK and any setting actually changed. The caller + /// uses this to mark the profile dirty. + public bool ChangedAnything { get; private set; } + + /// The final settings (after OK). Equals the input settings if Cancel was + /// pressed — caller should ignore this on a non-OK DialogResult. + 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); +} diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj index dd84680..b8218e4 100644 --- a/src/RemSound.App/RemSound.App.csproj +++ b/src/RemSound.App/RemSound.App.csproj @@ -14,7 +14,7 @@ tag_name on the latest GitHub release; bump it on every public release. The AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version is what the About dialog and the updater both read. --> - 1.1.0 + 1.2.0 @@ -22,6 +22,17 @@ + + + + + + @@ -35,6 +46,16 @@ disconnect.wav PreserveNewest + + + record start.wav + PreserveNewest + + + record stop.wav + PreserveNewest +
      TermMeaning