@pro-gram/flux 1.5.0 → 1.6.1
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 +42 -8
- package/dependencies.json +2 -1
- package/package.json +3 -3
- 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 +28 -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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.IO;
|
|
3
|
+
using System.Linq;
|
|
4
|
+
using System.Collections.Generic;
|
|
5
|
+
|
|
6
|
+
namespace DrumBuddy
|
|
7
|
+
{
|
|
8
|
+
internal enum ClipDrag { None, Move, TrimLeft, TrimRight, Slip, FadeIn, FadeOut }
|
|
9
|
+
public static class EditorTools
|
|
10
|
+
{
|
|
11
|
+
public static double Envelope(TimelineRegion region, long offset)
|
|
12
|
+
{
|
|
13
|
+
double value = 1;
|
|
14
|
+
if (region.FadeIn > 0) value = Math.Min(value, Math.Max(0, offset + region.FadeInOffset) / (double)region.FadeIn);
|
|
15
|
+
if (region.FadeOut > 0) value = Math.Min(value, Math.Max(0, region.Length - 1 - offset + region.FadeOutOffset) / (double)region.FadeOut);
|
|
16
|
+
return Math.Max(0, Math.Min(1, value));
|
|
17
|
+
}
|
|
18
|
+
public static void ClampFades(TimelineRegion region)
|
|
19
|
+
{ region.FadeIn = Math.Max(0, Math.Min(region.Length, region.FadeIn)); region.FadeOut = Math.Max(0, Math.Min(region.Length, region.FadeOut)); region.FadeInOffset = region.FadeOutOffset = 0; }
|
|
20
|
+
public static bool Move(EditorSession session, ISet<string> ids, long delta, int tracks)
|
|
21
|
+
{
|
|
22
|
+
var selected = session.Regions.Where(r => ids.Contains(r.Id)).ToArray(); if (selected.Length == 0) return false;
|
|
23
|
+
delta = Math.Max(-selected.Min(r => r.Start), Math.Min(EditorSession.MaxFrame - selected.Max(r => r.End), delta));
|
|
24
|
+
tracks = Math.Max(-selected.Min(r => r.Track), Math.Min(15 - selected.Max(r => r.Track), tracks));
|
|
25
|
+
if (delta == 0 && tracks == 0) return false;
|
|
26
|
+
foreach (var region in selected) { region.Start += delta; region.Track += tracks; } return true;
|
|
27
|
+
}
|
|
28
|
+
public static bool Trim(TimelineRegion region, bool left, long boundary, long sourceFrames)
|
|
29
|
+
{
|
|
30
|
+
if (sourceFrames < region.SourceStart + region.Length) throw new IOException("Re-import the source before extending this clip.");
|
|
31
|
+
long before = region.Length;
|
|
32
|
+
if (left) {
|
|
33
|
+
long start = Math.Max(Math.Max(0, region.Start - region.SourceStart), Math.Min(region.End - 1, boundary));
|
|
34
|
+
long delta = start - region.Start; region.Start = start; region.SourceStart += delta; region.Length -= delta;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
long end = Math.Max(region.Start + 1, Math.Min(Math.Min(EditorSession.MaxFrame, region.Start + sourceFrames - region.SourceStart), boundary)); region.Length = end - region.Start;
|
|
38
|
+
}
|
|
39
|
+
ClampFades(region); return before != region.Length;
|
|
40
|
+
}
|
|
41
|
+
public static bool Slip(TimelineRegion region, long delta, long sourceFrames)
|
|
42
|
+
{
|
|
43
|
+
long before = region.SourceStart; region.SourceStart = Math.Max(0, Math.Min(sourceFrames - region.Length, region.SourceStart + delta));
|
|
44
|
+
return before != region.SourceStart;
|
|
45
|
+
}
|
|
46
|
+
public static bool Fade(TimelineRegion region, bool fadeIn, long length)
|
|
47
|
+
{
|
|
48
|
+
length = Math.Max(0, Math.Min(region.Length, length)); long before = fadeIn ? region.FadeIn : region.FadeOut;
|
|
49
|
+
if (fadeIn) { region.FadeIn = length; region.FadeInOffset = 0; } else { region.FadeOut = length; region.FadeOutOffset = 0; } return before != length;
|
|
50
|
+
}
|
|
51
|
+
public static List<string> Repeat(EditorSession session, ISet<string> ids, int copies)
|
|
52
|
+
{
|
|
53
|
+
var source = session.Regions.Where(r => ids.Contains(r.Id)).ToArray();
|
|
54
|
+
if (source.Length == 0) throw new IOException("Select clips to duplicate or repeat.");
|
|
55
|
+
if (copies < 1 || copies > 64 || session.Regions.Count + copies * source.Length > 20000) throw new IOException("Choose 1 to 64 additional copies within the session's region limit.");
|
|
56
|
+
long length = source.Max(r => r.End) - source.Min(r => r.Start);
|
|
57
|
+
if (length > (EditorSession.MaxFrame - source.Max(r => r.End)) / copies) throw new IOException("The repeats would exceed the 24-hour timeline.");
|
|
58
|
+
var added = new List<string>();
|
|
59
|
+
for (int n = 1; n <= copies; n++) foreach (var item in source) {
|
|
60
|
+
var copy = item.Copy(); copy.Id = Guid.NewGuid().ToString("N"); copy.Start += length * n; session.Regions.Add(copy); added.Add(copy.Id);
|
|
61
|
+
}
|
|
62
|
+
return added;
|
|
63
|
+
}
|
|
64
|
+
public static bool ToggleMute(EditorSession session, ISet<string> ids)
|
|
65
|
+
{
|
|
66
|
+
var selected = session.Regions.Where(r => ids.Contains(r.Id)).ToArray(); if (selected.Length == 0) return false;
|
|
67
|
+
bool mute = selected.Any(r => !r.Muted); foreach (var region in selected) region.Muted = mute; return true;
|
|
68
|
+
}
|
|
69
|
+
public static bool Crossfade(EditorSession session, ISet<string> ids)
|
|
70
|
+
{
|
|
71
|
+
var selected = session.Regions.Where(r => ids.Contains(r.Id)).OrderBy(r => r.Start).ToArray();
|
|
72
|
+
if (selected.Length != 2 || selected[0].Track != selected[1].Track || selected[0].Start >= selected[1].Start || selected[0].End <= selected[1].Start || selected[0].End > selected[1].End)
|
|
73
|
+
throw new IOException("Select two overlapping clips on one track, with the second extending beyond the first, to crossfade.");
|
|
74
|
+
long overlap = selected[0].End - selected[1].Start;
|
|
75
|
+
selected[0].FadeOut = overlap; selected[0].FadeOutOffset = 0; selected[1].FadeIn = overlap; selected[1].FadeInOffset = 1; return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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.Windows;
|
|
8
|
+
using System.Windows.Media;
|
|
9
|
+
using System.Windows.Media.Imaging;
|
|
10
|
+
|
|
11
|
+
namespace DrumBuddy
|
|
12
|
+
{
|
|
13
|
+
public static class EditorToolsTests
|
|
14
|
+
{
|
|
15
|
+
private static readonly List<string> log = new List<string>();
|
|
16
|
+
private static void Check(bool value, string text) { if (!value) throw new Exception(text); log.Add("PASS " + text); }
|
|
17
|
+
private static long Frame(double seconds) { return (long)Math.Round(seconds * Audio.Rate); }
|
|
18
|
+
private static float[] Render(EditorSession session, IDictionary<string, Clip> assets)
|
|
19
|
+
{
|
|
20
|
+
using (var mix = new TimelineMix(session, assets)) { var output = new float[checked((int)session.End * 2)]; int n = 0, read; while ((read = mix.Read(output, n, output.Length - n)) > 0) n += read; return output; }
|
|
21
|
+
}
|
|
22
|
+
private static void Picture(FrameworkElement view, string file)
|
|
23
|
+
{
|
|
24
|
+
view.Measure(new Size(1100, 650)); view.Arrange(new Rect(0, 0, 1100, 650)); view.UpdateLayout();
|
|
25
|
+
var bitmap = new RenderTargetBitmap(1100, 650, 96, 96, PixelFormats.Pbgra32); bitmap.Render(view);
|
|
26
|
+
var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var output = File.Create(file)) encoder.Save(output);
|
|
27
|
+
}
|
|
28
|
+
public static int Run(string[] args)
|
|
29
|
+
{
|
|
30
|
+
string root = Path.GetFullPath(args[0]); Directory.CreateDirectory(root); log.Clear(); var preferences = InterfacePreferences.Current;
|
|
31
|
+
try {
|
|
32
|
+
InterfacePreferences.Current = InterfacePreferences.Defaults(); InterfacePreferences.Current.SnapToGrid = InterfacePreferences.Current.SnapToEdges = false;
|
|
33
|
+
string file = Path.Combine(root, "Original float.wav"); var samples = Enumerable.Repeat(1.5f, Audio.Rate * 4).ToArray();
|
|
34
|
+
using (var writer = new FloatWaveWriter(file, 1, Audio.Rate)) writer.Write(samples, samples.Length, 1);
|
|
35
|
+
byte[] original = File.ReadAllBytes(file);
|
|
36
|
+
var clip = new Clip { Source = file, Cache = file, Name = "Original float.wav", Channels = 1, Duration = 4, Peak = 1.5, Waveform = Enumerable.Repeat(1.5f, 900).ToArray(), Ready = true };
|
|
37
|
+
var assets = new Dictionary<string, Clip> { { file, clip } };
|
|
38
|
+
var fade = new EditorSession(); fade.Regions.Add(new TimelineRegion { Id = "fade", Source = file, Length = 100, FadeIn = 20, FadeOut = 20 });
|
|
39
|
+
var rendered = Render(fade, assets);
|
|
40
|
+
Check(rendered[0] == 0 && rendered[40] == 1.5f && rendered[198] == 0, "Apply clip fade-in/out to actual float samples while retaining headroom");
|
|
41
|
+
var split = fade.Copy(); TimelineEdits.Split(split, 0, false, 10); split.Validate(); Check(rendered.SequenceEqual(Render(split, assets)), "Splitting inside a fade preserves the exact audible envelope");
|
|
42
|
+
var copied = TimelineEdits.Copy(fade, 0, false, 5, 85); var pasted = new EditorSession(); TimelineEdits.Paste(pasted, copied, 0, 0);
|
|
43
|
+
Check(Render(pasted, assets).SequenceEqual(rendered.Skip(10).Take(160)), "Copy/paste part of a faded clip preserves its original sample levels");
|
|
44
|
+
var muted = fade.Copy(); EditorTools.ToggleMute(muted, new HashSet<string> { "fade" }); Check(Render(muted, assets).All(v => v == 0), "Clip mute silences playback and mix output without changing source audio");
|
|
45
|
+
var cross = new EditorSession(); cross.Regions.Add(new TimelineRegion { Id = "a", Source = file, Length = 100 }); cross.Regions.Add(new TimelineRegion { Id = "b", Source = file, Start = 80, Length = 100 });
|
|
46
|
+
EditorTools.Crossfade(cross, new HashSet<string> { "a", "b" });
|
|
47
|
+
Check(Render(cross, assets).Skip(160).Take(40).All(v => Math.Abs(v - 1.5f) < 1e-6), "Crossfade overlapping clips with complementary levels across the overlap");
|
|
48
|
+
string exported = Path.Combine(root, "Faded mix.wav"); TimelineRender.Export(fade, assets, exported, 32, false, 0, 0, CancellationToken.None, null);
|
|
49
|
+
using (var wave = new NAudio.Wave.WaveFileReader(exported)) { var bytes = new byte[checked((int)wave.Length)]; wave.Read(bytes, 0, bytes.Length); var actual = new float[bytes.Length / 4]; Buffer.BlockCopy(bytes, 0, actual, 0, bytes.Length); Check(actual.SequenceEqual(rendered), "Save the same clip fades heard in playback into a float mix export"); }
|
|
50
|
+
string project = Path.Combine(root, "Fades.dbedit"); split.Regions[0].Muted = true; split.Save(project); var loaded = EditorSession.Load(project);
|
|
51
|
+
Check(loaded.Regions[0].Muted && loaded.Regions[1].FadeInOffset == 10, "Persist clip mute, fades and split-envelope offsets in project files");
|
|
52
|
+
var state = new EditorSession { Name = "Clip tools" };
|
|
53
|
+
state.Regions.Add(new TimelineRegion { Id = "first", Name = "Move / trim / slip", Source = file, Start = Frame(1), SourceStart = Frame(1), Length = Frame(2) });
|
|
54
|
+
state.Regions.Add(new TimelineRegion { Id = "second", Name = "Group item", Source = file, Track = 1, Start = Frame(2), Length = Frame(1) });
|
|
55
|
+
var owner = new Window(); ThemePalette.Apply(true); ThemePalette.Install(owner);
|
|
56
|
+
using (var editor = new EditorView(owner, Path.Combine(root, "cache"), delegate { }, delegate { })) {
|
|
57
|
+
owner.Content = editor; editor.SetForTest(state, assets); editor.ZoomForTest(250); Picture(editor, Path.Combine(root, "clip-tools-start.png"));
|
|
58
|
+
editor.PointerDownForTest(Frame(1.25), 0, false); editor.PointerUpForTest(Frame(1.25)); editor.PointerDownForTest(Frame(2.25), 1, true);
|
|
59
|
+
editor.ToolDownForTest("first", "Move", Frame(1.5)); editor.ToolMoveForTest(Frame(3.5), 2); editor.ToolEndForTest(false);
|
|
60
|
+
var moved = editor.SessionForTest;
|
|
61
|
+
Check(moved.Regions.Single(r => r.Id == "first").Start == Frame(3) && moved.Regions.Single(r => r.Id == "second").Start == Frame(4) && moved.Regions.Single(r => r.Id == "first").Track == 2 && moved.Regions.Single(r => r.Id == "second").Track == 3, "Move a selected clip group in time and across tracks without losing relative placement");
|
|
62
|
+
editor.ExecuteAction("undo").GetAwaiter().GetResult(); Check(editor.SessionForTest.Regions.Single(r => r.Id == "first").Start == Frame(1), "Undo a complete clip drag as one edit");
|
|
63
|
+
editor.SetForTest(state, assets); editor.ToolDownForTest("first", "Move", Frame(1.5)); editor.ToolMoveForTest(Frame(2.5), 2); editor.ToolEndForTest(true);
|
|
64
|
+
Check(!editor.Dirty && editor.SessionForTest.Regions.Single(r => r.Id == "first").Track == 0 && editor.SessionForTest.Regions.Single(r => r.Id == "first").Start == Frame(1), "Cancel a clip drag without changing the session or dirty state");
|
|
65
|
+
editor.ToolDownForTest("first", "TrimLeft", Frame(1)); editor.ToolMoveForTest(Frame(1.5), 0); editor.ToolEndForTest(false);
|
|
66
|
+
var trimmed = editor.SessionForTest.Regions.Single(r => r.Id == "first"); Check(trimmed.Start == Frame(1.5) && trimmed.SourceStart == Frame(1.5) && trimmed.Length == Frame(1.5), "Trim the left edge while advancing the exact source offset");
|
|
67
|
+
editor.SetForTest(state, assets); editor.ToolDownForTest("first", "TrimRight", Frame(3)); editor.ToolMoveForTest(Frame(8), 0); editor.ToolEndForTest(false);
|
|
68
|
+
Check(editor.SessionForTest.Regions.Single(r => r.Id == "first").End == Frame(4), "Clamp edge extension to available source audio");
|
|
69
|
+
editor.SetForTest(state, assets); editor.ToolDownForTest("first", "Slip", Frame(1.5)); editor.ToolMoveForTest(Frame(1.25), 0); editor.ToolEndForTest(false);
|
|
70
|
+
var slipped = editor.SessionForTest.Regions.Single(r => r.Id == "first"); Check(slipped.SourceStart == Frame(1.25) && slipped.Start == Frame(1) && slipped.Length == Frame(2), "Slip source contents while retaining clip start and end");
|
|
71
|
+
editor.ToolDownForTest("first", "FadeIn", Frame(1)); editor.ToolMoveForTest(Frame(1.5), 0); editor.ToolEndForTest(false); editor.ToolDownForTest("first", "FadeOut", Frame(3)); editor.ToolMoveForTest(Frame(2.5), 0); editor.ToolEndForTest(false);
|
|
72
|
+
Check(editor.SessionForTest.Regions.Single(r => r.Id == "first").FadeIn == Frame(.5) && editor.SessionForTest.Regions.Single(r => r.Id == "first").FadeOut == Frame(.5), "Commit both fade handles as audible, undoable clip properties");
|
|
73
|
+
editor.ExecuteAction("zoomSelection").GetAwaiter().GetResult(); Picture(editor, Path.Combine(root, "clip-tools-fades.png"));
|
|
74
|
+
editor.SetForTest(state, assets); editor.PointerDownForTest(Frame(1.25), 0, false); editor.PointerUpForTest(Frame(1.25)); editor.ExecuteAction("duplicate").GetAwaiter().GetResult();
|
|
75
|
+
Check(editor.SessionForTest.Regions.Any(r => r.Id != "first" && r.Track == 0 && r.Start == Frame(3) && r.SourceStart == Frame(1)), "Duplicate selected audio directly after its original duration");
|
|
76
|
+
editor.SetForTest(state, assets); editor.ShowSelectionForTest(Frame(1.25), Frame(1.75)); editor.ContextForTest(Frame(1.5), 0);
|
|
77
|
+
Check(editor.SelectedClipsForTest == 0, "Keep a dragged time range active when opening its context menu");
|
|
78
|
+
editor.ExecuteAction("clearFades").GetAwaiter().GetResult();
|
|
79
|
+
Check(!editor.Dirty && editor.SessionForTest.Regions.Count == 2, "Roll back temporary splits when a range operation makes no audio change");
|
|
80
|
+
}
|
|
81
|
+
owner.Content = null; owner.Close();
|
|
82
|
+
var repeats = state.Copy(); EditorTools.Repeat(repeats, new HashSet<string> { "first", "second" }, 3);
|
|
83
|
+
Check(repeats.Regions.Count == 8 && repeats.Regions.Any(r => r.Track == 1 && r.Start == Frame(8)), "Repeat selected multitrack clips with their timing and source windows intact");
|
|
84
|
+
bool rejected = false; int before = repeats.Regions.Count; try { EditorTools.Repeat(repeats, new HashSet<string> { "first" }, 65); } catch (IOException) { rejected = true; }
|
|
85
|
+
Check(rejected && repeats.Regions.Count == before, "Reject excessive repeats before partially editing the project");
|
|
86
|
+
Check(original.SequenceEqual(File.ReadAllBytes(file)), "Keep original audio byte-for-byte unchanged across all new clip tools");
|
|
87
|
+
File.WriteAllLines(Path.Combine(root, "tools-test-results.txt"), log, Encoding.UTF8); return 0;
|
|
88
|
+
}
|
|
89
|
+
catch (Exception ex) { log.Add("FAIL " + ex); File.WriteAllLines(Path.Combine(root, "tools-test-results.txt"), log, Encoding.UTF8); return 1; }
|
|
90
|
+
finally { InterfacePreferences.Current = preferences; ThemePalette.Apply(preferences.Theme == "Dark"); }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|