@pro-gram/flux 0.0.0-stage → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/NOTICE.txt +33 -0
- package/README.md +125 -2
- package/bin/flux.js +29 -0
- package/dependencies.json +15 -0
- package/package.json +60 -6
- package/payload/DrumBuddy-Setup.exe +4 -0
- package/scripts/install.js +60 -0
- package/scripts/version.js +12 -0
- package/source/AssetBootstrap.cs +49 -0
- package/source/Audio Decoders.txt +230 -0
- package/source/Audio.cs +453 -0
- package/source/AudioDeviceSettings.cs +126 -0
- package/source/AudioEndpointWatcher.cs +25 -0
- package/source/AudioInputs.cs +50 -0
- package/source/AudioOutputs.cs +307 -0
- package/source/AudioWorkspace.cs +72 -0
- package/source/BrandArtwork.cs +71 -0
- package/source/Build Instructions.txt +65 -0
- package/source/Build.ps1 +135 -0
- package/source/ControlMode.cs +687 -0
- package/source/ControlTests.cs +88 -0
- package/source/ConversionFormats.cs +59 -0
- package/source/ConversionTests.cs +66 -0
- package/source/DockablePane.cs +95 -0
- package/source/DriverSetup.cs +28 -0
- package/source/EditorCompactLayout.cs +36 -0
- package/source/EditorPlaybackTests.cs +87 -0
- package/source/EditorSelectionTests.cs +73 -0
- package/source/EditorToolInteractions.cs +227 -0
- package/source/EditorTools.cs +78 -0
- package/source/EditorToolsTests.cs +93 -0
- package/source/EditorView.cs +798 -0
- package/source/Fetch-Dependencies.ps1 +15 -0
- package/source/FloatRecording.cs +99 -0
- package/source/FloatRecordingTests.cs +170 -0
- package/source/Flux-Logo.png +0 -0
- package/source/Flux-Mark.svg +49 -0
- package/source/FluxTempo Logo Prompt.txt +3 -0
- package/source/FluxTempo-Mark.svg +49 -0
- package/source/HomeView.cs +32 -0
- package/source/InterfaceSettings.cs +225 -0
- package/source/InterfaceTests.cs +172 -0
- package/source/LICENSE.txt +23 -0
- package/source/Logo Prompt.txt +16 -0
- package/source/Main.cs +787 -0
- package/source/Main.xaml +289 -0
- package/source/MediaPlayerView.cs +274 -0
- package/source/MidiControl.cs +113 -0
- package/source/MidiPerformance.cs +125 -0
- package/source/Mixer.cs +280 -0
- package/source/NativeMemory.cs +165 -0
- package/source/PanelFeedback.cs +75 -0
- package/source/PlaybackTests.cs +141 -0
- package/source/Quick Start.txt +96 -0
- package/source/SelfTest.cs +78 -0
- package/source/SettingsWindow.cs +209 -0
- package/source/Setup.cs +196 -0
- package/source/Setup.xaml +28 -0
- package/source/ShellStyles.xaml +99 -0
- package/source/TempoGrid.cs +95 -0
- package/source/TempoGridTests.cs +108 -0
- package/source/Third Party Notices.txt +33 -0
- package/source/Timeline.cs +268 -0
- package/source/TimelineGestures.cs +57 -0
- package/source/TimelineZoom.cs +21 -0
- package/source/Uninstall.cs +69 -0
- package/source/WaveformDetail.cs +104 -0
- package/source/WindowsAudioPlayer.cs +137 -0
- package/source/WorkspaceTests.cs +202 -0
- package/source/ZoomTests.cs +95 -0
- package/source/app.manifest +7 -0
- package/source/brand-archive/Drum-Buddy-Black-Hair.png +0 -0
- package/source/dependency-lock.json +62 -0
- package/source/fluxtempo-mark.png +0 -0
- package/source/logo.png +0 -0
- package/source/r16-panel.png +0 -0
- package/source/references/R16 Format Research.txt +16 -0
- package/test/package.test.js +55 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.IO;
|
|
3
|
+
using System.Linq;
|
|
4
|
+
using System.Reflection;
|
|
5
|
+
using System.Diagnostics;
|
|
6
|
+
using System.Threading;
|
|
7
|
+
using System.Windows.Forms;
|
|
8
|
+
using Microsoft.Win32;
|
|
9
|
+
|
|
10
|
+
public static class Uninstall
|
|
11
|
+
{
|
|
12
|
+
private static string Target { get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"Programs","DrumBuddy"); } }
|
|
13
|
+
[STAThread]
|
|
14
|
+
public static int Main(string[] args)
|
|
15
|
+
{
|
|
16
|
+
if (args.Length == 1 && args[0] == "--remove-installed-files") {
|
|
17
|
+
Thread.Sleep(1500);
|
|
18
|
+
if (!File.Exists(Path.Combine(Target,".drumbuddy-installed"))) return 1;
|
|
19
|
+
try {
|
|
20
|
+
string root = Path.GetFullPath(Target).TrimEnd('\\') + "\\";
|
|
21
|
+
string[] files = File.ReadAllLines(Path.Combine(Target,"installed-files.txt"));
|
|
22
|
+
foreach (string relative in files) {
|
|
23
|
+
string full = Path.GetFullPath(Path.Combine(root,relative));
|
|
24
|
+
if (!full.StartsWith(root,StringComparison.OrdinalIgnoreCase) || relative.Contains(":")) continue;
|
|
25
|
+
for (int retry = 0; retry < 20; retry++) {
|
|
26
|
+
try { if (File.Exists(full)) File.Delete(full); break; }
|
|
27
|
+
catch (IOException) { Thread.Sleep(250); }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
foreach (string dir in Directory.GetDirectories(root,"*",SearchOption.AllDirectories).OrderByDescending(d => d.Length))
|
|
31
|
+
if (!Directory.EnumerateFileSystemEntries(dir).Any()) Directory.Delete(dir);
|
|
32
|
+
if (!Directory.EnumerateFileSystemEntries(root).Any()) Directory.Delete(root);
|
|
33
|
+
Registry.CurrentUser.DeleteSubKeyTree(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\DrumBuddy",false);
|
|
34
|
+
RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"Flux.lnk"));
|
|
35
|
+
RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","Flux.lnk"));
|
|
36
|
+
RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"Flux Audio.lnk"));
|
|
37
|
+
RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","Flux Audio.lnk"));
|
|
38
|
+
RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"FluxTempo.lnk"));
|
|
39
|
+
RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","FluxTempo.lnk"));
|
|
40
|
+
RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"Drum Buddy.lnk"));
|
|
41
|
+
RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","Drum Buddy.lnk"));
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
catch (Exception ex) { MessageBox.Show("Some program files could not be removed. Your audio files were kept.\n\n" + ex.Message,"Flux"); return 1; }
|
|
45
|
+
}
|
|
46
|
+
if (!string.Equals(Path.GetFullPath(AppDomain.CurrentDomain.BaseDirectory).TrimEnd('\\'),Target,StringComparison.OrdinalIgnoreCase) || !File.Exists(Path.Combine(Target,".drumbuddy-installed"))) {
|
|
47
|
+
MessageBox.Show("This uninstaller can only remove the installed copy of Flux.","Flux"); return 1;
|
|
48
|
+
}
|
|
49
|
+
if (Process.GetProcessesByName("DrumBuddy").Length > 0) { MessageBox.Show("Close Flux before uninstalling.","Flux"); return 1; }
|
|
50
|
+
if (MessageBox.Show("Remove Flux?\n\nYour recordings, exported WAV files, and Zoom driver will be kept.","Uninstall Flux",MessageBoxButtons.YesNo,MessageBoxIcon.Question) != DialogResult.Yes) return 0;
|
|
51
|
+
string temporary = Path.Combine(Path.GetTempPath(),"DrumBuddy-Uninstall-" + Guid.NewGuid().ToString("N") + ".exe");
|
|
52
|
+
File.Copy(Assembly.GetExecutingAssembly().Location,temporary);
|
|
53
|
+
Process.Start(new ProcessStartInfo(temporary,"--remove-installed-files") { UseShellExecute = false, CreateNoWindow = true }); return 0;
|
|
54
|
+
}
|
|
55
|
+
private static void RemoveShortcut(string path)
|
|
56
|
+
{
|
|
57
|
+
if (!File.Exists(path)) return;
|
|
58
|
+
object shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell"));
|
|
59
|
+
try {
|
|
60
|
+
object link = shell.GetType().InvokeMember("CreateShortcut",BindingFlags.InvokeMethod,null,shell,new object[] { path });
|
|
61
|
+
try {
|
|
62
|
+
string target = (string)link.GetType().InvokeMember("TargetPath",BindingFlags.GetProperty,null,link,null);
|
|
63
|
+
if (string.Equals(target,Path.Combine(Target,"DrumBuddy.exe"),StringComparison.OrdinalIgnoreCase)) File.Delete(path);
|
|
64
|
+
}
|
|
65
|
+
finally { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(link); }
|
|
66
|
+
}
|
|
67
|
+
finally { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shell); }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.IO;
|
|
3
|
+
using System.Collections.Generic;
|
|
4
|
+
using System.Threading;
|
|
5
|
+
using System.Threading.Tasks;
|
|
6
|
+
using System.Windows.Threading;
|
|
7
|
+
using NAudio.Wave;
|
|
8
|
+
|
|
9
|
+
namespace DrumBuddy
|
|
10
|
+
{
|
|
11
|
+
internal sealed class WaveformEnvelope
|
|
12
|
+
{
|
|
13
|
+
public float[] Low, High;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Small viewport envelopes are read from the decoded float cache off the UI thread.
|
|
17
|
+
// The full-file overview remains available immediately while detail is loading.
|
|
18
|
+
internal sealed class WaveformDetail : IDisposable
|
|
19
|
+
{
|
|
20
|
+
private readonly Dispatcher dispatcher;
|
|
21
|
+
private readonly Action refresh;
|
|
22
|
+
private readonly SemaphoreSlim gate = new SemaphoreSlim(2);
|
|
23
|
+
private readonly Dictionary<string, WaveformEnvelope> ready = new Dictionary<string, WaveformEnvelope>();
|
|
24
|
+
private readonly Queue<string> order = new Queue<string>();
|
|
25
|
+
private readonly HashSet<string> pending = new HashSet<string>(), failed = new HashSet<string>();
|
|
26
|
+
private CancellationTokenSource cancellation = new CancellationTokenSource();
|
|
27
|
+
private string view;
|
|
28
|
+
private volatile bool disposed;
|
|
29
|
+
public WaveformDetail(Dispatcher dispatcher, Action refresh) { this.dispatcher = dispatcher; this.refresh = refresh; }
|
|
30
|
+
public void BeginView(string key)
|
|
31
|
+
{
|
|
32
|
+
if (view == key) return;
|
|
33
|
+
view = key; cancellation.Cancel(); cancellation.Dispose(); cancellation = new CancellationTokenSource(); failed.Clear();
|
|
34
|
+
}
|
|
35
|
+
public WaveformEnvelope Get(string file, long start, long end, int columns)
|
|
36
|
+
{
|
|
37
|
+
if (disposed || string.IsNullOrEmpty(file) || end <= start || end - start > Audio.Rate * 12L) return null;
|
|
38
|
+
columns = Math.Max(2, Math.Min(2000, columns));
|
|
39
|
+
string key = file + "|" + start + "|" + end + "|" + columns;
|
|
40
|
+
WaveformEnvelope result;
|
|
41
|
+
if (ready.TryGetValue(key, out result)) return result;
|
|
42
|
+
if (pending.Contains(key) || failed.Contains(key)) return null;
|
|
43
|
+
pending.Add(key); var token = cancellation.Token;
|
|
44
|
+
Load(key, file, start, end, columns, token);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
private async void Load(string key, string file, long start, long end, int columns, CancellationToken token)
|
|
48
|
+
{
|
|
49
|
+
WaveformEnvelope result = null; bool error = false;
|
|
50
|
+
try {
|
|
51
|
+
await gate.WaitAsync(token).ConfigureAwait(false);
|
|
52
|
+
try { result = await Task.Run(() => Read(file, start, end, columns, token), token).ConfigureAwait(false); }
|
|
53
|
+
finally { gate.Release(); }
|
|
54
|
+
}
|
|
55
|
+
catch (OperationCanceledException) { }
|
|
56
|
+
catch (Exception) { error = true; }
|
|
57
|
+
if (disposed || dispatcher.HasShutdownStarted) return;
|
|
58
|
+
try { await dispatcher.InvokeAsync((Action)delegate {
|
|
59
|
+
pending.Remove(key);
|
|
60
|
+
if (disposed) return;
|
|
61
|
+
if (result != null) {
|
|
62
|
+
ready[key] = result; order.Enqueue(key);
|
|
63
|
+
while (order.Count > 64) ready.Remove(order.Dequeue());
|
|
64
|
+
}
|
|
65
|
+
if (!token.IsCancellationRequested) { if (error) failed.Add(key); refresh(); }
|
|
66
|
+
}).Task.ConfigureAwait(false); }
|
|
67
|
+
catch (TaskCanceledException) { }
|
|
68
|
+
}
|
|
69
|
+
internal static WaveformEnvelope Read(string file, long start, long end, int columns, CancellationToken token)
|
|
70
|
+
{
|
|
71
|
+
token.ThrowIfCancellationRequested();
|
|
72
|
+
if (start < 0 || end <= start || end - start > Audio.Rate * 12L || columns < 2 || columns > 2000) throw new ArgumentOutOfRangeException("start");
|
|
73
|
+
using (var reader = new WaveFileReader(file)) {
|
|
74
|
+
int channels = reader.WaveFormat.Channels;
|
|
75
|
+
if (reader.WaveFormat.BitsPerSample != 32 || reader.WaveFormat.SampleRate != Audio.Rate || channels < 1 || channels > 2) throw new IOException("Unexpected waveform cache format.");
|
|
76
|
+
long frames = reader.Length / (4 * channels), count = end - start;
|
|
77
|
+
var result = new WaveformEnvelope { Low = new float[columns], High = new float[columns] };
|
|
78
|
+
for (int i = 0; i < columns; i++) { result.Low[i] = float.PositiveInfinity; result.High[i] = float.NegativeInfinity; }
|
|
79
|
+
reader.Position = Math.Min(start, frames) * 4 * channels;
|
|
80
|
+
var bytes = new byte[32768]; var values = new float[bytes.Length / 4];
|
|
81
|
+
long position = start, limit = Math.Min(end, frames);
|
|
82
|
+
while (position < limit) {
|
|
83
|
+
token.ThrowIfCancellationRequested();
|
|
84
|
+
int requested = (int)Math.Min(bytes.Length, (limit - position) * 4 * channels);
|
|
85
|
+
int read = reader.Read(bytes, 0, requested); if (read == 0) break;
|
|
86
|
+
Buffer.BlockCopy(bytes, 0, values, 0, read);
|
|
87
|
+
int readFrames = read / (4 * channels);
|
|
88
|
+
for (int i = 0; i < readFrames; i++, position++) {
|
|
89
|
+
int first = (int)((position - start) * columns / count);
|
|
90
|
+
int last = (int)Math.Min(columns - 1, Math.Ceiling((position - start + 1) * (double)columns / count) - 1);
|
|
91
|
+
for (int ch = 0; ch < channels; ch++) {
|
|
92
|
+
float value = values[i * channels + ch];
|
|
93
|
+
if (float.IsNaN(value) || float.IsInfinity(value)) continue;
|
|
94
|
+
for (int bin = first; bin <= last && bin < columns; bin++) { result.Low[bin] = Math.Min(result.Low[bin], value); result.High[bin] = Math.Max(result.High[bin], value); }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (int i = 0; i < columns; i++) if (float.IsInfinity(result.Low[i])) result.Low[i] = result.High[i] = 0;
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
public void Dispose() { disposed = true; cancellation.Cancel(); cancellation.Dispose(); ready.Clear(); }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.IO;
|
|
3
|
+
using System.Threading;
|
|
4
|
+
using System.Runtime.InteropServices;
|
|
5
|
+
using NAudio.CoreAudioApi;
|
|
6
|
+
using NAudio.Wave;
|
|
7
|
+
|
|
8
|
+
namespace DrumBuddy
|
|
9
|
+
{
|
|
10
|
+
// One COM apartment owns activation, initialization, rendering and release.
|
|
11
|
+
// Audio is already converted to the endpoint's exact mix format: ask Windows
|
|
12
|
+
// for a normal shared stream, without a second automatic converter or WinMM.
|
|
13
|
+
internal sealed class WindowsAudioPlayer : IWavePlayer, IWavePosition
|
|
14
|
+
{
|
|
15
|
+
[DllImport("avrt.dll", CharSet = CharSet.Unicode)] private static extern IntPtr AvSetMmThreadCharacteristics(string task, out uint index);
|
|
16
|
+
[DllImport("avrt.dll")] private static extern bool AvRevertMmThreadCharacteristics(IntPtr handle);
|
|
17
|
+
private readonly ISampleProvider source;
|
|
18
|
+
private readonly string endpointId;
|
|
19
|
+
private readonly bool events;
|
|
20
|
+
private readonly int latency;
|
|
21
|
+
private readonly Thread worker;
|
|
22
|
+
private readonly AutoResetEvent command = new AutoResetEvent(false), available = new AutoResetEvent(false);
|
|
23
|
+
private readonly ManualResetEvent initialized = new ManualResetEvent(false), started = new ManualResetEvent(false);
|
|
24
|
+
private volatile PlaybackState state;
|
|
25
|
+
private volatile bool stopping, disposed, closed;
|
|
26
|
+
private Exception failure;
|
|
27
|
+
private GainProvider deviceGain;
|
|
28
|
+
private long played;
|
|
29
|
+
private bool playedOnce;
|
|
30
|
+
public CountedPlayback Counter { get; private set; }
|
|
31
|
+
public WaveFormat OutputFormat { get; private set; }
|
|
32
|
+
public WaveFormat OutputWaveFormat { get { return OutputFormat; } }
|
|
33
|
+
public string Description { get; private set; }
|
|
34
|
+
public event EventHandler<StoppedEventArgs> PlaybackStopped;
|
|
35
|
+
public PlaybackState PlaybackState { get { return state; } }
|
|
36
|
+
public float Volume { get { return deviceGain == null ? 1 : (float)deviceGain.Gain; } set { if (deviceGain != null) deviceGain.Gain = Math.Max(0, Math.Min(1, value)); } }
|
|
37
|
+
internal static AudioClientStreamFlags Flags(bool eventDriven)
|
|
38
|
+
{ return eventDriven ? AudioClientStreamFlags.EventCallback : AudioClientStreamFlags.None; }
|
|
39
|
+
public WindowsAudioPlayer(ISampleProvider source, string endpointId, bool events, int latency)
|
|
40
|
+
{
|
|
41
|
+
this.source = source; this.endpointId = endpointId; this.events = events; this.latency = Math.Max(40, Math.Min(250, latency));
|
|
42
|
+
worker = new Thread(Render) { IsBackground = true, Name = "Flux Windows audio" };
|
|
43
|
+
worker.SetApartmentState(ApartmentState.MTA); worker.Start();
|
|
44
|
+
if (!initialized.WaitOne(8000)) { Dispose(); throw new IOException("Windows audio did not finish opening the selected device."); }
|
|
45
|
+
if (failure != null) { var error = failure; Dispose(); throw error; }
|
|
46
|
+
}
|
|
47
|
+
public void Init(IWaveProvider provider) { throw new InvalidOperationException("This output is initialized from the editor's sample source."); }
|
|
48
|
+
public void Play()
|
|
49
|
+
{
|
|
50
|
+
if (disposed || closed) throw failure ?? new ObjectDisposedException("Windows audio output");
|
|
51
|
+
state = PlaybackState.Playing; command.Set();
|
|
52
|
+
if (!playedOnce) {
|
|
53
|
+
playedOnce = true;
|
|
54
|
+
if (!started.WaitOne(8000)) { Stop(); throw new IOException("Windows audio did not start the selected device."); }
|
|
55
|
+
if (failure != null) throw failure;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
public void Pause() { if (!disposed && !closed) { state = PlaybackState.Paused; command.Set(); } }
|
|
59
|
+
public void Stop()
|
|
60
|
+
{
|
|
61
|
+
if (closed) return;
|
|
62
|
+
stopping = true; command.Set();
|
|
63
|
+
if (Thread.CurrentThread != worker) worker.Join(2500);
|
|
64
|
+
}
|
|
65
|
+
public long GetPosition() { return Interlocked.Read(ref played) * (OutputFormat == null ? 0 : OutputFormat.BlockAlign); }
|
|
66
|
+
private void Render()
|
|
67
|
+
{
|
|
68
|
+
bool running = false, wasInitialized = false, attemptedPlay = false;
|
|
69
|
+
string stage = "activate selected endpoint";
|
|
70
|
+
MMDevice device = null; AudioClient client = null; AudioRenderClient render = null;
|
|
71
|
+
IntPtr scheduling = IntPtr.Zero;
|
|
72
|
+
try {
|
|
73
|
+
uint taskIndex; scheduling = AvSetMmThreadCharacteristics("Audio", out taskIndex);
|
|
74
|
+
using (var enumerator = new MMDeviceEnumerator()) device = string.IsNullOrEmpty(endpointId)
|
|
75
|
+
? enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Console) : enumerator.GetDevice(endpointId);
|
|
76
|
+
if (device.State != DeviceState.Active) throw new IOException("The selected audio device is disconnected or disabled.");
|
|
77
|
+
stage = "read device mix format"; client = device.AudioClient; OutputFormat = client.MixFormat;
|
|
78
|
+
Counter = new CountedPlayback(AudioOutputs.Adapt(source, OutputFormat.SampleRate, OutputFormat.Channels));
|
|
79
|
+
deviceGain = new GainProvider(Counter) { Gain = 1 };
|
|
80
|
+
var wire = new EndpointWaveProvider(deviceGain, OutputFormat);
|
|
81
|
+
stage = "initialize native shared stream";
|
|
82
|
+
client.Initialize(AudioClientShareMode.Shared, Flags(events), latency * 10000L, 0, OutputFormat, Guid.Empty);
|
|
83
|
+
if (events) client.SetEventHandle(available.SafeWaitHandle.DangerousGetHandle());
|
|
84
|
+
render = client.AudioRenderClient;
|
|
85
|
+
int capacity = client.BufferSize, align = OutputFormat.BlockAlign;
|
|
86
|
+
var bytes = new byte[checked(capacity * align)];
|
|
87
|
+
var wait = events ? new WaitHandle[] { command, available } : new WaitHandle[] { command };
|
|
88
|
+
long submitted = 0; bool eof = false;
|
|
89
|
+
Description = device.FriendlyName + " — Windows shared audio — " + OutputFormat.SampleRate + " Hz";
|
|
90
|
+
wasInitialized = true; initialized.Set();
|
|
91
|
+
while (!stopping && !disposed) {
|
|
92
|
+
if (state != PlaybackState.Playing) {
|
|
93
|
+
if (running) { stage = "pause shared stream"; client.Stop(); running = false; Interlocked.Exchange(ref played, Math.Max(0, submitted - client.CurrentPadding)); }
|
|
94
|
+
command.WaitOne(); continue;
|
|
95
|
+
}
|
|
96
|
+
attemptedPlay = true;
|
|
97
|
+
stage = "query render buffer";
|
|
98
|
+
int padding = client.CurrentPadding;
|
|
99
|
+
Interlocked.Exchange(ref played, Math.Max(0, submitted - padding));
|
|
100
|
+
if (eof && padding == 0) break;
|
|
101
|
+
int frames = capacity - padding;
|
|
102
|
+
if (!eof && frames > 0) {
|
|
103
|
+
stage = "read decoded audio";
|
|
104
|
+
int read = wire.Read(bytes, 0, frames * align);
|
|
105
|
+
if (read % align != 0) throw new IOException("The decoded audio ended inside an output frame.");
|
|
106
|
+
int filled = read / align;
|
|
107
|
+
if (filled == 0) eof = true;
|
|
108
|
+
else {
|
|
109
|
+
stage = "submit audio buffer";
|
|
110
|
+
IntPtr buffer = render.GetBuffer(filled);
|
|
111
|
+
Marshal.Copy(bytes, 0, buffer, read); render.ReleaseBuffer(filled, AudioClientBufferFlags.None); submitted += filled;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!running && submitted > 0) { stage = "start shared stream"; client.Start(); running = true; started.Set(); }
|
|
115
|
+
if (eof && submitted == 0) break;
|
|
116
|
+
WaitHandle.WaitAny(wait, events ? latency : Math.Max(2, Math.Min(10, latency / 4)));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (Exception ex) { failure = new IOException("Windows audio failed at " + stage + ": " + ex.Message + " (0x" + ex.HResult.ToString("X8") + ")", ex); }
|
|
120
|
+
finally {
|
|
121
|
+
if (running && client != null) try { client.Stop(); } catch { }
|
|
122
|
+
if (render != null) try { render.Dispose(); } catch { }
|
|
123
|
+
if (client != null) try { client.Dispose(); } catch { }
|
|
124
|
+
if (device != null) try { device.Dispose(); } catch { }
|
|
125
|
+
if (scheduling != IntPtr.Zero) AvRevertMmThreadCharacteristics(scheduling);
|
|
126
|
+
state = PlaybackState.Stopped; closed = true; initialized.Set(); started.Set();
|
|
127
|
+
if (wasInitialized && attemptedPlay) { var handler = PlaybackStopped; if (handler != null) handler(this, new StoppedEventArgs(failure)); }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
public void Dispose()
|
|
131
|
+
{
|
|
132
|
+
if (disposed) return;
|
|
133
|
+
disposed = true; Stop();
|
|
134
|
+
if (!worker.IsAlive) { command.Dispose(); available.Dispose(); initialized.Dispose(); started.Dispose(); }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.IO;
|
|
3
|
+
using System.Linq;
|
|
4
|
+
using System.Text;
|
|
5
|
+
using System.Threading;
|
|
6
|
+
using System.Collections.Generic;
|
|
7
|
+
using System.Reflection;
|
|
8
|
+
using System.Windows;
|
|
9
|
+
using System.Windows.Markup;
|
|
10
|
+
using System.Windows.Media;
|
|
11
|
+
using System.Windows.Media.Imaging;
|
|
12
|
+
using NAudio.Wave;
|
|
13
|
+
using NAudio.Wave.SampleProviders;
|
|
14
|
+
|
|
15
|
+
namespace DrumBuddy
|
|
16
|
+
{
|
|
17
|
+
public static class WorkspaceTests
|
|
18
|
+
{
|
|
19
|
+
private static readonly List<string> log = new List<string>();
|
|
20
|
+
private static void Check(bool ok, string message) { if (!ok) throw new Exception(message); log.Add("PASS " + message); }
|
|
21
|
+
private static Clip Source(string root, string name, float[] values, int channels)
|
|
22
|
+
{
|
|
23
|
+
string path = Path.Combine(root, name + ".wav");
|
|
24
|
+
using (var writer = new FloatWaveWriter(path, channels, Audio.Rate)) writer.Write(values, values.Length, 1);
|
|
25
|
+
return Audio.Load(path, Path.Combine(root, "cache"), CancellationToken.None, null);
|
|
26
|
+
}
|
|
27
|
+
private static float[] Render(EditorSession session, IDictionary<string, Clip> decoded)
|
|
28
|
+
{
|
|
29
|
+
using (var mix = new TimelineMix(session, decoded)) {
|
|
30
|
+
var result = new List<float>(); var block = new float[126]; int read;
|
|
31
|
+
while ((read = mix.Read(block, 0, block.Length)) > 0) result.AddRange(block.Take(read));
|
|
32
|
+
return result.ToArray();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
private static void Picture(FrameworkElement view, string path, int width, int height)
|
|
36
|
+
{
|
|
37
|
+
view.Measure(new Size(width, height)); view.Arrange(new Rect(0, 0, width, height)); view.UpdateLayout();
|
|
38
|
+
var image = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
|
|
39
|
+
var background = new DrawingVisual(); using (var drawing = background.RenderOpen()) drawing.DrawRectangle(WorkspaceUi.Color("#F3F5F5"), null, new Rect(0, 0, width, height));
|
|
40
|
+
image.Render(background); image.Render(view);
|
|
41
|
+
var png = new PngBitmapEncoder(); png.Frames.Add(BitmapFrame.Create(image)); using (var file = File.Create(path)) png.Save(file);
|
|
42
|
+
}
|
|
43
|
+
public static int Run(string[] args)
|
|
44
|
+
{
|
|
45
|
+
string root = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(Path.GetTempPath(), "DrumBuddyWorkspace_" + Guid.NewGuid().ToString("N")));
|
|
46
|
+
Directory.CreateDirectory(root); log.Clear();
|
|
47
|
+
try {
|
|
48
|
+
PlaybackTests.Offline(Check);
|
|
49
|
+
var preference = new Preferences { AudioOutputKey = "compat:default", AudioInputKey = "input:microphone" };
|
|
50
|
+
var serializer = new System.Xml.Serialization.XmlSerializer(typeof(Preferences));
|
|
51
|
+
using (var xml = new StringWriter()) {
|
|
52
|
+
serializer.Serialize(xml, preference);
|
|
53
|
+
using (var read = new StringReader(xml.ToString())) {
|
|
54
|
+
var reopenedPreference = (Preferences)serializer.Deserialize(read);
|
|
55
|
+
Check(reopenedPreference.AudioInputKey == preference.AudioInputKey && reopenedPreference.AudioOutputKey == preference.AudioOutputKey,
|
|
56
|
+
"Read legacy microphone/output preferences before migrating retired output identifiers");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
var pickerA = new AudioOutputPicker(delegate { }, delegate(string text) { }); var pickerB = new AudioOutputPicker(delegate { }, delegate(string text) { });
|
|
60
|
+
string previousOutput = AudioOutputs.PreferredKey;
|
|
61
|
+
pickerA.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); pickerB.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent));
|
|
62
|
+
try {
|
|
63
|
+
string namedKey = AudioOutputs.Choices().First(c => c.EndpointId != null).Key;
|
|
64
|
+
AudioOutputs.PreferredKey = namedKey;
|
|
65
|
+
Check(pickerA.Selected != null && pickerB.Selected != null && pickerA.Selected.Key == namedKey && pickerB.Selected.Key == namedKey,
|
|
66
|
+
"Synchronize both loaded playback selectors to a real Windows endpoint");
|
|
67
|
+
AudioOutputs.PreferredKey = "compat:default";
|
|
68
|
+
Check(pickerA.Selected != null && pickerB.Selected != null && pickerA.Selected.Key == "default" && pickerB.Selected.Key == "default",
|
|
69
|
+
"Migrate a retired compatibility selection to the displayed Windows default in both selectors");
|
|
70
|
+
}
|
|
71
|
+
finally { AudioOutputs.PreferredKey = previousOutput; pickerA.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent)); pickerB.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent)); }
|
|
72
|
+
float[] precise = Enumerable.Range(0, 500).Select(i => (float)(i + 1) / 100).ToArray();
|
|
73
|
+
Clip ramp = Source(root, "Precise float source", precise, 1);
|
|
74
|
+
byte[] original = File.ReadAllBytes(ramp.Source);
|
|
75
|
+
var decoded = new Dictionary<string, Clip>(StringComparer.OrdinalIgnoreCase) { { ramp.Source, ramp } };
|
|
76
|
+
var session = new EditorSession();
|
|
77
|
+
session.Regions.Add(new TimelineRegion { Source = ramp.Source, Name = "Ramp", Track = 0, Start = 100, SourceStart = 20, Length = 100 });
|
|
78
|
+
var baseline = Render(session, decoded);
|
|
79
|
+
Check(baseline.Length == 400 && baseline.Take(200).All(v => v == 0), "Render silence before a region and stop exactly at the timeline end");
|
|
80
|
+
Check(Math.Abs(baseline[200] - precise[20]) < .000001 && baseline[200] == baseline[201] && Math.Abs(baseline[398] - precise[119]) < .000001,
|
|
81
|
+
"Use exact source-frame offsets and duplicate mono correctly into stereo");
|
|
82
|
+
var sixteen = new EditorSession();
|
|
83
|
+
for (int i = 0; i < 16; i++) sixteen.Regions.Add(new TimelineRegion { Source = ramp.Source, Name = "Input " + (i + 1), Track = i, Start = 0, SourceStart = 0, Length = 1 });
|
|
84
|
+
Check(Math.Abs(Render(sixteen, decoded)[0] - precise[0] * 16) < .000001, "Mix audio from all 16 editor tracks simultaneously");
|
|
85
|
+
Clip stereo = Source(root, "Stereo separation", Enumerable.Range(0, 512).Select(i => i % 2 == 0 ? .5f : -.25f).ToArray(), 2);
|
|
86
|
+
var stereoState = new EditorSession(); stereoState.Regions.Add(new TimelineRegion { Source = stereo.Source, Name = "Stereo", Track = 0, Start = 0, SourceStart = 10, Length = 100 });
|
|
87
|
+
var stereoAudio = Render(stereoState, new Dictionary<string, Clip> { { stereo.Source, stereo } });
|
|
88
|
+
Check(stereoAudio.Where((v, i) => i % 2 == 0).All(v => v == .5f) && stereoAudio.Where((v, i) => i % 2 == 1).All(v => v == -.25f), "Preserve stereo channel separation when trimming and mixing");
|
|
89
|
+
using (var mix = new TimelineMix(session, decoded)) {
|
|
90
|
+
mix.Seek(150); var block = new float[4]; mix.Read(block, 0, 4);
|
|
91
|
+
Check(Math.Abs(block[0] - precise[70]) < .000001 && mix.Position == 152, "Seek into a region without losing source position");
|
|
92
|
+
}
|
|
93
|
+
var split = session.Copy(); TimelineEdits.Split(split, 0, false, 150);
|
|
94
|
+
Check(split.Regions.Count == 2 && split.Regions[1].Start == 150 && split.Regions[1].SourceStart == 70 && Render(split, decoded).SequenceEqual(baseline),
|
|
95
|
+
"Split audio without changing any rendered sample or timing");
|
|
96
|
+
var cut = session.Copy(); TimelineEdits.Delete(cut, 0, false, 130, 170); var cutAudio = Render(cut, decoded);
|
|
97
|
+
Check(cut.Regions.Count == 2 && cut.Regions[0].Length == 30 && cut.Regions[1].Start == 170 && cut.Regions[1].SourceStart == 90,
|
|
98
|
+
"Cut the middle of a clip into correctly trimmed left and right regions");
|
|
99
|
+
Check(cutAudio.Skip(260).Take(80).All(v => v == 0) && cutAudio.Take(260).SequenceEqual(baseline.Take(260)) && cutAudio.Skip(340).SequenceEqual(baseline.Skip(340)),
|
|
100
|
+
"Leave the cut gap while preserving all unselected samples and alignment");
|
|
101
|
+
var copied = TimelineEdits.Copy(session, 0, false, 120, 180); var pasted = session.Copy();
|
|
102
|
+
TimelineEdits.Paste(pasted, copied, 15, 300); var lastTrack = pasted.Regions.Last();
|
|
103
|
+
Check(lastTrack.Track == 15 && lastTrack.Start == 300 && lastTrack.SourceStart == 40 && lastTrack.Length == 60,
|
|
104
|
+
"Copy a partial section and paste it into track 16 at the playhead");
|
|
105
|
+
Check(Math.Abs(Render(pasted, decoded)[600] - precise[40]) < .000001, "Render pasted audio from the copied source range");
|
|
106
|
+
var group = session.Copy(); group.Regions.Add(new TimelineRegion { Source = ramp.Source, Name = "Other", Track = 15, Start = 140, SourceStart = 5, Length = 30 });
|
|
107
|
+
var groupCopy = TimelineEdits.Copy(group, 0, true, 120, 180); TimelineEdits.Paste(group, groupCopy, 7, 500);
|
|
108
|
+
Check(group.Regions.Any(r => r.Track == 0 && r.Start == 500) && group.Regions.Any(r => r.Track == 15 && r.Start == 520),
|
|
109
|
+
"Copy all-track selections with original track numbers and relative timing");
|
|
110
|
+
var selectedOnly = group.Copy(); TimelineEdits.Delete(selectedOnly, 0, false, 0, 1000);
|
|
111
|
+
Check(selectedOnly.Regions.All(r => r.Track == 15), "Limit selected-track cuts to that track");
|
|
112
|
+
var invalid = session.Copy(); invalid.Regions[0].Track = 16; bool blocked = false;
|
|
113
|
+
try { invalid.Validate(); } catch (IOException) { blocked = true; }
|
|
114
|
+
Check(blocked, "Reject a track beyond the 16-track editor limit");
|
|
115
|
+
string saved = Path.Combine(root, "Practice.dbedit"); group.Save(saved); var reopened = EditorSession.Load(saved);
|
|
116
|
+
Check(reopened.Tracks.Length == 16 && Render(reopened, decoded).SequenceEqual(Render(group, decoded)), "Save and reopen editor sessions without changing edited audio");
|
|
117
|
+
var clone = group.Copy(); clone.Regions[0].Length = 1; clone.Tracks[0].GainDb = -12;
|
|
118
|
+
Check(group.Regions[0].Length != 1 && group.Tracks[0].GainDb == 0, "Keep undo snapshots independent of later region and track edits");
|
|
119
|
+
bool protectedSource = false; try { group.Save(ramp.Source); } catch (IOException) { protectedSource = true; }
|
|
120
|
+
Check(protectedSource && original.SequenceEqual(File.ReadAllBytes(ramp.Source)), "Refuse to save a session over original audio");
|
|
121
|
+
|
|
122
|
+
Clip left = Source(root, "Kick", Enumerable.Repeat(.6f, Audio.Rate).ToArray(), 1);
|
|
123
|
+
Clip right = Source(root, "Snare", Enumerable.Repeat(.7f, Audio.Rate).ToArray(), 1);
|
|
124
|
+
var mixedAssets = new Dictionary<string, Clip>(StringComparer.OrdinalIgnoreCase) { { left.Source, left }, { right.Source, right } };
|
|
125
|
+
var mixState = new EditorSession { Name = "Practice mix" };
|
|
126
|
+
mixState.Regions.Add(new TimelineRegion { Source = left.Source, Name = "Kick", Track = 0, Start = 0, Length = Audio.Rate });
|
|
127
|
+
mixState.Regions.Add(new TimelineRegion { Source = right.Source, Name = "Snare", Track = 1, Start = 0, Length = Audio.Rate });
|
|
128
|
+
Check(Render(mixState, mixedAssets).All(v => Math.Abs(v - 1.3f) < .00001), "Mix simultaneous tracks in float without clipping over-0-dBFS samples");
|
|
129
|
+
using (var mix = new TimelineMix(mixState, mixedAssets)) {
|
|
130
|
+
var listening = AudioOutputs.Adapt(mix, 48000, 2); var samples = new float[400]; int count = listening.Read(samples, 0, samples.Length);
|
|
131
|
+
Check(listening.WaveFormat.SampleRate == 48000 && count == 400 && samples.All(v => Math.Abs(v) <= 1),
|
|
132
|
+
"Adapt the float editor mix to the endpoint rate and bound its listening samples");
|
|
133
|
+
}
|
|
134
|
+
var solo = mixState.Copy(); solo.Tracks[1].Solo = true;
|
|
135
|
+
Check(Render(solo, mixedAssets).All(v => Math.Abs(v - .7f) < .00001), "Solo isolates the chosen editor track");
|
|
136
|
+
solo.Tracks[1].Mute = true; Check(Render(solo, mixedAssets).All(v => v == 0), "Mute silences a soloed track without leaking other tracks");
|
|
137
|
+
var level = mixState.Copy(); level.Tracks[0].GainDb = -6; level.Tracks[1].Mute = true;
|
|
138
|
+
Check(Math.Abs(Render(level, mixedAssets)[0] - .6 * Math.Pow(10, -.3)) < .00001, "Apply track level changes in the timeline mix");
|
|
139
|
+
foreach (int bits in new[] { 16, 24, 32 }) {
|
|
140
|
+
string path = Path.Combine(root, "editor-mix-" + bits + ".wav");
|
|
141
|
+
TimelineRender.Export(mixState, mixedAssets, path, bits, true, 0, 0, CancellationToken.None, null);
|
|
142
|
+
Clip exported = Audio.Load(path, Path.Combine(root, "cache"), CancellationToken.None, null);
|
|
143
|
+
using (var reader = new WaveFileReader(path)) Check(reader.WaveFormat.BitsPerSample == bits && reader.WaveFormat.SampleRate == Audio.Rate && reader.Length == Audio.Rate * 2 * bits / 8,
|
|
144
|
+
"Export exact-duration " + bits + "-bit stereo WAV from the multitrack editor");
|
|
145
|
+
Check(bits == 32 ? exported.Peak > 1.29 : Math.Abs(exported.PeakDb + 1) < .02,
|
|
146
|
+
bits == 32 ? "Preserve float mix headroom" : "Scale the full PCM mix to prevent clipping");
|
|
147
|
+
}
|
|
148
|
+
string selectedMix = Path.Combine(root, "selected-mix.wav");
|
|
149
|
+
TimelineRender.Export(mixState, mixedAssets, selectedMix, 32, false, Audio.Rate / 4, Audio.Rate / 2, CancellationToken.None, null);
|
|
150
|
+
using (var reader = new WaveFileReader(selectedMix)) Check(reader.Length == Audio.Rate / 4 * 8, "Export only the selected timeline range");
|
|
151
|
+
var cancelled = new CancellationTokenSource(); cancelled.Cancel(); string cancelledPath = Path.Combine(root, "cancelled.wav"); bool wasCancelled = false;
|
|
152
|
+
try { TimelineRender.Export(mixState, mixedAssets, cancelledPath, 32, false, 0, 0, cancelled.Token, null); } catch (OperationCanceledException) { wasCancelled = true; }
|
|
153
|
+
Check(wasCancelled && !File.Exists(cancelledPath) && !Directory.GetFiles(root, "*.partial").Any(), "Cancel rendering without leaving incomplete exports");
|
|
154
|
+
Check(original.SequenceEqual(File.ReadAllBytes(ramp.Source)), "Keep original source bytes unchanged through all timeline edits");
|
|
155
|
+
|
|
156
|
+
foreach (string codec in new[] { "pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "pcm_f64le" }) {
|
|
157
|
+
string file = Path.Combine(root, "player-" + codec + ".wav");
|
|
158
|
+
Audio.Run("-hide_banner -v error -nostdin -y -i " + Audio.Q(left.Source) + " -ar 48000 -c:a " + codec + " " + Audio.Q(file), CancellationToken.None, null, true);
|
|
159
|
+
var clip = Audio.Load(file, Path.Combine(root, "cache"), CancellationToken.None, null);
|
|
160
|
+
Check(clip.Ready && Math.Abs(clip.Duration - 1) < .001 && clip.Peak > .59 && clip.SourceFormat.Contains("48000 Hz"),
|
|
161
|
+
"Decode " + codec + " at 48 kHz for the Media Player and display source format");
|
|
162
|
+
}
|
|
163
|
+
string aiff = Path.Combine(root, "player-pcm-big-endian.aiff");
|
|
164
|
+
Audio.Run("-hide_banner -v error -nostdin -y -i " + Audio.Q(left.Source) + " -c:a pcm_s24be " + Audio.Q(aiff), CancellationToken.None, null, true);
|
|
165
|
+
Check(Audio.Load(aiff, Path.Combine(root, "cache"), CancellationToken.None, null).Ready, "Decode big-endian PCM AIFF without Windows Media Player codecs");
|
|
166
|
+
|
|
167
|
+
Window owner;
|
|
168
|
+
using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("Main.xaml")) owner = (Window)XamlReader.Load(stream);
|
|
169
|
+
ThemePalette.Install(owner);
|
|
170
|
+
Check(owner.FindName("AudioDevicesButton") != null, "Expose Audio devices in the main header on every workspace");
|
|
171
|
+
Picture((FrameworkElement)owner.Content, Path.Combine(root, "main-header-preview.png"), 1050, 850);
|
|
172
|
+
using (var devices = new AudioDevicesView(AudioInputs.Choices().FirstOrDefault(), delegate(InputChoice choice) { }, delegate { }, false)) {
|
|
173
|
+
owner.Content = devices; Picture(devices, Path.Combine(root, "audio-devices-preview.png"), 760, 640);
|
|
174
|
+
Check(devices.ActualWidth == 712 && devices.DesiredSize.Height <= 640, "Render output selector, speaker test, microphone selector and diagnostics together");
|
|
175
|
+
}
|
|
176
|
+
using (var view = new EditorView(owner, Path.Combine(root, "editor-cache"), delegate(string message) { }, delegate { })) {
|
|
177
|
+
owner.Content = view; view.SetForTest(mixState, mixedAssets);
|
|
178
|
+
Check(view.CheckUndoRedoForTest(), "Exercise the editor's actual cut, undo and redo handlers");
|
|
179
|
+
float[] pulse = new float[Audio.Rate * 8];
|
|
180
|
+
for (int i = 0; i < pulse.Length; i++) pulse[i] = (float)(.7 * Math.Exp(-(i % (Audio.Rate / 2)) / 3000.0) * Math.Sin(i * .06));
|
|
181
|
+
var beat = Source(root, "Generated drum reference", pulse, 1);
|
|
182
|
+
var previewAssets = new Dictionary<string, Clip>(StringComparer.OrdinalIgnoreCase) { { beat.Source, beat } };
|
|
183
|
+
var previewState = new EditorSession { Name = "Practice · generated test audio" };
|
|
184
|
+
string[] names = { "Kick", "Snare", "Hi-hat", "Tom 1", "Tom 2", "Floor tom", "Overhead L", "Overhead R", "Room L", "Room R", "Percussion", "Bass guide", "Guitar guide", "Keys guide", "Click", "Reference" };
|
|
185
|
+
for (int i = 0; i < 16; i++) { previewState.Tracks[i].Name = names[i]; previewState.Regions.Add(new TimelineRegion { Source = beat.Source, Name = names[i], Track = i, Start = (i % 3) * Audio.Rate / 2, SourceStart = i % 2 * Audio.Rate, Length = (6 - i % 3) * Audio.Rate }); }
|
|
186
|
+
view.SetForTest(previewState, previewAssets); view.ShowSelectionForTest(Audio.Rate, 4L * Audio.Rate); Picture(view, Path.Combine(root, "editor-preview.png"), 1280, 760);
|
|
187
|
+
Picture(view, Path.Combine(root, "editor-small-preview.png"), 800, 560);
|
|
188
|
+
Check(view.ActualWidth == 800 && view.ActualHeight == 560, "Lay out editor controls and scrolling timeline at small window size");
|
|
189
|
+
}
|
|
190
|
+
using (var view = new MediaPlayerView(owner, Path.Combine(root, "player-cache"), delegate(string message) { }, delegate { })) {
|
|
191
|
+
owner.Content = view; view.SetForTest(left); Picture(view, Path.Combine(root, "player-preview.png"), 1100, 760);
|
|
192
|
+
Picture(view, Path.Combine(root, "player-small-preview.png"), 800, 560);
|
|
193
|
+
Check(view.ActualWidth == 800, "Render the Media Player waveform, playlist, transport and seek controls");
|
|
194
|
+
}
|
|
195
|
+
owner.Content = null;
|
|
196
|
+
log.Add("Rendered views use generated samples. Realtek playback on the affected PC and physical Windows/H5studio capture need a connected-device acceptance test.");
|
|
197
|
+
File.WriteAllLines(Path.Combine(root, "workspace-test-results.txt"), log, Encoding.UTF8); return 0;
|
|
198
|
+
}
|
|
199
|
+
catch (Exception ex) { log.Add("FAIL " + ex); File.WriteAllLines(Path.Combine(root, "workspace-test-results.txt"), log, Encoding.UTF8); return 1; }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.IO;
|
|
3
|
+
using System.Linq;
|
|
4
|
+
using System.Text;
|
|
5
|
+
using System.Threading;
|
|
6
|
+
using System.Collections.Generic;
|
|
7
|
+
using System.Diagnostics;
|
|
8
|
+
using System.Windows;
|
|
9
|
+
using System.Windows.Controls;
|
|
10
|
+
using System.Windows.Input;
|
|
11
|
+
using System.Windows.Media;
|
|
12
|
+
using System.Windows.Media.Imaging;
|
|
13
|
+
using System.Windows.Threading;
|
|
14
|
+
|
|
15
|
+
namespace DrumBuddy
|
|
16
|
+
{
|
|
17
|
+
public static class ZoomTests
|
|
18
|
+
{
|
|
19
|
+
private static readonly List<string> log = new List<string>();
|
|
20
|
+
private static void Check(bool value, string name) { if (!value) throw new Exception(name); log.Add("PASS " + name); }
|
|
21
|
+
private static void Wait(Func<bool> ready)
|
|
22
|
+
{
|
|
23
|
+
var watch = Stopwatch.StartNew(); var frame = new DispatcherFrame();
|
|
24
|
+
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) };
|
|
25
|
+
timer.Tick += delegate { if (ready() || watch.ElapsedMilliseconds > 5000) { timer.Stop(); frame.Continue = false; } };
|
|
26
|
+
timer.Start(); Dispatcher.PushFrame(frame);
|
|
27
|
+
}
|
|
28
|
+
private static void Picture(FrameworkElement view, string file, int width, int height)
|
|
29
|
+
{
|
|
30
|
+
view.Measure(new Size(width, height)); view.Arrange(new Rect(0, 0, width, height)); view.UpdateLayout();
|
|
31
|
+
var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); bitmap.Render(view);
|
|
32
|
+
var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var stream = File.Create(file)) encoder.Save(stream);
|
|
33
|
+
}
|
|
34
|
+
public static int Run(string[] args)
|
|
35
|
+
{
|
|
36
|
+
string root = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(Path.GetTempPath(), "FluxZoom_" + Guid.NewGuid().ToString("N")));
|
|
37
|
+
Directory.CreateDirectory(root); log.Clear(); var previous = InterfacePreferences.Current;
|
|
38
|
+
try {
|
|
39
|
+
InterfacePreferences.Current = InterfacePreferences.Defaults(); ThemePalette.Apply(true);
|
|
40
|
+
Check(TimelineZoom.Clamp(double.PositiveInfinity) == 40 && TimelineZoom.Clamp(-1) == .01 && TimelineZoom.Clamp(1e8) == Audio.Rate * 2, "Bound invalid and extreme zoom levels without invalid WPF dimensions");
|
|
41
|
+
Check(TimelineZoom.Fit(Audio.Rate * 86400L, 1000) * 86400 <= 1000, "Fit a 24-hour session into the viewport");
|
|
42
|
+
Check(TimelineZoom.SecondStep(Audio.Rate * 2) < .002, "Use fractional-second ruler marks at sample-level zoom");
|
|
43
|
+
Check(InterfacePreferences.Resolve(Key.OemPlus, ModifierKeys.Control) == "zoomIn" && InterfacePreferences.Resolve(Key.D0, ModifierKeys.Control | ModifierKeys.Shift) == "zoomSelection", "Expose editable zoom and fit shortcuts");
|
|
44
|
+
var legacy = InterfacePreferences.Defaults(); legacy.Keybinds.RemoveAll(b => b.Action.StartsWith("zoom"));
|
|
45
|
+
legacy.Keybinds.First(b => b.Action == "play").Key = "D0"; legacy.Keybinds.First(b => b.Action == "play").Modifiers = (int)ModifierKeys.Control; InterfacePreferences.Upgrade(legacy);
|
|
46
|
+
Check(legacy.Keybinds.First(b => b.Action == "zoomFit").Key == "None" && legacy.Keybinds.First(b => b.Action == "play").Key == "D0", "Preserve a custom binding that conflicts with the new Fit shortcut");
|
|
47
|
+
string file = Path.Combine(root, "Original stereo float.wav"); var samples = new float[Audio.Rate * 4 * 2];
|
|
48
|
+
for (int i = 0; i < samples.Length / 2; i++) { samples[i * 2] = (float)(.4 * Math.Sin(i * .05)); samples[i * 2 + 1] = -samples[i * 2]; }
|
|
49
|
+
samples[2000] = .8f; samples[2001] = -.6f; samples[2002] = .1f; samples[2003] = -1.2f;
|
|
50
|
+
using (var writer = new FloatWaveWriter(file, 2, Audio.Rate)) writer.Write(samples, samples.Length, 1);
|
|
51
|
+
byte[] original = File.ReadAllBytes(file);
|
|
52
|
+
var detail = WaveformDetail.Read(file, 1000, 1002, 8, CancellationToken.None);
|
|
53
|
+
Check(detail.High.Take(4).All(v => v == .8f) && detail.Low.Take(4).All(v => v == -.6f), "Read actual positive and negative stereo samples at the requested source offset");
|
|
54
|
+
Check(detail.Low.Skip(4).All(v => v == -1.2f) && detail.High.Skip(4).All(v => v == .1f), "Preserve float headroom and opposite-phase channel detail when zoomed in");
|
|
55
|
+
var cancelled = new CancellationTokenSource(); cancelled.Cancel(); bool stopped = false;
|
|
56
|
+
try { WaveformDetail.Read(file, 0, 100, 10, cancelled.Token); } catch (OperationCanceledException) { stopped = true; }
|
|
57
|
+
Check(stopped, "Cancel an obsolete waveform request before reading samples");
|
|
58
|
+
bool bounded = false; try { WaveformDetail.Read(file, 0, Audio.Rate * 13L, 100, CancellationToken.None); } catch (ArgumentOutOfRangeException) { bounded = true; }
|
|
59
|
+
Check(bounded, "Bound each detailed waveform read instead of loading the full recording");
|
|
60
|
+
var clip = new Clip { Source = file, Cache = file, Name = "Stereo hit", Channels = 2, Duration = 4, Peak = 1.2, Waveform = Enumerable.Repeat(1.2f, 900).ToArray(), Ready = true };
|
|
61
|
+
var session = new EditorSession { Name = "Zoom detail" };
|
|
62
|
+
session.Regions.Add(new TimelineRegion { Source = file, Name = "Stereo hit", Track = 0, Start = Audio.Rate * 5L, SourceStart = 1000, Length = Audio.Rate * 2L });
|
|
63
|
+
var owner = new Window(); ThemePalette.Install(owner);
|
|
64
|
+
using (var editor = new EditorView(owner, Path.Combine(root, "cache"), delegate { }, delegate { })) {
|
|
65
|
+
owner.Content = editor; editor.SetForTest(session, new Dictionary<string, Clip> { { file, clip } });
|
|
66
|
+
Picture(editor, Path.Combine(root, "zoom-overview.png"), 1100, 650);
|
|
67
|
+
editor.ZoomForTest(80); editor.OffsetForTestSet(300); double before = (editor.OffsetForTest + 205) / editor.ZoomPixelsForTest;
|
|
68
|
+
editor.ZoomAtForTest(640, 205);
|
|
69
|
+
Check(Math.Abs((editor.OffsetForTest + 205) / editor.ZoomPixelsForTest - before) < 1.0 / Audio.Rate, "Keep the exact time under the pointer while zooming a scrolled timeline");
|
|
70
|
+
Wait(() => editor.DetailsForTest > 0);
|
|
71
|
+
Check(editor.DetailsForTest > 0, "Replace the overview with a real sample envelope asynchronously");
|
|
72
|
+
editor.ShowSelectionForTest(Audio.Rate * 5L + 10000, Audio.Rate * 5L + 11000);
|
|
73
|
+
editor.ExecuteAction("zoomSelection").GetAwaiter().GetResult();
|
|
74
|
+
double first = (Audio.Rate * 5L + 10000) / (double)Audio.Rate * editor.ZoomPixelsForTest - editor.OffsetForTest;
|
|
75
|
+
double last = (Audio.Rate * 5L + 11000) / (double)Audio.Rate * editor.ZoomPixelsForTest - editor.OffsetForTest;
|
|
76
|
+
Check(first >= 0 && last <= editor.WidthForTest && last - first >= editor.WidthForTest - 34, "Fit the selected hit into the visible timeline with a small margin");
|
|
77
|
+
Wait(() => editor.DetailsForTest > 0); Picture(editor, Path.Combine(root, "zoom-selected-hit.png"), 1100, 650);
|
|
78
|
+
editor.ExecuteAction("zoomIn").GetAwaiter().GetResult(); editor.ExecuteAction("zoomOut").GetAwaiter().GetResult();
|
|
79
|
+
Check(!editor.Dirty && editor.PlayheadFrame == 0 && editor.SessionForTest.Regions[0].SourceStart == 1000, "Zoom leaves the playhead, source offsets and undo/dirty state unchanged");
|
|
80
|
+
editor.ExecuteAction("zoomFit").GetAwaiter().GetResult();
|
|
81
|
+
Check(editor.OffsetForTest == 0 && session.End / (double)Audio.Rate * editor.ZoomPixelsForTest <= editor.WidthForTest, "Fit all project audio and return the view to the start");
|
|
82
|
+
Picture(editor, Path.Combine(root, "zoom-compact.png"), 700, 480);
|
|
83
|
+
Check(editor.WidthForTest > 300, "Keep an accessible timeline and zoom strip in a compact editor");
|
|
84
|
+
editor.ZoomForTest(Audio.Rate * 2); editor.OffsetForTestSet(Audio.Rate * 5 * editor.ZoomPixelsForTest / Audio.Rate);
|
|
85
|
+
Wait(() => editor.DetailsForTest > 0); Picture(editor, Path.Combine(root, "zoom-samples.png"), 1100, 650);
|
|
86
|
+
}
|
|
87
|
+
owner.Content = null; owner.Close();
|
|
88
|
+
Check(original.SequenceEqual(File.ReadAllBytes(file)), "Preserve the original audio bytes through detailed reads and zoom operations");
|
|
89
|
+
File.WriteAllLines(Path.Combine(root, "zoom-test-results.txt"), log, Encoding.UTF8); return 0;
|
|
90
|
+
}
|
|
91
|
+
catch (Exception ex) { log.Add("FAIL " + ex); File.WriteAllLines(Path.Combine(root, "zoom-test-results.txt"), log, Encoding.UTF8); return 1; }
|
|
92
|
+
finally { InterfacePreferences.Current = previous; ThemePalette.Apply(previous.Theme == "Dark"); }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
|
3
|
+
<assemblyIdentity version="1.0.0.0" name="DrumBuddy"/>
|
|
4
|
+
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"><security><requestedPrivileges><requestedExecutionLevel level="asInvoker" uiAccess="false"/></requestedPrivileges></security></trustInfo>
|
|
5
|
+
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"><application><supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/></application></compatibility>
|
|
6
|
+
<application xmlns="urn:schemas-microsoft-com:asm.v3"><windowsSettings><dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware><dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness><longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware></windowsSettings></application>
|
|
7
|
+
</assembly>
|
|
Binary file
|