v3.4 refinements: WASAPI drift correction, startup-dialog sequencing, quick-switch polish, docs
Builds on the v3.4 freeze (dd70613) with the fixes and tuning from live testing,
plus the v3.4 documentation pass.
Audio (receiver):
- Per-device WASAPI drift correction in MultiOutputPlayout. A pull-side resampler
(mirroring SessionPlayout's proven corrector) holds each output device's buffer at
a fixed low depth, cancelling the slow clock drift that made WASAPI peers "lag
apart" over long sessions. Feed-forward clock-ratio measurement plus a gentle
depth-restoring term; the first measurement window is discarded because WASAPI
start-up priming poisons it. ASIO already self-corrected; this brings WASAPI level.
- Output device buffer requested at 5 ms (WASAPI clamps it up to the device's minimum
period, ~10 ms) instead of 15 ms, since the corrector keeps it fed — a free saving.
UI / accessibility (MainForm, Program):
- Startup notices (what's-new About box, Realtek warning) now run one at a time via a
single sequence instead of separate BeginInvokes, so they no longer stack into
nested modals that couldn't be closed. The loading splash is skipped for a
tray-bound quick switch.
- Quick profile switch keeps RemSound in the tray if it was there, and plays the
switch cue immediately on click.
- Profile-switch cue now plays on click for every switch path (recent menu, quick
switch, File > Open) and no longer on a fresh start into the first profile. It was
also previously dead on the rebuilt form (pendingProfile was nulled first).
- Realtek ASIO toggle's accessible name now reads "Enable"/"Disable" to match the
visible text, instead of "Toggle" (screen reader read the wrong word).
Docs (plain English):
- RELEASE_NOTES.md: v3.4 entry.
- About dialog: v3.4 "what's new".
- readme.html (the canonical bundled manual): quick switch, the hotkey read-outs, the
new profile-menu-open cue, Realtek auto-detect/disable, and the config-folder path.
- MANUAL.md regenerated from readme.html via sync-manual.py so the two stay in sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
dd70613017
commit
946e6f4be4
@@ -48,6 +48,8 @@ Step| What happens
|
|||||||
|
|
||||||
There is no central server, no account, and nothing stored online. The sound goes straight from one computer to the other.
|
There is no central server, no account, and nothing stored online. The sound goes straight from one computer to the other.
|
||||||
|
|
||||||
|
> **RemSound on Android (receiver):** there is a companion app that lets a phone or tablet _receive_ RemSound audio — handy for listening on the move. It's a separate community project built and maintained by Aryan Choudhary, who is a screen-reader user himself and has tuned the app for TalkBack; it is not part of RemSound and is not maintained by us. Get the signed app from its releases page (download the latest **app-release.apk**): [RemSound Android — Releases](https://github.com/aryanchoudharypro/RemSoundAndroid/releases).
|
||||||
|
|
||||||
## 2. Quick start
|
## 2. Quick start
|
||||||
|
|
||||||
Let's assume you and a friend both have RemSound running, and that your two computers can reach each other on the network (the same Wi-Fi, the same Tailscale account, and so on).
|
Let's assume you and a friend both have RemSound running, and that your two computers can reach each other on the network (the same Wi-Fi, the same Tailscale account, and so on).
|
||||||
@@ -109,10 +111,12 @@ Action| How
|
|||||||
|
|
||||||
### Where profiles are stored
|
### Where profiles are stored
|
||||||
|
|
||||||
Each profile is one small file on your computer. By default they live at:
|
Each profile is one small file on your computer. By default they live in a `config` folder inside RemSound, at:
|
||||||
|
|
||||||
|
|
||||||
<RemSound folder>\profiles\<your computer name>\<profile name>
|
<RemSound folder>\config\profiles\<your computer name>\<profile name>
|
||||||
|
|
||||||
|
(If you're upgrading from an older version, RemSound moves your existing settings and profiles into this `config` folder automatically the first time you run this version, and tells you once that it's done it. Nothing is lost.)
|
||||||
|
|
||||||
The folder named after your computer keeps each machine's profiles separate. If you used the **Browse …** button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder — with no per-computer subfolder — so two computers pointed at the same shared folder see exactly the same list.
|
The folder named after your computer keeps each machine's profiles separate. If you used the **Browse …** button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder — with no per-computer subfolder — so two computers pointed at the same shared folder see exactly the same list.
|
||||||
|
|
||||||
@@ -419,6 +423,8 @@ RemSound has no buffer-size control of its own. To change the ASIO buffer size,
|
|||||||
|
|
||||||
> **About Realtek ASIO:** if you see “Realtek ASIO” in the driver list, be careful with it. Despite the name, it isn't tied to Realtek hardware — it's a generic driver that opens whatever Windows treats as the default sound device. On a computer that has a real audio interface (Audient, Komplete, and so on), choosing Realtek ASIO will often grab _that_ interface and end up fighting both your real ASIO driver and your screen reader for the same hardware. It's usually best to ignore Realtek ASIO completely.
|
> **About Realtek ASIO:** if you see “Realtek ASIO” in the driver list, be careful with it. Despite the name, it isn't tied to Realtek hardware — it's a generic driver that opens whatever Windows treats as the default sound device. On a computer that has a real audio interface (Audient, Komplete, and so on), choosing Realtek ASIO will often grab _that_ interface and end up fighting both your real ASIO driver and your screen reader for the same hardware. It's usually best to ignore Realtek ASIO completely.
|
||||||
|
|
||||||
|
> **RemSound watches for it for you (new in v3.4):** if a Realtek ASIO driver is installed, RemSound spots it on startup and offers, just once, to disable it — partly for the device-grabbing reason above, and partly because it leaks Windows resources every time it's opened. Say yes and RemSound adds it to a never-touch list and takes it out of the driver picker, so it can't be chosen by accident. You can reverse that — or disable it later if you kept it — any time from **Options → Enable / Disable Realtek ASIO driver in RemSound**. Once you've answered the startup question, RemSound won't ask again.
|
||||||
|
|
||||||
### Same driver, sending and receiving, on one computer
|
### Same driver, sending and receiving, on one computer
|
||||||
|
|
||||||
RemSound supports this — you can capture from your audio interface and play received sound out of the same interface at the same time, on the same computer. Most modern professional audio drivers handle this fine.
|
RemSound supports this — you can capture from your audio interface and play received sound out of the same interface at the same time, on the same computer. Most modern professional audio drivers handle this fine.
|
||||||
@@ -742,6 +748,7 @@ Hotkey| Action| Default
|
|||||||
Receive mute| Mute / unmute incoming sound (this computer)| Ctrl+Shift+Alt+R
|
Receive mute| Mute / unmute incoming sound (this computer)| Ctrl+Shift+Alt+R
|
||||||
Send mute| Mute / unmute outgoing sound (this computer)| Ctrl+Shift+Alt+S
|
Send mute| Mute / unmute outgoing sound (this computer)| Ctrl+Shift+Alt+S
|
||||||
Tray toggle| Show / hide the main window| Ctrl+Shift+F10
|
Tray toggle| Show / hide the main window| Ctrl+Shift+F10
|
||||||
|
Quick profile switch| Pop up a list of all your profiles and switch to one — works from anywhere, even with RemSound in the tray (where it stays after the switch). See _Quick profile switch_ below.| Unset
|
||||||
Volume up / down| Adjust this computer's received-sound volume| Unset
|
Volume up / down| Adjust this computer's received-sound volume| Unset
|
||||||
Start / Stop recording| Start or stop a recording on this computer. The same toggle as the Record menu's start/stop item and the in-app Ctrl+R, but it works system-wide (RemSound doesn't need to be the active window). See Recording to a file for what gets captured.| Unset
|
Start / Stop recording| Start or stop a recording on this computer. The same toggle as the Record menu's start/stop item and the in-app Ctrl+R, but it works system-wide (RemSound doesn't need to be the active window). See Recording to a file for what gets captured.| Unset
|
||||||
Send remote volume up to peers| Tell every connected peer to raise their RemSound volume slider by 5 points (only obeyed by peers that have ticked “Accept remote volume commands”). It doesn't change your own volume. See Remote control.| Unset
|
Send remote volume up to peers| Tell every connected peer to raise their RemSound volume slider by 5 points (only obeyed by peers that have ticked “Accept remote volume commands”). It doesn't change your own volume. See Remote control.| Unset
|
||||||
@@ -753,6 +760,16 @@ Send Windows global mute toggle to peers| Tell every connected peer to toggle th
|
|||||||
|
|
||||||
You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.
|
You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.
|
||||||
|
|
||||||
|
### Quick profile switch
|
||||||
|
|
||||||
|
Once you've given **Quick profile switch** a key, pressing it anywhere pops up a small list of every profile you have. Arrow to the one you want and press Enter (or click it) to switch straight to it. The profile you're currently on is marked in the list. A sound plays as the list opens, and the profile-switch sound plays the moment you pick one. Press Escape to close the list without switching.
|
||||||
|
|
||||||
|
If RemSound was minimised to the system tray when you pressed the hotkey, it switches the profile and **stays in the tray** — the window doesn't jump up in front of whatever you're doing. So you can change profiles mid-task without losing your place.
|
||||||
|
|
||||||
|
### Your screen reader reads out the hotkeys
|
||||||
|
|
||||||
|
Once a hotkey is set, your screen reader reads it out whenever you land on the menu item or control it's tied to — for example, moving onto **File → Open profile** announces “Ctrl+O”, and a control with a global hotkey announces “press [your key] anywhere”. So you can learn and confirm your shortcuts just by arrowing around the window, without coming back to this dialog.
|
||||||
|
|
||||||
## 16. Remote control: adjusting a peer's listening volume from your end
|
## 16. Remote control: adjusting a peer's listening volume from your end
|
||||||
|
|
||||||
Here's the situation this is for: you're on your laptop, listening to sound coming from your desktop, and you've got NVDA Remote open so you can drive the desktop using your laptop's keyboard. Every key you press goes to the desktop — including any volume key on the laptop, which now never reaches the laptop itself. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.
|
Here's the situation this is for: you're on your laptop, listening to sound coming from your desktop, and you've got NVDA Remote open so you can drive the desktop using your laptop's keyboard. Every key you press goes to the desktop — including any volume key on the laptop, which now never reaches the laptop itself. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.
|
||||||
@@ -824,7 +841,7 @@ Toggle| What it does
|
|||||||
|
|
||||||
## 18. Audio cue sounds
|
## 18. Audio cue sounds
|
||||||
|
|
||||||
RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called **cue sounds**. Seven events have a cue:
|
RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called **cue sounds**. Eight events have a cue:
|
||||||
|
|
||||||
Cue| Plays when
|
Cue| Plays when
|
||||||
---|---
|
---|---
|
||||||
@@ -833,14 +850,15 @@ Cue| Plays when
|
|||||||
**Recording start sound**| You start a recording.
|
**Recording start sound**| You start a recording.
|
||||||
**Recording stop sound**| You stop a recording.
|
**Recording stop sound**| You stop a recording.
|
||||||
**Profile saved sound**| A profile is saved — whether via File → Save or File → Save as.
|
**Profile saved sound**| A profile is saved — whether via File → Save or File → Save as.
|
||||||
**Profile switched sound**| A profile finishes loading. Plays at startup if you started with a profile, and after every profile switch — using the new profile's cue, not the old one's.
|
**Profile switched sound**| You switch to a different profile — from the Recent profiles menu, the Quick profile switch popup, or File → Open profile. It plays the moment you pick the new profile. It deliberately does _not_ play on a fresh start into your first profile, so it isn't layered on top of the connect sound at launch.
|
||||||
|
**Profile menu open sound**| The Quick profile switch popup opens.
|
||||||
**Update sound**| An update is about to install — it plays just before RemSound closes to update itself. Handy when updates install silently in the background, so you're not caught off guard when RemSound restarts. Plays whether you ran the update by hand or it installed on its own.
|
**Update sound**| An update is about to install — it plays just before RemSound closes to update itself. Handy when updates install silently in the background, so you're not caught off guard when RemSound restarts. Plays whether you ran the update by hand or it installed on its own.
|
||||||
|
|
||||||
All seven cues play through your default Windows sound output, which is separate from the audio RemSound is sending or receiving. They don't appear in a normal recording. (The exception: if your sending side is capturing the very output device the cues play through, then they get captured along with everything else from that device.)
|
All eight cues play through your default Windows sound output, which is separate from the audio RemSound is sending or receiving. They don't appear in a normal recording. (The exception: if your sending side is capturing the very output device the cues play through, then they get captured along with everything else from that device.)
|
||||||
|
|
||||||
### Turning each cue on or off
|
### Turning each cue on or off
|
||||||
|
|
||||||
Open **File → Preferences** (or Ctrl+P). The **Audio cue sounds (Alt+N)** list shows all seven cues with a tickbox next to each. Tick to play the cue when the corresponding event happens; untick to silence it.
|
Open **File → Preferences** (or Ctrl+P). The **Audio cue sounds (Alt+N)** list shows all eight cues with a tickbox next to each. Tick to play the cue when the corresponding event happens; untick to silence it.
|
||||||
|
|
||||||
Use the up and down arrow keys to move between cues; press **Space** to toggle the highlighted cue's tick on or off.
|
Use the up and down arrow keys to move between cues; press **Space** to toggle the highlighted cue's tick on or off.
|
||||||
|
|
||||||
@@ -873,6 +891,8 @@ The default WAV files are in the `sounds` folder next to `RemSound.exe`. If you
|
|||||||
* `sounds\record stop.wav` — recording stop cue
|
* `sounds\record stop.wav` — recording stop cue
|
||||||
* `sounds\save.wav` — profile saved cue
|
* `sounds\save.wav` — profile saved cue
|
||||||
* `sounds\profile.wav` — profile switched cue
|
* `sounds\profile.wav` — profile switched cue
|
||||||
|
* `sounds\profile menu open.wav` — profile menu open cue
|
||||||
|
* `sounds\update.wav` — update cue
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+28
-21
@@ -1,44 +1,51 @@
|
|||||||
# RemSound v3.3
|
# RemSound v3.4
|
||||||
|
|
||||||
The big one: **your audio is now encrypted end to end.** Plus the connect/disconnect cues are fixed, and a handful of smaller reliability improvements.
|
A round of quality-of-life and reliability improvements: a fast new way to switch profiles from anywhere, a safety net for the troublesome Realtek ASIO driver, your screen reader now reads out global hotkeys, and WASAPI connections stay rock-steady over long sessions.
|
||||||
|
|
||||||
## Your audio is now encrypted
|
## Quick profile switch
|
||||||
|
|
||||||
Everything RemSound sends is now scrambled as it leaves your computer and only unscrambled at the other end — so nobody in between can listen in, and you no longer need a VPN just to keep a private connection private.
|
There's a new global hotkey — **Quick profile switch** — that pops up a small list of all your profiles, wherever you are and whatever you're doing. Arrow to the one you want, press Enter, and RemSound switches straight to it.
|
||||||
|
|
||||||
It works with a password on each profile:
|
- The profile you're currently on is marked in the list.
|
||||||
|
- A sound plays as the list opens, and the switch cue plays the moment you pick one.
|
||||||
|
- If RemSound was tucked away in the system tray, **it stays there** — no window jumping up in front of whatever you're working on.
|
||||||
|
|
||||||
- **You and the person you connect to use the same password** → you hear each other.
|
It's unset by default. Give it a key under **Options → Keyboard shortcuts**.
|
||||||
- **Different passwords** → no audio passes, and RemSound tells you so plainly instead of leaving you with silent confusion.
|
|
||||||
|
|
||||||
Setting a password is easy: RemSound asks for one when you create a profile, you can change it any time with **File → Change this profile's password**, and you can see and edit the passwords for all your profiles in one place under **Options → Profile passwords**. If you start sending or receiving on a profile with no password, RemSound asks you to set one first. The encryption adds no delay you could ever notice.
|
## A safety net for the Realtek ASIO driver
|
||||||
|
|
||||||
### Important: everyone needs v3.3
|
Realtek's bundled ASIO driver leaks Windows resources every time it's opened, and can make audio unstable. RemSound now **spots it on startup and offers, just once, to disable it** — say yes and RemSound will never touch that driver again. If you'd rather keep it, RemSound won't nag you about it after that first time.
|
||||||
|
|
||||||
Because the audio format changed to carry the encryption, **a v3.3 copy can only talk to other v3.3 (and later) copies.** Anyone you connect with needs to update to v3.3 too. If you try to connect to an older copy, RemSound will tell you they need to update.
|
You can flip it back on, or off, whenever you like from **Options → Enable / Disable Realtek ASIO driver in RemSound**.
|
||||||
|
|
||||||
## Connect and disconnect cues, fixed
|
## Your screen reader now reads out global hotkeys
|
||||||
|
|
||||||
- **Cues now play reliably, whatever the WAV format.** The old sound player couldn't handle high-resolution (24-bit / 96 kHz) files and would play them only sometimes — so cues, and the Preview button, were hit-and-miss. They now play every time, including any custom sound you supply.
|
When you move over a menu item, or a control that has a global hotkey assigned, your screen reader now reads the hotkey along with everything else — for example, "press Ctrl+Shift+M anywhere". So you can learn and double-check your shortcuts just by arrowing around the window, without opening the shortcuts dialog.
|
||||||
- **The connect/disconnect cues now follow the actual audio**, not just the background "are you there?" heartbeat. So you won't hear a false "disconnect" while the sound is still playing, and the connect ding lands when the audio actually starts.
|
|
||||||
|
## Smoother long sessions on WASAPI
|
||||||
|
|
||||||
|
On WASAPI — the ordinary Windows sound path — two machines' sound clocks run at very slightly different speeds. Over a long session that tiny difference used to add up, and the delay would slowly drift. RemSound now measures and gently corrects that drift the whole time, so a WASAPI connection is as tight at the end of a three-hour session as it was at the start. (ASIO already kept itself in step; this brings WASAPI up to the same standard.)
|
||||||
|
|
||||||
## Smaller improvements
|
## Smaller improvements
|
||||||
|
|
||||||
- **No more crackle from address-hopping.** On setups where a peer is reachable two ways at once (e.g. a VPN and a local network), RemSound now sticks to the address that's actually working instead of flip-flopping between them.
|
- **A new "profile menu open" cue** sounds when the Quick profile switch popup appears. Like every other cue, it has its own mute toggle and custom-sound option in Preferences.
|
||||||
- **Honest "online/offline".** A peer is no longer shown "offline" just because a discovery beacon blinked — only when it's genuinely gone by every measure.
|
- **The profile-switch cue now plays the instant you switch**, and no longer plays on a fresh start into your first profile — so you won't hear it stacked on top of the connect sound at launch.
|
||||||
- **See what's new after an update.** RemSound now opens its About box once after each update so you can see what changed. On by default; turn it off in Preferences.
|
- **Faster device detection.** RemSound now reacts the moment you plug in or unplug a sound device, instead of checking every few seconds. This also closed a slow resource leak on the receiving side.
|
||||||
|
- **Tidier folder.** Your settings and profiles now live in a `config` folder inside RemSound. The move happens automatically the first time you run v3.4, and RemSound tells you once that it's done it. Nothing is lost, and everything works exactly as before.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
**v3.4 talks to v3.3 with no trouble** — the over-the-network format hasn't changed, so you don't have to update both ends at once. (You still need everyone on **v3.3 or newer**, because that's where end-to-end encryption came in.)
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. Download `RemSound-v3.3.zip` from this release.
|
1. Download `RemSound-v3.4.zip` from this release.
|
||||||
2. Close RemSound.
|
2. Close RemSound.
|
||||||
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your profiles, settings or recordings.
|
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your profiles, settings or recordings.
|
||||||
4. Run `RemSound.exe`.
|
4. Run `RemSound.exe`.
|
||||||
|
|
||||||
After updating, set a password on the profile(s) you use to connect (RemSound will prompt you), and make sure the people you connect with have also updated to v3.3 and are using the same password.
|
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
**v1.9 through v3.2:** Help → Check for updates works — it will fetch and install v3.3 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.3 installs itself shortly after launch.
|
**From v1.9 onward:** Help → Check for updates works — it will fetch and install v3.4 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.4 installs itself shortly after launch.
|
||||||
|
|
||||||
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it installing updates, so Check for updates will download v3.3 but not apply it. Install v3.3 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it installing updates, so Check for updates will download v3.4 but not apply it. Install v3.4 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
||||||
|
|||||||
+26
-6
@@ -137,9 +137,11 @@ ul, ol { padding-left: 1.4em; }
|
|||||||
|
|
||||||
<h3>Where profiles are stored</h3>
|
<h3>Where profiles are stored</h3>
|
||||||
|
|
||||||
<p>Each profile is one small file on your computer. By default they live at:</p>
|
<p>Each profile is one small file on your computer. By default they live in a <code>config</code> folder inside RemSound, at:</p>
|
||||||
|
|
||||||
<pre><RemSound folder>\profiles\<your computer name>\<profile name></pre>
|
<pre><RemSound folder>\config\profiles\<your computer name>\<profile name></pre>
|
||||||
|
|
||||||
|
<p>(If you're upgrading from an older version, RemSound moves your existing settings and profiles into this <code>config</code> folder automatically the first time you run this version, and tells you once that it's done it. Nothing is lost.)</p>
|
||||||
|
|
||||||
<p>The folder named after your computer keeps each machine's profiles separate. If you used the <strong>Browse…</strong> button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder — with no per-computer subfolder — so two computers pointed at the same shared folder see exactly the same list.</p>
|
<p>The folder named after your computer keeps each machine's profiles separate. If you used the <strong>Browse…</strong> button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder — with no per-computer subfolder — so two computers pointed at the same shared folder see exactly the same list.</p>
|
||||||
|
|
||||||
@@ -462,6 +464,10 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
|||||||
<strong>About Realtek ASIO:</strong> if you see “Realtek ASIO” in the driver list, be careful with it. Despite the name, it isn't tied to Realtek hardware — it's a generic driver that opens whatever Windows treats as the default sound device. On a computer that has a real audio interface (Audient, Komplete, and so on), choosing Realtek ASIO will often grab <em>that</em> interface and end up fighting both your real ASIO driver and your screen reader for the same hardware. It's usually best to ignore Realtek ASIO completely.
|
<strong>About Realtek ASIO:</strong> if you see “Realtek ASIO” in the driver list, be careful with it. Despite the name, it isn't tied to Realtek hardware — it's a generic driver that opens whatever Windows treats as the default sound device. On a computer that has a real audio interface (Audient, Komplete, and so on), choosing Realtek ASIO will often grab <em>that</em> interface and end up fighting both your real ASIO driver and your screen reader for the same hardware. It's usually best to ignore Realtek ASIO completely.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="warn">
|
||||||
|
<strong>RemSound watches for it for you (new in v3.4):</strong> if a Realtek ASIO driver is installed, RemSound spots it on startup and offers, just once, to disable it — partly for the device-grabbing reason above, and partly because it leaks Windows resources every time it's opened. Say yes and RemSound adds it to a never-touch list and takes it out of the driver picker, so it can't be chosen by accident. You can reverse that — or disable it later if you kept it — any time from <strong>Options → Enable / Disable Realtek ASIO driver in RemSound</strong>. Once you've answered the startup question, RemSound won't ask again.
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Same driver, sending and receiving, on one computer</h3>
|
<h3>Same driver, sending and receiving, on one computer</h3>
|
||||||
<p>RemSound supports this — you can capture from your audio interface and play received sound out of the same interface at the same time, on the same computer. Most modern professional audio drivers handle this fine.</p>
|
<p>RemSound supports this — you can capture from your audio interface and play received sound out of the same interface at the same time, on the same computer. Most modern professional audio drivers handle this fine.</p>
|
||||||
|
|
||||||
@@ -786,6 +792,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
|||||||
<tr><td>Receive mute</td><td>Mute / unmute incoming sound (this computer)</td><td>Ctrl+Shift+Alt+R</td></tr>
|
<tr><td>Receive mute</td><td>Mute / unmute incoming sound (this computer)</td><td>Ctrl+Shift+Alt+R</td></tr>
|
||||||
<tr><td>Send mute</td><td>Mute / unmute outgoing sound (this computer)</td><td>Ctrl+Shift+Alt+S</td></tr>
|
<tr><td>Send mute</td><td>Mute / unmute outgoing sound (this computer)</td><td>Ctrl+Shift+Alt+S</td></tr>
|
||||||
<tr><td>Tray toggle</td><td>Show / hide the main window</td><td>Ctrl+Shift+F10</td></tr>
|
<tr><td>Tray toggle</td><td>Show / hide the main window</td><td>Ctrl+Shift+F10</td></tr>
|
||||||
|
<tr><td>Quick profile switch</td><td>Pop up a list of all your profiles and switch to one — works from anywhere, even with RemSound in the tray (where it stays after the switch). See <em>Quick profile switch</em> below.</td><td>Unset</td></tr>
|
||||||
<tr><td>Volume up / down</td><td>Adjust this computer's received-sound volume</td><td>Unset</td></tr>
|
<tr><td>Volume up / down</td><td>Adjust this computer's received-sound volume</td><td>Unset</td></tr>
|
||||||
<tr><td>Start / Stop recording</td><td>Start or stop a recording on this computer. The same toggle as the Record menu's start/stop item and the in-app Ctrl+R, but it works system-wide (RemSound doesn't need to be the active window). See <a href="#recording">Recording to a file</a> for what gets captured.</td><td>Unset</td></tr>
|
<tr><td>Start / Stop recording</td><td>Start or stop a recording on this computer. The same toggle as the Record menu's start/stop item and the in-app Ctrl+R, but it works system-wide (RemSound doesn't need to be the active window). See <a href="#recording">Recording to a file</a> for what gets captured.</td><td>Unset</td></tr>
|
||||||
<tr><td>Send remote volume up to peers</td><td>Tell every connected peer to raise their RemSound volume slider by 5 points (only obeyed by peers that have ticked “Accept remote volume commands”). It doesn't change your own volume. See <a href="#remote-control">Remote control</a>.</td><td>Unset</td></tr>
|
<tr><td>Send remote volume up to peers</td><td>Tell every connected peer to raise their RemSound volume slider by 5 points (only obeyed by peers that have ticked “Accept remote volume commands”). It doesn't change your own volume. See <a href="#remote-control">Remote control</a>.</td><td>Unset</td></tr>
|
||||||
@@ -798,6 +805,16 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
|||||||
|
|
||||||
<p>You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.</p>
|
<p>You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.</p>
|
||||||
|
|
||||||
|
<h3>Quick profile switch</h3>
|
||||||
|
|
||||||
|
<p>Once you've given <strong>Quick profile switch</strong> a key, pressing it anywhere pops up a small list of every profile you have. Arrow to the one you want and press Enter (or click it) to switch straight to it. The profile you're currently on is marked in the list. A sound plays as the list opens, and the profile-switch sound plays the moment you pick one. Press Escape to close the list without switching.</p>
|
||||||
|
|
||||||
|
<p>If RemSound was minimised to the system tray when you pressed the hotkey, it switches the profile and <strong>stays in the tray</strong> — the window doesn't jump up in front of whatever you're doing. So you can change profiles mid-task without losing your place.</p>
|
||||||
|
|
||||||
|
<h3>Your screen reader reads out the hotkeys</h3>
|
||||||
|
|
||||||
|
<p>Once a hotkey is set, your screen reader reads it out whenever you land on the menu item or control it's tied to — for example, moving onto <strong>File → Open profile</strong> announces “Ctrl+O”, and a control with a global hotkey announces “press [your key] anywhere”. So you can learn and confirm your shortcuts just by arrowing around the window, without coming back to this dialog.</p>
|
||||||
|
|
||||||
<h2 id="remote-control">16. Remote control: adjusting a peer's listening volume from your end</h2>
|
<h2 id="remote-control">16. Remote control: adjusting a peer's listening volume from your end</h2>
|
||||||
|
|
||||||
<p>Here's the situation this is for: you're on your laptop, listening to sound coming from your desktop, and you've got NVDA Remote open so you can drive the desktop using your laptop's keyboard. Every key you press goes to the desktop — including any volume key on the laptop, which now never reaches the laptop itself. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.</p>
|
<p>Here's the situation this is for: you're on your laptop, listening to sound coming from your desktop, and you've got NVDA Remote open so you can drive the desktop using your laptop's keyboard. Every key you press goes to the desktop — including any volume key on the laptop, which now never reaches the laptop itself. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.</p>
|
||||||
@@ -879,7 +896,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
|
|
||||||
<h2 id="audio-cues">18. Audio cue sounds</h2>
|
<h2 id="audio-cues">18. Audio cue sounds</h2>
|
||||||
|
|
||||||
<p>RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called <strong>cue sounds</strong>. Seven events have a cue:</p>
|
<p>RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called <strong>cue sounds</strong>. Eight events have a cue:</p>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Cue</th><th>Plays when</th></tr>
|
<tr><th>Cue</th><th>Plays when</th></tr>
|
||||||
@@ -888,15 +905,16 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
<tr><td><strong>Recording start sound</strong></td><td>You start a recording.</td></tr>
|
<tr><td><strong>Recording start sound</strong></td><td>You start a recording.</td></tr>
|
||||||
<tr><td><strong>Recording stop sound</strong></td><td>You stop a recording.</td></tr>
|
<tr><td><strong>Recording stop sound</strong></td><td>You stop a recording.</td></tr>
|
||||||
<tr><td><strong>Profile saved sound</strong></td><td>A profile is saved — whether via File → Save or File → Save as.</td></tr>
|
<tr><td><strong>Profile saved sound</strong></td><td>A profile is saved — whether via File → Save or File → Save as.</td></tr>
|
||||||
<tr><td><strong>Profile switched sound</strong></td><td>A profile finishes loading. Plays at startup if you started with a profile, and after every profile switch — using the new profile's cue, not the old one's.</td></tr>
|
<tr><td><strong>Profile switched sound</strong></td><td>You switch to a different profile — from the Recent profiles menu, the Quick profile switch popup, or File → Open profile. It plays the moment you pick the new profile. It deliberately does <em>not</em> play on a fresh start into your first profile, so it isn't layered on top of the connect sound at launch.</td></tr>
|
||||||
|
<tr><td><strong>Profile menu open sound</strong></td><td>The Quick profile switch popup opens.</td></tr>
|
||||||
<tr><td><strong>Update sound</strong></td><td>An update is about to install — it plays just before RemSound closes to update itself. Handy when updates install silently in the background, so you're not caught off guard when RemSound restarts. Plays whether you ran the update by hand or it installed on its own.</td></tr>
|
<tr><td><strong>Update sound</strong></td><td>An update is about to install — it plays just before RemSound closes to update itself. Handy when updates install silently in the background, so you're not caught off guard when RemSound restarts. Plays whether you ran the update by hand or it installed on its own.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p>All seven cues play through your default Windows sound output, which is separate from the audio RemSound is sending or receiving. They don't appear in a normal recording. (The exception: if your sending side is capturing the very output device the cues play through, then they get captured along with everything else from that device.)</p>
|
<p>All eight cues play through your default Windows sound output, which is separate from the audio RemSound is sending or receiving. They don't appear in a normal recording. (The exception: if your sending side is capturing the very output device the cues play through, then they get captured along with everything else from that device.)</p>
|
||||||
|
|
||||||
<h3>Turning each cue on or off</h3>
|
<h3>Turning each cue on or off</h3>
|
||||||
|
|
||||||
<p>Open <strong>File → Preferences</strong> (or Ctrl+P). The <strong>Audio cue sounds (Alt+N)</strong> list shows all seven cues with a tickbox next to each. Tick to play the cue when the corresponding event happens; untick to silence it.</p>
|
<p>Open <strong>File → Preferences</strong> (or Ctrl+P). The <strong>Audio cue sounds (Alt+N)</strong> list shows all eight cues with a tickbox next to each. Tick to play the cue when the corresponding event happens; untick to silence it.</p>
|
||||||
|
|
||||||
<p>Use the up and down arrow keys to move between cues; press <strong>Space</strong> to toggle the highlighted cue's tick on or off.</p>
|
<p>Use the up and down arrow keys to move between cues; press <strong>Space</strong> to toggle the highlighted cue's tick on or off.</p>
|
||||||
|
|
||||||
@@ -930,6 +948,8 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
<li><code>sounds\record stop.wav</code> — recording stop cue</li>
|
<li><code>sounds\record stop.wav</code> — recording stop cue</li>
|
||||||
<li><code>sounds\save.wav</code> — profile saved cue</li>
|
<li><code>sounds\save.wav</code> — profile saved cue</li>
|
||||||
<li><code>sounds\profile.wav</code> — profile switched cue</li>
|
<li><code>sounds\profile.wav</code> — profile switched cue</li>
|
||||||
|
<li><code>sounds\profile menu open.wav</code> — profile menu open cue</li>
|
||||||
|
<li><code>sounds\update.wav</code> — update cue</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>If a cue's WAV file is missing — either the default file doesn't exist or a custom path points at a file you've since deleted — the cue stays silent rather than producing an error. RemSound logs a note in the diagnostic log (if logging is on) so you can see what happened.</p>
|
<p>If a cue's WAV file is missing — either the default file doesn't exist or a custom path points at a file you've since deleted — the cue stays silent rather than producing an error. RemSound logs a note in the diagnostic log (if logging is on) so you can see what happened.</p>
|
||||||
|
|||||||
@@ -20,6 +20,41 @@ internal sealed class AboutDialog : Form
|
|||||||
/// updates" path.</summary>
|
/// updates" path.</summary>
|
||||||
private const string ReleaseNotes =
|
private const string ReleaseNotes =
|
||||||
"""
|
"""
|
||||||
|
RemSound v3.4
|
||||||
|
|
||||||
|
Quick profile switch: a new global hotkey pops up a
|
||||||
|
list of all your profiles from anywhere — even when
|
||||||
|
RemSound is in the system tray. Arrow to one, press
|
||||||
|
Enter, and it switches straight away. It marks the
|
||||||
|
profile you're on, plays a sound as the list opens
|
||||||
|
and again when you switch, and stays in the tray if
|
||||||
|
that's where it was. Unset by default; give it a key
|
||||||
|
under Options → Keyboard shortcuts.
|
||||||
|
|
||||||
|
A safety net for the Realtek ASIO driver, which leaks
|
||||||
|
Windows resources and can make audio unstable: RemSound
|
||||||
|
now spots it on startup and offers, just once, to
|
||||||
|
disable it. Re-enable or disable it any time from the
|
||||||
|
Options menu.
|
||||||
|
|
||||||
|
Your screen reader now reads out global hotkeys when
|
||||||
|
you move over the menu item or control they're tied to,
|
||||||
|
so you can learn your shortcuts just by arrowing around
|
||||||
|
— no need to open the shortcuts dialog.
|
||||||
|
|
||||||
|
Smoother long sessions on WASAPI: two machines' sound
|
||||||
|
clocks drift apart by a hair over time, which slowly
|
||||||
|
added delay. RemSound now corrects that continuously,
|
||||||
|
so a WASAPI link stays as tight after three hours as it
|
||||||
|
was at the start. (ASIO already kept itself in step.)
|
||||||
|
|
||||||
|
Also: a new "profile menu open" cue; the profile-switch
|
||||||
|
cue now plays the instant you switch, and no longer on
|
||||||
|
a fresh start; faster reaction when you plug or unplug a
|
||||||
|
device; and your settings now tuck into a "config"
|
||||||
|
folder, moved there automatically the first time you
|
||||||
|
run this version.
|
||||||
|
|
||||||
RemSound v3.3
|
RemSound v3.3
|
||||||
|
|
||||||
Your audio is now encrypted, end to end, so you no
|
Your audio is now encrypted, end to end, so you no
|
||||||
|
|||||||
@@ -1180,17 +1180,10 @@ public sealed class MainForm : Form
|
|||||||
// blank-template case (no pendingProfile) we schedule it here.
|
// blank-template case (no pendingProfile) we schedule it here.
|
||||||
if (pendingProfile is null) ScheduleBaselineCapture();
|
if (pendingProfile is null) ScheduleBaselineCapture();
|
||||||
ApplyPendingProfileToControls();
|
ApplyPendingProfileToControls();
|
||||||
// Profile-switch cue (2026-05-28): fires once after the profile finishes loading
|
// The profile-switch cue is played ON CLICK by the switch entry points (Recent menu,
|
||||||
// into the UI. Covers BOTH startup (user picks a profile from the picker) and
|
// quick switch, File open) — NOT here. A fresh launch into the first profile must stay
|
||||||
// mid-session switch (user picks a different profile from the menu — Program.Main
|
// silent: hearing the switch cue and then the connect cue at startup is confusing
|
||||||
// re-creates MainForm under the new profile). Skipped when the user is on the
|
// (Ed, 2026-06-08). So the rebuilt form never replays it.
|
||||||
// blank template, where there's no profile to announce. Honours the per-profile
|
|
||||||
// EnableProfileSwitchCue flag set in Preferences.
|
|
||||||
if (pendingProfile is not null
|
|
||||||
&& settings.LoadEnableProfileSwitchCue())
|
|
||||||
{
|
|
||||||
profileSwitchSound?.Play();
|
|
||||||
}
|
|
||||||
// Show/hide the Update vs Save-as buttons based on whether we're on a loaded
|
// Show/hide the Update vs Save-as buttons based on whether we're on a loaded
|
||||||
// profile or the blank template.
|
// profile or the blank template.
|
||||||
UpdateProfileButtonsVisibility();
|
UpdateProfileButtonsVisibility();
|
||||||
@@ -1210,7 +1203,9 @@ public sealed class MainForm : Form
|
|||||||
// some virtual-machine drivers throw a redraw exception). The pending-profile
|
// some virtual-machine drivers throw a redraw exception). The pending-profile
|
||||||
// apply path above is unaffected — settings/devices/peers are already wired
|
// apply path above is unaffected — settings/devices/peers are already wired
|
||||||
// up before we hide the window.
|
// up before we hide the window.
|
||||||
if (AppConfig.Load().StartMinimised)
|
var minimizeThisInstance = AppConfig.Load().StartMinimised || startNextInstanceMinimized;
|
||||||
|
startNextInstanceMinimized = false;
|
||||||
|
if (minimizeThisInstance)
|
||||||
{
|
{
|
||||||
BeginInvoke(() => trayController.Minimize());
|
BeginInvoke(() => trayController.Minimize());
|
||||||
}
|
}
|
||||||
@@ -1260,14 +1255,14 @@ public sealed class MainForm : Form
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user opted in, show the About box once on the first launch after an update
|
// Post-launch notices, shown ONE AT A TIME via a single BeginInvoke that runs them in
|
||||||
// installed, so they see what's new. BeginInvoke so it opens after Shown completes.
|
// sequence — NOT one BeginInvoke per notice. Separate BeginInvokes NEST: the second
|
||||||
BeginInvoke(new Action(MaybeShowWhatsNewAfterUpdate));
|
// dialog opens inside the first's modal message loop, the two stack on top of each
|
||||||
|
// other, and that nesting tangles their modal state so the boxes stop closing cleanly
|
||||||
// Offer once to disable a handle-leaking Realtek ASIO driver if one is installed.
|
// (the bug where the what's-new About box wouldn't close after the Realtek warning).
|
||||||
// BeginInvoke so the TaskDialog opens after Shown completes (and after the what's-new
|
// RunStartupNotices shows each notice, waits for the user to close it, THEN shows the
|
||||||
// box, if that fired).
|
// next — every one modal to the main window, never nested.
|
||||||
BeginInvoke(new Action(MaybeWarnAboutRealtekAsio));
|
BeginInvoke(new Action(RunStartupNotices));
|
||||||
};
|
};
|
||||||
|
|
||||||
statusTimer.Start();
|
statusTimer.Start();
|
||||||
@@ -1292,6 +1287,21 @@ public sealed class MainForm : Form
|
|||||||
deviceRefreshTimer.Start();
|
deviceRefreshTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the post-launch notices one at a time — each ShowDialog blocks until the user closes it,
|
||||||
|
/// so the next never opens on top of a still-open one. Order: the what's-new About box (after an
|
||||||
|
/// update), then the Realtek-ASIO compatibility warning. The config-migration notice is handled
|
||||||
|
/// separately in Program.Main (shown before the profile picker), so it's already outside this
|
||||||
|
/// sequence and can't stack with these.
|
||||||
|
/// </summary>
|
||||||
|
private void RunStartupNotices()
|
||||||
|
{
|
||||||
|
if (IsDisposed) return;
|
||||||
|
MaybeShowWhatsNewAfterUpdate();
|
||||||
|
if (IsDisposed) return;
|
||||||
|
MaybeWarnAboutRealtekAsio();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
|
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
|
||||||
/// running version changed since the last launch we recorded, open the About box once so
|
/// running version changed since the last launch we recorded, open the About box once so
|
||||||
/// they see what changed in the update just installed. Always records the current version
|
/// they see what changed in the update just installed. Always records the current version
|
||||||
@@ -1573,7 +1583,9 @@ public sealed class MainForm : Form
|
|||||||
ToolStripMenuItem? realtekToggle = null;
|
ToolStripMenuItem? realtekToggle = null;
|
||||||
if (realtekAsioDriverNames.Count > 0)
|
if (realtekAsioDriverNames.Count > 0)
|
||||||
{
|
{
|
||||||
realtekToggle = new ToolStripMenuItem { AccessibleName = "Toggle Realtek ASIO driver in RemSound" };
|
// AccessibleName is set (alongside Text) by UpdateRealtekAsioMenuItemText so the screen
|
||||||
|
// reader hears "Enable"/"Disable", matching what's shown — never "Toggle".
|
||||||
|
realtekToggle = new ToolStripMenuItem();
|
||||||
realtekToggle.Click += (_, _) => ToggleRealtekAsio();
|
realtekToggle.Click += (_, _) => ToggleRealtekAsio();
|
||||||
realtekAsioToggleItem = realtekToggle;
|
realtekAsioToggleItem = realtekToggle;
|
||||||
UpdateRealtekAsioMenuItemText();
|
UpdateRealtekAsioMenuItemText();
|
||||||
@@ -1689,6 +1701,12 @@ public sealed class MainForm : Form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Carried across the close-and-relaunch profile switch (static so the NEXT MainForm instance,
|
||||||
|
// built by Program.Main after this one closes, can read it). A switch done while RemSound was
|
||||||
|
// in the tray should land back in the tray; internal so Program.Main can also skip the
|
||||||
|
// "loading audio driver" splash in that case.
|
||||||
|
internal static bool startNextInstanceMinimized;
|
||||||
|
|
||||||
/// <summary>Switch to the profile at <paramref name="path"/> via the same close-and-relaunch
|
/// <summary>Switch to the profile at <paramref name="path"/> via the same close-and-relaunch
|
||||||
/// flow OpenProfileFromPicker uses. The active profile gets pushed to the front of the
|
/// flow OpenProfileFromPicker uses. The active profile gets pushed to the front of the
|
||||||
/// recents list by the next MainForm constructor when it sees the loaded path.</summary>
|
/// recents list by the next MainForm constructor when it sees the loaded path.</summary>
|
||||||
@@ -1709,8 +1727,20 @@ public sealed class MainForm : Form
|
|||||||
}
|
}
|
||||||
var title = Path.GetFileNameWithoutExtension(path);
|
var title = Path.GetFileNameWithoutExtension(path);
|
||||||
if (string.IsNullOrEmpty(title)) return;
|
if (string.IsNullOrEmpty(title)) return;
|
||||||
|
// Play the switch cue NOW, on click, for immediate feedback — CuePlayer.Play is fire-and-
|
||||||
|
// forget on its own thread + device, so it survives the form rebuild that follows. Covers
|
||||||
|
// BOTH the Recent-profiles menu and the quick-switch popup (both route through here). The
|
||||||
|
// rebuilt form deliberately does NOT replay it, so startup into the first profile is silent.
|
||||||
|
if (settings.LoadEnableProfileSwitchCue())
|
||||||
|
{
|
||||||
|
profileSwitchSound?.Play();
|
||||||
|
}
|
||||||
NextProfilePathToLoad = path;
|
NextProfilePathToLoad = path;
|
||||||
NextProfileTitleToLoad = title;
|
NextProfileTitleToLoad = title;
|
||||||
|
// If the switch was triggered while the window was minimised / in the tray (the quick-
|
||||||
|
// switch hotkey can fire from anywhere), keep the rebuilt instance in the tray too rather
|
||||||
|
// than popping the window up in front of whatever the user is doing.
|
||||||
|
startNextInstanceMinimized = !Visible || WindowState == FormWindowState.Minimized;
|
||||||
AppendLogEntry($"profile switch via Recent profiles: \"{title}\" from {path}");
|
AppendLogEntry($"profile switch via Recent profiles: \"{title}\" from {path}");
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
@@ -1748,8 +1778,8 @@ public sealed class MainForm : Form
|
|||||||
var chosen = QuickProfileSwitchDialog.Show(entries);
|
var chosen = QuickProfileSwitchDialog.Show(entries);
|
||||||
if (!string.IsNullOrEmpty(chosen))
|
if (!string.IsNullOrEmpty(chosen))
|
||||||
{
|
{
|
||||||
// No-ops if it's already the current profile; otherwise reloads into the chosen one,
|
// SwitchToRecentProfile plays the switch cue on click, keeps the window in the
|
||||||
// which plays the profile-switch cue on the relaunch.
|
// tray if it was there, and no-ops if the chosen profile is already current.
|
||||||
SwitchToRecentProfile(chosen);
|
SwitchToRecentProfile(chosen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1897,6 +1927,11 @@ public sealed class MainForm : Form
|
|||||||
var picked = Path.GetFileNameWithoutExtension(pickedPath);
|
var picked = Path.GetFileNameWithoutExtension(pickedPath);
|
||||||
if (string.IsNullOrEmpty(picked)) return;
|
if (string.IsNullOrEmpty(picked)) return;
|
||||||
if (string.Equals(pickedPath, currentProfilePath, StringComparison.OrdinalIgnoreCase)) return; // already loaded
|
if (string.Equals(pickedPath, currentProfilePath, StringComparison.OrdinalIgnoreCase)) return; // already loaded
|
||||||
|
// Switch cue on click (same rationale as SwitchToRecentProfile).
|
||||||
|
if (settings.LoadEnableProfileSwitchCue())
|
||||||
|
{
|
||||||
|
profileSwitchSound?.Play();
|
||||||
|
}
|
||||||
// Always pass the full path through. Program.cs deserialises directly from this
|
// Always pass the full path through. Program.cs deserialises directly from this
|
||||||
// path, so profiles saved outside the active BaseDirectory still load correctly.
|
// path, so profiles saved outside the active BaseDirectory still load correctly.
|
||||||
NextProfilePathToLoad = pickedPath;
|
NextProfilePathToLoad = pickedPath;
|
||||||
@@ -3856,9 +3891,14 @@ public sealed class MainForm : Form
|
|||||||
{
|
{
|
||||||
if (realtekAsioToggleItem is null || realtekAsioDriverNames.Count == 0) return;
|
if (realtekAsioToggleItem is null || realtekAsioDriverNames.Count == 0) return;
|
||||||
var anyDisabled = realtekAsioDriverNames.Exists(d => disabledAsioDrivers.Contains(d));
|
var anyDisabled = realtekAsioDriverNames.Exists(d => disabledAsioDrivers.Contains(d));
|
||||||
|
// Set BOTH the visible Text (with the mnemonic) and the AccessibleName (no mnemonic) to the
|
||||||
|
// same Enable/Disable wording, so the screen reader reads exactly what's shown — never "Toggle".
|
||||||
realtekAsioToggleItem.Text = anyDisabled
|
realtekAsioToggleItem.Text = anyDisabled
|
||||||
? "&Enable Realtek ASIO driver in RemSound"
|
? "&Enable Realtek ASIO driver in RemSound"
|
||||||
: "&Disable Realtek ASIO driver in RemSound";
|
: "&Disable Realtek ASIO driver in RemSound";
|
||||||
|
realtekAsioToggleItem.AccessibleName = anyDisabled
|
||||||
|
? "Enable Realtek ASIO driver in RemSound"
|
||||||
|
: "Disable Realtek ASIO driver in RemSound";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveDisabledDriverFromPicker(string driver)
|
private void RemoveDisabledDriverFromPicker(string driver)
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ internal static class Program
|
|||||||
// MainForm construction. Show a "Loading audio driver" splash — on its own
|
// MainForm construction. Show a "Loading audio driver" splash — on its own
|
||||||
// thread, so it stays painted while this thread is busy — so startup doesn't
|
// thread, so it stays painted while this thread is busy — so startup doesn't
|
||||||
// look hung. No-op for WASAPI-only profiles (construction is near-instant).
|
// look hung. No-op for WASAPI-only profiles (construction is near-instant).
|
||||||
var splash = AsioLoadingSplash.StartIfNeeded(profile);
|
// Skip the loading splash when this rebuild is a quick-profile-switch that's
|
||||||
|
// staying in the tray — popping a splash up in front of the user's current app
|
||||||
|
// defeats the point of keeping RemSound minimised, and the switch cue already
|
||||||
|
// gave them feedback. Normal launches and visible switches still show it.
|
||||||
|
var splash = MainForm.startNextInstanceMinimized ? null : AsioLoadingSplash.StartIfNeeded(profile);
|
||||||
using var form = new MainForm(store, profile, title, nextPath);
|
using var form = new MainForm(store, profile, title, nextPath);
|
||||||
// Expose the live window to the single-instance activation callback (a second
|
// Expose the live window to the single-instance activation callback (a second
|
||||||
// copy choosing "switch to the running copy" signals us to surface this form).
|
// copy choosing "switch to the running copy" signals us to surface this form).
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using NAudio.CoreAudioApi;
|
using NAudio.CoreAudioApi;
|
||||||
|
using NAudio.Dsp;
|
||||||
using NAudio.Wave;
|
using NAudio.Wave;
|
||||||
using RemSound.Core;
|
using RemSound.Core;
|
||||||
|
|
||||||
@@ -15,7 +17,20 @@ namespace RemSound.Receiver;
|
|||||||
/// - With multiple WasapiOuts, each render thread would call Read independently and only one
|
/// - With multiple WasapiOuts, each render thread would call Read independently and only one
|
||||||
/// output would get each frame; the others would starve.
|
/// output would get each frame; the others would starve.
|
||||||
/// - The producer loop runs at the canonical 48 kHz / 10 ms cadence, decoupled from any one
|
/// - The producer loop runs at the canonical 48 kHz / 10 ms cadence, decoupled from any one
|
||||||
/// device's clock. Per-device drift is absorbed by the BufferedWaveProvider's headroom.
|
/// device's clock.
|
||||||
|
///
|
||||||
|
/// Per-device drift correction (2026-06-08): the producer feeds every device's buffer at the
|
||||||
|
/// receiver's Stopwatch clock, but each WASAPI device drains at its OWN crystal. Left alone, the
|
||||||
|
/// two clocks diverge by tens-to-hundreds of ppm and the buffer slowly fills (device slower) or
|
||||||
|
/// empties (device faster) — Andre's "desktop and laptop drift apart over time on WASAPI". Each
|
||||||
|
/// device is wrapped in a <see cref="DriftResamplingProvider"/> that sits on the PULL side
|
||||||
|
/// (between the buffer and the WasapiOut) and continuously stretches/compresses by the measured
|
||||||
|
/// clock ratio, holding the buffer level steady. This mirrors the proven per-sender corrector in
|
||||||
|
/// <see cref="SessionPlayout"/> exactly: resampler on the consumer side, output-driven, slow
|
||||||
|
/// rate-ratio measurement over a multi-second window. Crucially the producer still writes the RAW
|
||||||
|
/// mix into each buffer (no resampling on the input), so the buffer keeps its natural cushion —
|
||||||
|
/// the resampler only adjusts the rate at which the device drains it. An earlier attempt that
|
||||||
|
/// resampled on the PRODUCER side drained the cushion to zero and crackled; this does not.
|
||||||
///
|
///
|
||||||
/// Output-device set is diffed on <see cref="SetOutputDevices"/>: existing devices stay live,
|
/// Output-device set is diffed on <see cref="SetOutputDevices"/>: existing devices stay live,
|
||||||
/// removed ones are stopped, new ones are opened. No audio interruption to the unchanged ones.
|
/// removed ones are stopped, new ones are opened. No audio interruption to the unchanged ones.
|
||||||
@@ -38,14 +53,15 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
|||||||
private readonly Dictionary<string, OutputEntry> outputs = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, OutputEntry> outputs = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly byte[] frameScratch = new byte[FrameBytes];
|
private readonly byte[] frameScratch = new byte[FrameBytes];
|
||||||
private readonly WaveFormat sharedFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
private readonly WaveFormat sharedFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
||||||
// Snapshot of the current output buffers, rebuilt only when SetOutputDevices changes the
|
// Snapshot of the current per-device drift providers, rebuilt only when SetOutputDevices
|
||||||
// device set (rare — typically once per user action, minutes apart). The producer loop
|
// changes the device set (rare — typically once per user action, minutes apart). The
|
||||||
// reads this with a single volatile load per tick instead of taking the gate and
|
// producer loop reads this with a single volatile load per tick instead of taking the gate
|
||||||
// rebuilding `outputs.Values.Select(o => o.Buffer).ToArray()` on every 10 ms tick.
|
// and rebuilding the list on every 10 ms tick. Item 7 of RemSoundefficiency.md — eliminates
|
||||||
// Item 7 of RemSoundefficiency.md — eliminates ~100 array allocations per second on the
|
// ~100 array allocations per second on the receive side. Empty array is a singleton via
|
||||||
// receive side whenever any output device is ticked. Empty array is a singleton via
|
// Array.Empty<T>(), so the default value costs nothing. We feed each provider (which writes
|
||||||
// Array.Empty<T>(), so the default value costs nothing.
|
// the raw mix into its buffer AND counts the bytes for the drift measurement) rather than
|
||||||
private volatile BufferedWaveProvider[] outputBufferSnapshot = Array.Empty<BufferedWaveProvider>();
|
// touching the BufferedWaveProvider directly.
|
||||||
|
private volatile DriftResamplingProvider[] outputSnapshot = Array.Empty<DriftResamplingProvider>();
|
||||||
|
|
||||||
private CancellationTokenSource? cts;
|
private CancellationTokenSource? cts;
|
||||||
private Task? produceTask;
|
private Task? produceTask;
|
||||||
@@ -106,7 +122,7 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
|||||||
// Reset the snapshot the producer loop reads so any subsequent Start sees the
|
// Reset the snapshot the producer loop reads so any subsequent Start sees the
|
||||||
// empty state cleanly (not a stale snapshot from the previous session). Empty
|
// empty state cleanly (not a stale snapshot from the previous session). Empty
|
||||||
// array is a cached singleton, no allocation.
|
// array is a cached singleton, no allocation.
|
||||||
outputBufferSnapshot = Array.Empty<BufferedWaveProvider>();
|
outputSnapshot = Array.Empty<DriftResamplingProvider>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +167,21 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
|||||||
DiscardOnBufferOverflow = true,
|
DiscardOnBufferOverflow = true,
|
||||||
BufferDuration = TimeSpan.FromMilliseconds(OutputBufferMs),
|
BufferDuration = TimeSpan.FromMilliseconds(OutputBufferMs),
|
||||||
};
|
};
|
||||||
wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 15);
|
// Per-device drift corrector sits between the buffer and the WasapiOut. The
|
||||||
wasapi.Init(buffer);
|
// device pulls THROUGH it; it pulls the matching amount from `buffer` and
|
||||||
|
// resamples by the measured clock ratio. The producer writes the raw mix
|
||||||
|
// into `buffer` via drift.Feed (which also counts bytes for the measurement).
|
||||||
|
var drift = new DriftResamplingProvider(buffer, name,
|
||||||
|
msg => onDiagnostic?.Invoke($"drift: {msg}"));
|
||||||
|
// Output device buffer. Request 5 ms; shared-mode WASAPI clamps it up to the
|
||||||
|
// device's minimum period (~10 ms on tested hardware, 2026-06-08) — but ~10 ms
|
||||||
|
// is still ~5 ms tighter than the old 15 ms, a free latency win. The per-device
|
||||||
|
// drift corrector keeps this buffer fed from its held ~12 ms cushion, so the
|
||||||
|
// smaller endpoint reserve doesn't risk underruns even on a flaky onboard device.
|
||||||
|
wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 5);
|
||||||
|
wasapi.Init(drift);
|
||||||
wasapi.Play();
|
wasapi.Play();
|
||||||
outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Name = name };
|
outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Drift = drift, Name = name };
|
||||||
onDiagnostic?.Invoke($"output added: \"{name}\"");
|
onDiagnostic?.Invoke($"output added: \"{name}\"");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -169,9 +196,9 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
|||||||
// sees a consistent view; once published via the volatile field, the loop reads
|
// sees a consistent view; once published via the volatile field, the loop reads
|
||||||
// it without taking the gate every tick. Empty case uses the cached singleton
|
// it without taking the gate every tick. Empty case uses the cached singleton
|
||||||
// so it's allocation-free. Item 7 of RemSoundefficiency.md.
|
// so it's allocation-free. Item 7 of RemSoundefficiency.md.
|
||||||
outputBufferSnapshot = outputs.Count == 0
|
outputSnapshot = outputs.Count == 0
|
||||||
? Array.Empty<BufferedWaveProvider>()
|
? Array.Empty<DriftResamplingProvider>()
|
||||||
: outputs.Values.Select(o => o.Buffer).ToArray();
|
: outputs.Values.Select(o => o.Drift).ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,16 +246,19 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
|||||||
// WASAPI has nothing ticked would consume PlayoutEngine audio ahead of the
|
// WASAPI has nothing ticked would consume PlayoutEngine audio ahead of the
|
||||||
// ASIO consumer. Pre-2026-05-23 this whole block ran under `lock (gate)` and
|
// ASIO consumer. Pre-2026-05-23 this whole block ran under `lock (gate)` and
|
||||||
// rebuilt the array on every tick — fixed as item 7 of RemSoundefficiency.md.
|
// rebuilt the array on every tick — fixed as item 7 of RemSoundefficiency.md.
|
||||||
var targets = outputBufferSnapshot;
|
var targets = outputSnapshot;
|
||||||
if (targets.Length == 0) continue;
|
if (targets.Length == 0) continue;
|
||||||
|
|
||||||
var produced = source.Read(frameScratch, 0, FrameBytes);
|
var produced = source.Read(frameScratch, 0, FrameBytes);
|
||||||
if (produced <= 0) continue;
|
if (produced <= 0) continue;
|
||||||
|
|
||||||
foreach (var buffer in targets)
|
// Feed the RAW mix into each device's buffer (no resampling here — the buffer
|
||||||
|
// keeps its natural cushion). Feed also counts the bytes for that device's
|
||||||
|
// drift measurement. Per-output failure shouldn't kill the loop.
|
||||||
|
foreach (var drift in targets)
|
||||||
{
|
{
|
||||||
try { buffer.AddSamples(frameScratch, 0, produced); }
|
try { drift.Feed(frameScratch, 0, produced); }
|
||||||
catch { /* per-output failure shouldn't kill the loop */ }
|
catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { break; }
|
catch (OperationCanceledException) { break; }
|
||||||
@@ -245,6 +275,207 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
|||||||
public required MMDevice Device { get; init; }
|
public required MMDevice Device { get; init; }
|
||||||
public required WasapiOut Output { get; init; }
|
public required WasapiOut Output { get; init; }
|
||||||
public required BufferedWaveProvider Buffer { get; init; }
|
public required BufferedWaveProvider Buffer { get; init; }
|
||||||
|
public required DriftResamplingProvider Drift { get; init; }
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sits between a device's <see cref="BufferedWaveProvider"/> and its <see cref="WasapiOut"/>.
|
||||||
|
/// The WasapiOut render thread pulls from THIS (not the buffer directly); we pull the matching
|
||||||
|
/// amount of audio from the buffer and run it through a continuous fixed-ratio resampler whose
|
||||||
|
/// rate is the measured (producer-feed ÷ device-drain) clock ratio over a multi-second window.
|
||||||
|
/// That holds the buffer level steady against per-device clock drift.
|
||||||
|
///
|
||||||
|
/// This deliberately mirrors <see cref="SessionPlayout"/>'s Phase-4 corrector: output-driven
|
||||||
|
/// (ResamplePrepare asks how many input frames it needs for N output frames), linear-interp
|
||||||
|
/// mode, 10 s window, 70/30 smoothing, ±5 % sanity clamp. Resampling on the PULL side keeps
|
||||||
|
/// the producer's raw feed (and therefore the buffer's natural cushion) intact — the earlier
|
||||||
|
/// producer-side attempt resampled the input and starved the cushion to zero.
|
||||||
|
///
|
||||||
|
/// Threading: <see cref="Feed"/> runs on the producer thread; <see cref="Read"/> (and the
|
||||||
|
/// resampler + rate update) run on the WasapiOut render thread. The only shared state is
|
||||||
|
/// <c>producerFedBytes</c>, guarded by Interlocked. The resampler itself is touched only on
|
||||||
|
/// the render thread.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class DriftResamplingProvider : IWaveProvider
|
||||||
|
{
|
||||||
|
// Mirror SessionPlayout's proven constants for the clock-ratio feed-forward.
|
||||||
|
private const double DriftMeasurementWindowSec = 10.0;
|
||||||
|
private const double DriftRatioSmoothingNew = 0.30;
|
||||||
|
private const double DriftRatioMin = 0.95;
|
||||||
|
private const double DriftRatioMax = 1.05;
|
||||||
|
// Feedback: steer the buffer toward a known, low cushion. Pure rate-matching holds the
|
||||||
|
// buffer wherever the start-up transient left it (~50 ms and climbing in the field) —
|
||||||
|
// SessionPlayout gets away without this because it ARMS at target and has a click-trim
|
||||||
|
// net; the device buffer has neither, so it needs an explicit depth term. The correction
|
||||||
|
// is tiny (≤0.3 % rate, spread over seconds): a sub-audible pitch nudge, never a click.
|
||||||
|
private const int TargetDepthMs = 12; // operating depth we hold the buffer at
|
||||||
|
private const double DepthCorrectionSec = 15.0; // correct a depth error over ~this long
|
||||||
|
private const double MaxDepthBias = 0.003; // cap the depth nudge at 0.3 % rate
|
||||||
|
|
||||||
|
private readonly BufferedWaveProvider buffer;
|
||||||
|
private readonly string name;
|
||||||
|
private readonly Action<string>? onDiagnostic;
|
||||||
|
private readonly WdlResampler resampler;
|
||||||
|
|
||||||
|
public WaveFormat WaveFormat => buffer.WaveFormat;
|
||||||
|
|
||||||
|
// Drift measurement. producerFedBytes is incremented by the producer thread in Feed;
|
||||||
|
// deviceDrainedBytes is incremented by the render thread (us) in Read. Their ratio over
|
||||||
|
// a multi-second window is the receiver-feed-rate ÷ device-drain-rate — exactly the
|
||||||
|
// ratio the resampler needs to hold the buffer level.
|
||||||
|
private long producerFedBytes; // Interlocked (producer writes, render reads)
|
||||||
|
private long deviceDrainedBytes; // render thread only
|
||||||
|
private long windowStartTicks;
|
||||||
|
private long windowStartFed;
|
||||||
|
private long windowStartDrained;
|
||||||
|
private double smoothedRatio = 1.0;
|
||||||
|
private bool tracking;
|
||||||
|
private bool firstWindowDone;
|
||||||
|
|
||||||
|
// Scratch — grown lazily, persists across calls so the hot path doesn't allocate.
|
||||||
|
private byte[] inputBytes = new byte[16384];
|
||||||
|
private float[] outputScratch = new float[4096];
|
||||||
|
|
||||||
|
public DriftResamplingProvider(BufferedWaveProvider buffer, string name, Action<string>? onDiagnostic)
|
||||||
|
{
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.name = name;
|
||||||
|
this.onDiagnostic = onDiagnostic;
|
||||||
|
// interp=true, filtercnt=0 → linear-interpolation mode, plenty for sub-1000-ppm
|
||||||
|
// corrections. SetFeedMode(false) = output-driven. Start at 1:1; the first window's
|
||||||
|
// measurement replaces it.
|
||||||
|
resampler = new WdlResampler();
|
||||||
|
resampler.SetMode(interp: true, filtercnt: 0, sinc: false);
|
||||||
|
resampler.SetFeedMode(false);
|
||||||
|
resampler.SetRates(MixSampleRate, MixSampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Producer thread: write the raw mix into the device buffer and count the
|
||||||
|
/// bytes for the drift measurement. No resampling here — the buffer keeps its cushion.</summary>
|
||||||
|
public void Feed(byte[] data, int offset, int count)
|
||||||
|
{
|
||||||
|
buffer.AddSamples(data, offset, count);
|
||||||
|
Interlocked.Add(ref producerFedBytes, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Render thread: the WasapiOut pulls <paramref name="count"/> bytes. We pull
|
||||||
|
/// the resampler's required input from the buffer and produce exactly that many output
|
||||||
|
/// bytes (zero-padding any shortfall, so WASAPI never sees a short read).</summary>
|
||||||
|
public int Read(byte[] outBuffer, int offset, int count)
|
||||||
|
{
|
||||||
|
var outFrames = count / MixBytesPerFrame;
|
||||||
|
if (outFrames <= 0) return 0;
|
||||||
|
|
||||||
|
UpdateRatioIfDue();
|
||||||
|
|
||||||
|
var inputFramesNeeded = resampler.ResamplePrepare(outFrames, MixChannels, out var inBuf, out var inBufOff);
|
||||||
|
if (inputFramesNeeded > 0)
|
||||||
|
{
|
||||||
|
var inputFloats = inputFramesNeeded * MixChannels;
|
||||||
|
var inputByteCount = inputFloats * sizeof(float);
|
||||||
|
if (inputBytes.Length < inputByteCount) inputBytes = new byte[inputByteCount];
|
||||||
|
// BufferedWaveProvider has ReadFully=true, so this returns inputByteCount,
|
||||||
|
// zero-padding if the buffer is momentarily short (a brief underrun produces
|
||||||
|
// silence, not a glitch — same as the pre-corrector behaviour).
|
||||||
|
var got = buffer.Read(inputBytes, 0, inputByteCount);
|
||||||
|
var gotFloats = got / sizeof(float);
|
||||||
|
MemoryMarshal.Cast<byte, float>(inputBytes.AsSpan(0, got)).CopyTo(inBuf.AsSpan(inBufOff, gotFloats));
|
||||||
|
if (gotFloats < inputFloats) inBuf.AsSpan(inBufOff + gotFloats, inputFloats - gotFloats).Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
var outFloats = outFrames * MixChannels;
|
||||||
|
if (outputScratch.Length < outFloats) outputScratch = new float[outFloats];
|
||||||
|
var produced = resampler.ResampleOut(outputScratch, 0, inputFramesNeeded, outFrames, MixChannels);
|
||||||
|
var producedFloats = produced * MixChannels;
|
||||||
|
|
||||||
|
var outSpan = MemoryMarshal.Cast<byte, float>(outBuffer.AsSpan(offset, count));
|
||||||
|
var copy = Math.Min(producedFloats, outSpan.Length);
|
||||||
|
for (var i = 0; i < copy; i++)
|
||||||
|
{
|
||||||
|
var v = outputScratch[i];
|
||||||
|
// Safety clamp — a NaN or out-of-range sample would otherwise be a loud pop.
|
||||||
|
if (v > 1f) v = 1f;
|
||||||
|
else if (v < -1f) v = -1f;
|
||||||
|
else if (float.IsNaN(v)) v = 0f;
|
||||||
|
outSpan[i] = v;
|
||||||
|
}
|
||||||
|
// Zero-fill any shortfall (startup priming of the resampler delay line, mainly).
|
||||||
|
if (copy < outSpan.Length) outSpan.Slice(copy).Clear();
|
||||||
|
|
||||||
|
// Count the device's consumption (always the full requested amount — WASAPI took
|
||||||
|
// `count` bytes regardless of how much real audio backed it). Matches SessionPlayout.
|
||||||
|
deviceDrainedBytes += count;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>If the measurement window has elapsed, recompute the clock ratio (feed-forward)
|
||||||
|
/// and the depth-restoring nudge (feedback), combine them, and push to the resampler.
|
||||||
|
/// Render thread only.</summary>
|
||||||
|
private void UpdateRatioIfDue()
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
if (windowStartTicks == 0)
|
||||||
|
{
|
||||||
|
windowStartTicks = now;
|
||||||
|
windowStartFed = Interlocked.Read(ref producerFedBytes);
|
||||||
|
windowStartDrained = deviceDrainedBytes;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var elapsedSec = (now - windowStartTicks) / (double)Stopwatch.Frequency;
|
||||||
|
if (elapsedSec < DriftMeasurementWindowSec) return;
|
||||||
|
|
||||||
|
var fedNow = Interlocked.Read(ref producerFedBytes);
|
||||||
|
var fedDelta = fedNow - windowStartFed;
|
||||||
|
var drainedDelta = deviceDrainedBytes - windowStartDrained;
|
||||||
|
|
||||||
|
// Re-anchor immediately so every early return below still advances the window cleanly.
|
||||||
|
windowStartTicks = now;
|
||||||
|
windowStartFed = fedNow;
|
||||||
|
windowStartDrained = deviceDrainedBytes;
|
||||||
|
|
||||||
|
// Discard the FIRST completed window. WASAPI primes its endpoint buffer at start-up,
|
||||||
|
// which inflates the device-drain count for that window and reads as a large bogus
|
||||||
|
// ppm (−1199 ppm observed) that shoves the buffer off target. Start measuring from
|
||||||
|
// the next window, by which point start-up is done.
|
||||||
|
if (!firstWindowDone) { firstWindowDone = true; return; }
|
||||||
|
|
||||||
|
if (fedDelta <= 0 || drainedDelta <= 0) return;
|
||||||
|
|
||||||
|
// Feed-forward: the true crystal ratio (system feed ÷ device drain). Independent of
|
||||||
|
// the resampler rate we apply, so it's a clean measurement of the clock difference.
|
||||||
|
// Cancels steady-state drift so the feedback term doesn't have to fight a constant.
|
||||||
|
var measured = (double)fedDelta / drainedDelta;
|
||||||
|
if (measured >= DriftRatioMin && measured <= DriftRatioMax)
|
||||||
|
{
|
||||||
|
smoothedRatio = tracking
|
||||||
|
? (1.0 - DriftRatioSmoothingNew) * smoothedRatio + DriftRatioSmoothingNew * measured
|
||||||
|
: measured;
|
||||||
|
tracking = true;
|
||||||
|
}
|
||||||
|
if (!tracking) return; // nothing valid measured yet — don't touch the rate.
|
||||||
|
|
||||||
|
// Feedback: nudge the buffer toward TargetDepthMs. depthError > 0 = too deep → bias
|
||||||
|
// the rate UP so the resampler pulls more per output and drains the buffer faster;
|
||||||
|
// < 0 = too shallow → bias down. Clamped + spread over DepthCorrectionSec so it's a
|
||||||
|
// gentle, inaudible pitch trim, not a per-sample discontinuity.
|
||||||
|
var depthFrames = buffer.BufferedBytes / MixBytesPerFrame;
|
||||||
|
var targetFrames = TargetDepthMs * MixSampleRate / 1000;
|
||||||
|
var depthError = depthFrames - targetFrames;
|
||||||
|
var depthCorrection = Math.Clamp(
|
||||||
|
depthError / (DepthCorrectionSec * MixSampleRate),
|
||||||
|
-MaxDepthBias, MaxDepthBias);
|
||||||
|
|
||||||
|
var appliedRatio = smoothedRatio + depthCorrection;
|
||||||
|
resampler.SetRates(MixSampleRate * appliedRatio, MixSampleRate);
|
||||||
|
|
||||||
|
var depthMs = buffer.BufferedBytes / MixBytesPerFrame * 1000 / MixSampleRate;
|
||||||
|
var clockPpm = (smoothedRatio - 1.0) * 1_000_000.0;
|
||||||
|
var corrPpm = depthCorrection * 1_000_000.0;
|
||||||
|
onDiagnostic?.Invoke(
|
||||||
|
$"\"{name}\": clock={smoothedRatio:F6} ({clockPpm:+0;-0}ppm) depthMs={depthMs} " +
|
||||||
|
$"target={TargetDepthMs} corr={corrPpm:+0;-0}ppm applied={appliedRatio:F6}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user