mirror of
https://github.com/unclshura/ABStemPlayer.git
synced 2026-08-07 00:43:38 +00:00
Changes for making playback working (still doesn't).
This commit is contained in:
parent
8216792170
commit
5010af3a62
@ -22,7 +22,11 @@ public class BlockingRingBuffer
|
|||||||
|
|
||||||
while (written < srcLen)
|
while (written < srcLen)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
if ( ct.IsCancellationRequested )
|
||||||
|
{
|
||||||
|
Debug.WriteLine("BlockingRingBuffer: No room in the buffer to write. Timeout.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int remaining = srcLen - written;
|
int remaining = srcLen - written;
|
||||||
|
|
||||||
@ -33,12 +37,11 @@ public class BlockingRingBuffer
|
|||||||
? _ringWrite - _ringRead
|
? _ringWrite - _ringRead
|
||||||
: _ring.Length - _ringRead + _ringWrite;
|
: _ring.Length - _ringRead + _ringWrite;
|
||||||
|
|
||||||
free = _ring.Length - used - 1; // leave 1 byte to distinguish full/empty
|
free = _ring.Length - used - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (free <= 0)
|
if (free <= 0)
|
||||||
{
|
{
|
||||||
// No room → block until space becomes available
|
|
||||||
Thread.Sleep(1);
|
Thread.Sleep(1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -49,7 +52,6 @@ public class BlockingRingBuffer
|
|||||||
{
|
{
|
||||||
int first = Math.Min(toWrite, _ring.Length - _ringWrite);
|
int first = Math.Min(toWrite, _ring.Length - _ringWrite);
|
||||||
|
|
||||||
// Write first segment
|
|
||||||
src.Slice(written, first)
|
src.Slice(written, first)
|
||||||
.CopyTo(new Span<byte>(_ring, _ringWrite, first));
|
.CopyTo(new Span<byte>(_ring, _ringWrite, first));
|
||||||
|
|
||||||
@ -58,7 +60,6 @@ public class BlockingRingBuffer
|
|||||||
int leftover = toWrite - first;
|
int leftover = toWrite - first;
|
||||||
if (leftover > 0)
|
if (leftover > 0)
|
||||||
{
|
{
|
||||||
// Wrap-around segment
|
|
||||||
src.Slice(written + first, leftover)
|
src.Slice(written + first, leftover)
|
||||||
.CopyTo(new Span<byte>(_ring, _ringWrite, leftover));
|
.CopyTo(new Span<byte>(_ring, _ringWrite, leftover));
|
||||||
|
|
||||||
@ -72,8 +73,14 @@ public class BlockingRingBuffer
|
|||||||
|
|
||||||
public int WaitForOutput(CancellationToken token)
|
public int WaitForOutput(CancellationToken token)
|
||||||
{
|
{
|
||||||
while (!token.IsCancellationRequested)
|
while (true)
|
||||||
{
|
{
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Debug.WriteLine("BlockingRingBuffer: No data in the buffer to read. Timeout.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
lock (_ringLock)
|
lock (_ringLock)
|
||||||
{
|
{
|
||||||
var available = (_ringWrite >= _ringRead)
|
var available = (_ringWrite >= _ringRead)
|
||||||
@ -86,9 +93,6 @@ public class BlockingRingBuffer
|
|||||||
|
|
||||||
Thread.Sleep(2);
|
Thread.Sleep(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.WriteLine("BlockingRingBuffer: Timeout waiting for output");
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int DrainRing(Span<byte> dest, int maxBytes)
|
public int DrainRing(Span<byte> dest, int maxBytes)
|
||||||
|
|||||||
@ -27,7 +27,7 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
|||||||
_channels = channels;
|
_channels = channels;
|
||||||
|
|
||||||
var bytesPerSecond = sampleRate * channels * sizeof(float);
|
var bytesPerSecond = sampleRate * channels * sizeof(float);
|
||||||
_ring = new BlockingRingBuffer(1 * bytesPerSecond);
|
_ring = new BlockingRingBuffer(10 * bytesPerSecond);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Configure(PlaybackSpeedSettings settings)
|
public void Configure(PlaybackSpeedSettings settings)
|
||||||
@ -37,25 +37,17 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
|||||||
|
|
||||||
_speed = settings.Speed;
|
_speed = settings.Speed;
|
||||||
|
|
||||||
if (Math.Abs(_speed - 1.0f) < 0.01f)
|
|
||||||
{
|
|
||||||
DisposeProcess();
|
DisposeProcess();
|
||||||
_ring.ResetRing();
|
_ring.ResetRing();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
RestartProcess();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task Submit(MixedAudioBlock input)
|
public Task Submit(MixedAudioBlock input, CancellationToken token)
|
||||||
{
|
{
|
||||||
// No-stretch path: enqueue block and signal semaphore
|
// No-stretch path: enqueue block and signal semaphore
|
||||||
if (Math.Abs(_speed - 1.0f) < 0.01f)
|
if (Math.Abs(_speed - 1.0f) < 0.01f)
|
||||||
{
|
{
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
||||||
var b = MemoryMarshal.AsBytes(input.Buffer.Span);
|
var b = MemoryMarshal.AsBytes(input.Buffer.Span);
|
||||||
_ring.WriteToOutput(b, b.Length, cts.Token);
|
_ring.WriteToOutput(b, b.Length, token);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@ -72,21 +64,19 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TimeStretchedAudioBlock> Receive()
|
public async Task<TimeStretchedAudioBlock> Receive(CancellationToken token)
|
||||||
{
|
{
|
||||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
int available = 0;
|
int available = 0;
|
||||||
while (!cts.IsCancellationRequested)
|
while (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
available = _ring.WaitForOutput(cts.Token);
|
available = _ring.WaitForOutput(token);
|
||||||
if (available > 0)
|
if (available > 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
await Task.Delay(2).ConfigureAwait(false);
|
await Task.Delay(2).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cts.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
var maxFloats = available / sizeof(float);
|
var maxFloats = available / sizeof(float);
|
||||||
@ -132,13 +122,6 @@ public sealed class RubberBandTimeStretchEngine : ITimeStretchEngine, IDisposabl
|
|||||||
_readerThread.Start();
|
_readerThread.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RestartProcess()
|
|
||||||
{
|
|
||||||
DisposeProcess();
|
|
||||||
_ring.ResetRing();
|
|
||||||
StartProcess();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReaderLoop()
|
private void ReaderLoop()
|
||||||
{
|
{
|
||||||
var buf = new byte[4096];
|
var buf = new byte[4096];
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
namespace AudioCore.Impl;
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace AudioCore.Impl;
|
||||||
|
|
||||||
public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
||||||
{
|
{
|
||||||
@ -238,7 +240,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
var decodeTask = DecodeLoopAsync(pipeline, ct);
|
var decodeTask = DecodeLoopAsync(pipeline, ct);
|
||||||
var stretchTask = StretchLoopAsync(pipeline, ct);
|
var stretchTask = StretchLoopAsync(pipeline, ct);
|
||||||
|
|
||||||
await Task.WhenAny(decodeTask, stretchTask);
|
await Task.WhenAny(decodeTask, stretchTask).ConfigureAwait(false);
|
||||||
|
|
||||||
// When either loop ends, stop output
|
// When either loop ends, stop output
|
||||||
if (pipeline.OutputStarted)
|
if (pipeline.OutputStarted)
|
||||||
@ -250,6 +252,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
|
|
||||||
private async Task DecodeLoopAsync(PipelineState pipeline, CancellationToken ct)
|
private async Task DecodeLoopAsync(PipelineState pipeline, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
@ -286,7 +290,8 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
if (!decoder.TryDecodeNextBlock(out var block))
|
if (!decoder.TryDecodeNextBlock(out var block))
|
||||||
{
|
{
|
||||||
eof = true;
|
eof = true;
|
||||||
foreach (var b in _stemBlocks) b.Dispose();
|
foreach (var b in _stemBlocks)
|
||||||
|
b.Dispose();
|
||||||
_stemBlocks.Clear();
|
_stemBlocks.Clear();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -307,7 +312,7 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
b.Dispose();
|
b.Dispose();
|
||||||
_stemBlocks.Clear();
|
_stemBlocks.Clear();
|
||||||
|
|
||||||
await _timeStretchEngine.Submit(mixed);
|
await _timeStretchEngine.Submit(mixed, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var progress = TimeSpan.FromSeconds(
|
var progress = TimeSpan.FromSeconds(
|
||||||
(double)_currentFramePosition / _outputDevice.SampleRate);
|
(double)_currentFramePosition / _outputDevice.SampleRate);
|
||||||
@ -336,11 +341,12 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
|
|
||||||
private async Task StretchLoopAsync(PipelineState pipeline, CancellationToken ct)
|
private async Task StretchLoopAsync(PipelineState pipeline, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await Task.Yield();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var stretched = await _timeStretchEngine.Receive();
|
var stretched = await _timeStretchEngine.Receive(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
if (stretched.Buffer == null)
|
if (stretched.Buffer == null)
|
||||||
{
|
{
|
||||||
@ -357,7 +363,10 @@ public sealed class StemPlaybackEngine : IStemPlaybackEngine, IDisposable
|
|||||||
_outputDevice.Write(stretched.Buffer.Span);
|
_outputDevice.Write(stretched.Buffer.Span);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"StemPlaybackEngine: Error in StretchLoopAsync: {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -101,17 +101,38 @@ public sealed class WasapiOutputDevice : IAudioOutputDevice, IDisposable
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Lock _lock = new();
|
||||||
|
|
||||||
private void Send(byte[] bytes)
|
private void Send(byte[] bytes)
|
||||||
{
|
{
|
||||||
// Wait until buffer has enough free space
|
if (_out.PlaybackState != PlaybackState.Playing)
|
||||||
while (_buffer.BufferedBytes + bytes.Length > _buffer.BufferLength)
|
return;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
|
||||||
|
int offset = 0;
|
||||||
|
|
||||||
|
while (offset < bytes.Length)
|
||||||
|
{
|
||||||
|
if (_out.PlaybackState != PlaybackState.Playing)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int free = _buffer.BufferLength - _buffer.BufferedBytes;
|
||||||
|
if (free <= 0)
|
||||||
{
|
{
|
||||||
// Sleep a tiny amount to let WASAPI consume data
|
|
||||||
Thread.Sleep(2);
|
Thread.Sleep(2);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
_buffer.AddSamples(bytes, 0, bytes.Length);
|
int toWrite = Math.Min(free, bytes.Length - offset);
|
||||||
|
|
||||||
|
_buffer.AddSamples(bytes, offset, toWrite);
|
||||||
|
offset += toWrite;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,6 +11,6 @@ public interface ITimeStretchEngine
|
|||||||
void Configure(PlaybackSpeedSettings settings);
|
void Configure(PlaybackSpeedSettings settings);
|
||||||
|
|
||||||
// Streaming block processing
|
// Streaming block processing
|
||||||
Task Submit(MixedAudioBlock input);
|
Task Submit(MixedAudioBlock input, CancellationToken token);
|
||||||
Task<TimeStretchedAudioBlock> Receive();
|
Task<TimeStretchedAudioBlock> Receive(CancellationToken token);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,13 +110,13 @@ public sealed class StemPlaybackEngine_Tests
|
|||||||
// no-op for tests
|
// no-op for tests
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Submit(MixedAudioBlock input)
|
public Task Submit(MixedAudioBlock input, CancellationToken token)
|
||||||
{
|
{
|
||||||
_lastInput = input;
|
_lastInput = input;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<TimeStretchedAudioBlock> Receive()
|
public Task<TimeStretchedAudioBlock> Receive(CancellationToken token)
|
||||||
{
|
{
|
||||||
if (_lastInput.Buffer == null)
|
if (_lastInput.Buffer == null)
|
||||||
return Task.FromResult(default(TimeStretchedAudioBlock));
|
return Task.FromResult(default(TimeStretchedAudioBlock));
|
||||||
|
|||||||
@ -34,8 +34,8 @@ public sealed class TimeStretchEngine_Tests
|
|||||||
|
|
||||||
var input = MakeBlock(5000);
|
var input = MakeBlock(5000);
|
||||||
|
|
||||||
await engine.Submit(input);
|
await engine.Submit(input, CancellationToken.None);
|
||||||
var output = await engine.Receive();
|
var output = await engine.Receive(CancellationToken.None);
|
||||||
|
|
||||||
Assert.IsGreaterThan(0, output.Frames);
|
Assert.IsGreaterThan(0, output.Frames);
|
||||||
Assert.AreEqual(2, output.Channels);
|
Assert.AreEqual(2, output.Channels);
|
||||||
@ -59,12 +59,12 @@ public sealed class TimeStretchEngine_Tests
|
|||||||
var input = MakeBlock(1000);
|
var input = MakeBlock(1000);
|
||||||
|
|
||||||
for (var i = 0; i < 25; i++)
|
for (var i = 0; i < 25; i++)
|
||||||
await engine.Submit(input);
|
await engine.Submit(input, CancellationToken.None);
|
||||||
|
|
||||||
var normalFrames = 0;
|
var normalFrames = 0;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
using var data = await engine.Receive();
|
using var data = await engine.Receive(CancellationToken.None);
|
||||||
normalFrames += data.Frames;
|
normalFrames += data.Frames;
|
||||||
if (data.Buffer == null)
|
if (data.Buffer == null)
|
||||||
break;
|
break;
|
||||||
@ -75,12 +75,12 @@ public sealed class TimeStretchEngine_Tests
|
|||||||
|
|
||||||
|
|
||||||
for (var i = 0; i < 25; i++)
|
for (var i = 0; i < 25; i++)
|
||||||
await engine.Submit(input);
|
await engine.Submit(input, CancellationToken.None);
|
||||||
|
|
||||||
var fasterFrames = 0;
|
var fasterFrames = 0;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
using var data = await engine.Receive();
|
using var data = await engine.Receive(CancellationToken.None);
|
||||||
fasterFrames += data.Frames;
|
fasterFrames += data.Frames;
|
||||||
if (data.Buffer == null)
|
if (data.Buffer == null)
|
||||||
break;
|
break;
|
||||||
@ -98,12 +98,12 @@ public sealed class TimeStretchEngine_Tests
|
|||||||
var input = MakeBlock(1000);
|
var input = MakeBlock(1000);
|
||||||
|
|
||||||
for (var i = 0; i < 25; i++)
|
for (var i = 0; i < 25; i++)
|
||||||
await engine.Submit(input);
|
await engine.Submit(input, CancellationToken.None);
|
||||||
|
|
||||||
var normalFrames = 0;
|
var normalFrames = 0;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
using var data = await engine.Receive();
|
using var data = await engine.Receive(CancellationToken.None);
|
||||||
normalFrames += data.Frames;
|
normalFrames += data.Frames;
|
||||||
if (data.Buffer == null)
|
if (data.Buffer == null)
|
||||||
break;
|
break;
|
||||||
@ -112,12 +112,12 @@ public sealed class TimeStretchEngine_Tests
|
|||||||
engine.Configure(new PlaybackSpeedSettings { Speed = 0.5f });
|
engine.Configure(new PlaybackSpeedSettings { Speed = 0.5f });
|
||||||
|
|
||||||
for (var i = 0; i < 25; i++)
|
for (var i = 0; i < 25; i++)
|
||||||
await engine.Submit(input);
|
await engine.Submit(input, CancellationToken.None);
|
||||||
|
|
||||||
var slowerFrames = 0;
|
var slowerFrames = 0;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
using var data = await engine.Receive();
|
using var data = await engine.Receive(CancellationToken.None);
|
||||||
slowerFrames += data.Frames;
|
slowerFrames += data.Frames;
|
||||||
if (data.Buffer == null)
|
if (data.Buffer == null)
|
||||||
break;
|
break;
|
||||||
@ -135,13 +135,13 @@ public sealed class TimeStretchEngine_Tests
|
|||||||
|
|
||||||
var input = MakeBlock(100);
|
var input = MakeBlock(100);
|
||||||
|
|
||||||
await engine.Submit(input);
|
await engine.Submit(input, CancellationToken.None);
|
||||||
var before = await engine.Receive();
|
var before = await engine.Receive(CancellationToken.None);
|
||||||
|
|
||||||
engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f });
|
engine.Configure(new PlaybackSpeedSettings { Speed = 0.75f });
|
||||||
|
|
||||||
await engine.Submit(input);
|
await engine.Submit(input, CancellationToken.None);
|
||||||
var after = await engine.Receive();
|
var after = await engine.Receive(CancellationToken.None);
|
||||||
|
|
||||||
Assert.AreEqual(0, after.Frames);
|
Assert.AreEqual(0, after.Frames);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,19 +3,19 @@
|
|||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="12.0.3" />
|
<PackageVersion Include="Avalonia" Version="12.1.0" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
|
<PackageVersion Include="Avalonia.Desktop" Version="12.1.0" />
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.3" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1"/>
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||||
<PackageVersion Include="BunLabs.NAudio.Flac" Version="2.0.1" />
|
<PackageVersion Include="BunLabs.NAudio.Flac" Version="2.0.1" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.ML.OnnxRuntime" Version="1.27.0" />
|
<PackageVersion Include="Microsoft.ML.OnnxRuntime" Version="1.27.0" />
|
||||||
<PackageVersion Include="Microsoft.ML.OnnxRuntime.DirectML" Version="1.24.4" />
|
<PackageVersion Include="Microsoft.ML.OnnxRuntime.DirectML" Version="1.24.4" />
|
||||||
<PackageVersion Include="NAudio" Version="2.3.0" />
|
<PackageVersion Include="NAudio" Version="2.3.0" />
|
||||||
<PackageVersion Include="NAudio.Flac" Version="1.0.5702.29018" />
|
<PackageVersion Include="NAudio.Flac" Version="1.0.5702.29018" />
|
||||||
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
|
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
|
||||||
<PackageVersion Include="MSTest" Version="4.0.2" />
|
<PackageVersion Include="MSTest" Version="4.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
Loading…
x
Reference in New Issue
Block a user