Files
RemSound/src/RemSound.App/RemSoundLog.cs
T
EdnunpandClaude Opus 4.8 4dbe9a47a0 Release v3.5: USB-card recovery, per-card adaptive buffer, one user folder, audit fixes
- 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>
2026-06-10 11:34:17 +01:00

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', ' ');
}