@pro-gram/flux 0.0.0-stage → 1.5.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.
Files changed (66) hide show
  1. package/LICENSE +23 -0
  2. package/NOTICE.txt +32 -0
  3. package/README.md +102 -2
  4. package/bin/flux.js +29 -0
  5. package/dependencies.json +14 -0
  6. package/package.json +60 -6
  7. package/payload/DrumBuddy-Setup.exe +0 -0
  8. package/scripts/install.js +58 -0
  9. package/scripts/version.js +12 -0
  10. package/source/AssetBootstrap.cs +49 -0
  11. package/source/Audio.cs +453 -0
  12. package/source/AudioDeviceSettings.cs +126 -0
  13. package/source/AudioInputs.cs +50 -0
  14. package/source/AudioOutputs.cs +327 -0
  15. package/source/AudioWorkspace.cs +71 -0
  16. package/source/BrandArtwork.cs +71 -0
  17. package/source/Build Instructions.txt +59 -0
  18. package/source/Build.ps1 +123 -0
  19. package/source/ControlMode.cs +687 -0
  20. package/source/ControlTests.cs +88 -0
  21. package/source/ConversionFormats.cs +59 -0
  22. package/source/ConversionTests.cs +66 -0
  23. package/source/DockablePane.cs +95 -0
  24. package/source/DriverSetup.cs +28 -0
  25. package/source/EditorView.cs +626 -0
  26. package/source/Fetch-Dependencies.ps1 +15 -0
  27. package/source/FloatRecording.cs +99 -0
  28. package/source/FloatRecordingTests.cs +170 -0
  29. package/source/Flux-Mark.svg +49 -0
  30. package/source/FluxTempo Logo Prompt.txt +3 -0
  31. package/source/FluxTempo-Mark.svg +49 -0
  32. package/source/HomeView.cs +32 -0
  33. package/source/InterfaceSettings.cs +219 -0
  34. package/source/InterfaceTests.cs +166 -0
  35. package/source/LICENSE.txt +23 -0
  36. package/source/Logo Prompt.txt +16 -0
  37. package/source/Main.cs +781 -0
  38. package/source/Main.xaml +292 -0
  39. package/source/MediaPlayerView.cs +274 -0
  40. package/source/MidiControl.cs +113 -0
  41. package/source/MidiPerformance.cs +125 -0
  42. package/source/Mixer.cs +280 -0
  43. package/source/NativeMemory.cs +165 -0
  44. package/source/PanelFeedback.cs +75 -0
  45. package/source/PlaybackTests.cs +134 -0
  46. package/source/Quick Start.txt +77 -0
  47. package/source/SelfTest.cs +78 -0
  48. package/source/SettingsWindow.cs +208 -0
  49. package/source/Setup.cs +192 -0
  50. package/source/Setup.xaml +28 -0
  51. package/source/ShellStyles.xaml +99 -0
  52. package/source/TempoGrid.cs +95 -0
  53. package/source/TempoGridTests.cs +108 -0
  54. package/source/Third Party Notices.txt +32 -0
  55. package/source/Timeline.cs +261 -0
  56. package/source/TimelineGestures.cs +57 -0
  57. package/source/Uninstall.cs +69 -0
  58. package/source/WorkspaceTests.cs +198 -0
  59. package/source/app.manifest +7 -0
  60. package/source/brand-archive/Drum-Buddy-Black-Hair.png +0 -0
  61. package/source/dependency-lock.json +62 -0
  62. package/source/fluxtempo-mark.png +0 -0
  63. package/source/logo.png +0 -0
  64. package/source/r16-panel.png +0 -0
  65. package/source/references/R16 Format Research.txt +16 -0
  66. package/test/package.test.js +55 -0
@@ -0,0 +1,95 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Globalization;
4
+ using System.IO;
5
+
6
+ namespace DrumBuddy
7
+ {
8
+ public sealed class TempoGridOptions
9
+ {
10
+ public double Tempo = 120;
11
+ public int BeatsPerBar = 4, BeatUnit = 4, Division = 16;
12
+ public bool Triplet, MusicalRuler = true;
13
+ public TempoGridOptions Copy() { return (TempoGridOptions)MemberwiseClone(); }
14
+ public string Signature() { return Tempo.ToString("R", CultureInfo.InvariantCulture) + ":" + BeatsPerBar + ":" + BeatUnit + ":" + Division + ":" + Triplet + ":" + MusicalRuler; }
15
+ public void Validate()
16
+ {
17
+ if (double.IsNaN(Tempo) || double.IsInfinity(Tempo) || Tempo < 20 || Tempo > 400) throw new IOException("Tempo must be between 20 and 400 BPM.");
18
+ if (BeatsPerBar < 1 || BeatsPerBar > 32 || !PowerOfTwo(BeatUnit) || BeatUnit > 32) throw new IOException("Choose a time signature with 1–32 beats and a power-of-two beat unit up to 32.");
19
+ if (!PowerOfTwo(Division) || Division > 256) throw new IOException("Choose a grid division from 1/1 through 1/256.");
20
+ }
21
+ private static bool PowerOfTwo(int value) { return value > 0 && (value & (value - 1)) == 0; }
22
+ }
23
+ public sealed class TempoGridTick
24
+ {
25
+ public long Frame;
26
+ public int Kind; // 0 subdivision, 1 beat, 2 bar
27
+ public string Label;
28
+ }
29
+ public static class TempoGrid
30
+ {
31
+ public static readonly int[] Divisions = { 1, 2, 4, 8, 16, 32, 64, 128, 256 };
32
+ public static bool TryTempo(string text, out double value)
33
+ { return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) || double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out value); }
34
+ public static double StepSeconds(TempoGridOptions options) { return 240.0 / options.Tempo / options.Division * (options.Triplet ? 2.0 / 3 : 1); }
35
+ public static double BeatSeconds(TempoGridOptions options) { return 240.0 / options.Tempo / options.BeatUnit; }
36
+ public static double BarSeconds(TempoGridOptions options) { return BeatSeconds(options) * options.BeatsPerBar; }
37
+ public static long Snap(long frame, TempoGridOptions options)
38
+ {
39
+ double seconds = Math.Max(0, frame) / (double)Audio.Rate, bar = BarSeconds(options), step = StepSeconds(options);
40
+ double start = Math.Floor(seconds / bar) * bar, inside = seconds - start;
41
+ double nearest = Math.Min(bar, Math.Round(inside / step, MidpointRounding.AwayFromZero) * step);
42
+ if (bar - inside <= Math.Abs(nearest - inside)) nearest = bar;
43
+ return FrameAt((start + nearest) * Audio.Rate);
44
+ }
45
+ private static long FrameAt(double frame) { return (long)Math.Round(Math.Max(0, Math.Min(EditorSession.MaxFrame, frame)), MidpointRounding.AwayFromZero); }
46
+ public static string Position(long frame, TempoGridOptions options)
47
+ {
48
+ double beats = Math.Max(0, frame) / (double)Audio.Rate / BeatSeconds(options);
49
+ long whole = (long)Math.Floor(beats + .500001 / (Audio.Rate * BeatSeconds(options)));
50
+ return (whole / options.BeatsPerBar + 1).ToString(CultureInfo.InvariantCulture) + "." + (whole % options.BeatsPerBar + 1).ToString(CultureInfo.InvariantCulture);
51
+ }
52
+ public static IEnumerable<TempoGridTick> VisibleTicks(TempoGridOptions options, double left, double width, double pixelsPerSecond)
53
+ {
54
+ options.Validate();
55
+ if (pixelsPerSecond <= 0 || width <= 0) yield break;
56
+ left = Math.Max(0, left); width = Math.Min(width, 16000);
57
+ foreach (var tick in Subdivisions(options, left, width, pixelsPerSecond)) yield return tick;
58
+ foreach (var tick in Series(BeatSeconds(options), left, width, pixelsPerSecond, 9, 1, options)) yield return tick;
59
+ foreach (var tick in Series(BarSeconds(options), left, width, pixelsPerSecond, 14, 2, options)) yield return tick;
60
+ }
61
+ private static IEnumerable<TempoGridTick> Subdivisions(TempoGridOptions options, double left, double width, double scale)
62
+ {
63
+ double bar = BarSeconds(options), step = StepSeconds(options), barPixels = bar * scale, stepPixels = step * scale;
64
+ long barStride = 1, stepStride = 1;
65
+ while (barPixels * barStride < 4) barStride *= 2;
66
+ while (stepPixels * stepStride < 4) stepStride *= 2;
67
+ long firstBar = Math.Max(0, (long)Math.Floor(left / barPixels / barStride) * barStride);
68
+ long lastBar = (long)Math.Ceiling((left + width) / barPixels); int count = 0;
69
+ for (long index = firstBar; index <= lastBar && count < 5000; index += barStride) {
70
+ double start = index * bar;
71
+ long first = Math.Max(0, (long)Math.Floor((left / scale - start) / step / stepStride) * stepStride);
72
+ for (long part = first; part * step < bar - 1e-10; part += stepStride) {
73
+ double seconds = start + part * step;
74
+ if (seconds * scale > left + width || seconds * Audio.Rate > EditorSession.MaxFrame || count++ >= 5000) break;
75
+ yield return new TempoGridTick { Frame = FrameAt(seconds * Audio.Rate), Kind = 0 };
76
+ }
77
+ }
78
+ }
79
+ private static IEnumerable<TempoGridTick> Series(double seconds, double left, double width, double scale, double minimum, int kind, TempoGridOptions options)
80
+ {
81
+ double spacing = seconds * scale; long stride = 1;
82
+ while (spacing * stride < minimum && stride < (1L << 40)) stride *= 2;
83
+ long first = Math.Max(0, (long)Math.Floor(left / spacing / stride) * stride);
84
+ long last = (long)Math.Ceiling((left + width) / spacing);
85
+ long labelStride = stride; while (spacing * labelStride < 58) labelStride *= 2;
86
+ int count = 0;
87
+ for (long index = first; index <= last && count++ < 5000; index += stride) {
88
+ double frame = index * seconds * Audio.Rate; if (frame > EditorSession.MaxFrame) break;
89
+ string label = kind == 2 && index % labelStride == 0 ? (index + 1).ToString(CultureInfo.InvariantCulture) + ".1" : null;
90
+ if (kind == 1 && spacing >= 58 && index % options.BeatsPerBar != 0) label = (index / options.BeatsPerBar + 1) + "." + (index % options.BeatsPerBar + 1);
91
+ yield return new TempoGridTick { Frame = FrameAt(frame), Kind = kind, Label = label };
92
+ }
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,108 @@
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.Windows;
9
+ using System.Windows.Input;
10
+ using System.Windows.Threading;
11
+ using System.Xml;
12
+ using System.Xml.Serialization;
13
+
14
+ namespace DrumBuddy
15
+ {
16
+ public static class TempoGridTests
17
+ {
18
+ private static readonly List<string> log = new List<string>();
19
+ private static void Check(bool value, string name) { if (!value) throw new Exception(name); log.Add("PASS " + name); }
20
+ private static void RunUi(Func<Task> action)
21
+ {
22
+ var previous = SynchronizationContext.Current; var dispatcher = Dispatcher.CurrentDispatcher;
23
+ SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(dispatcher));
24
+ try {
25
+ var task = action(); var frame = new DispatcherFrame();
26
+ task.ContinueWith(delegate { dispatcher.BeginInvoke((Action)delegate { frame.Continue = false; }); });
27
+ if (!task.IsCompleted) Dispatcher.PushFrame(frame); task.GetAwaiter().GetResult();
28
+ }
29
+ finally { SynchronizationContext.SetSynchronizationContext(previous); }
30
+ }
31
+ public static int Run(string[] args)
32
+ {
33
+ string root = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(Path.GetTempPath(), "FluxGrid_" + Guid.NewGuid().ToString("N")));
34
+ Directory.CreateDirectory(root); log.Clear(); var previous = InterfacePreferences.Current;
35
+ try {
36
+ InterfacePreferences.Current = InterfacePreferences.Defaults();
37
+ var grid = new TempoGridOptions(); grid.Validate();
38
+ Check(Math.Abs(TempoGrid.StepSeconds(grid) - .125) < 1e-12, "At 120 BPM, a sixteenth note lasts 0.125 seconds");
39
+ grid.Division = 32; Check(Math.Abs(TempoGrid.StepSeconds(grid) - .0625) < 1e-12, "At 120 BPM, a thirty-second note lasts 0.0625 seconds");
40
+ grid.Division = 256; Check(Math.Abs(TempoGrid.StepSeconds(grid) - .0078125) < 1e-12, "Allow fine divisions through 1/256");
41
+ grid.Division = 8; grid.Triplet = true; Check(Math.Abs(TempoGrid.StepSeconds(grid) - 1.0 / 6) < 1e-12, "Use three eighth-note triplets per quarter-note beat");
42
+ grid.Triplet = false; grid.BeatsPerBar = 6; grid.BeatUnit = 8;
43
+ Check(Math.Abs(TempoGrid.BarSeconds(grid) - 1.5) < 1e-12 && TempoGrid.Position(66150, grid) == "2.1", "Label a 6/8 bar correctly using quarter-note BPM");
44
+ grid = new TempoGridOptions { BeatsPerBar = 7, BeatUnit = 8, Division = 4 };
45
+ Check(TempoGrid.Snap(88000, grid) == 77175, "Restart quarter-note subdivisions at each 7/8 bar boundary");
46
+ grid = new TempoGridOptions { BeatsPerBar = 3, Division = 1 };
47
+ Check(TempoGrid.Snap(Audio.Rate, grid) == 66150, "Keep bar lines snappable when a whole-note division is longer than the bar");
48
+ grid = new TempoGridOptions { Tempo = 123, Division = 128 };
49
+ long distant = (long)Math.Round(100000 * 240.0 / 123 / 128 * Audio.Rate);
50
+ Check(Math.Abs(TempoGrid.Snap(distant, grid) - distant) <= 1, "Avoid accumulated frame rounding drift far into a fine grid");
51
+ grid = new TempoGridOptions { Division = 32 };
52
+ long first = TimelineGesture.FrameAt(2.5, 40, new long[] { 0 }, false, grid, true, true);
53
+ Check(first == 2756, "Do not swallow the first 1/32 grid line with the start-of-track magnet");
54
+ Check(TimelineGesture.FrameAt(21.4, 40, new long[0], false, new TempoGridOptions(), true, false) == 22050, "Snap a playhead gesture to the nearest sixteenth-note frame");
55
+ long boundary = 23000;
56
+ Check(TimelineGesture.FrameAt(boundary / (double)Audio.Rate * 160 + 2, 160, new[] { boundary }, false, grid, true, true) == boundary, "Give a nearby clip edge priority over the beat grid");
57
+ Check(TimelineGesture.FrameAt(21.4, 40, new[] { boundary }, false, new TempoGridOptions(), true, false) == 22050, "Disable edge snapping independently of grid snapping");
58
+ long raw = (long)Math.Round(21.4 / 40 * Audio.Rate);
59
+ Check(TimelineGesture.FrameAt(21.4, 40, new[] { raw + 1 }, true, grid, true, true) == raw, "Alt bypasses both snapping modes at sample resolution");
60
+ Check(TimelineGesture.FrameAt(2.5, 40, new long[] { 0 }, false, grid, false, false) == 2756, "Both snap switches off permits a free chop near the start");
61
+ var ticks = TempoGrid.VisibleTicks(new TempoGridOptions { Tempo = 400, Division = 256, Triplet = true }, 86400 * 2560.0 - 1280, 1280, 2560).ToArray();
62
+ Check(ticks.Length > 10 && ticks.Length < 1200 && ticks.All(t => t.Frame <= EditorSession.MaxFrame), "Bound visible grid rendering near the end of a 24-hour timeline");
63
+ bool invalid = false; try { new TempoGridOptions { Tempo = double.NaN }.Validate(); } catch (IOException) { invalid = true; }
64
+ Check(invalid, "Reject a non-finite BPM before drawing or saving a grid");
65
+ invalid = false; try { new TempoGridOptions { Division = 33 }.Validate(); } catch (IOException) { invalid = true; }
66
+ Check(invalid, "Reject unsupported grid denominators");
67
+
68
+ var legacy = InterfacePreferences.Defaults(); legacy.KeymapRevision = 0; legacy.Keybinds.RemoveAll(b => b.Action == "deleteAlternate"); legacy.Keybinds.First(b => b.Action == "delete").Key = "Delete";
69
+ InterfacePreferences.Upgrade(legacy); InterfacePreferences.Validate(legacy);
70
+ Check(legacy.Keybinds.First(b => b.Action == "delete").Key == "Back" && legacy.Keybinds.First(b => b.Action == "deleteAlternate").Key == "Delete", "Upgrade the old default to Backspace while retaining Delete as an editable alternate");
71
+ var custom = InterfacePreferences.Defaults(); custom.KeymapRevision = 0; custom.Keybinds.RemoveAll(b => b.Action == "deleteAlternate"); custom.Keybinds.First(b => b.Action == "delete").Key = "F9"; custom.Keybinds.First(b => b.Action == "play").Key = "Delete";
72
+ InterfacePreferences.Upgrade(custom); InterfacePreferences.Validate(custom);
73
+ Check(custom.Keybinds.First(b => b.Action == "delete").Key == "F9" && custom.Keybinds.First(b => b.Action == "play").Key == "Delete" && custom.Keybinds.First(b => b.Action == "deleteAlternate").Key == "None", "Preserve custom keybinds when a new alternate would conflict");
74
+ Check(InterfacePreferences.Resolve(Key.Back, ModifierKeys.None) == "delete" && InterfacePreferences.Resolve(Key.Delete, ModifierKeys.None) == "deleteAlternate", "Resolve both keyboard delete actions");
75
+ var preferences = InterfacePreferences.Defaults(); preferences.Grid = new TempoGridOptions { Tempo = 97.5, Division = 64, BeatsPerBar = 7, BeatUnit = 8, Triplet = true }; preferences.SnapToGrid = false;
76
+ string preferencePath = Path.Combine(root, "timeline-settings.xml"); InterfacePreferences.SaveToFile(preferences, preferencePath); InterfaceSettings reopened;
77
+ using (var file = File.OpenRead(preferencePath)) reopened = (InterfaceSettings)new XmlSerializer(typeof(InterfaceSettings)).Deserialize(file);
78
+ Check(reopened.Grid.Signature() == preferences.Grid.Signature() && !reopened.SnapToGrid && reopened.SnapToEdges, "Save tempo, meter, subdivisions and independent snap settings");
79
+ var project = new EditorSession { Grid = preferences.Grid.Copy() }; string projectPath = Path.Combine(root, "grid.dbedit"); project.Save(projectPath);
80
+ Check(EditorSession.Load(projectPath).Grid.Signature() == project.Grid.Signature(), "Restore the project-specific grid from a saved session");
81
+ var independent = project.Copy(); independent.Grid.Tempo = 150; Check(project.Grid.Tempo == 97.5, "Keep undo snapshots' grid settings independent");
82
+ var oldProject = new XmlDocument(); oldProject.Load(projectPath); var node = oldProject.DocumentElement.SelectSingleNode("Grid"); node.ParentNode.RemoveChild(node); oldProject.Save(Path.Combine(root, "legacy.dbedit"));
83
+ Check(EditorSession.Load(Path.Combine(root, "legacy.dbedit")).Grid.Tempo == 120, "Open pre-grid projects with sensible musical defaults");
84
+
85
+ string source = Path.Combine(root, "Original.wav"); var audio = new float[Audio.Rate]; for (int i = 0; i < audio.Length; i++) audio[i] = (float)(.5 * Math.Sin(i * .05));
86
+ using (var writer = new FloatWaveWriter(source, 1, Audio.Rate)) writer.Write(audio, audio.Length, 1);
87
+ byte[] original = File.ReadAllBytes(source); var clip = Audio.Load(source, Path.Combine(root, "cache"), CancellationToken.None, null);
88
+ var state = new EditorSession(); state.Regions.Add(new TimelineRegion { Source = source, Name = "Original", Track = 0, Length = Audio.Rate });
89
+ var owner = new Window(); ThemePalette.Install(owner);
90
+ using (var editor = new EditorView(owner, Path.Combine(root, "editor"), delegate(string text) { }, delegate { })) {
91
+ owner.Content = editor; editor.SetForTest(state, new Dictionary<string, Clip> { { source, clip } }); editor.ShowSelectionForTest(11025, 22050); editor.PlayheadForTest(88200);
92
+ RunUi(() => editor.ExecuteAction(InterfacePreferences.Resolve(Key.C, ModifierKeys.Control)));
93
+ RunUi(() => editor.ExecuteAction(InterfacePreferences.Resolve(Key.Back, ModifierKeys.None)));
94
+ Check(editor.SessionForTest.Regions.All(r => r.End <= 11025 || r.Start >= 22050), "Backspace removes only the selected section and leaves its gap");
95
+ RunUi(() => editor.ExecuteAction(InterfacePreferences.Resolve(Key.V, ModifierKeys.Control)));
96
+ Check(editor.SessionForTest.Regions.Any(r => r.Start == 88200 && r.Length == 11025 && r.SourceStart == 11025), "Ctrl+C then Backspace preserves the copy for Ctrl+V at the playhead");
97
+ editor.ApplyGridOptions(new TempoGridOptions { Tempo = 150, Division = 32 }); RunUi(() => editor.ExecuteAction("undo"));
98
+ Check(editor.GridOptions.Tempo == 120 && editor.SessionForTest.Regions.Any(r => r.Start == 88200), "Undo a tempo-grid change without undoing the audio paste");
99
+ Check(original.SequenceEqual(File.ReadAllBytes(source)), "Keep the original float audio unchanged through grid editing and copy/delete/paste");
100
+ }
101
+ owner.Content = null; owner.Close();
102
+ File.WriteAllLines(Path.Combine(root, "grid-test-results.txt"), log, Encoding.UTF8); return 0;
103
+ }
104
+ catch (Exception ex) { log.Add("FAIL " + ex); File.WriteAllLines(Path.Combine(root, "grid-test-results.txt"), log, Encoding.UTF8); return 1; }
105
+ finally { InterfacePreferences.Current = previous; }
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,32 @@
1
+ Flux 1.5.0 - third-party components
2
+
3
+ FFmpeg 9.0.2, release essentials build by Gyan Doshi
4
+ Used as a separate command-line decoding process.
5
+ GPL version 3. See licenses\FFmpeg-GPLv3.txt and FFmpeg-build.txt.
6
+ Binary: https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip
7
+ Build information and corresponding upstream source links:
8
+ https://www.gyan.dev/ffmpeg/builds/
9
+ https://github.com/FFmpeg/FFmpeg/tree/946fcce07b
10
+ The build README lists linked third-party libraries and their versions.
11
+
12
+ NAudio.Core, NAudio.Asio, NAudio.WinMM, NAudio.Wasapi 2.2.1
13
+ Copyright (c) Mark Heath and contributors. MIT license.
14
+ See licenses\NAudio-MIT.txt.
15
+ Source: https://github.com/naudio/NAudio/tree/v2.2.1
16
+
17
+ Microsoft.Win32.Registry, System.Security.AccessControl,
18
+ System.Security.Principal.Windows 4.7.0
19
+ Copyright (c) .NET Foundation and Contributors. MIT license.
20
+ https://github.com/dotnet/runtime/blob/main/LICENSE.TXT
21
+
22
+ Zoom H5studio and R16/R24 drivers: linked from Zoom's official support pages, not bundled.
23
+ R16 reference panel image: copyright Zoom Corporation. The npm installer
24
+ fetches the image directly from Zoom's public product website.
25
+ Zoom and R16 are trademarks of Zoom Corporation. This is an independent app.
26
+
27
+ The original drummer mascot was generated for Drum Buddy using OpenAI's
28
+ built-in image-generation tool, then revised to have long curly black hair.
29
+
30
+ The FluxTempo wave/tempo emblem was generated with OpenAI's built-in
31
+ image-generation tool. The original Drum Buddy mascot is preserved in
32
+ the source archive under brand-archive.
@@ -0,0 +1,261 @@
1
+ using System;
2
+ using System.IO;
3
+ using System.Linq;
4
+ using System.Collections.Generic;
5
+ using System.Threading;
6
+ using System.Xml;
7
+ using System.Xml.Serialization;
8
+ using NAudio.Wave;
9
+
10
+ namespace DrumBuddy
11
+ {
12
+ public sealed class EditorTrack
13
+ {
14
+ public string Name = "";
15
+ public bool Mute, Solo;
16
+ public double GainDb;
17
+ public EditorTrack Copy() { return (EditorTrack)MemberwiseClone(); }
18
+ }
19
+
20
+ public sealed class TimelineRegion
21
+ {
22
+ public string Id = Guid.NewGuid().ToString("N"), Source = "", Name = "";
23
+ public int Track;
24
+ public long Start, SourceStart, Length;
25
+ public long End { get { return Start + Length; } }
26
+ public TimelineRegion Copy() { return (TimelineRegion)MemberwiseClone(); }
27
+ }
28
+
29
+ public sealed class EditorSession
30
+ {
31
+ public const long MaxFrame = 44100L * 86400;
32
+ public int Version = 1;
33
+ public string Name = "Untitled session";
34
+ public TempoGridOptions Grid = new TempoGridOptions();
35
+ public EditorTrack[] Tracks = Enumerable.Range(1, 16).Select(i => new EditorTrack { Name = "Track " + i }).ToArray();
36
+ public List<TimelineRegion> Regions = new List<TimelineRegion>();
37
+ [XmlIgnore] public long End { get { return Regions.Count == 0 ? 0 : Regions.Max(r => r.End); } }
38
+ public EditorSession Copy()
39
+ { return new EditorSession { Version = Version, Name = Name, Grid = Grid.Copy(), Tracks = Tracks.Select(t => t.Copy()).ToArray(), Regions = Regions.Select(r => r.Copy()).ToList() }; }
40
+
41
+ public void Validate()
42
+ {
43
+ if (Grid == null) Grid = new TempoGridOptions(); Grid.Validate();
44
+ if (Version != 1 || Tracks == null || Tracks.Length != 16 || Regions == null || Regions.Count > 20000)
45
+ throw new IOException("This is not a supported Flux 16-track editor session.");
46
+ foreach (var track in Tracks)
47
+ if (track == null || double.IsNaN(track.GainDb) || double.IsInfinity(track.GainDb) || track.GainDb < -60 || track.GainDb > 12)
48
+ throw new IOException("The session has an invalid track level.");
49
+ foreach (var r in Regions)
50
+ 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)
52
+ throw new IOException("The session has an invalid audio region.");
53
+ }
54
+
55
+ public static EditorSession Load(string path)
56
+ {
57
+ using (var reader = XmlReader.Create(path, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 32000000 })) {
58
+ var session = (EditorSession)new XmlSerializer(typeof(EditorSession)).Deserialize(reader);
59
+ session.Validate(); return session;
60
+ }
61
+ }
62
+ public void Save(string path)
63
+ {
64
+ Validate(); string target = Path.GetFullPath(path);
65
+ if (Regions.Any(r => string.Equals(Path.GetFullPath(r.Source), target, StringComparison.OrdinalIgnoreCase)))
66
+ throw new IOException("Choose a session file name different from your original audio files.");
67
+ string temp = target + "." + Guid.NewGuid().ToString("N") + ".partial";
68
+ try {
69
+ using (var file = File.Create(temp)) new XmlSerializer(typeof(EditorSession)).Serialize(file, this);
70
+ if (File.Exists(target)) File.Replace(temp, target, null); else File.Move(temp, target);
71
+ }
72
+ finally { Audio.TryDelete(temp); }
73
+ }
74
+ }
75
+
76
+ public sealed class TimelineClipboard
77
+ {
78
+ public bool AllTracks;
79
+ public long Length;
80
+ public List<TimelineRegion> Regions = new List<TimelineRegion>();
81
+ }
82
+
83
+ public static class TimelineEdits
84
+ {
85
+ private static bool Included(TimelineRegion region, int track, bool all) { return all || region.Track == track; }
86
+ public static TimelineClipboard Copy(EditorSession session, int track, bool all, long from, long to)
87
+ {
88
+ if (from < 0 || to <= from) throw new ArgumentException("Select a time range first.");
89
+ 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)) {
91
+ long left = Math.Max(from, r.Start), right = Math.Min(to, r.End);
92
+ var part = r.Copy(); part.Id = Guid.NewGuid().ToString("N");
93
+ part.Start = left - from; part.SourceStart += left - r.Start; part.Length = right - left;
94
+ if (!all) part.Track = 0;
95
+ copied.Regions.Add(part);
96
+ }
97
+ return copied;
98
+ }
99
+ public static bool Delete(EditorSession session, int track, bool all, long from, long to)
100
+ {
101
+ if (from < 0 || to <= from) return false;
102
+ var result = new List<TimelineRegion>(); bool changed = false;
103
+ foreach (var r in session.Regions) {
104
+ if (!Included(r, track, all) || r.Start >= to || r.End <= from) { result.Add(r); continue; }
105
+ changed = true;
106
+ if (r.Start < from) { var left = r.Copy(); left.Length = from - r.Start; result.Add(left); }
107
+ if (r.End > to) { var right = r.Copy(); right.Id = Guid.NewGuid().ToString("N"); right.Start = to; right.SourceStart += to - r.Start; right.Length = r.End - to; result.Add(right); }
108
+ }
109
+ session.Regions = result; return changed;
110
+ }
111
+ public static bool Split(EditorSession session, int track, bool all, long frame)
112
+ {
113
+ var result = new List<TimelineRegion>(); bool changed = false;
114
+ 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.Copy(); left.Length = frame - r.Start;
117
+ var right = r.Copy(); right.Id = Guid.NewGuid().ToString("N"); right.Start = frame; right.SourceStart += left.Length; right.Length -= left.Length;
118
+ result.Add(left); result.Add(right); changed = true;
119
+ }
120
+ session.Regions = result; return changed;
121
+ }
122
+ public static bool Paste(EditorSession session, TimelineClipboard copied, int track, long at)
123
+ {
124
+ if (copied == null || copied.Regions.Count == 0) return false;
125
+ if (at < 0 || at > EditorSession.MaxFrame - copied.Length || track < 0 || track >= 16)
126
+ throw new IOException("The pasted selection would exceed the timeline.");
127
+ foreach (var r in copied.Regions) {
128
+ var part = r.Copy(); part.Id = Guid.NewGuid().ToString("N"); part.Start += at;
129
+ if (!copied.AllTracks) part.Track = track;
130
+ session.Regions.Add(part);
131
+ }
132
+ return true;
133
+ }
134
+ }
135
+
136
+ // The edit model uses integer audio frames. Rendering does not modify source files.
137
+ public sealed class TimelineMix : ISampleProvider, IDisposable
138
+ {
139
+ private sealed class Asset : IDisposable
140
+ {
141
+ public WaveFileReader Reader;
142
+ public byte[] Bytes = new byte[0];
143
+ public float[] Samples = new float[0];
144
+ public void Dispose() { Reader.Dispose(); }
145
+ }
146
+ private readonly object gate = new object();
147
+ private readonly EditorSession session;
148
+ private readonly Dictionary<string, Asset> assets = new Dictionary<string, Asset>(StringComparer.OrdinalIgnoreCase);
149
+ private long position;
150
+ private bool disposed;
151
+ public float Peak;
152
+ public readonly float[] TrackPeaks = new float[16];
153
+ public WaveFormat WaveFormat { get { return WaveFormat.CreateIeeeFloatWaveFormat(Audio.Rate, 2); } }
154
+ public long End { get { return session.End; } }
155
+ public long Position { get { lock (gate) return position; } }
156
+
157
+ public TimelineMix(EditorSession state, IDictionary<string, Clip> decoded)
158
+ {
159
+ state.Validate(); session = state.Copy();
160
+ try {
161
+ foreach (string source in session.Regions.Select(r => r.Source).Distinct(StringComparer.OrdinalIgnoreCase)) {
162
+ Clip clip;
163
+ if (!decoded.TryGetValue(source, out clip) || clip == null || !clip.Ready) throw new IOException("Audio is missing: " + Path.GetFileName(source));
164
+ var reader = new WaveFileReader(clip.Cache);
165
+ if (reader.WaveFormat.Encoding != WaveFormatEncoding.IeeeFloat || reader.WaveFormat.BitsPerSample != 32 || reader.WaveFormat.SampleRate != Audio.Rate || reader.WaveFormat.Channels < 1 || reader.WaveFormat.Channels > 2) {
166
+ reader.Dispose(); throw new IOException("Re-import this audio file before using it on the timeline.");
167
+ }
168
+ assets.Add(source, new Asset { Reader = reader });
169
+ }
170
+ foreach (var r in session.Regions)
171
+ if (r.SourceStart + r.Length > assets[r.Source].Reader.Length / assets[r.Source].Reader.WaveFormat.BlockAlign)
172
+ throw new IOException("An audio source is shorter than its saved region: " + r.Name);
173
+ }
174
+ catch { Dispose(); throw; }
175
+ }
176
+ public void Seek(long frame) { lock (gate) { position = Math.Max(0, Math.Min(End, frame)); } }
177
+ public int Read(float[] output, int offset, int count)
178
+ {
179
+ lock (gate) {
180
+ if (disposed || position >= End) return 0;
181
+ int frames = (int)Math.Min(count / 2, End - position);
182
+ if (frames == 0) return 0;
183
+ Array.Clear(output, offset, frames * 2);
184
+ bool solo = session.Tracks.Any(t => t.Solo);
185
+ long blockEnd = position + frames;
186
+ foreach (var r in session.Regions) {
187
+ var track = session.Tracks[r.Track];
188
+ if (track.Mute || (solo && !track.Solo) || r.Start >= blockEnd || r.End <= position) continue;
189
+ long from = Math.Max(r.Start, position), to = Math.Min(r.End, blockEnd);
190
+ int take = (int)(to - from), skip = (int)(from - position);
191
+ var asset = assets[r.Source]; int channels = asset.Reader.WaveFormat.Channels;
192
+ int byteCount = checked(take * channels * 4);
193
+ if (asset.Bytes.Length < byteCount) { asset.Bytes = new byte[byteCount]; asset.Samples = new float[byteCount / 4]; }
194
+ asset.Reader.Position = (r.SourceStart + from - r.Start) * channels * 4;
195
+ int got = 0, n;
196
+ while (got < byteCount && (n = asset.Reader.Read(asset.Bytes, got, byteCount - got)) > 0) got += n;
197
+ if (got != byteCount) throw new IOException("An audio source changed while playing. Reopen the session.");
198
+ Buffer.BlockCopy(asset.Bytes, 0, asset.Samples, 0, got);
199
+ float gain = (float)Math.Pow(10, track.GainDb / 20);
200
+ for (int f = 0; f < take; f++) {
201
+ float left = asset.Samples[f * channels] * gain;
202
+ float right = asset.Samples[f * channels + (channels == 1 ? 0 : 1)] * gain;
203
+ output[offset + (skip + f) * 2] += left; output[offset + (skip + f) * 2 + 1] += right;
204
+ TrackPeaks[r.Track] = Math.Max(TrackPeaks[r.Track], Math.Max(Math.Abs(left), Math.Abs(right)));
205
+ }
206
+ }
207
+ for (int i = offset; i < offset + frames * 2; i++) {
208
+ if (float.IsNaN(output[i]) || float.IsInfinity(output[i])) throw new IOException("The mix contains invalid audio samples.");
209
+ Peak = Math.Max(Peak, Math.Abs(output[i]));
210
+ }
211
+ position += frames; return frames * 2;
212
+ }
213
+ }
214
+ public void Dispose()
215
+ { lock (gate) { if (disposed) return; disposed = true; foreach (var asset in assets.Values) asset.Dispose(); assets.Clear(); } }
216
+ }
217
+
218
+ public static class TimelineRender
219
+ {
220
+ public static void Export(EditorSession session, IDictionary<string, Clip> assets, string path, int bits, bool protect, long from, long to, CancellationToken cancel, Action<double> progress)
221
+ {
222
+ if (bits != 16 && bits != 24 && bits != 32) throw new ArgumentException("Choose PCM 16-bit, PCM 24-bit, or 32-bit float.");
223
+ session.Validate(); string target = Path.GetFullPath(path);
224
+ if (session.Regions.Any(r => string.Equals(Path.GetFullPath(r.Source), target, StringComparison.OrdinalIgnoreCase)))
225
+ throw new IOException("Choose a new export file name to preserve your original audio.");
226
+ from = Math.Max(0, from); to = to <= 0 ? session.End : Math.Min(to, session.End);
227
+ long frames = to - from;
228
+ if (frames <= 0) throw new IOException("The selected timeline range has no audio to export.");
229
+ if (frames * 2 * (bits / 8) > FloatWaveWriter.MaxData) throw new IOException("This mix would exceed the 4 GB WAV limit. Export a shorter selection.");
230
+ string temp = target + "." + Guid.NewGuid().ToString("N") + ".partial";
231
+ try {
232
+ using (var mix = new TimelineMix(session, assets)) {
233
+ var buffer = new float[8192 * 2]; double scale = 1; bool scan = protect && bits != 32;
234
+ if (scan) {
235
+ mix.Seek(from); long remaining = frames;
236
+ while (remaining > 0) {
237
+ cancel.ThrowIfCancellationRequested(); int read = mix.Read(buffer, 0, (int)Math.Min(8192, remaining) * 2);
238
+ if (read == 0) throw new IOException("The audio ended before the selection finished.");
239
+ remaining -= read / 2; if (progress != null) progress((1 - remaining / (double)frames) * .45);
240
+ }
241
+ if (mix.Peak > .891250938f) scale = .891250938 / mix.Peak;
242
+ }
243
+ mix.Seek(from);
244
+ IRecordingWriter writer = bits == 32 ? (IRecordingWriter)new FloatWaveWriter(temp, 2, Audio.Rate) : new PcmWriter(temp, bits, 2);
245
+ using (writer) {
246
+ long remaining = frames;
247
+ while (remaining > 0) {
248
+ cancel.ThrowIfCancellationRequested(); int read = mix.Read(buffer, 0, (int)Math.Min(8192, remaining) * 2);
249
+ if (read == 0) throw new IOException("The audio ended before the selection finished.");
250
+ writer.Write(buffer, read, scale); remaining -= read / 2;
251
+ if (progress != null) progress((scan ? .45 : 0) + (1 - remaining / (double)frames) * (scan ? .55 : 1));
252
+ }
253
+ }
254
+ }
255
+ cancel.ThrowIfCancellationRequested();
256
+ if (File.Exists(target)) File.Replace(temp, target, null); else File.Move(temp, target);
257
+ }
258
+ finally { Audio.TryDelete(temp); }
259
+ }
260
+ }
261
+ }
@@ -0,0 +1,57 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+
4
+ namespace DrumBuddy
5
+ {
6
+ internal enum TimelineDrag { None, Playhead, Selection, SelectionStart, SelectionEnd }
7
+ internal sealed class TimelineGesture
8
+ {
9
+ public TimelineDrag Mode;
10
+ public long Playhead, From, To;
11
+ private long anchor, oldPlayhead, oldFrom, oldTo;
12
+ public void Begin(TimelineDrag mode, long frame, long playhead, long from, long to)
13
+ {
14
+ Mode = mode; anchor = frame; oldPlayhead = Playhead = playhead; oldFrom = From = from; oldTo = To = to;
15
+ Move(frame);
16
+ }
17
+ public void Move(long frame)
18
+ {
19
+ frame = Math.Max(0, Math.Min(EditorSession.MaxFrame, frame));
20
+ if (Mode == TimelineDrag.Playhead) Playhead = frame;
21
+ else if (Mode == TimelineDrag.Selection) { From = Math.Min(anchor, frame); To = Math.Max(anchor, frame); }
22
+ else if (Mode == TimelineDrag.SelectionStart) From = Math.Min(frame, To);
23
+ else if (Mode == TimelineDrag.SelectionEnd) To = Math.Max(From, frame);
24
+ }
25
+ public void End(long frame) { Move(frame); Mode = TimelineDrag.None; }
26
+ public void Finish() { Mode = TimelineDrag.None; }
27
+ public void Cancel() { Playhead = oldPlayhead; From = oldFrom; To = oldTo; Mode = TimelineDrag.None; }
28
+ public static long FrameAt(double x, double pixelsPerSecond, IEnumerable<long> boundaries, bool precise)
29
+ { return FrameAt(x, pixelsPerSecond, boundaries, precise, null, false, true); }
30
+ public static long FrameAt(double x, double pixelsPerSecond, IEnumerable<long> boundaries, bool precise, TempoGridOptions grid, bool snapGrid, bool snapEdges)
31
+ {
32
+ if (double.IsNaN(x) || double.IsInfinity(x) || pixelsPerSecond <= 0) return 0;
33
+ x = Math.Max(0, x);
34
+ long frame = Math.Max(0, Math.Min(EditorSession.MaxFrame, (long)Math.Round(x / pixelsPerSecond * Audio.Rate)));
35
+ if (precise) return frame;
36
+ double zeroRange = snapGrid && grid != null ? Math.Min(10, Math.Min(TempoGrid.StepSeconds(grid), TempoGrid.BarSeconds(grid)) * pixelsPerSecond / 2) : 10;
37
+ if ((snapEdges || snapGrid) && (snapGrid ? x < zeroRange : x <= zeroRange)) return 0;
38
+ double nearest = 8; long snapped = frame; bool edge = false;
39
+ if (snapEdges) foreach (long boundary in boundaries) {
40
+ if (boundary < 0 || boundary > EditorSession.MaxFrame) continue;
41
+ double distance = Math.Abs(boundary / (double)Audio.Rate * pixelsPerSecond - x);
42
+ if (boundary == 0 && snapGrid && distance >= zeroRange) continue;
43
+ if (distance < nearest) { nearest = distance; snapped = boundary; edge = true; }
44
+ }
45
+ return edge ? snapped : snapGrid && grid != null ? TempoGrid.Snap(frame, grid) : frame;
46
+ }
47
+ public static TimelineDrag RulerHit(double x, double y, double startX, double endX, bool hasRange)
48
+ {
49
+ if (hasRange && y >= 13) {
50
+ double a = Math.Abs(x - startX), b = Math.Abs(x - endX);
51
+ if (a <= 10 && a <= b) return TimelineDrag.SelectionStart;
52
+ if (b <= 10) return TimelineDrag.SelectionEnd;
53
+ }
54
+ return TimelineDrag.Playhead;
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,69 @@
1
+ using System;
2
+ using System.IO;
3
+ using System.Linq;
4
+ using System.Reflection;
5
+ using System.Diagnostics;
6
+ using System.Threading;
7
+ using System.Windows.Forms;
8
+ using Microsoft.Win32;
9
+
10
+ public static class Uninstall
11
+ {
12
+ private static string Target { get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"Programs","DrumBuddy"); } }
13
+ [STAThread]
14
+ public static int Main(string[] args)
15
+ {
16
+ if (args.Length == 1 && args[0] == "--remove-installed-files") {
17
+ Thread.Sleep(1500);
18
+ if (!File.Exists(Path.Combine(Target,".drumbuddy-installed"))) return 1;
19
+ try {
20
+ string root = Path.GetFullPath(Target).TrimEnd('\\') + "\\";
21
+ string[] files = File.ReadAllLines(Path.Combine(Target,"installed-files.txt"));
22
+ foreach (string relative in files) {
23
+ string full = Path.GetFullPath(Path.Combine(root,relative));
24
+ if (!full.StartsWith(root,StringComparison.OrdinalIgnoreCase) || relative.Contains(":")) continue;
25
+ for (int retry = 0; retry < 20; retry++) {
26
+ try { if (File.Exists(full)) File.Delete(full); break; }
27
+ catch (IOException) { Thread.Sleep(250); }
28
+ }
29
+ }
30
+ foreach (string dir in Directory.GetDirectories(root,"*",SearchOption.AllDirectories).OrderByDescending(d => d.Length))
31
+ if (!Directory.EnumerateFileSystemEntries(dir).Any()) Directory.Delete(dir);
32
+ if (!Directory.EnumerateFileSystemEntries(root).Any()) Directory.Delete(root);
33
+ Registry.CurrentUser.DeleteSubKeyTree(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\DrumBuddy",false);
34
+ RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"Flux.lnk"));
35
+ RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","Flux.lnk"));
36
+ RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"Flux Audio.lnk"));
37
+ RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","Flux Audio.lnk"));
38
+ RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"FluxTempo.lnk"));
39
+ RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","FluxTempo.lnk"));
40
+ RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),"Drum Buddy.lnk"));
41
+ RemoveShortcut(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu),"Programs","Drum Buddy.lnk"));
42
+ return 0;
43
+ }
44
+ catch (Exception ex) { MessageBox.Show("Some program files could not be removed. Your audio files were kept.\n\n" + ex.Message,"Flux"); return 1; }
45
+ }
46
+ if (!string.Equals(Path.GetFullPath(AppDomain.CurrentDomain.BaseDirectory).TrimEnd('\\'),Target,StringComparison.OrdinalIgnoreCase) || !File.Exists(Path.Combine(Target,".drumbuddy-installed"))) {
47
+ MessageBox.Show("This uninstaller can only remove the installed copy of Flux.","Flux"); return 1;
48
+ }
49
+ if (Process.GetProcessesByName("DrumBuddy").Length > 0) { MessageBox.Show("Close Flux before uninstalling.","Flux"); return 1; }
50
+ if (MessageBox.Show("Remove Flux?\n\nYour recordings, exported WAV files, and Zoom driver will be kept.","Uninstall Flux",MessageBoxButtons.YesNo,MessageBoxIcon.Question) != DialogResult.Yes) return 0;
51
+ string temporary = Path.Combine(Path.GetTempPath(),"DrumBuddy-Uninstall-" + Guid.NewGuid().ToString("N") + ".exe");
52
+ File.Copy(Assembly.GetExecutingAssembly().Location,temporary);
53
+ Process.Start(new ProcessStartInfo(temporary,"--remove-installed-files") { UseShellExecute = false, CreateNoWindow = true }); return 0;
54
+ }
55
+ private static void RemoveShortcut(string path)
56
+ {
57
+ if (!File.Exists(path)) return;
58
+ object shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell"));
59
+ try {
60
+ object link = shell.GetType().InvokeMember("CreateShortcut",BindingFlags.InvokeMethod,null,shell,new object[] { path });
61
+ try {
62
+ string target = (string)link.GetType().InvokeMember("TargetPath",BindingFlags.GetProperty,null,link,null);
63
+ if (string.Equals(target,Path.Combine(Target,"DrumBuddy.exe"),StringComparison.OrdinalIgnoreCase)) File.Delete(path);
64
+ }
65
+ finally { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(link); }
66
+ }
67
+ finally { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shell); }
68
+ }
69
+ }