Bump to v1.2.0: recording, sound cues, drift compensation, diagnostics
New user-facing features:
* Recording. Dedicated Record menu (Alt+O — moved from Alt+R to
avoid clashing with the Receive audio checkbox), Start/Stop on
Ctrl+R, settings dialog, per-profile source / format / bit-depth /
channel-mode / folder. Three source modes (received only, sent
only, both). Files are crash-resilient — a process crash
mid-recording leaves a playable file containing everything up to
the last header refresh (~5 seconds).
* Four output formats, all functional:
- WAV: 16/24-bit PCM or 32-bit float, custom writer with
periodic RIFF re-patching.
- MP3: LAME 128–320 kbps CBR (via NAudio.Lame).
- OGG-Opus: 96–256 kbps VBR (via Concentus.Oggfile, reusing the
Concentus encoder from the wire path).
- FLAC: 16/24-bit lossless (via CUETools.Codecs.FLAKE — pure
managed, no native DLL).
* Recording start/stop sound cues. record start.wav and
record stop.wav play around the recording transition. Played via
System.Media.SoundPlayer to the default Windows output, separate
from the recording pipeline so a normal recording does not contain
the cue.
* Per-cue Preferences. The old single "Mute connect/disconnect
sounds" checkbox is replaced by a CheckedListBox: Connect /
Disconnect / Recording start / Recording stop. Old profiles with
the legacy MuteConnectionCues=true are honoured on first load via
a migration path in the new Load* helpers.
* Receiver-side drift compensation switched from discrete
single-frame splices to a continuous WdlResampler at a smoothed
rate ratio. SessionPlayout.cs rewrite.
Diagnostics (only active with Enable logs ticked):
* Per-stage discontinuity probes — sender raw capture (per backend,
PushModeWasapi + Asio both wired), sender pre-encode (now per
lane in BothIndependent, fixing a cross-stream artefact), receiver
post-decode, post-ring, post-resampler.
* Wire-level packet sequence tracking on each PCM stream — in-order
/ missed / reordered / duplicated counts in the diag log.
* Clipped-sample delta in the diag log.
* New AudioStepProbe in RemSound.Core with per-channel scan helper.
UI changes:
* Record menu uses Alt+O (Rec&ord). Inside the menu, item mnemonics
unchanged (S / T / O / C).
* Auto-tune interval combo label is mode-aware: "Auto-tune latency
interval" in classic modes, "Auto-tune interval — WASAPI and ASIO"
in BothIndependent. The combo's Enabled state now follows EITHER
lane's auto-tune checkbox (was only the WASAPI one — bug).
Wire format and audio pipeline unchanged from v1.1 — v1.1 and v1.2
peers interoperate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a0fe8070ed
commit
c59e413c1f
@@ -0,0 +1,129 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Per-stage sample-step "discontinuity detector" for diagnosing where in the audio pipeline
|
||||
/// pops are being introduced. A single-sample step magnitude is the absolute difference
|
||||
/// between consecutive samples of the same channel; a typical "click" in real audio shows
|
||||
/// up as a step well above what naturally occurs in music or speech content.
|
||||
///
|
||||
/// Each probe holds the maximum step observed across all calls to <see cref="ScanStereo"/>
|
||||
/// since the last <see cref="TakeMax"/>. The diag log polls TakeMax once per second to
|
||||
/// emit the worst step at that pipeline stage. Comparing the max across stages — sender
|
||||
/// pre-encode, receiver post-decode, receiver post-ring-read, receiver post-resampler,
|
||||
/// final output — reveals which stage introduces the click.
|
||||
///
|
||||
/// Thread model: writes are lock-free CAS-update of a long-encoded float bit pattern (so
|
||||
/// one probe can be hit from multiple threads if needed). Read-and-reset is also atomic.
|
||||
/// Buffers are scanned cheaply — one subtract + abs + compare per sample — and the whole
|
||||
/// scan is gated by <see cref="DiagnosticsGate.Enabled"/> so it pays nothing in production
|
||||
/// when logging is off.
|
||||
/// </summary>
|
||||
public sealed class AudioStepProbe
|
||||
{
|
||||
private long maxStepBits;
|
||||
// Remember the last sample on each channel so the next scan can compute the cross-buffer
|
||||
// step. Without this we'd miss any discontinuity at the buffer boundary (the most
|
||||
// suspicious place — that's where copies, format conversions and resampler hand-offs
|
||||
// happen).
|
||||
private float lastL;
|
||||
private float lastR;
|
||||
private bool hasLast;
|
||||
|
||||
/// <summary>Scan a single-channel slice of an interleaved multi-channel float buffer and
|
||||
/// update the max step magnitude. <paramref name="channelCount"/> is the total number of
|
||||
/// interleaved channels; <paramref name="channelIndex"/> picks which one to scan. Used by
|
||||
/// the ASIO capture probe to look at raw driver-delivered samples on individual channels
|
||||
/// before any mixing or clamping happens. Cheap; safe to call from any thread; no-op when
|
||||
/// diagnostics are disabled.</summary>
|
||||
public void ScanInterleavedChannel(ReadOnlySpan<float> interleavedFloats, int channelCount, int channelIndex)
|
||||
{
|
||||
if (!DiagnosticsGate.Enabled) return;
|
||||
if (interleavedFloats.IsEmpty) return;
|
||||
if (channelCount <= 0 || channelIndex < 0 || channelIndex >= channelCount) return;
|
||||
var max = ReadMax();
|
||||
// Use lastL as the cross-buffer carry for single-channel scans. (We don't need a
|
||||
// separate "lastSingle" — every probe is consumed by exactly one caller at a time, so
|
||||
// reusing the field is fine. The cross-buffer step is what matters for buffer-boundary
|
||||
// glitches.)
|
||||
var prev = lastL;
|
||||
var seedFromPrev = hasLast;
|
||||
var samples = interleavedFloats.Length / channelCount;
|
||||
for (var i = 0; i < samples; i++)
|
||||
{
|
||||
var s = interleavedFloats[i * channelCount + channelIndex];
|
||||
if (i == 0 && !seedFromPrev) prev = s;
|
||||
var step = s - prev;
|
||||
if (step < 0f) step = -step;
|
||||
if (step > max) max = step;
|
||||
prev = s;
|
||||
}
|
||||
lastL = prev;
|
||||
hasLast = true;
|
||||
WriteMaxIfGreater(max);
|
||||
}
|
||||
|
||||
/// <summary>Scan an interleaved stereo float span and update the max step. Cheap; safe
|
||||
/// to call from any thread. No-op if diagnostics are disabled.</summary>
|
||||
public void ScanStereo(ReadOnlySpan<float> stereoFloats)
|
||||
{
|
||||
if (!DiagnosticsGate.Enabled) return;
|
||||
if (stereoFloats.IsEmpty) return;
|
||||
var max = ReadMax();
|
||||
var prevL = lastL;
|
||||
var prevR = lastR;
|
||||
var seedFromPrev = hasLast;
|
||||
// Pair walk. For samples after the first, compare to the previous sample of the
|
||||
// same channel from THIS buffer. For the first pair, compare to the saved
|
||||
// last-sample-from-the-previous-buffer if available.
|
||||
for (var i = 0; i + 1 < stereoFloats.Length; i += 2)
|
||||
{
|
||||
var l = stereoFloats[i];
|
||||
var r = stereoFloats[i + 1];
|
||||
float stepL, stepR;
|
||||
if (i == 0)
|
||||
{
|
||||
if (!seedFromPrev) { prevL = l; prevR = r; }
|
||||
stepL = l - prevL;
|
||||
stepR = r - prevR;
|
||||
}
|
||||
else
|
||||
{
|
||||
stepL = l - stereoFloats[i - 2];
|
||||
stepR = r - stereoFloats[i - 1];
|
||||
}
|
||||
var absL = stepL < 0f ? -stepL : stepL;
|
||||
var absR = stepR < 0f ? -stepR : stepR;
|
||||
if (absL > max) max = absL;
|
||||
if (absR > max) max = absR;
|
||||
}
|
||||
// Save the last sample of this buffer for the next scan.
|
||||
var lastIdx = stereoFloats.Length - 2;
|
||||
lastL = stereoFloats[lastIdx];
|
||||
lastR = stereoFloats[lastIdx + 1];
|
||||
hasLast = true;
|
||||
WriteMaxIfGreater(max);
|
||||
}
|
||||
|
||||
/// <summary>Atomic snapshot of the current max + reset to zero. Returns the value as
|
||||
/// a float in the same units as the input (i.e. 0.5 = a 0.5-magnitude single-sample
|
||||
/// step, which is a 6 dB jump and definitely audible).</summary>
|
||||
public float TakeMax()
|
||||
{
|
||||
var bits = Interlocked.Exchange(ref maxStepBits, 0);
|
||||
return BitConverter.Int32BitsToSingle((int)bits);
|
||||
}
|
||||
|
||||
private float ReadMax() => BitConverter.Int32BitsToSingle((int)Volatile.Read(ref maxStepBits));
|
||||
|
||||
private void WriteMaxIfGreater(float candidate)
|
||||
{
|
||||
var candidateBits = (long)BitConverter.SingleToInt32Bits(candidate);
|
||||
long current;
|
||||
do
|
||||
{
|
||||
current = Volatile.Read(ref maxStepBits);
|
||||
var currentValue = BitConverter.Int32BitsToSingle((int)current);
|
||||
if (candidate <= currentValue) return;
|
||||
} while (Interlocked.CompareExchange(ref maxStepBits, candidateBits, current) != current);
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,23 @@ public sealed class Profile
|
||||
/// profile (not in AppConfig) because the right answer genuinely differs between
|
||||
/// profiles.</summary>
|
||||
public bool PriorityMode { get; set; }
|
||||
/// <summary>True suppresses the connect/disconnect sound cues. Off by default.</summary>
|
||||
/// <summary>Legacy combined "mute connect/disconnect sounds" toggle. True suppresses
|
||||
/// both connect AND disconnect cues. Superseded 2026-05-15 by the four individual
|
||||
/// <c>Enable*Cue</c> flags below — the new flags take precedence when set. This field
|
||||
/// is preserved on the profile for backward compatibility with older builds that don't
|
||||
/// know about the per-cue flags; on first load the per-cue flags inherit from this
|
||||
/// (true → connect+disconnect cues disabled).</summary>
|
||||
public bool MuteConnectionCues { get; set; }
|
||||
|
||||
/// <summary>Per-cue enable flags. Nullable so a missing entry in an older profile JSON
|
||||
/// falls back to the legacy <see cref="MuteConnectionCues"/> migration path; once the
|
||||
/// user touches the new UI we write a concrete <c>true</c>/<c>false</c> and the legacy
|
||||
/// field stops mattering. Defaults to "play the sound" (true) for both cases — the
|
||||
/// audio cues are part of the normal user feedback loop, not opt-in. 2026-05-15.</summary>
|
||||
public bool? EnableConnectCue { get; set; }
|
||||
public bool? EnableDisconnectCue { get; set; }
|
||||
public bool? EnableRecordStartCue { get; set; }
|
||||
public bool? EnableRecordStopCue { get; set; }
|
||||
public int MaxLatencyMs { get; set; } = 80;
|
||||
public int Smoothness { get; set; } = 3;
|
||||
public bool ContinuousAutoTuneEnabled { get; set; }
|
||||
@@ -95,6 +110,15 @@ public sealed class Profile
|
||||
set => ConcealmentArtifactRaw = (int)value;
|
||||
}
|
||||
|
||||
// === Recording ===
|
||||
/// <summary>Recording source / format / attributes. The whole settings object is saved
|
||||
/// per profile so different profiles can record different things (a "long session"
|
||||
/// profile might record everything to MP3, a "monitoring" profile might not record at
|
||||
/// all but keep the dialog defaults sensible). The recording isn't running until the
|
||||
/// user explicitly triggers it via the Record menu; this just holds the configuration
|
||||
/// the recorder picks up when it starts.</summary>
|
||||
public RecordingSettings RecordingSettings { get; set; } = new();
|
||||
|
||||
// === Peers ===
|
||||
public List<string> RememberedPeers { get; set; } = [];
|
||||
/// <summary>Peer addresses (IP or host[:port]) the user had ticked in the connected
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>What audio gets captured by the recorder. Selected in the Recording settings
|
||||
/// dialog and saved per profile. Defaults to <see cref="ReceivedOnly"/> which is the most
|
||||
/// common "I want a copy of what my collaborator just played me" case.</summary>
|
||||
public enum RecordingSource
|
||||
{
|
||||
/// <summary>Record only audio coming from connected peers (everything that would play
|
||||
/// out of the receiver's render path).</summary>
|
||||
ReceivedOnly = 0,
|
||||
/// <summary>Record only audio captured locally (everything this machine is sending to
|
||||
/// peers — your own mics / loopback / ASIO inputs).</summary>
|
||||
SentOnly = 1,
|
||||
/// <summary>Record the sum of received + sent audio, soft-mixed and limiter-protected
|
||||
/// just like the playback path. Useful for capturing a complete two-way exchange in a
|
||||
/// single file.</summary>
|
||||
Both = 2,
|
||||
}
|
||||
|
||||
/// <summary>Output container format the recorder writes to disk. The format dictates the
|
||||
/// shape of <see cref="RecordingSettings.AudioAttributes"/> — uncompressed formats take
|
||||
/// bit-depth, compressed formats take a bitrate, mono/stereo applies to all of them.</summary>
|
||||
public enum RecordingFileFormat
|
||||
{
|
||||
/// <summary>RIFF WAVE, PCM. Lossless, large. Writer: in-process custom WAV writer with
|
||||
/// periodic header re-patching so a mid-session crash leaves a playable file.</summary>
|
||||
Wav = 0,
|
||||
/// <summary>MPEG Layer III. Lossy, small. Writer: NAudio.Lame (LAME library).</summary>
|
||||
Mp3 = 1,
|
||||
/// <summary>Ogg container with Opus codec. Lossy, very small at sane bitrates.
|
||||
/// Reuses the Concentus Opus encoder the wire path uses, wrapped in an Ogg container
|
||||
/// via the Concentus.Oggfile NuGet.</summary>
|
||||
Ogg = 2,
|
||||
/// <summary>FLAC — lossless, typically ~50 % the size of equivalent WAV.
|
||||
/// Writer: CUETools.Codecs.FLAKE — pure-managed FLAC encoder, no native DLL.</summary>
|
||||
Flac = 3,
|
||||
}
|
||||
|
||||
/// <summary>Channel layout for the recording — independent of the format. Stereo preserves
|
||||
/// the L/R as captured; Mono downmixes to (L + R) / 2 with a 3 dB headroom safety knock so
|
||||
/// fully-correlated content doesn't clip.</summary>
|
||||
public enum RecordingChannelMode
|
||||
{
|
||||
Stereo = 0,
|
||||
Mono = 1,
|
||||
}
|
||||
|
||||
/// <summary>All the user-selectable knobs for a recording. Stored on <see cref="Profile"/>.
|
||||
///
|
||||
/// The <see cref="AudioAttributes"/> field is a flat int that means different things per
|
||||
/// format — <see cref="WavBitsPerSample"/> for WAV, <see cref="Mp3BitrateKbps"/> for MP3
|
||||
/// — kept as one slot rather than a separate field per format because (a) only one is
|
||||
/// active at a time and (b) it keeps the profile JSON narrow. The format enum decides
|
||||
/// which interpretation applies.
|
||||
///
|
||||
/// Path policy: <see cref="Folder"/> is stored verbatim. When empty, recordings go to
|
||||
/// the default location (<c><exe>\recordings\<machine>\</c>). When set, it
|
||||
/// IS the folder — no per-machine subfolder is appended. Each recording session creates
|
||||
/// a new file inside the folder, named with a UTC timestamp and the format extension.
|
||||
/// </summary>
|
||||
public sealed class RecordingSettings
|
||||
{
|
||||
public RecordingSource Source { get; set; } = RecordingSource.ReceivedOnly;
|
||||
public RecordingFileFormat FileFormat { get; set; } = RecordingFileFormat.Wav;
|
||||
public RecordingChannelMode ChannelMode { get; set; } = RecordingChannelMode.Stereo;
|
||||
|
||||
/// <summary>WAV bit depth. 16 / 24 / 32. 32 means IEEE float; 16 and 24 are signed PCM.
|
||||
/// Defaults to 24 which matches RemSound's on-wire PCM bit depth — no extra quantisation
|
||||
/// happens on the way to disk. Ignored when <see cref="FileFormat"/> isn't WAV.</summary>
|
||||
public int WavBitsPerSample { get; set; } = 24;
|
||||
|
||||
/// <summary>MP3 CBR bitrate in kbps. Common values: 128, 192, 256, 320. 320 is the
|
||||
/// LAME maximum and the default here — the recording feature is for archival of audio
|
||||
/// you cared enough to send over the network, not for a podcast feed, so the bias is
|
||||
/// toward "make the file slightly bigger for an audibly cleaner result". Ignored when
|
||||
/// <see cref="FileFormat"/> isn't MP3.</summary>
|
||||
public int Mp3BitrateKbps { get; set; } = 320;
|
||||
|
||||
/// <summary>OGG-Opus VBR target bitrate in kbps. Opus' practical sweet spot for music
|
||||
/// is 96–256 kbps; below 96 starts to introduce audible artefacts on dense material,
|
||||
/// above 256 is diminishing returns. Default 192 — same compromise as the MP3 default
|
||||
/// "noticeably-larger file for noticeably-cleaner result". Ignored when
|
||||
/// <see cref="FileFormat"/> isn't Ogg.</summary>
|
||||
public int OggOpusBitrateKbps { get; set; } = 192;
|
||||
|
||||
/// <summary>FLAC bit depth. 16 or 24 — FLAC is integer-PCM only, no 32-bit float, so
|
||||
/// the WAV "32-bit float" option doesn't carry over. 24-bit matches the wire PCM bit
|
||||
/// depth and is the default. Ignored when <see cref="FileFormat"/> isn't FLAC.</summary>
|
||||
public int FlacBitsPerSample { get; set; } = 24;
|
||||
|
||||
/// <summary>FLAC compression level, 0–8. Higher = smaller file, more CPU during encode;
|
||||
/// all levels are losslessly identical on decode. Reference encoder default is 5; we
|
||||
/// match that — the encode is comfortably real-time at level 5 on any modern CPU.
|
||||
/// Ignored when <see cref="FileFormat"/> isn't FLAC.</summary>
|
||||
public int FlacCompressionLevel { get; set; } = 5;
|
||||
|
||||
/// <summary>Absolute path to the folder recordings get written into. Empty / null
|
||||
/// means "use the default <c><exe>\recordings\<machine>\</c>". Persisted
|
||||
/// verbatim — if a saved profile points at a folder that doesn't exist on the loading
|
||||
/// machine, the recorder falls back to the default and notes it in the diagnostics.</summary>
|
||||
public string? Folder { get; set; }
|
||||
|
||||
public RecordingSettings Clone() => new()
|
||||
{
|
||||
Source = Source,
|
||||
FileFormat = FileFormat,
|
||||
ChannelMode = ChannelMode,
|
||||
WavBitsPerSample = WavBitsPerSample,
|
||||
Mp3BitrateKbps = Mp3BitrateKbps,
|
||||
OggOpusBitrateKbps = OggOpusBitrateKbps,
|
||||
FlacBitsPerSample = FlacBitsPerSample,
|
||||
FlacCompressionLevel = FlacCompressionLevel,
|
||||
Folder = Folder,
|
||||
};
|
||||
|
||||
/// <summary>Default folder path used when <see cref="Folder"/> is blank. Computed at
|
||||
/// call time (not cached) so a launch from a different exe directory picks up that
|
||||
/// directory rather than the first-load one. The per-machine subfolder lets two
|
||||
/// machines sharing a Dropbox-backed RemSound install keep their recordings tidily
|
||||
/// separated by sender identity.</summary>
|
||||
public static string DefaultFolder() =>
|
||||
Path.Combine(AppContext.BaseDirectory, "recordings", Environment.MachineName);
|
||||
|
||||
/// <summary>Returns the resolved folder this profile would record into right now —
|
||||
/// either the explicit <see cref="Folder"/> if set, or <see cref="DefaultFolder"/>.
|
||||
/// Does not create the folder on disk.</summary>
|
||||
public string ResolvedFolder() =>
|
||||
string.IsNullOrWhiteSpace(Folder) ? DefaultFolder() : Folder!;
|
||||
}
|
||||
@@ -313,6 +313,79 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
// === Per-cue enable flags (2026-05-15) ===
|
||||
// Each cue sound has its own enable toggle, surfaced in the Preferences dialog as a
|
||||
// CheckedListBox. The legacy MuteConnectionCues above used to gate both connect AND
|
||||
// disconnect — when the new flags are absent (null cache + null profile), the load
|
||||
// helpers fall back to the legacy value as a migration step. Once the user touches
|
||||
// any per-cue toggle, that flag's load returns the explicit value directly and the
|
||||
// legacy field becomes irrelevant for that cue.
|
||||
|
||||
public bool LoadEnableConnectCue()
|
||||
{
|
||||
var s = Load();
|
||||
if (s?.EnableConnectCue is bool v) return v;
|
||||
// Legacy fallback: an older profile with MuteConnectionCues=true was muting both
|
||||
// connect AND disconnect at once. Honour that intent on first load.
|
||||
if (s?.MuteConnectionCues == true) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SaveEnableConnectCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableConnectCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableDisconnectCue()
|
||||
{
|
||||
var s = Load();
|
||||
if (s?.EnableDisconnectCue is bool v) return v;
|
||||
if (s?.MuteConnectionCues == true) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SaveEnableDisconnectCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableDisconnectCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableRecordStartCue() =>
|
||||
Try(() => Load()?.EnableRecordStartCue) ?? true;
|
||||
|
||||
public void SaveEnableRecordStartCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableRecordStartCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableRecordStopCue() =>
|
||||
Try(() => Load()?.EnableRecordStopCue) ?? true;
|
||||
|
||||
public void SaveEnableRecordStopCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableRecordStopCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>The whole recording-settings bag for the current profile. Loaded as a
|
||||
/// CLONE so callers can mutate the returned object without inadvertently writing
|
||||
/// back to the cache. Save flushes the object atomically.</summary>
|
||||
public RecordingSettings LoadRecordingSettings() =>
|
||||
(Try(() => Load()?.RecordingSettings) ?? new RecordingSettings()).Clone();
|
||||
|
||||
public void SaveRecordingSettings(RecordingSettings value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.RecordingSettings = value?.Clone() ?? new RecordingSettings();
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>How aggressively the receiver pulls the playout queue back to the user's
|
||||
/// target latency under network jitter. 1 = stupid aggressive (~10 % playback rate change,
|
||||
/// audible pitch shift on drift, sub-second recovery). 10 = perfectly smooth (gentle
|
||||
@@ -418,6 +491,11 @@ public sealed class RemSoundSettingsStore
|
||||
Smoothness = profile.Smoothness,
|
||||
ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw,
|
||||
MuteConnectionCues = profile.MuteConnectionCues,
|
||||
EnableConnectCue = profile.EnableConnectCue,
|
||||
EnableDisconnectCue = profile.EnableDisconnectCue,
|
||||
EnableRecordStartCue = profile.EnableRecordStartCue,
|
||||
EnableRecordStopCue = profile.EnableRecordStopCue,
|
||||
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,6 +537,14 @@ public sealed class RemSoundSettingsStore
|
||||
if (s.Smoothness is int sm) profile.Smoothness = sm;
|
||||
if (s.ConcealmentArtifact is ConcealmentArtifact ca) profile.ConcealmentArtifactRaw = (int)ca;
|
||||
if (s.MuteConnectionCues is bool mc) profile.MuteConnectionCues = mc;
|
||||
// Per-cue enable flags — copy through verbatim (nullable on both sides, so an
|
||||
// unset flag in the cache stays unset on the profile, letting the legacy
|
||||
// MuteConnectionCues path govern that cue on the next load).
|
||||
profile.EnableConnectCue = s.EnableConnectCue;
|
||||
profile.EnableDisconnectCue = s.EnableDisconnectCue;
|
||||
profile.EnableRecordStartCue = s.EnableRecordStartCue;
|
||||
profile.EnableRecordStopCue = s.EnableRecordStopCue;
|
||||
if (s.RecordingSettings is RecordingSettings rs) profile.RecordingSettings = rs.Clone();
|
||||
}
|
||||
|
||||
private static HotkeySetting HotkeySettingFromRecord(HotkeyRecord r) => new()
|
||||
@@ -513,6 +599,13 @@ public sealed class RemSoundSettingsStore
|
||||
public int? Smoothness { get; set; }
|
||||
public ConcealmentArtifact? ConcealmentArtifact { get; set; }
|
||||
public bool? MuteConnectionCues { get; set; }
|
||||
// Per-cue enable flags (2026-05-15). Nullable so an absent value in the loaded
|
||||
// profile falls back to the legacy MuteConnectionCues migration path.
|
||||
public bool? EnableConnectCue { get; set; }
|
||||
public bool? EnableDisconnectCue { get; set; }
|
||||
public bool? EnableRecordStartCue { get; set; }
|
||||
public bool? EnableRecordStopCue { get; set; }
|
||||
public RecordingSettings? RecordingSettings { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HotkeySetting
|
||||
|
||||
Reference in New Issue
Block a user