From 5e654f62f61bbac414431b16cc7b6563be063204 Mon Sep 17 00:00:00 2001 From: Alexander Shabarshov Date: Thu, 20 Aug 2026 08:53:35 +0100 Subject: [PATCH] Speed change on the fly is almost working. Still hangs sometime. --- ABStemPlayer/ViewModels/MixerViewModel.cs | 2 +- ABStemPlayer/ViewModels/PlaybackViewModel.cs | 4 +- .../ViewModels/WaveformBandViewModel.cs | 4 +- AudioCore/Impl/AudioBuffer.cs | 20 +- AudioCore/Impl/FfmpegProcess.cs | 11 +- AudioCore/Impl/GenericBufferPool.cs | 2 +- AudioCore/Impl/RubberBandTimeStretchEngine.cs | 350 ++++++++++-------- AudioCore/Impl/StemPlaybackEngine.cs | 16 +- AudioCore/Impl/WasapiOutputDevice.cs | 1 + AudioCore/Interfaces/ITimeStretchEngine.cs | 2 +- AudioCore/Models/Tracer.cs | 19 +- 11 files changed, 247 insertions(+), 184 deletions(-) diff --git a/ABStemPlayer/ViewModels/MixerViewModel.cs b/ABStemPlayer/ViewModels/MixerViewModel.cs index 984ab16..5dbce5f 100644 --- a/ABStemPlayer/ViewModels/MixerViewModel.cs +++ b/ABStemPlayer/ViewModels/MixerViewModel.cs @@ -4,7 +4,7 @@ namespace ABStemPlayer.ViewModels; public sealed class MixerViewModel { - public ObservableCollection Stems { get; } = new(); + public ObservableCollection Stems { get; } = []; public MixerViewModel() { diff --git a/ABStemPlayer/ViewModels/PlaybackViewModel.cs b/ABStemPlayer/ViewModels/PlaybackViewModel.cs index 6679b06..61b1f6d 100644 --- a/ABStemPlayer/ViewModels/PlaybackViewModel.cs +++ b/ABStemPlayer/ViewModels/PlaybackViewModel.cs @@ -54,8 +54,8 @@ public sealed partial class PlaybackViewModel : ObservableObject [ObservableProperty] private TimeSpan _totalTime; [ObservableProperty] private MixerViewModel _mixer = null!; - public ObservableCollection Segments { get; } = new(); - public ObservableCollection Bands { get; } = new(); + public ObservableCollection Segments { get; } = []; + public ObservableCollection Bands { get; } = []; private TimeSpan? _loopA; diff --git a/ABStemPlayer/ViewModels/WaveformBandViewModel.cs b/ABStemPlayer/ViewModels/WaveformBandViewModel.cs index 54c6b7c..6865a92 100644 --- a/ABStemPlayer/ViewModels/WaveformBandViewModel.cs +++ b/ABStemPlayer/ViewModels/WaveformBandViewModel.cs @@ -25,11 +25,11 @@ public partial class WaveformBandViewModel : ObservableObject [ObservableProperty] private double _canvasHeight; [ObservableProperty] private Geometry? _waveformGeometry; - public ObservableCollection WaveformBars { get; } = new(); + public ObservableCollection WaveformBars { get; } = []; [ObservableProperty] private double _playbackX; - public ObservableCollection Segments { get; } = new(); + public ObservableCollection Segments { get; } = []; public TimeSpan Duration { get; set; } public string DurationFormatted => Duration.ToString("mm\\:ss"); diff --git a/AudioCore/Impl/AudioBuffer.cs b/AudioCore/Impl/AudioBuffer.cs index 4f34052..331d3b7 100644 --- a/AudioCore/Impl/AudioBuffer.cs +++ b/AudioCore/Impl/AudioBuffer.cs @@ -1,8 +1,11 @@ +using System.Runtime.InteropServices; + namespace AudioCore.Impl; -public class AudioBuffer : IDisposable +public class AudioBuffer : IDisposable where T : unmanaged { private readonly GenericBufferPool _owner; + private static GenericBufferPool _bytePool = new GenericBufferPool(); private bool _disposed; public T[] Samples { get; } @@ -24,4 +27,19 @@ public class AudioBuffer : IDisposable _owner.Return(Samples); _disposed = true; } + + public async Task WriteAsync(Stream stream, CancellationToken token) + { + using var outBuf = _bytePool.Rent(Samples.Length * sizeof(T)); + + var src = Samples.AsSpan(0, Length); + var dst = outBuf.Span; + + MemoryMarshal.Cast(src).CopyTo(dst); + + await stream.WriteAsync(outBuf.Samples, 0, dst.Length, token) + .ConfigureAwait(false); + } + + } diff --git a/AudioCore/Impl/FfmpegProcess.cs b/AudioCore/Impl/FfmpegProcess.cs index cdc3e96..6d06fb4 100644 --- a/AudioCore/Impl/FfmpegProcess.cs +++ b/AudioCore/Impl/FfmpegProcess.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Diagnostics; using System.Runtime.InteropServices; +using static AudioCore.Models.Tracer; namespace AudioCore.Impl; @@ -19,15 +20,15 @@ public sealed class FfmpegProcess : IDisposable public FfmpegProcess(string name, string commandLine, bool redirectOutput = true, bool redirectInput = true) { - _name = name; - _commandLine = commandLine; + _name = name; + _commandLine = commandLine; _redirectOutput = redirectOutput; - _redirectInput = redirectInput; + _redirectInput = redirectInput; } public void StartProcess() { - Debug.WriteLine($"{_name}: Starting ffmpeg process: {_commandLine.Replace("\r", "").Replace("\n", " ").Replace("\t", " ")}"); + Msg($"{_name}: Starting ffmpeg process: {_commandLine.Replace("\r", "").Replace("\n", " ").Replace("\t", " ")}"); var psi = new ProcessStartInfo { @@ -119,7 +120,7 @@ public sealed class FfmpegProcess : IDisposable private void DisposeProcessOnly() { if (Proc != null) - Debug.WriteLine($"{_name}: Disposing ffmpeg process"); + Msg($"{_name}: Disposing ffmpeg process"); try { Stdout?.Dispose(); Stdout = null; } catch { } try { Stdin?.Dispose(); Stdin = null; } catch { } diff --git a/AudioCore/Impl/GenericBufferPool.cs b/AudioCore/Impl/GenericBufferPool.cs index 32ce824..dc656ac 100644 --- a/AudioCore/Impl/GenericBufferPool.cs +++ b/AudioCore/Impl/GenericBufferPool.cs @@ -2,7 +2,7 @@ using System.Buffers; namespace AudioCore.Impl; -public class GenericBufferPool +public class GenericBufferPool where T : unmanaged { private readonly ArrayPool _pool = ArrayPool.Shared; diff --git a/AudioCore/Impl/RubberBandTimeStretchEngine.cs b/AudioCore/Impl/RubberBandTimeStretchEngine.cs index ec4ca8d..df4fd35 100644 --- a/AudioCore/Impl/RubberBandTimeStretchEngine.cs +++ b/AudioCore/Impl/RubberBandTimeStretchEngine.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; +using System.Runtime.InteropServices; using static AudioCore.Models.Tracer; namespace AudioCore.Impl; @@ -8,7 +7,7 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp { private readonly AudioBufferPool _pool; private readonly int _sampleRate; - private long[] _sourcePositions; + private readonly long[] _sourcePositions; private sealed class SourceChunkInfo { @@ -17,15 +16,22 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp } private readonly Queue[] _pendingInput; - private double[] _fractionalInput; + private readonly double[] _fractionalInput; + private readonly List _stemProcesses = []; + private readonly int _stemCount; + private int _activeIo; // Interlocked counter + private float _speed = 1.0f; + private CancellationTokenSource? _cts; + private CancellationToken _token; + private Task? _readerTask; // One RubberBand/ffmpeg process per stem (each is stereo: 2 channels) private sealed class StemProcess : IDisposable { - public readonly int StemIndex; - public FfmpegProcess? Ff; - public Stream? Stdin; - public Stream? Stdout; + public readonly int StemIndex; + public FfmpegProcess? Ff; + public Stream? Stdin; + public Stream? Stdout; public BlockingRingBuffer Ring; public StemProcess(int stemIndex, int sampleRate) @@ -45,13 +51,7 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp } } - private readonly List _stemProcesses = new(); - private readonly int _stemCount; - private float _speed = 1.0f; - private CancellationTokenSource? _cts; - private CancellationToken _token; - private Task? _readerTask; public RubberBandTimeStretchEngine(AudioBufferPool pool, int sampleRate = 44100, int stemCount = 6) { @@ -59,25 +59,27 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp _sampleRate = sampleRate; _stemProcesses.Capacity = stemCount; _stemCount = stemCount; - _sourcePositions = new long[_stemCount]; - _fractionalInput = new double[_stemCount]; - - _pendingInput = Enumerable.Range(0, _stemCount) - .Select(_ => new Queue()) - .ToArray(); + _sourcePositions = new long[_stemCount]; + _fractionalInput = new double[_stemCount]; + _pendingInput = [.. Enumerable.Range(0, _stemCount).Select(_ => new Queue())]; } - public async Task Configure(PlaybackSpeedSettings settings, CancellationToken token) + public async Task Configure(PlaybackSpeedSettings settings, CancellationToken globalToken) { Trace(settings); + + var speedChanged = Math.Abs(_speed - settings.Speed) > 0.01f; + _speed = settings.Speed; - if (_cts != null) - await DisposeProcesses().ConfigureAwait(false); + if (globalToken != CancellationToken.None) + _token = globalToken; - if ( token != CancellationToken.None ) - _token = token; + if (_cts != null && speedChanged) + { + await DisposeProcesses().ConfigureAwait(false); + } } @@ -88,160 +90,179 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IAsyncDisp return Task.CompletedTask; } - public Task SubmitStems(IReadOnlyList stemBlocks, CancellationToken token) + public async Task SubmitStems(IReadOnlyList stemBlocks, CancellationToken token) { - if (stemBlocks.Count != _stemCount) + Interlocked.Increment(ref _activeIo); + try + { + if (stemBlocks.Count != _stemCount) throw new ArgumentException($"Expected {_stemCount} stems, but got {stemBlocks.Count}."); - EnsureStemProcesses(stemBlocks.Count); + EnsureStemProcesses(stemBlocks.Count); + + // No-stretch path: just enqueue into per-stem rings + if (Math.Abs(_speed - 1.0f) < 0.01f) + { + for (int i = 0; i < stemBlocks.Count; i++) + { + if (token.IsCancellationRequested) + return; + + var block = stemBlocks[i]; + + // Track source position for passthrough mode + _pendingInput[i].Enqueue(new SourceChunkInfo + { + SourcePosition = block.Position, + SourceFrames = block.Frames + }); + + var bytes = MemoryMarshal.AsBytes(block.Buffer.Span); + _stemProcesses[i].Ring.Write(bytes, bytes.Length, token); + } + + return; + } + + // Stretch path: one ffmpeg+rubberband per stem + StartProcessesIfNeeded(stemBlocks.Count); - // No-stretch path: just enqueue into per-stem rings - if (Math.Abs(_speed - 1.0f) < 0.01f) - { for (int i = 0; i < stemBlocks.Count; i++) { var block = stemBlocks[i]; - // Track source position for passthrough mode + // Track source position for stretched mode _pendingInput[i].Enqueue(new SourceChunkInfo { SourcePosition = block.Position, SourceFrames = block.Frames }); - var bytes = MemoryMarshal.AsBytes(block.Buffer.Span); - _stemProcesses[i].Ring.Write(bytes, bytes.Length, token); - } + var proc = _stemProcesses[i]; - return Task.CompletedTask; - } - - // Stretch path: one ffmpeg+rubberband per stem - StartProcessesIfNeeded(stemBlocks.Count); - - for (int i = 0; i < stemBlocks.Count; i++) - { - var block = stemBlocks[i]; - - // Track source position for stretched mode - _pendingInput[i].Enqueue(new SourceChunkInfo - { - SourcePosition = block.Position, - SourceFrames = block.Frames - }); - - var proc = _stemProcesses[i]; - var span = block.Buffer.Span; - var bytes = MemoryMarshal.AsBytes(span); - - try - { - if (token.IsCancellationRequested) - return Task.CompletedTask; - - // Only write if ffmpeg is alive - if (proc.Stdin != null && !(proc.Ff?.Proc?.HasExited ?? true)) + try { - proc.Stdin.Write(bytes); + if (token.IsCancellationRequested) + return; - try + // Only write if ffmpeg is alive + if (proc.Stdin != null && !(proc.Ff?.Proc?.HasExited ?? true)) { - proc.Stdin.Flush(); - } - catch (ObjectDisposedException) - { - // ffmpeg exited early + await block.Buffer.WriteAsync(proc.Stdin, token).ConfigureAwait(false); + + try + { + await proc.Stdin.FlushAsync(token).ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // ffmpeg exited early + } } } - } - catch - { - // Ignore write errors (ffmpeg may exit early) + catch (TaskCanceledException) + { + break; + } + catch + { + // Ignore write errors (ffmpeg may exit early) + } } } - - return Task.CompletedTask; + finally + { + Interlocked.Decrement(ref _activeIo); + } } public async Task ReceiveStems(CancellationToken token) { - EnsureStemProcesses(_stemCount); - - int framesPerBlock = (int)(_sampleRate / 2); // 0.5 seconds - int samplesPerBlock = framesPerBlock * 2; // stereo - int bytesPerBlock = samplesPerBlock * sizeof(float); - - var result = new TimeStretchedAudioBlock[_stemCount]; - - for (int i = 0; i < _stemCount; i++) + Interlocked.Increment(ref _activeIo); + try { - var proc = _stemProcesses[i]; + EnsureStemProcesses(_stemCount); - // Wait until *some* data is available - int available = 0; - while (!token.IsCancellationRequested) + int framesPerBlock = (int)(_sampleRate / 2); // 0.5 seconds + int samplesPerBlock = framesPerBlock * 2; // stereo + int bytesPerBlock = samplesPerBlock * sizeof(float); + + var result = new TimeStretchedAudioBlock[_stemCount]; + + for (int i = 0; i < _stemCount; i++) { - available = await proc.Ring.WaitForDataToRead(token).ConfigureAwait(false); - if (available > 0) - break; + var proc = _stemProcesses[i]; - await Task.Delay(1, token).ConfigureAwait(false); - } - - if (token.IsCancellationRequested) - return Array.Empty(); - - // Determine block size (final block may be smaller) - int bytesToRead = Math.Min(bytesPerBlock, available); - int samplesToRead = bytesToRead / sizeof(float); - int framesToRead = samplesToRead / 2; - - // Allocate a temporary byte[] buffer (safe across await) - byte[] temp = new byte[bytesToRead]; - - int totalRead = 0; - - // Read exactly bytesToRead into temp[] - while (totalRead < bytesToRead && !token.IsCancellationRequested) - { - int toRead = bytesToRead - totalRead; - int read = proc.Ring.Read(temp.AsSpan(totalRead, toRead), toRead); - - if (read > 0) + // Wait until *some* data is available + int available = 0; + while (!token.IsCancellationRequested) { - totalRead += read; - continue; + available = await proc.Ring.WaitForDataToRead(token).ConfigureAwait(false); + if (available > 0) + break; + + await Task.Delay(1, token).ConfigureAwait(false); } - await Task.Delay(1, token).ConfigureAwait(false); + if (token.IsCancellationRequested) + return []; + + // Determine block size (final block may be smaller) + int bytesToRead = Math.Min(bytesPerBlock, available); + int samplesToRead = bytesToRead / sizeof(float); + int framesToRead = samplesToRead / 2; + + // Allocate a temporary byte[] buffer (safe across await) + byte[] temp = new byte[bytesToRead]; + + int totalRead = 0; + + // Read exactly bytesToRead into temp[] + while (totalRead < bytesToRead && !token.IsCancellationRequested) + { + int toRead = bytesToRead - totalRead; + int read = proc.Ring.Read(temp.AsSpan(totalRead, toRead), toRead); + + if (read > 0) + { + totalRead += read; + continue; + } + + await Task.Delay(1, token).ConfigureAwait(false); + } + + if (token.IsCancellationRequested) + return []; + + // Now allocate the float buffer + var outBuf = _pool.Rent(samplesToRead); + outBuf.Length = samplesToRead; + + // Copy temp[] → float buffer (safe, no await) + var outBytes = MemoryMarshal.AsBytes(outBuf.Span); + temp.AsSpan().CopyTo(outBytes); + + // + // *** Correct source-position mapping *** + // + long sourcePos = ComputeSourcePosition(i, framesToRead); + + result[i] = new TimeStretchedAudioBlock( + outBuf, + framesToRead, + 2, + _sampleRate, + sourcePos); } - if (token.IsCancellationRequested) - return Array.Empty(); - - // Now allocate the float buffer - var outBuf = _pool.Rent(samplesToRead); - outBuf.Length = samplesToRead; - - // Copy temp[] → float buffer (safe, no await) - var outBytes = MemoryMarshal.AsBytes(outBuf.Span); - temp.AsSpan().CopyTo(outBytes); - - // - // *** Correct source-position mapping *** - // - long sourcePos = ComputeSourcePosition(i, framesToRead); - - result[i] = new TimeStretchedAudioBlock( - outBuf, - framesToRead, - 2, - _sampleRate, - sourcePos); + return result; + } + finally + { + Interlocked.Decrement(ref _activeIo); } - - return result; } private long ComputeSourcePosition(int stemIndex, int outputFrames) @@ -341,37 +362,37 @@ private long ComputeSourcePosition(int stemIndex, int outputFrames) var buf = new byte[4096]; - try + while (!token.IsCancellationRequested) { - while (!token.IsCancellationRequested) + try { - bool anyActive = false; - foreach (var proc in _stemProcesses) { if (proc.Stdout == null) continue; - anyActive = true; - - var read = await proc.Stdout.ReadAsync(buf, 0, buf.Length, token).ConfigureAwait(false); + var read = await proc.Stdout.ReadAsync(buf, token).ConfigureAwait(false); if (read > 0) proc.Ring.Write(buf, read, token); } - - if (!anyActive) - break; } + catch (OperationCanceledException) + { + // expected on dispose; let outer while exit via token + return; + } + catch { } } - catch { } } private async Task DisposeProcesses() { + Trace(); + if (_cts != null) { Msg("Cancelling RubberBand/ffmpeg reader task..."); - try { _cts.Cancel(); } catch { } + try { await _cts.CancelAsync(); } catch { } } if (_readerTask != null) @@ -390,16 +411,29 @@ private long ComputeSourcePosition(int stemIndex, int outputFrames) _stemProcesses.Clear(); } - if (_cts != null) + _cts?.Dispose(); + _cts = null; + + // Reset position tracking + for (int i = 0; i < _stemCount; i++) { - _cts.Dispose(); - _cts = null; + _pendingInput[i].Clear(); + _fractionalInput[i] = 0; + _sourcePositions[i] = 0; } + + _readerTask = null; + + _cts = null; + + // Wait until no active I/O before tearing down + while (Interlocked.CompareExchange(ref _activeIo, 0, 0) != 0) + await Task.Delay(1, CancellationToken.None).ConfigureAwait(false); + } public async ValueTask DisposeAsync() { - Trace(); await DisposeProcesses().ConfigureAwait(false); } } diff --git a/AudioCore/Impl/StemPlaybackEngine.cs b/AudioCore/Impl/StemPlaybackEngine.cs index 4984f1a..0693c90 100644 --- a/AudioCore/Impl/StemPlaybackEngine.cs +++ b/AudioCore/Impl/StemPlaybackEngine.cs @@ -48,6 +48,9 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable private PipelineState? _pipeline; private long _pendingSeekFrames; + private PlaybackSpeedSettings _currentSpeed = new(){ Speed = 1.0f }; + private PlaybackSpeedSettings _prevSpeed = new(){ Speed = 1.0f }; + public StemPlaybackEngine( IStemDecoderFactory stemDecoderFactory, IAudioOutputDevice outputDevice, @@ -168,7 +171,10 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable if (pipelineToDispose is not null) { - try { pipelineToDispose.Cts?.Cancel(); } catch { } + if (pipelineToDispose.Cts != null) + { + try { await pipelineToDispose.Cts.CancelAsync(); } catch { } + } var task = pipelineToDispose.RenderTask; if (task is not null && task.Id != Task.CurrentId) @@ -207,7 +213,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable { Trace(settings); - await _timeStretchEngine.Configure(settings, _pipeline?.Cts?.Token ?? CancellationToken.None).ConfigureAwait(false); + _currentSpeed = settings; } public Task UpdateMixerAsync(MixerSettings settings) @@ -392,6 +398,12 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable var gotFirstBlock = false; while (!token.IsCancellationRequested) { + if ( _prevSpeed.Speed != _currentSpeed.Speed ) + { + await _timeStretchEngine.Configure(_currentSpeed, token).ConfigureAwait(false); + _prevSpeed = _currentSpeed; + } + var stretchedBlocks = await _timeStretchEngine.ReceiveStems(token).ConfigureAwait(false); if (stretchedBlocks == null || stretchedBlocks.Length == 0 || stretchedBlocks[0].Buffer == null) diff --git a/AudioCore/Impl/WasapiOutputDevice.cs b/AudioCore/Impl/WasapiOutputDevice.cs index e9f906b..723337d 100644 --- a/AudioCore/Impl/WasapiOutputDevice.cs +++ b/AudioCore/Impl/WasapiOutputDevice.cs @@ -3,6 +3,7 @@ using System.Runtime.InteropServices; using NAudio.CoreAudioApi; using NAudio.Dmo; using NAudio.Wave; +using static AudioCore.Models.Tracer; namespace AudioCore.Impl; diff --git a/AudioCore/Interfaces/ITimeStretchEngine.cs b/AudioCore/Interfaces/ITimeStretchEngine.cs index 8e7216f..908a538 100644 --- a/AudioCore/Interfaces/ITimeStretchEngine.cs +++ b/AudioCore/Interfaces/ITimeStretchEngine.cs @@ -10,7 +10,7 @@ public sealed class PlaybackSpeedSettings public interface ITimeStretchEngine { - Task Configure(PlaybackSpeedSettings settings, CancellationToken token); + Task Configure(PlaybackSpeedSettings settings, CancellationToken globalToken = default); // Streaming block processing Task IsReadyToAcceptStems(CancellationToken token); diff --git a/AudioCore/Models/Tracer.cs b/AudioCore/Models/Tracer.cs index de86de8..ad5a4c5 100644 --- a/AudioCore/Models/Tracer.cs +++ b/AudioCore/Models/Tracer.cs @@ -8,15 +8,12 @@ namespace AudioCore.Models; public static class Tracer { - public static void Trace([CallerMemberName] string method = null!) => Debug.WriteLine($"TRACE: ____________ {method}() called"); - public static void Trace(T args, [CallerArgumentExpression("args")] string argsExpression = "", [CallerMemberName] string method = "") - { - Debug.WriteLine($"TRACE: ____________ {method}({argsExpression}: {args}) called"); - } - public static void Trace(T1 arg1, T2 arg2, [CallerArgumentExpression("arg1")] string arg1Expression = "", [CallerArgumentExpression("arg2")] string arg2Expression = "", [CallerMemberName] string method = "") - { - Debug.WriteLine($"TRACE: ____________ {method}({arg1Expression}: {arg1}, {arg2Expression}: {arg2}) called"); - } - - public static void Msg(string message, [CallerMemberName] string method = null!) => Debug.WriteLine($"TRACE: ____________ {method}(): {message}"); + public static void Trace([CallerFilePath] string path = null!, [CallerMemberName] string method = null!) + => Debug.WriteLine($"TRACE: ____________ {Path.GetFileNameWithoutExtension(path)}.{method}() called"); + public static void Trace(T args, [CallerArgumentExpression("args")] string argsExpression = "", [CallerFilePath] string path = null!, [CallerMemberName] string method = "") + => Debug.WriteLine($"TRACE: ____________ {Path.GetFileNameWithoutExtension(path)}.{method}({argsExpression}: {args}) called"); + public static void Trace(T1 arg1, T2 arg2, [CallerArgumentExpression("arg1")] string arg1Expression = "", [CallerArgumentExpression("arg2")] string arg2Expression = "", [CallerFilePath] string path = null!, [CallerMemberName] string method = "") + => Debug.WriteLine($"TRACE: ____________ {Path.GetFileNameWithoutExtension(path)}.{method}({arg1Expression}: {arg1}, {arg2Expression}: {arg2}) called"); + public static void Msg(string message, [CallerFilePath] string path = null!, [CallerMemberName] string method = null!) + => Debug.WriteLine($"TRACE: ____________ {Path.GetFileNameWithoutExtension(path)}.{method}(): {message}"); }