diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fa3399d39..a6dbc4ddd 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -35,11 +35,48 @@ jobs:
- name: Get Path to Tests
run: echo "TEST_PATH=$(dotnet msbuild SIL.Core.Tests/ --getProperty:OutputPath -p:TargetFramework=${{ matrix.framework }} -p:Configuration=Release)" >> $GITHUB_ENV
+ # Several steps to set up FFmpeg and Scream and start Audio Service so that audio tests can run
+ - name: Install FFmpeg
+ uses: FedericoCarboni/setup-ffmpeg@v3
+ id: setup-ffmpeg
+ with:
+ ffmpeg-version: release
+ - run: echo ffmpeg path ${{ steps.setup-ffmpeg.outputs.ffmpeg-path }}
+
+ - name: Verify FFmpeg Installation
+ run: |
+ echo $PATH
+ ffmpeg -version
+
+ - name: Install Scream
+ shell: powershell
+ run: |
+ Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/4.0/Scream4.0.zip -OutFile Scream4.0.zip
+ Expand-Archive -Path Scream4.0.zip -DestinationPath Scream
+ openssl req -batch -verbose -x509 -newkey rsa -keyout ScreamCertificate.pvk -out ScreamCertificate.cer -nodes -extensions v3_req
+ openssl pkcs12 -export -nodes -in ScreamCertificate.cer -inkey ScreamCertificate.pvk -out ScreamCertificate.pfx -passout pass:
+
+ - name: Setup MSVC Dev Cmd
+ uses: ilammy/msvc-dev-cmd@v1
+
+ - name: Sign and Install Scream Driver - allows unit tests that require an audio output device
+ shell: powershell
+ run: |
+ signtool sign /v /fd SHA256 /f ScreamCertificate.pfx Scream\Install\driver\x64\Scream.cat
+ Import-Certificate -FilePath ScreamCertificate.cer -CertStoreLocation Cert:\LocalMachine\root
+ Import-Certificate -FilePath ScreamCertificate.cer -CertStoreLocation Cert:\LocalMachine\TrustedPublisher
+ Scream\Install\helpers\devcon-x64.exe install Scream\Install\driver\x64\Scream.inf *Scream
+ timeout-minutes: 5
+
+ - name: Start Windows Audio Service
+ run: net start audiosrv
+ shell: powershell
+
# there are cases where this will fail and we want to know about it
# so we don't use continue-on-error, but we still want to publish the results
- name: Test project
id: test
- run: dotnet test "$TEST_PATH"/SIL*Tests.dll --filter "TestCategory != SkipOnTeamCity" --blame-hang-timeout 5m --logger:"trx;LogFilePrefix=results" --results-directory ./test-results
+ run: dotnet test "$TEST_PATH"/SIL*Tests.dll --filter "(TestCategory != SkipOnTeamCity) & (TestCategory != RequiresAudioInputDevice)" --blame-hang-timeout 5m --logger:"trx;LogFilePrefix=results" --results-directory ./test-results
- name: Upload test results
if: always()
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50855f1f1..882c855e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- [SIL.Archiving] Added public property isValid to IMDIPackage.
- [SIL.Archiving] Added public event InitializationFailed to IMDIArchivingDlgViewModel.
- [SIL.Archiving] Added the following properties to ArchivingDlgViewModel as an alternative way to customize the initial summary displayed: GetOverriddenPreArchivingMessages, InitialFileGroupDisplayMessageType, OverrideGetFileGroupDisplayMessage
+- [SIL.Media] Added FFmpegRunner.MinimumVersion property (also used by MediaInfo for FFprobe).
### Changed
@@ -78,11 +79,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- [SIL.Archiving] Changed the name of the third parameter in ArchivingDlgViewModel.AddFileGroup from progressMessage to addingToArchiveProgressMessage.
- [SIL.Windows.Forms.Archiving] Changed Cancel Button to say Close instead in IMDIArchivingDlg.
- [SIL.Core.Desktop] Renamed GetFromRegistryProgramThatOpensFileType to GetDefaultProgramForFileType.
+- [SIL.Media] Made FFmpegRunner able to use version of FFmpeg found on the path.
+- [SIL.Media] Upgraded irrKlang to v. 1.6.
+- [SIL.Media] In FFmpegRunner, changed ExtractMp3Audio, ExtractOggAudio, ExtractAudio, and ChangeNumberOfAudioChannels to use LocateAndRememberFFmpeg instead of LocateFFmpeg. This is potentially a breaking change but only in the edge case where an app does not install FFmpeg and the user installs it while running the app.
+- [SIL.Media] Made the Windows implementation of ISimpleAudioSession more robust in that it will attempt to create an irrKlang-based recorder even if there is no audio output device enabled.
+- [SIL.Media] Made FFmpegRunner explicitly a static class (technically a breaking change, though all methods were already static).
+- [SIL.Media] Made FFmpegRunner look for the exe on the path before trying to find a version installed for Audacity (which is unlikely to succeed anyway).
+- [SIL.Media] Made MediaInfo look for the FFprobe exe in the same location as FFmpeg when the application has specified the location for it or when it was previously located in one of the expected locations. Also made it more robust by making it more likely to find FFprobe (when it is on the system path).
### Fixed
- [SIL.Archiving] Fixed typo in RampArchivingDlgViewModel for Ethnomusicology performance collection.
- [SIL.Archiving] Changed URLs that used http: to https: in resource EmptyMets.xml.
- [SIL.Core.Desktop] Implemented GetDefaultProgramForFileType (as trenamed) in a way that works on Windows 11, Mono (probably) and MacOS (untested).
+- [SIL.Media] MediaInfo.HaveNecessaryComponents properly returns true if FFprobe is on the system path.
+- [SIL.Media] Made MediaInfo.FFprobeFolder look for and return the folder when first accessed, even if no prior call to the setter or other action had caused it t be found.
### Removed
diff --git a/Palaso.sln.DotSettings b/Palaso.sln.DotSettings
index 6bfe95d19..e644a8af1 100644
--- a/Palaso.sln.DotSettings
+++ b/Palaso.sln.DotSettings
@@ -23,6 +23,7 @@
True
True
True
+ True
True
True
True
@@ -34,6 +35,7 @@
True
True
True
+ True
True
True
True
@@ -118,6 +120,7 @@
True
True
True
+ True
True
True
True
diff --git a/SIL.Core/CommandLineProcessing/CommandLineRunner.cs b/SIL.Core/CommandLineProcessing/CommandLineRunner.cs
index a10e8c2d9..5b2e7d88d 100644
--- a/SIL.Core/CommandLineProcessing/CommandLineRunner.cs
+++ b/SIL.Core/CommandLineProcessing/CommandLineRunner.cs
@@ -90,7 +90,7 @@ public bool Abort(int secondsBeforeTimeout)
}
///
- /// use this one if you're doing a long running task that you'll have running in a thread,
+ /// use this one if you're doing a long-running task that you'll have running in a thread,
/// so that you need a way to abort it
///
public ExecutionResult Start(string exePath, string arguments, Encoding encoding,
diff --git a/SIL.Core/IO/TempFile.cs b/SIL.Core/IO/TempFile.cs
index 7ac21fadf..d327c7f6c 100644
--- a/SIL.Core/IO/TempFile.cs
+++ b/SIL.Core/IO/TempFile.cs
@@ -267,7 +267,7 @@ public static TempFile WithFilenameInTempFolder(string fileName)
/// Used to make a real file out of a resource for the purpose of testing
///
/// e.g., an audio resource
- /// with or with out '.', will work the same
+ /// with or without '.', will work the same
public static TempFile FromResource(Stream resource, string extension)
{
var f = WithExtension(extension);
diff --git a/SIL.Media.Tests/AudioFactoryTests.cs b/SIL.Media.Tests/AudioFactoryTests.cs
index b3ba3ee9a..8a680a7cb 100644
--- a/SIL.Media.Tests/AudioFactoryTests.cs
+++ b/SIL.Media.Tests/AudioFactoryTests.cs
@@ -4,10 +4,9 @@
namespace SIL.Media.Tests
{
- // These will not work if a speaker is not available.
[TestFixture]
- [Category("SkipOnTeamCity")]
[Category("AudioTests")]
+ [Category("RequiresAudioOutputDevice")] // These will not work if a speaker is not available.
public class AudioFactoryTests
{
[Test]
@@ -27,7 +26,6 @@ public void Construct_FileDoesExistButEmpty_OK()
}
}
-
[Test]
public void Construct_FileDoesNotExist_DoesNotCreateFile()
{
diff --git a/SIL.Media.Tests/AudioPlayerTests.cs b/SIL.Media.Tests/AudioPlayerTests.cs
index 87a40a40c..13e33ec96 100644
--- a/SIL.Media.Tests/AudioPlayerTests.cs
+++ b/SIL.Media.Tests/AudioPlayerTests.cs
@@ -10,13 +10,12 @@
namespace SIL.Media.Tests
{
///
- /// All these tests are skipped on TeamCity (even if you remove this category) because SIL.Media.Tests compiles to an exe,
- /// and the project that builds libpalaso on TeamCity (build/Palaso.proj, task Test) invokes RunNUnitTC which
- /// selects the test assemblies using Include="$(RootDir)/output/$(Configuration)/*.Tests.dll" which excludes exes.
- /// I have not tried to verify that all of these tests would actually have problems on TeamCity, but it seemed
- /// helpful to document in the usual way that they are not, in fact, run there.
+ /// Tests for the AudioPlayer class.
///
- [Category("SkipOnTeamCity")]
+ /// This fixture used to be skipped during the CI build, perhaps because of the test
+ /// that actually tries to do playback (since that would require an audio output device).
+ /// However, that test is now ignored and the only non-ignored test works fine without an
+ /// actual playback device.
[TestFixture]
public class AudioPlayerTests
{
@@ -33,7 +32,7 @@ public void LoadFile_ThenDispose_FileCanBeDeleted()
}
///
- /// This test shows what caused hearthis to abandon the naudio; previous to a change to this class in 2/2012, it is believed that all was ok.
+ /// This test shows what caused HearThis to abandon NAudio; previous to a change to this class in 2/2012, it is believed that all was ok.
///
[Test, Ignore("Known to Fail (hangs forever")]
public void PlayFile_ThenDispose_FileCanBeDeleted()
diff --git a/SIL.Media.Tests/AudioRecorderTests.cs b/SIL.Media.Tests/AudioRecorderTests.cs
index b6cf4f997..9156acfb3 100644
--- a/SIL.Media.Tests/AudioRecorderTests.cs
+++ b/SIL.Media.Tests/AudioRecorderTests.cs
@@ -8,14 +8,21 @@
namespace SIL.Media.Tests
{
- // Some of these tests require a speaker. Others require a microphone.
- // None of them will work if neither a speaker nor a microphone is available.
[TestFixture]
- [Category("SkipOnTeamCity")]
[Category("AudioTests")]
+ [Category("RequiresAudioInputDevice")]
public class AudioRecorderTests
{
-
+ private RecordingDevice _defaultRecordingDevice;
+
+ [OneTimeSetUp]
+ public void SetUpFixture()
+ {
+ _defaultRecordingDevice = RecordingDevice.Devices.FirstOrDefault() as RecordingDevice;
+ Assert.That(_defaultRecordingDevice, Is.Not.Null,
+ "These tests require a microphone.");
+ }
+
[Test]
public void BeginRecording_OnNonUiThread_Throws()
{
@@ -23,7 +30,7 @@ public void BeginRecording_OnNonUiThread_Throws()
{
using (var recorder = new AudioRecorder(1))
{
- recorder.SelectedDevice = RecordingDevice.Devices.First() as RecordingDevice;
+ recorder.SelectedDevice = _defaultRecordingDevice;
Assert.That(() => recorder.BeginRecording(f.Path, false), Throws.Exception);
}
}
@@ -42,8 +49,7 @@ public void BeginRecording_ThenStop_RecordingSavedToFile()
// Note: BeginRecording
ctrl.HandleCreated += delegate
{
- recorder.SelectedDevice =
- RecordingDevice.Devices.First() as RecordingDevice;
+ recorder.SelectedDevice = _defaultRecordingDevice;
recorder.BeginRecording(f.Path, false);
Thread.Sleep(100);
recorder.Stop();
@@ -86,8 +92,7 @@ public void BeginRecording_WhileRecording_ThrowsInvalidOperationException()
// Note: BeginRecording
ctrl.HandleCreated += delegate
{
- recorder.SelectedDevice =
- RecordingDevice.Devices.First() as RecordingDevice;
+ recorder.SelectedDevice = _defaultRecordingDevice;
recorder.BeginRecording(f.Path, false);
};
@@ -117,8 +122,7 @@ public void BeginMonitoring_WhileRecording_ThrowsInvalidOperationException()
// Note: BeginRecording
ctrl.HandleCreated += delegate
{
- recorder.SelectedDevice =
- RecordingDevice.Devices.First() as RecordingDevice;
+ recorder.SelectedDevice = _defaultRecordingDevice;
recorder.BeginRecording(f.Path, false);
};
@@ -148,8 +152,7 @@ public void BeginRecording_OnNonUiThreadAfterMonitoringStartedOnUIThread_NoExcep
{
ctrl.HandleCreated += delegate
{
- recorder.SelectedDevice =
- RecordingDevice.Devices.First() as RecordingDevice;
+ recorder.SelectedDevice = _defaultRecordingDevice;
recorder.BeginMonitoring();
monitoringStarted = true;
};
diff --git a/SIL.Media.Tests/AudioSessionTests.cs b/SIL.Media.Tests/AudioSessionTests.cs
index ea557bbff..988c2f617 100644
--- a/SIL.Media.Tests/AudioSessionTests.cs
+++ b/SIL.Media.Tests/AudioSessionTests.cs
@@ -12,10 +12,8 @@
namespace SIL.Media.Tests
{
- // Some of these tests require a speaker. Others require a microphone.
- // None of them will work if neither a speaker nor a microphone is available.
+ // Some of these tests require a microphone (or a virtual audio input device).
[TestFixture]
- [NUnit.Framework.Category("SkipOnTeamCity")]
[NUnit.Framework.Category("AudioTests")]
public class AudioSessionTests
{
@@ -28,17 +26,6 @@ public void StopRecordingAndSaveAsWav_NotRecording_Throws()
}
}
- [Test]
- public void StopPlaying_WhilePlaying_NoExceptionThrown()
- {
- using (var session = new RecordingSession(1000))
- {
- session.Recorder.Play();
- Thread.Sleep(100);
- session.Recorder.StopPlaying();
- }
- }
-
[Test]
public void StopRecordingAndSaveAsWav_WhileRecording_NoExceptionThrown()
{
@@ -51,6 +38,7 @@ public void StopRecordingAndSaveAsWav_WhileRecording_NoExceptionThrown()
}
[Test]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void Play_WhileRecording_Throws()
{
using (var session = new RecordingSession())
@@ -117,19 +105,34 @@ public void Play_FileDoesNotExist_Throws()
}
}
+ ///
+ /// For reasons I don't entirely understand, this test will actually pass when run
+ /// by itself against a single target framework without an audio output device, but
+ /// to get it to pass when running as part of the fixture or when testing against both
+ /// frameworks, it is necessary to have an audio output device.
+ ///
[Test]
+ [NUnit.Framework.Category("RequiresAudioOutputDevice")]
public void CanStop_WhilePlaying_True()
{
- using (var session = new RecordingSession(1000))
+ using (var file = TempFile.FromResource(Resources.finished, ".wav"))
{
- session.Recorder.Play();
- Thread.Sleep(100);
- Assert.IsTrue(session.Recorder.CanStop);
+ using (var x = AudioFactory.CreateAudioSession(file.Path))
+ {
+ x.Play();
+ Thread.Sleep(200);
+ Assert.That(x.CanStop, Is.True, "Playback should last more than 200 ms.");
+ // We used to test this in a separate test:
+ // StopPlaying_WhilePlaying_NoExceptionThrown
+ // But now that would be redundant since we need to stop playback in order to
+ // safely dispose.
+ x.StopPlaying();
+ }
}
}
-
[Test]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void RecordAndStop_FileAlreadyExists_FileReplaced()
{
using (var f = new TempFile())
@@ -151,6 +154,7 @@ public void RecordAndStop_FileAlreadyExists_FileReplaced()
}
[Test]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void IsRecording_WhileRecording_True()
{
using (var f = new TempFile())
@@ -167,6 +171,7 @@ public void IsRecording_WhileRecording_True()
[Test]
[Platform(Exclude = "Linux", Reason = "AudioAlsaSession doesn't implement ISimpleAudioWithEvents")]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void RecordThenPlay_SmokeTest()
{
using (var f = new TempFile())
@@ -210,37 +215,28 @@ public void RecordThenPlay_SmokeTest()
}
[Test]
- public void Play_GiveThaiFileName_ShouldHearTwoSounds()
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
+ public void Play_GiveThaiFileName_ShouldHearTinklingSounds()
{
- using (var d = new TemporaryFolder("palaso media test"))
+ using (var file = TempFile.FromResource(Resources.finished, ".wav"))
{
- var soundPath = d.Combine("ก.wav");
- File.Create(soundPath).Close();
- using (var f = TempFile.TrackExisting(soundPath))
+ using (var d = new TemporaryFolder("palaso media test"))
{
- var w = new BackgroundWorker();
- // ReSharper disable once RedundantDelegateCreation
- w.DoWork += new DoWorkEventHandler((o, args) => SystemSounds.Exclamation.Play());
-
- using (var x = AudioFactory.CreateAudioSession(f.Path))
+ var soundPath = d.Combine("ก.wav");
+ RobustFile.Copy(file.Path, soundPath);
+ using (var f = TempFile.TrackExisting(soundPath))
{
- x.StartRecording();
- w.RunWorkerAsync();
- Thread.Sleep(2000);
- x.StopRecordingAndSaveAsWav();
- }
-
- using (var y = AudioFactory.CreateAudioSession(f.Path))
- {
- y.Play();
- Thread.Sleep(1000);
- y.StopPlaying();
+ using (var y = AudioFactory.CreateAudioSession(f.Path))
+ {
+ y.Play();
+ Thread.Sleep(1000);
+ y.StopPlaying();
+ }
}
}
}
}
-
///
/// for testing things while recording is happening
///
@@ -265,10 +261,7 @@ public RecordingSession(int millisecondsToRecordBeforeStopping)
_recorder.StopRecordingAndSaveAsWav();
}
- public ISimpleAudioSession Recorder
- {
- get { return _recorder; }
- }
+ public ISimpleAudioSession Recorder => _recorder;
public void Dispose()
{
@@ -288,6 +281,7 @@ public void Dispose()
}
[Test]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void CanStop_WhileRecording_True()
{
using (var session = new RecordingSession())
@@ -297,6 +291,7 @@ public void CanStop_WhileRecording_True()
}
[Test]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void CanPlay_WhileRecording_False()
{
using (var session = new RecordingSession())
@@ -306,6 +301,7 @@ public void CanPlay_WhileRecording_False()
}
[Test]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void CanRecord_WhileRecording_False()
{
using (var session = new RecordingSession())
@@ -323,26 +319,48 @@ public void StartRecording_WhileRecording_Throws()
}
}
+ ///
+ /// For reasons I don't entirely understand, this test will actually pass when run
+ /// by itself against a single target framework without an audio output device, but
+ /// to get it to pass when running as part of the fixture or when testing against both
+ /// frameworks, it is necessary to have an audio output device.
+ ///
[Test]
+ [NUnit.Framework.Category("RequiresAudioOutputDevice")]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void CanRecord_WhilePlaying_False()
{
using (var session = new RecordingSession(1000))
{
session.Recorder.Play();
Thread.Sleep(100);
- Assert.IsTrue(session.Recorder.IsPlaying);
- Assert.IsFalse(session.Recorder.CanRecord);
+ Assert.That(session.Recorder.IsPlaying, Is.True,
+ "Should be playing, not recording.");
+ Assert.That(session.Recorder.CanRecord, Is.False);
}
}
+ ///
+ /// For reasons I don't entirely understand, this test will actually pass when run
+ /// by itself against a single target framework without an audio output device, but
+ /// to get it to pass when running as part of the fixture or when testing against both
+ /// frameworks, it is necessary to have an audio output device. I tried setting the
+ /// ParallelScope to None, but even that didn't work, so it does not seem to be an
+ /// issue with parallel test runs affecting each other.
+ ///
[Test]
+ [NUnit.Framework.Category("RequiresAudioOutputDevice")]
public void CanPlay_WhilePlaying_False()
{
- using (var session = new RecordingSession(1000))
+ using (var file = TempFile.FromResource(Resources.finished, ".wav"))
{
- session.Recorder.Play();
- Thread.Sleep(100);
- Assert.IsFalse(session.Recorder.CanPlay);
+ using (var x = AudioFactory.CreateAudioSession(file.Path))
+ {
+ x.Play();
+ Thread.Sleep(200);
+ Assert.That(x.CanPlay, Is.False, "Playback should last more than 200 ms.");
+ x.StopPlaying();
+ }
}
}
@@ -371,6 +389,7 @@ public void RecordThenStop_CanPlay_IsTrue()
}
[Test]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void RecordThenPlay_OK()
{
using (var f = new TempFile())
@@ -429,6 +448,7 @@ public void Play_DoesPlayMp3_SmokeTest()
}
[Test]
+ [NUnit.Framework.Category("RequiresAudioInputDevice")]
public void Record_DoesRecord ()
{
using (var folder = new TemporaryFolder("Record_DoesRecord"))
@@ -451,8 +471,8 @@ public void Record_LongRecording()
{
using (var folder = new TemporaryFolder("Record_LongRecording"))
{
- string fpath = Path.Combine(folder.Path, "long.wav");
- using (var x = AudioFactory.CreateAudioSession(fpath))
+ string fPath = Path.Combine(folder.Path, "long.wav");
+ using (var x = AudioFactory.CreateAudioSession(fPath))
{
SystemSounds.Beep.Play();
Assert.DoesNotThrow(() => x.StartRecording());
diff --git a/SIL.Media.Tests/FFmpegRunnerTests.cs b/SIL.Media.Tests/FFmpegRunnerTests.cs
index 1430daf64..19952e360 100644
--- a/SIL.Media.Tests/FFmpegRunnerTests.cs
+++ b/SIL.Media.Tests/FFmpegRunnerTests.cs
@@ -1,4 +1,6 @@
+using System;
using System.IO;
+using FFMpegCore;
using NUnit.Framework;
using SIL.IO;
using SIL.Media.Tests.Properties;
@@ -6,14 +8,7 @@
namespace SIL.Media.Tests
{
- ///
- /// All these tests are skipped on TeamCity (even if you remove this category) because SIL.Media.Tests compiles to an exe,
- /// and the project that builds libpalaso on TeamCity (build/Palaso.proj, task Test) invokes RunNUnitTC which
- /// selects the test assemblies using Include="$(RootDir)/output/$(Configuration)/*.Tests.dll" which excludes exes.
- /// I have not tried to verify that all of these tests would actually have problems on TeamCity, but it seemed
- /// helpful to document in the usual way that they are not, in fact, run there.
- ///
- [Category("SkipOnTeamCity")]
+ [Category("RequiresFfmpeg")]
[TestFixture]
public class FFmpegRunnerTests
{
@@ -21,18 +16,52 @@ public class FFmpegRunnerTests
public void CheckRequirements()
{
if (!FFmpegRunner.HaveNecessaryComponents)
- Assert.Ignore("These tests require ffmpeg to be installed.");
+ {
+ if (Environment.GetEnvironmentVariable("CI") == null)
+ Assert.Ignore("These tests require ffmpeg to be installed.");
+ else
+ Assert.Fail("On CI build using GHA, FFMpeg should have been installed before running tests.");
+ }
}
[Test]
- [Category("RequiresFfmpeg")]
- public void HaveNecessaryComponents_ReturnsTrue()
+ public void HaveNecessaryComponents_NoExplicitMinVersion_ReturnsTrue()
+ {
+ Assert.IsTrue(FFmpegRunner.HaveNecessaryComponents);
+ }
+
+ [TestCase(5, 1)]
+ [TestCase(4, 9)]
+ public void HaveNecessaryComponents_TwoDigitMinVersion_ReturnsTrue(int major, int minor)
{
+ FFmpegRunner.MinimumVersion = new Version(major, minor);
Assert.IsTrue(FFmpegRunner.HaveNecessaryComponents);
}
+ [TestCase(5, 1, 1)]
+ [TestCase(5, 0, 0)]
+ public void HaveNecessaryComponents_ThreeDigitMinVersion_ReturnsTrue(int major, int minor, int build)
+ {
+ FFmpegRunner.MinimumVersion = new Version(major, minor, build);
+ Assert.IsTrue(FFmpegRunner.HaveNecessaryComponents);
+ }
+
+ [TestCase(5, 1, 1, 0)]
+ [TestCase(5, 0, 0, 9)]
+ public void HaveNecessaryComponents_FourDigitMinVersion_ReturnsTrue(int major, int minor, int build, int revision)
+ {
+ FFmpegRunner.MinimumVersion = new Version(major, minor, build, revision);
+ Assert.IsTrue(FFmpegRunner.HaveNecessaryComponents);
+ }
+
+ [Test]
+ public void HaveNecessaryComponents_ReallyHighVersionThatDoesNotExist_ReturnsFalse()
+ {
+ FFmpegRunner.MinimumVersion = new Version(int.MaxValue, int.MaxValue);
+ Assert.IsFalse(FFmpegRunner.HaveNecessaryComponents);
+ }
+
[Test]
- [Category("RequiresFfmpeg")]
public void ExtractMp3Audio_CreatesFile()
{
using (var file = TempFile.FromResource(Resources.tiny, ".wmv"))
@@ -44,7 +73,6 @@ public void ExtractMp3Audio_CreatesFile()
}
[Test]
- [Category("RequiresFfmpeg")]
public void ExtractOggAudio_CreatesFile()
{
using (var file = TempFile.FromResource(Resources.tiny, ".wmv"))
@@ -56,7 +84,6 @@ public void ExtractOggAudio_CreatesFile()
}
[Test]
- [Category("RequiresFfmpeg")]
public void ChangeNumberOfAudioChannels_CreatesFile()
{
using (var file = TempFile.FromResource(Resources._2Channel, ".wav"))
@@ -68,7 +95,6 @@ public void ChangeNumberOfAudioChannels_CreatesFile()
}
[Test]
- [Category("RequiresFfmpeg")]
public void MakeLowQualityCompressedAudio_CreatesFile()
{
using (var file = TempFile.FromResource(Resources.tiny, ".wmv"))
@@ -79,12 +105,29 @@ public void MakeLowQualityCompressedAudio_CreatesFile()
var outputPath = originalAudioPath.Replace("mp3", "low.mp3");
FFmpegRunner.MakeLowQualityCompressedAudio(originalAudioPath, outputPath, new ConsoleProgress());
Assert.IsTrue(File.Exists(outputPath));
- System.Diagnostics.Process.Start(outputPath);
+
+ var mediaInfoOrig = FFProbe.Analyse(file.Path);
+ var mediaInfo = FFProbe.Analyse(outputPath);
+
+ // Validate resolution and bit rate
+ Assert.That(mediaInfo.PrimaryVideoStream, Is.Null);
+ Assert.That(mediaInfo.PrimaryAudioStream, Is.Not.Null);
+ Assert.That(mediaInfo.AudioStreams.Count, Is.EqualTo(1));
+ Assert.That(mediaInfo.PrimaryAudioStream.Channels, Is.EqualTo(1));
+ Assert.That(mediaInfo.Format.BitRate, Is.LessThan(mediaInfoOrig.Format.BitRate));
+ Assert.That(mediaInfo.PrimaryAudioStream.SampleRateHz, Is.EqualTo(8000));
+ try
+ {
+ RobustFile.Delete(outputPath);
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e);
+ }
}
}
[Test]
- [Category("RequiresFfmpeg")]
public void MakeLowQualitySmallVideo_CreatesFile()
{
using (var file = TempFile.FromResource(Resources.tiny, ".wmv"))
@@ -92,7 +135,28 @@ public void MakeLowQualitySmallVideo_CreatesFile()
var outputPath = file.Path.Replace("wmv", "low.wmv");
FFmpegRunner.MakeLowQualitySmallVideo(file.Path, outputPath, 0, new ConsoleProgress());
Assert.IsTrue(File.Exists(outputPath));
- System.Diagnostics.Process.Start(outputPath);
+
+ var mediaInfoOrig = FFProbe.Analyse(file.Path);
+ var mediaInfo = FFProbe.Analyse(outputPath);
+
+ // Validate resolution and bit rate
+ Assert.That(mediaInfo.PrimaryVideoStream, Is.Not.Null);
+ Assert.That(mediaInfo.VideoStreams.Count, Is.EqualTo(1));
+ Assert.That(mediaInfo.PrimaryAudioStream, Is.Not.Null);
+ Assert.That(mediaInfo.AudioStreams.Count, Is.EqualTo(1));
+ Assert.That(mediaInfo.PrimaryVideoStream.Width, Is.EqualTo(160));
+ Assert.That(mediaInfo.PrimaryVideoStream.Height, Is.EqualTo(120));
+ Assert.That(mediaInfo.Format.BitRate, Is.LessThan(mediaInfoOrig.Format.BitRate));
+ try
+ {
+ // When running the by-hand test, the default media player might leave this
+ // locked, so this cleanup will fail.
+ RobustFile.Delete(outputPath);
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e);
+ }
}
}
}
diff --git a/SIL.Media.Tests/MediaInfoTests.cs b/SIL.Media.Tests/MediaInfoTests.cs
index a9f8a608b..021acd80e 100644
--- a/SIL.Media.Tests/MediaInfoTests.cs
+++ b/SIL.Media.Tests/MediaInfoTests.cs
@@ -1,17 +1,12 @@
+using System;
+using System.IO;
using NUnit.Framework;
using SIL.IO;
using SIL.Media.Tests.Properties;
namespace SIL.Media.Tests
{
- ///
- /// All these tests are skipped on TeamCity (even if you remove this category) because SIL.Media.Tests compiles to an exe,
- /// and the project that builds libpalaso on TeamCity (build/Palaso.proj, task Test) invokes RunNUnitTC which
- /// selects the test assemblies using Include="$(RootDir)/output/$(Configuration)/*.Tests.dll" which excludes exes.
- /// I have not tried to verify that all of these tests would actually have problems on TeamCity, but it seemed
- /// helpful to document in the usual way that they are not, in fact, run there.
- ///
- [Category("SkipOnTeamCity")]
+ [Category("RequiresFFprobe")]
[TestFixture]
public class MediaInfoTests
{
@@ -19,18 +14,49 @@ public class MediaInfoTests
public void CheckRequirements()
{
if (!MediaInfo.HaveNecessaryComponents)
- Assert.Ignore(MediaInfo.MissingComponentMessage);
+ {
+ if (Environment.GetEnvironmentVariable("CI") == null)
+ Assert.Ignore(MediaInfo.MissingComponentMessage);
+ else
+ Assert.Fail("On CI build using GHA, FFMpeg should have been installed before running tests.");
+ }
}
[Test]
- [Category("RequiresFFprobe")]
public void HaveNecessaryComponents_ReturnsTrue()
{
- Assert.IsTrue(MediaInfo.HaveNecessaryComponents);
+ Assert.IsTrue(MediaInfo.HaveNecessaryComponents,
+ "FFprobe was expected to have been found on system path or in a known location.");
+ }
+
+ [TestCase(null)]
+ [TestCase("")]
+ public void SetFFprobeFolder_ToNullOrEmpty_HaveNecessaryComponentsReturnsTrue(string presetFolder)
+ {
+ MediaInfo.FFprobeFolder = presetFolder;
+ Assert.IsTrue(MediaInfo.HaveNecessaryComponents,
+ "FFprobe was expected to have been found on system path or in a known location.");
+ }
+
+ [Test]
+ public void SetFFprobeFolder_ToNonexistentFolder_ThrowsDirectoryNotFoundException()
+ {
+ Assert.That(() =>
+ {
+ MediaInfo.FFprobeFolder = "D:\\ThereIsNoWayThi5F0lderShould\\exist";
+ }, Throws.Exception.InstanceOf());
+ }
+
+ [Test]
+ public void SetFFprobeFolder_ToFolderWithoutFFprobe_ThrowsFileNotFoundException()
+ {
+ Assert.That(() =>
+ {
+ MediaInfo.FFprobeFolder = Environment.GetFolderPath(Environment.SpecialFolder.CommonDocuments);
+ }, Throws.Exception.InstanceOf());
}
[Test]
- [Category("RequiresFFprobe")]
public void VideoInfo_Duration_Correct()
{
using (var file = TempFile.FromResource(Resources.tiny, ".wmv"))
@@ -43,7 +69,6 @@ public void VideoInfo_Duration_Correct()
}
[Test]
- [Category("RequiresFFprobe")]
public void VideoInfo_Encoding_Correct()
{
using (var file = TempFile.FromResource(Resources.tiny, ".wmv"))
@@ -54,7 +79,6 @@ public void VideoInfo_Encoding_Correct()
}
[Test]
- [Category("RequiresFFprobe")]
public void VideoInfo_Resolution_Correct()
{
using (var file = TempFile.FromResource(Resources.tiny, ".wmv"))
@@ -65,7 +89,6 @@ public void VideoInfo_Resolution_Correct()
}
[Test]
- [Category("RequiresFFprobe")]
public void VideoInfo_FramesPerSecond_Correct()
{
using (var file = TempFile.FromResource(Resources.tiny, ".wmv"))
@@ -77,7 +100,6 @@ public void VideoInfo_FramesPerSecond_Correct()
}
[Test]
- [Category("RequiresFFprobe")]
public void AudioInfo_Duration_Correct()
{
using (var file = TempFile.FromResource(Resources.finished, ".wav"))
@@ -89,7 +111,6 @@ public void AudioInfo_Duration_Correct()
[Test]
- [Category("RequiresFFprobe")]
public void AudioInfo_SampleFrequency_Correct()
{
using (var file = TempFile.FromResource(Resources.finished, ".wav"))
@@ -100,7 +121,6 @@ public void AudioInfo_SampleFrequency_Correct()
}
[Test]
- [Category("RequiresFFprobe")]
public void AudioInfo_Channels_Correct()
{
using (var file = TempFile.FromResource(Resources.finished, ".wav"))
@@ -111,7 +131,6 @@ public void AudioInfo_Channels_Correct()
}
[Test]
- [Category("RequiresFFprobe")]
public void AudioInfo_BitDepth_Correct()
{
using (var file = TempFile.FromResource(Resources.finished, ".wav"))
@@ -122,7 +141,6 @@ public void AudioInfo_BitDepth_Correct()
}
[Test]
- [Category("RequiresFFprobe")]
public void AudioInfo_H4N24BitStereoBitDepth_Correct()
{
using (var file = TempFile.FromResource(Resources._24bitH4NSample, ".wav"))
@@ -133,14 +151,12 @@ public void AudioInfo_H4N24BitStereoBitDepth_Correct()
}
[Test]
- [Category("RequiresFFprobe")]
public void GetMediaInfo_AudioFile_VideoInfoAndImageInfoAreNull()
{
using (var file = TempFile.FromResource(Resources.finished,".wav"))
{
var info =MediaInfo.GetInfo(file.Path);
Assert.IsNull(info.Video);
- //Assert.IsNull(info.Image);
}
}
diff --git a/SIL.Media/FFmpegRunner.cs b/SIL.Media/FFmpegRunner.cs
index f29b25c38..05cddf928 100644
--- a/SIL.Media/FFmpegRunner.cs
+++ b/SIL.Media/FFmpegRunner.cs
@@ -1,13 +1,16 @@
using System;
-using System.Collections.Generic;
-using System.Diagnostics;
using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
using JetBrains.Annotations;
using SIL.CommandLineProcessing;
using SIL.IO;
using SIL.PlatformUtilities;
using SIL.Progress;
+using static System.Environment;
+using static System.IO.Path;
using static System.String;
+using Version = System.Version;
namespace SIL.Media
{
@@ -18,15 +21,41 @@ namespace SIL.Media
/// maybe figure out how to use that to provide progress reporting, but it's not clear
/// that it would be worth it.
///
- public class FFmpegRunner
+ public static class FFmpegRunner
{
+ internal const string kLinuxBinFolder = "/usr/bin";
+ internal const string kFFmpeg = "ffmpeg";
private const string kFFmpegExe = "ffmpeg.exe";
- private const string mp3LameCodecArg = "-acodec libmp3lame";
+ private const string kMp3LameCodecArg = "-acodec libmp3lame";
///
- /// If your app knows where FFmpeg lives, you can tell us before making any calls.
+ /// If your app knows where FFmpeg lives, set this before making any calls.
+ /// Unless the exe is on the system path, this is the full path to the executable,
+ /// including the executable file itself.
///
public static string FFmpegLocation;
+ private static bool s_locationSetByClient = true;
+
+ ///
+ /// If your app has a known minimum version of FFMpeg tools that it will work with, you can
+ /// set this to prevent this library from attempting to use a version that is not going to
+ /// meet your requirements. This will be ignored if you set FFmpegLocation, if the ffmpeg
+ /// installation is based on a Linux package dependency, or if FFmpeg is colocated with the
+ /// applications, since it seems safe to assume that you would not specify or install a
+ /// version that does not satisfy your needs. Note that this also applies to FFprobe (used
+ /// in MediaInfo).
+ ///
+ public static Version MinimumVersion
+ {
+ get => s_minimumVersion;
+ set
+ {
+ s_minimumVersion = value;
+ if (!s_locationSetByClient)
+ FFmpegLocation = null;
+ }
+ }
+ private static Version s_minimumVersion;
///
/// Find the path to FFmpeg, and remember it (some apps (like SayMore) call FFmpeg a lot)
@@ -34,76 +63,113 @@ public class FFmpegRunner
///
internal static string LocateAndRememberFFmpeg()
{
- if (null != FFmpegLocation) //NO! string.empty means we looked and didn't find: string.IsNullOrEmpty(s_ffmpegLocation))
+ // Do not change this to IsNullOrEmpty(FFmpegLocation) because Empty means we already
+ // looked and didn't find it.
+ if (null != FFmpegLocation)
return FFmpegLocation;
- FFmpegLocation = LocateFFmpeg() ?? Empty;
+
+ s_locationSetByClient = false;
+
+ FFmpegLocation = LocateFFmpeg();
return FFmpegLocation;
- }
- ///
- /// FFmpeg will typically be distributed with SIL software on Windows or automatically
- /// installed via package dependencies on other platforms, but if something wants to
- /// use this library and work with a version of it that the user downloaded or compiled
- /// locally, this tries to find where they put it.
- ///
- /// the path, if found, else null
- private static string LocateFFmpeg()
- {
- if (Platform.IsLinux)
+ // This returns the exe path if found; otherwise Empty.
+ static string LocateFFmpeg()
{
- //on linux, we can safely assume the package has included the needed dependency
- if (File.Exists("/usr/bin/ffmpeg"))
- return "/usr/bin/ffmpeg";
- if (File.Exists("/usr/bin/avconv"))
- return "/usr/bin/avconv"; // the new name of ffmpeg on Linux
-
- return null;
- }
+ if (Platform.IsLinux)
+ {
+ // On Linux, we can assume the package has included the needed dependency.
+ var convExePath = Combine(kLinuxBinFolder, kFFmpeg);
+ if (File.Exists(convExePath))
+ return convExePath;
+ // Try avconv, the new name of ffmpeg on Linux
+ convExePath = Combine(kLinuxBinFolder, "avconv");
+ return File.Exists(convExePath) ? convExePath : Empty;
+ }
- string withApplicationDirectory = GetPathToBundledFFmpeg();
+ // On Windows FFmpeg will typically be distributed with the SIL software that
+ // accompanies the SIL.Media DLL.
+ var withApplicationDirectory = GetPathToBundledFFmpeg();
+ if (withApplicationDirectory != null && File.Exists(withApplicationDirectory))
+ return withApplicationDirectory;
- if (withApplicationDirectory != null && File.Exists(withApplicationDirectory))
- return withApplicationDirectory;
+ // Failing that, if a program wants to use this library and work with a version of
+ // it that the user downloaded or compiled locally, this logic tries to find it.
+ var fromChoco = GetFFmpegFolderFromChocoInstall(kFFmpegExe);
+ if (fromChoco != null)
+ {
+ var pathToFFmpeg = Combine(fromChoco, kFFmpegExe);
+ return pathToFFmpeg;
+ }
- var fromChoco = MediaInfo.GetFFmpegFolderFromChocoInstall(kFFmpegExe);
- if (fromChoco != null)
- return Path.Combine(fromChoco, kFFmpegExe);
+ // Try to just run ffmpeg from the path, if it works then we can use that directly.
+ if (MeetsMinimumVersionRequirement(kFFmpeg))
+ return kFFmpeg;
+
+ // REVIEW: I just followed the instructions in the current version of Audacity for
+ // installing FFmpeg for Audacity and the result is a folder that does not contain
+ // ffmpeg.exe. This maybe used to work, but I don't think we'll ever find ffmpeg this
+ // way now.
+ // https://support.audacityteam.org/basics/installing-ffmpeg
+ return new[] {
+ GetFolderPath(SpecialFolder.ProgramFiles),
+ GetFolderPath(SpecialFolder.ProgramFilesX86) }
+ .Select(path => Combine(path, "FFmpeg for Audacity", kFFmpegExe))
+ .FirstOrDefault(exePath => File.Exists(exePath) &&
+ MeetsMinimumVersionRequirement(exePath)) ?? Empty;
+ }
+ }
- var progFileDirs = new List {
- Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
- Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)
- };
+ internal static bool MeetsMinimumVersionRequirement(string exe)
+ {
+ try
+ {
+ var version = new Regex(GetFileNameWithoutExtension(exe).ToLowerInvariant() +
+ @" version (?\d+\.\d+(\.\d+)?)");
+ var results = CommandLineRunner.Run(exe, "-version", ".", 5, new NullProgress());
+ var match = version.Match(results.StandardOutput);
+ if (!match.Success)
+ return false;
+ if (MinimumVersion == null)
+ return true;
+ var actualVersion = Version.Parse(match.Groups["version"].Value);
+ actualVersion = new Version(actualVersion.Major, actualVersion.Minor,
+ actualVersion.Build >= 0 ? actualVersion.Build : 0,
+ actualVersion.Revision >= 0 ? actualVersion.Revision : 0);
+ return actualVersion >= MinimumVersion;
+ }
+ catch
+ {
+ return false;
+ }
+ }
- /* We DON'T SUPPORT THIS ONE (it lacks some information on the output, at least as of
- * July 2010)
- //from http://www.arachneweb.co.uk/software/windows/avchdview/ffmpeg.html
- foreach (var path in progFileDirs)
+ internal static string GetFFmpegFolderFromChocoInstall(string exeNeeded)
+ {
+ try
{
- var exePath = (Path.Combine(path, "ffmpeg/win32-static/bin/ffmpeg.exe"));
- if(File.Exists(exePath))
- return exePath;
+ var programData = GetFolderPath(SpecialFolder
+ .CommonApplicationData);
+
+ var folder = Combine(programData, "chocolatey", "lib", kFFmpeg, "tools",
+ kFFmpeg, "bin");
+ var pathToExe = Combine(folder, exeNeeded);
+ if (!File.Exists(pathToExe)|| !MeetsMinimumVersionRequirement(pathToExe))
+ folder = null;
+ return folder;
}
- */
-
- // REVIEW: I just followed the instructions in the current version of Audacity for
- // installing FFmpeg for Audacity and the result is a folder that does not contain
- // ffmpeg.exe. This maybe used to work, but I don't think we'll ever find ffmpeg this
- // way now.
- //http://manual.audacityteam.org/index.php?title=FAQ:Installation_and_Plug-Ins#installffmpeg
- foreach (var path in progFileDirs)
+ catch (Exception e)
{
- var exePath = (Path.Combine(path, "FFmpeg for Audacity", kFFmpegExe));
- if (File.Exists(exePath))
- return exePath;
+ Console.WriteLine(e);
+ return null;
}
- return null;
}
private static string GetPathToBundledFFmpeg()
{
try
{
- return FileLocationUtilities.GetFileDistributedWithApplication("ffmpeg", kFFmpegExe);
+ return FileLocationUtilities.GetFileDistributedWithApplication(kFFmpeg, kFFmpegExe);
}
catch (Exception)
{
@@ -114,43 +180,11 @@ private static string GetPathToBundledFFmpeg()
///
/// Returns false if it can't find ffmpeg
///
- public static bool HaveNecessaryComponents => LocateFFmpeg() != null;
+ public static bool HaveNecessaryComponents => LocateAndRememberFFmpeg() != Empty;
private static ExecutionResult NoFFmpeg =>
new ExecutionResult { StandardError = "Could not locate FFmpeg" };
- ///
- /// Returns false if it can't find ffmpeg
- ///
- private static bool HaveValidFFmpegOnPath
- {
- get
- {
- if (Platform.IsWindows)
- {
- if (LocateFFmpeg() != null)
- return true;
- }
-
- //see if there is one on the %path%
-
- var p = new Process();
- p.StartInfo.FileName = "ffmpeg";
- p.StartInfo.RedirectStandardError = true;
- p.StartInfo.UseShellExecute = false;
- p.StartInfo.CreateNoWindow = true;
- try
- {
- p.Start();
- }
- catch (Exception)
- {
- return false;
- }
- return true;
- }
- }
-
///
/// Extracts the audio from a video. Note, it will fail if the file exists, so the client
/// is responsible for verifying with the user and deleting the file before calling this.
@@ -162,13 +196,13 @@ private static bool HaveValidFFmpegOnPath
/// log of the run
public static ExecutionResult ExtractMp3Audio(string inputPath, string outputPath, int channels, IProgress progress)
{
- if (LocateFFmpeg() == null)
+ if (LocateAndRememberFFmpeg() == null)
return NoFFmpeg;
- var arguments = $"-i \"{inputPath}\" -vn {mp3LameCodecArg} -ac {channels} \"{outputPath}\"";
+ var arguments = $"-i \"{inputPath}\" -vn {kMp3LameCodecArg} -ac {channels} \"{outputPath}\"";
var result = RunFFmpeg(arguments, progress);
- //hide a meaningless error produced by some versions of liblame
+ // Hide a meaningless error produced by some versions of liblame
if (result.StandardError.Contains("lame: output buffer too small")
&& File.Exists(outputPath))
{
@@ -197,13 +231,13 @@ public static ExecutionResult ExtractMp3Audio(string inputPath, string outputPat
/// log of the run
public static ExecutionResult ExtractOggAudio(string inputPath, string outputPath, int channels, IProgress progress)
{
- if (LocateFFmpeg() == null)
+ if (LocateAndRememberFFmpeg() == null)
return NoFFmpeg;
var arguments = $"-i \"{inputPath}\" -vn -acodec vorbis -ac {channels} \"{outputPath}\"";
var result = RunFFmpeg(arguments, progress);
- //hide a meaningless error produced by some versions of liblame
+ // Hide a meaningless error produced by some versions of liblame
if (result.StandardError.Contains("lame: output buffer too small")
&& File.Exists(outputPath))
{
@@ -278,7 +312,7 @@ public static ExecutionResult ExtractPcmAudio(string inputPath, string outputPat
private static ExecutionResult ExtractAudio(string inputPath, string outputPath,
string audioCodec, int sampleRate, int channels, IProgress progress)
{
- if (LocateFFmpeg() == null)
+ if (LocateAndRememberFFmpeg() == null)
return NoFFmpeg;
var sampleRateArg = "";
@@ -294,7 +328,7 @@ private static ExecutionResult ExtractAudio(string inputPath, string outputPath,
var result = RunFFmpeg(arguments, progress);
- //hide a meaningless error produced by some versions of liblame
+ // Hide a meaningless error produced by some versions of liblame
if (result.StandardError.Contains("lame: output buffer too small")
&& File.Exists(outputPath))
{
@@ -321,14 +355,14 @@ private static ExecutionResult ExtractAudio(string inputPath, string outputPath,
public static ExecutionResult ChangeNumberOfAudioChannels(string inputPath,
string outputPath, int channels, IProgress progress)
{
- if (LocateFFmpeg() == null)
+ if (LocateAndRememberFFmpeg() == null)
return NoFFmpeg;
var arguments = $"-i \"{inputPath}\" -vn -ac {channels} \"{outputPath}\"";
var result = RunFFmpeg(arguments, progress);
- //hide a meaningless error produced by some versions of liblame
+ // Hide a meaningless error produced by some versions of liblame
if (result.StandardError.Contains("lame: output buffer too small") && File.Exists(outputPath))
{
var doctoredResult = new ExecutionResult
@@ -357,11 +391,11 @@ public static ExecutionResult MakeLowQualityCompressedAudio(string inputPath, st
if (IsNullOrEmpty(LocateAndRememberFFmpeg()))
return NoFFmpeg;
- var arguments = $"-i \"{inputPath}\" {mp3LameCodecArg} -ac 1 -ar 8000 \"{outputPath}\"";
+ var arguments = $"-i \"{inputPath}\" {kMp3LameCodecArg} -ac 1 -ar 8000 \"{outputPath}\"";
var result = RunFFmpeg(arguments, progress);
- //hide a meaningless error produced by some versions of liblame
+ // Hide a meaningless error produced by some versions of liblame
if (result.StandardError.Contains("lame: output buffer too small")
&& File.Exists(outputPath))
{
@@ -397,14 +431,14 @@ public static ExecutionResult MakeLowQualitySmallVideo(string inputPath, string
// isn't working: var arguments = "-i \"" + inputPath + "\" -vcodec mpeg4 -s 160x120 -b 800 -acodec libmp3lame -ar 22050 -ab 32k -ac 1 \"" + outputPath + "\"";
var arguments = $"-i \"{inputPath}\" -vcodec mpeg4 -s 160x120 -b 800 " +
- $"{mp3LameCodecArg} -ar 22050 -ab 32k -ac 1 ";
+ $"{kMp3LameCodecArg} -ar 22050 -ab 32k -ac 1 ";
if (maxSeconds > 0)
arguments += $" -t {maxSeconds} ";
arguments += $"\"{outputPath}\"";
var result = RunFFmpeg(arguments, progress);
- //hide a meaningless error produced by some versions of liblame
+ // Hide a meaningless error produced by some versions of liblame
if (result.StandardError.Contains("lame: output buffer too small")
&& File.Exists(outputPath))
{
@@ -448,11 +482,11 @@ public static ExecutionResult MakeLowQualitySmallPicture(string inputPath, strin
private static ExecutionResult RunFFmpeg(string arguments, IProgress progress)
{
- progress.WriteMessage("ffmpeg " + arguments);
+ progress.WriteMessage($"{GetFileNameWithoutExtension(LocateAndRememberFFmpeg())} {arguments}");
const int timeout = 600; // 60 * 10 = 10 minutes
var result = CommandLineRunner.Run(LocateAndRememberFFmpeg(), arguments,
- Environment.CurrentDirectory, timeout, progress);
+ CurrentDirectory, timeout, progress);
progress.WriteVerbose(result.StandardOutput);
diff --git a/SIL.Media/MediaInfo.cs b/SIL.Media/MediaInfo.cs
index fff240eec..a2ca74e2b 100644
--- a/SIL.Media/MediaInfo.cs
+++ b/SIL.Media/MediaInfo.cs
@@ -8,6 +8,7 @@
using SIL.Reporting;
using static System.String;
using static SIL.IO.FileLocationUtilities;
+using static SIL.Media.FFmpegRunner;
namespace SIL.Media
{
@@ -16,14 +17,16 @@ namespace SIL.Media
///
public class MediaInfo
{
- private static string FFprobeExe => Platform.IsWindows ? "ffprobe.exe" : "ffprobe";
+ private const string kFFprobe = "ffprobe";
+ private static string FFprobeExe => Platform.IsWindows ? "ffprobe.exe" : kFFprobe;
private static string s_ffProbeFolder;
+ private static bool s_foundFFProbeOnPath;
///
/// Returns false if it can't find FFprobe
///
- public static bool HaveNecessaryComponents => !IsNullOrEmpty(LocateAndRememberFFprobe());
+ public static bool HaveNecessaryComponents => !IsNullOrEmpty(LocateAndRememberFFprobe()) || s_foundFFProbeOnPath;
///
/// The folder where FFprobe should be found. An application can use this to set
@@ -32,20 +35,26 @@ public class MediaInfo
/// for other purposes, this folder will also be used for those calls.
///
/// Folder does not exist
- /// Folder does not contain the ffprobe
+ /// Folder does not contain the FFprobe
/// executable
/// FFMpegCore failed to set the requested
/// FFprobe folder.
- /// If set to , indicates to this library that
- /// FFprobe is not installed, and therefore methods to retrieve media info will
- /// fail unconditionally (i.e. they will throw an exception)
+ /// if FFprobe is not installed or is on the path.
+ /// If this returns , clients can use
+ /// to know whether FFprobe was found on the system
+ /// path. If not, attempts to retrieve media info will fail unconditionally (i.e. they will
+ /// throw an exception)
[PublicAPI]
public static string FFprobeFolder
{
- get => s_ffProbeFolder;
+ get => LocateAndRememberFFprobe();
set
{
- if (value != Empty)
+ if (IsNullOrEmpty(value))
+ {
+ s_foundFFProbeOnPath = MeetsMinimumVersionRequirement(FFprobeExe);
+ }
+ else
{
if (!Directory.Exists(value))
throw new DirectoryNotFoundException("Directory not found: " + value);
@@ -77,73 +86,74 @@ public static string FFprobeFolder
///
private static string LocateAndRememberFFprobe()
{
- if (null != FFprobeFolder)
- return FFprobeFolder;
+ if (null != s_ffProbeFolder)
+ return s_ffProbeFolder;
try
{
- FFprobeFolder = GetPresumedFFprobeFolder();
+ s_ffProbeFolder = GetPresumedFFprobeFolder();
+ return s_ffProbeFolder;
}
catch (Exception e)
{
Console.WriteLine(e);
return null;
}
- return FFprobeFolder;
- }
-
- ///
- /// On Windows, FFprobe will typically be distributed with SIL software, but if something
- /// wants to use this library and work with a version of it that the user downloaded or
- /// compiled locally, this tries to find where they put it. On other platforms, ffprobe
- /// should be installed in the standard location by the package manager.
- ///
- /// A folder where FFprobe can be found; else
- private static string GetPresumedFFprobeFolder()
- {
- var folder = GlobalFFOptions.Current.BinaryFolder;
- // On Linux, we assume the package has included the needed dependency.
- // ENHANCE: Make it possible to find in a non-default location
- if (!Platform.IsWindows)
+ // On Windows, FFprobe will typically be distributed with SIL software, but if something
+ // wants to use this library and work with a version of it that the user downloaded or
+ // compiled locally, this tries to find where they put it. On other platforms, FFprobe
+ // should be installed in the standard location by the package manager.
+ // Returns a folder where FFprobe can be found; else Empty.
+ static string GetPresumedFFprobeFolder()
{
- if (IsNullOrEmpty(folder) && File.Exists(Path.Combine("/usr/bin", FFprobeExe)))
- folder = "/usr/bin";
- }
- else
- {
- if (IsNullOrEmpty(folder) || !File.Exists(Path.Combine(folder, FFprobeExe)))
- {
- var withApplicationDirectory =
- GetFileDistributedWithApplication(true, "ffmpeg", FFprobeExe) ??
- GetFileDistributedWithApplication(true, "ffprobe", FFprobeExe);
+ var folder = GlobalFFOptions.Current.BinaryFolder;
- folder = (!IsNullOrEmpty(withApplicationDirectory) ?
- Path.GetDirectoryName(withApplicationDirectory) :
- GetFFmpegFolderFromChocoInstall(FFprobeExe)) ?? Empty;
+ // On Linux, we assume the package has included the needed dependency.
+ // ENHANCE: Make it possible to find in a non-default location
+ if (!Platform.IsWindows)
+ {
+ if (IsNullOrEmpty(folder) &&
+ File.Exists(Path.Combine(kLinuxBinFolder, FFprobeExe)))
+ folder = kLinuxBinFolder;
}
- }
+ else
+ {
+ if (IsNullOrEmpty(folder) || !File.Exists(Path.Combine(folder, FFprobeExe)))
+ {
+ // If the application explicitly set FFmpegLocation or we can find it
+ // by the normal logic, then use the ffProbe found in that same folder.
+ if (LocateAndRememberFFmpeg() != null)
+ {
+ folder = Path.GetDirectoryName(FFmpegLocation);
+ if (!IsNullOrEmpty(folder) &&
+ File.Exists(Path.Combine(folder, FFprobeExe)))
+ return folder;
+ }
- return folder;
- }
+ // Most of this logic is likely redundant since LocateAndRememberFFmpeg should
+ // have already checked these places, but there's a faint chance that FFprobe
+ // is there even is FFmpeg is not.
+ var withApplicationDirectory =
+ GetFileDistributedWithApplication(true, kFFmpeg, FFprobeExe) ??
+ GetFileDistributedWithApplication(true, kFFprobe, FFprobeExe);
- internal static string GetFFmpegFolderFromChocoInstall(string exeNeeded)
- {
- try
- {
- var programData = Environment.GetFolderPath(Environment.SpecialFolder
- .CommonApplicationData);
+ folder = (!IsNullOrEmpty(withApplicationDirectory) ?
+ Path.GetDirectoryName(withApplicationDirectory) :
+ GetFFmpegFolderFromChocoInstall(FFprobeExe)) ?? Empty;
+
+ if (folder == Empty)
+ {
+ if (MeetsMinimumVersionRequirement(FFprobeExe))
+ {
+ s_foundFFProbeOnPath = true;
+ return Empty;
+ }
+ }
+ }
+ }
- var folder = Path.Combine(programData, "chocolatey", "lib", "ffmpeg", "tools",
- "ffmpeg", "bin");
- if (!File.Exists(Path.Combine(folder, exeNeeded)))
- folder = null;
return folder;
}
- catch (Exception e)
- {
- Console.WriteLine(e);
- return null;
- }
}
[PublicAPI]
@@ -240,8 +250,8 @@ internal AudioInfo(IMediaAnalysis mediaAnalysis)
///
/// Note that since a media file might contain multiple audio and video tracks
- /// and they might start and stop at different times, the total duration reported
- /// by could be greater than this duration.
+ /// which might start and stop at different times, the total duration reported
+ /// by could be greater than this duration.
///
[PublicAPI]
public TimeSpan Duration { get; }
@@ -269,8 +279,8 @@ internal VideoInfo(IMediaAnalysis mediaAnalysis)
///
/// Note that since a media file might contain multiple audio and video tracks
- /// and they might start and stop at different times, the total duration reported
- /// by could be greater than this duration.
+ /// which might start and stop at different times, the total duration reported
+ /// by could be greater than this duration.
///
[PublicAPI]
public TimeSpan Duration { get; }
diff --git a/SIL.Media/Naudio/AudioPlayer.cs b/SIL.Media/Naudio/AudioPlayer.cs
index 46cdf41c1..c6d325b75 100644
--- a/SIL.Media/Naudio/AudioPlayer.cs
+++ b/SIL.Media/Naudio/AudioPlayer.cs
@@ -1,5 +1,6 @@
-using System;
+using System;
using System.Timers;
+using JetBrains.Annotations;
using NAudio.Wave;
namespace SIL.Media.Naudio
@@ -26,6 +27,7 @@ public AudioPlayer()
};
}
+ [PublicAPI]
public void LoadStream(WaveStream stream)
{
InternalLoad(stream as TrimWaveStream ?? new TrimWaveStream(stream));
@@ -82,22 +84,19 @@ public void Stop()
public TimeSpan StartPosition
{
- get { return _inStream.StartPosition; }
- set { _inStream.StartPosition = value; }
+ get => _inStream.StartPosition;
+ set => _inStream.StartPosition = value;
}
public TimeSpan EndPosition
{
- get { return _inStream.EndPosition; }
- set { _inStream.EndPosition = value; }
+ get => _inStream.EndPosition;
+ set => _inStream.EndPosition = value;
}
public TimeSpan CurrentPosition { get; set; }
- public PlaybackState PlaybackState
- {
- get { return _waveOut == null ? PlaybackState.Stopped : _waveOut.PlaybackState; }
- }
+ public PlaybackState PlaybackState => _waveOut?.PlaybackState ?? PlaybackState.Stopped;
public void Dispose()
{
diff --git a/SIL.Media/WindowsAudioSession.cs b/SIL.Media/WindowsAudioSession.cs
index b764f0238..22d4df603 100644
--- a/SIL.Media/WindowsAudioSession.cs
+++ b/SIL.Media/WindowsAudioSession.cs
@@ -17,7 +17,7 @@ namespace SIL.Media
internal class WindowsAudioSession : ISimpleAudioSession, ISimpleAudioWithEvents
{
private readonly IrrKlang.IAudioRecorder _recorder;
- private readonly ISoundEngine _engine = new ISoundEngine();
+ private readonly ISoundEngine _engine = CreateSoundEngine();
private bool _thinkWeAreRecording;
private DateTime _startRecordingTime;
private DateTime _stopRecordingTime;
@@ -29,6 +29,28 @@ internal class WindowsAudioSession : ISimpleAudioSession, ISimpleAudioWithEvents
///
public event EventHandler PlaybackStopped;
+ private static ISoundEngine CreateSoundEngine()
+ {
+ try
+ {
+ // By default, try to auto-detect the sound driver. Normally on an end-user
+ // computer this will succeed, but even though we're trying to use irrKlang
+ // for recording only, if there's no default audio output device, it will
+ // fail.
+ return new ISoundEngine();
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine(e);
+ // As a fallback, we'll try to create the engine with a null output driver.
+ // This should succeed even if there's no default audio output device, and
+ // it gets a bunch of tests to pass in that scenario, but I'm not 100% sure
+ // it will work for actual recording in all situations as it might rely on
+ // it for timing or internal buffer synchronization, even when only recording.
+ return new ISoundEngine(SoundOutputDriver.NullDriver);
+ }
+ }
+
///
/// Constructor for an AudioSession using the IrrKlang library
///
@@ -113,7 +135,8 @@ private void OnPlaybackStopped(object sender, StoppedEventArgs args)
/// The current version of Play uses NAudio for playback. IrrKlang had issues with playback.
/// In the future it may be best to try the latest version of IrrKlang and see if true safe
/// cross-platform recording and playback can be accomplished now. This would eliminate the need for
- /// the AlsaAudio classes on linux.
+ /// the AlsaAudio classes on linux. Note: irrKlang was upgraded to v. 1.6 in Nov 2024, but I did
+ /// not re-check to see if it works for playback on all platforms.
///
public void Play()
{
diff --git a/SIL.Media/lib/win-x64/ikpFlac.dll b/SIL.Media/lib/win-x64/ikpFlac.dll
index abfed4f1c..2271a3fae 100644
Binary files a/SIL.Media/lib/win-x64/ikpFlac.dll and b/SIL.Media/lib/win-x64/ikpFlac.dll differ
diff --git a/SIL.Media/lib/win-x64/ikpMP3.dll b/SIL.Media/lib/win-x64/ikpMP3.dll
index 2f46856ec..b9134000c 100644
Binary files a/SIL.Media/lib/win-x64/ikpMP3.dll and b/SIL.Media/lib/win-x64/ikpMP3.dll differ
diff --git a/SIL.Media/lib/win-x64/irrKlang.NET4.dll b/SIL.Media/lib/win-x64/irrKlang.NET4.dll
index e32b982d3..ef53ee589 100644
Binary files a/SIL.Media/lib/win-x64/irrKlang.NET4.dll and b/SIL.Media/lib/win-x64/irrKlang.NET4.dll differ
diff --git a/SIL.Media/lib/win-x86/ikpFlac.dll b/SIL.Media/lib/win-x86/ikpFlac.dll
index 335e8b06d..b3cdcc550 100644
Binary files a/SIL.Media/lib/win-x86/ikpFlac.dll and b/SIL.Media/lib/win-x86/ikpFlac.dll differ
diff --git a/SIL.Media/lib/win-x86/ikpMP3.dll b/SIL.Media/lib/win-x86/ikpMP3.dll
index 974b8929b..b2e3ec342 100644
Binary files a/SIL.Media/lib/win-x86/ikpMP3.dll and b/SIL.Media/lib/win-x86/ikpMP3.dll differ
diff --git a/SIL.Media/lib/win-x86/irrKlang.NET4.dll b/SIL.Media/lib/win-x86/irrKlang.NET4.dll
index ca182caef..97131be4d 100644
Binary files a/SIL.Media/lib/win-x86/irrKlang.NET4.dll and b/SIL.Media/lib/win-x86/irrKlang.NET4.dll differ
diff --git a/appveyor.yml b/appveyor.yml
index 3587e5062..6f297c7af 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -10,6 +10,7 @@ init:
set IGNORE_NORMALISATION_GIT_HEAD_MOVE=1
install:
- choco install gitversion.portable --version 5.11.1 -y
+ - choco install ffmpeg -y
nuget:
disable_publish_on_pr: true
disable_publish_octopus: true
@@ -32,6 +33,8 @@ test:
categories:
except:
- SkipOnTeamCity
+ - RequiresAudioOutputDevice
+ - RequiresAudioInputDevice
artifacts:
- path: output/*nupkg
name: nuget