diff --git a/QPlayer/Models/PeakFile.cs b/QPlayer/Models/PeakFile.cs new file mode 100644 index 0000000..36ea3ea --- /dev/null +++ b/QPlayer/Models/PeakFile.cs @@ -0,0 +1,387 @@ +using NAudio.Wave; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO.Compression; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using QPlayer.ViewModels; + +namespace QPlayer.Models +{ + /// + /// Stores a compact representation of the audio peaks in an audio file for faster waveform rendering. + /// + [Serializable] + public struct PeakFile + { + public const uint FILE_MAGIC = ((byte)'Q') + ((byte)'P' << 8) + ((byte)'e' << 16) + ((byte)'k' << 24); + public const int FILE_VERSION = 1; + public const string FILE_EXTENSION = ".qpek"; + + /// + /// The reduction factor of the highest resolution pyramid. + /// + public const int MIN_REDUCTION = 32; + /// + /// The number of bits to shift the reduction factor by between each pyramid. + /// + public const int REDUCTION_STEP = 1; + /// + /// The minimum number of samples in a pyramid. + /// + public const int MIN_SAMPLES = 64; + + // Metadata + public uint fileMagic; + public int fileVersion; + public long sourceFileLength; + public DateTime sourceDate; + public int sourceNameLength; + public string sourceName; + + // Peak data + public int fs; // Sample rate + public long length; // The total number of uncompressed mono samples in the source file + public PeakData[] peakDataPyramid; + + [StructLayout(LayoutKind.Explicit)] + public struct Sample + { + [FieldOffset(0)] public ushort peak; + [FieldOffset(2)] public ushort rms; + + [FieldOffset(0)] public uint data; + } + + public struct PeakData + { + // How many samples each of the actual samples each sample at this level summarises + public int reductionFactor; + public Sample[] samples; + } + } + + internal static class PeakFileReader + { + /// + /// Reads and validates the metadata portion of a . + /// + /// This method does not close the stream once complete! + /// + /// The binary stream to parse + /// A new instance parsed from the stream. + /// + public static PeakFile ReadMetadata(Stream stream) + { + var peak = new PeakFile(); + try + { + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); + + peak.fileMagic = reader.ReadUInt32(); + if (peak.fileMagic != PeakFile.FILE_MAGIC) + throw new FormatException($"Peak file is corrupt! " + + $"Peak file magic = 0x{peak.fileMagic:X8}, expected file magic = 0x{PeakFile.FILE_MAGIC:X8}"); + + peak.fileVersion = reader.ReadInt32(); + if (peak.fileVersion != PeakFile.FILE_VERSION) + throw new FormatException($"Peak file version does not match application version! " + + $"Peak file version = {peak.fileVersion}, expected version = {PeakFile.FILE_VERSION}!"); + + peak.sourceFileLength = reader.ReadInt64(); + peak.sourceDate = new DateTime(reader.ReadInt64(), DateTimeKind.Utc); + peak.sourceNameLength = reader.ReadInt32(); + Span sourceName = stackalloc char[peak.sourceNameLength]; + for (int i = 0; i < peak.sourceNameLength; i++) + sourceName[i] = reader.ReadChar(); + peak.sourceName = sourceName.ToString(); + } + catch (Exception ex) + { + throw new FormatException("Error while decoding peak file metadata!", ex); + } + + return peak; + } + + /// + /// Reads and validates from a stream. + /// + /// The binary stream to parse + /// A new instance parsed from the stream. + public static PeakFile ReadPeakFile(Stream stream) + { + var peak = ReadMetadata(stream); + + using BinaryReader reader = new(stream); + peak.fs = reader.ReadInt32(); + peak.length = reader.ReadInt64(); + + using BrotliStream br = new(stream, CompressionMode.Decompress); + using BinaryReader compressedReader = new(br); + + peak.peakDataPyramid = new PeakFile.PeakData[compressedReader.ReadInt32()]; + for (int i = 0; i < peak.peakDataPyramid.Length; i++) + { + var pyramid = new PeakFile.PeakData(); + pyramid.reductionFactor = compressedReader.ReadInt32(); + pyramid.samples = new PeakFile.Sample[compressedReader.ReadInt64()]; + for (int j = 0; j < pyramid.samples.LongLength; j++) + { + pyramid.samples[j] = new() { data = compressedReader.ReadUInt32() }; + } + peak.peakDataPyramid[i] = pyramid; + } + + return peak; + } + + /// + /// Reads and validates from a stream. + /// + /// The binary stream to parse + /// A new instance parsed from the stream. + /// + public static async Task ReadPeakFile(string path) + { + return await Task.Run(() => + { + using var f = File.OpenRead(path); + return ReadPeakFile(f); + }); + } + } + + internal static class PeakFileWriter + { + /// + /// Loads a for the given audio file if it's valid, otherwise generates a + /// new for an audio file if needed. + /// + /// The path of the audio to process + /// The loaded or generated + public static async Task LoadOrGeneratePeakFile(string path) + { + string peakFilePath = Path.ChangeExtension(path, PeakFile.FILE_EXTENSION); + // Check if we actually need to write a new peak file + if (await CheckForExistingPeakFile(path)) + { + return await Task.Run(() => + { + try + { + using var f = File.OpenRead(peakFilePath); + return PeakFileReader.ReadPeakFile(f); + } + catch (Exception ex) + { + MainViewModel.Log($"Exception encountered while reading peak file '{path}', it will be regenerated:\n" + ex, + MainViewModel.LogLevel.Warning); + } + return default; + }); + } + + PeakFile peakFile = default; + try + { + Stopwatch sw = new(); + sw.Start(); + peakFile = await Task.Run(() => GeneratePeakFile(path)); + MainViewModel.Log($"Generated peak file for '{Path.GetFileName(path)}' in {sw.Elapsed:mm\\:ss\\.fff}", MainViewModel.LogLevel.Debug); + sw.Restart(); + using var f = File.Open(peakFilePath, FileMode.Create, FileAccess.Write); + await Task.Run(() => WritePeakFile(peakFile, f)); + MainViewModel.Log($" wrote peak file for '{Path.GetFileName(path)}' in {sw.Elapsed:mm\\:ss\\.fff}", MainViewModel.LogLevel.Debug); + } + catch (Exception ex) + { + MainViewModel.Log($"Exception encountered while generating peak file for '{Path.GetFileName(path)}':\n" + ex, + MainViewModel.LogLevel.Error); + } + + return peakFile; + } + + private static async Task CheckForExistingPeakFile(string path) + { + if (!File.Exists(path)) + return false; + string peakPath = Path.ChangeExtension(path, PeakFile.FILE_EXTENSION); + if (!File.Exists(peakPath)) + return false; + + try + { + using var f = File.OpenRead(peakPath); + var peakMeta = await Task.Run(() => PeakFileReader.ReadMetadata(f)); + var fileInfo = new FileInfo(path); + + if (peakMeta.sourceName != fileInfo.Name) + throw new Exception("Peak file name doesn't match audio file name!"); + if (peakMeta.sourceDate != (fileInfo.CreationTimeUtc > fileInfo.LastWriteTimeUtc ? fileInfo.CreationTimeUtc : fileInfo.LastWriteTimeUtc)) + throw new Exception("Peak file date doesn't match audio file date!"); + if (peakMeta.sourceFileLength != fileInfo.Length) + throw new Exception("Peak file doesn't match"); + } + catch (FormatException ex) + { + MainViewModel.Log($"Exception encountered while reading peak file '{peakPath}', it will be regenerated:\n" + ex, + MainViewModel.LogLevel.Warning); + return false; + } + catch (Exception ex) + { + MainViewModel.Log($"Peak file for '{path}' was invalid, it will be regenerated:\n" + ex, + MainViewModel.LogLevel.Debug); + return false; + } + + return true; + } + + private static PeakFile GeneratePeakFile(string path) + { + var peakFile = new PeakFile(); + + // Load source file + var sourceInfo = new FileInfo(path); + using var sourceAudio = new AudioFileReader(path); + + // Generate metadata... + peakFile.fileMagic = PeakFile.FILE_MAGIC; + peakFile.fileVersion = PeakFile.FILE_VERSION; + + peakFile.sourceFileLength = sourceInfo.Length; + peakFile.sourceDate = (sourceInfo.CreationTimeUtc > sourceInfo.LastWriteTimeUtc ? sourceInfo.CreationTimeUtc : sourceInfo.LastWriteTimeUtc); + peakFile.sourceNameLength = sourceInfo.Name.Length; + peakFile.sourceName = sourceInfo.Name; + + peakFile.fs = sourceAudio.WaveFormat.SampleRate; + + // Generate peak data + List pyramids = new(); + + // Generate the peaks for the first pyramid from the audio file + float[] sourceBuffer = new float[sourceAudio.WaveFormat.Channels * 4 * PeakFile.MIN_REDUCTION]; + int samplesPerSample = sourceBuffer.Length; + PeakFile.PeakData pyramid = new(); + pyramid.reductionFactor = PeakFile.MIN_REDUCTION; + List samples = new(); + do + { + // Read a number of samples to average together + int read = 0; + do + { + int lastRead = sourceAudio.Read(sourceBuffer, read, samplesPerSample - read); + read += lastRead; + if (lastRead == 0) + break; + } while (read < samplesPerSample); + peakFile.length += read; + if (read < samplesPerSample) + break; + + // Compute the peak and rms of the samples + float max = 0; + float sqrSum = 0; + for (int i = 0; i < sourceBuffer.Length; i++) + { + float s = Math.Abs(sourceBuffer[i]); + max = Math.Max(s, max); + sqrSum += s * s; + } + ushort peak = (ushort)(max * ushort.MaxValue); + ushort rms = (ushort)(Math.Sqrt(sqrSum / sourceBuffer.Length) * ushort.MaxValue); + + samples.Add(new PeakFile.Sample() { peak = peak, rms = rms }); + } while (true); + + pyramid.samples = samples.ToArray(); + //if (samples.Count >= PeakFile.MIN_SAMPLES) + pyramids.Add(pyramid); + peakFile.length /= sourceAudio.WaveFormat.Channels; + + // Now compute all subsequant pyramids from the previous pyramid + int currReduction = PeakFile.MIN_REDUCTION << PeakFile.REDUCTION_STEP; + do + { + pyramid = new(); + samples.Clear(); + pyramid.reductionFactor = currReduction; + samplesPerSample = 1 << PeakFile.REDUCTION_STEP; + var lastPyramid = pyramids[^1].samples; + + int i = 0; + do + { + float max = 0; + float sqrSum = 0; + int y = 0; + for (y = 0; y < samplesPerSample && i < lastPyramid.Length; y++, i++) + { + float p = lastPyramid[i].peak * (1 / (float)ushort.MaxValue); + float r = lastPyramid[i].rms * (1 / (float)ushort.MaxValue); + max = Math.Max(p, max); + sqrSum += r * r; + } + ushort peak = (ushort)(max * ushort.MaxValue); + ushort rms = (ushort)(Math.Sqrt(sqrSum / y) * ushort.MaxValue); + samples.Add(new PeakFile.Sample() { peak = peak, rms = rms }); + } while (i < lastPyramid.Length); + + currReduction <<= PeakFile.REDUCTION_STEP; + pyramid.samples = samples.ToArray(); + if (pyramid.samples.Length >= PeakFile.MIN_SAMPLES) + pyramids.Add(pyramid); + else + break; + } while (true); + + peakFile.peakDataPyramid = pyramids.Reverse().ToArray(); + + return peakFile; + } + + private static void WritePeakFile(PeakFile peakFile, Stream stream) + { + using BinaryWriter writer = new(stream, Encoding.UTF8); + + writer.Write(peakFile.fileMagic); + writer.Write(peakFile.fileVersion); + + writer.Write(peakFile.sourceFileLength); + writer.Write(peakFile.sourceDate.Ticks); + writer.Write(peakFile.sourceName.Length); + writer.Write(new ReadOnlySpan(peakFile.sourceName.ToCharArray())); + + writer.Write(peakFile.fs); + writer.Write(peakFile.length); + + using (BrotliStream br = new(stream, CompressionLevel.Optimal, true)) + using (BinaryWriter compressedWriter = new(br, Encoding.UTF8, true)) + { + compressedWriter.Write(peakFile.peakDataPyramid.Length); + for (int i = 0; i < peakFile.peakDataPyramid.Length; i++) + { + var pyramid = peakFile.peakDataPyramid[i]; + compressedWriter.Write(pyramid.reductionFactor); + compressedWriter.Write(pyramid.samples.LongLength); + for (int j = 0; j < pyramid.samples.LongLength; j++) + { + var sample = pyramid.samples[j]; + compressedWriter.Write(sample.data); + } + } + } + + writer.Write("\n\nThis is a QPlayer peak file used to accelerate waveform rendering.\n" + + "https://github.com/space928/QPlayer\n\n<3"); + } + } +} diff --git a/QPlayer/QPlayer.csproj b/QPlayer/QPlayer.csproj index 4576913..89d6f70 100644 --- a/QPlayer/QPlayer.csproj +++ b/QPlayer/QPlayer.csproj @@ -6,7 +6,7 @@ enable true QPlayer - 1.5.1-beta + 1.5.2-beta Thomas Mathieson Thomas Mathieson ©️ Thomas Mathieson 2024 diff --git a/QPlayer/ViewModels/ViewModel.cs b/QPlayer/ViewModels/MainViewModel.cs similarity index 100% rename from QPlayer/ViewModels/ViewModel.cs rename to QPlayer/ViewModels/MainViewModel.cs diff --git a/QPlayer/ViewModels/SoundCueViewModel.cs b/QPlayer/ViewModels/SoundCueViewModel.cs index 12f1517..d7e9214 100644 --- a/QPlayer/ViewModels/SoundCueViewModel.cs +++ b/QPlayer/ViewModels/SoundCueViewModel.cs @@ -46,7 +46,7 @@ [Reactive] public override TimeSpan PlaybackTime public SoundCueViewModel(MainViewModel mainViewModel) : base(mainViewModel) { OpenMediaFileCommand = new(OpenMediaFileExecute); - audioProgressUpdater = new Timer(100); + audioProgressUpdater = new Timer(50); audioProgressUpdater.Elapsed += AudioProgressUpdater_Elapsed; fadeOutTimer = new Timer { @@ -72,7 +72,7 @@ public SoundCueViewModel(MainViewModel mainViewModel) : base(mainViewModel) break; } }; - waveFormRenderer = new WaveFormRenderer(); + waveFormRenderer = new WaveFormRenderer(this); } /// @@ -133,7 +133,8 @@ public override void Go() } } mainViewModel.AudioPlaybackManager.PlaySound(fadeInOutProvider, (x)=>Stop()); - fadeInOutProvider.BeginFade(1, Math.Max(FadeIn * 1000, 1000/(double)fadeInOutProvider.WaveFormat.SampleRate), FadeType); + fadeInOutProvider.Volume = 0; + fadeInOutProvider.BeginFade(Volume, Math.Max(FadeIn * 1000, 1000/(double)fadeInOutProvider.WaveFormat.SampleRate), FadeType); } public override void Pause() @@ -144,6 +145,9 @@ public override void Pause() mainViewModel.AudioPlaybackManager.StopSound(fadeInOutProvider); audioProgressUpdater.Stop(); fadeOutTimer.Stop(); + OnPropertyChanged(nameof(PlaybackTime)); + OnPropertyChanged(nameof(PlaybackTimeString)); + OnPropertyChanged(nameof(PlaybackTimeStringShort)); } public override void Stop() @@ -241,6 +245,11 @@ private void AudioProgressUpdater_Elapsed(object? sender, ElapsedEventArgs e) OnPropertyChanged(nameof(PlaybackTime)); OnPropertyChanged(nameof(PlaybackTimeString)); OnPropertyChanged(nameof(PlaybackTimeStringShort)); + + // When not using a fadeout, there's nothing to stop the sound early if it's been trimmed. + // This won't be very accurate, but should work for now... + if (PlaybackTime >= Duration) + Stop(); }, null); } @@ -306,7 +315,11 @@ private void LoadAudioFile() return await PeakFileWriter.LoadOrGeneratePeakFile(path); }).ContinueWith(x => { - waveFormRenderer.PeakFile = x.Result; + // Make sure this happens on the UI thread... + synchronizationContext?.Post(x => + { + waveFormRenderer.PeakFile = (PeakFile?)x; + }, x.Result); }); } catch (Exception ex) { diff --git a/QPlayer/ViewModels/WaveFormRenderer.cs b/QPlayer/ViewModels/WaveFormRenderer.cs index d97c39a..5dc377e 100644 --- a/QPlayer/ViewModels/WaveFormRenderer.cs +++ b/QPlayer/ViewModels/WaveFormRenderer.cs @@ -15,14 +15,15 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Media; +using QPlayer.Models; namespace QPlayer.ViewModels { public class WaveFormRenderer : ObservableObject { - [Reactive] public Drawing WaveFormDrawing => drawingGroup; - [Reactive] public PeakFile? PeakFile - { + [Reactive] + public PeakFile? PeakFile + { set { peakFile = value; @@ -32,6 +33,8 @@ [Reactive] public PeakFile? PeakFile } get => peakFile; } + [Reactive] public SoundCueViewModel SoundCueViewModel { get; init; } + [Reactive] public Drawing WaveFormDrawing => drawingGroup; [Reactive] public double Width { @@ -55,6 +58,8 @@ public TimeSpan ViewEnd set { endTime = Math.Clamp(((float)value.TotalSeconds) / ((peakFile?.length ?? 0) / (float)(peakFile?.fs ?? 1)), 0, 1); Update(); } } [Reactive] + public TimeSpan ViewSpan => TimeSpan.FromSeconds((endTime-startTime) * (peakFile?.length ?? 0) / (double)(peakFile?.fs ?? 1)); + [Reactive] public TimeSpan Duration => TimeSpan.FromSeconds((peakFile?.length ?? 0) / (double)(peakFile?.fs ?? 1)); [Reactive, ReactiveDependency(nameof(PeakFile))] public string FileName => peakFile?.sourceName ?? string.Empty; [Reactive, ReactiveDependency(nameof(PeakFile))] public string WindowTitle => $"QPlayer - Waveform - {peakFile?.sourceName ?? string.Empty}"; @@ -88,9 +93,8 @@ public TimeSpan ViewEnd private int height = 2; private float startTime = 0; private float endTime = 1; - private SynchronizationContext synchronizationContext; - public WaveFormRenderer() + public WaveFormRenderer(SoundCueViewModel soundCue) { // Accursed over-abstraction... Blame Microsoft... peakPen = new(peakBrush, 0); @@ -125,17 +129,14 @@ public WaveFormRenderer() drawingGroup.Children.Add(geometryDrawingRMS); drawingGroup.ClipGeometry = clipGeometry; + SoundCueViewModel = soundCue; + //WaveFormDrawing = new DrawingImage(drawingGroup); - synchronizationContext = SynchronizationContext.Current ?? - throw new Exception("WaveFormRenderer must be created from a thread with a valid SynchronizationContext!"); } public void Update() { - synchronizationContext.Send(x => - { - Render(); - }, null); + Render(); } private void Render() @@ -184,373 +185,4 @@ private void Render() rmsPoly.Points.Add(rmsPoints); } } - - /// - /// Stores a compact representation of the audio peaks in an audio file for faster waveform rendering. - /// - [Serializable] - public struct PeakFile - { - public const uint FILE_MAGIC = ((byte)'Q') + ((byte)'P' << 8) + ((byte)'e' << 16) + ((byte)'k' << 24); - public const int FILE_VERSION = 1; - public const string FILE_EXTENSION = ".qpek"; - - /// - /// The reduction factor of the highest resolution pyramid. - /// - public const int MIN_REDUCTION = 32; - /// - /// The number of bits to shift the reduction factor by between each pyramid. - /// - public const int REDUCTION_STEP = 1; - /// - /// The minimum number of samples in a pyramid. - /// - public const int MIN_SAMPLES = 64; - - // Metadata - public uint fileMagic; - public int fileVersion; - public long sourceFileLength; - public DateTime sourceDate; - public int sourceNameLength; - public string sourceName; - - // Peak data - public int fs; // Sample rate - public long length; // The total number of uncompressed mono samples in the source file - public PeakData[] peakDataPyramid; - - [StructLayout(LayoutKind.Explicit)] - public struct Sample - { - [FieldOffset(0)] public ushort peak; - [FieldOffset(2)] public ushort rms; - - [FieldOffset(0)] public uint data; - } - - public struct PeakData - { - // How many samples each of the actual samples each sample at this level summarises - public int reductionFactor; - public Sample[] samples; - } - } - - internal static class PeakFileReader - { - /// - /// Reads and validates the metadata portion of a . - /// - /// This method does not close the stream once complete! - /// - /// The binary stream to parse - /// A new instance parsed from the stream. - /// - public static PeakFile ReadMetadata(Stream stream) - { - var peak = new PeakFile(); - try - { - using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); - - peak.fileMagic = reader.ReadUInt32(); - if (peak.fileMagic != PeakFile.FILE_MAGIC) - throw new FormatException($"Peak file is corrupt! " + - $"Peak file magic = 0x{peak.fileMagic:X8}, expected file magic = 0x{PeakFile.FILE_MAGIC:X8}"); - - peak.fileVersion = reader.ReadInt32(); - if (peak.fileVersion != PeakFile.FILE_VERSION) - throw new FormatException($"Peak file version does not match application version! " + - $"Peak file version = {peak.fileVersion}, expected version = {PeakFile.FILE_VERSION}!"); - - peak.sourceFileLength = reader.ReadInt64(); - peak.sourceDate = new DateTime(reader.ReadInt64(), DateTimeKind.Utc); - peak.sourceNameLength = reader.ReadInt32(); - Span sourceName = stackalloc char[peak.sourceNameLength]; - for (int i = 0; i < peak.sourceNameLength; i++) - sourceName[i] = reader.ReadChar(); - peak.sourceName = sourceName.ToString(); - } catch (Exception ex) - { - throw new FormatException("Error while decoding peak file metadata!", ex); - } - - return peak; - } - - /// - /// Reads and validates from a stream. - /// - /// The binary stream to parse - /// A new instance parsed from the stream. - public static PeakFile ReadPeakFile(Stream stream) - { - var peak = ReadMetadata(stream); - - using BinaryReader reader = new(stream); - peak.fs = reader.ReadInt32(); - peak.length = reader.ReadInt64(); - - using BrotliStream br = new(stream, CompressionMode.Decompress); - using BinaryReader compressedReader = new(br); - - peak.peakDataPyramid = new PeakFile.PeakData[compressedReader.ReadInt32()]; - for (int i = 0; i < peak.peakDataPyramid.Length; i++) - { - var pyramid = new PeakFile.PeakData(); - pyramid.reductionFactor = compressedReader.ReadInt32(); - pyramid.samples = new PeakFile.Sample[compressedReader.ReadInt64()]; - for (int j = 0; j < pyramid.samples.LongLength; j++) - { - pyramid.samples[j] = new() { data = compressedReader.ReadUInt32() }; - } - peak.peakDataPyramid[i] = pyramid; - } - - return peak; - } - - /// - /// Reads and validates from a stream. - /// - /// The binary stream to parse - /// A new instance parsed from the stream. - /// - public static async Task ReadPeakFile(string path) - { - return await Task.Run(() => - { - using var f = File.OpenRead(path); - return ReadPeakFile(f); - }); - } - } - - internal static class PeakFileWriter - { - /// - /// Loads a for the given audio file if it's valid, otherwise generates a - /// new for an audio file if needed. - /// - /// The path of the audio to process - /// The loaded or generated - public static async Task LoadOrGeneratePeakFile(string path) - { - string peakFilePath = Path.ChangeExtension(path, PeakFile.FILE_EXTENSION); - // Check if we actually need to write a new peak file - if (await CheckForExistingPeakFile(path)) - { - return await Task.Run(() => - { - try - { - using var f = File.OpenRead(peakFilePath); - return PeakFileReader.ReadPeakFile(f); - } catch (Exception ex) - { - MainViewModel.Log($"Exception encountered while reading peak file '{path}', it will be regenerated:\n" + ex, - MainViewModel.LogLevel.Warning); - } - return default; - }); - } - - PeakFile peakFile = default; - try - { - Stopwatch sw = new(); - sw.Start(); - peakFile = await Task.Run(() => GeneratePeakFile(path)); - MainViewModel.Log($"Generated peak file for '{Path.GetFileName(path)}' in {sw.Elapsed:mm\\:ss\\.fff}", MainViewModel.LogLevel.Debug); - sw.Restart(); - using var f = File.Open(peakFilePath, FileMode.Create, FileAccess.Write); - await Task.Run(() => WritePeakFile(peakFile, f)); - MainViewModel.Log($" wrote peak file for '{Path.GetFileName(path)}' in {sw.Elapsed:mm\\:ss\\.fff}", MainViewModel.LogLevel.Debug); - } catch (Exception ex) - { - MainViewModel.Log($"Exception encountered while generating peak file for '{Path.GetFileName(path)}':\n" + ex, - MainViewModel.LogLevel.Error); - } - - return peakFile; - } - - private static async Task CheckForExistingPeakFile(string path) - { - if(!File.Exists(path)) - return false; - string peakPath = Path.ChangeExtension(path, PeakFile.FILE_EXTENSION); - if (!File.Exists(peakPath)) - return false; - - try - { - using var f = File.OpenRead(peakPath); - var peakMeta = await Task.Run(() => PeakFileReader.ReadMetadata(f)); - var fileInfo = new FileInfo(path); - - if (peakMeta.sourceName != fileInfo.Name) - throw new Exception("Peak file name doesn't match audio file name!"); - if (peakMeta.sourceDate != (fileInfo.CreationTimeUtc > fileInfo.LastWriteTimeUtc ? fileInfo.CreationTimeUtc : fileInfo.LastWriteTimeUtc)) - throw new Exception("Peak file date doesn't match audio file date!"); - if (peakMeta.sourceFileLength != fileInfo.Length) - throw new Exception("Peak file doesn't match"); - } catch (FormatException ex) - { - MainViewModel.Log($"Exception encountered while reading peak file '{peakPath}', it will be regenerated:\n" + ex, - MainViewModel.LogLevel.Warning); - return false; - } - catch (Exception ex) - { - MainViewModel.Log($"Peak file for '{path}' was invalid, it will be regenerated:\n" + ex, - MainViewModel.LogLevel.Debug); - return false; - } - - return true; - } - - private static PeakFile GeneratePeakFile(string path) - { - var peakFile = new PeakFile(); - - // Load source file - var sourceInfo = new FileInfo(path); - using var sourceAudio = new AudioFileReader(path); - - // Generate metadata... - peakFile.fileMagic = PeakFile.FILE_MAGIC; - peakFile.fileVersion = PeakFile.FILE_VERSION; - - peakFile.sourceFileLength = sourceInfo.Length; - peakFile.sourceDate = (sourceInfo.CreationTimeUtc > sourceInfo.LastWriteTimeUtc ? sourceInfo.CreationTimeUtc : sourceInfo.LastWriteTimeUtc); - peakFile.sourceNameLength = sourceInfo.Name.Length; - peakFile.sourceName = sourceInfo.Name; - - peakFile.fs = sourceAudio.WaveFormat.SampleRate; - - // Generate peak data - List pyramids = new(); - - // Generate the peaks for the first pyramid from the audio file - float[] sourceBuffer = new float[sourceAudio.WaveFormat.Channels * 4 * PeakFile.MIN_REDUCTION]; - int samplesPerSample = sourceBuffer.Length; - PeakFile.PeakData pyramid = new(); - pyramid.reductionFactor = PeakFile.MIN_REDUCTION; - List samples = new(); - do - { - // Read a number of samples to average together - int read = 0; - do - { - int lastRead = sourceAudio.Read(sourceBuffer, read, samplesPerSample - read); - read += lastRead; - if (lastRead == 0) - break; - } while (read < samplesPerSample); - peakFile.length += read; - if (read < samplesPerSample) - break; - - // Compute the peak and rms of the samples - float max = 0; - float sqrSum = 0; - for (int i = 0; i < sourceBuffer.Length; i++) - { - float s = Math.Abs(sourceBuffer[i]); - max = Math.Max(s, max); - sqrSum += s * s; - } - ushort peak = (ushort)(max * ushort.MaxValue); - ushort rms = (ushort)(Math.Sqrt(sqrSum / sourceBuffer.Length) * ushort.MaxValue); - - samples.Add(new PeakFile.Sample() { peak = peak, rms = rms }); - } while (true); - - pyramid.samples = samples.ToArray(); - //if (samples.Count >= PeakFile.MIN_SAMPLES) - pyramids.Add(pyramid); - peakFile.length /= sourceAudio.WaveFormat.Channels; - - // Now compute all subsequant pyramids from the previous pyramid - int currReduction = PeakFile.MIN_REDUCTION << PeakFile.REDUCTION_STEP; - do - { - pyramid = new(); - samples.Clear(); - pyramid.reductionFactor = currReduction; - samplesPerSample = 1 << PeakFile.REDUCTION_STEP; - var lastPyramid = pyramids[^1].samples; - - int i = 0; - do - { - float max = 0; - float sqrSum = 0; - int y = 0; - for (y = 0; y < samplesPerSample && i < lastPyramid.Length; y++, i++) - { - float p = lastPyramid[i].peak * (1 / (float)ushort.MaxValue); - float r = lastPyramid[i].rms * (1 / (float)ushort.MaxValue); - max = Math.Max(p, max); - sqrSum += r * r; - } - ushort peak = (ushort)(max * ushort.MaxValue); - ushort rms = (ushort)(Math.Sqrt(sqrSum / y) * ushort.MaxValue); - samples.Add(new PeakFile.Sample() { peak = peak, rms = rms }); - } while (i < lastPyramid.Length); - - currReduction <<= PeakFile.REDUCTION_STEP; - pyramid.samples = samples.ToArray(); - if (pyramid.samples.Length >= PeakFile.MIN_SAMPLES) - pyramids.Add(pyramid); - else - break; - } while (true); - - peakFile.peakDataPyramid = pyramids.Reverse().ToArray(); - - return peakFile; - } - - private static void WritePeakFile(PeakFile peakFile, Stream stream) - { - using BinaryWriter writer = new(stream, Encoding.UTF8); - - writer.Write(peakFile.fileMagic); - writer.Write(peakFile.fileVersion); - - writer.Write(peakFile.sourceFileLength); - writer.Write(peakFile.sourceDate.Ticks); - writer.Write(peakFile.sourceName.Length); - writer.Write(new ReadOnlySpan(peakFile.sourceName.ToCharArray())); - - writer.Write(peakFile.fs); - writer.Write(peakFile.length); - - using (BrotliStream br = new(stream, CompressionLevel.Optimal, true)) - using (BinaryWriter compressedWriter = new(br, Encoding.UTF8, true)) - { - compressedWriter.Write(peakFile.peakDataPyramid.Length); - for (int i = 0; i < peakFile.peakDataPyramid.Length; i++) - { - var pyramid = peakFile.peakDataPyramid[i]; - compressedWriter.Write(pyramid.reductionFactor); - compressedWriter.Write(pyramid.samples.LongLength); - for (int j = 0; j < pyramid.samples.LongLength; j++) - { - var sample = pyramid.samples[j]; - compressedWriter.Write(sample.data); - } - } - } - - writer.Write("\n\nThis is a QPlayer peak file used to accelerate waveform rendering.\n" + - "https://github.com/space928/QPlayer\n\n<3"); - } - } } diff --git a/QPlayer/Views/MainWindow.xaml b/QPlayer/Views/MainWindow.xaml index 49945bc..545b0e2 100644 --- a/QPlayer/Views/MainWindow.xaml +++ b/QPlayer/Views/MainWindow.xaml @@ -237,7 +237,7 @@ + + + +