using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Xml.XPath; namespace FDK { /// /// The LoudnessMetadataScanner plays two roles: /// 1. Scanning of song audio files using BS1770GAIN (http://bs1770gain.sourceforge.net/) /// to determine their perceived loudness. Running on a background thread while not /// in song gameplay, songs without existing loudness metadata files (e.g. *.bs1770gain.xml) /// have their perceived loudness determined and saved into an associated metadata file /// without modifying the original audio file. This scanning process begins running /// with scanning jobs ordered based on the order in which songs are enumerated when /// the application starts, but shifts to prioritize songs which are browsed and previewed /// while on the song select screen. /// 2. Loading of loudness metadata from the BS1770GAIN metadata file alongside each audio file. /// This occurs when parsing .tja files, when song preview begins, and when song playback /// begins. When no file is available on disk, a scanning job is passed to the background /// scanning thread for processing. The loaded metadata is then passed into the /// SongGainController for combination with a configured target loudness, resulting in a /// gain value assigned to the sound object just before playback begins. /// public static class LoudnessMetadataScanner { private const string Bs1770GainExeFileName = "bs1770gain.exe"; private static readonly Stack Jobs = new Stack(); private static readonly object LockObject = new object(); private static readonly Queue RecentFileScanDurations = new Queue(); private static Thread ScanningThread; private static Semaphore Semaphore; public static void StartBackgroundScanning() { var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(StartBackgroundScanning)}"; if (!IsBs1770GainAvailable()) { Trace.TraceInformation($"{tracePrefix}: BS1770GAIN is not available. A background scanning thread will not be started."); return; } Trace.TraceInformation($"{tracePrefix}: BS1770GAIN is available. Starting background scanning thread..."); lock (LockObject) { Semaphore = new Semaphore(Jobs.Count, int.MaxValue); ScanningThread = new Thread(Scan) { IsBackground = true, Name = "LoudnessMetadataScanner background scanning thread.", Priority = ThreadPriority.Lowest }; ScanningThread.Start(); } Trace.TraceInformation($"{tracePrefix}: Background scanning thread started."); } public static void StopBackgroundScanning(bool joinImmediately) { var scanningThread = ScanningThread; if (scanningThread == null) { return; } var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(StopBackgroundScanning)}"; Trace.TraceInformation($"{tracePrefix}: Stopping background scanning thread..."); lock (LockObject) { ScanningThread = null; Semaphore.Release(); Semaphore = null; } if (joinImmediately) { scanningThread.Join(); } Trace.TraceInformation($"{tracePrefix}: Background scanning thread stopped."); } public static LoudnessMetadata? LoadForAudioPath(string absoluteBgmPath) { try { var loudnessMetadataPath = GetLoudnessMetadataPath(absoluteBgmPath); if (File.Exists(loudnessMetadataPath)) { return LoadFromMetadataPath(loudnessMetadataPath); } SubmitForBackgroundScanning(absoluteBgmPath); } catch (Exception e) { var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(LoadForAudioPath)}"; Trace.TraceError($"{tracePrefix}: Encountered an exception while attempting to load {absoluteBgmPath}"); Trace.TraceError(e.ToString()); } return null; } private static string GetLoudnessMetadataPath(string absoluteBgmPath) { return Path.Combine( Path.GetDirectoryName(absoluteBgmPath), Path.GetFileNameWithoutExtension(absoluteBgmPath) + ".bs1770gain.xml"); } private static LoudnessMetadata? LoadFromMetadataPath(string loudnessMetadataPath) { XPathDocument xPathDocument; try { xPathDocument = new XPathDocument(loudnessMetadataPath); } catch (IOException) { var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(LoadFromMetadataPath)}"; Trace.TraceWarning($"{tracePrefix}: Encountered IOException while attempting to read {loudnessMetadataPath}. This can occur when attempting to load while scanning the same file. Returning null..."); return null; } var trackNavigator = xPathDocument.CreateNavigator() .SelectSingleNode(@"//bs1770gain/track[@ToTal=""1"" and @Number=""1""]"); var integratedLufsNode = trackNavigator?.SelectSingleNode(@"integrated/@lufs"); var truePeakTpfsNode = trackNavigator?.SelectSingleNode(@"true-peak/@tpfs"); if (trackNavigator == null || integratedLufsNode == null || truePeakTpfsNode == null) { var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(LoadFromMetadataPath)}"; Trace.TraceWarning($"{tracePrefix}: Encountered incorrect xml element structure while parsing {loudnessMetadataPath}. Returning null..."); return null; } var integrated = integratedLufsNode.ValueAsDouble; var truePeak = truePeakTpfsNode.ValueAsDouble; if (integrated <= -70.0 || truePeak >= 12.04) { var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(LoadFromMetadataPath)}"; Trace.TraceWarning($"{tracePrefix}: Encountered evidence of extreme clipping while parsing {loudnessMetadataPath}. Returning null..."); return null; } return new LoudnessMetadata(new Lufs(integrated), new Lufs(truePeak)); } private static void SubmitForBackgroundScanning(string absoluteBgmPath) { lock (LockObject) { // Quite often, the loading process will cause the same job to be submitted many times. // As such, we'll do a quick check as when this happens an equivalent job will often // already be at the top of the stack and we need not add it again. // // Note that we will not scan the whole stack as that is an O(n) operation on the main // thread, whereas redundant file existence checks on the background thread are not harmful. // // We also do not want to scan the whole stack, for example to skip pushing a new item onto it, // because we want to re-submit jobs as the user interacts with their data, usually by // scrolling through songs and previewing them. Their current interests should drive // scanning priorities, and it is for this reason that a stack is used instead of a queue. var semaphore = Semaphore; if (semaphore != null && (Jobs.Count == 0 || Jobs.Peek() != absoluteBgmPath)) { Jobs.Push(absoluteBgmPath); semaphore.Release(); } } } private static void Scan() { try { while (true) { RaiseScanningStateChanged(false); Semaphore?.WaitOne(); if (ScanningThread == null) { return; } RaiseScanningStateChanged(true); int jobCount; string absoluteBgmPath; lock (LockObject) { jobCount = Jobs.Count; absoluteBgmPath = Jobs.Pop(); } var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(Scan)}"; try { if (!File.Exists(absoluteBgmPath)) { Trace.TraceWarning($"{tracePrefix}: Scanning jobs outstanding: {jobCount - 1}. Missing audio file. Skipping {absoluteBgmPath}..."); continue; } var loudnessMetadataPath = GetLoudnessMetadataPath(absoluteBgmPath); if (File.Exists(loudnessMetadataPath)) { Trace.TraceWarning($"{tracePrefix}: Scanning jobs outstanding: {jobCount - 1}. Pre-existing metadata. Skipping {absoluteBgmPath}..."); continue; } Trace.TraceInformation($"{tracePrefix}: Scanning jobs outstanding: {jobCount}. Scanning {absoluteBgmPath}..."); var stopwatch = Stopwatch.StartNew(); File.Delete(loudnessMetadataPath); var arguments = $"-it --xml -f \"{Path.GetFileName(loudnessMetadataPath)}\" \"{Path.GetFileName(absoluteBgmPath)}\""; Execute(Path.GetDirectoryName(absoluteBgmPath), Bs1770GainExeFileName, arguments, true); var seconds = stopwatch.Elapsed.TotalSeconds; RecentFileScanDurations.Enqueue(seconds); while (RecentFileScanDurations.Count > 20) { RecentFileScanDurations.Dequeue(); } var averageSeconds = RecentFileScanDurations.Average(); Trace.TraceInformation($"{tracePrefix}: Scanned in {seconds}s. Estimated remaining: {(int)(averageSeconds * (jobCount - 1))}s."); } catch (Exception e) { Trace.TraceError($"{tracePrefix}: Encountered an exception while attempting to scan {absoluteBgmPath}"); Trace.TraceError(e.ToString()); } } } catch (Exception e) { var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(Scan)}"; Trace.TraceError($"{tracePrefix}: caught an exception at the level of the thread method. The background scanning thread will now terminate."); Trace.TraceError(e.ToString()); } } private static bool IsBs1770GainAvailable() { try { Execute(null, Bs1770GainExeFileName, "-h"); return true; } catch (Win32Exception) { return false; } catch (Exception e) { var tracePrefix = $"{nameof(LoudnessMetadataScanner)}.{nameof(IsBs1770GainAvailable)}"; Trace.TraceError($"{tracePrefix}: Encountered an exception. Returning false..."); Trace.TraceError(e.ToString()); return false; } } private static string Execute( string workingDirectory, string fileName, string arguments, bool shouldFailOnStdErrDataReceived = false) { var processStartInfo = new ProcessStartInfo(fileName, arguments) { CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, WorkingDirectory = workingDirectory ?? "" }; var stdoutWriter = new StringWriter(); var stderrWriter = new StringWriter(); using (var process = Process.Start(processStartInfo)) { process.OutputDataReceived += (s, e) => { if (e.Data != null) { stdoutWriter.Write(e.Data); stdoutWriter.Write(Environment.NewLine); } }; var errorDataReceived = false; process.ErrorDataReceived += (s, e) => { if (e.Data != null) { errorDataReceived = true; stderrWriter.Write(e.Data); stderrWriter.Write(Environment.NewLine); } }; process.BeginOutputReadLine(); process.BeginErrorReadLine(); process.WaitForExit(); if ((shouldFailOnStdErrDataReceived && errorDataReceived) || process.ExitCode != 0) { var stderr = stderrWriter.ToString(); if (string.IsNullOrEmpty(stderr)) { stderr = stdoutWriter.ToString(); } throw new Exception( $"Execution of {processStartInfo.FileName} with arguments {processStartInfo.Arguments} failed with exit code {process.ExitCode}: {stderr}"); } return stdoutWriter.ToString(); } } private static void RaiseScanningStateChanged(bool isActivelyScanning) { ScanningStateChanged?.Invoke(null, new ScanningStateChangedEventArgs(isActivelyScanning)); } public class ScanningStateChangedEventArgs : EventArgs { public ScanningStateChangedEventArgs(bool isActivelyScanning) { IsActivelyScanning = isActivelyScanning; } public bool IsActivelyScanning { get; private set; } } public static event EventHandler ScanningStateChanged; } }