Bump to v3.0.0: Opus live latency, codec rename, profile-resume after update, save-through-lock
* New "Opus, live latency" codec mode: 2.5 ms frames (120 samples/ch at 48 kHz)
via the float-input encode path. End-to-end codec delay drops to ~5 ms (vs
~12.5 ms at standard 10 ms Opus). Test on LAN: 400 pps/lane, zero missed /
reordered / duplicate packets, ~15 ms one-way saved end-to-end.
* Wire-format change: AudioFormatInfo.FrameDurationMilliseconds renamed to
FrameSamplesPerChannel (int sample-count at announced sample rate). Removes
the lossy 48000*ms/1000 conversion that couldn't represent 2.5 ms. v3 <-> v3
exact; v3 <-> v2 still passes audio (Opus decoder is self-describing from
packet TOC) but v2 side over-sizes its buffer wildly. v2.x profiles auto-
migrate via <120 sentinel rule in RemSoundSettingsStore (anything below 120
is treated as legacy ms and multiplied by 48). Profile JSON key kept as
OpusFrameMilliseconds via [JsonPropertyName] so old profile files still load.
* Codec dropdown rebuilt with use-case names: "PCM 48K 24 bit - uncompressed",
"Opus, broadcast quality - loss tolerant", "Opus, live latency - for jamming
and monitoring". Middle 10 ms option retired; saved 480-sample profiles
collapse to broadcast quality (safer-side default).
* Profile auto-resume after self-update: RemSoundUpdater writes a one-shot
_resume-after-update.txt sentinel containing the active profile title before
exit; Program.Main reads + deletes it on next start and silently loads that
profile, skipping the picker. Helper batch's robocopy /XF excludes the
sentinel and the failure-branch cleans it up if the install aborts. Falls
through to normal startup behaviour (StartWithProfileTitle or picker) if the
sentinel is missing, empty, or names a profile that no longer exists.
* Read-only profile saves now go through on explicit Ctrl+S / File -> Save
with a one-time TaskDialog warning ("Save anyway" / "Cancel" + Do-not-show-
again). Lock continues to suppress the automatic unsaved-changes prompt on
close / profile switch (its main job). AppConfig.SaveOnReadOnlyMessageSuppressed
renamed to SaveOnReadOnlyWarningSuppressed; v2.x suppression flag is silently
discarded since the behaviour changed and the user needs to see the warning
once on each machine.
* Manual (readme.html) updated: codec table rewritten with the three new
choices and corrected bandwidth figures, send-rate description updated, new
sections "The same profile picks up automatically after an update" and
"Saving on purpose while a profile is locked".
* Subsumes the never-separately-released v2.2 work: native Opus encoder
(~97% less per-second memory churn on Opus send path via Concentus.Native),
efficiency tidy-ups (item 4 ASIO probe rate, item 6 WaitHandle, item 7
snapshot cache, items 14/16 heartbeat + discovery), legacy cleanup
(items 30/34/35/36: KeepAlive infrastructure, drift drop/repeat/accumulator
fields, fan-out cache stat). New diagnostic columns cpu/memMB/wsMB/
allocKBps/captureMs/sendMs/recvMs/renderMs gated on Enable-logs.
About dialog updated with v3.0 block at top; v2.2 block retained for the
subsumed work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6d6d6897e4
commit
af0e7c3fff
+35
-18
@@ -1,32 +1,49 @@
|
||||
# RemSound v2.2
|
||||
# RemSound v3.0
|
||||
|
||||
A maintenance release that makes RemSound use less of your computer's CPU and memory, especially when sending audio with the Opus codec. No new features to learn, no settings have changed, audio sounds exactly the same. Wire format and audio pipeline are unchanged from v1.5 onward — every version from v1.5 to v2.2 still talks to every other version cleanly.
|
||||
A big release with two things you'll actually notice — plus everything from the never-separately-released v2.2 work bundled in. v3.0 is a wire-format break: two v3.0 machines talk to each other perfectly, but a v3.0 machine talking to a v2.x machine will have very high latency until you upgrade the second machine too. See **Compatibility** below.
|
||||
|
||||
## What's lighter on your computer
|
||||
## What's new
|
||||
|
||||
* **Opus sending now uses much less memory.** RemSound's Opus encoder used to do quite a lot of one-off memory work on every audio frame — about 4 MB per second of "throwaway" memory churn while sending Opus audio. v2.2 ships a native build of the same encoder that does the same work in a tighter way. The audio you hear is identical (it really is the same encoder, just packaged better); the memory churn drops by about 97 %. On laptops you should see less background CPU when streaming Opus, and long sessions are less likely to see brief pauses while Windows tidies up memory.
|
||||
* **Opus "live latency" mode.** RemSound now sends sound in tiny 2.5-millisecond chunks instead of the usual 10 or 20 ms. End-to-end delay drops to about 5 ms of codec delay — close to PCM — perfect for playing along with someone in real time. Uses a bit more network bandwidth than the regular Opus mode but still a fraction of PCM. Best on a clean wired network. Pick it from the codec list as **"Opus, live latency — for jamming and monitoring"**.
|
||||
|
||||
* **Smaller all-round efficiency tidy-up.** A handful of small fixes — RemSound checks the audio-device list a bit less often, reuses some small bits of memory it used to make fresh each time, and skips some paperwork on the receive side when there's nothing to do. Each one is small on its own; together they shave a few percent off RemSound's everyday CPU footprint and reduce memory churn modestly.
|
||||
* **RemSound reopens on the same profile after it updates itself.** If a silent update fires in the middle of a session, the session drops briefly while the new files swap in, then RemSound reopens with the same devices, peers and settings — you don't see the profile picker, and you don't have to be at the computer for it. The next time you launch RemSound yourself, your normal startup choice applies as before.
|
||||
|
||||
* **Removed some old leftover code** that was retired months ago but still lived on as zero-valued columns in the diagnostic log. Same behaviour, cleaner files for anyone who reads the diagnostic logs.
|
||||
## Other changes
|
||||
|
||||
## For people who use the diagnostic logs
|
||||
* **Codec list is now three choices, with clearer names.**
|
||||
* `PCM 48K 24 bit — uncompressed` — best quality, ~2.3 Mbps
|
||||
* `Opus, broadcast quality — loss tolerant` — ~200 kbps, ~12 ms codec delay (was "Opus high quality (20 ms)")
|
||||
* `Opus, live latency — for jamming and monitoring` — ~320 kbps, ~5 ms codec delay (the new one)
|
||||
|
||||
* **New columns added** (only emit when Enable logs is ticked, so cost nothing when off):
|
||||
* `cpu=X.X%` — how much of one CPU core RemSound is using right now.
|
||||
* `memMB=X.X` and `wsMB=X.X` — RemSound's memory footprint (managed heap and working set).
|
||||
* `allocKBps=X.X` — how fast RemSound is asking Windows for new bits of memory right now. A low number is what we want.
|
||||
* `captureMs / sendMs / recvMs / renderMs` — milliseconds of CPU each of RemSound's four audio threads spent doing work in the last second.
|
||||
The old `Opus lower quality (10 ms)` middle option has been retired — it sat between the other two without a clear reason to pick it. If your saved profile was using it, RemSound silently picks broadcast quality for you on first launch — slightly more delay, more loss tolerance. Pick "live latency" if you want the new low-latency mode.
|
||||
|
||||
* **Some old columns removed.** `fanCacheMs`, `driftDrop`, `driftDropΔ`, `driftRep`, `driftRepΔ` and `driftAcc` are gone — they were always zero after the playback engine was changed in May.
|
||||
* **Save on a locked (read-only) profile now goes through when you ask on purpose.** The lock still suppresses the automatic "save your changes?" prompt on close (its main job), but if you press Save (Ctrl+S) or pick File → Save deliberately, a one-time warning explains what's about to happen and lets you confirm or cancel. Once you tick "do not show again", future deliberate saves on a locked profile go through silently. Save as... is unchanged — it always works.
|
||||
|
||||
## Nothing else has changed
|
||||
## Includes everything from v2.2 (which was never separately released)
|
||||
|
||||
No bug fixes in v2.2 specifically. Everything in v2.1 — the UPnP automatic router-opening, the lock-profile (read-only) tick, the silent-install notice, the wake-from-sleep audio fix, the hibernate fix — is still in place and works exactly the same.
|
||||
* **Native Opus encoder.** RemSound's Opus encoder used to put quite a lot of work on Windows' memory manager — about 4 megabytes per second of "throwaway" memory churn while sending Opus audio. v3.0 ships a native build of the same encoder that does its work in a tighter, faster way. The audio you hear is identical (it really is the same encoder, just packaged better); the memory churn drops by about 97 %. On laptops you should see less background CPU when streaming Opus, and longer sessions are less likely to see brief pauses while Windows tidies up memory.
|
||||
* **Smaller all-round efficiency tidy-up.** RemSound checks the audio-device list less often, reuses some small bits of memory it used to make fresh each time, and skips some paperwork on the receive side when there's nothing to do. Each one is small on its own; together they cut everyday memory churn modestly.
|
||||
* **New diagnostic log columns** (only emit when Enable logs is ticked):
|
||||
* `cpu` — how much of one CPU core RemSound just used
|
||||
* `memMB` / `wsMB` — managed heap and working set
|
||||
* `allocKBps` — per-second memory-churn rate (lower is better)
|
||||
* `captureMs / sendMs / recvMs / renderMs` — how busy each of the four audio threads is
|
||||
* **Some old diagnostic columns removed.** `fanCacheMs`, `driftDrop`, `driftRep`, `driftAcc` are gone — they were always zero after the playback engine changed in May.
|
||||
|
||||
## Compatibility
|
||||
|
||||
**v3.0 is a wire-format break.** RemSound describes audio frame sizes to other RemSound machines using a slightly different unit on the wire (sample-count instead of milliseconds) so the new 2.5 ms mode can be expressed cleanly.
|
||||
|
||||
* **v3.0 ↔ v3.0**: works perfectly.
|
||||
* **v3.0 ↔ v2.x**: audio still passes (the underlying Opus / PCM decode is unchanged) but the v2.x side will mis-read the frame-size announcement and build up too much buffer. Expect very high latency on that side until you upgrade it to v3.0.
|
||||
|
||||
To avoid the high-latency window: update **both** machines to v3.0 before your next session. The auto-updater on v1.9 and later will handle the upgrade for you, but the timing matters — if one machine updates before the other, latency will be high until the second machine catches up.
|
||||
|
||||
**Suppression flags** from v2.x for the "save was blocked on a read-only profile" dialog do **not** carry over. You'll see the new one-time warning once per machine. That's deliberate; the behaviour changed and you need to know.
|
||||
|
||||
## Install
|
||||
|
||||
1. Download `RemSound-v2.2.zip` from this release.
|
||||
1. Download `RemSound-v3.0.zip` from this release.
|
||||
2. Close RemSound.
|
||||
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it will not touch your profiles, settings or recordings.
|
||||
4. Run `RemSound.exe`. Press F1 for the user manual.
|
||||
@@ -35,8 +52,8 @@ Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch i
|
||||
|
||||
## Upgrading
|
||||
|
||||
**v1.9, v2.0, v2.1:** Help → Check for updates works — it will fetch and install v2.2 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v2.2 will install itself shortly after launch with a brief notice.
|
||||
**v1.9, v2.0, v2.1:** Help → Check for updates works — it will fetch and install v3.0 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.0 installs itself shortly after launch with a brief notice; RemSound then reopens on whichever profile you were running.
|
||||
|
||||
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v2.2 but not apply it. Install v2.2 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 from installing updates, so Check for updates will download v3.0 but not apply it. Install v3.0 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
||||
|
||||
If you installed RemSound inside a synced folder (Dropbox etc.) and your install is v1.0 / v1.1 / v1.2, see the [v1.3 release notes](https://github.com/Ednunp/RemSound/releases/tag/v1.3) for one-time manual install steps.
|
||||
|
||||
@@ -265,3 +265,59 @@ The remaining items in the original list are now categorised honestly:
|
||||
* **Big remaining lever, requires real project**: replacement of NAudio for direct WASAPI/ASIO P/Invoke. Year-scale work. Not on the table now.
|
||||
|
||||
**RemSound's efficiency is in a healthy state.** Steady-state PCM-sending: 1.6-4.7 % CPU, 150 KB/s allocs. Receive side: 10 % CPU (mostly NAudio + ASIO host thread, not ours), 178 KB/s. Opus is no longer special. No further efficiency work is recommended in this round; the next time someone surfaces a real performance complaint, this document should be the starting point for measuring before guessing.
|
||||
|
||||
---
|
||||
|
||||
## Design note (forward-looking): low-latency Opus mode, v3.0 candidate
|
||||
|
||||
Captured 2026-05-23 — Ed asked whether we can get Opus closer to PCM-latency, citing Jamulus as the reference for "very very fast" Opus. Recorded here so a future session has the lay of the land without re-deriving it from scratch.
|
||||
|
||||
### What Jamulus actually does
|
||||
|
||||
Jamulus's low latency comes from a stack of choices, not one trick:
|
||||
|
||||
1. **Opus Custom CELT**, not standard Opus. Opus Custom is a non-standard build of libopus exposing the CELT layer directly (it skips the SILK/CELT decision layer, and accepts non-standard frame sizes / sample rates). Requires libopus to be compiled with `--enable-custom-modes` — that flag is OFF by default in stock builds, including the binaries that ship in the `Concentus.Native` NuGet package we use today.
|
||||
2. **2.67 ms frames** (128 samples at 48 kHz), or 5.33 ms (256 samples). That's the dominant latency win. Algorithmic latency in Opus is roughly `frame size + 2.5 ms look-ahead` — at 2.67 ms frames you're at ~5 ms total instead of the ~12.5 ms a 10 ms standard Opus frame gives.
|
||||
3. **Complexity 1** (we use 10) — cheaper encode, marginally lower quality at the same bitrate.
|
||||
4. **CBR, no FEC, int16 input, joint stereo** — Jamulus prioritises predictable bandwidth and minimum per-packet overhead over robustness. We use VBR + FEC + float input because RemSound runs over WAN via Tailscale and needs to survive packet loss.
|
||||
5. **Tiny jitter buffer** — often 1-2 frames. RemSound's is sized for forgiveness, not LAN snappiness.
|
||||
|
||||
### What RemSound v3.0 adds (shipped 2026-05-23)
|
||||
|
||||
Standard libopus in `OPUS_APPLICATION_RESTRICTED_LOWDELAY` mode goes down to 2.5 ms frames at 48 kHz — same algorithmic latency as Opus Custom at the same frame size. The "Custom" bit gives you sub-frame-size flexibility (any sample count, any sample rate), not lower latency per se. So most of the Jamulus latency advantage is available from stock libopus, and that's what v3.0 pursues.
|
||||
|
||||
**Test result (2026-05-23, desktop↔laptop on LAN, ASIO Audient + Tight rate + auto-tune):** at the new 2.5 ms mode, send-accum dropped from 5 ms to 1.3 ms (3.75 ms shaved), auto-tune pulled the receive buffer to 19 ms (vs 24–29 ms at 10 ms Opus, 5–10 ms shaved). End-to-end one-way ≈ 30 ms vs ≈ 45 ms at standard 10 ms. **~15 ms shaved one-way, ~30 ms round-trip.** Wire integrity perfect over the test session: 400 packets/sec/lane, zero missed, zero reordered, zero duplicates, zero FEC recoveries triggered. CPU on the sender doubled to ~12 % (was ~5 % at PCM), receiver ~9–23 %. Memory rock-solid (gen0/1/2 zero most ticks).
|
||||
|
||||
**Refactor shipped in v3.0:**
|
||||
|
||||
* `AudioFormatInfo.FrameDurationMilliseconds` → `FrameSamplesPerChannel`. The wire field at byte offset 28 of the format payload now carries the exact sample-count per channel at the announced sample rate. v2.x receivers reading a v3.0 format packet misinterpret 480 (= 10 ms in samples) as 480 ms; the actual Opus decode still works because the decoder is self-describing from the packet TOC byte, but their buffer sizing will be wildly off. v2 ↔ v3 audio works but with absurd buffer latency. v3 ↔ v3 is exact.
|
||||
* `OpusEncoderState` constructor now takes `frameSamplesPerChannel` directly — the lossy `48000 × ms / 1000` conversion (which couldn't represent 2.5 ms) is gone.
|
||||
* `AudioSender.OpusFrameMilliseconds` → `OpusFrameSamplesPerChannel`; `ConfigureCodec(codec, samples)` accepts 120 to 2880 (= 2.5 ms to 60 ms).
|
||||
* `SenderLane` constructor + `OnCodecChanged` signatures and `EnsureFormatPacketSent` propagate sample-counts straight through.
|
||||
* `MainFormChoices.CodecChoice` field renamed `OpusFrameMs` → `OpusFrameSamples`.
|
||||
* `MainForm.cs` codec dropdown: PCM, Opus 20 ms (960), Opus 10 ms (480), Opus 2.5 ms experimental (120). `EffectiveOpusFrameSamples` halves for Tight rate with a floor of 120 (so picking 2.5 ms + Tight stays at 2.5 ms; picking 20 ms + Tight gives 10 ms). `FormatCodecLabel` derives ms display from samples / 48 with one decimal so "Opus 2.5ms" displays cleanly. `ResolveCodecIndex` maps 960/480/120 to dropdown indices.
|
||||
* `RemSoundSettingsStore.LoadOpusFrameSamplesPerChannel` / `SaveOpusFrameSamplesPerChannel` (in-memory cache rename). The load path includes a `<120` sentinel migration: any persisted value below 120 is treated as legacy v2.x integer-ms and multiplied by 48. The ranges don't overlap (max legitimate ms = 60, min legitimate samples = 120), so the disambiguation is unambiguous.
|
||||
* `Profile.OpusFrameMilliseconds` → `Profile.OpusFrameSamplesPerChannel`. The JSON key is **kept** as `"OpusFrameMilliseconds"` via `[JsonPropertyName]`, so v2.x profile files still load (the `<120` sentinel migration converts their ms value on read).
|
||||
* `AudioReceiver.ActiveStreamFrameMs` rounds samples-per-channel up to the next integer ms so the auto-tune always overestimates rather than underestimates the codec floor.
|
||||
* `StreamSession.MatchesFormat` + `HandleOpus` use `FrameSamplesPerChannel` directly; the buffer-size calc dropped its `× SampleRate / 1000` conversion.
|
||||
|
||||
Wire-format compatibility: v3.x ↔ v3.x exact; v2.x ↔ v3.x produces garbled buffer sizing but the Opus stream still decodes (TOC-self-describing). Users on the field upgrade both ends together.
|
||||
|
||||
### What 2.5 ms gives you in real-world test
|
||||
|
||||
* **~5 ms codec latency** (vs ~12.5 ms at the standard Opus 10 ms choice) — about 7.5 ms shaved off the end-to-end path. Within ~0.17 ms of Jamulus's Opus Custom 2.67 ms frame.
|
||||
* **4× the packet rate per lane** (200 pps → 800 pps) — measurably more CPU per second, more pressure on the kernel UDP send path, slightly worse single-packet-loss tolerance.
|
||||
* **Same encoder otherwise** — RESTRICTED_LOWDELAY, native libopus, FEC on, bitrate unchanged.
|
||||
|
||||
### What Opus Custom would add on top of 2.5 ms (deferred to v3.1+ if ever)
|
||||
|
||||
Once 2.5 ms standard libopus is in real-world use, the remaining gap to Jamulus is:
|
||||
|
||||
* **Sub-2.5 ms frames** (Jamulus uses 64 samples = 1.33 ms, below stock libopus's floor). Saves ~1.2 ms more codec latency. At that point we're below the CPU-scheduling jitter floor on most consumer Windows hardware — the saving is theoretical more than perceptible.
|
||||
* **Skipping standard Opus packet framing** — saves 1-2 bytes per packet (about 1 % of total bandwidth at 2.5 ms frames). Trivial.
|
||||
|
||||
Cost to add: half a day to a day of native-binary work (build libopus with `--enable-custom-modes`, P/Invoke layer for `opus_custom_mode_create` / `opus_custom_encoder_create` / `opus_custom_encode_float` / matching decode, wire-format extension to mark packets as custom-mode, NuGet/packaging plumbing).
|
||||
|
||||
**Recommendation: do not pursue Opus Custom unless 2.5 ms in real-world testing shows a perceptible gap to Jamulus and the listening tests prove the missing ~1 ms matters.** Most users won't be able to tell.
|
||||
|
||||
Skip the tight-jitter receive-buffer mode (1-2 frames) unless it surfaces as a complaint independently — the current buffer sizing is what makes Wi-Fi tolerable.
|
||||
|
||||
+39
-14
@@ -167,15 +167,28 @@ ul, ol { padding-left: 1.4em; }
|
||||
<li>When you close RemSound, there is <strong>no save prompt</strong>. The app just closes. Whatever you changed during the session is forgotten; the next time you open the profile it's back to what it was when you locked it.</li>
|
||||
<li>The window title shows “(read-only)” so you can always tell at a glance.</li>
|
||||
<li>The startup profile picker also shows “(read-only)” next to locked profiles, so you know what you're picking before you hit Enter.</li>
|
||||
<li>Pressing Save (Ctrl+S) shows a one-time message telling you the profile is locked and suggesting Save as… instead. There's a tick on the message to stop it showing again.</li>
|
||||
<li>Pressing Save (Ctrl+S) still works — the lock only blocks the automatic save prompt, not deliberate saves. See “Saving on purpose while a profile is locked” below.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>How to lock or unlock:</strong> open the File menu (Alt+F) and pick <strong>Lock profile (read-only)</strong> (Alt+F, L). It's a tickable menu item — pick it once to turn the lock on (a tick appears next to it); pick it again to turn the lock off (the tick disappears). The lock state is remembered with the profile, so closing and reopening RemSound keeps the profile locked exactly as you left it.</p>
|
||||
|
||||
<p><strong>If you decide to keep your changes:</strong> two ways:</p>
|
||||
<h4>Saving on purpose while a profile is locked</h4>
|
||||
|
||||
<p>The lock is there to stop accidents — it doesn't stop you saving when you mean to. If you press <strong>Save</strong> (Ctrl+S) or pick <strong>File → Save</strong> on a locked profile, RemSound shows a one-time warning explaining what's about to happen:</p>
|
||||
|
||||
<blockquote>
|
||||
<p><strong>Saving onto a read-only profile.</strong> You're about to save changes onto a profile that's marked as read-only. RemSound allows this because you asked to save on purpose — the lock only stops the automatic “save your changes?” prompt; it doesn't stop you saving when you mean to.</p>
|
||||
<p>Click <strong>Save anyway</strong> to overwrite this profile, or <strong>Cancel</strong> and use File → Save as… if you'd rather save your changes to a new profile.</p>
|
||||
</blockquote>
|
||||
|
||||
<p>There's a <strong>Do not show me this message again</strong> tick on the warning. Once you tick it, future deliberate saves on a locked profile go through silently without the warning. The setting is per-machine, not per-profile — tick it once and it applies on every locked profile from that point on.</p>
|
||||
|
||||
<p>So in summary, on a locked profile:</p>
|
||||
<ul>
|
||||
<li>Unlock the profile first (untick the menu item), then press Save (Ctrl+S). Your changes are saved to the profile.</li>
|
||||
<li>Or use <strong>Save as…</strong> directly. That always works, even on a locked profile, and the new copy starts out unlocked — so you can edit it normally afterwards.</li>
|
||||
<li>Closing RemSound — no prompt, changes are forgotten.</li>
|
||||
<li>Switching to a different profile — no prompt, changes are forgotten.</li>
|
||||
<li>Pressing Save (Ctrl+S) on purpose — warning the first time (with a do-not-show-again tick), then the save goes through and overwrites the profile.</li>
|
||||
<li><strong>Save as…</strong> — always works, never warns. The new copy starts out unlocked.</li>
|
||||
</ul>
|
||||
|
||||
<div class="note">
|
||||
@@ -340,7 +353,7 @@ ul, ol { padding-left: 1.4em; }
|
||||
|
||||
<table>
|
||||
<tr><th>Control</th><th>Shortcut</th><th>What it does</th></tr>
|
||||
<tr><td><strong>Audio codec</strong></td><td>Alt+C</td><td>The codec is the method RemSound uses to package the sound before sending it. Choices: PCM 48k 24-bit, Opus high quality (20 ms), or Opus lower quality (10 ms). See <a href="#latency">codec choice</a>.</td></tr>
|
||||
<tr><td><strong>Audio codec</strong></td><td>Alt+C</td><td>The codec is the method RemSound uses to package the sound before sending it. Three choices: PCM 48k 24-bit (uncompressed), Opus broadcast quality (loss tolerant), or Opus live latency (for jamming and monitoring). See <a href="#latency">codec choice</a>.</td></tr>
|
||||
<tr><td><strong>Packet size</strong></td><td>Alt+P</td><td><em>Standard</em> (the default) or <em>Small</em> (for a local network only). Smaller packets save a couple of milliseconds of delay on the sending side, but they double how many packets are sent.</td></tr>
|
||||
<tr><td><strong>Lock to audio clock</strong></td><td>Alt+D</td><td>A timing setting on the sending side. It ties the sending of packets to the sound device's own hardware clock, which removes a little jitter (jitter means uneven packet timing). Brief clicks are possible if the connection can't keep up. The label changes depending on whether ASIO is in use, so it always describes what it does in your setup.</td></tr>
|
||||
</table>
|
||||
@@ -512,7 +525,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
||||
<li><strong>Continuous auto-tune</strong> — lets the receiving side choose the latency target for you, re-checking every few seconds.</li>
|
||||
</ul>
|
||||
|
||||
<p>Plus the codec choice (PCM, Opus 20 ms or Opus 10 ms), also on the Audio profile tab. Most people only need to pick a codec and a smoothness level and leave the rest at the default.</p>
|
||||
<p>Plus the codec choice (PCM, Opus broadcast quality, or Opus live latency), also on the Audio profile tab. Most people only need to pick a codec and a smoothness level and leave the rest at the default.</p>
|
||||
|
||||
<h3>Audio latency control</h3>
|
||||
|
||||
@@ -548,7 +561,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
||||
|
||||
<table>
|
||||
<tr><th>Packet size</th><th>What changes</th><th>Pick when</th></tr>
|
||||
<tr><td>Standard</td><td>One audio packet every 5 ms with PCM, or every 10 or 20 ms with Opus, depending on the codec you chose.</td><td>Any internet or Tailscale connection — any time you don't have a guaranteed-clean local network.</td></tr>
|
||||
<tr><td>Standard</td><td>One audio packet every 5 ms with PCM, every 20 ms with Opus broadcast quality, or every 2.5 ms with Opus live latency.</td><td>Any internet or Tailscale connection — any time you don't have a guaranteed-clean local network.</td></tr>
|
||||
<tr><td>Small (local network only)</td><td>Halves how much sound each packet carries. Saves up to 2.5 ms of delay on the sending side.</td><td>A same-house local network over wired Ethernet, where the network simply isn't going to drop packets or jitter.</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -590,15 +603,19 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
||||
<p>This happens on its own — there's no switch for it. PCM mode doesn't have it.</p>
|
||||
|
||||
<h3>Codec choice</h3>
|
||||
<p>Remember, the codec is the method RemSound uses to package the sound before sending it.</p>
|
||||
<p>Remember, the codec is the method RemSound uses to package the sound before sending it. There are three choices:</p>
|
||||
<table>
|
||||
<tr><th>Codec</th><th>Quality</th><th>Network use</th><th>Use when</th></tr>
|
||||
<tr><td>PCM 48k 24-bit</td><td>Best</td><td>About 2.3 Mbps</td><td>Local networks, fast internet connections.</td></tr>
|
||||
<tr><td>Opus high quality (20 ms)</td><td>Very good</td><td>About 96 kbps</td><td>Most internet connections — the right default.</td></tr>
|
||||
<tr><td>Opus lower quality (10 ms)</td><td>Good</td><td>About 64 kbps</td><td>Slower or less reliable connections; tighter delay.</td></tr>
|
||||
<tr><th>Codec</th><th>Quality</th><th>Network use</th><th>Delay added by the codec</th></tr>
|
||||
<tr><td>PCM 48k 24-bit — uncompressed</td><td>Best, no loss at all</td><td>About 2.3 Mbps</td><td>None — the sound goes out exactly as it was captured.</td></tr>
|
||||
<tr><td>Opus, broadcast quality — loss tolerant</td><td>Very good</td><td>About 200 kbps</td><td>About 12 ms.</td></tr>
|
||||
<tr><td>Opus, live latency — for jamming and monitoring</td><td>Very good</td><td>About 320 kbps</td><td>About 5 ms.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>PCM gives the very best sound with no quality loss at all, but it uses about 30 times the network bandwidth of Opus. Over the open internet, Opus is almost always the right choice.</p>
|
||||
<p>The difference between the two Opus choices is what they trade for what. <strong>Broadcast quality</strong> packs sound into larger chunks — bigger packets, sent less often, more tolerant of a wobbly connection. <strong>Live latency</strong> packs sound into very small chunks and sends them eight times more often, getting your audio there with almost no codec delay at all — close to PCM — at the cost of being a bit more sensitive to a noisy connection. Broadcast quality is the right pick for anything across the open internet; live latency is for playing along together over a clean local network or a wired connection.</p>
|
||||
|
||||
<p>PCM gives the very best sound with no quality loss at all, but it uses about ten times the network bandwidth of Opus. Over the open internet, Opus is almost always the right choice.</p>
|
||||
|
||||
<p>Both Opus choices can automatically repair a single missing packet (see the section just above), so single drops are inaudible on both. PCM doesn't have that ability.</p>
|
||||
|
||||
<h2 id="shortcuts">13. Keyboard shortcuts (within the main window)</h2>
|
||||
|
||||
@@ -818,12 +835,20 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
||||
<li>RemSound downloads the new version into a holding folder next to the running program.</li>
|
||||
<li>It writes a tiny helper alongside it that watches for RemSound to close.</li>
|
||||
<li>RemSound closes.</li>
|
||||
<li>The helper notices, copies the new files over the install folder, deletes the holding folder, and reopens RemSound.</li>
|
||||
<li>The helper notices, copies the new files over the install folder, deletes the holding folder, and reopens RemSound on the same profile you were running.</li>
|
||||
<li>The helper deletes itself.</li>
|
||||
</ol>
|
||||
|
||||
<p>You'll see the window close, then reopen on the new version within a second or two. Anything that was unsaved in the old session (a profile you were partway through editing, for example) is lost — RemSound will not save it for you before installing. Save first if you've been making changes.</p>
|
||||
|
||||
<h3>The same profile picks up automatically after an update</h3>
|
||||
|
||||
<p>When the install finishes and RemSound reopens, it loads the same profile that was running just before the update — you don't see the profile picker, and your devices, peer list, codec and latency settings all come back exactly as they were. This means a silent update in the middle of a session drops the audio briefly while the install finishes, then your session reconnects on its own. You don't have to be at the computer when it happens.</p>
|
||||
|
||||
<p>This is a one-shot, just-after-the-update behaviour. The very next time you launch RemSound manually (from the desktop, the Start menu, or the tray icon), it follows your normal startup choice — the picker if that's how you've set it, or your chosen startup profile if you've picked one in Options → Startup behaviour.</p>
|
||||
|
||||
<p>If the profile that was running can't be found after the update (you'd renamed or moved it during the session, for example), RemSound falls back to your normal startup behaviour rather than getting stuck.</p>
|
||||
|
||||
<h3>If an install fails</h3>
|
||||
|
||||
<p>The update download is best-effort: a flaky network, a locked install folder, or a temporarily-unavailable version will pop up a message saying it couldn't finish, and leave your running version untouched. The address of the download page is in that message, so you can get the new version in a browser and install it by hand if you need to. If you installed RemSound into <code>Program Files</code> without giving your account permission to write to that folder, the install helper's copy step will fail too — either fix the permission or move RemSound to a folder you can write to (somewhere inside your own user folder, for instance).</p>
|
||||
|
||||
@@ -20,6 +20,81 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.0
|
||||
|
||||
A big release with two things you'll actually notice:
|
||||
|
||||
1) A new "live latency" Opus mode. RemSound now sends
|
||||
sound in tiny 2.5-millisecond chunks instead of the
|
||||
usual 10 or 20 ms. End-to-end delay drops to about
|
||||
5 ms of codec delay — close to PCM — perfect for
|
||||
playing along with someone in real time. Uses a bit
|
||||
more network bandwidth than the regular Opus mode
|
||||
but still a fraction of PCM. Best on a clean wired
|
||||
network. Pick it from the codec list as "Opus, live
|
||||
latency — for jamming and monitoring".
|
||||
|
||||
2) After RemSound updates itself, it now reopens on
|
||||
the same profile you were using. So if a silent
|
||||
update fires mid-session, the session drops briefly
|
||||
while the new files swap in, then RemSound reopens
|
||||
with the same devices, peers and settings — you
|
||||
don't see the profile picker, and you don't have to
|
||||
be at the computer when it happens. The next time
|
||||
you launch RemSound yourself, your normal startup
|
||||
choice applies as before.
|
||||
|
||||
Other changes:
|
||||
* The codec list is now three choices, with clearer
|
||||
names: PCM 48K 24 bit (uncompressed); Opus,
|
||||
broadcast quality (loss tolerant); Opus, live
|
||||
latency (the new low-latency mode). The old
|
||||
"Opus lower quality (10 ms)" middle option has
|
||||
been retired — it sat between the other two
|
||||
without a clear reason to pick it. If your saved
|
||||
profile was using it, RemSound silently picks
|
||||
broadcast quality for you — slightly more delay,
|
||||
more loss tolerance.
|
||||
* Save on a locked (read-only) profile now goes
|
||||
through when you ask on purpose. The lock still
|
||||
suppresses the automatic "save your changes?"
|
||||
prompt on close (its main job), but if you press
|
||||
Save deliberately, a one-time warning explains
|
||||
what's about to happen and lets you confirm or
|
||||
cancel. Once you tick "do not show again", future
|
||||
deliberate saves on a locked profile go through
|
||||
silently. Save as... is unchanged — it always
|
||||
works.
|
||||
|
||||
Also includes everything from the never-separately-
|
||||
released v2.2 work: a native Opus encoder that puts
|
||||
much less load on Windows' memory manager (about 97 %
|
||||
less per-second memory churn while sending Opus),
|
||||
various small CPU and memory tidy-ups, and new
|
||||
diagnostic log columns (cpu, memMB, wsMB, allocKBps,
|
||||
captureMs / sendMs / recvMs / renderMs).
|
||||
|
||||
Compatibility note — please read:
|
||||
|
||||
v3.0 changes how RemSound describes audio frame sizes
|
||||
to other RemSound machines on the wire. Two v3.0
|
||||
machines talk to each other perfectly. A v3.0 machine
|
||||
talking to a v2.x machine will still pass audio, but
|
||||
the v2.x side will build up too much buffer and
|
||||
latency will be very high. To avoid this, update BOTH
|
||||
machines to v3.0. The auto-updater on v1.9 and later
|
||||
will handle the upgrade for you, but the timing
|
||||
matters: if one machine updates before the other,
|
||||
expect a short period of high latency until the
|
||||
second machine catches up.
|
||||
|
||||
If you had previously ticked "do not show me this
|
||||
message again" on the v2.x "save was blocked on a
|
||||
read-only profile" dialog, that suppression doesn't
|
||||
carry over — you'll see the new one-time warning once
|
||||
per machine. That's deliberate; the behaviour changed
|
||||
and you need to know.
|
||||
|
||||
RemSound v2.2
|
||||
|
||||
A maintenance release that makes RemSound use less of
|
||||
|
||||
@@ -722,19 +722,26 @@ public sealed class MainForm : Form
|
||||
maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)";
|
||||
|
||||
// --- Populate static choices ---
|
||||
// Order: PCM first (LAN), Opus 20 ms (higher quality, more robust to loss), Opus 10 ms
|
||||
// (lower latency at the cost of slightly less audio quality and loss tolerance). Labels
|
||||
// intentionally avoid all numbers and ms jargon — the slider is the only place ms
|
||||
// should appear in the UI.
|
||||
// Three transport choices, ordered most-tolerant-of-bad-networks to most-demanding:
|
||||
// * PCM 48K 24-bit — uncompressed, ~2.3 Mbps
|
||||
// * Opus broadcast quality — 20 ms frame (960 samples/ch at 48 kHz), loss tolerant
|
||||
// * Opus live latency — 2.5 ms frame (120 samples/ch at 48 kHz), 8× the packet
|
||||
// rate of broadcast quality, for jamming / live monitoring
|
||||
// The 10 ms middle option (480 samples/ch) that lived here in v2.x has been retired —
|
||||
// it sat between the other two without a clear use case (saved only 5 ms over 20 ms
|
||||
// and gave up loss tolerance for no clearly audible win). Frame size on the wire is
|
||||
// samples-per-channel at 48 kHz (v3.0 unit). Labels avoid numbers and ms jargon per
|
||||
// the manual's "use case in words" convention; the per-peer status line surfaces the
|
||||
// actual ms figure for users who want to verify.
|
||||
codecBox.Items.AddRange(new object[]
|
||||
{
|
||||
new CodecChoice("PCM 48K 24 bit for very fast connections", AudioTransportCodec.Pcm, 0),
|
||||
new CodecChoice("Opus high quality for fast connections", AudioTransportCodec.Opus, 20),
|
||||
new CodecChoice("Opus lower quality for slower connections", AudioTransportCodec.Opus, 10),
|
||||
new CodecChoice("PCM 48K 24 bit — uncompressed", AudioTransportCodec.Pcm, 0),
|
||||
new CodecChoice("Opus, broadcast quality — loss tolerant", AudioTransportCodec.Opus, 960),
|
||||
new CodecChoice("Opus, live latency — for jamming and monitoring", AudioTransportCodec.Opus, 120),
|
||||
});
|
||||
codecBox.SelectedIndex = ResolveCodecIndex(settings.LoadCodec(), settings.LoadOpusFrameMilliseconds());
|
||||
codecBox.SelectedIndex = ResolveCodecIndex(settings.LoadCodec(), settings.LoadOpusFrameSamplesPerChannel());
|
||||
var initialCodec = (CodecChoice)codecBox.SelectedItem!;
|
||||
sender.ConfigureCodec(initialCodec.Codec, EffectiveOpusFrameMs(initialCodec.Codec, initialCodec.OpusFrameMs, settings.LoadSendRate()));
|
||||
sender.ConfigureCodec(initialCodec.Codec, EffectiveOpusFrameSamples(initialCodec.Codec, initialCodec.OpusFrameSamples, settings.LoadSendRate()));
|
||||
sender.SetSendRate(settings.LoadSendRate());
|
||||
|
||||
// Relay-mode plumbing. The sender's UDP socket is always-receiving from form construction
|
||||
@@ -859,10 +866,10 @@ public sealed class MainForm : Form
|
||||
if (codecBox.SelectedItem is CodecChoice item)
|
||||
{
|
||||
settings.SaveCodec(item.Codec);
|
||||
if (item.Codec == AudioTransportCodec.Opus) settings.SaveOpusFrameMilliseconds(item.OpusFrameMs);
|
||||
var effectiveFrameMs = EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, settings.LoadSendRate());
|
||||
sender.ConfigureCodec(item.Codec, effectiveFrameMs);
|
||||
logFile.Event($"codec changed to {item.Codec}{(item.Codec == AudioTransportCodec.Opus ? $" {effectiveFrameMs}ms" : "")}");
|
||||
if (item.Codec == AudioTransportCodec.Opus) settings.SaveOpusFrameSamplesPerChannel(item.OpusFrameSamples);
|
||||
var effectiveSamples = EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, settings.LoadSendRate());
|
||||
sender.ConfigureCodec(item.Codec, effectiveSamples);
|
||||
logFile.Event($"codec changed to {item.Codec}{(item.Codec == AudioTransportCodec.Opus ? $" {effectiveSamples / 48.0:0.##}ms" : "")}");
|
||||
MarkProfileDirty();
|
||||
}
|
||||
};
|
||||
@@ -1572,50 +1579,70 @@ public sealed class MainForm : Form
|
||||
|
||||
/// <summary>Ctrl+S / File → Save behaviour: if a profile is currently loaded, overwrite
|
||||
/// it; if we're on the blank template (no current profile), fall through to Save as.
|
||||
/// Read-only profiles refuse here with a hint pointing at Save As — that's the whole
|
||||
/// point of read-only mode, so silently ignoring Ctrl+S would be more confusing than
|
||||
/// a one-time message explaining why nothing happened. The message is suppressible
|
||||
/// via the "Do not show again" tick (same pattern as the Save-success popup).</summary>
|
||||
///
|
||||
/// Read-only profiles: the lock suppresses the automatic "you have unsaved changes"
|
||||
/// prompt on close / profile switch (the user has declared "anything I changed this
|
||||
/// session is throwaway"), but it does NOT block explicit Ctrl+S / File → Save — if the
|
||||
/// user asks to save on purpose, the save goes through. First time they do this we show
|
||||
/// a one-time warning explaining the situation, with a "Do not show again" tick so the
|
||||
/// warning self-suppresses for power users. Changed 2026-05-23 from the v2.x hard-block
|
||||
/// behaviour after Ed's feedback that the lock should protect against accident, not
|
||||
/// against intent.</summary>
|
||||
private void SaveOrSaveAs()
|
||||
{
|
||||
if (currentProfileReadOnly)
|
||||
{
|
||||
if (!AppConfig.Load().SaveOnReadOnlyMessageSuppressed)
|
||||
if (!AppConfig.Load().SaveOnReadOnlyWarningSuppressed)
|
||||
{
|
||||
ShowSaveBlockedByReadOnlyDialog();
|
||||
if (!ShowSaveOnReadOnlyWarningDialog()) return;
|
||||
}
|
||||
// Read-only profiles always have a title — read-only is meaningless on the blank
|
||||
// template — so we go straight to UpdateExistingProfile without the
|
||||
// string-null-check that the unlocked path needs.
|
||||
UpdateExistingProfile();
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs();
|
||||
else UpdateExistingProfile();
|
||||
}
|
||||
|
||||
/// <summary>Native TaskDialog explaining why Ctrl+S / File → Save did nothing on a
|
||||
/// read-only profile. Verification checkbox lets the user suppress future occurrences;
|
||||
/// same shape as <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading +
|
||||
/// body + checkbox as part of the normal tab order. 2026-05-22.</summary>
|
||||
private void ShowSaveBlockedByReadOnlyDialog()
|
||||
/// <summary>Native TaskDialog warning the user that they're about to overwrite a profile
|
||||
/// marked read-only. Returns true if the user confirmed the save, false if they
|
||||
/// cancelled. Verification checkbox lets the user suppress future occurrences via
|
||||
/// <see cref="AppConfig.SaveOnReadOnlyWarningSuppressed"/>; same shape as
|
||||
/// <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading + body + checkbox
|
||||
/// as part of the normal tab order. 2026-05-23 (rewrite of the v2.x hard-block dialog).
|
||||
/// </summary>
|
||||
private bool ShowSaveOnReadOnlyWarningDialog()
|
||||
{
|
||||
var verification = new TaskDialogVerificationCheckBox("Do not show me this message again");
|
||||
var saveButton = new TaskDialogButton("Save anyway");
|
||||
var cancelButton = new TaskDialogButton("Cancel") { AllowCloseDialog = true };
|
||||
var page = new TaskDialogPage
|
||||
{
|
||||
Caption = AppName,
|
||||
Heading = "This profile is read-only",
|
||||
Text = "This profile is locked, so Save was skipped. Use File → Save as... to save your changes to a new profile, or untick File → Lock profile (read-only) to unlock this one.",
|
||||
Icon = TaskDialogIcon.Information,
|
||||
Heading = "Saving onto a read-only profile",
|
||||
Text = "You're about to save changes onto a profile that's marked as read-only. "
|
||||
+ "RemSound allows this because you asked to save on purpose — the lock only "
|
||||
+ "stops the automatic \"save your changes?\" prompt; it doesn't stop you "
|
||||
+ "saving when you mean to.\n\n"
|
||||
+ "Click Save anyway to overwrite this profile, or Cancel and use "
|
||||
+ "File → Save as... if you'd rather save your changes to a new profile.",
|
||||
Icon = TaskDialogIcon.Warning,
|
||||
Verification = verification,
|
||||
Buttons = { TaskDialogButton.OK },
|
||||
DefaultButton = TaskDialogButton.OK,
|
||||
Buttons = { saveButton, cancelButton },
|
||||
DefaultButton = cancelButton,
|
||||
AllowCancel = true,
|
||||
};
|
||||
TaskDialog.ShowDialog(this, page);
|
||||
var clicked = TaskDialog.ShowDialog(this, page);
|
||||
if (verification.Checked)
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.SaveOnReadOnlyMessageSuppressed = true;
|
||||
cfg.SaveOnReadOnlyWarningSuppressed = true;
|
||||
try { cfg.Save(); } catch { /* harmless — preference just won't persist */ }
|
||||
AppendLogEntry("save-blocked-by-read-only message suppressed by user");
|
||||
AppendLogEntry("save-on-read-only warning suppressed by user");
|
||||
}
|
||||
return clicked == saveButton;
|
||||
}
|
||||
|
||||
/// <summary>Rename the currently-active profile JSON on disk. No-op on the blank
|
||||
@@ -1877,7 +1904,13 @@ public sealed class MainForm : Form
|
||||
/// untouched.</summary>
|
||||
private async Task InstallUpdateAsync(UpdateInfo info)
|
||||
{
|
||||
var ok = await updater.DownloadAndStageInstallAsync(info).ConfigureAwait(true);
|
||||
// Pass the currently-loaded profile title so the updater drops a resume-after-update
|
||||
// sentinel; the relaunched RemSound.exe will pick this up in Program.Main and silently
|
||||
// re-open the same profile, skipping the picker. Without this, a silent or
|
||||
// mid-session update would drop the session AND leave the user back at the picker —
|
||||
// the session never resumes by itself. Null/empty title (blank template, no profile
|
||||
// saved yet) skips the sentinel and the relaunch falls through to normal startup.
|
||||
var ok = await updater.DownloadAndStageInstallAsync(info, currentProfileTitle).ConfigureAwait(true);
|
||||
if (!ok)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
@@ -2658,7 +2691,7 @@ public sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
var sendingNow = connected && IsSendEnabled && sender.IsRunning;
|
||||
var codecLabel = FormatCodecLabel(sender.Codec, sender.OpusFrameMilliseconds);
|
||||
var codecLabel = FormatCodecLabel(sender.Codec, sender.OpusFrameSamplesPerChannel);
|
||||
|
||||
for (int i = 0; i < connectedPeersList.Items.Count; i++)
|
||||
{
|
||||
@@ -5272,7 +5305,8 @@ public sealed class MainForm : Form
|
||||
var rate = settings.LoadSendRate();
|
||||
if (item.Codec == AudioTransportCodec.Opus)
|
||||
{
|
||||
return EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate) / 2.0;
|
||||
// EffectiveOpusFrameSamples is samples-per-channel at 48 kHz; ÷ 48 → ms, ÷ 2 → half-frame.
|
||||
return EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate) / 96.0;
|
||||
}
|
||||
// PCM
|
||||
if (settings.LoadTightLatencyMode() && settings.LoadAudioMode() == AudioMode.AsioOnly)
|
||||
@@ -5304,28 +5338,29 @@ public sealed class MainForm : Form
|
||||
private double RenderBufferEstimateMs() => 10;
|
||||
|
||||
/// <summary>
|
||||
/// Translates a codec choice + the user's Send Rate into the effective Opus frame size.
|
||||
/// PCM frame size is set separately in AudioSender.SetSendRate (it's a sample-count, not
|
||||
/// a milliseconds value). Standard returns the codec's natural frame; Tight halves it
|
||||
/// (Opus 20 → 10, Opus 10 → 5, PCM frame size handled in AudioSender). Opus codec accepts
|
||||
/// 2.5/5/10/20/40/60 ms — never goes below 5 here so we don't need sub-millisecond Opus.
|
||||
/// Translates a codec choice + the user's Send Rate into the effective Opus frame size in
|
||||
/// samples-per-channel at 48 kHz. PCM frame size is set separately in AudioSender.SetSendRate.
|
||||
/// Standard returns the codec's natural frame; Tight halves it (Opus 960 → 480 → 240 → 120
|
||||
/// floored). Floor is 120 samples = 2.5 ms = standard libopus's RESTRICTED_LOWDELAY minimum.
|
||||
/// </summary>
|
||||
private static int EffectiveOpusFrameMs(AudioTransportCodec codec, int opusFrameMs, SendRate rate)
|
||||
private static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate)
|
||||
{
|
||||
if (codec != AudioTransportCodec.Opus) return opusFrameMs;
|
||||
return rate == SendRate.Tight ? Math.Max(5, opusFrameMs / 2) : opusFrameMs;
|
||||
if (codec != AudioTransportCodec.Opus) return opusFrameSamples;
|
||||
return rate == SendRate.Tight ? Math.Max(120, opusFrameSamples / 2) : opusFrameSamples;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM",
|
||||
/// "Opus 10ms", "Opus 20ms". Uses the same EffectiveOpusFrameMs the encoder uses so the
|
||||
/// label reflects the actually-encoded frame size, not the codec menu choice.
|
||||
/// "Opus 10ms", "Opus 20ms", "Opus 2.5ms". Input is samples-per-channel at 48 kHz; the
|
||||
/// label derives ms from samples / 48 with up to one decimal place. Uses the same
|
||||
/// EffectiveOpusFrameSamples the encoder uses so the label reflects the actually-encoded
|
||||
/// frame size, not the codec menu choice.
|
||||
/// </summary>
|
||||
private static string FormatCodecLabel(AudioTransportCodec codec, int opusFrameMs)
|
||||
private static string FormatCodecLabel(AudioTransportCodec codec, int opusFrameSamples)
|
||||
{
|
||||
return codec switch
|
||||
{
|
||||
AudioTransportCodec.Opus => $"Opus {Math.Max(1, opusFrameMs)}ms",
|
||||
AudioTransportCodec.Opus => $"Opus {Math.Max(1, opusFrameSamples) / 48.0:0.##}ms",
|
||||
AudioTransportCodec.Pcm => "PCM",
|
||||
_ => codec.ToString(),
|
||||
};
|
||||
@@ -5342,8 +5377,9 @@ public sealed class MainForm : Form
|
||||
{
|
||||
if (codecBox.SelectedItem is CodecChoice item && item.Codec == AudioTransportCodec.Opus)
|
||||
{
|
||||
sender.ConfigureCodec(item.Codec, EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate));
|
||||
logFile.Event($"send rate changed to {rate} → Opus frame {EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate)}ms");
|
||||
var effectiveSamples = EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate);
|
||||
sender.ConfigureCodec(item.Codec, effectiveSamples);
|
||||
logFile.Event($"send rate changed to {rate} → Opus frame {effectiveSamples / 48.0:0.##}ms");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -5351,10 +5387,21 @@ public sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveCodecIndex(AudioTransportCodec codec, int opusFrameMs)
|
||||
private static int ResolveCodecIndex(AudioTransportCodec codec, int opusFrameSamples)
|
||||
{
|
||||
if (codec == AudioTransportCodec.Pcm) return 0;
|
||||
return opusFrameMs == 20 ? 1 : 2; // Opus 20 = index 1, Opus 10 (default) = index 2
|
||||
// Opus 120 (2.5 ms — live latency) = index 2. Anything else (including the retired
|
||||
// 10 ms middle (480) and the never-exposed 5 ms (240)) collapses to index 1
|
||||
// (broadcast quality / 20 ms), the safer default — losing a little latency is the
|
||||
// less surprising outcome on upgrade than losing loss tolerance. v2.x profiles that
|
||||
// saved OpusFrameMilliseconds=10 (which the settings store migrates to 480 samples
|
||||
// via the <120 sentinel) land here on the broadcast side; users who specifically
|
||||
// want low latency re-pick "live latency" from the dropdown.
|
||||
return opusFrameSamples switch
|
||||
{
|
||||
120 => 2,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ===================== Auto-tune =====================
|
||||
|
||||
@@ -16,9 +16,11 @@ internal sealed class LiveCheckedListBox : CheckedListBox
|
||||
|
||||
/// <summary>
|
||||
/// User-facing choice that maps a friendly label to a codec + Opus frame size pair. The frame
|
||||
/// size is only meaningful when Codec == Opus; for PCM it's ignored.
|
||||
/// size is in samples-per-channel at 48 kHz (v3.0 wire-format unit — see
|
||||
/// <see cref="AudioFormatInfo"/>). 120 = 2.5 ms, 240 = 5 ms, 480 = 10 ms, 960 = 20 ms.
|
||||
/// Only meaningful when Codec == Opus; for PCM it's ignored.
|
||||
/// </summary>
|
||||
internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameMs)
|
||||
internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameSamples)
|
||||
{
|
||||
public override string ToString() => Label;
|
||||
}
|
||||
|
||||
+45
-10
@@ -35,18 +35,53 @@ internal static class Program
|
||||
|
||||
Profile? profile;
|
||||
string? title;
|
||||
// Auto-load shortcut: if AppConfig.StartWithProfileTitle is set and the named
|
||||
// profile actually exists in the current store, skip the picker entirely and
|
||||
// load that profile directly. This is what the Startup behaviour dialog's
|
||||
// "Start with a specific profile" toggle drives. Combined with the Windows
|
||||
// auto-start registry entry and the StartMinimised flag, it lets the user
|
||||
// boot a machine and have RemSound up and streaming with no clicks. Falls
|
||||
// through to the normal picker if the configured profile no longer exists
|
||||
// (deleted since it was selected, or the profiles folder changed) so the user
|
||||
// isn't stuck.
|
||||
// Auto-load shortcut: two paths.
|
||||
//
|
||||
// (a) Resume-after-update sentinel — a one-shot file written by the updater
|
||||
// just before it relaunches RemSound.exe (see RemSoundUpdater
|
||||
// ResumeProfileSentinelName). Holds the title of whichever profile was
|
||||
// loaded at the moment the update fired. If present, we load that profile
|
||||
// silently and delete the sentinel — so a silent or mid-session update
|
||||
// restores the same session the user was running, without dropping them
|
||||
// at the picker. This takes precedence over StartWithProfileTitle because
|
||||
// a mid-session update may have moved the user away from their configured
|
||||
// startup profile.
|
||||
//
|
||||
// (b) AppConfig.StartWithProfileTitle — the persistent "Start with a specific
|
||||
// profile" preference set via the Startup behaviour dialog. Loaded if (a)
|
||||
// didn't fire. Combined with the Windows auto-start registry entry and the
|
||||
// StartMinimised flag, it lets the user boot a machine and have RemSound
|
||||
// up and streaming with no clicks.
|
||||
//
|
||||
// Either path falls through to the normal picker if the named profile no longer
|
||||
// exists (deleted since it was selected, or the profiles folder changed) so the
|
||||
// user isn't stuck.
|
||||
Profile? autoLoaded = null;
|
||||
string? autoLoadedTitle = null;
|
||||
if (!string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
|
||||
|
||||
var resumeSentinelPath = Path.Combine(AppContext.BaseDirectory, RemSoundUpdater.ResumeProfileSentinelName);
|
||||
string? resumeTitle = null;
|
||||
if (File.Exists(resumeSentinelPath))
|
||||
{
|
||||
try { resumeTitle = File.ReadAllText(resumeSentinelPath).Trim(); }
|
||||
catch { resumeTitle = null; }
|
||||
// Delete the sentinel unconditionally — it's a one-shot. If the load fails
|
||||
// below, the user gets the picker on this launch and a normal start next
|
||||
// time, rather than the sentinel re-firing on every relaunch forever.
|
||||
try { File.Delete(resumeSentinelPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resumeTitle))
|
||||
{
|
||||
try
|
||||
{
|
||||
autoLoaded = store.Load(resumeTitle!);
|
||||
if (autoLoaded is not null) autoLoadedTitle = resumeTitle;
|
||||
}
|
||||
catch { /* fall through to StartWithProfileTitle / picker */ }
|
||||
}
|
||||
|
||||
if (autoLoaded is null && !string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -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. -->
|
||||
<Version>2.2.0</Version>
|
||||
<Version>3.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -134,12 +134,29 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Filename of the one-shot "after the update restart, silently load this profile"
|
||||
/// sentinel. Written next to RemSound.exe by <see cref="DownloadAndStageInstallAsync"/>
|
||||
/// when the caller supplies a non-empty <c>activeProfileTitle</c>; read and deleted by
|
||||
/// <c>Program.Main</c> on the next startup. Lives in the install directory (not %TEMP%
|
||||
/// or %APPDATA%) because the helper batch's robocopy step needs to know to skip it —
|
||||
/// see the <c>/XF</c> list in <see cref="BuildInstallScript"/>.</summary>
|
||||
public const string ResumeProfileSentinelName = "_resume-after-update.txt";
|
||||
|
||||
/// <summary>Download the update ZIP, stage it next to RemSound.exe, spawn the detached
|
||||
/// install helper, and ask the App to exit so the helper can take over. Returns true if
|
||||
/// the helper was launched (caller should Application.Exit immediately afterwards);
|
||||
/// false on any failure earlier in the pipeline. A false return leaves the running
|
||||
/// instance untouched.</summary>
|
||||
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, CancellationToken token = default)
|
||||
/// instance untouched.
|
||||
///
|
||||
/// <paramref name="activeProfileTitle"/> — when non-empty, a one-shot sentinel file
|
||||
/// <see cref="ResumeProfileSentinelName"/> is written next to RemSound.exe just before
|
||||
/// the helper is launched. On the next startup, Program.Main reads it, loads that profile
|
||||
/// silently (skipping the picker), and deletes the sentinel. This makes a silent / manual
|
||||
/// update behave like the session never ended — the user is back in the same profile
|
||||
/// they were running, without having to remember which one it was. When null/empty, no
|
||||
/// sentinel is written and the post-update launch uses whatever startup behaviour
|
||||
/// AppConfig has configured.</summary>
|
||||
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, string? activeProfileTitle = null, CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -147,13 +164,17 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
var stagingDir = Path.Combine(baseDir, "_update");
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), $"RemSound-update-{info.Tag}.zip");
|
||||
var batchPath = Path.Combine(baseDir, "_apply-update.cmd");
|
||||
var resumeSentinelPath = Path.Combine(baseDir, ResumeProfileSentinelName);
|
||||
|
||||
// Tidy any leftover from a previous failed attempt before we start. Also clear
|
||||
// the failure marker — the new attempt starts clean and only re-creates the
|
||||
// marker if THIS run fails.
|
||||
// marker if THIS run fails. The resume sentinel from a previous run (if any) is
|
||||
// also cleared here; if the caller supplies a profile title, the new sentinel is
|
||||
// written below after staging succeeds.
|
||||
TryDelete(zipPath);
|
||||
TryDeleteDirectory(stagingDir);
|
||||
TryDelete(Path.Combine(baseDir, "update-failed.txt"));
|
||||
TryDelete(resumeSentinelPath);
|
||||
|
||||
Log?.Invoke($"updater: downloading {info.DownloadUrl}");
|
||||
await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false))
|
||||
@@ -174,6 +195,25 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
Log?.Invoke($"updater: writing install helper {batchPath}");
|
||||
File.WriteAllText(batchPath, BuildInstallScript(stagingRoot, baseDir));
|
||||
|
||||
// Write the resume-after-update sentinel so the post-restart launch loads the
|
||||
// same profile silently (no picker, no missed session). Only when the caller
|
||||
// supplied a title — blank-template sessions and explicit "no profile yet" cases
|
||||
// fall through to the normal startup logic.
|
||||
if (!string.IsNullOrWhiteSpace(activeProfileTitle))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(resumeSentinelPath, activeProfileTitle);
|
||||
Log?.Invoke($"updater: wrote resume sentinel for profile '{activeProfileTitle}'");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Sentinel is best-effort. If we can't write it (disk full, ACL change),
|
||||
// the update still proceeds; the user gets the picker on relaunch.
|
||||
Log?.Invoke($"updater: could not write resume sentinel: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var pid = System.Environment.ProcessId;
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
@@ -265,7 +305,7 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
rem startup settings) and their data folders (logs / profiles / recordings). An update
|
||||
rem replaces APP files only. build-release.ps1 already keeps those out of the release
|
||||
rem zip; this is the second line of defence so a bad zip still can't clobber them.
|
||||
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XD logs profiles recordings _update /LOG+:"%LOG%"
|
||||
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update /LOG+:"%LOG%"
|
||||
set "ROBO_EXIT=%ERRORLEVEL%"
|
||||
echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%"
|
||||
|
||||
@@ -294,6 +334,10 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
echo. >> "%MARKER%"
|
||||
echo Once RemSound has updated successfully you can delete this file. >> "%MARKER%"
|
||||
echo Technical details for support are in _update-helper.log in this folder. >> "%MARKER%"
|
||||
rem Drop the resume-after-update sentinel on failure too — there's no restart
|
||||
rem happening, so a stale sentinel would mis-direct the user's next manual launch
|
||||
rem into auto-loading a profile they may have moved on from in the meantime.
|
||||
del "{installArg}\{ResumeProfileSentinelName}" 2>nul
|
||||
echo %DATE% %TIME% FAILURE: robocopy exit=%ROBO_EXIT%, update folder kept, NOT restarting RemSound >> "%LOG%"
|
||||
del "%~f0"
|
||||
exit /b %ROBO_EXIT%
|
||||
|
||||
@@ -50,12 +50,18 @@ public sealed class AppConfig
|
||||
public bool SaveProfileConfirmationSuppressed { get; set; }
|
||||
|
||||
/// <summary>True if the user has ticked "do not show me this message again" on the
|
||||
/// "this profile is read-only, save was skipped" popup. Once ticked, Ctrl+S / File → Save
|
||||
/// on a read-only profile silently does nothing instead of explaining why — the user
|
||||
/// has acknowledged that they know it's a no-op. Machine-local (not per-profile) so the
|
||||
/// preference sticks across profile switches; the prompt itself is the same wording on
|
||||
/// any read-only profile so a single dismissal applies everywhere. 2026-05-22.</summary>
|
||||
public bool SaveOnReadOnlyMessageSuppressed { get; set; }
|
||||
/// "you are saving onto a read-only profile" warning. Once ticked, Ctrl+S / File → Save
|
||||
/// on a read-only profile saves silently through the lock instead of warning first.
|
||||
/// Machine-local (not per-profile) so the preference sticks across profile switches; the
|
||||
/// prompt itself is the same wording on any read-only profile so a single dismissal
|
||||
/// applies everywhere. 2026-05-23 — semantic shift from v2.x: in v2.x the read-only lock
|
||||
/// hard-blocked explicit saves and this flag suppressed the explanatory "save was skipped"
|
||||
/// popup. In v3.0 the lock only suppresses the automatic "you have unsaved changes" prompt
|
||||
/// on close / profile switch; explicit Ctrl+S / File → Save now goes through with a
|
||||
/// one-time warning gated by this flag. The JSON key was renamed alongside the semantic
|
||||
/// change so users upgrading from v2.x see the new warning at least once — a v2.x
|
||||
/// suppression flag is no longer applicable and is silently discarded on load.</summary>
|
||||
public bool SaveOnReadOnlyWarningSuppressed { get; set; }
|
||||
|
||||
/// <summary>If true, RemSound minimises to the system tray immediately after the main
|
||||
/// window finishes loading. Lets the user "boot up the machine and have RemSound
|
||||
|
||||
@@ -6,6 +6,18 @@ namespace RemSound.Core;
|
||||
/// the semantics. The field is wire-backward-compatible: old receivers parse the first 32
|
||||
/// bytes of the format payload and ignore the extra; new receivers reading a 32-byte
|
||||
/// payload from an old sender default Lane to <see cref="RenderRoute.Mixed"/>.
|
||||
///
|
||||
/// 2026-05-23 — <c>FrameDurationMilliseconds</c> renamed to <c>FrameSamplesPerChannel</c>
|
||||
/// (v3.0 wire-format change). Previously held an int millisecond value; now holds the exact
|
||||
/// sample-count per channel at the announced <see cref="SampleRate"/>. Conversion is
|
||||
/// <c>ms = FrameSamplesPerChannel * 1000 / SampleRate</c> for display. The change exists
|
||||
/// because Opus's 2.5 ms RESTRICTED_LOWDELAY frame (= 120 samples at 48 kHz) can't be
|
||||
/// expressed cleanly in integer milliseconds; using sample-count also removes a lossy
|
||||
/// conversion that the encoder/decoder pipeline previously did at every announcement.
|
||||
/// v2.x receivers reading this field will misinterpret the value as milliseconds and
|
||||
/// over-size their internal buffers; the actual decode still works because the Opus
|
||||
/// decoder is self-describing from the packet TOC byte. Within-major-version (v3.x ↔ v3.x)
|
||||
/// the field is unambiguous.
|
||||
/// </summary>
|
||||
public sealed record AudioFormatInfo(
|
||||
int SampleRate,
|
||||
@@ -15,9 +27,15 @@ public sealed record AudioFormatInfo(
|
||||
int BlockAlign,
|
||||
int AverageBytesPerSecond,
|
||||
int Codec = (int)AudioTransportCodec.Pcm,
|
||||
int FrameDurationMilliseconds = 10,
|
||||
int FrameSamplesPerChannel = 480,
|
||||
RenderRoute Lane = RenderRoute.Mixed)
|
||||
{
|
||||
/// <summary>Human-friendly frame duration in milliseconds, derived from
|
||||
/// <see cref="FrameSamplesPerChannel"/> and <see cref="SampleRate"/>. May be a fraction
|
||||
/// (2.5 ms at 48 kHz / 120 samples). For string formatting only — the encoder/decoder
|
||||
/// hot path uses <see cref="FrameSamplesPerChannel"/> directly.</summary>
|
||||
public double FrameDurationMs => SampleRate > 0 ? FrameSamplesPerChannel * 1000.0 / SampleRate : 0;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var encodingName = Encoding switch
|
||||
@@ -28,7 +46,7 @@ public sealed record AudioFormatInfo(
|
||||
};
|
||||
var codecName = (AudioTransportCodec)Codec switch
|
||||
{
|
||||
AudioTransportCodec.Opus => $" over Opus ({FrameDurationMilliseconds} ms)",
|
||||
AudioTransportCodec.Opus => $" over Opus ({FrameDurationMs:0.##} ms)",
|
||||
_ => ""
|
||||
};
|
||||
var laneName = Lane == RenderRoute.Mixed ? "" : $" [{Lane}]";
|
||||
|
||||
@@ -62,7 +62,17 @@ public sealed class Profile
|
||||
// === Connectivity & transport ===
|
||||
public int AudioPort { get; set; } = 47830;
|
||||
public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm;
|
||||
public int OpusFrameMilliseconds { get; set; } = 10;
|
||||
/// <summary>Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms,
|
||||
/// 480 = 10 ms (default), 960 = 20 ms. Renamed from <c>OpusFrameMilliseconds</c> in the
|
||||
/// v3.0 wire-format refactor (2026-05-23). The JSON key is kept as
|
||||
/// <c>OpusFrameMilliseconds</c> for back-compat with v2.x profile files; on read,
|
||||
/// <see cref="RemSoundSettingsStore.LoadOpusFrameSamplesPerChannel"/> disambiguates the
|
||||
/// legacy integer-ms encoding (5/10/20) from the new sample-count encoding
|
||||
/// (120/240/480/960) using a sentinel: any persisted value < 120 is treated as ms and
|
||||
/// multiplied by 48. v2.x readers loading a v3.x profile see e.g. 480 and clamp it to
|
||||
/// their accept-list (which only knows 10 or 20), defaulting silently to 10 ms.</summary>
|
||||
[JsonPropertyName("OpusFrameMilliseconds")]
|
||||
public int OpusFrameSamplesPerChannel { get; set; } = 480;
|
||||
public int SendRateRaw { get; set; } = (int)SendRate.Standard;
|
||||
public bool TightLatencyMode { get; set; }
|
||||
/// <summary>True if this profile asks Windows to keep the RemSound process in
|
||||
|
||||
@@ -157,7 +157,10 @@ public static class RemPacket
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[16..], format.BlockAlign);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[20..], format.AverageBytesPerSecond);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[24..], format.Codec);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[28..], format.FrameDurationMilliseconds);
|
||||
// Wire field at offset 28: sample-count per channel at the announced sample rate
|
||||
// (formerly milliseconds, renamed 2026-05-23 — see AudioFormatInfo doc comment for
|
||||
// the v3.0 wire-format change).
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[28..], format.FrameSamplesPerChannel);
|
||||
// Extension: 1 byte Lane + 3 reserved-zero bytes. Zero-fill the reserved slot so a
|
||||
// future receiver doesn't accidentally read stale stack data if WriteFormatPayload
|
||||
// is called on an uninitialised buffer.
|
||||
|
||||
@@ -199,16 +199,41 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public int LoadOpusFrameMilliseconds(int defaultValue = 10) =>
|
||||
Try(() => Load()?.OpusFrameMilliseconds is int v && (v == 10 || v == 20) ? v : (int?)null) ?? defaultValue;
|
||||
/// <summary>Loads the Opus frame size in samples-per-channel at 48 kHz. Default 480 = 10 ms.
|
||||
/// Migration path: profiles written by v2.x stored milliseconds (5/10/20) in the same JSON
|
||||
/// field; values < 120 are interpreted as legacy ms and converted (×48 → samples). The
|
||||
/// ranges don't overlap (max legitimate ms = 60, min legitimate samples = 120), so the
|
||||
/// disambiguation is unambiguous.</summary>
|
||||
public int LoadOpusFrameSamplesPerChannel(int defaultValue = 480) =>
|
||||
Try(() => Load()?.OpusFrameSamplesPerChannel is int v ? NormalizeOpusFrameSamples(v) : (int?)null) ?? defaultValue;
|
||||
|
||||
public void SaveOpusFrameMilliseconds(int value)
|
||||
/// <summary>Saves the Opus frame size in samples-per-channel at 48 kHz. Accepts the four
|
||||
/// standard-Opus RESTRICTED_LOWDELAY values (120/240/480/960); anything else collapses to
|
||||
/// 480 (= 10 ms), the safe default.</summary>
|
||||
public void SaveOpusFrameSamplesPerChannel(int value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.OpusFrameMilliseconds = value == 20 ? 20 : 10;
|
||||
s.OpusFrameSamplesPerChannel = value switch
|
||||
{
|
||||
960 => 960, // 20 ms
|
||||
480 => 480, // 10 ms
|
||||
240 => 240, // 5 ms — not exposed in the dropdown but reachable via Tight rate
|
||||
120 => 120, // 2.5 ms experimental
|
||||
_ => 480,
|
||||
};
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>Disambiguates a persisted Opus frame-size value between the legacy v2.x
|
||||
/// integer-milliseconds storage (5/10/20) and the v3.x samples-per-channel storage
|
||||
/// (120/240/480/960). Values < 120 are legacy ms; ≥ 120 are samples. See
|
||||
/// <see cref="LoadOpusFrameSamplesPerChannel"/>.</summary>
|
||||
private static int NormalizeOpusFrameSamples(int persisted)
|
||||
{
|
||||
if (persisted < 120) return persisted * 48; // legacy ms → samples at 48 kHz
|
||||
return persisted;
|
||||
}
|
||||
|
||||
public bool LoadContinuousAutoTuneEnabled(bool defaultValue = false) =>
|
||||
Try(() => Load()?.ContinuousAutoTuneEnabled) ?? defaultValue;
|
||||
|
||||
@@ -486,7 +511,7 @@ public sealed class RemSoundSettingsStore
|
||||
AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands,
|
||||
MaxLatencyMs = profile.MaxLatencyMs,
|
||||
Codec = profile.Codec,
|
||||
OpusFrameMilliseconds = profile.OpusFrameMilliseconds,
|
||||
OpusFrameSamplesPerChannel = profile.OpusFrameSamplesPerChannel,
|
||||
ContinuousAutoTuneEnabled = profile.ContinuousAutoTuneEnabled,
|
||||
ContinuousAutoTuneIntervalSec = profile.ContinuousAutoTuneIntervalSec,
|
||||
MaxLatencyMsAsio = profile.MaxLatencyMsAsio,
|
||||
@@ -533,7 +558,7 @@ public sealed class RemSoundSettingsStore
|
||||
if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc;
|
||||
if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml;
|
||||
if (s.Codec is AudioTransportCodec c) profile.Codec = c;
|
||||
if (s.OpusFrameMilliseconds is int op) profile.OpusFrameMilliseconds = op;
|
||||
if (s.OpusFrameSamplesPerChannel is int op) profile.OpusFrameSamplesPerChannel = op;
|
||||
if (s.ContinuousAutoTuneEnabled is bool cae) profile.ContinuousAutoTuneEnabled = cae;
|
||||
if (s.ContinuousAutoTuneIntervalSec is int cai) profile.ContinuousAutoTuneIntervalSec = cai;
|
||||
if (s.MaxLatencyMsAsio is int mla) profile.MaxLatencyMsAsio = mla;
|
||||
@@ -592,7 +617,10 @@ public sealed class RemSoundSettingsStore
|
||||
public bool? AcceptRemoteVolumeCommands { get; set; }
|
||||
public int? MaxLatencyMs { get; set; }
|
||||
public AudioTransportCodec? Codec { get; set; }
|
||||
public int? OpusFrameMilliseconds { get; set; }
|
||||
// Renamed 2026-05-23 (v3.0). Was OpusFrameMilliseconds; value semantic shifted to
|
||||
// samples-per-channel at 48 kHz. The cache is in-memory only — no JSON migration is
|
||||
// needed here; on-disk migration happens in Profile via [JsonPropertyName].
|
||||
public int? OpusFrameSamplesPerChannel { get; set; }
|
||||
public bool? ContinuousAutoTuneEnabled { get; set; }
|
||||
public int? ContinuousAutoTuneIntervalSec { get; set; }
|
||||
// Per-route latency settings used in AudioMode.BothIndependent only. MaxLatencyMs
|
||||
|
||||
@@ -274,7 +274,9 @@ public sealed class AudioReceiver : IDisposable
|
||||
/// <summary>
|
||||
/// Frame duration of the most-recently-active stream (10 ms PCM, 20 ms Opus). null when no
|
||||
/// stream is active. With multiple senders this picks the largest frame duration as the
|
||||
/// codec floor — most conservative for the auto-tune.
|
||||
/// codec floor — most conservative for the auto-tune. Returns ms (rounded up to the next
|
||||
/// integer if the underlying sample-count yields a fractional duration, e.g. 2.5 ms → 3),
|
||||
/// so the auto-tune always overestimates rather than underestimates the codec floor.
|
||||
/// </summary>
|
||||
public int? ActiveStreamFrameMs
|
||||
{
|
||||
@@ -283,12 +285,20 @@ public sealed class AudioReceiver : IDisposable
|
||||
lock (sessionsLock)
|
||||
{
|
||||
if (sessions.Count == 0) return null;
|
||||
var maxFrame = 0;
|
||||
var maxSamples = 0;
|
||||
var sampleRate = 48000;
|
||||
foreach (var s in sessions.Values)
|
||||
{
|
||||
if (s.Format.FrameDurationMilliseconds > maxFrame) maxFrame = s.Format.FrameDurationMilliseconds;
|
||||
if (s.Format.FrameSamplesPerChannel > maxSamples)
|
||||
{
|
||||
maxSamples = s.Format.FrameSamplesPerChannel;
|
||||
sampleRate = s.Format.SampleRate > 0 ? s.Format.SampleRate : 48000;
|
||||
}
|
||||
return maxFrame;
|
||||
}
|
||||
// Round up so a 2.5 ms frame reports as 3 ms — the auto-tune treats this as
|
||||
// a floor, and overestimating by half a millisecond is safer than rounding
|
||||
// down to 2 ms and pushing the buffer below the real codec frame size.
|
||||
return (maxSamples * 1000 + sampleRate - 1) / sampleRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ internal sealed class StreamSession : IDisposable
|
||||
&& Format.Codec == format.Codec
|
||||
&& Format.SampleRate == format.SampleRate
|
||||
&& Format.Channels == format.Channels
|
||||
&& Format.FrameDurationMilliseconds == format.FrameDurationMilliseconds;
|
||||
&& Format.FrameSamplesPerChannel == format.FrameSamplesPerChannel;
|
||||
|
||||
public bool IsSameEndpoint(IPEndPoint endpoint) => Endpoint.Equals(endpoint);
|
||||
|
||||
@@ -220,7 +220,11 @@ internal sealed class StreamSession : IDisposable
|
||||
{
|
||||
if (opusDecoder is null) return false;
|
||||
|
||||
var frameSize = Math.Max(1, Format.SampleRate * Math.Max(5, Format.FrameDurationMilliseconds) / 1000);
|
||||
// Frame size in samples-per-channel comes directly off the wire in v3.0+ (was
|
||||
// SampleRate × ms / 1000 in v2.x). Floor at 120 = 2.5 ms = standard libopus
|
||||
// RESTRICTED_LOWDELAY minimum, so a malformed format packet with a tiny value can't
|
||||
// size the scratch buffer below the encoder's minimum frame size.
|
||||
var frameSize = Math.Max(120, Format.FrameSamplesPerChannel);
|
||||
var totalShorts = frameSize * Format.Channels;
|
||||
Span<short> shortScratch = totalShorts <= 4096 ? stackalloc short[totalShorts] : new short[totalShorts];
|
||||
|
||||
|
||||
@@ -87,15 +87,18 @@ public sealed class AudioSender : IDisposable
|
||||
|
||||
private readonly Stopwatch uptime = new();
|
||||
private volatile AudioTransportCodec codec = AudioTransportCodec.Pcm;
|
||||
private volatile int opusFrameMs = 10; // only meaningful when codec == Opus
|
||||
// Opus frame size in samples-per-channel at 48 kHz. Default 480 = 10 ms. Renamed from
|
||||
// opusFrameMs 2026-05-23 (v3.0 wire-format refactor) so the 2.5 ms RESTRICTED_LOWDELAY
|
||||
// mode (= 120 samples) can be expressed cleanly. Only meaningful when codec == Opus.
|
||||
private volatile int opusFrameSamples = 480;
|
||||
private volatile bool muted;
|
||||
private IPEndPoint[] receivers = [];
|
||||
private long packetsSent;
|
||||
private long bytesSent;
|
||||
|
||||
// Internal accessor so SenderLane can read tight-latency without exposing the field
|
||||
// publicly. Codec, OpusFrameMilliseconds and IsMuted are already exposed publicly below
|
||||
// and re-used directly by the lane.
|
||||
// publicly. Codec, OpusFrameSamplesPerChannel and IsMuted are already exposed publicly
|
||||
// below and re-used directly by the lane.
|
||||
internal bool IsTightLatencyEnabled => tightLatencyEnabled;
|
||||
|
||||
// Hot-path timing instrumentation. Both lanes update these on every emit; the SNAP
|
||||
@@ -253,8 +256,8 @@ public sealed class AudioSender : IDisposable
|
||||
// construction time), the lines are silently dropped, which is acceptable for a
|
||||
// success/no-op outcome. On failure the socket keeps working without prioritisation.
|
||||
networkPriority.TryAttach(udp.Client, msg => diagnostic?.Invoke(msg));
|
||||
defaultLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
|
||||
asioLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
|
||||
defaultLane = new SenderLane(this, opusFrameSamples, OpusBitrateLan);
|
||||
asioLane = new SenderLane(this, opusFrameSamples, OpusBitrateLan);
|
||||
// WasapiOnly at startup — no ASIO needed yet, so persistentAsio stays null.
|
||||
currentAudioMode = AudioMode.WasapiOnly;
|
||||
currentAsioDriverName = null;
|
||||
@@ -388,9 +391,10 @@ public sealed class AudioSender : IDisposable
|
||||
public bool IsAsioBackend => engine is CompositeCaptureBackend;
|
||||
|
||||
/// <summary>Updates the PCM frame size based on the user's "Send rate" choice. For Opus,
|
||||
/// frame size is set via <see cref="ConfigureCodec"/>'s opusFrameMs parameter (the App
|
||||
/// halves it when SendRate is Tight). On a frame-size change, resets the accumulator and
|
||||
/// stream id so the receiver opens a fresh session at the new format.</summary>
|
||||
/// frame size is set via <see cref="ConfigureCodec"/>'s opusFrameSamplesPerChannel
|
||||
/// parameter (the App halves it when SendRate is Tight). On a frame-size change, resets
|
||||
/// the accumulator and stream id so the receiver opens a fresh session at the new format.
|
||||
/// </summary>
|
||||
public void SetSendRate(SendRate rate)
|
||||
{
|
||||
lock (configGate)
|
||||
@@ -444,7 +448,10 @@ public sealed class AudioSender : IDisposable
|
||||
public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription;
|
||||
public string? LastCaptureError => engine.FirstCaptureLastError;
|
||||
public AudioTransportCodec Codec => codec;
|
||||
public int OpusFrameMilliseconds => opusFrameMs;
|
||||
/// <summary>Opus frame size in samples-per-channel at 48 kHz. 120 = 2.5 ms, 240 = 5 ms,
|
||||
/// 480 = 10 ms, 960 = 20 ms. Renamed from OpusFrameMilliseconds in the v3.0 wire-format
|
||||
/// refactor (see <see cref="AudioFormatInfo"/>).</summary>
|
||||
public int OpusFrameSamplesPerChannel => opusFrameSamples;
|
||||
|
||||
/// <summary>
|
||||
/// Atomically set the codec and (for Opus) the frame size. Resets stream identity and the
|
||||
@@ -452,22 +459,23 @@ public sealed class AudioSender : IDisposable
|
||||
/// parameters are taken together because changing only one would briefly send malformed
|
||||
/// frames at the encoder boundary.
|
||||
/// </summary>
|
||||
public void ConfigureCodec(AudioTransportCodec newCodec, int newOpusFrameMs = 10)
|
||||
public void ConfigureCodec(AudioTransportCodec newCodec, int newOpusFrameSamplesPerChannel = 480)
|
||||
{
|
||||
var clampedFrameMs = Math.Clamp(newOpusFrameMs, 5, 60);
|
||||
if (codec == newCodec && (newCodec != AudioTransportCodec.Opus || opusFrameMs == clampedFrameMs))
|
||||
// Clamp to the legal Opus range at 48 kHz: 120 (2.5 ms) to 2880 (60 ms).
|
||||
var clampedSamples = Math.Clamp(newOpusFrameSamplesPerChannel, 120, 2880);
|
||||
if (codec == newCodec && (newCodec != AudioTransportCodec.Opus || opusFrameSamples == clampedSamples))
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock (configGate)
|
||||
{
|
||||
codec = newCodec;
|
||||
opusFrameMs = clampedFrameMs;
|
||||
opusFrameSamples = clampedSamples;
|
||||
// Rebuild both lanes' encoders + rotate their streamIds. Same idle-lane rationale
|
||||
// as SetSendRate — harmless when the asio lane has no producer; necessary when it
|
||||
// does (BothIndependent).
|
||||
defaultLane.OnCodecChanged(newCodec, clampedFrameMs);
|
||||
asioLane.OnCodecChanged(newCodec, clampedFrameMs);
|
||||
defaultLane.OnCodecChanged(newCodec, clampedSamples);
|
||||
asioLane.OnCodecChanged(newCodec, clampedSamples);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,22 @@ namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a Concentus Opus encoder configured for real-time low-latency 48 kHz stereo audio.
|
||||
/// Frame size is selectable at construction (10 ms or 20 ms). Receiver auto-handles whatever
|
||||
/// frame size the sender announces in the format packet — no coordination required.
|
||||
/// Frame size is selectable at construction as samples-per-channel. Receiver auto-handles
|
||||
/// whatever frame size the sender announces in the format packet — no coordination required.
|
||||
///
|
||||
/// 2026-05-23 — switched from the <c>Encode(ReadOnlySpan<short>...)</c> overload to the
|
||||
/// float overload after the first allocation-rate measurement (Part C, item 51 of
|
||||
/// RemSoundefficiency.md). The float overload skips one internal float→short→float round trip
|
||||
/// inside Concentus (CELT runs in float natively in RESTRICTED_LOWDELAY mode), and lets us
|
||||
/// drop our own per-sample Math.Clamp + cast loop — Concentus' float overload does its own
|
||||
/// out-of-range clipping per its XML docs. Same encoder configuration, same bitrate, same
|
||||
/// frame size, same audio output bit-for-bit.
|
||||
/// 2026-05-23 (a) — switched from the <c>Encode(ReadOnlySpan<short>...)</c> overload to
|
||||
/// the float overload after the first allocation-rate measurement (Part C, item 51 of
|
||||
/// RemSoundefficiency.md). The float overload skips one internal float→short→float round
|
||||
/// trip inside Concentus (CELT runs in float natively in RESTRICTED_LOWDELAY mode), and
|
||||
/// lets us drop our own per-sample Math.Clamp + cast loop — Concentus' float overload does
|
||||
/// its own out-of-range clipping per its XML docs. Same encoder configuration, same bitrate,
|
||||
/// same audio output bit-for-bit.
|
||||
///
|
||||
/// 2026-05-23 (b) — constructor parameter switched from milliseconds to samples-per-channel
|
||||
/// as part of the v3.0 wire-format refactor. Lets us express the 2.5 ms (= 120 samples)
|
||||
/// experimental low-latency mode cleanly without floating-point ms, and removes the
|
||||
/// <c>48000 * ms / 1000</c> conversion (which lost precision below 1 ms boundaries). Encoder
|
||||
/// configuration is otherwise identical.
|
||||
/// </summary>
|
||||
internal sealed class OpusEncoderState : IDisposable
|
||||
{
|
||||
@@ -24,16 +30,15 @@ internal sealed class OpusEncoderState : IDisposable
|
||||
private readonly IOpusEncoder encoder;
|
||||
private readonly byte[] packetScratch = new byte[PacketBufferBytes];
|
||||
|
||||
public int FrameMilliseconds { get; }
|
||||
public int FrameSizePerChannel { get; }
|
||||
|
||||
public OpusEncoderState(int frameMilliseconds, int bitrate)
|
||||
public OpusEncoderState(int frameSamplesPerChannel, int bitrate)
|
||||
{
|
||||
// RESTRICTED_LOWDELAY supports 2.5/5/10/20 ms frames. 10 ms = lowest practical latency,
|
||||
// 20 ms = same bitrate but more robust to packet loss (each lost packet is half the audio
|
||||
// share). We expose 10 and 20 as the user-selectable choices.
|
||||
FrameMilliseconds = Math.Clamp(frameMilliseconds, 5, 60);
|
||||
FrameSizePerChannel = 48000 * FrameMilliseconds / 1000;
|
||||
// RESTRICTED_LOWDELAY supports 2.5/5/10/20 ms frames at 48 kHz = 120/240/480/960
|
||||
// samples-per-channel. 120 (2.5 ms) is the lowest standard-Opus frame size. We clamp
|
||||
// to the legal Opus range; the UI never offers a value outside it but a corrupt
|
||||
// setting can't crash the encoder constructor.
|
||||
FrameSizePerChannel = Math.Clamp(frameSamplesPerChannel, 120, 2880);
|
||||
|
||||
encoder = OpusCodecFactory.CreateEncoder(48000, Channels, OpusApplication.OPUS_APPLICATION_RESTRICTED_LOWDELAY, TextWriter.Null);
|
||||
encoder.Bitrate = bitrate;
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace RemSound.Sender;
|
||||
///
|
||||
/// Threading: the hot-path methods (<see cref="OnMixedSamples"/> and below) are called from
|
||||
/// the capture engine's callback thread. Each lane has exactly one such thread feeding it.
|
||||
/// Cross-thread state read from AudioSender (codec, mute, opusFrameMs, etc.) goes through
|
||||
/// Cross-thread state read from AudioSender (codec, mute, opusFrameSamples, etc.) goes through
|
||||
/// volatile fields on the owner. Configuration mutations (<see cref="ConfigureCodec"/>,
|
||||
/// <see cref="OnPcmFrameSizeChanged"/>) come from the UI thread; they take the same
|
||||
/// configGate that AudioSender does to serialise streamId rotation against in-flight
|
||||
@@ -77,11 +77,11 @@ internal sealed class SenderLane
|
||||
|
||||
public ushort StreamId => streamId;
|
||||
|
||||
public SenderLane(AudioSender owner, int initialOpusFrameMs, int opusBitrate)
|
||||
public SenderLane(AudioSender owner, int initialOpusFrameSamplesPerChannel, int opusBitrate)
|
||||
{
|
||||
this.owner = owner;
|
||||
this.opusBitrate = opusBitrate;
|
||||
opusEncoder = new OpusEncoderState(initialOpusFrameMs, opusBitrate);
|
||||
opusEncoder = new OpusEncoderState(initialOpusFrameSamplesPerChannel, opusBitrate);
|
||||
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
|
||||
streamId = NewStreamId();
|
||||
}
|
||||
@@ -121,11 +121,11 @@ internal sealed class SenderLane
|
||||
/// format), rebuilds the Opus encoder if Opus is in play, and zeroes the accumulator so
|
||||
/// any half-filled frame from the previous format doesn't leak into the new one.
|
||||
/// </summary>
|
||||
public void OnCodecChanged(AudioTransportCodec newCodec, int opusFrameMs)
|
||||
public void OnCodecChanged(AudioTransportCodec newCodec, int opusFrameSamplesPerChannel)
|
||||
{
|
||||
if (newCodec == AudioTransportCodec.Opus)
|
||||
{
|
||||
opusEncoder = new OpusEncoderState(opusFrameMs, opusBitrate);
|
||||
opusEncoder = new OpusEncoderState(opusFrameSamplesPerChannel, opusBitrate);
|
||||
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
|
||||
}
|
||||
streamId = NewStreamId();
|
||||
@@ -287,19 +287,19 @@ internal sealed class SenderLane
|
||||
if (DateTime.UtcNow - lastFormatPacketUtc < TimeSpan.FromMilliseconds(FormatResendIntervalMs)) return;
|
||||
lastFormatPacketUtc = DateTime.UtcNow;
|
||||
|
||||
// PCM FrameDurationMilliseconds: receiver only uses this for buffer sizing and
|
||||
// diagnostics, not for decode. Round 2.5 ms up to ≥1 to keep the wire field integer.
|
||||
var pcmFrameMs = owner.PcmFrameSamplesPerChannel * 1000 / MixSampleRate;
|
||||
if (pcmFrameMs < 1) pcmFrameMs = 1;
|
||||
// Wire field FrameSamplesPerChannel: receiver uses this for buffer sizing and the
|
||||
// decoder hot path. PCM passes through the sender's own sample-count directly; Opus
|
||||
// uses whatever the encoder is configured for. v3.0 wire format — see
|
||||
// AudioFormatInfo doc comment for the semantic-shift rationale.
|
||||
var codec = owner.Codec;
|
||||
var opusFrameMs = owner.OpusFrameMilliseconds;
|
||||
var opusFrameSamples = owner.OpusFrameSamplesPerChannel;
|
||||
// Pass this lane's current Route as the Lane field. In classic-mode senders this is
|
||||
// Mixed and the receiver routes the session to its legacy mix bus; in BothIndependent
|
||||
// senders this is WasapiLane or AsioLane and the receiver routes to the matching
|
||||
// per-route IWaveProvider surface.
|
||||
var format = codec == AudioTransportCodec.Opus
|
||||
? new AudioFormatInfo(48000, 2, 16, 1, 4, 192_000, (int)AudioTransportCodec.Opus, opusFrameMs, route)
|
||||
: new AudioFormatInfo(48000, 2, 24, 1, 6, 288_000, (int)AudioTransportCodec.Pcm, pcmFrameMs, route);
|
||||
? new AudioFormatInfo(48000, 2, 16, 1, 4, 192_000, (int)AudioTransportCodec.Opus, opusFrameSamples, route)
|
||||
: new AudioFormatInfo(48000, 2, 24, 1, 6, 288_000, (int)AudioTransportCodec.Pcm, owner.PcmFrameSamplesPerChannel, route);
|
||||
|
||||
// Allocate the extended (36-byte) format payload — see RemPacket.FormatPayloadExtendedSize
|
||||
// for the backward-compat contract. Old receivers parse the first 32 bytes and ignore
|
||||
|
||||
Reference in New Issue
Block a user