@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.
@@ -13,9 +13,13 @@ Dependencies are pinned to reviewed SHA-256 hashes. The FFmpeg release URL is ve
13
13
  Verification:
14
14
  DrumBuddy.exe --self-test "C:\path\to\new-audio-test-folder"
15
15
  DrumBuddy.exe --grid-self-test "C:\path\to\new-grid-test-folder"
16
+ DrumBuddy.exe --zoom-self-test "C:\path\to\new-zoom-test-folder"
17
+ DrumBuddy.exe --selection-self-test "C:\path\to\new-selection-test-folder"
18
+ DrumBuddy.exe --tools-self-test "C:\path\to\new-tool-test-folder"
16
19
  DrumBuddy.exe --workspace-self-test "C:\path\to\new-editor-test-folder"
17
20
  DrumBuddy.exe --float-self-test "C:\path\to\new-float-test-folder"
18
21
  DrumBuddy.exe --output-self-test "C:\path\to\new-output-test-folder"
22
+ DrumBuddy.exe --editor-playback-self-test "C:\path\to\new-playback-test-folder"
19
23
  DrumBuddy.exe --interface-self-test "C:\path\to\new-interface-test-folder"
20
24
  DrumBuddy.exe --conversion-self-test "C:\path\to\new-conversion-test-folder"
21
25
  DrumBuddy.exe --audio-diagnostics "C:\path\to\Audio-Diagnostics.txt"
@@ -30,9 +34,11 @@ The workspace suite covers the 16-track editor, range/playhead gestures,
30
34
  endpoint resampling/channel adaptation, PCM/float imports and exports,
31
35
  and offscreen player/editor renders. The output suite opens the actual
32
36
  default Windows endpoint and plays only silence; it never changes volume.
33
- Windows speakers/headphones use shared WASAPI with the exact endpoint format,
34
- with an endpoint-matched 16-bit PCM compatibility fallback. The output suite
35
- checks both paths and queries microphone formats without opening capture.
37
+ Windows speakers/headphones use the native shared WASAPI renderer with the
38
+ exact endpoint format. No WinMM output fallback or compatibility devices are
39
+ exposed. The output suite queries microphone formats without opening capture.
40
+ The editor-playback suite imports float/PCM files and exercises the actual
41
+ editor transport with muted listening, plus native pause/resume/drain checks.
36
42
  Windows microphone buffer tests cover routing, saved samples and queue draining.
37
43
  The interface suite checks keybind capture, saved/unsaved settings, theme
38
44
  switching, integrated recording insertion and dark/light/compact layouts.
package/source/Build.ps1 CHANGED
@@ -30,14 +30,15 @@ $panelSource = Join-Path $PSScriptRoot 'r16-panel.png'
30
30
  if (!(Test-Path -LiteralPath $panelSource)) { $panelSource = Join-Path $Dependencies 'r16-panel.png' }
31
31
  Copy-Item -LiteralPath $panelSource -Destination $package -Force
32
32
  }
33
- Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'logo.png') -Destination $package -Force
33
+ Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Flux-Logo.png') -Destination $package -Force
34
34
  Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Flux-Mark.svg') -Destination $package -Force
35
- foreach ($file in @('Quick Start.txt','LICENSE.txt','Third Party Notices.txt')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $file) -Destination $package -Force }
35
+ foreach ($file in @('Quick Start.txt','LICENSE.txt','Third Party Notices.txt','Audio Decoders.txt')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $file) -Destination $package -Force }
36
36
  if (Test-Path -LiteralPath (Join-Path $Dependencies 'NAudio-license.txt')) { Copy-Item -LiteralPath (Join-Path $Dependencies 'NAudio-license.txt') -Destination (Join-Path $package 'licenses\NAudio-MIT.txt') -Force }
37
37
 
38
- # Convert the original logo into the native Windows ICO container, preserving alpha.
38
+ # Use the current Flux mark for every executable/shortcut icon. The drummer
39
+ # artwork is retained separately in source/brand-archive.
39
40
  Add-Type -AssemblyName System.Drawing
40
- $image = [Drawing.Image]::FromFile((Join-Path $PSScriptRoot 'logo.png'))
41
+ $image = [Drawing.Image]::FromFile((Join-Path $PSScriptRoot 'Flux-Logo.png'))
41
42
  $sizes = @(16,24,32,48,64,128,256)
42
43
  $iconImages = @()
43
44
  foreach ($size in $sizes) {
@@ -45,10 +46,12 @@ foreach ($size in $sizes) {
45
46
  $graphics = [Drawing.Graphics]::FromImage($bitmap)
46
47
  $graphics.Clear([Drawing.Color]::Transparent)
47
48
  $graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
48
- $scale = [Math]::Min($size / $image.Width,$size / $image.Height)
49
- $w = [int]($image.Width * $scale)
50
- $h = [int]($image.Height * $scale)
51
- $graphics.DrawImage($image,[int](($size-$w)/2),[int](($size-$h)/2),$w,$h)
49
+ $markBounds = New-Object Drawing.Rectangle(100,280,1050,800)
50
+ $scale = [Math]::Min($size / $markBounds.Width,$size / $markBounds.Height)
51
+ $w = [int]($markBounds.Width * $scale)
52
+ $h = [int]($markBounds.Height * $scale)
53
+ $destinationBounds = New-Object Drawing.Rectangle([int](($size-$w)/2),[int](($size-$h)/2),$w,$h)
54
+ $graphics.DrawImage($image,$destinationBounds,$markBounds,[Drawing.GraphicsUnit]::Pixel)
52
55
  $memory = New-Object IO.MemoryStream
53
56
  $bitmap.Save($memory,[Drawing.Imaging.ImageFormat]::Png)
54
57
  $iconImages += ,$memory.ToArray()
@@ -90,8 +93,17 @@ $appArgs = $common + $refs + @(('/out:' + (Join-Path $package 'DrumBuddy.exe')),
90
93
  $appArgs += '/resource:' + (Join-Path $PSScriptRoot 'ShellStyles.xaml') + ',ShellStyles.xaml'
91
94
  $appArgs += '/resource:' + (Join-Path $PSScriptRoot 'Flux-Mark.svg') + ',Flux-Mark.svg'
92
95
  $appArgs += Join-Path $PSScriptRoot 'BrandArtwork.cs'
96
+ $appArgs += @('TimelineZoom.cs','WaveformDetail.cs','ZoomTests.cs') | ForEach-Object { Join-Path $PSScriptRoot $_ }
97
+ $appArgs += @('WindowsAudioPlayer.cs','AudioEndpointWatcher.cs') | ForEach-Object { Join-Path $PSScriptRoot $_ }
98
+ $appArgs += Join-Path $PSScriptRoot 'EditorPlaybackTests.cs'
99
+ $appArgs += Join-Path $PSScriptRoot 'EditorSelectionTests.cs'
100
+ $appArgs += @('EditorTools.cs','EditorToolInteractions.cs') | ForEach-Object { Join-Path $PSScriptRoot $_ }
101
+ $appArgs += Join-Path $PSScriptRoot 'EditorToolsTests.cs'
102
+ $appArgs += Join-Path $PSScriptRoot 'EditorCompactLayout.cs'
93
103
  & $compiler @appArgs
94
104
  if ($LASTEXITCODE -ne 0) { throw 'App compilation failed.' }
105
+ $iconVersion = [Reflection.AssemblyName]::GetAssemblyName((Join-Path $package 'DrumBuddy.exe')).Version.ToString(3)
106
+ Copy-Item -LiteralPath $iconPath -Destination (Join-Path $package ('Flux-' + $iconVersion + '.ico')) -Force
95
107
  @'
96
108
  <?xml version="1.0" encoding="utf-8"?>
97
109
  <configuration><startup useLegacyV2RuntimeActivationPolicy="true"><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8"/></startup><runtime><AppContextSwitchOverrides value="Switch.System.Windows.DoNotScaleForDpiChanges=false"/></runtime></configuration>
@@ -114,7 +126,7 @@ $manifestPath = Join-Path $BuildFolder 'payload.sha256'
114
126
  $setupRefs = $refs | Where-Object { $_ -notmatch 'NAudio|Microsoft.Win32.Registry|System.Security' }
115
127
  $setupRefs += '/reference:' + (Join-Path $framework 'System.IO.Compression.dll')
116
128
  $setupRefs += '/reference:' + (Join-Path $framework 'System.IO.Compression.FileSystem.dll')
117
- $setupArgs = $common + $setupRefs + @(('/out:' + (Join-Path $BuildFolder 'DrumBuddy-Setup.exe')),('/resource:' + $payload + ',payload.zip'),('/resource:' + $manifestPath + ',payload.sha256'),('/resource:' + (Join-Path $PSScriptRoot 'Setup.xaml') + ',Setup.xaml'),('/resource:' + (Join-Path $PSScriptRoot 'logo.png') + ',logo.png'),(Join-Path $PSScriptRoot 'Setup.cs'),(Join-Path $PSScriptRoot 'DriverSetup.cs'),(Join-Path $PSScriptRoot 'AssetBootstrap.cs'))
129
+ $setupArgs = $common + $setupRefs + @(('/out:' + (Join-Path $BuildFolder 'DrumBuddy-Setup.exe')),('/resource:' + $payload + ',payload.zip'),('/resource:' + $manifestPath + ',payload.sha256'),('/resource:' + (Join-Path $PSScriptRoot 'Setup.xaml') + ',Setup.xaml'),('/resource:' + (Join-Path $PSScriptRoot 'Flux-Logo.png') + ',Flux-Logo.png'),(Join-Path $PSScriptRoot 'Setup.cs'),(Join-Path $PSScriptRoot 'DriverSetup.cs'),(Join-Path $PSScriptRoot 'AssetBootstrap.cs'))
118
130
  $setupArgs += '/resource:' + (Join-Path $PSScriptRoot 'Flux-Mark.svg') + ',Flux-Mark.svg'
119
131
  $setupArgs += Join-Path $PSScriptRoot 'BrandArtwork.cs'
120
132
  & $compiler @setupArgs
@@ -0,0 +1,36 @@
1
+ using System;
2
+ using System.Linq;
3
+ using System.Windows;
4
+ using System.Windows.Controls;
5
+
6
+ namespace DrumBuddy
7
+ {
8
+ public sealed partial class EditorView
9
+ {
10
+ private void ArrangeCompactChrome(Panel top, Panel editing, Panel range, Panel viewControls, Panel zoomTools, Panel bottom, Panel transport, Panel exports)
11
+ {
12
+ top.Children.Remove(title); top.Children.Remove(cancel); top.Visibility = Visibility.Collapsed;
13
+ title.FontSize = 14; title.MaxWidth = 180; title.TextWrapping = TextWrapping.NoWrap; title.TextTrimming = TextTrimming.CharacterEllipsis; title.Margin = new Thickness(0, 0, 12, 4);
14
+ editing.Children.Insert(0, title); editing.Children.Insert(1, cancel);
15
+ editing.Children.Remove(separate); separate.Content = "Separate import tracks"; range.Children.Add(separate);
16
+ compactOptionsButton.Content = "Export settings"; scope.Width = 132;
17
+ foreach (var control in editing.Children.OfType<Control>()) { control.FontSize = 12; control.MinHeight = 26; control.Padding = new Thickness(6, 2, 6, 2); control.Margin = new Thickness(0, 0, 4, 4); }
18
+ viewControls.Children.Remove(zoomTools); viewControls.Children.Remove(selectionText); viewControls.Visibility = Visibility.Collapsed;
19
+ zoomTools.Margin = new Thickness(10, 0, 0, 0); gridControls.Children.Add(zoomTools);
20
+ foreach (var control in gridControls.Children.OfType<Control>()) { control.FontSize = 12; control.MinHeight = 26; }
21
+ tempoBox.Width = 50; gridDivision.Width = 72; rulerMode.Width = 104;
22
+ foreach (var button in gridControls.Children.OfType<Button>()) { button.Content = "Grid…"; button.MinHeight = 26; button.Padding = new Thickness(6, 2, 6, 2); }
23
+ foreach (var button in zoomTools.Children.OfType<Button>()) { button.MinHeight = 24; button.Padding = new Thickness(4, 1, 4, 1); button.Margin = new Thickness(0, 0, 4, 4); }
24
+ fitSelection.Content = "Sel"; zoomSpan.Width = 62;
25
+ bottom.Margin = new Thickness(0, 5, 0, 0);
26
+ play.MinWidth = 52;
27
+ foreach (var button in transport.Children.OfType<Button>()) { button.FontSize = 12; button.MinHeight = 26; button.Padding = new Thickness(6, 2, 6, 2); button.Margin = new Thickness(0, 0, 4, 4); }
28
+ positionText.FontSize = 12; positionText.Margin = new Thickness(3, 0, 9, 4); volume.Width = 80; volume.Margin = new Thickness(0, 0, 8, 4);
29
+ foreach (var text in transport.Children.OfType<TextBlock>().Where(t => t != positionText)) { text.FontSize = 12; if (text.Text.StartsWith("Listen")) text.Text = "Volume "; }
30
+ bottom.Children.Remove(outputPicker); outputPicker.UseCompactLayout(); transport.Children.Add(outputPicker);
31
+ selectionText.FontSize = 11; selectionText.MaxWidth = 220; selectionText.Margin = new Thickness(5, 0, 0, 4); transport.Children.Add(selectionText);
32
+ foreach (var control in exports.Children.OfType<Control>()) { control.FontSize = 12; control.MinHeight = 26; control.Margin = new Thickness(0, 0, 8, 3); }
33
+ exportFormat.Width = 150;
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,87 @@
1
+ using System;
2
+ using System.IO;
3
+ using System.Linq;
4
+ using System.Text;
5
+ using System.Threading;
6
+ using System.Threading.Tasks;
7
+ using System.Collections.Generic;
8
+ using System.Diagnostics;
9
+ using System.Windows;
10
+ using System.Windows.Threading;
11
+ using NAudio.Wave;
12
+
13
+ namespace DrumBuddy
14
+ {
15
+ public static class EditorPlaybackTests
16
+ {
17
+ private static readonly List<string> log = new List<string>();
18
+ private static void Check(bool value, string message) { if (!value) throw new Exception(message); log.Add("PASS " + message); }
19
+ private static void Pump(Func<bool> ready, int milliseconds)
20
+ {
21
+ var frame = new DispatcherFrame(); var watch = Stopwatch.StartNew();
22
+ var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) };
23
+ timer.Tick += delegate { if (ready() || watch.ElapsedMilliseconds > milliseconds) { timer.Stop(); frame.Continue = false; } };
24
+ timer.Start(); Dispatcher.PushFrame(frame);
25
+ }
26
+ private static void RunUi(Func<Task> action)
27
+ {
28
+ var previous = SynchronizationContext.Current;
29
+ SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher));
30
+ try { var task = action(); Pump(() => task.IsCompleted, 20000); if (!task.IsCompleted) throw new TimeoutException("Editor action did not finish."); task.GetAwaiter().GetResult(); }
31
+ finally { SynchronizationContext.SetSynchronizationContext(previous); }
32
+ }
33
+ private sealed class Silence : ISampleProvider
34
+ {
35
+ private int remaining = Audio.Rate;
36
+ public WaveFormat WaveFormat { get { return WaveFormat.CreateIeeeFloatWaveFormat(Audio.Rate, 1); } }
37
+ public int Read(float[] data, int offset, int count) { int read = Math.Min(count, remaining); Array.Clear(data, offset, read); remaining -= read; return read; }
38
+ }
39
+ public static int Run(string[] args)
40
+ {
41
+ string root = Path.GetFullPath(args[0]); Directory.CreateDirectory(root); log.Clear();
42
+ var previous = InterfacePreferences.Current; string oldKey = AudioOutputs.PreferredKey, oldLog = AudioOutputs.TestErrorLog;
43
+ AudioOutputs.TestErrorLog = Path.Combine(root, "playback-errors.txt");
44
+ try {
45
+ InterfacePreferences.Current = InterfacePreferences.Defaults(); AudioOutputs.PreferredKey = "default";
46
+ using (var done = new ManualResetEvent(false)) using (var player = new WindowsAudioPlayer(new Silence(), null, false, 80)) {
47
+ Exception stopped = null; player.PlaybackStopped += delegate(object sender, StoppedEventArgs e) { stopped = e.Exception; done.Set(); };
48
+ player.Play(); Thread.Sleep(80); player.Pause(); Thread.Sleep(70); long a = player.GetPosition(); Thread.Sleep(70); long b = player.GetPosition();
49
+ Check(player.PlaybackState == PlaybackState.Paused && a > 0 && a == b, "Pause a native polling stream without advancing its audible position");
50
+ player.Play(); Check(done.WaitOne(5000) && stopped == null && player.Counter.Frames > Audio.Rate / 2, "Resume and drain the native shared polling stream");
51
+ }
52
+ foreach (string kind in new[] { "float-mono-44100", "float-stereo-48000", "float-eight-channel-96000", "pcm16", "pcm24" }) {
53
+ bool pcm = kind.StartsWith("pcm"); int channels = kind.Contains("eight") ? 8 : kind.Contains("stereo") || kind == "pcm24" ? 2 : 1;
54
+ int rate = kind.Contains("96000") ? 96000 : kind.Contains("48000") ? 48000 : Audio.Rate;
55
+ string file = Path.Combine(root, kind + ".wav"); var samples = new float[rate / 5 * channels];
56
+ for (int i = 0; i < samples.Length; i++) samples[i] = (float)((pcm ? .6 : 1.5) * Math.Sin(i / channels * .08));
57
+ if (pcm) { using (var writer = new PcmWriter(file, kind == "pcm16" ? 16 : 24, channels)) writer.Write(samples, samples.Length, 1); }
58
+ else { using (var writer = new FloatWaveWriter(file, channels, rate)) writer.Write(samples, samples.Length, 1); }
59
+ if (channels == 8) {
60
+ string extensible = Path.Combine(root, "float-extensible.wav");
61
+ Audio.Run("-hide_banner -v error -nostdin -i " + Audio.Q(file) + " -c:a pcm_f32le " + Audio.Q(extensible), CancellationToken.None, null, true); file = extensible;
62
+ using (var wave = new WaveFileReader(file)) Check(wave.WaveFormat.Encoding == WaveFormatEncoding.Extensible, "Exercise a real multichannel WAVEFORMATEXTENSIBLE float file");
63
+ }
64
+ byte[] original = File.ReadAllBytes(file); var messages = new List<string>(); var owner = new Window(); ThemePalette.Install(owner);
65
+ using (var editor = new EditorView(owner, Path.Combine(root, "cache-" + kind), text => messages.Add(text), delegate { })) {
66
+ owner.Content = editor; editor.ListenVolumeForTest(0);
67
+ RunUi(() => editor.AddFiles(new[] { file }));
68
+ Check(editor.SessionForTest.Regions.Count == 1 && editor.SessionForTest.End > 0, "Import " + kind + " into the actual editor");
69
+ long end = editor.SessionForTest.End;
70
+ RunUi(() => editor.ExecuteAction("play"));
71
+ Pump(() => editor.PlayheadFrame == end || messages.Any(t => t.StartsWith("Could not play") || t.StartsWith("Editor playback stopped")), 5000);
72
+ Check(editor.PlayheadFrame == end && !messages.Any(t => t.StartsWith("Could not play") || t.StartsWith("Editor playback stopped")), "Play and finish imported " + kind + " through the actual editor transport at muted listening volume");
73
+ }
74
+ owner.Content = null; owner.Close();
75
+ Check(original.SequenceEqual(File.ReadAllBytes(file)), "Preserve the original " + kind + " file through import and playback");
76
+ }
77
+ var picker = new AudioOutputPicker(delegate { }, delegate { });
78
+ AudioOutputs.PreferredKey = "windows:missing-output-for-test"; picker.RefreshForTest();
79
+ Check(picker.SelectionForTest == "default" && AudioOutputs.PreferredKey == "default", "Keep the displayed default and actual saved routing selection consistent after a device disappears");
80
+ log.Add("Local Windows output used Cirrus Logic. Muted listening preserves system volume. Affected Realtek-device acceptance remains required.");
81
+ File.WriteAllLines(Path.Combine(root, "editor-playback-test-results.txt"), log, Encoding.UTF8); return 0;
82
+ }
83
+ catch (Exception ex) { log.Add("FAIL " + ex); File.WriteAllLines(Path.Combine(root, "editor-playback-test-results.txt"), log, Encoding.UTF8); return 1; }
84
+ finally { InterfacePreferences.Current = previous; AudioOutputs.PreferredKey = oldKey; AudioOutputs.TestErrorLog = oldLog; }
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,73 @@
1
+ using System;
2
+ using System.IO;
3
+ using System.Linq;
4
+ using System.Text;
5
+ using System.Collections.Generic;
6
+ using System.Windows;
7
+ using System.Windows.Input;
8
+ using System.Windows.Media;
9
+ using System.Windows.Media.Imaging;
10
+
11
+ namespace DrumBuddy
12
+ {
13
+ public static class EditorSelectionTests
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 void Picture(FrameworkElement view, string file)
19
+ {
20
+ view.Measure(new Size(1100, 650)); view.Arrange(new Rect(0, 0, 1100, 650)); view.UpdateLayout();
21
+ var bitmap = new RenderTargetBitmap(1100, 650, 96, 96, PixelFormats.Pbgra32); bitmap.Render(view);
22
+ var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var output = File.Create(file)) encoder.Save(output);
23
+ }
24
+ public static int Run(string[] args)
25
+ {
26
+ string root = Path.GetFullPath(args[0]); Directory.CreateDirectory(root); log.Clear(); var previous = InterfacePreferences.Current;
27
+ try {
28
+ InterfacePreferences.Current = InterfacePreferences.Defaults(); InterfacePreferences.Current.SnapToGrid = InterfacePreferences.Current.SnapToEdges = false;
29
+ Check(InterfacePreferences.Resolve(Key.V, ModifierKeys.None) == "selectTool" && InterfacePreferences.Resolve(Key.V, ModifierKeys.Control) == "paste" && InterfacePreferences.Resolve(Key.S, ModifierKeys.None) == "split", "Keep V selection, S split and Ctrl+V paste as separate actions");
30
+ Check(InterfacePreferences.Resolve(Key.OemPlus, ModifierKeys.Control | ModifierKeys.Shift) == "zoomInPlus" && InterfacePreferences.Resolve(Key.Add, ModifierKeys.Control) == "zoomInKeypad" && InterfacePreferences.Resolve(Key.Subtract, ModifierKeys.Control) == "zoomOutKeypad", "Recognize literal Ctrl+plus and numeric-keypad zoom shortcuts");
31
+ string file = Path.Combine(root, "Original float.wav"); var samples = new float[Audio.Rate * 4]; for (int i = 0; i < samples.Length; i++) samples[i] = (float)(.7 * Math.Sin(i * .03));
32
+ using (var writer = new FloatWaveWriter(file, 1, Audio.Rate)) writer.Write(samples, samples.Length, 1);
33
+ byte[] original = File.ReadAllBytes(file);
34
+ var clip = new Clip { Source = file, Cache = file, Name = "Original float.wav", Channels = 1, Duration = 4, Peak = .7, Waveform = Enumerable.Repeat(.7f, 900).ToArray(), Ready = true };
35
+ var state = new EditorSession { Name = "Selection tools" };
36
+ var first = new TimelineRegion { Id = "first", Source = file, Name = "Selected audio", Track = 0, Start = Frame(1), Length = Frame(2) };
37
+ var overlap = new TimelineRegion { Id = "overlap", Source = file, Name = "Overlapping unselected audio", Track = 0, Start = Frame(1.5), SourceStart = Frame(.5), Length = Frame(1) };
38
+ var other = new TimelineRegion { Id = "other", Source = file, Name = "Second selected track", Track = 1, Start = Frame(2), Length = Frame(1.5) };
39
+ state.Regions.AddRange(new[] { first, overlap, other });
40
+ var assets = new Dictionary<string, Clip> { { file, clip } }; var owner = new Window(); ThemePalette.Apply(true); ThemePalette.Install(owner);
41
+ using (var editor = new EditorView(owner, Path.Combine(root, "cache"), delegate { }, delegate { })) {
42
+ owner.Content = editor; editor.SetForTest(state, assets); Picture(editor, Path.Combine(root, "selection-overview.png")); editor.ZoomForTest(320);
43
+ editor.PointerDownForTest(Frame(1.25), 0, false); editor.PointerUpForTest(Frame(1.25));
44
+ Check(editor.SelectedClipsForTest == 1 && editor.HighlightedForTest == 1, "Click one clip and highlight only that clip despite overlapping audio");
45
+ editor.ExecuteAction("copy").GetAwaiter().GetResult(); editor.ExecuteAction("delete").GetAwaiter().GetResult();
46
+ Check(editor.SessionForTest.Regions.Count == 2 && editor.SessionForTest.Regions.Any(r => r.Id == "overlap"), "Delete the selected clip without deleting overlapping unselected audio");
47
+ editor.PlayheadForTest(Frame(5)); editor.ExecuteAction("paste").GetAwaiter().GetResult();
48
+ Check(editor.SessionForTest.Regions.Any(r => r.Start == Frame(5) && r.Length == Frame(2) && r.SourceStart == 0) && editor.SelectedClipsForTest == 1, "Paste the copied clip at the playhead and select the new item");
49
+ editor.ExecuteAction("undo").GetAwaiter().GetResult(); Check(editor.SessionForTest.Regions.Count == 2, "Undo the selected-clip paste independently");
50
+ editor.SetForTest(state, assets); editor.PointerDownForTest(Frame(1.25), 0, false); editor.PointerUpForTest(Frame(1.25)); editor.PointerDownForTest(Frame(3), 1, true);
51
+ Check(editor.SelectedClipsForTest == 2 && editor.HighlightedForTest == 2, "Shift-click selects clips across tracks with distinct purple highlights");
52
+ editor.ExecuteAction("zoomSelection").GetAwaiter().GetResult(); Picture(editor, Path.Combine(root, "selected-clips-dark.png")); ThemePalette.Apply(false); Picture(editor, Path.Combine(root, "selected-clips-light.png")); ThemePalette.Apply(true);
53
+ editor.ExecuteAction("copy").GetAwaiter().GetResult(); editor.ExecuteAction("delete").GetAwaiter().GetResult();
54
+ Check(editor.SessionForTest.Regions.Count == 1 && editor.SessionForTest.Regions[0].Id == "overlap", "Multi-clip deletion preserves unselected regions between selected items");
55
+ editor.PlayheadForTest(Frame(5)); editor.ExecuteAction("paste").GetAwaiter().GetResult();
56
+ Check(editor.SessionForTest.Regions.Any(r => r.Track == 0 && r.Start == Frame(5)) && editor.SessionForTest.Regions.Any(r => r.Track == 1 && r.Start == Frame(6)) && editor.SessionForTest.Regions.Count == 3, "Copy/paste selected clips preserves their relative timing and track layout");
57
+ editor.SetForTest(state, assets); editor.PointerDownForTest(Frame(1.75), 0, false); editor.PointerMoveForTest(Frame(2.25));
58
+ Check(editor.SelectedClipsForTest == 0 && editor.HighlightedForTest == 2, "Dragging within audio automatically changes to a precise time range with live highlights");
59
+ editor.PointerUpForTest(Frame(2.25)); editor.ExecuteAction("delete").GetAwaiter().GetResult();
60
+ Check(editor.SessionForTest.Regions.Where(r => r.Track == 0).All(r => r.End <= Frame(1.75) || r.Start >= Frame(2.25)) && editor.SessionForTest.Regions.Any(r => r.Id == "other"), "Backspace on a dragged range removes that time only from the selected track");
61
+ editor.SetForTest(state, assets); editor.PointerDownForTest(Frame(1.25), 0, false); editor.PointerUpForTest(Frame(1.25)); editor.ExecuteAction("split").GetAwaiter().GetResult();
62
+ Check(editor.SessionForTest.Regions.Count == 4 && editor.SessionForTest.Regions.Single(r => r.Id == "overlap").Length == Frame(1), "S splits the selected clip at the playhead without splitting an unselected overlap");
63
+ editor.ExecuteAction("scissors").GetAwaiter().GetResult(); editor.ExecuteAction("selectTool").GetAwaiter().GetResult(); Check(editor.SelectCursorForTest, "V immediately restores the normal selection pointer");
64
+ double zoom = editor.ZoomPixelsForTest; editor.ExecuteAction("zoomInPlus").GetAwaiter().GetResult(); editor.ExecuteAction("zoomOutKeypad").GetAwaiter().GetResult(); Check(editor.ZoomPixelsForTest == zoom, "Execute plus/minus zoom actions without modifying the audio");
65
+ }
66
+ owner.Content = null; owner.Close(); Check(original.SequenceEqual(File.ReadAllBytes(file)), "Preserve the original audio through clip selection, range editing and split operations");
67
+ File.WriteAllLines(Path.Combine(root, "selection-test-results.txt"), log, Encoding.UTF8); return 0;
68
+ }
69
+ catch (Exception ex) { log.Add("FAIL " + ex); File.WriteAllLines(Path.Combine(root, "selection-test-results.txt"), log, Encoding.UTF8); return 1; }
70
+ finally { InterfacePreferences.Current = previous; ThemePalette.Apply(previous.Theme == "Dark"); }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,227 @@
1
+ using System;
2
+ using System.Linq;
3
+ using System.Collections.Generic;
4
+ using System.Windows;
5
+ using System.Windows.Controls;
6
+ using System.Windows.Input;
7
+ using System.Windows.Media;
8
+ using System.Windows.Shapes;
9
+ using System.Windows.Threading;
10
+
11
+ namespace DrumBuddy
12
+ {
13
+ public sealed partial class EditorView
14
+ {
15
+ private ClipDrag clipDrag;
16
+ private EditorSession clipBefore;
17
+ private Dictionary<string, TimelineRegion> clipOriginal;
18
+ private string dragId, pointerTool = "select";
19
+ private Point dragOrigin;
20
+ private long dragOriginFrame;
21
+ private int dragOriginTrack;
22
+ private bool clipChanged, renderPending, changingTool;
23
+ private ComboBox toolPicker;
24
+
25
+ private void InitializeClipTools(Panel toolbar)
26
+ {
27
+ toolPicker = new ComboBox { ItemsSource = new[] { "Select (V)", "Slip (Y)", "Fade (F)" }, SelectedIndex = 0, Width = 112, MinHeight = 30, Margin = new Thickness(0, 0, 6, 6) };
28
+ toolPicker.SelectionChanged += delegate { if (!changingTool) SetPointerTool(toolPicker.SelectedIndex == 1 ? "slip" : toolPicker.SelectedIndex == 2 ? "fade" : "select"); }; toolbar.Children.Add(toolPicker);
29
+ var menu = new ContextMenu(); timeline.ContextMenu = menu;
30
+ string[,] commands = {
31
+ { "Selection cursor", "selectTool" }, { "Slip source contents", "slipTool" }, { "Fade tool", "fadeTool" },
32
+ { "Split at playhead", "split" }, { "Duplicate", "duplicate" }, { "Repeat…", "repeatClips" },
33
+ { "Mute / unmute clips", "muteClips" }, { "Crossfade selected clips", "crossfade" }, { "Clear clip fades", "clearFades" },
34
+ { "Cut", "cut" }, { "Copy", "copy" }, { "Paste", "paste" }, { "Delete", "delete" }
35
+ };
36
+ for (int i = 0; i < commands.GetLength(0); i++) {
37
+ string action = commands[i, 1]; var item = new MenuItem { Header = commands[i, 0], Tag = action };
38
+ item.Click += async delegate { await ExecuteAction(action); }; menu.Items.Add(item);
39
+ }
40
+ timeline.PreviewMouseRightButtonDown += delegate(object sender, MouseButtonEventArgs e) {
41
+ SelectContextTarget(e.GetPosition(timeline));
42
+ };
43
+ timeline.ContextMenuOpening += delegate(object sender, ContextMenuEventArgs e) {
44
+ if (IsBusy) { e.Handled = true; return; }
45
+ foreach (MenuItem item in menu.Items) { string key = InterfacePreferences.Gesture((string)item.Tag); item.InputGestureText = key == "Unassigned" ? "" : key; }
46
+ };
47
+ }
48
+ private void SelectContextTarget(Point point)
49
+ {
50
+ if (IsBusy) return; var region = RegionAt(point); long frame = FrameAt(point.X);
51
+ bool inRange = region != null && !clipSelection && to > from && frame >= from && frame < to && (AllTracks || region.Track == track);
52
+ if (region != null && !inRange && (!clipSelection || !selectedIds.Contains(region.Id))) { track = region.Track; SelectRegion(region, false); BuildHeaders(); DrawCanvas(); }
53
+ timeline.Focus();
54
+ }
55
+ private void SetPointerTool(string tool)
56
+ {
57
+ pointerTool = tool; sliceCursor = false;
58
+ changingTool = true; toolPicker.SelectedIndex = tool == "slip" ? 1 : tool == "fade" ? 2 : 0; changingTool = false;
59
+ timeline.Cursor = tool == "select" ? Cursors.Arrow : Cursors.SizeWE; timeline.Focus(); UpdateSelectionText();
60
+ }
61
+ private TimelineRegion RegionAt(Point point)
62
+ {
63
+ if (point.Y < RulerHeight) return null;
64
+ int row = Math.Max(0, Math.Min(15, (int)((point.Y - RulerHeight) / RowHeight))); long frame = FrameAt(point.X);
65
+ var hit = session.Regions.LastOrDefault(r => r.Track == row && r.Start <= frame && r.End > frame);
66
+ if (hit == null || (pointerTool == "select" && clipSelection && point.Y - RulerHeight - row * RowHeight >= 13)) {
67
+ var edge = session.Regions.Where(r => r.Track == row && (hit == null || selectedIds.Contains(r.Id))).OrderBy(r => Math.Min(Math.Abs(point.X - X(r.Start)), Math.Abs(point.X - X(r.End)))).FirstOrDefault();
68
+ if (edge != null && Math.Min(Math.Abs(point.X - X(edge.Start)), Math.Abs(point.X - X(edge.End))) <= 6) return edge;
69
+ }
70
+ return hit;
71
+ }
72
+ private ClipDrag ClipToolAt(TimelineRegion region, Point point)
73
+ {
74
+ if (pointerTool == "slip") return ClipDrag.Slip;
75
+ if (pointerTool == "fade") return point.X < (X(region.Start) + X(region.End)) / 2 ? ClipDrag.FadeIn : ClipDrag.FadeOut;
76
+ double y = point.Y - RulerHeight - region.Track * RowHeight;
77
+ if (RegionSelected(region) && y < 13) {
78
+ if (Math.Abs(point.X - FadeX(region, true)) <= 7) return ClipDrag.FadeIn;
79
+ if (Math.Abs(point.X - FadeX(region, false)) <= 7) return ClipDrag.FadeOut;
80
+ }
81
+ if (y >= 13 && Math.Abs(point.X - X(region.Start)) <= 6) return ClipDrag.TrimLeft;
82
+ if (y >= 13 && Math.Abs(point.X - X(region.End)) <= 6) return ClipDrag.TrimRight;
83
+ return y < 19 ? ClipDrag.Move : ClipDrag.None;
84
+ }
85
+ private void UpdateClipCursor(Point point)
86
+ {
87
+ if (sliceCursor) { timeline.Cursor = Cursors.Cross; return; }
88
+ var region = RegionAt(point); var tool = region == null ? ClipDrag.None : ClipToolAt(region, point);
89
+ timeline.Cursor = tool == ClipDrag.Move ? Cursors.SizeAll : tool == ClipDrag.None ? Cursors.Arrow : Cursors.SizeWE;
90
+ }
91
+ private void BeginClipDrag(ClipDrag tool, TimelineRegion region, Point point)
92
+ {
93
+ if (tool != ClipDrag.Move || !clipSelection || !selectedIds.Contains(region.Id)) SelectRegion(region, false);
94
+ clipBefore = session.Copy(); clipOriginal = session.Regions.Where(r => selectedIds.Contains(r.Id)).ToDictionary(r => r.Id, r => r.Copy());
95
+ clipDrag = tool; dragId = region.Id; dragOrigin = point; dragOriginFrame = FrameAt(point.X); dragOriginTrack = region.Track; clipChanged = false;
96
+ playhead = PointerFrame(point.X); RefreshGridToolbar(); RefreshClipBounds(); BuildHeaders(); DrawCanvas();
97
+ }
98
+ private long SourceFrames(TimelineRegion region)
99
+ {
100
+ Clip clip; if (!assets.TryGetValue(region.Source, out clip)) throw new System.IO.IOException("The clip's source is missing. Re-import it before editing.");
101
+ return (long)Math.Round(clip.Duration * Audio.Rate);
102
+ }
103
+ private long ToolFrame(double x)
104
+ { return TimelineGesture.FrameAt(x, pixelsPerSecond, session.Regions.Where(r => !selectedIds.Contains(r.Id)).SelectMany(r => new[] { r.Start, r.End }), (Keyboard.Modifiers & ModifierKeys.Alt) != 0, session.Grid, InterfacePreferences.Current.SnapToGrid, InterfacePreferences.Current.SnapToEdges); }
105
+ private void PreviewClipDrag(Point point)
106
+ {
107
+ if (clipDrag == ClipDrag.None) return;
108
+ try {
109
+ foreach (var region in session.Regions) {
110
+ TimelineRegion original; if (!clipOriginal.TryGetValue(region.Id, out original)) continue;
111
+ region.Start = original.Start; region.Track = original.Track; region.SourceStart = original.SourceStart; region.Length = original.Length;
112
+ region.FadeIn = original.FadeIn; region.FadeOut = original.FadeOut; region.FadeInOffset = original.FadeInOffset; region.FadeOutOffset = original.FadeOutOffset;
113
+ }
114
+ var target = session.Regions.First(r => r.Id == dragId);
115
+ if (clipDrag == ClipDrag.Move) {
116
+ long first = clipOriginal.Values.Min(r => r.Start);
117
+ double anchor = (dragOriginFrame - first) / (double)Audio.Rate * pixelsPerSecond;
118
+ long delta = ToolFrame(point.X - anchor) - first; int row = (int)Math.Floor((point.Y - RulerHeight) / RowHeight);
119
+ EditorTools.Move(session, selectedIds, delta, row - dragOriginTrack); track = target.Track;
120
+ }
121
+ else if (clipDrag == ClipDrag.TrimLeft || clipDrag == ClipDrag.TrimRight) EditorTools.Trim(target, clipDrag == ClipDrag.TrimLeft, ToolFrame(point.X), SourceFrames(target));
122
+ else if (clipDrag == ClipDrag.Slip) {
123
+ long delta = (long)Math.Round((dragOrigin.X - point.X) / pixelsPerSecond * Audio.Rate);
124
+ if (InterfacePreferences.Current.SnapToGrid && (Keyboard.Modifiers & ModifierKeys.Alt) == 0) { double step = TempoGrid.StepSeconds(session.Grid) * Audio.Rate; delta = (long)Math.Round(Math.Round(delta / step) * step); }
125
+ EditorTools.Slip(target, delta, SourceFrames(target));
126
+ }
127
+ else EditorTools.Fade(target, clipDrag == ClipDrag.FadeIn, clipDrag == ClipDrag.FadeIn ? ToolFrame(point.X) - target.Start : target.End - ToolFrame(point.X));
128
+ clipChanged = session.Regions.Where(r => clipOriginal.ContainsKey(r.Id)).Any(r => {
129
+ var old = clipOriginal[r.Id]; return r.Start != old.Start || r.Track != old.Track || r.SourceStart != old.SourceStart || r.Length != old.Length || r.FadeIn != old.FadeIn || r.FadeOut != old.FadeOut || r.FadeInOffset != old.FadeInOffset || r.FadeOutOffset != old.FadeOutOffset;
130
+ });
131
+ RefreshClipBounds(); RequestClipRender();
132
+ }
133
+ catch (Exception ex) { CancelClipDrag(); status("Could not edit this clip: " + ex.Message); }
134
+ }
135
+ private void RequestClipRender()
136
+ {
137
+ if (renderPending) return; renderPending = true;
138
+ Dispatcher.BeginInvoke(DispatcherPriority.Render, (Action)delegate { renderPending = false; if (!disposed) DrawCanvas(); });
139
+ }
140
+ private void FinishClipDrag()
141
+ {
142
+ if (clipDrag == ClipDrag.None) return; var action = clipDrag;
143
+ try { session.Validate(); if (clipChanged) { undo.Push(clipBefore); redo.Clear(); dirty = true; } }
144
+ catch (Exception ex) { session = clipBefore; clipChanged = false; status("Could not finish the clip edit: " + ex.Message); }
145
+ clipDrag = ClipDrag.None; clipBefore = null; clipOriginal = null; RefreshClipBounds(); RefreshGridToolbar(); BuildHeaders(); DrawCanvas();
146
+ if (clipChanged) status(action == ClipDrag.Move ? "Clips moved." : action == ClipDrag.Slip ? "Source contents slipped." : action == ClipDrag.TrimLeft || action == ClipDrag.TrimRight ? "Clip trimmed." : "Clip fade updated.");
147
+ }
148
+ private void CancelClipDrag()
149
+ {
150
+ if (clipDrag == ClipDrag.None) return; session = clipBefore; clipDrag = ClipDrag.None; clipBefore = null; clipOriginal = null;
151
+ selectedIds.Clear(); if (previousIds != null) foreach (string id in previousIds) selectedIds.Add(id); clipSelection = previousClipSelection; track = previousTrack;
152
+ from = previousFrom; to = previousTo; playhead = previousPlayhead;
153
+ RefreshClipBounds(); UpdateSelectionText(); RefreshGridToolbar(); BuildHeaders(); DrawCanvas();
154
+ }
155
+ private double FadeX(TimelineRegion region, bool incoming)
156
+ {
157
+ long frames = incoming ? Math.Max(0, region.FadeIn - region.FadeInOffset) : Math.Max(0, region.FadeOut - region.FadeOutOffset);
158
+ return X(incoming ? region.Start + Math.Min(region.Length, frames) : region.End - Math.Min(region.Length, frames));
159
+ }
160
+ private void DrawClipFades(TimelineRegion region, double left, double right, bool selected)
161
+ {
162
+ double y = RulerHeight + region.Track * RowHeight + 4;
163
+ if (region.FadeIn > 0 || region.FadeOut > 0) {
164
+ var line = new Polyline { Stroke = WorkspaceUi.Color("#7359C7"), StrokeThickness = 1.3, IsHitTestVisible = false };
165
+ var positions = new[] { Math.Max(left, X(region.Start)), Math.Min(right, X(region.End)), FadeX(region, true), FadeX(region, false) }.Where(x => x >= left && x <= right && x >= X(region.Start) && x <= X(region.End)).Distinct().OrderBy(x => x);
166
+ foreach (double x in positions) line.Points.Add(new Point(x, y + 4 + (1 - EditorTools.Envelope(region, Math.Min(region.Length - 1, Math.Max(0, FrameAt(x) - region.Start)))) * (RowHeight - 14)));
167
+ timeline.Children.Add(line);
168
+ }
169
+ if (selected) foreach (bool incoming in new[] { true, false }) {
170
+ double x = FadeX(region, incoming); if (x < left || x > right) continue;
171
+ var handle = new Polygon { Fill = WorkspaceUi.Color("#7359C7"), IsHitTestVisible = false };
172
+ handle.Points.Add(new Point(x, y)); handle.Points.Add(new Point(x + (incoming ? 7 : -7), y)); handle.Points.Add(new Point(x, y + 8)); timeline.Children.Add(handle);
173
+ }
174
+ }
175
+ private HashSet<string> EditableClipIds()
176
+ {
177
+ if (clipSelection) return new HashSet<string>(selectedIds);
178
+ if (to <= from) return new HashSet<string>();
179
+ TimelineEdits.Split(session, track, AllTracks, from); TimelineEdits.Split(session, track, AllTracks, to);
180
+ return new HashSet<string>(session.Regions.Where(r => (AllTracks || r.Track == track) && r.Start >= from && r.End <= to).Select(r => r.Id));
181
+ }
182
+ private void SelectAdded(IEnumerable<string> ids)
183
+ { selectedIds.Clear(); foreach (string id in ids) selectedIds.Add(id); clipSelection = selectedIds.Count > 0; RefreshClipBounds(); }
184
+ private void RepeatClips(int count)
185
+ {
186
+ Change(delegate {
187
+ if (clipSelection) { SelectAdded(EditorTools.Repeat(session, selectedIds, count)); return true; }
188
+ if (to <= from) return false; var copied = TimelineEdits.Copy(session, track, AllTracks, from, to); if (copied.Regions.Count == 0) return false;
189
+ var before = new HashSet<string>(session.Regions.Select(r => r.Id)); long start = from;
190
+ for (int n = 1; n <= count; n++) TimelineEdits.Paste(session, copied, track, start + copied.Length * n);
191
+ SelectAdded(session.Regions.Where(r => !before.Contains(r.Id)).Select(r => r.Id).ToArray()); return true;
192
+ }, count == 1 ? "Selection duplicated." : "Selection repeated.");
193
+ }
194
+ private bool ExecuteClipAction(string action)
195
+ {
196
+ switch (action) {
197
+ case "slipTool": SetPointerTool("slip"); return true;
198
+ case "fadeTool": SetPointerTool("fade"); return true;
199
+ case "duplicate": RepeatClips(1); return true;
200
+ case "repeatClips": var count = AskRepeatCount(); if (count.HasValue) RepeatClips(count.Value); return true;
201
+ case "muteClips": Change(delegate { var ids = EditableClipIds(); bool changed = EditorTools.ToggleMute(session, ids); if (changed) SelectAdded(ids); return changed; }, "Clip mute updated."); return true;
202
+ case "crossfade": Change(() => EditorTools.Crossfade(session, selectedIds), "Crossfade applied."); return true;
203
+ case "clearFades": Change(delegate { var ids = EditableClipIds(); bool changed = false; foreach (var region in session.Regions.Where(r => ids.Contains(r.Id))) { changed |= region.FadeIn > 0 || region.FadeOut > 0; region.FadeIn = region.FadeOut = region.FadeInOffset = region.FadeOutOffset = 0; } if (changed) SelectAdded(ids); return changed; }, "Clip fades cleared."); return true;
204
+ default: return false;
205
+ }
206
+ }
207
+ private int? AskRepeatCount()
208
+ {
209
+ var dialog = new Window { Owner = owner, Title = "Repeat selected audio", Width = 330, Height = 180, ResizeMode = ResizeMode.NoResize, WindowStartupLocation = WindowStartupLocation.CenterOwner, ShowInTaskbar = false, Icon = BrandArtwork.Raster() }; ThemePalette.Install(dialog);
210
+ var body = new StackPanel { Margin = new Thickness(18) }; dialog.Content = body; body.Children.Add(WorkspaceUi.Text("Additional copies (1–64)", 14));
211
+ var input = new TextBox { Text = "3", Margin = new Thickness(0, 8, 0, 10), Padding = new Thickness(5) }; body.Children.Add(input);
212
+ var buttons = new StackPanel { Orientation = Orientation.Horizontal }; body.Children.Add(buttons); int result = 0;
213
+ var apply = WorkspaceUi.Button("Repeat", delegate { int value; if (int.TryParse(input.Text, out value) && value >= 1 && value <= 64) { result = value; dialog.DialogResult = true; } else input.SelectAll(); }); apply.IsDefault = true; buttons.Children.Add(apply);
214
+ var close = WorkspaceUi.Button("Cancel", delegate { dialog.DialogResult = false; }); close.IsCancel = true; buttons.Children.Add(close); dialog.Loaded += delegate { input.Focus(); input.SelectAll(); };
215
+ return dialog.ShowDialog() == true ? (int?)result : null;
216
+ }
217
+ internal void ToolDownForTest(string id, string tool, long frame)
218
+ {
219
+ var region = session.Regions.Single(r => r.Id == id); previousIds = selectedIds.ToArray(); previousClipSelection = clipSelection; previousTrack = track;
220
+ previousFrom = from; previousTo = to; previousPlayhead = playhead;
221
+ BeginClipDrag((ClipDrag)Enum.Parse(typeof(ClipDrag), tool), region, new Point(X(frame), RulerHeight + region.Track * RowHeight + 8));
222
+ }
223
+ internal void ToolMoveForTest(long frame, int row) { PreviewClipDrag(new Point(X(frame), RulerHeight + row * RowHeight + 8)); }
224
+ internal void ToolEndForTest(bool cancel) { if (cancel) CancelClipDrag(); else FinishClipDrag(); }
225
+ internal void ContextForTest(long frame, int row) { SelectContextTarget(new Point(X(frame), RulerHeight + row * RowHeight + 35)); }
226
+ }
227
+ }
@@ -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
+ }