Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions src/Aurio.FFmpeg.UnitTest/FFmpegSourceStreamTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ public void SuggestWaveProxyFileInfo_WithoutDirectory_Windows()
{
Skip.IfNot(OperatingSystem.IsWindows());

var fileInfo = new FileInfo("X:\\folder\\file.wav");
var fileDescriptor = new FFmpegSourceStream.FileDescriptor(
"X:\\folder\\file.wav",
0,
DateTime.Now
);

var proxyFileInfo = FFmpegSourceStream.SuggestWaveProxyFileInfo(fileInfo);
var proxyFileInfo = FFmpegSourceStream.SuggestWaveProxyFileInfo(fileDescriptor);

Assert.Equal("X:\\folder\\file.wav.ffproxy.wav", proxyFileInfo.FullName);
}
Expand All @@ -36,9 +40,13 @@ public void SuggestWaveProxyFileInfo_WithoutDirectory_Unix()
{
Skip.If(OperatingSystem.IsWindows());

var fileInfo = new FileInfo("/folder/file.wav");
var fileDescriptor = new FFmpegSourceStream.FileDescriptor(
"/folder/file.wav",
0,
DateTime.Now
);

var proxyFileInfo = FFmpegSourceStream.SuggestWaveProxyFileInfo(fileInfo);
var proxyFileInfo = FFmpegSourceStream.SuggestWaveProxyFileInfo(fileDescriptor);

Assert.Equal("/folder/file.wav.ffproxy.wav", proxyFileInfo.FullName);
}
Expand All @@ -48,16 +56,20 @@ public void SuggestWaveProxyFileInfo_WithDirectory_Windows()
{
Skip.IfNot(OperatingSystem.IsWindows());

var fileInfo = new FileInfo("X:\\folder\\file.wav");
var fileDescriptor = new FFmpegSourceStream.FileDescriptor(
"X:\\folder\\file.wav",
1,
new DateTime(0).AddMilliseconds(100)
);
var directoryInfo = new DirectoryInfo("Y:\\temp\\dir");

var proxyFileInfo = FFmpegSourceStream.SuggestWaveProxyFileInfo(
fileInfo,
fileDescriptor,
directoryInfo
);

Assert.Equal(
"Y:\\temp\\dir\\87c18cf1c8d8e07552df4ecc1ef629995fca9c59ad47ccf7eb4816de33590af7.ffproxy.wav",
"Y:\\temp\\dir\\c80fb33f795ec563becb32d569268c831cfe8bbb360b00eb0da10c260b7870b6.ffproxy.wav",
proxyFileInfo.FullName
);
}
Expand All @@ -67,16 +79,20 @@ public void SuggestWaveProxyFileInfo_WithDirectory_Unix()
{
Skip.If(OperatingSystem.IsWindows());

var fileInfo = new FileInfo("/folder/file.wav");
var fileDescriptor = new FFmpegSourceStream.FileDescriptor(
"/folder/file.wav",
1,
new DateTime(0).AddMilliseconds(100)
);
var directoryInfo = new DirectoryInfo("/temp/dir");

var proxyFileInfo = FFmpegSourceStream.SuggestWaveProxyFileInfo(
fileInfo,
fileDescriptor,
directoryInfo
);

Assert.Equal(
"/temp/dir/e2c5b9282d156c5bdbf95639ca2ce2516b096e4828b7aa16620cb317cc90b3d9.ffproxy.wav",
"/temp/dir/54f862953943df20eb4b2c8b343b118ab7134f1b5980b6dd261d452e33569afb.ffproxy.wav",
proxyFileInfo.FullName
);
}
Expand Down Expand Up @@ -264,5 +280,23 @@ public void SeekBeyondEnd_ReadIndicatesEndOfStream()

Assert.Equal(0, bytesRead);
}

[Fact]
public void SuggestWaveProxyFileName_FileWriteTimeChange()
{
string path = "/virtual/test.bin";
long length = 1234;
DateTime t1 = new DateTime(2024, 01, 01, 0, 0, 0, DateTimeKind.Utc);
DateTime t2 = t1.AddMilliseconds(1); // changed timestamp

var name1 = FFmpegSourceStream.SuggestWaveProxyFileName(
new FFmpegSourceStream.FileDescriptor(path, length, t1)
);
var name2 = FFmpegSourceStream.SuggestWaveProxyFileName(
new FFmpegSourceStream.FileDescriptor(path, length, t2)
);

Assert.NotEqual(name1, name2);
}
}
}
57 changes: 50 additions & 7 deletions src/Aurio.FFmpeg/FFmpegSourceStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -389,9 +389,12 @@ public static FileInfo CreateWaveProxy(FileInfo fileInfo, FileInfo proxyFileInfo
);
var writer = new BlockingFixedLengthFifoStream(properties, 1024 * 1024 * 16);

// Use a temporary file during writing to avoid incomplete proxy files on unexpected termination
var tempProxyFileInfo = new FileInfo(proxyFileInfo.FullName + ".part");

var writerTask = Task.Run(() =>
{
AudioStreamFactory.WriteToFile(writer, proxyFileInfo.FullName);
AudioStreamFactory.WriteToFile(writer, tempProxyFileInfo.FullName);
});

int output_buffer_size = reader.AudioOutputConfig.frame_size * writer.SampleBlockSize;
Expand All @@ -418,6 +421,9 @@ out Type type
writer.SignalEndOfInput();
writerTask.Wait();

// Move temp file to final proxy file
tempProxyFileInfo.MoveTo(proxyFileInfo.FullName, true);

return proxyFileInfo;
}

Expand Down Expand Up @@ -469,32 +475,58 @@ public static bool WaveProxySuggested(FileInfo fileInfo)
/// If no storage diretory is specified, the proxy file will be located in the same directory and
/// with the same name as the specified file.
/// </summary>
/// <param name="fileInfo">the file for which a proxy file should be created</param>
/// <param name="fileDescriptor">the file for which a proxy file should be created</param>
/// <param name="storageDirectory">optional directory where the proxy file will be stored (can be null)</param>
/// <returns>the FileInfo of the suggested proxy file</returns>
public static FileInfo SuggestWaveProxyFileInfo(
FileInfo fileInfo,
DirectoryInfo storageDirectory = null
)
{
return SuggestWaveProxyFileInfo(
new FileDescriptor(fileInfo.FullName, fileInfo.Length, fileInfo.LastWriteTimeUtc),
storageDirectory
);
}

public static FileInfo SuggestWaveProxyFileInfo(
FileDescriptor fileDescriptor,
DirectoryInfo storageDirectory = null
)
{
if (storageDirectory == null)
{
// Without a storage directory, store the proxy file beside the original file
return new FileInfo(fileInfo.FullName + ProxyFileExtension);
return new FileInfo(fileDescriptor.FullName + ProxyFileExtension);
}
else
{
// With a storage directory specified, store the proxy file with a hashed name
// (to avoid name collision / overwrites) in the target directory (e.g. a temp or working directory)
using var sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(Encoding.Unicode.GetBytes(fileInfo.FullName));
string hashString = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
var name = SuggestWaveProxyFileName(fileDescriptor);
return new FileInfo(
Path.Combine(storageDirectory.FullName, hashString + ProxyFileExtension)
Path.Combine(storageDirectory.FullName, name + ProxyFileExtension)
);
}
}

public static string SuggestWaveProxyFileName(FileDescriptor fileDescriptor)
{
// Include file size and last write timestamp (UTC) in hash to differentiate identical paths with changed contents.
string hashInput =
fileDescriptor.FullName
+ "|"
+ fileDescriptor.Length
+ "|"
+ fileDescriptor.LastWriteTimeUtc.Ticks;

using var sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(Encoding.Unicode.GetBytes(hashInput));
string hashString = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();

return hashString;
}

public class FileNotSeekableException : Exception
{
public FileNotSeekableException()
Expand Down Expand Up @@ -530,5 +562,16 @@ public SeekTargetMissedException(string message)
public SeekTargetMissedException(string message, Exception innerException)
: base(message, innerException) { }
}

/// <summary>
/// Lightweight immutable abstraction of <see cref="FileInfo"/> holding only the metadata
/// required for proxy file name generation.
/// Use this instead of <see cref="FileInfo"/> where direct filesystem access should be
/// avoided (e.g. in unit tests) or when a simple value object is sufficient.
/// </summary>
/// <param name="FullName">Absolute path (or unique identifier) of the original file.</param>
/// <param name="Length">File size in bytes.</param>
/// <param name="LastWriteTimeUtc">Last write time expressed in UTC; used to distinguish changed contents with identical paths and sizes.</param>
public record FileDescriptor(string FullName, long Length, DateTime LastWriteTimeUtc);
}
}
50 changes: 49 additions & 1 deletion src/Aurio.UnitTest/BlockingFixedLengthFifoStreamTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Aurio.Streams;
using System.Threading;
using System.Threading.Tasks;
using Aurio.Streams;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Aurio.UnitTest
Expand All @@ -16,5 +18,51 @@ public void WriteReadSequenceTestBlocking()
)
);
}

[TestMethod]
[Timeout(5000)]
public void ReadBlocksUntilDataArrives()
{
var props = new AudioProperties(1, 8000, 16, AudioFormat.LPCM);
var stream = new BlockingFixedLengthFifoStream(props, 1024);
var buf = new byte[128];

var readTask = Task.Run(() => stream.Read(buf, 0, buf.Length));

// ensure the read has time to enter waiting state
Task.Delay(50).Wait();

stream.Write(new byte[buf.Length], 0, buf.Length);

var completed = Task.WaitAny(new[] { readTask }, 2000);
Assert.AreNotEqual(-1, completed, "Read did not complete after write");

Assert.AreEqual(
buf.Length,
readTask.Result,
"Read should return requested bytes after data arrives"
);
}

[TestMethod]
[Timeout(5000)]
public void ReadUnblocksOnEndSignalWithoutData()
{
var props = new AudioProperties(1, 8000, 16, AudioFormat.LPCM);
var stream = new BlockingFixedLengthFifoStream(props, 1024);
var buf = new byte[128];

var readTask = Task.Run(() => stream.Read(buf, 0, buf.Length));
Task.Delay(50).Wait(); // give time to block
stream.SignalEndOfInput();

var completed = Task.WaitAny(new[] { readTask }, 2000);
Assert.AreNotEqual(-1, completed, "Read did not complete after end-of-input signal");
Assert.AreEqual(
0,
readTask.Result,
"Read should return 0 at end-of-input with no data"
);
}
}
}
2 changes: 2 additions & 0 deletions src/Aurio/Streams/BlockingFixedLengthFifoStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ public override long ReadDelay
/// can still be consumed.
/// </summary>
/// <see cref="EndOfInputSignalled"/>
[MethodImpl(MethodImplOptions.Synchronized)]
public void SignalEndOfInput()
{
EndOfInputSignalled = true;
Monitor.PulseAll(this);
}

/// <summary>
Expand Down
Loading