@pro-gram/flux 1.5.0 → 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/NOTICE.txt +4 -3
- package/README.md +29 -6
- package/dependencies.json +2 -1
- package/package.json +2 -2
- package/payload/DrumBuddy-Setup.exe +0 -0
- package/scripts/install.js +4 -2
- package/source/AssetBootstrap.cs +1 -1
- package/source/Audio Decoders.txt +230 -0
- package/source/AudioDeviceSettings.cs +1 -1
- package/source/AudioEndpointWatcher.cs +25 -0
- package/source/AudioOutputs.cs +40 -60
- package/source/AudioWorkspace.cs +2 -1
- package/source/BrandArtwork.cs +2 -2
- package/source/Build Instructions.txt +9 -3
- package/source/Build.ps1 +21 -9
- 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 +247 -75
- package/source/Flux-Logo.png +0 -0
- package/source/InterfaceSettings.cs +10 -4
- package/source/InterfaceTests.cs +6 -0
- package/source/Main.cs +19 -13
- package/source/Main.xaml +25 -28
- package/source/PlaybackTests.cs +13 -6
- package/source/Quick Start.txt +25 -6
- package/source/SettingsWindow.cs +4 -3
- package/source/Setup.cs +9 -5
- package/source/Third Party Notices.txt +4 -3
- package/source/Timeline.cs +24 -17
- package/source/TimelineZoom.cs +21 -0
- package/source/WaveformDetail.cs +104 -0
- package/source/WindowsAudioPlayer.cs +137 -0
- package/source/WorkspaceTests.cs +7 -3
- package/source/ZoomTests.cs +95 -0
package/source/Timeline.cs
CHANGED
|
@@ -22,6 +22,8 @@ namespace DrumBuddy
|
|
|
22
22
|
public string Id = Guid.NewGuid().ToString("N"), Source = "", Name = "";
|
|
23
23
|
public int Track;
|
|
24
24
|
public long Start, SourceStart, Length;
|
|
25
|
+
public long FadeIn, FadeOut, FadeInOffset, FadeOutOffset;
|
|
26
|
+
public bool Muted;
|
|
25
27
|
public long End { get { return Start + Length; } }
|
|
26
28
|
public TimelineRegion Copy() { return (TimelineRegion)MemberwiseClone(); }
|
|
27
29
|
}
|
|
@@ -48,7 +50,7 @@ namespace DrumBuddy
|
|
|
48
50
|
throw new IOException("The session has an invalid track level.");
|
|
49
51
|
foreach (var r in Regions)
|
|
50
52
|
if (r == null || r.Track < 0 || r.Track >= 16 || string.IsNullOrWhiteSpace(r.Source) ||
|
|
51
|
-
r.Start < 0 || r.SourceStart < 0 || r.Length <= 0 || r.Start > MaxFrame || r.SourceStart > MaxFrame || r.Length > MaxFrame || r.End > MaxFrame)
|
|
53
|
+
r.Start < 0 || r.SourceStart < 0 || r.Length <= 0 || r.Start > MaxFrame || r.SourceStart > MaxFrame || r.Length > MaxFrame || r.End > MaxFrame || r.FadeIn < 0 || r.FadeOut < 0 || r.FadeIn > MaxFrame || r.FadeOut > MaxFrame || r.FadeInOffset < 0 || r.FadeOutOffset < 0 || r.FadeInOffset > MaxFrame || r.FadeOutOffset > MaxFrame)
|
|
52
54
|
throw new IOException("The session has an invalid audio region.");
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -82,39 +84,43 @@ namespace DrumBuddy
|
|
|
82
84
|
|
|
83
85
|
public static class TimelineEdits
|
|
84
86
|
{
|
|
85
|
-
private static bool Included(TimelineRegion region, int track, bool all) { return all || region.Track == track; }
|
|
86
|
-
|
|
87
|
+
private static bool Included(TimelineRegion region, int track, bool all, ISet<string> ids = null) { return ids != null ? ids.Contains(region.Id) : all || region.Track == track; }
|
|
88
|
+
private static TimelineRegion Slice(TimelineRegion region, long from, long to)
|
|
89
|
+
{
|
|
90
|
+
var part = region.Copy(); part.Start = from; part.SourceStart += from - region.Start; part.Length = to - from;
|
|
91
|
+
part.FadeInOffset += from - region.Start; part.FadeOutOffset += region.End - to; return part;
|
|
92
|
+
}
|
|
93
|
+
public static TimelineClipboard Copy(EditorSession session, int track, bool all, long from, long to, ISet<string> ids = null)
|
|
87
94
|
{
|
|
88
95
|
if (from < 0 || to <= from) throw new ArgumentException("Select a time range first.");
|
|
89
96
|
var copied = new TimelineClipboard { AllTracks = all, Length = to - from };
|
|
90
|
-
foreach (var r in session.Regions.Where(r => Included(r, track, all) && r.Start < to && r.End > from)) {
|
|
97
|
+
foreach (var r in session.Regions.Where(r => Included(r, track, all, ids) && r.Start < to && r.End > from)) {
|
|
91
98
|
long left = Math.Max(from, r.Start), right = Math.Min(to, r.End);
|
|
92
|
-
var part = r
|
|
93
|
-
part.Start = left - from; part.SourceStart += left - r.Start; part.Length = right - left;
|
|
99
|
+
var part = Slice(r, left, right); part.Id = Guid.NewGuid().ToString("N"); part.Start -= from;
|
|
94
100
|
if (!all) part.Track = 0;
|
|
95
101
|
copied.Regions.Add(part);
|
|
96
102
|
}
|
|
97
103
|
return copied;
|
|
98
104
|
}
|
|
99
|
-
public static bool Delete(EditorSession session, int track, bool all, long from, long to)
|
|
105
|
+
public static bool Delete(EditorSession session, int track, bool all, long from, long to, ISet<string> ids = null)
|
|
100
106
|
{
|
|
101
107
|
if (from < 0 || to <= from) return false;
|
|
102
108
|
var result = new List<TimelineRegion>(); bool changed = false;
|
|
103
109
|
foreach (var r in session.Regions) {
|
|
104
|
-
if (!Included(r, track, all) || r.Start >= to || r.End <= from) { result.Add(r); continue; }
|
|
110
|
+
if (!Included(r, track, all, ids) || r.Start >= to || r.End <= from) { result.Add(r); continue; }
|
|
105
111
|
changed = true;
|
|
106
|
-
if (r.Start < from)
|
|
107
|
-
if (r.End > to) { var right = r.
|
|
112
|
+
if (r.Start < from) result.Add(Slice(r, r.Start, from));
|
|
113
|
+
if (r.End > to) { var right = Slice(r, to, r.End); right.Id = Guid.NewGuid().ToString("N"); result.Add(right); }
|
|
108
114
|
}
|
|
109
115
|
session.Regions = result; return changed;
|
|
110
116
|
}
|
|
111
|
-
public static bool Split(EditorSession session, int track, bool all, long frame)
|
|
117
|
+
public static bool Split(EditorSession session, int track, bool all, long frame, ISet<string> ids = null)
|
|
112
118
|
{
|
|
113
119
|
var result = new List<TimelineRegion>(); bool changed = false;
|
|
114
120
|
foreach (var r in session.Regions) {
|
|
115
|
-
if (!Included(r, track, all) || frame <= r.Start || frame >= r.End) { result.Add(r); continue; }
|
|
116
|
-
var left = r
|
|
117
|
-
var right = r.
|
|
121
|
+
if (!Included(r, track, all, ids) || frame <= r.Start || frame >= r.End) { result.Add(r); continue; }
|
|
122
|
+
var left = Slice(r, r.Start, frame);
|
|
123
|
+
var right = Slice(r, frame, r.End); right.Id = Guid.NewGuid().ToString("N");
|
|
118
124
|
result.Add(left); result.Add(right); changed = true;
|
|
119
125
|
}
|
|
120
126
|
session.Regions = result; return changed;
|
|
@@ -185,7 +191,7 @@ namespace DrumBuddy
|
|
|
185
191
|
long blockEnd = position + frames;
|
|
186
192
|
foreach (var r in session.Regions) {
|
|
187
193
|
var track = session.Tracks[r.Track];
|
|
188
|
-
if (track.Mute || (solo && !track.Solo) || r.Start >= blockEnd || r.End <= position) continue;
|
|
194
|
+
if (r.Muted || track.Mute || (solo && !track.Solo) || r.Start >= blockEnd || r.End <= position) continue;
|
|
189
195
|
long from = Math.Max(r.Start, position), to = Math.Min(r.End, blockEnd);
|
|
190
196
|
int take = (int)(to - from), skip = (int)(from - position);
|
|
191
197
|
var asset = assets[r.Source]; int channels = asset.Reader.WaveFormat.Channels;
|
|
@@ -198,8 +204,9 @@ namespace DrumBuddy
|
|
|
198
204
|
Buffer.BlockCopy(asset.Bytes, 0, asset.Samples, 0, got);
|
|
199
205
|
float gain = (float)Math.Pow(10, track.GainDb / 20);
|
|
200
206
|
for (int f = 0; f < take; f++) {
|
|
201
|
-
float
|
|
202
|
-
float
|
|
207
|
+
float envelope = (float)EditorTools.Envelope(r, from + f - r.Start);
|
|
208
|
+
float left = asset.Samples[f * channels] * gain * envelope;
|
|
209
|
+
float right = asset.Samples[f * channels + (channels == 1 ? 0 : 1)] * gain * envelope;
|
|
203
210
|
output[offset + (skip + f) * 2] += left; output[offset + (skip + f) * 2 + 1] += right;
|
|
204
211
|
TrackPeaks[r.Track] = Math.Max(TrackPeaks[r.Track], Math.Max(Math.Abs(left), Math.Abs(right)));
|
|
205
212
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
|
|
3
|
+
namespace DrumBuddy
|
|
4
|
+
{
|
|
5
|
+
internal static class TimelineZoom
|
|
6
|
+
{
|
|
7
|
+
public const double Minimum = .01, Maximum = Audio.Rate * 2;
|
|
8
|
+
public static double Clamp(double value)
|
|
9
|
+
{ return double.IsNaN(value) || double.IsInfinity(value) ? 40 : Math.Max(Minimum, Math.Min(Maximum, value)); }
|
|
10
|
+
public static double Offset(double oldScale, double newScale, double offset, double pointer)
|
|
11
|
+
{ return Math.Max(0, (Math.Max(0, offset) + Math.Max(0, pointer)) / oldScale * newScale - Math.Max(0, pointer)); }
|
|
12
|
+
public static double Fit(long frames, double width)
|
|
13
|
+
{ return Clamp(Math.Max(1, width - 32) * Audio.Rate / Math.Max(1, frames)); }
|
|
14
|
+
public static double SecondStep(double scale)
|
|
15
|
+
{
|
|
16
|
+
double desired = 70 / scale, power = Math.Pow(10, Math.Floor(Math.Log10(desired)));
|
|
17
|
+
double unit = desired / power;
|
|
18
|
+
return (unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 5 ? 5 : 10) * power;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -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
|
+
}
|
package/source/WorkspaceTests.cs
CHANGED
|
@@ -53,16 +53,20 @@ namespace DrumBuddy
|
|
|
53
53
|
using (var read = new StringReader(xml.ToString())) {
|
|
54
54
|
var reopenedPreference = (Preferences)serializer.Deserialize(read);
|
|
55
55
|
Check(reopenedPreference.AudioInputKey == preference.AudioInputKey && reopenedPreference.AudioOutputKey == preference.AudioOutputKey,
|
|
56
|
-
"
|
|
56
|
+
"Read legacy microphone/output preferences before migrating retired output identifiers");
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
var pickerA = new AudioOutputPicker(delegate { }, delegate(string text) { }); var pickerB = new AudioOutputPicker(delegate { }, delegate(string text) { });
|
|
60
60
|
string previousOutput = AudioOutputs.PreferredKey;
|
|
61
61
|
pickerA.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent)); pickerB.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent));
|
|
62
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");
|
|
63
67
|
AudioOutputs.PreferredKey = "compat:default";
|
|
64
|
-
Check(pickerA.Selected != null && pickerB.Selected != null && pickerA.Selected.Key == "
|
|
65
|
-
"
|
|
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");
|
|
66
70
|
}
|
|
67
71
|
finally { AudioOutputs.PreferredKey = previousOutput; pickerA.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent)); pickerB.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent)); }
|
|
68
72
|
float[] precise = Enumerable.Range(0, 500).Select(i => (float)(i + 1) / 100).ToArray();
|
|
@@ -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
|
+
}
|