- Recover an unplugged/replugged output sound card automatically (issue #5): detect the dead WASAPI device and remember the receive-output selection so it re-ticks and re-opens when the card returns. - Adaptive per-card WASAPI buffer target sized to each card's pull chunk, held stable so it never flits about under CPU/network load. - Consolidate all per-user data (config, profiles, logs, sounds) into one "user settings and logs" folder; migrate every older layout; exclude it from the updater so custom cue sounds now survive updates. - Mic-privacy detector: warn once when a Windows-blocked mic is switched on, or a profile loads with one already on. - All warning/notice dialogs now come to the foreground even when minimised. - Apply volume + mute on profile load (were saved but not restored). - Crash-safe (atomic) profile/config saves. - Fix two resource leaks (push-mode capture MMDevice; UPnP DeviceFound handler). - Remove dead code (baseline-diff machinery, dead ASIO probes, no-op stubs). - Docs: readme.html, MANUAL.md, About-box changelog and release notes for v3.5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
7.5 KiB
C#
185 lines
7.5 KiB
C#
namespace RemSound.App;
|
|
|
|
/// <summary>
|
|
/// Lightweight tab-separated log file written next to the executable in <c>logs\</c>.
|
|
/// Two row kinds:
|
|
/// SNAP — periodic snapshot of runtime counters (one per second)
|
|
/// EVT — one-off events (connect, disconnect, codec change, errors)
|
|
/// Format:
|
|
/// SNAP\t{Timestamp}\t{Machine}\t{Connected}\t{SendRunning}\t{ReceiveRunning}\t{Codec}\t
|
|
/// {MaxLatencyMs}\t{TargetLatencyMs}\t{BufferMs}\t{SenderPackets}\t{SenderKB}\t
|
|
/// {SenderDevice}\t{ReceiverPackets}\t{ReceiverKB}\t{Underruns}\t{Drops}\t{ReceiveDevice}
|
|
/// EVT\t{Timestamp}\t{Message}
|
|
///
|
|
/// The log file is created lazily on the first write that arrives while <see cref="Enabled"/>
|
|
/// is true. When the user has logs turned off in Preferences (the default), no file is created
|
|
/// at all — the App can construct the log object freely without spawning empty files in
|
|
/// <c>logs\</c>. Flipping <see cref="Enabled"/> back on mid-session is also safe: the next
|
|
/// write creates the file with its header and the session continues normally.
|
|
/// </summary>
|
|
internal sealed class RemSoundLog : IDisposable
|
|
{
|
|
// MaxLatencyMsAsio / TargetLatencyMsAsio columns hold the per-lane numbers in
|
|
// BothIndependent mode. In WasapiOnly they are 0 and the legacy MaxLatencyMs /
|
|
// TargetLatencyMs columns continue to mean "the receiver's only latency".
|
|
private const string SnapHeader =
|
|
"Kind\tTimestamp\tMachine\tConnected\tSendRunning\tReceiveRunning\tCodec\t" +
|
|
"MaxLatencyMs\tTargetLatencyMs\tBufferMs\tSenderPackets\tSenderKB\t" +
|
|
"SenderDevice\tReceiverPackets\tReceiverKB\tUnderruns\tDrops\tReceiveDevice\tHeartbeat\t" +
|
|
"OpusFecRecoveries\tOpusUnrecoveredGaps\tMaxLatencyMsAsio\tTargetLatencyMsAsio";
|
|
|
|
private StreamWriter? writer;
|
|
private bool fileCreationFailed;
|
|
/// <summary>Serialises all writes. StreamWriter is documented as non-thread-safe and
|
|
/// concurrent WriteLine calls from the mix loop, heartbeat thread, network listener,
|
|
/// UI thread and ASIO callback can interleave bytes into a single line in the output
|
|
/// file. Worse, an interleaved write can leave the StreamWriter's internal char buffer
|
|
/// in a state that throws on the next Flush — that exception escapes the inner try/catch
|
|
/// and can bring the process down. A single gate around every WriteLine, every Dispose
|
|
/// and every lazy-init step serialises writes cleanly; the cost is microseconds and
|
|
/// worth the diagnostic integrity.</summary>
|
|
private readonly object writeGate = new();
|
|
|
|
/// <summary>Path of the log file once it has been created. Null until the first write
|
|
/// arrives with <see cref="Enabled"/> true (or null forever if logging is never enabled
|
|
/// or file creation fails).</summary>
|
|
public string? Path { get; private set; }
|
|
|
|
/// <summary>Master gate for all writes. When false, both <see cref="Event"/> and
|
|
/// <see cref="Snapshot"/> short-circuit before touching the file system — no creation,
|
|
/// no headers, no data. Defaults to false so the App can construct the log object
|
|
/// before reading the user's preference; the App pushes the real value in after.</summary>
|
|
public bool Enabled { get; set; }
|
|
|
|
public RemSoundLog()
|
|
{
|
|
// No file work in the constructor. EnsureFileOpenLocked does it lazily on first
|
|
// write, only when Enabled has been confirmed true.
|
|
}
|
|
|
|
/// <summary>Open the underlying file if it hasn't been opened yet and write the schema
|
|
/// header + a "log started" event line. Must be called while holding
|
|
/// <see cref="writeGate"/>. Returns true on success or if the file is already open;
|
|
/// false if creation has failed (either now or earlier) — caller should give up on the
|
|
/// current write.</summary>
|
|
private bool EnsureFileOpenLocked()
|
|
{
|
|
if (writer is not null) return true;
|
|
if (fileCreationFailed) return false;
|
|
try
|
|
{
|
|
var dir = RemSound.Core.AppConfig.LogsDirectory;
|
|
Directory.CreateDirectory(dir);
|
|
var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
|
|
Path = System.IO.Path.Combine(dir, name);
|
|
writer = new StreamWriter(new FileStream(Path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite))
|
|
{
|
|
AutoFlush = true,
|
|
};
|
|
writer.WriteLine(SnapHeader);
|
|
writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog started");
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
// Logging is best-effort. Locked dir, permissions, disk full — any of these
|
|
// shouldn't kill the app. Park the file as failed so we don't keep retrying
|
|
// creation on every subsequent write attempt.
|
|
writer = null;
|
|
Path = null;
|
|
fileCreationFailed = true;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void Snapshot(
|
|
bool connected,
|
|
bool sendRunning,
|
|
bool receiveRunning,
|
|
string codec,
|
|
int maxLatencyMs,
|
|
int targetLatencyMs,
|
|
int bufferMs,
|
|
long senderPackets,
|
|
long senderBytes,
|
|
string senderDevice,
|
|
long receiverPackets,
|
|
long receiverBytes,
|
|
long underruns,
|
|
long drops,
|
|
string receiveDevice,
|
|
string heartbeat,
|
|
long opusFecRecoveries,
|
|
long opusUnrecoveredGaps,
|
|
int maxLatencyMsAsio = 0,
|
|
int targetLatencyMsAsio = 0)
|
|
{
|
|
if (!Enabled) return;
|
|
lock (writeGate)
|
|
{
|
|
if (!EnsureFileOpenLocked()) return;
|
|
try
|
|
{
|
|
writer!.WriteLine(string.Join('\t',
|
|
"SNAP",
|
|
DateTime.Now.ToString("o"),
|
|
Environment.MachineName,
|
|
connected,
|
|
sendRunning,
|
|
receiveRunning,
|
|
codec,
|
|
maxLatencyMs,
|
|
targetLatencyMs,
|
|
bufferMs,
|
|
senderPackets,
|
|
senderBytes / 1024,
|
|
Sanitize(senderDevice),
|
|
receiverPackets,
|
|
receiverBytes / 1024,
|
|
underruns,
|
|
drops,
|
|
Sanitize(receiveDevice),
|
|
Sanitize(heartbeat),
|
|
opusFecRecoveries,
|
|
opusUnrecoveredGaps,
|
|
maxLatencyMsAsio,
|
|
targetLatencyMsAsio));
|
|
}
|
|
catch { /* swallow — log is best-effort */ }
|
|
}
|
|
}
|
|
|
|
public void Event(string message)
|
|
{
|
|
if (!Enabled) return;
|
|
lock (writeGate)
|
|
{
|
|
if (!EnsureFileOpenLocked()) return;
|
|
try
|
|
{
|
|
writer!.WriteLine($"EVT\t{DateTime.Now:o}\t{message.Replace('\t', ' ').Replace('\n', ' ')}");
|
|
}
|
|
catch { /* swallow */ }
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (writeGate)
|
|
{
|
|
try
|
|
{
|
|
if (writer is not null && Enabled)
|
|
{
|
|
// Inline the "log stopped" write so we don't re-acquire the gate.
|
|
writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog stopped");
|
|
}
|
|
writer?.Dispose();
|
|
}
|
|
catch { /* swallow */ }
|
|
}
|
|
}
|
|
|
|
private static string Sanitize(string value) => value.Replace('\t', ' ').Replace('\n', ' ');
|
|
}
|