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 @@
-
+
@@ -40,11 +40,19 @@
-
+ AlignmentY="Bottom" AlignmentX="Left" Stretch="Uniform">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QPlayer/Views/WaveForm.xaml.cs b/QPlayer/Views/WaveForm.xaml.cs
index b466d3d..3ac7d64 100644
--- a/QPlayer/Views/WaveForm.xaml.cs
+++ b/QPlayer/Views/WaveForm.xaml.cs
@@ -23,16 +23,44 @@ public partial class WaveForm : UserControl, INotifyPropertyChanged
private const float panSpeed = 0.5f;
private bool isWaveformCapturingMouse = false;
+ private bool isTimeinCapturing = false;
+ private bool isTimeoutCapturing = false;
private POINT mouseStartPos;
private Window? window;
- private WaveFormWindow? waveFormWindow;
- private bool enabled = true;
-
- [Reactive] public bool Enabled => enabled;
- [Reactive] public Visibility WaveFormVisible => enabled ? Visibility.Visible : Visibility.Hidden;
- [Reactive] public Visibility InvWaveFormVisible => enabled ? Visibility.Hidden : Visibility.Visible;
+ private static WaveFormWindow? waveFormWindow;
+ private double timeOutMarkerPos = 0;
+ private double timeInMarkerPos = 0;
+ private double timeHandleMouseOffset = 0;
+ private TimeSpan timeOutStart = TimeSpan.Zero;
+
+ [Reactive] public bool Enabled => waveFormWindow == null || waveFormWindow == window || waveFormWindow.DataContext != SoundCue;
+ [Reactive] public Visibility WaveFormVisible => Enabled ? Visibility.Visible : Visibility.Hidden;
+ [Reactive] public Visibility InvWaveFormVisible => Enabled ? Visibility.Hidden : Visibility.Visible;
[Reactive] public RelayCommand PopupCommand { get; private set; }
[Reactive, ReactiveDependency(nameof(NavBarHeight))] public double TimeStampFontSize => NavBarHeight / 2;
+ [Reactive]
+ public Thickness PlaybackMarkerPos
+ {
+ get
+ {
+ if (SoundCue == null || WaveFormRenderer == null || WaveFormRenderer.ViewSpan == TimeSpan.Zero)
+ return new(-10, 0, 0, 0);
+ return new((SoundCue.PlaybackTime - WaveFormRenderer.ViewStart + SoundCue.StartTime) / WaveFormRenderer.ViewSpan * Graph.ActualWidth - PlaybackMarker.Width, 0, 0, 0);
+ }
+ }
+ [Reactive] public Thickness TimeInMarkerPos => new(timeInMarkerPos,0,0,0);
+ [Reactive] public Thickness TimeOutMarkerPos => new(timeOutMarkerPos,0,0,0);
+
+ #region Dependency Properties
+
+ public SoundCueViewModel SoundCue
+ {
+ get { return (SoundCueViewModel)GetValue(SoundCueProperty); }
+ set { SetValue(SoundCueProperty, value); }
+ }
+
+ public static readonly DependencyProperty SoundCueProperty =
+ DependencyProperty.Register("SoundCue", typeof(SoundCueViewModel), typeof(WaveForm), new PropertyMetadata(SoundCueUpdated));
public double NavBarHeight
{
@@ -49,34 +77,104 @@ public WaveFormRenderer WaveFormRenderer
set { SetValue(WaveFormRendererProperty, value); }
}
- // Using a DependencyProperty as the backing store for WaveFormRenderer. This enables animation, styling, binding, etc...
public static readonly DependencyProperty WaveFormRendererProperty =
- DependencyProperty.Register("WaveFormRenderer", typeof(WaveFormRenderer), typeof(WaveForm), new PropertyMetadata(null));
+ DependencyProperty.Register("WaveFormRenderer", typeof(WaveFormRenderer), typeof(WaveForm), new PropertyMetadata(WaveFormRendererUpdated));
+
+ #endregion
public WaveForm()
{
InitializeComponent();
PopupCommand = new(OpenPopup);
+ UpdateTimePositions();
}
- private void WaveForm_SizeChanged(object sender, SizeChangedEventArgs e)
+ private static void SoundCueUpdated(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
- if (!enabled)
+ // The property change notfification chain to make this all work is jank-saurus-rex
+ // Basically, a SoundCueViewModel owns a WaveFormRenderer (which is also a vm);
+ // in the view we create a WaveForm usercontrol, the WaveForm usercontrol uses it's code-behind (this) as it's vm
+ // as such it defines 2 important dep properties: SoundCue and WaveFormRenderer (the important VMs which store
+ // everything we care about). Changes in in any of the 3 VMs (WaveForm, WaveFormRenderer, and SoundCue) are important
+ // to the view. Traditional data-bindings aren't flexible enough for all the data transformation we're doing
+ // (ValueConverters are inefficient and a mess, and very error prone) so we have to link up all these dependencies
+ // manually through property change notifications. And of course MS didn't really intend us to do this, so it's a pain.
+ // Maybe there's some neat solution to this I'm not seeing, but for now we have a tangled mess of dependencies.
+ PropertyChangedEventHandler f = (o, a) => SoundCue_PropertyChanged((WaveForm)sender, a);
+ if (e.OldValue != null)
+ ((SoundCueViewModel)e.OldValue).PropertyChanged -= f;
+ if(e.NewValue != null)
+ ((SoundCueViewModel)e.NewValue).PropertyChanged += f;
+ }
+
+ private static void WaveFormRendererUpdated(DependencyObject sender, DependencyPropertyChangedEventArgs e)
+ {
+ // Use a lambda to capture the dependency object (which is just this) sending the message, otherwise
+ // we won't know who to route notifications to.
+ // Did I mention that this is janky........
+ PropertyChangedEventHandler f = (o, a) => WaveFormRenderer_PropertyChanged((WaveForm)sender, a);
+ if (e.OldValue != null)
+ ((WaveFormRenderer)e.OldValue).PropertyChanged -= f;
+ if (e.NewValue != null)
+ ((WaveFormRenderer)e.NewValue).PropertyChanged += f;
+ }
+
+ private static void SoundCue_PropertyChanged(WaveForm sender, PropertyChangedEventArgs e)
+ {
+ if (sender is not WaveForm vm)
+ return;
+ switch (e.PropertyName)
+ {
+ case (nameof(vm.SoundCue.PlaybackTime)):
+ vm.OnPropertyChanged(nameof(PlaybackMarkerPos));
+ break;
+ case (nameof(vm.SoundCue.StartTime)):
+ case (nameof(vm.SoundCue.PlaybackDuration)):
+ case (nameof(vm.SoundCue.Duration)):
+ vm.UpdateTimePositions();
+ break;
+ }
+ }
+
+ private static void WaveFormRenderer_PropertyChanged(WaveForm sender, PropertyChangedEventArgs e)
+ {
+ if (sender is not WaveForm vm)
+ return;
+ switch (e.PropertyName)
+ {
+ case (nameof(vm.WaveFormRenderer.ViewStart)):
+ case (nameof(vm.WaveFormRenderer.ViewEnd)):
+ case (nameof(vm.WaveFormRenderer.WaveFormDrawing)):
+ vm.OnPropertyChanged(nameof(PlaybackMarkerPos));
+ vm.UpdateTimePositions();
+ break;
+ case (nameof(vm.WaveFormRenderer.PeakFile)):
+ vm.WaveForm_SizeChanged(vm, null);
+ break;
+ }
+ }
+
+ private void WaveForm_SizeChanged(object sender, SizeChangedEventArgs? e)
+ {
+ if (!Enabled)
return;
if (WaveFormRenderer == null)
return;
- WaveFormRenderer.Width = ((Rectangle)sender).ActualWidth;
- WaveFormRenderer.Height = ((Rectangle)sender).ActualHeight;
+ WaveFormRenderer.Width = Graph.ActualWidth;
+ WaveFormRenderer.Height = Graph.ActualHeight;
+ OnPropertyChanged(nameof(PlaybackMarkerPos));
+ UpdateTimePositions();
}
private void WaveFormZoom_MouseDown(object sender, MouseButtonEventArgs e)
{
- if (!enabled)
+ if (!Enabled)
return;
isWaveformCapturingMouse = true;
+ e.MouseDevice.Capture(NavBar);
ShowCursor(false);
GetCursorPos(out mouseStartPos);
mouseStartPos.y -= 40;
@@ -88,13 +186,14 @@ private void WaveFormZoom_MouseDown(object sender, MouseButtonEventArgs e)
private void WaveFormZoom_MouseUp(object sender, MouseButtonEventArgs e)
{
- if(!enabled)
+ if(!Enabled)
return;
if(!isWaveformCapturingMouse)
return;
isWaveformCapturingMouse = false;
SetCursorPos(mouseStartPos.x, mouseStartPos.y + 40);
+ e.MouseDevice.Capture(null);
ShowCursor(true);
NavBarScale.ScaleX = 1;
NavBarTranslate.X = 0;
@@ -124,7 +223,7 @@ private void WaveFormZoom_MouseMove(object sender, MouseEventArgs e)
{
if (!isWaveformCapturingMouse)
return;
- if (!enabled)
+ if (!Enabled)
return;
GetCursorPos(out POINT nPos);
@@ -164,22 +263,13 @@ private void NavBar_Loaded(object sender, RoutedEventArgs e)
if (window == null)
return;
- window.MouseMove += WaveFormZoom_MouseMove;
- window.MouseUp += WaveFormZoom_MouseUp;
-
- WaveFormRenderer.Width = Graph.ActualWidth;
- WaveFormRenderer.Height = Graph.ActualHeight;
+ WaveForm_SizeChanged(sender, null);
}
private void NavBar_Unloaded(object sender, RoutedEventArgs e)
{
if (window == null)
return;
-
- window.MouseUp -= WaveFormZoom_MouseUp;
- window.MouseMove -= WaveFormZoom_MouseMove;
-
- waveFormWindow?.Close();
}
private void OpenPopup()
@@ -192,16 +282,15 @@ private void OpenPopup()
return;
if (waveFormWindow != null)
{
- waveFormWindow.DataContext = WaveFormRenderer;
+ waveFormWindow.DataContext = SoundCue;
waveFormWindow.Activate();
return;
}
waveFormWindow = new();
- waveFormWindow.DataContext = WaveFormRenderer;
+ waveFormWindow.DataContext = SoundCue;
waveFormWindow.Closed += (s, e) =>
{
- enabled = true;
waveFormWindow = null;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WaveFormVisible)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(InvWaveFormVisible)));
@@ -212,13 +301,115 @@ private void OpenPopup()
WaveFormRenderer.Height = Graph.ActualHeight;
}
};
- enabled = false;
+ window.Closed += (s, e) =>
+ {
+ waveFormWindow?.Close();
+ };
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WaveFormVisible)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(InvWaveFormVisible)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Enabled)));
waveFormWindow.Show();
}
+ #region Time In/Out Markers
+ private void TimeInMarker_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ isTimeinCapturing = true;
+ timeHandleMouseOffset = e.GetPosition(TimeInMarker).X;
+ if(SoundCue != null)
+ timeOutStart = SoundCue.Duration + SoundCue.StartTime;
+ e.MouseDevice.Capture(TimeInMarker, CaptureMode.Element);
+ }
+
+ private void TimeInMarker_MouseUp(object sender, MouseButtonEventArgs e)
+ {
+ if (!isTimeinCapturing)
+ return;
+
+ isTimeinCapturing = false;
+ e.MouseDevice.Capture(null);
+ }
+
+ private void TimeInMarker_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (!isTimeinCapturing)
+ return;
+ if(SoundCue == null || WaveFormRenderer == null)
+ return;
+
+ var relGraph = e.GetPosition(Graph);
+ relGraph.X -= timeHandleMouseOffset;
+ var width = Graph.ActualWidth;
+ var time = (relGraph.X / width) * WaveFormRenderer.ViewSpan + WaveFormRenderer.ViewStart;
+ if (time < TimeSpan.Zero)
+ time = TimeSpan.Zero;
+ SoundCue.StartTime = time;
+ var duration = timeOutStart - time;
+ if(duration < TimeSpan.Zero)
+ duration = TimeSpan.Zero;
+ SoundCue.PlaybackDuration = duration;
+ if(SoundCue.State == CueState.Ready)
+ SoundCue.PlaybackTime = TimeSpan.Zero;
+ }
+
+ private void TimeOutMarker_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ isTimeoutCapturing = true;
+ timeHandleMouseOffset = e.GetPosition(TimeOutMarker).X;
+ e.MouseDevice.Capture(TimeOutMarker, CaptureMode.Element);
+ }
+
+ private void TimeOutMarker_MouseUp(object sender, MouseButtonEventArgs e)
+ {
+ if (!isTimeoutCapturing)
+ return;
+
+ isTimeoutCapturing = false;
+ e.MouseDevice.Capture(null);
+ }
+
+ private void TimeOutMarker_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (!isTimeoutCapturing)
+ return;
+ if (SoundCue == null || WaveFormRenderer == null)
+ return;
+
+ var relGraph = e.GetPosition(Graph);
+ relGraph.X += TimeOutMarker.ActualWidth - timeHandleMouseOffset;
+ var width = Graph.ActualWidth;
+ var time = (relGraph.X / width) * WaveFormRenderer.ViewSpan + WaveFormRenderer.ViewStart - SoundCue.StartTime;
+ if(time < TimeSpan.Zero)
+ time = TimeSpan.Zero;
+ SoundCue.PlaybackDuration = time;
+ }
+
+ private void UpdateTimePositions()
+ {
+ if (SoundCue == null || WaveFormRenderer == null || WaveFormRenderer.ViewSpan == TimeSpan.Zero)
+ {
+ timeInMarkerPos = 0;
+ timeOutMarkerPos = 0;
+ OnPropertyChanged(nameof(TimeInMarkerPos));
+ OnPropertyChanged(nameof(TimeOutMarkerPos));
+ return;
+ }
+
+ var start = WaveFormRenderer.ViewStart;
+ var end = WaveFormRenderer.ViewEnd;
+ var span = WaveFormRenderer.ViewSpan;
+ double left = (SoundCue.StartTime - start) / span;
+ double right = (SoundCue.Duration + SoundCue.StartTime - start) / span;
+ var markerWidth = TimeInMarker.ActualWidth;
+ var graphWidth = Graph.ActualWidth;
+ timeInMarkerPos = left * graphWidth;
+ timeOutMarkerPos = right * graphWidth - markerWidth;
+
+ OnPropertyChanged(nameof(TimeInMarkerPos));
+ OnPropertyChanged(nameof(TimeOutMarkerPos));
+ }
+ #endregion
+
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
diff --git a/QPlayer/Views/WaveFormWindow.xaml b/QPlayer/Views/WaveFormWindow.xaml
index 510efba..7da803f 100644
--- a/QPlayer/Views/WaveFormWindow.xaml
+++ b/QPlayer/Views/WaveFormWindow.xaml
@@ -5,12 +5,34 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:QPlayer.Views"
xmlns:vm="clr-namespace:QPlayer.ViewModels"
- d:DataContext="{d:DesignInstance Type=vm:WaveFormRenderer}"
+ d:DataContext="{d:DesignInstance Type=vm:SoundCueViewModel}"
mc:Ignorable="d"
- Title="{Binding WindowTitle}" Height="500" Width="1000"
+ Title="{Binding WaveForm.WindowTitle}" Height="500" Width="1000"
Style="{DynamicResource CustomWindowStyle}"
Icon="{StaticResource IconImage}">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+