v3.9.1: plain/WASAPI streams now play on an ASIO-mode receiver (silent-mic fix)
Receiver: a Mixed (plain) session is rendered on an active lane in BothIndependent mode instead of being skipped, so a WASAPI-only sender is no longer silent to a receiver that has an ASIO driver selected. Also port the per-session buffer depth-drain (stops the receive jitter buffer bloating). Sender: add sndAudFr meter (audio frames actually sent) to localise capture vs send. App: startup sound cue (machine-wide, Preferences); stop sending audio when no peer is reachable (issue #8). Version 3.9.1. Server (relay): fix updater version-compare for multi-dot tags, guard the main loop against crashes, reject spoofed BYE from a mismatched endpoint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
04b17ff1ab
commit
84c4a47411
+1
-1
@@ -1 +1 @@
|
||||
server-v2.3
|
||||
server-v2.4
|
||||
|
||||
@@ -88,41 +88,46 @@ read_current_version() {
|
||||
printf '%s' "${TAG_PREFIX}v0"
|
||||
}
|
||||
|
||||
# Parse a tag like "server-v2.10" into a comparable numeric form.
|
||||
# Outputs MAJOR.MINOR; both default to 0 if the tag is unparseable.
|
||||
tag_to_version() {
|
||||
local tag="$1"
|
||||
# strip the prefix
|
||||
tag="${tag#"$TAG_PREFIX"}"
|
||||
# strip a leading "v" if present
|
||||
tag="${tag#v}"
|
||||
local major minor
|
||||
major="${tag%%.*}"
|
||||
minor="${tag#*.}"
|
||||
# if there's no dot, minor==major. Treat as MAJOR.0.
|
||||
if [[ "$minor" == "$tag" ]]; then
|
||||
minor="0"
|
||||
fi
|
||||
# keep only digits — survive things like "v2.0-rc1" by ignoring the suffix.
|
||||
major="${major//[^0-9]/}"
|
||||
minor="${minor//[^0-9]/}"
|
||||
: "${major:=0}"
|
||||
: "${minor:=0}"
|
||||
printf '%s.%s' "$major" "$minor"
|
||||
}
|
||||
# Compare two release tags by their dotted numeric version, parsed the SAME way as the Python
|
||||
# release selector in get_latest_release() — split on every '.', keep the digits of each
|
||||
# component, then pad to equal length before comparing. Delegated to python3 (already a hard
|
||||
# dependency of this script) so the upgrade GATE and the release SELECTOR can never disagree, and
|
||||
# so multi-component tags (server-v2.3.1), pre-release suffixes (server-v2.3-rc1) and missing
|
||||
# components are all handled correctly.
|
||||
#
|
||||
# This replaces a bash-only parser that collapsed everything after the FIRST dot into the "minor"
|
||||
# field and then stripped the dot — so server-v2.3.1 read as "2.31" and was wrongly judged NEWER
|
||||
# than server-v2.3. The moment any patch-style tag existed, the hourly update check would STOP and
|
||||
# RESTART the live relay (a real multi-second outage for every connected client), and it could even
|
||||
# "upgrade" to an OLDER build (server-v2.9.1 -> "2.91" > server-v2.10 -> "2.10"). 2026-06-12.
|
||||
|
||||
# Returns 0 if $1 > $2 (i.e. left tag is newer), 1 otherwise.
|
||||
# Returns 0 if $1 is a strictly newer version than $2, 1 otherwise (equal counts as NOT newer).
|
||||
tag_newer_than() {
|
||||
local left right lv rv
|
||||
left="$(tag_to_version "$1")"
|
||||
right="$(tag_to_version "$2")"
|
||||
# Numeric compare major then minor.
|
||||
lv="${left%.*}"; rv="${right%.*}"
|
||||
if (( lv > rv )); then return 0; fi
|
||||
if (( lv < rv )); then return 1; fi
|
||||
lv="${left#*.}"; rv="${right#*.}"
|
||||
if (( lv > rv )); then return 0; fi
|
||||
return 1
|
||||
TAG_PREFIX="$TAG_PREFIX" python3 - "$1" "$2" <<'PY'
|
||||
import os, sys
|
||||
|
||||
prefix = os.environ.get("TAG_PREFIX", "server-")
|
||||
|
||||
def parse(tag):
|
||||
if tag.startswith(prefix):
|
||||
tag = tag[len(prefix):]
|
||||
if tag.startswith("v"):
|
||||
tag = tag[1:]
|
||||
out = []
|
||||
for part in tag.split("."):
|
||||
digits = "".join(c for c in part if c.isdigit())
|
||||
out.append(int(digits) if digits else 0)
|
||||
return out
|
||||
|
||||
left = parse(sys.argv[1])
|
||||
right = parse(sys.argv[2])
|
||||
# Pad to equal length so 2.3 and 2.3.0 compare EQUAL — a shorter tuple must not read as older,
|
||||
# or a re-tagged same-version release would trigger a needless stop/restart of the relay.
|
||||
n = max(len(left), len(right))
|
||||
left += [0] * (n - len(left))
|
||||
right += [0] * (n - len(right))
|
||||
sys.exit(0 if left > right else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
# -------- GitHub releases query ---------------------------------------------
|
||||
|
||||
@@ -362,6 +362,12 @@ class Relay:
|
||||
return
|
||||
now = time.monotonic()
|
||||
entry = self.v2_clients.get(client_id)
|
||||
# Capture whether this packet came from the endpoint this client_id is CURRENTLY registered
|
||||
# at, BEFORE the NAT-rebind update below overwrites entry.addr. Used to reject spoofed
|
||||
# control packets: the roster broadcast ships every member's client_id to all members, so on
|
||||
# an internet-facing relay anyone who joins learns the others' ids and could otherwise forge
|
||||
# a BYE to evict them. A genuine BYE always comes from the client's own registered endpoint.
|
||||
from_registered_endpoint = entry is not None and entry.addr == addr
|
||||
if entry is None:
|
||||
# Admit attempt.
|
||||
if len(self.v2_clients) >= self.max_clients:
|
||||
@@ -398,6 +404,14 @@ class Relay:
|
||||
self.v2_roster_dirty = True
|
||||
return
|
||||
if pkt_type == TYPE_LOBBY_BYE:
|
||||
# Only the endpoint a client is registered at may say goodbye for it — otherwise a
|
||||
# forged BYE bearing a known client_id (learned from the roster) could evict any peer.
|
||||
if not from_registered_endpoint:
|
||||
self.log.warning(
|
||||
"event=bye_rejected reason=endpoint_mismatch client_id=%s from=%s",
|
||||
client_id, _fmt_addr(addr),
|
||||
)
|
||||
return
|
||||
self.v2_clients.pop(client_id, None)
|
||||
self.log.info(
|
||||
"event=client_left client_id=%s addr=%s reason=bye",
|
||||
@@ -526,16 +540,28 @@ def main() -> int:
|
||||
ready, _, _ = select.select([sock], [], [], SOCKET_POLL_TIMEOUT_SECONDS)
|
||||
except InterruptedError:
|
||||
continue
|
||||
now = time.monotonic()
|
||||
if ready:
|
||||
try:
|
||||
data, addr = sock.recvfrom(RECV_BUFFER_BYTES)
|
||||
except OSError as e:
|
||||
log.warning("event=recv_failed err=%s", e)
|
||||
# select() itself failed (e.g. a transient resource-pressure error on a long-
|
||||
# running, low-RAM host). Log and pause briefly rather than spin or exit.
|
||||
log.warning("event=select_failed err=%s", e)
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
now = time.monotonic()
|
||||
# Per-iteration work, fully guarded. A relay that must stay up for DAYS — and that is
|
||||
# reachable from the open internet — can never let a single packet or a housekeeping
|
||||
# tick crash the whole process: that would drop EVERY connected client and force a ~5s
|
||||
# systemd restart. Anything unexpected is logged (with a traceback) and we carry on.
|
||||
try:
|
||||
if ready:
|
||||
data, addr = sock.recvfrom(RECV_BUFFER_BYTES)
|
||||
relay.handle_packet(data, addr)
|
||||
relay.tick(now)
|
||||
relay.maybe_log_stats(now)
|
||||
except OSError as e:
|
||||
# recvfrom, or a sendto that escaped its own guard — transient; keep serving.
|
||||
log.warning("event=io_error err=%s", e)
|
||||
except Exception:
|
||||
log.exception("event=loop_error — recovered, continuing")
|
||||
finally:
|
||||
log.info("event=shutdown")
|
||||
sock.close()
|
||||
|
||||
Binary file not shown.
@@ -20,6 +20,21 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.9
|
||||
|
||||
Listening for a long time no longer slowly builds up
|
||||
delay. On the standard (non-ASIO) path the incoming audio
|
||||
buffer used to creep deeper after a network hiccup and
|
||||
never settle back, so a connection that started tight
|
||||
could feel laggy by morning. It now eases itself back to
|
||||
your chosen latency, gently and silently, so a long
|
||||
session stays as tight as it began.
|
||||
|
||||
RemSound's log now also records whether your microphone
|
||||
audio is actually leaving the machine, alongside how loud
|
||||
it is — so a "my mic isn't getting through" report can be
|
||||
pinned down from the log instead of guessed at.
|
||||
|
||||
RemSound v3.8
|
||||
|
||||
You can now start a brand-new profile at any time. A
|
||||
|
||||
@@ -3626,7 +3626,15 @@ public sealed class MainForm : Form
|
||||
if (!connected) return;
|
||||
var all = allSendEndpoints;
|
||||
|
||||
// Endpoints the heartbeat currently considers long-dead, keyed by "ip:port".
|
||||
// Decide which selected peers to actually TRANSMIT audio to. We arm only peers that are
|
||||
// genuinely reachable — anyone whose heartbeat has been Unreachable for longer than
|
||||
// AudioPruneUnreachableAfter is dropped from the send set, so RemSound never streams audio
|
||||
// into a dead address. The heartbeat keeps pinging EVERY selected peer regardless (it runs
|
||||
// off SetTrackedPeers, not this set), so the instant a peer comes back it is re-armed.
|
||||
//
|
||||
// Carve-out: a peer we are actively RECEIVING audio from stays armed even if its heartbeat
|
||||
// reads Unreachable — that covers an asymmetric path where audio flows but the heartbeat
|
||||
// round-trip doesn't, so a working stream is never cut.
|
||||
HashSet<string>? dead = null;
|
||||
if (all.Length > 0 && heartbeatService is { } hb)
|
||||
{
|
||||
@@ -3634,7 +3642,8 @@ public sealed class MainForm : Form
|
||||
{
|
||||
if (ph.State == PeerHealthState.Unreachable
|
||||
&& ph.AgeOfLastPong is { } age
|
||||
&& age > AudioPruneUnreachableAfter)
|
||||
&& age > AudioPruneUnreachableAfter
|
||||
&& !receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, TimeSpan.FromSeconds(3)))
|
||||
{
|
||||
(dead ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase))
|
||||
.Add($"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}");
|
||||
@@ -3646,17 +3655,24 @@ public sealed class MainForm : Form
|
||||
? all
|
||||
: all.Where(ep => !dead.Contains($"{ep.Address}:{ep.Port}")).ToArray();
|
||||
|
||||
// Safety net: never silence EVERY peer through pruning. If the whole set looks dead (a
|
||||
// total network drop), keep sending to all — the wasted SendTo per dead endpoint is
|
||||
// cheaper than masking a global outage or missing the recovery.
|
||||
if (armed.Length == 0) armed = all;
|
||||
// There used to be a "never silence EVERY peer" safety net here that re-armed the whole
|
||||
// set when pruning would leave nobody. Removed 2026-06-12: when NO peer is reachable we
|
||||
// must send NOTHING, not blast audio at every dead address. Otherwise someone connected to
|
||||
// a single peer that goes offline keeps uploading the full stream into the void — exactly
|
||||
// a-singer's issue #8 ("Not connected to any peer ... sending 51.1 kB/s ... sent 2116.5 MB"
|
||||
// after the only receiver was switched off hours earlier). The heartbeat still probes all
|
||||
// peers, so the moment one answers again it is re-armed and audio resumes on its own.
|
||||
|
||||
var signature = string.Join("|", armed.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
|
||||
if (signature == activeAudioReceiverSignature) return;
|
||||
activeAudioReceiverSignature = signature;
|
||||
sender.SetReceivers(armed);
|
||||
var pruned = all.Length - armed.Length;
|
||||
if (pruned > 0)
|
||||
if (armed.Length == 0)
|
||||
{
|
||||
logFile.Event($"audio receivers updated: 0 active — no reachable peer, not sending (heartbeat still probing {all.Length})");
|
||||
}
|
||||
else if (pruned > 0)
|
||||
{
|
||||
logFile.Event($"audio receivers updated: {armed.Length} active, {pruned} pruned (unreachable >{AudioPruneUnreachableAfter.TotalSeconds:0}s); heartbeat still probing all");
|
||||
}
|
||||
@@ -5443,6 +5459,7 @@ public sealed class MainForm : Form
|
||||
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
|
||||
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
|
||||
var capPeak = sender.TakeMaxSenderPreEncodePeak();
|
||||
var sndAudFr = sender.TakeSenderAudioFramesSent();
|
||||
var clippedNow = sender.ClippedSampleCount;
|
||||
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
|
||||
var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer();
|
||||
@@ -5478,7 +5495,7 @@ public sealed class MainForm : Form
|
||||
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
|
||||
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
|
||||
$"filtErr={filteredErrorFrames:0.0}f " +
|
||||
$"capPeak={capPeak:0.000} stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
|
||||
$"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
|
||||
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
|
||||
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
|
||||
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
|
||||
@@ -5539,8 +5556,15 @@ public sealed class MainForm : Form
|
||||
var selfMeter = processSelfMeter.Take();
|
||||
var captureMs = sender.TakeCaptureWorkMs();
|
||||
var sendMs = sender.TakeSendWorkMs();
|
||||
// capPeak = loudest sample reaching the encoder; sndAudFrΔ = audio frames that
|
||||
// actually left the socket this second. Together they split "mic captured but
|
||||
// nothing sent" (drop at encode/encrypt) from "mic captured and sent" — the
|
||||
// measurement the "mic only works in ASIO" report needs, now on the talker side too.
|
||||
var capPeak = sender.TakeMaxSenderPreEncodePeak();
|
||||
var sndAudFr = sender.TakeSenderAudioFramesSent();
|
||||
logFile.Event(
|
||||
$"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " +
|
||||
$"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} " +
|
||||
$"stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} " +
|
||||
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
|
||||
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
|
||||
@@ -6430,6 +6454,10 @@ public sealed class MainForm : Form
|
||||
public const string ProfileSwitch = "profile-switch";
|
||||
public const string ProfileMenuOpen = "profile-menu-open";
|
||||
public const string Update = "update";
|
||||
// Startup cue is special: it plays from Program.cs before any profile loads, so its
|
||||
// enable flag and custom-path live machine-wide in AppConfig, not the per-profile
|
||||
// settings store. This id is still used by the Preferences cue list for display/keying.
|
||||
public const string Startup = "startup";
|
||||
}
|
||||
|
||||
/// <summary>Load one cue sound. Resolution order:
|
||||
|
||||
@@ -77,36 +77,68 @@ internal sealed class PreferencesDialog : Form
|
||||
Padding = new Padding(6, 2, 6, 2),
|
||||
};
|
||||
|
||||
/// <summary>Describes one cue. <see cref="DisplayName"/> ends up in the listbox row;
|
||||
/// <see cref="CueId"/> is the well-known key from <see cref="MainForm.CueId"/>; the
|
||||
/// LoadEnabled / SaveEnabled pair routes the checkbox state to the right
|
||||
/// <see cref="RemSoundSettingsStore"/> getter/setter so we don't need a hard-coded
|
||||
/// switch on index.</summary>
|
||||
/// <summary>Describes one cue row in the list. <see cref="DisplayName"/> is the listbox
|
||||
/// text; <see cref="CueId"/> is the well-known key from <see cref="MainForm.CueId"/>;
|
||||
/// <see cref="DefaultFileName"/> is the bundled WAV in <c>sounds\</c>. The Load/Save
|
||||
/// delegates close over the right backing store so the handlers don't need to know whether
|
||||
/// a row is per-profile (<see cref="RemSoundSettingsStore"/>) or machine-wide
|
||||
/// (<see cref="AppConfig"/> — the Startup cue, which fires before any profile loads).
|
||||
/// <see cref="IsProfileSetting"/> tells the handlers whether toggling the row should flag
|
||||
/// a pending profile save; machine-wide rows persist immediately and never do.</summary>
|
||||
private sealed record CueRowDescriptor(
|
||||
string DisplayName,
|
||||
string CueId,
|
||||
Func<RemSoundSettingsStore, bool> LoadEnabled,
|
||||
Action<RemSoundSettingsStore, bool> SaveEnabled);
|
||||
string DefaultFileName,
|
||||
bool IsProfileSetting,
|
||||
Func<bool> LoadEnabled,
|
||||
Action<bool> SaveEnabled,
|
||||
Func<string?> LoadCustomPath,
|
||||
Action<string?> SaveCustomPath);
|
||||
|
||||
private static readonly CueRowDescriptor[] CueRows =
|
||||
// Built per-dialog (not static) so the per-profile rows can close over the live `settings`
|
||||
// store while the Startup row closes over machine-wide AppConfig. Order = listbox order.
|
||||
private readonly CueRowDescriptor[] cueRows;
|
||||
|
||||
private static CueRowDescriptor[] BuildCueRows(RemSoundSettingsStore settings)
|
||||
{
|
||||
CueRowDescriptor ProfileRow(string name, string id, string file,
|
||||
Func<RemSoundSettingsStore, bool> load, Action<RemSoundSettingsStore, bool> save) =>
|
||||
new(name, id, file, true,
|
||||
() => load(settings), v => save(settings, v),
|
||||
() => settings.LoadCustomCuePath(id), p => settings.SaveCustomCuePath(id, p));
|
||||
|
||||
return
|
||||
[
|
||||
new("Connect sound", MainForm.CueId.Connect,
|
||||
ProfileRow("Connect sound", MainForm.CueId.Connect, "connect.wav",
|
||||
s => s.LoadEnableConnectCue(), (s, v) => s.SaveEnableConnectCue(v)),
|
||||
new("Disconnect sound", MainForm.CueId.Disconnect,
|
||||
ProfileRow("Disconnect sound", MainForm.CueId.Disconnect, "disconnect.wav",
|
||||
s => s.LoadEnableDisconnectCue(), (s, v) => s.SaveEnableDisconnectCue(v)),
|
||||
new("Recording start sound", MainForm.CueId.RecordStart,
|
||||
ProfileRow("Recording start sound", MainForm.CueId.RecordStart, "record start.wav",
|
||||
s => s.LoadEnableRecordStartCue(), (s, v) => s.SaveEnableRecordStartCue(v)),
|
||||
new("Recording stop sound", MainForm.CueId.RecordStop,
|
||||
ProfileRow("Recording stop sound", MainForm.CueId.RecordStop, "record stop.wav",
|
||||
s => s.LoadEnableRecordStopCue(), (s, v) => s.SaveEnableRecordStopCue(v)),
|
||||
new("Profile saved sound", MainForm.CueId.Save,
|
||||
ProfileRow("Profile saved sound", MainForm.CueId.Save, "save.wav",
|
||||
s => s.LoadEnableSaveCue(), (s, v) => s.SaveEnableSaveCue(v)),
|
||||
new("Profile switched sound", MainForm.CueId.ProfileSwitch,
|
||||
ProfileRow("Profile switched sound", MainForm.CueId.ProfileSwitch, "profile.wav",
|
||||
s => s.LoadEnableProfileSwitchCue(), (s, v) => s.SaveEnableProfileSwitchCue(v)),
|
||||
new("Profile menu open sound", MainForm.CueId.ProfileMenuOpen,
|
||||
ProfileRow("Profile menu open sound", MainForm.CueId.ProfileMenuOpen, "profile menu open.wav",
|
||||
s => s.LoadEnableProfileMenuOpenCue(), (s, v) => s.SaveEnableProfileMenuOpenCue(v)),
|
||||
new("Update sound", MainForm.CueId.Update,
|
||||
ProfileRow("Update sound", MainForm.CueId.Update, "update.wav",
|
||||
s => s.LoadEnableUpdateCue(), (s, v) => s.SaveEnableUpdateCue(v)),
|
||||
// Startup cue — machine-wide (AppConfig), because it plays before a profile is loaded.
|
||||
// Persists immediately on change and never flags a profile save (IsProfileSetting=false).
|
||||
new("Startup sound", MainForm.CueId.Startup, "start up.wav", false,
|
||||
() => AppConfig.Load().EnableStartupCue,
|
||||
v => { var c = AppConfig.Load(); c.EnableStartupCue = v; TrySaveConfig(c); },
|
||||
() => AppConfig.Load().StartupCueCustomPath,
|
||||
p => { var c = AppConfig.Load(); c.StartupCueCustomPath = p; TrySaveConfig(c); }),
|
||||
];
|
||||
}
|
||||
|
||||
private static void TrySaveConfig(AppConfig cfg)
|
||||
{
|
||||
try { cfg.Save(); } catch { /* harmless — the choice just won't survive a restart */ }
|
||||
}
|
||||
|
||||
private readonly AccessibleCheckBox acceptRemoteVolumeBox = new()
|
||||
{
|
||||
@@ -227,6 +259,7 @@ internal sealed class PreferencesDialog : Form
|
||||
Action<EventHandler> unsubscribeUpnpStatusChanged)
|
||||
{
|
||||
this.getUpnpSnapshot = getUpnpSnapshot;
|
||||
cueRows = BuildCueRows(settings);
|
||||
|
||||
Text = "Preferences";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
@@ -270,13 +303,13 @@ internal sealed class PreferencesDialog : Form
|
||||
"Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
};
|
||||
|
||||
// Populate the cue listbox — order matches the CueRows array, and the index of a
|
||||
// selected row maps 1:1 to a CueRowDescriptor. Each row's ticked state is loaded
|
||||
// from the active profile's per-cue enable flag via the descriptor.
|
||||
// Populate the cue listbox — order matches the cueRows array, and the index of a
|
||||
// selected row maps 1:1 to a CueRowDescriptor. Each row's ticked state is loaded via
|
||||
// the descriptor (per-profile cues from the settings store; the Startup cue from AppConfig).
|
||||
cueList.Items.Clear();
|
||||
foreach (var c in CueRows)
|
||||
foreach (var c in cueRows)
|
||||
{
|
||||
cueList.Items.Add(c.DisplayName, c.LoadEnabled(settings));
|
||||
cueList.Items.Add(c.DisplayName, c.LoadEnabled());
|
||||
}
|
||||
if (cueList.Items.Count > 0) cueList.SelectedIndex = 0;
|
||||
cueList.ItemCheck += (_, e) =>
|
||||
@@ -284,28 +317,31 @@ internal sealed class PreferencesDialog : Form
|
||||
// ItemCheck fires BEFORE the visual state actually flips; e.NewValue is what
|
||||
// it's about to become, so the persisted value matches what the user just
|
||||
// clicked.
|
||||
if (e.Index < 0 || e.Index >= CueRows.Length) return;
|
||||
if (e.Index < 0 || e.Index >= cueRows.Length) return;
|
||||
var nowEnabled = e.NewValue == CheckState.Checked;
|
||||
CueRows[e.Index].SaveEnabled(settings, nowEnabled);
|
||||
ChangedAnyProfileSetting = true;
|
||||
var row = cueRows[e.Index];
|
||||
row.SaveEnabled(nowEnabled);
|
||||
// Machine-wide rows (the Startup cue) persist immediately and aren't part of the
|
||||
// profile, so they must not arm the "save profile?" prompt on the way out.
|
||||
if (row.IsProfileSetting) ChangedAnyProfileSetting = true;
|
||||
};
|
||||
|
||||
// Selection changes update the two action buttons' labels so they always tell the
|
||||
// user which cue they're about to act on. Refreshed eagerly at construction time
|
||||
// for the initial selection too.
|
||||
cueList.SelectedIndexChanged += (_, _) => RefreshCueActionButtons(settings);
|
||||
RefreshCueActionButtons(settings);
|
||||
cueList.SelectedIndexChanged += (_, _) => RefreshCueActionButtons();
|
||||
RefreshCueActionButtons();
|
||||
|
||||
playSelectedCueButton.Click += (_, _) =>
|
||||
{
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return;
|
||||
OnPlayClicked(CueRows[cueList.SelectedIndex], settings);
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= cueRows.Length) return;
|
||||
OnPlayClicked(cueRows[cueList.SelectedIndex]);
|
||||
};
|
||||
browseSelectedCueButton.Click += (_, _) =>
|
||||
{
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return;
|
||||
OnBrowseClicked(browseSelectedCueButton, CueRows[cueList.SelectedIndex], settings);
|
||||
RefreshCueActionButtons(settings);
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= cueRows.Length) return;
|
||||
OnBrowseClicked(browseSelectedCueButton, cueRows[cueList.SelectedIndex]);
|
||||
RefreshCueActionButtons();
|
||||
};
|
||||
|
||||
// Right-click "Use default sound" context menu lives on the Browse button. It acts
|
||||
@@ -315,26 +351,26 @@ internal sealed class PreferencesDialog : Form
|
||||
var useDefaultItem = new ToolStripMenuItem("Use default sound");
|
||||
useDefaultItem.Click += (_, _) =>
|
||||
{
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return;
|
||||
var cue = CueRows[cueList.SelectedIndex];
|
||||
if (settings.LoadCustomCuePath(cue.CueId) is not null)
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= cueRows.Length) return;
|
||||
var cue = cueRows[cueList.SelectedIndex];
|
||||
if (cue.LoadCustomPath() is not null)
|
||||
{
|
||||
settings.SaveCustomCuePath(cue.CueId, null);
|
||||
ChangedAnyProfileSetting = true;
|
||||
RefreshCueActionButtons(settings);
|
||||
cue.SaveCustomPath(null);
|
||||
if (cue.IsProfileSetting) ChangedAnyProfileSetting = true;
|
||||
RefreshCueActionButtons();
|
||||
}
|
||||
};
|
||||
browseCtx.Opening += (_, _) =>
|
||||
{
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length)
|
||||
if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= cueRows.Length)
|
||||
{
|
||||
useDefaultItem.Enabled = false;
|
||||
useDefaultItem.Text = "Use default sound";
|
||||
}
|
||||
else
|
||||
{
|
||||
var cue = CueRows[cueList.SelectedIndex];
|
||||
useDefaultItem.Enabled = settings.LoadCustomCuePath(cue.CueId) is not null;
|
||||
var cue = cueRows[cueList.SelectedIndex];
|
||||
useDefaultItem.Enabled = cue.LoadCustomPath() is not null;
|
||||
useDefaultItem.Text = $"Use default {cue.DisplayName.ToLowerInvariant()}";
|
||||
useDefaultItem.AccessibleName = useDefaultItem.Text;
|
||||
}
|
||||
@@ -557,10 +593,10 @@ internal sealed class PreferencesDialog : Form
|
||||
/// based on whether a custom path is set). When the selection is empty — e.g. the
|
||||
/// listbox briefly clears during a profile reload — both buttons get a generic label
|
||||
/// and are disabled so a stray click can't act on a stale index.</summary>
|
||||
private void RefreshCueActionButtons(RemSoundSettingsStore settings)
|
||||
private void RefreshCueActionButtons()
|
||||
{
|
||||
var idx = cueList.SelectedIndex;
|
||||
if (idx < 0 || idx >= CueRows.Length)
|
||||
if (idx < 0 || idx >= cueRows.Length)
|
||||
{
|
||||
playSelectedCueButton.Text = "&Play selected sound";
|
||||
playSelectedCueButton.AccessibleName = "Play selected sound";
|
||||
@@ -571,12 +607,12 @@ internal sealed class PreferencesDialog : Form
|
||||
return;
|
||||
}
|
||||
|
||||
var cue = CueRows[idx];
|
||||
var cue = cueRows[idx];
|
||||
playSelectedCueButton.Enabled = true;
|
||||
playSelectedCueButton.Text = $"&Play {cue.DisplayName.ToLowerInvariant()}";
|
||||
playSelectedCueButton.AccessibleName = $"Play {cue.DisplayName.ToLowerInvariant()}";
|
||||
|
||||
var customPath = settings.LoadCustomCuePath(cue.CueId);
|
||||
var customPath = cue.LoadCustomPath();
|
||||
browseSelectedCueButton.Enabled = true;
|
||||
if (string.IsNullOrWhiteSpace(customPath))
|
||||
{
|
||||
@@ -599,30 +635,16 @@ internal sealed class PreferencesDialog : Form
|
||||
/// preview exactly what the cue would play if it fired now. Reads through the settings
|
||||
/// cache so we see whatever the user has changed in this dialog session, including
|
||||
/// custom paths not yet persisted to the profile JSON.</summary>
|
||||
private static string? ResolveCueFilePath(CueRowDescriptor cue, RemSoundSettingsStore settings)
|
||||
private static string? ResolveCueFilePath(CueRowDescriptor cue)
|
||||
{
|
||||
var customPath = settings.LoadCustomCuePath(cue.CueId);
|
||||
var customPath = cue.LoadCustomPath();
|
||||
if (!string.IsNullOrWhiteSpace(customPath) && File.Exists(customPath))
|
||||
{
|
||||
return customPath;
|
||||
}
|
||||
// Default WAV filename is built from the cue ID — same convention as MainForm.
|
||||
// The dictionary kept here makes the mapping explicit and lets us pretty-print
|
||||
// "record start" / "record stop" with the space rather than the cue ID's hyphen.
|
||||
var defaultFileName = cue.CueId switch
|
||||
{
|
||||
MainForm.CueId.Connect => "connect.wav",
|
||||
MainForm.CueId.Disconnect => "disconnect.wav",
|
||||
MainForm.CueId.RecordStart => "record start.wav",
|
||||
MainForm.CueId.RecordStop => "record stop.wav",
|
||||
MainForm.CueId.Save => "save.wav",
|
||||
MainForm.CueId.ProfileSwitch => "profile.wav",
|
||||
MainForm.CueId.ProfileMenuOpen => "profile menu open.wav",
|
||||
MainForm.CueId.Update => "update.wav",
|
||||
_ => null,
|
||||
};
|
||||
if (defaultFileName is null) return null;
|
||||
var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName);
|
||||
// Otherwise the bundled default WAV in sounds\ — the filename the descriptor carries
|
||||
// (preserves spaces like "record start.wav" / "start up.wav" verbatim).
|
||||
var defaultPath = Path.Combine(AppConfig.SoundsDirectory, cue.DefaultFileName);
|
||||
return File.Exists(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
|
||||
@@ -632,9 +654,9 @@ internal sealed class PreferencesDialog : Form
|
||||
/// resolves — e.g. a cue without a default WAV and no custom path — show a small popup
|
||||
/// so the user knows why nothing happened, rather than silently doing nothing and
|
||||
/// leaving them wondering whether the Play button worked.</summary>
|
||||
private void OnPlayClicked(CueRowDescriptor cue, RemSoundSettingsStore settings)
|
||||
private void OnPlayClicked(CueRowDescriptor cue)
|
||||
{
|
||||
var path = ResolveCueFilePath(cue, settings);
|
||||
var path = ResolveCueFilePath(cue);
|
||||
if (path is null)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
@@ -665,10 +687,10 @@ internal sealed class PreferencesDialog : Form
|
||||
/// replaces the default WAV). Writes through the settings cache, since custom cue paths
|
||||
/// are per-profile — clearing here also flips ChangedAnyProfileSetting so the save-prompt
|
||||
/// fires on the way out.</summary>
|
||||
private void OnBrowseClicked(Button btn, CueRowDescriptor cue, RemSoundSettingsStore settings)
|
||||
private void OnBrowseClicked(Button btn, CueRowDescriptor cue)
|
||||
{
|
||||
var soundsFolder = AppConfig.SoundsDirectory;
|
||||
var existing = settings.LoadCustomCuePath(cue.CueId);
|
||||
var existing = cue.LoadCustomPath();
|
||||
var initialDir = !string.IsNullOrWhiteSpace(existing) && File.Exists(existing)
|
||||
? Path.GetDirectoryName(existing) ?? soundsFolder
|
||||
: soundsFolder;
|
||||
@@ -691,17 +713,18 @@ internal sealed class PreferencesDialog : Form
|
||||
// user on a specific shipped-default file across updates.
|
||||
if (pickedFullPath.StartsWith(soundsFolderFullPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
settings.SaveCustomCuePath(cue.CueId, null);
|
||||
cue.SaveCustomPath(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
settings.SaveCustomCuePath(cue.CueId, pickedFullPath);
|
||||
cue.SaveCustomPath(pickedFullPath);
|
||||
}
|
||||
ChangedAnyProfileSetting = true;
|
||||
// The Startup cue is machine-wide, not part of the profile — don't arm the save prompt.
|
||||
if (cue.IsProfileSetting) ChangedAnyProfileSetting = true;
|
||||
// Refresh the visible action-button labels so the "(custom)" tag appears or
|
||||
// disappears right away. Belt-and-braces: the caller also refreshes, but doing it
|
||||
// here makes the function self-consistent.
|
||||
RefreshCueActionButtons(settings);
|
||||
RefreshCueActionButtons();
|
||||
}
|
||||
|
||||
/// <summary>Pull the latest UPnP snapshot and update the inline status label. Always
|
||||
|
||||
@@ -89,6 +89,14 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
// We hold the single-instance lock — THIS copy has taken over (any stuck older copy was
|
||||
// force-closed just above). Play the one-shot startup cue here: after the take-over
|
||||
// decision is settled and before the profile picker/load, so if a copy was already
|
||||
// running you only hear it once the NEW process is in charge. The earlier "switch to the
|
||||
// running copy" / "cancel" paths returned before this point, so a copy that bowed out
|
||||
// never plays it. Fire-and-forget WaveOut, so it sounds even when we launch into the tray.
|
||||
PlayStartupCueIfEnabled();
|
||||
|
||||
// We hold the single-instance lock. Listen for a later copy asking us to surface, and
|
||||
// route that request to whichever main window is open at the time.
|
||||
instance.StartActivationListener();
|
||||
@@ -369,4 +377,25 @@ internal static class Program
|
||||
}
|
||||
catch { /* never let cue consolidation disturb startup */ }
|
||||
}
|
||||
|
||||
/// <summary>Play the startup cue once if the machine-wide setting is on. Resolves the WAV the
|
||||
/// same way the in-app cues do — a user-set custom path (machine-wide, in <see cref="AppConfig"/>)
|
||||
/// if it exists on disk, otherwise the bundled <c>sounds\start up.wav</c>. Read straight from
|
||||
/// AppConfig because no profile (and therefore no settings store) is loaded yet at this point
|
||||
/// in startup. Best-effort: a cue must never stop RemSound from starting.</summary>
|
||||
private static void PlayStartupCueIfEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
if (!cfg.EnableStartupCue) return;
|
||||
var custom = cfg.StartupCueCustomPath;
|
||||
var path = !string.IsNullOrWhiteSpace(custom) && File.Exists(custom)
|
||||
? custom
|
||||
: Path.Combine(AppConfig.SoundsDirectory, "start up.wav");
|
||||
if (!File.Exists(path)) return;
|
||||
new CuePlayer(path).Play();
|
||||
}
|
||||
catch { /* a startup cue must never disturb startup */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>3.8.0</Version>
|
||||
<Version>3.9.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -103,6 +103,13 @@
|
||||
<Link>sounds\profile menu open.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- start up.wav (v3.9): plays once when RemSound starts, right after this copy wins the
|
||||
single-instance takeover and before the profile loads. Machine-wide enable + custom
|
||||
override (AppConfig), since it fires before any profile is chosen. -->
|
||||
<Content Include="..\..\sounds\start up.wav" Condition="Exists('..\..\sounds\start up.wav')">
|
||||
<Link>sounds\start up.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
|
||||
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
|
||||
the published copy whenever the source is newer; manually-edited copies inside
|
||||
|
||||
@@ -68,6 +68,20 @@ public sealed class AppConfig
|
||||
/// already running quietly". Default false.</summary>
|
||||
public bool StartMinimised { get; set; }
|
||||
|
||||
/// <summary>If true (the default), RemSound plays the startup cue once, right after this
|
||||
/// copy wins the single-instance takeover and before the profile loads. Machine-wide (not
|
||||
/// per-<see cref="Profile"/>) because it fires before any profile — and its per-profile
|
||||
/// custom-cue dictionary — has been chosen. Like the connect/disconnect cues it's audible
|
||||
/// feedback, on by default; the user unticks "Startup sound" in Preferences to silence it.
|
||||
/// (The usual "auto-options default off" rule is about data-persistence toggles, not cues.)</summary>
|
||||
public bool EnableStartupCue { get; set; } = true;
|
||||
|
||||
/// <summary>Optional custom WAV path for the startup cue. Null = use the bundled
|
||||
/// <c>sounds\start up.wav</c>. Machine-wide for the same reason as
|
||||
/// <see cref="EnableStartupCue"/>: the cue plays before any profile (and the per-profile
|
||||
/// custom-cue paths) is loaded, so it can't live on <see cref="Profile"/>.</summary>
|
||||
public string? StartupCueCustomPath { get; set; }
|
||||
|
||||
/// <summary>If true, RemSound writes a tab-separated diagnostic log to
|
||||
/// <c><exe>\logs\</c>. Lives here (not in <see cref="Profile"/>) because logging
|
||||
/// is a debugging affordance for the installation, not a user-facing audio preference —
|
||||
|
||||
@@ -336,9 +336,10 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
private const double DriftRatioMin = 0.95;
|
||||
private const double DriftRatioMax = 1.05;
|
||||
// Feedback: steer the buffer toward a 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,
|
||||
// the start-up transient left it (~50 ms and climbing in the field). SessionPlayout was
|
||||
// originally thought to get away without this (it ARMS at target and has a click-trim net),
|
||||
// but the same standing-bloat showed up on its ring over a long WASAPI session, so as of
|
||||
// 2026-06-12 it carries the identical depth term. The correction is tiny (≤0.3 % rate,
|
||||
// spread over seconds): a sub-audible pitch nudge, never a click.
|
||||
//
|
||||
// The cushion is sized to the CARD, not hardcoded. A device can't hold a buffer below one
|
||||
|
||||
@@ -668,12 +668,24 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
foreach (var session in snap)
|
||||
{
|
||||
var matchesOwnLane = session.Route == route;
|
||||
// Orphan = session tagged for the OTHER non-Mixed lane, and that lane has no
|
||||
// active output. (Mixed-tagged sessions never appear in BothIndependent.)
|
||||
// Orphan = session tagged for the OTHER non-Mixed lane whose lane has no active
|
||||
// output — fall it through onto whichever lane IS being read so it stays audible
|
||||
// (2026-05-15).
|
||||
var isOrphanFromOtherLane = !matchesOwnLane
|
||||
&& session.Route != RenderRoute.Mixed
|
||||
&& !otherLaneActive;
|
||||
if (!matchesOwnLane && !isOrphanFromOtherLane) continue;
|
||||
// A Mixed (plain) session belongs to NO lane — it's what a classic WASAPI-only sender
|
||||
// announces. In BothIndependent mode the lane-filtered reads would otherwise SKIP it
|
||||
// (it matches neither lane, and the orphan clause above excludes Mixed), so a plain
|
||||
// stream sent to an ASIO-mode receiver was decoded but never rendered — heard as total
|
||||
// silence while its session ring filled and overflowed. 2026-06-12 fix: render a Mixed
|
||||
// session on exactly ONE active lane — the WASAPI lane by preference, falling back to
|
||||
// the ASIO lane only when no WASAPI output is active — so a plain stream always plays,
|
||||
// in any mode, honouring the "every send/receive combination interoperates" contract.
|
||||
// (A read on route==WasapiLane implies that lane is active, so this never double-plays.)
|
||||
var playMixedHere = session.Route == RenderRoute.Mixed
|
||||
&& (route == RenderRoute.WasapiLane || !wasapiLaneActive);
|
||||
if (!matchesOwnLane && !isOrphanFromOtherLane && !playMixedHere) continue;
|
||||
aggregateBufferedBytes += session.BufferedBytes;
|
||||
var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, routeTargetMs, routeMaxMs, smoothness);
|
||||
if (produced <= 0) continue;
|
||||
|
||||
@@ -199,6 +199,20 @@ internal sealed class SessionPlayout : IDisposable
|
||||
// Low-pass filter time constant for the buffer-level-error display in the diag log.
|
||||
// Doesn't affect any correction logic in Phase 4 — purely informational.
|
||||
private const double DriftFilterTimeConstantSec = 2.0;
|
||||
// === Depth feedback (2026-06-12) — drain a standing buffer back to target ===
|
||||
// The clock-ratio resampler above is a pure rate-MATCHER: it equalises long-run sender-write
|
||||
// and receiver-output rates, which holds the ring at whatever depth it currently sits. On the
|
||||
// WASAPI render path (a Stopwatch-paced producer loop drains the ring, not a hardware clock) a
|
||||
// stall-then-burst can leave the ring standing ~100 ms deep and the rate-matcher then freezes
|
||||
// it there for the whole session — receive latency ratchets up overnight and only ever resets
|
||||
// on a reconnect or a glitchy click-trim. Andre's 2026-06-11 overnight WASAPI log showed
|
||||
// exactly this sawtooth. The per-device WASAPI corrector (MultiOutputPlayout.DriftResampling-
|
||||
// Provider) already carries this depth term; this ports the same term onto the per-sender ring.
|
||||
// depthError > 0 (too deep) biases the rate UP so the resampler pulls more input per output and
|
||||
// drains faster; clamped to ±MaxDepthBias and spread over DepthCorrectionSec → a sub-audible
|
||||
// ≤0.3 % pitch nudge, never a click.
|
||||
private const double DepthCorrectionSec = 15.0;
|
||||
private const double MaxDepthBias = 0.003;
|
||||
// Number of stereo frames each side of a splice point that get blended when a drop or
|
||||
// repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio
|
||||
// DriftDropFramesTotal / DriftRepeatFramesTotal accessors removed 2026-05-23 alongside
|
||||
@@ -541,7 +555,7 @@ internal sealed class SessionPlayout : IDisposable
|
||||
}
|
||||
prevDriftSampleTicks = nowTicks;
|
||||
|
||||
UpdateDriftResamplerRateIfDue(nowTicks);
|
||||
UpdateDriftResamplerRateIfDue(nowTicks, targetLatencyMs);
|
||||
|
||||
// Read through the resampler and apply concealment on full underruns.
|
||||
ReadThroughResampler(output, outFrames);
|
||||
@@ -554,7 +568,7 @@ internal sealed class SessionPlayout : IDisposable
|
||||
/// into the live ratio, and push it to the resampler. Called from the audio thread
|
||||
/// on every ReadFloats. No-op if the window hasn't elapsed yet.
|
||||
/// </summary>
|
||||
private void UpdateDriftResamplerRateIfDue(long nowTicks)
|
||||
private void UpdateDriftResamplerRateIfDue(long nowTicks, int targetLatencyMs)
|
||||
{
|
||||
if (resamplerWindowStartTicks == 0)
|
||||
{
|
||||
@@ -577,8 +591,10 @@ internal sealed class SessionPlayout : IDisposable
|
||||
|
||||
if (bytesOutputInWindow > 0 && bytesWrittenInWindow > 0)
|
||||
{
|
||||
// ratio = bytes_sender_produced / bytes_receiver_consumed over the window.
|
||||
// Above 1.0 = sender clock faster than receiver. Below 1.0 = sender slower.
|
||||
// Feed-forward: ratio = bytes_sender_produced / bytes_receiver_consumed over the
|
||||
// window. Above 1.0 = sender clock faster than receiver; below = slower. This is the
|
||||
// true crystal difference, independent of the resampler rate we apply, so it cleanly
|
||||
// cancels steady-state drift and the depth feedback below doesn't have to fight it.
|
||||
// For Ed's hardware (sender slower than receiver) this should settle ~0.9998.
|
||||
var measuredRatio = (double)bytesWrittenInWindow / bytesOutputInWindow;
|
||||
if (measuredRatio >= DriftRatioMin && measuredRatio <= DriftRatioMax)
|
||||
@@ -594,18 +610,32 @@ internal sealed class SessionPlayout : IDisposable
|
||||
// Subsequent — smooth so a one-window outlier doesn't yank the rate.
|
||||
smoothedRateRatio = (1.0 - DriftRatioSmoothingNew) * smoothedRateRatio + DriftRatioSmoothingNew * measuredRatio;
|
||||
}
|
||||
// Push to the resampler. SetRates(input_rate, output_rate). Input rate
|
||||
// = measured sender rate; output rate = the receiver's nominal MixSampleRate.
|
||||
// The resampler now stretches or compresses incoming audio by the ppm
|
||||
// necessary to keep the playout ring buffer level constant.
|
||||
driftResampler.SetRates(MixSampleRate * smoothedRateRatio, MixSampleRate);
|
||||
Interlocked.Increment(ref resamplerUpdatesTotal);
|
||||
}
|
||||
// If the measured ratio is outside the sanity window (>5 % off), reject it.
|
||||
// That happens transiently during session arming, slider raises, or sender
|
||||
// start-of-stream bursts. Keep the previous ratio rather than yanking.
|
||||
}
|
||||
|
||||
// Feedback: nudge the ring back toward the user's target depth. Without this the
|
||||
// resampler is a pure rate-matcher and a standing (bloated) buffer never drains — see the
|
||||
// DepthCorrectionSec / MaxDepthBias note above. depthError > 0 (too deep) biases the
|
||||
// applied rate UP so the resampler pulls more input per output and drains the buffer
|
||||
// faster; < 0 biases down to refill. Clamped + spread over DepthCorrectionSec so it's a
|
||||
// gentle, inaudible pitch trim, not a per-sample discontinuity. No-op until the
|
||||
// feed-forward has a valid measurement (smoothedRateRatio is meaningless before then).
|
||||
if (resamplerActivelyTracking)
|
||||
{
|
||||
var depthFrames = playout.BufferedBytes / MixBytesPerFrame;
|
||||
var targetFrames = targetLatencyMs * MixSampleRate / 1000;
|
||||
var depthError = depthFrames - targetFrames;
|
||||
var depthCorrection = Math.Clamp(
|
||||
depthError / (DepthCorrectionSec * MixSampleRate),
|
||||
-MaxDepthBias, MaxDepthBias);
|
||||
var appliedRatio = smoothedRateRatio + depthCorrection;
|
||||
driftResampler.SetRates(MixSampleRate * appliedRatio, MixSampleRate);
|
||||
Interlocked.Increment(ref resamplerUpdatesTotal);
|
||||
}
|
||||
|
||||
// Anchor the next window.
|
||||
resamplerWindowStartTicks = nowTicks;
|
||||
resamplerWindowStartBytesWritten = bytesWrittenNow;
|
||||
|
||||
@@ -188,6 +188,15 @@ public sealed class AudioSender : IDisposable
|
||||
return a > b ? a : b;
|
||||
}
|
||||
|
||||
/// <summary>Total audio frames both lanes actually handed to the wire since the last call
|
||||
/// (resets on read). Pairs with <see cref="TakeMaxSenderPreEncodePeak"/> on the diag line:
|
||||
/// capPeak proves real signal reached the encoder; this proves frames left the socket. A
|
||||
/// high capPeak with zero frames sent localises a silence to the encode/encrypt stage — the
|
||||
/// missing measurement behind the "mic only works in ASIO" report. In WasapiOnly mode only
|
||||
/// defaultLane fires, so this number IS the WASAPI mic lane's output.</summary>
|
||||
public long TakeSenderAudioFramesSent() =>
|
||||
defaultLane.TakeAudioFramesSent() + asioLane.TakeAudioFramesSent();
|
||||
|
||||
// Cross-buffer (boundary) and within-buffer (content) split — see AudioStepProbe for the
|
||||
// diagnostic distinction. Used by the per-second diag logger to emit two extra columns so
|
||||
// an offline log inspection can tell a real audio transient apart from a buffer-boundary
|
||||
|
||||
@@ -83,6 +83,17 @@ internal sealed class SenderLane
|
||||
private float preEncodePeak;
|
||||
public float TakeMaxPreEncodePeak() { var p = preEncodePeak; preEncodePeak = 0f; return p; }
|
||||
|
||||
// Count of audio frames this lane actually handed to the wire (encode AND encrypt both
|
||||
// succeeded → SendAudio / SendPcmPart called) since the last drain. Pairs with preEncodePeak:
|
||||
// capPeak proves real signal reached the encoder INPUT, but every post-encode early-return —
|
||||
// the Opus encoder returning len<=0, no password so cryptoGcm is null, or the accumulator
|
||||
// never completing a frame — is INVISIBLE to it. This counts what actually left the machine,
|
||||
// so a log can finally tell "mic captured but nothing sent" (a drop at encode/encrypt) from
|
||||
// "mic captured and sent" (the silence is downstream). Added 2026-06-12 for Andre's
|
||||
// WASAPI-mic-only-works-in-ASIO investigation. Reset on read, like the peak.
|
||||
private long audioFramesSent;
|
||||
public long TakeAudioFramesSent() => Interlocked.Exchange(ref audioFramesSent, 0);
|
||||
|
||||
// Which render route this lane announces in its format packets. The receiver reads the
|
||||
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine
|
||||
// route the lane's audio to the corresponding per-route IWaveProvider surface (lane
|
||||
@@ -297,6 +308,7 @@ internal sealed class SenderLane
|
||||
var maxPart = RemPacket.MaxAudioPayloadBytes;
|
||||
var totalParts = (byte)((ctLen + maxPart - 1) / maxPart);
|
||||
pcmFrameId++;
|
||||
Interlocked.Increment(ref audioFramesSent);
|
||||
for (byte part = 0; part < totalParts; part++)
|
||||
{
|
||||
var offset = part * maxPart;
|
||||
@@ -324,6 +336,7 @@ internal sealed class SenderLane
|
||||
EnsureCrypto();
|
||||
if (cryptoGcm is null) return; // no password yet → never send audio in the clear
|
||||
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, opusBytes, cipherScratch);
|
||||
Interlocked.Increment(ref audioFramesSent);
|
||||
SendAudio(cipherScratch.AsSpan(0, ctLen));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user