@pro-gram/flux 1.6.2 → 1.6.4

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/README.md CHANGED
@@ -44,7 +44,7 @@ Editor opens by default. File contains New, Open session, Save, Save as and Add
44
44
 
45
45
  The compact header puts navigation beside the logo. Grid and zoom share one toolbar, and playback/output selection share the bottom strip. Export settings and extra range/import options expand when needed, keeping more tracks visible on smaller displays.
46
46
 
47
- The editor supports 16 tracks, multiple audio regions, selected/all-track edits, mute/solo, levels, split, cut/copy/paste, undo/redo, saved `.dbedit` sessions and PCM/float mix export. Original audio stays unchanged.
47
+ The editor supports 16 tracks, multiple audio regions, selected/all-track edits, a live mixer, split, cut/copy/paste, undo/redo, saved `.dbedit` sessions and PCM/float mix export. Original audio stays unchanged.
48
48
 
49
49
  Select audio, press **Ctrl+C**, press **Backspace** to delete that section, place the playhead and press **Ctrl+V**. Deleting preserves the timeline gap and the internal audio clipboard. Delete is also available as an editable alternate; Ctrl+X still cuts. Paste starts exactly at the playhead. The Cut/Copy/Paste toolbar buttons are removed.
50
50
 
@@ -56,6 +56,18 @@ Drag a clip's header to move it; selected groups retain their time/track offsets
56
56
 
57
57
  Moves, trims, slip, clip mute and fades are non-destructive and undoable. Fades affect playback and exported mixes; splitting/copying inside a fade preserves the original envelope. Escape cancels a clip drag. The editor's select/slip/fade modes and all default commands have editable keybindings.
58
58
 
59
+ **Ctrl+Left / Ctrl+Right** moves the playhead to the nearest bar line strictly left/right using the project tempo and time signature. From an exact bar line, it moves to the previous/next bar. Playback continues when jumping within the audio; a paused jump stays silent. **Enter** returns to the beginning, and **Home** remains available. These bindings appear in Settings > Keybinds. Text fields retain their normal editing keys.
60
+
61
+ ### Mixer
62
+
63
+ Open **Mixer** from the editor toolbar or **View > Mixer**. Each of the 16 tracks has a **−60 to +24 dB fader**, numeric gain entry, pan/stereo balance, mute and solo. A pinned master strip provides **−60 to +12 dB gain**, mute and stereo output metering. Changes take effect while playback continues, support undo/redo, save with the session and apply to exported mixes. Original audio stays unchanged.
64
+
65
+ **File peak** shows the loaded source level independently of mixer gain. Choose **Before fader** to inspect incoming track levels, including muted tracks, or **After fader** to see the adjusted signal. Stereo peak readouts turn red at 0 dBFS and stay latched until cleared. **Monitor** controls listening volume only; it changes neither exports nor mixer readings.
66
+
67
+ During recording, the registered strips show the selected raw input channels. Set capture gain on the input device. The mixer changes playback/export levels after recording; use hardware direct monitoring during capture.
68
+
69
+ Scroll across the tracks while the master remains visible. Resize the panel, choose Side/Bottom, or collapse it to recover timeline space.
70
+
59
71
  ### Editor zoom
60
72
 
61
73
  The visible **- / + buttons and slider** zoom the timeline from a full-project overview to individual samples. **Ctrl+mouse wheel** zooms around the time under the pointer; **Shift+wheel** scrolls sideways. **Fit** shows the full project, and **Selection** fills the pane with the selected time range.
@@ -120,7 +132,8 @@ The Zoom R16 conversion preset writes classic 44.1 kHz 16/24-bit PCM WAV with sh
120
132
  | Save / save as | Ctrl+S / Ctrl+Shift+S |
121
133
  | Open / new session | Ctrl+O / Ctrl+N |
122
134
  | Import / export | Ctrl+I / Ctrl+E |
123
- | Start / end | Home / End |
135
+ | Start / end | Enter or Home / End |
136
+ | Previous / next bar | Ctrl+Left / Ctrl+Right |
124
137
  | Zoom in / out | Ctrl+= / Ctrl+- |
125
138
  | Fit project / selection | Ctrl+0 / Ctrl+Shift+0 |
126
139
  | Home / Editor / Converter / Player | Ctrl+1 / Ctrl+2 / Ctrl+3 / Ctrl+4 |
package/dependencies.json CHANGED
@@ -10,6 +10,6 @@
10
10
  "sha256": "315a71e8942a445c903b2d1aedb703b64aab30f054e46199a2bac447d3d03cb2",
11
11
  "alternateSha256": "5564fa622f101dc27ea402c399c5b9b1e1e039b8771daf40b185dab9772adf38"
12
12
  },
13
- "installerSha256": "1d54666d43c987d0200d70634cec75da7e7cc269aaee224603c5cc7139a2cb81",
13
+ "installerSha256": "c30e7d28feca44ba1116d70e2cd1a66443b5fbf2296d3a800ec59320d75c0a77",
14
14
  "bundledAssets": true
15
15
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pro-gram/flux",
3
- "version": "1.6.2",
4
- "description": "Flux: a 16-track Windows audio studio with native shared playback, float/PCM recording, clip editing tools, tempo grids and bundled audio codecs.",
3
+ "version": "1.6.4",
4
+ "description": "Flux: a 16-track Windows audio studio with a live mixer, float/PCM recording, clip editing, tempo grids and bundled audio codecs.",
5
5
  "license": "MIT",
6
6
  "author": "pro-gram",
7
7
  "os": [
@@ -50,7 +50,8 @@
50
50
  "flux",
51
51
  "tempo-grid",
52
52
  "beat-grid",
53
- "snap-to-grid"
53
+ "snap-to-grid",
54
+ "mixer"
54
55
  ],
55
56
  "publishConfig": {
56
57
  "access": "public",
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/payload/DrumBuddy-Setup.exe
3
- size: 41726976 bytes
4
- sha256: 1d54666d43c987d0200d70634cec75da7e7cc269aaee224603c5cc7139a2cb81
3
+ size: 41809920 bytes
4
+ sha256: c30e7d28feca44ba1116d70e2cd1a66443b5fbf2296d3a800ec59320d75c0a77
@@ -21,7 +21,7 @@ namespace DrumBuddySetup
21
21
  if(File.Exists(target) && expected.Contains(Hash(target),StringComparer.OrdinalIgnoreCase))return target;
22
22
  string temporary=target+"."+Guid.NewGuid().ToString("N")+".partial";
23
23
  try {
24
- using(var client=new WebClient()) { client.Headers[HttpRequestHeader.UserAgent]="DrumBuddy/1.6.2";client.DownloadFile(url,temporary); }
24
+ using(var client=new WebClient()) { client.Headers[HttpRequestHeader.UserAgent]="DrumBuddy/1.6.4";client.DownloadFile(url,temporary); }
25
25
  if(!expected.Contains(Hash(temporary),StringComparer.OrdinalIgnoreCase))throw new IOException("A downloaded component failed its integrity check. Please retry setup or obtain a newer Drum Buddy release.");
26
26
  File.Copy(temporary,target,true);return target;
27
27
  }finally { if(File.Exists(temporary))File.Delete(temporary); }
@@ -1,4 +1,4 @@
1
- Flux 1.6.2 bundled FFmpeg audio decoders
1
+ Flux 1.6.4 bundled FFmpeg audio decoders
2
2
  These decode imported files without Windows Media Player codecs.
3
3
 
4
4
  A..... = Audio
@@ -1,4 +1,4 @@
1
- FLUX SOURCE (DRUM BUDDY COMPATIBLE UPDATE)
1
+ FLUX SOURCE
2
2
 
3
3
  Build on Windows 10/11 x64 with .NET Framework 4.8.
4
4
  No Visual Studio or .NET SDK is required: Build.ps1 uses the C# compiler
@@ -18,6 +18,7 @@ Verification:
18
18
  DrumBuddy.exe --tools-self-test "C:\path\to\new-tool-test-folder"
19
19
  DrumBuddy.exe --workspace-self-test "C:\path\to\new-editor-test-folder"
20
20
  DrumBuddy.exe --float-self-test "C:\path\to\new-float-test-folder"
21
+ DrumBuddy.exe --mixer-self-test "C:\path\to\new-mixer-test-folder"
21
22
  DrumBuddy.exe --recording-self-test "C:\path\to\new-recording-test-folder"
22
23
  DrumBuddy.exe --output-self-test "C:\path\to\new-output-test-folder"
23
24
  DrumBuddy.exe --editor-playback-self-test "C:\path\to\new-playback-test-folder"
@@ -44,6 +45,9 @@ Windows microphone buffer tests cover routing, saved samples and queue draining.
44
45
  The recording suite covers count-in timing/cancellation, frozen input and track
45
46
  routing, captured-frame playhead progress, recording visuals, take insertion
46
47
  and preserved originals using generated input buffers.
48
+ The mixer suite checks live track/master gain, pan, mute/solo, source/output
49
+ meters, session compatibility, undo, exported levels and dark/light layouts.
50
+ It exercises native playback with the monitor volume at zero.
47
51
  The interface suite checks keybind capture, saved/unsaved settings, theme
48
52
  switching, integrated recording insertion and dark/light/compact layouts.
49
53
  The general-conversion suite checks original-rate float/PCM WAV, FLAC,
package/source/Build.ps1 CHANGED
@@ -101,6 +101,7 @@ $appArgs += @('EditorTools.cs','EditorToolInteractions.cs') | ForEach-Object { J
101
101
  $appArgs += Join-Path $PSScriptRoot 'EditorToolsTests.cs'
102
102
  $appArgs += Join-Path $PSScriptRoot 'EditorCompactLayout.cs'
103
103
  $appArgs += @('RecordingSession.cs','EditorRecording.cs','RecordingTests.cs') | ForEach-Object { Join-Path $PSScriptRoot $_ }
104
+ $appArgs += @('TimelineMixer.cs','EditorMixer.cs','EditorMixerTests.cs') | ForEach-Object { Join-Path $PSScriptRoot $_ }
104
105
  & $compiler @appArgs
105
106
  if ($LASTEXITCODE -ne 0) { throw 'App compilation failed.' }
106
107
  $iconVersion = [Reflection.AssemblyName]::GetAssemblyName((Join-Path $package 'DrumBuddy.exe')).Version.ToString(3)
@@ -26,7 +26,7 @@ namespace DrumBuddy
26
26
  play.MinWidth = 52;
27
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
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 "; }
29
+ foreach (var text in transport.Children.OfType<TextBlock>().Where(t => t != positionText)) { text.FontSize = 12; if (text.Text.StartsWith("Monitor")) text.Text = "Monitor "; }
30
30
  bottom.Children.Remove(outputPicker); outputPicker.UseCompactLayout(); transport.Children.Add(outputPicker);
31
31
  selectionText.FontSize = 11; selectionText.MaxWidth = 220; selectionText.Margin = new Thickness(5, 0, 0, 4); transport.Children.Add(selectionText);
32
32
  foreach (var control in exports.Children.OfType<Control>()) { control.FontSize = 12; control.MinHeight = 26; control.Margin = new Thickness(0, 0, 8, 3); }
@@ -0,0 +1,267 @@
1
+ using System;
2
+ using System.Globalization;
3
+ using System.Linq;
4
+ using System.Windows;
5
+ using System.Windows.Controls;
6
+ using System.Windows.Controls.Primitives;
7
+ using System.Windows.Input;
8
+ using System.Windows.Media;
9
+
10
+ namespace DrumBuddy
11
+ {
12
+ public sealed class StereoLevelMeter : FrameworkElement
13
+ {
14
+ private float left, right;
15
+ public StereoLevelMeter() { Width = 21; Height = 96; IsHitTestVisible = false; }
16
+ public static double Level(float sample) { return sample <= .001f ? 0 : Math.Max(0, Math.Min(1, (20 * Math.Log10(sample) + 60) / 60)); }
17
+ public void SetLevels(float l, float r) { left = Math.Max(0, l); right = Math.Max(0, r); InvalidateVisual(); }
18
+ protected override void OnRender(DrawingContext drawing)
19
+ {
20
+ base.OnRender(drawing); double width = (ActualWidth - 3) / 2, height = ActualHeight;
21
+ for (int channel = 0; channel < 2; channel++) {
22
+ double level = Level(channel == 0 ? left : right);
23
+ for (int segment = 0; segment < 24; segment++) {
24
+ bool lit = (segment + 1) / 24.0 <= level;
25
+ var color = !lit ? "#D6E1DF" : segment >= 23 ? "#B52C36" : segment >= 19 ? "#D0772C" : "#007A6B";
26
+ drawing.PushOpacity(lit ? 1 : .3);
27
+ drawing.DrawRectangle(WorkspaceUi.Color(color), null, new Rect(channel * (width + 3), height - (segment + 1) * height / 24, width, Math.Max(1, height / 24 - 1)));
28
+ drawing.Pop();
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ public sealed partial class EditorView
35
+ {
36
+ private sealed class MixerStrip
37
+ {
38
+ public Border Frame;
39
+ public Button Name, Peak;
40
+ public TextBlock NameText;
41
+ public StereoLevelMeter Meter;
42
+ public Slider Gain, Pan;
43
+ public TextBox GainText;
44
+ public ToggleButton Mute, Solo;
45
+ public TextBlock Source;
46
+ public float Left, Right, Hold;
47
+ public DateTime HoldUntil;
48
+ public bool Over;
49
+ }
50
+ private DockablePane mixerPane;
51
+ private readonly MixerStrip[] mixerStrips = new MixerStrip[16];
52
+ private MixerStrip masterStrip;
53
+ private ScrollViewer mixerTrackScroll;
54
+ private ComboBox mixerMeterMode;
55
+ private TextBlock mixerContext;
56
+ private readonly ToggleButton[] headerMutes = new ToggleButton[16], headerSolos = new ToggleButton[16];
57
+ private readonly ComboBox[] headerLevels = new ComboBox[16];
58
+ private bool updatingMixer;
59
+ private string mixerUndoKey;
60
+ private DateTime mixerUndoUntil, inputMeterUntil;
61
+ private readonly float[] recordingMixerPeaks = new float[16];
62
+ private readonly float[] recordingMixerLeft = new float[16], recordingMixerRight = new float[16];
63
+ private int lastMixerSelection = -1;
64
+ public bool MixerVisible { get { return mixerPane != null && mixerPane.IsPaneVisible; } }
65
+ public void ToggleMixer() { ShowMixer(!MixerVisible); }
66
+ public void ShowMixer(bool visible)
67
+ {
68
+ mixerPane.ShowPane(visible);
69
+ if (visible) { if (mix != null) mix.DrainMeters(); RefreshMixerControls(); }
70
+ }
71
+ private FrameworkElement CreateMixer()
72
+ {
73
+ var root = new Grid { Margin = new Thickness(7, 0, 7, 5) };
74
+ root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); root.RowDefinitions.Add(new RowDefinition()); root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
75
+ var toolbar = new WrapPanel(); root.Children.Add(toolbar);
76
+ mixerContext = WorkspaceUi.Text("Meters", 12); mixerContext.Margin = new Thickness(0, 0, 7, 4); toolbar.Children.Add(mixerContext);
77
+ mixerMeterMode = new ComboBox { ItemsSource = new[] { "After fader", "Before fader" }, SelectedIndex = 0, Width = 116, MinHeight = 25, FontSize = 11, Margin = new Thickness(0, 0, 8, 4) };
78
+ toolbar.Children.Add(mixerMeterMode);
79
+ mixerMeterMode.SelectionChanged += delegate { ResetMixerMeters(); };
80
+ var clear = WorkspaceUi.Button("Clear peaks", delegate { ResetMixerMeters(); }); clear.MinHeight = 25; clear.FontSize = 11; clear.Padding = new Thickness(6, 2, 6, 2); toolbar.Children.Add(clear);
81
+ var reset = WorkspaceUi.Button("Reset mix", delegate { ResetMixer(); }); reset.MinHeight = 25; reset.FontSize = 11; reset.Padding = new Thickness(6, 2, 6, 2); toolbar.Children.Add(reset);
82
+ var close = WorkspaceUi.Button("Hide mixer", delegate { ShowMixer(false); }); close.MinHeight = 25; close.FontSize = 11; close.Padding = new Thickness(6, 2, 6, 2); toolbar.Children.Add(close);
83
+ var channels = new Grid(); channels.ColumnDefinitions.Add(new ColumnDefinition()); channels.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(108) }); SetRow(channels, 1); root.Children.Add(channels);
84
+ var trackStrips = new StackPanel { Orientation = Orientation.Horizontal };
85
+ for (int i = 0; i < 16; i++) { mixerStrips[i] = CreateMixerStrip(i); trackStrips.Children.Add(mixerStrips[i].Frame); }
86
+ mixerTrackScroll = new ScrollViewer { Content = trackStrips, HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Disabled };
87
+ channels.Children.Add(mixerTrackScroll);
88
+ masterStrip = CreateMixerStrip(-1); SetColumn(masterStrip.Frame, 1); channels.Children.Add(masterStrip.Frame);
89
+ var note = WorkspaceUi.Text("File peak shows the saved source level. Faders change playback/export; recording gain is set on the input device.", 11);
90
+ note.Margin = new Thickness(2, 3, 2, 0); SetRow(note, 2); root.Children.Add(note);
91
+ return root;
92
+ }
93
+ private MixerStrip CreateMixerStrip(int index)
94
+ {
95
+ bool master = index < 0; var strip = new MixerStrip();
96
+ var body = new StackPanel();
97
+ strip.Name = WorkspaceUi.Button(master ? "MASTER" : (index + 1).ToString("00"), delegate { if (!master && !IsBusy) { track = index; UpdateSelectionText(); BuildHeaders(); DrawCanvas(); } });
98
+ strip.Name.FontSize = 11; strip.Name.FontWeight = FontWeights.SemiBold; strip.Name.MinHeight = 25; strip.Name.Height = 25; strip.Name.Padding = new Thickness(2); strip.Name.Margin = new Thickness(0, 0, 0, 3);
99
+ strip.NameText = WorkspaceUi.Text(master ? "MASTER" : (index + 1).ToString("00"), 11); strip.NameText.MaxWidth = 80; strip.NameText.TextWrapping = TextWrapping.NoWrap; strip.NameText.TextTrimming = TextTrimming.CharacterEllipsis; strip.Name.Content = strip.NameText;
100
+ body.Children.Add(strip.Name);
101
+ strip.Source = WorkspaceUi.Text(master ? "Mix output" : "File peak —", 11); strip.Source.TextWrapping = TextWrapping.NoWrap; strip.Source.TextTrimming = TextTrimming.CharacterEllipsis;
102
+ strip.Source.Margin = new Thickness(0, 0, 0, 3); body.Children.Add(strip.Source);
103
+ var level = new Grid { Height = 96 }; level.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(27) }); level.ColumnDefinitions.Add(new ColumnDefinition()); level.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(21) }); body.Children.Add(level);
104
+ var scale = new Canvas { Height = 96 }; level.Children.Add(scale);
105
+ foreach (var mark in new[] { new { Text = master ? "+12" : "+24", Top = 0.0 }, new { Text = "0 dB", Top = 96.0 * (master ? 12 : 24) / (master ? 72 : 84) - 6 }, new { Text = "−60", Top = 83.0 } }) {
106
+ var label = WorkspaceUi.Text(mark.Text, 11); label.FontSize = 9; Canvas.SetTop(label, Math.Max(0, mark.Top)); scale.Children.Add(label);
107
+ }
108
+ strip.Gain = new Slider { Minimum = -60, Maximum = master ? 12 : 24, Value = 0, Orientation = Orientation.Vertical, SmallChange = 1, LargeChange = 3, TickFrequency = 1, IsSnapToTickEnabled = false, ToolTip = master ? "Master mix gain: playback and export, before the listening volume" : "Track gain: playback and export; original recording unchanged", Margin = new Thickness(1, 0, 1, 0) };
109
+ strip.Gain.Height = 96;
110
+ SetColumn(strip.Gain, 1); level.Children.Add(strip.Gain);
111
+ strip.Meter = new StereoLevelMeter(); SetColumn(strip.Meter, 2); level.Children.Add(strip.Meter);
112
+ strip.Gain.ValueChanged += delegate { if (!updatingMixer) SetMixerGain(index, Math.Round(strip.Gain.Value, 1)); };
113
+ strip.Gain.PreviewMouseLeftButtonUp += delegate { FinishMixerGesture(); }; strip.Gain.LostKeyboardFocus += delegate { FinishMixerGesture(); };
114
+ strip.GainText = new TextBox { Text = "0.0", FontSize = 11, FontFamily = new FontFamily("Consolas"), HorizontalContentAlignment = HorizontalAlignment.Center, Padding = new Thickness(2), MinHeight = 22, Height = 22, Margin = new Thickness(0, 3, 0, 2), ToolTip = "Gain in dB; enter 0 for unity" }; body.Children.Add(strip.GainText);
115
+ Action commit = delegate {
116
+ double value; if (!TempoGrid.TryTempo(strip.GainText.Text, out value) || double.IsNaN(value) || double.IsInfinity(value) || value < -60 || value > (master ? 12 : 24)) {
117
+ status("Enter a mixer level between -60 and +" + (master ? 12 : 24) + " dB.");
118
+ } else SetMixerGain(index, value);
119
+ strip.GainText.Text = (master ? session.MasterGainDb : session.Tracks[index].GainDb).ToString("0.0", CultureInfo.InvariantCulture); FinishMixerGesture();
120
+ };
121
+ strip.GainText.LostKeyboardFocus += delegate { commit(); };
122
+ strip.GainText.TextChanged += delegate { double value; if (!updatingMixer && strip.GainText.IsKeyboardFocusWithin && TempoGrid.TryTempo(strip.GainText.Text, out value) && !double.IsNaN(value) && !double.IsInfinity(value) && value >= -60 && value <= (master ? 12 : 24)) SetMixerGain(index, value); };
123
+ strip.GainText.KeyDown += delegate(object sender, KeyEventArgs e) { if (e.Key == Key.Enter) { commit(); e.Handled = true; } };
124
+ strip.Pan = new Slider { Minimum = -1, Maximum = 1, Value = 0, SmallChange = .05, LargeChange = .2, Height = 19, ToolTip = master ? "The master is stereo" : "Pan / stereo balance. Centre preserves the original channel levels.", IsEnabled = !master, Margin = new Thickness(0, 1, 0, 2) }; body.Children.Add(strip.Pan);
125
+ strip.Pan.ValueChanged += delegate { if (!master && !updatingMixer) SetMixerPan(index, Math.Round(strip.Pan.Value, 2)); };
126
+ strip.Pan.PreviewMouseLeftButtonUp += delegate { FinishMixerGesture(); }; strip.Pan.LostKeyboardFocus += delegate { FinishMixerGesture(); };
127
+ var switches = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center };
128
+ strip.Mute = new ToggleButton { Content = master ? "Mute" : "M", MinWidth = master ? 66 : 35, Height = 23, FontSize = 11, Margin = new Thickness(0, 0, 3, 0), ToolTip = master ? "Mute the master mix in playback and export" : "Mute this track" }; switches.Children.Add(strip.Mute);
129
+ strip.Mute.Click += delegate { if (!updatingMixer) SetMixerMute(index, strip.Mute.IsChecked == true); };
130
+ strip.Solo = new ToggleButton { Content = "S", Width = 35, Height = 23, FontSize = 11, ToolTip = "Solo this track", Visibility = master ? Visibility.Collapsed : Visibility.Visible }; switches.Children.Add(strip.Solo);
131
+ strip.Solo.Click += delegate { if (!master && !updatingMixer) SetMixerSolo(index, strip.Solo.IsChecked == true); }; body.Children.Add(switches);
132
+ strip.Peak = WorkspaceUi.Button("−∞ dBFS", delegate { ClearMixerPeak(strip); }); strip.Peak.FontSize = 10; strip.Peak.FontFamily = new FontFamily("Consolas"); strip.Peak.MinHeight = 22; strip.Peak.Height = 22; strip.Peak.Padding = new Thickness(1); strip.Peak.Margin = new Thickness(0, 2, 0, 0);
133
+ strip.Peak.ToolTip = "Peak hold in dBFS. Red means a level at or above 0 dBFS; click to clear. Float originals retain their headroom."; body.Children.Add(strip.Peak);
134
+ strip.Frame = new Border { Child = body, Width = 100, Padding = new Thickness(5), Margin = new Thickness(0, 0, 4, 0), Background = WorkspaceUi.Color(master ? "#E4ECEA" : "#F3F5F5"), BorderBrush = WorkspaceUi.Color("#D6E1DF"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(3) };
135
+ return strip;
136
+ }
137
+ private void FinishMixerGesture() { mixerUndoKey = null; }
138
+ private bool ChangeMixer(Action change, string key, bool coalesce)
139
+ {
140
+ if (IsBusy) return false;
141
+ if (!coalesce || mixerUndoKey != key || DateTime.UtcNow > mixerUndoUntil) { undo.Push(session.Copy()); redo.Clear(); }
142
+ change(); dirty = true; mixerUndoKey = coalesce ? key : null; mixerUndoUntil = DateTime.UtcNow.AddMilliseconds(500);
143
+ if (mix != null) mix.UpdateMixer(session);
144
+ RefreshMixerControls(); RefreshHeaderMixerControls(); RefreshRegionHighlights();
145
+ undoButton.IsEnabled = true; redoButton.IsEnabled = false; title.Text = session.Name + " *";
146
+ return true;
147
+ }
148
+ private void SetMixerGain(int index, double value)
149
+ {
150
+ value = Math.Round(Math.Max(-60, Math.Min(index < 0 ? 12 : 24, value)), 1, MidpointRounding.AwayFromZero);
151
+ if ((index < 0 ? session.MasterGainDb : session.Tracks[index].GainDb) == value) return;
152
+ ChangeMixer(delegate { if (index < 0) session.MasterGainDb = value; else session.Tracks[index].GainDb = value; }, "gain:" + index, true);
153
+ }
154
+ private void SetMixerPan(int index, double value)
155
+ {
156
+ value = Math.Max(-1, Math.Min(1, value)); if (session.Tracks[index].Pan == value) return;
157
+ ChangeMixer(delegate { session.Tracks[index].Pan = value; }, "pan:" + index, true);
158
+ }
159
+ private void SetMixerMute(int index, bool value)
160
+ { if ((index < 0 ? session.MasterMuted : session.Tracks[index].Mute) != value) ChangeMixer(delegate { if (index < 0) session.MasterMuted = value; else session.Tracks[index].Mute = value; }, "mute:" + index, false); }
161
+ private void SetMixerSolo(int index, bool value)
162
+ { if (session.Tracks[index].Solo != value) ChangeMixer(delegate { session.Tracks[index].Solo = value; }, "solo:" + index, false); }
163
+ private void ResetMixer()
164
+ {
165
+ ChangeMixer(delegate { foreach (var state in session.Tracks) { state.GainDb = state.Pan = 0; state.Mute = state.Solo = false; } session.MasterGainDb = 0; session.MasterMuted = false; }, "reset", false);
166
+ ResetMixerMeters();
167
+ }
168
+ private void RefreshHeaderMixerControls()
169
+ {
170
+ bool previous = updating; updating = true;
171
+ try {
172
+ for (int i = 0; i < 16; i++) {
173
+ if (headerMutes[i] == null) continue; var state = session.Tracks[i];
174
+ headerMutes[i].IsChecked = state.Mute; headerSolos[i].IsChecked = state.Solo;
175
+ var levels = new[] { -60.0, -24, -18, -12, -6, 0, 3, 6, 12, 18, 24, state.GainDb }.Distinct().OrderBy(v => v).ToArray();
176
+ string selected = state.GainDb.ToString("0.#", CultureInfo.InvariantCulture) + " dB";
177
+ if (!Equals(headerLevels[i].SelectedItem, selected)) { headerLevels[i].ItemsSource = levels.Select(v => v.ToString("0.#", CultureInfo.InvariantCulture) + " dB").ToArray(); headerLevels[i].SelectedItem = selected; }
178
+ }
179
+ }
180
+ finally { updating = previous; }
181
+ }
182
+ private void RefreshMixerControls()
183
+ {
184
+ if (masterStrip == null || !MixerVisible) return; updatingMixer = true;
185
+ try {
186
+ var filePeaks = new double[16]; var hasAudio = new bool[16];
187
+ foreach (var region in session.Regions) { Clip clip; if (assets.TryGetValue(region.Source, out clip)) { hasAudio[region.Track] = true; filePeaks[region.Track] = Math.Max(filePeaks[region.Track], clip.Peak); } }
188
+ for (int i = 0; i < 17; i++) {
189
+ bool master = i == 16; var strip = master ? masterStrip : mixerStrips[i]; var state = master ? null : session.Tracks[i];
190
+ strip.Gain.Value = master ? session.MasterGainDb : state.GainDb;
191
+ if (!strip.GainText.IsKeyboardFocusWithin) strip.GainText.Text = strip.Gain.Value.ToString("0.0", CultureInfo.InvariantCulture);
192
+ strip.Mute.IsChecked = master ? session.MasterMuted : state.Mute;
193
+ if (!master) {
194
+ strip.Pan.Value = state.Pan; strip.Solo.IsChecked = state.Solo;
195
+ strip.NameText.Text = (i + 1).ToString("00") + (RecordingTrack(i) ? " IN " : " ") + state.Name; strip.Name.ToolTip = "Select track " + (i + 1) + ": " + state.Name;
196
+ strip.Source.Text = filePeaks[i] > 0 ? "File " + (20 * Math.Log10(filePeaks[i])).ToString("0.0", CultureInfo.InvariantCulture) + " dBFS" : hasAudio[i] ? "File −∞ dBFS" : "File peak —";
197
+ strip.Pan.ToolTip = state.Pan == 0 ? "Pan / balance: centre" : "Pan / balance: " + (state.Pan < 0 ? "L " : "R ") + (Math.Abs(state.Pan) * 100).ToString("0", CultureInfo.InvariantCulture) + "%";
198
+ strip.Source.ToolTip = "Maximum peak in the original source file(s), before mixer gain. " + strip.Source.Text;
199
+ strip.Frame.BorderBrush = WorkspaceUi.Color(RecordingTrack(i) ? "#B52C36" : i == track ? "#006D68" : "#D6E1DF");
200
+ }
201
+ strip.Gain.IsEnabled = strip.GainText.IsEnabled = strip.Mute.IsEnabled = !IsBusy;
202
+ strip.Pan.IsEnabled = strip.Solo.IsEnabled = !master && !IsBusy;
203
+ strip.Name.IsEnabled = master || !IsBusy;
204
+ }
205
+ mixerContext.Text = recording ? "Recording inputs" : "Meters"; mixerMeterMode.Visibility = recording ? Visibility.Collapsed : Visibility.Visible;
206
+ if (MixerVisible && lastMixerSelection != track) { mixerTrackScroll.ScrollToHorizontalOffset(Math.Max(0, track * 104 - 104)); lastMixerSelection = track; }
207
+ }
208
+ finally { updatingMixer = false; }
209
+ }
210
+ private static void ClearMixerPeak(MixerStrip strip) { strip.Over = false; strip.Hold = 0; strip.HoldUntil = DateTime.MinValue; }
211
+ private void ResetMixerMeters()
212
+ {
213
+ foreach (var strip in mixerStrips) if (strip != null) { ClearMixerPeak(strip); strip.Left = strip.Right = 0; strip.Meter.SetLevels(0, 0); }
214
+ if (masterStrip != null) { ClearMixerPeak(masterStrip); masterStrip.Left = masterStrip.Right = 0; masterStrip.Meter.SetLevels(0, 0); }
215
+ if (mix != null) mix.DrainMeters();
216
+ }
217
+ private void TickMixerMeters()
218
+ {
219
+ if (!MixerVisible || !mixerPane.IsExpanded) return;
220
+ var levels = mix == null ? new TimelineMeterLevels() : mix.DrainMeters(); bool source = mixerMeterMode.SelectedIndex == 1;
221
+ for (int i = 0; i < 16; i++) {
222
+ float left = source ? levels.SourceLeft[i] : levels.OutputLeft[i], right = source ? levels.SourceRight[i] : levels.OutputRight[i];
223
+ if (recording && DateTime.UtcNow < inputMeterUntil) { left = recordingMixerLeft[i]; right = recordingMixerRight[i]; }
224
+ UpdateMixerMeter(mixerStrips[i], left, right);
225
+ }
226
+ UpdateMixerMeter(masterStrip, levels.MasterLeft, levels.MasterRight);
227
+ }
228
+ private static void UpdateMixerMeter(MixerStrip strip, float left, float right)
229
+ {
230
+ strip.Left = Math.Max(left, strip.Left * .78f); strip.Right = Math.Max(right, strip.Right * .78f); strip.Meter.SetLevels(strip.Left, strip.Right);
231
+ float peak = Math.Max(left, right); strip.Over |= peak >= 1;
232
+ if (strip.Over) strip.Hold = Math.Max(strip.Hold, peak);
233
+ else if (peak >= strip.Hold || DateTime.UtcNow >= strip.HoldUntil) { strip.Hold = peak; strip.HoldUntil = DateTime.UtcNow.AddSeconds(1.5); }
234
+ strip.Peak.Content = strip.Hold > .000001 ? (20 * Math.Log10(strip.Hold)).ToString("0.0", CultureInfo.InvariantCulture) + " dBFS" : "−∞ dBFS";
235
+ strip.Peak.Foreground = WorkspaceUi.Color(strip.Over ? "#B52C36" : "#526771"); strip.Peak.FontWeight = strip.Over ? FontWeights.Bold : FontWeights.Normal;
236
+ }
237
+ public void UpdateRecordingInputMeters(float[] inputs)
238
+ {
239
+ Array.Clear(recordingMixerPeaks, 0, recordingMixerPeaks.Length);
240
+ Array.Clear(recordingMixerLeft, 0, recordingMixerLeft.Length); Array.Clear(recordingMixerRight, 0, recordingMixerRight.Length);
241
+ if (recordingPlan == null) return;
242
+ int first = recordingPlan.Mode > 0 ? recordingPlan.Mode - 1 : 0;
243
+ for (int i = 0; i < recordingPlan.InputChannels && first + i < inputs.Length; i++) {
244
+ int target = recordingPlan.FirstTrack + (recordingPlan.Mode == -1 ? i : 0);
245
+ recordingMixerPeaks[target] = Math.Max(recordingMixerPeaks[target], inputs[first + i]);
246
+ if (recordingPlan.Mode == 0) { if (i == 0) recordingMixerLeft[target] = inputs[first + i]; else recordingMixerRight[target] = inputs[first + i]; }
247
+ else recordingMixerLeft[target] = recordingMixerRight[target] = inputs[first + i];
248
+ }
249
+ inputMeterUntil = DateTime.UtcNow.AddMilliseconds(350);
250
+ }
251
+ internal DockablePane MixerPaneForTest { get { return mixerPane; } }
252
+ internal void MixerGainForTest(int index, double value) { FinishMixerGesture(); SetMixerGain(index, value); FinishMixerGesture(); }
253
+ internal void MixerPanForTest(int index, double value) { FinishMixerGesture(); SetMixerPan(index, value); FinishMixerGesture(); }
254
+ internal void MixerMuteForTest(int index, bool value) { SetMixerMute(index, value); }
255
+ internal void MixerSoloForTest(int index, bool value) { SetMixerSolo(index, value); }
256
+ internal void MixerMeterForTest(TimelineMeterLevels levels) { for (int i = 0; i < 16; i++) UpdateMixerMeter(mixerStrips[i], levels.OutputLeft[i], levels.OutputRight[i]); UpdateMixerMeter(masterStrip, levels.MasterLeft, levels.MasterRight); }
257
+ internal string MixerSourceForTest(int index) { return mixerStrips[index].Source.Text; }
258
+ internal string MixerPeakForTest(int index) { return (index < 0 ? masterStrip : mixerStrips[index]).Peak.Content.ToString(); }
259
+ internal float RecordingMixerPeakForTest(int index) { return recordingMixerPeaks[index]; }
260
+ internal float RecordingMixerLeftForTest(int index) { return recordingMixerLeft[index]; }
261
+ internal float RecordingMixerRightForTest(int index) { return recordingMixerRight[index]; }
262
+ internal Slider MixerFaderForTest(int index) { return (index < 0 ? masterStrip : mixerStrips[index]).Gain; }
263
+ internal ScrollViewer MixerScrollForTest { get { return mixerTrackScroll; } }
264
+ internal bool MasterControlsVisibleForTest { get { return masterStrip.Peak.TransformToAncestor(this).Transform(new Point(0, masterStrip.Peak.ActualHeight)).Y <= ActualHeight; } }
265
+ internal void ResetMixerForTest() { ResetMixer(); }
266
+ }
267
+ }
@@ -0,0 +1,159 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.IO;
4
+ using System.Linq;
5
+ using System.Text;
6
+ using System.Threading;
7
+ using System.Threading.Tasks;
8
+ using System.Windows;
9
+ using System.Windows.Controls;
10
+ using System.Windows.Controls.Primitives;
11
+ using System.Windows.Media;
12
+ using System.Windows.Media.Imaging;
13
+ using System.Windows.Threading;
14
+ using System.Xml;
15
+
16
+ namespace DrumBuddy
17
+ {
18
+ public static class EditorMixerTests
19
+ {
20
+ private static readonly List<string> log = new List<string>();
21
+ private static void Check(bool value, string message) { if (!value) throw new Exception(message); log.Add("PASS " + message); }
22
+ private static bool Near(float actual, double expected) { return Math.Abs(actual - expected) < .00001; }
23
+ private static Clip Source(string root, string name, float left, float right, bool stereo)
24
+ {
25
+ string file = Path.Combine(root, name + ".wav"); int channels = stereo ? 2 : 1; var samples = new float[Audio.Rate * 8 * channels];
26
+ for (int f = 0; f < Audio.Rate * 8; f++) { samples[f * channels] = left; if (stereo) samples[f * channels + 1] = right; }
27
+ using (var writer = new FloatWaveWriter(file, channels, Audio.Rate)) writer.Write(samples, samples.Length, 1);
28
+ return Audio.Load(file, Path.Combine(root, "cache"), CancellationToken.None, null);
29
+ }
30
+ private static EditorSession State(params Clip[] clips)
31
+ {
32
+ var state = new EditorSession();
33
+ for (int i = 0; i < clips.Length; i++) { state.Tracks[i].Name = Path.GetFileNameWithoutExtension(clips[i].Name); state.Regions.Add(new TimelineRegion { Source = clips[i].Source, Name = state.Tracks[i].Name, Track = i, Length = Audio.Rate * 8L }); }
34
+ return state;
35
+ }
36
+ private static void RunUi(Func<Task> action)
37
+ {
38
+ var previous = SynchronizationContext.Current; var dispatcher = Dispatcher.CurrentDispatcher;
39
+ SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(dispatcher));
40
+ try { var task = action(); var frame = new DispatcherFrame(); task.ContinueWith(delegate { dispatcher.BeginInvoke((Action)delegate { frame.Continue = false; }); }); if (!task.IsCompleted) Dispatcher.PushFrame(frame); task.GetAwaiter().GetResult(); }
41
+ finally { SynchronizationContext.SetSynchronizationContext(previous); }
42
+ }
43
+ private static void Picture(FrameworkElement view, string path, int width, int height)
44
+ {
45
+ view.Measure(new Size(width, height)); view.Arrange(new Rect(0, 0, width, height)); view.UpdateLayout();
46
+ var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); bitmap.Render(view);
47
+ var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var file = File.Create(path)) encoder.Save(file);
48
+ }
49
+ public static int Run(string[] args)
50
+ {
51
+ string root = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(Path.GetTempPath(), "FluxMixer_" + Guid.NewGuid().ToString("N")));
52
+ Directory.CreateDirectory(root); log.Clear(); var old = InterfacePreferences.Current; string oldOutput = AudioOutputs.PreferredKey;
53
+ try {
54
+ InterfacePreferences.Current = InterfacePreferences.Defaults(); ThemePalette.Apply(true); AudioOutputs.PreferredKey = "default";
55
+ var quiet = Source(root, "Quiet kick", .01f, .01f, false); var strong = Source(root, "Snare", .2f, .2f, false); var stereo = Source(root, "Stereo overhead", .25f, -.125f, true);
56
+ var decoded = new Dictionary<string, Clip> { { quiet.Source, quiet }, { strong.Source, strong }, { stereo.Source, stereo } };
57
+ byte[] original = File.ReadAllBytes(quiet.Source); var state = State(quiet, strong);
58
+ using (var mix = new TimelineMix(state, decoded)) {
59
+ var samples = new float[128]; mix.Read(samples, 0, samples.Length); var meters = mix.DrainMeters();
60
+ Check(samples.All(v => Near(v, .21)), "Keep unity track/master gain and centred pan identical to the existing mix");
61
+ Check(Near(meters.SourceLeft[0], .01) && Near(meters.OutputLeft[0], .01) && Near(meters.MasterLeft, .21), "Measure actual pre-fader, post-fader and stereo master levels");
62
+ var empty = mix.DrainMeters(); Check(empty.SourceLeft.All(v => v == 0) && empty.MasterLeft == 0 && mix.Peak > .2, "Drain UI meter peaks without resetting the export protection peak");
63
+ long before = mix.Position; state.Tracks[0].GainDb = 6; state.Tracks[0].Pan = -1; state.Tracks[1].Mute = true; state.MasterGainDb = 3; mix.UpdateMixer(state);
64
+ Check(mix.Position == before, "Apply live mixer controls without seeking or reopening source audio");
65
+ mix.Read(samples, 0, samples.Length); meters = mix.DrainMeters();
66
+ Check(Near(samples[0], .01 * Math.Pow(10, 9 / 20.0)) && samples.Where((v, i) => i % 2 == 1).All(v => v == 0), "Apply live gain, hard-left pan, track mute and master gain on the next audio block");
67
+ Check(Near(meters.SourceLeft[1], .2) && meters.OutputLeft[1] == 0, "Keep pre-fader source metering available on a muted track");
68
+ state.MasterMuted = true; mix.UpdateMixer(state); mix.Read(samples, 0, samples.Length); meters = mix.DrainMeters();
69
+ Check(samples.All(v => v == 0) && meters.MasterLeft == 0 && meters.OutputLeft[0] > 0, "Master mute silences the mix while keeping channel/source diagnostics available");
70
+ }
71
+ state = State(quiet, strong); state.Tracks[0].Solo = true;
72
+ using (var mix = new TimelineMix(state, decoded)) {
73
+ var samples = new float[32]; mix.Read(samples, 0, samples.Length); var meters = mix.DrainMeters();
74
+ Check(samples.All(v => Near(v, .01)) && Near(meters.SourceLeft[1], .2) && meters.OutputLeft[1] == 0, "Solo isolates the chosen track while source meters still reveal the other signal");
75
+ }
76
+ var overlap = State(strong); overlap.Regions.Add(overlap.Regions[0].Copy());
77
+ using (var mix = new TimelineMix(overlap, decoded)) { mix.Read(new float[32], 0, 32); var meters = mix.DrainMeters(); Check(Near(meters.SourceLeft[0], .4) && Near(meters.OutputLeft[0], .4), "Meter the sum of overlapping clips on a track rather than their individual maximum"); }
78
+ var panned = State(stereo);
79
+ using (var mix = new TimelineMix(panned, decoded)) {
80
+ var samples = new float[32]; mix.Read(samples, 0, samples.Length);
81
+ Check(Near(samples[0], .25) && Near(samples[1], -.125), "Centred stereo balance preserves original left/right channel levels");
82
+ panned.Tracks[0].Pan = 1; mix.UpdateMixer(panned); mix.Read(samples, 0, samples.Length);
83
+ Check(samples.Where((v, i) => i % 2 == 0).All(v => v == 0) && Near(samples[1], -.125), "Hard-right stereo balance produces an exactly silent left channel");
84
+ }
85
+ var boosted = State(quiet); boosted.Tracks[0].GainDb = 24;
86
+ using (var mix = new TimelineMix(boosted, decoded)) { var samples = new float[8]; mix.Read(samples, 0, samples.Length); Check(Near(samples[0], .01 * Math.Pow(10, 24 / 20.0)), "Boost a quiet recording by up to 24 dB without rewriting its source"); }
87
+ boosted.MasterGainDb = 6; boosted.Tracks[0].Pan = -.5;
88
+ var clone = boosted.Copy(); clone.MasterGainDb = -6; clone.Tracks[0].Pan = 1;
89
+ Check(boosted.MasterGainDb == 6 && boosted.Tracks[0].Pan == -.5, "Keep master/pan settings independent in undo snapshots");
90
+ string saved = Path.Combine(root, "mixer.dbedit"); boosted.Save(saved); var reopened = EditorSession.Load(saved);
91
+ Check(reopened.MasterGainDb == 6 && reopened.Tracks[0].GainDb == 24 && reopened.Tracks[0].Pan == -.5, "Save and reopen mixer gain, pan and master settings in a session");
92
+ var xml = new XmlDocument(); xml.Load(saved); foreach (XmlNode node in xml.SelectNodes("/EditorSession/MasterGainDb|/EditorSession/MasterMuted|/EditorSession/Tracks/EditorTrack/Pan")) node.ParentNode.RemoveChild(node);
93
+ foreach (XmlNode node in xml.SelectNodes("/EditorSession/Tracks/EditorTrack/GainDb")) node.InnerText = "0";
94
+ string legacyPath = Path.Combine(root, "legacy.dbedit"); xml.Save(legacyPath); var legacy = EditorSession.Load(legacyPath);
95
+ Check(legacy.MasterGainDb == 0 && !legacy.MasterMuted && legacy.Tracks.All(t => t.Pan == 0), "Open older sessions with unity master gain and centred pan");
96
+ var bad = state.Copy(); bad.Tracks[0].Pan = double.NaN; bool rejected = false; try { bad.Validate(); } catch (IOException) { rejected = true; }
97
+ Check(rejected, "Reject non-finite pan positions before rendering or saving");
98
+ bad = state.Copy(); bad.MasterGainDb = 13; rejected = false; try { bad.Validate(); } catch (IOException) { rejected = true; }
99
+ Check(rejected, "Reject unsupported master gain");
100
+ var exportState = State(quiet); exportState.Tracks[0].GainDb = 6; exportState.MasterGainDb = 6; exportState.Tracks[0].Pan = -1;
101
+ string rendered = Path.Combine(root, "mixer-export.wav"); TimelineRender.Export(exportState, decoded, rendered, 32, false, 0, Audio.Rate, CancellationToken.None, null);
102
+ var renderedClip = Audio.Load(rendered, Path.Combine(root, "export-cache"), CancellationToken.None, null);
103
+ Check(Math.Abs(renderedClip.Peak - .01 * Math.Pow(10, 12 / 20.0)) < .00001, "Render track and master levels into float mix exports");
104
+ exportState = State(strong); exportState.Tracks[0].GainDb = 24; exportState.MasterGainDb = 12;
105
+ rendered = Path.Combine(root, "protected-mixer-export.wav"); TimelineRender.Export(exportState, decoded, rendered, 24, true, 0, Audio.Rate, CancellationToken.None, null);
106
+ Check(Math.Abs(Audio.Load(rendered, Path.Combine(root, "export-cache"), CancellationToken.None, null).PeakDb + 1) < .02, "Keep PCM clipping protection effective after track and master amplification");
107
+
108
+ var main = new MainWindow(Path.Combine(root, "preferences.xml"), true); main.OpenEditorForTest(); var editor = main.RecordingEditorForTest;
109
+ main.EditorFixtureForTest(State(quiet, strong), decoded); editor.ShowMixer(true);
110
+ Picture((FrameworkElement)main.NativeWindow.Content, Path.Combine(root, "mixer-initial-dark.png"), 1280, 800);
111
+ Check(editor.MixerVisible && main.NativeWindow.FindName("ViewMixer") != null, "Expose the integrated mixer from the editor and View menu");
112
+ Check(editor.MasterControlsVisibleForTest && editor.MixerScrollForTest.ComputedHorizontalScrollBarVisibility == Visibility.Visible, "Keep the master controls and track-scroll bar visible at the default mixer size");
113
+ editor.MixerScrollForTest.ScrollToRightEnd(); ((FrameworkElement)main.NativeWindow.Content).UpdateLayout();
114
+ Check(editor.MixerScrollForTest.HorizontalOffset > 0 && editor.MasterControlsVisibleForTest, "Reach the remaining tracks while keeping the master strip pinned in view");
115
+ editor.MixerScrollForTest.ScrollToLeftEnd(); ((FrameworkElement)main.NativeWindow.Content).UpdateLayout();
116
+ Check(editor.MixerSourceForTest(0).Contains("-40.0"), "Display the quiet recording's actual file peak independently of mixer gain");
117
+ var fader = editor.MixerFaderForTest(0); fader.ApplyTemplate(); var track = (Track)fader.Template.FindName("PART_Track", fader);
118
+ Check(track.Orientation == Orientation.Vertical && fader.ActualHeight >= 90 && track.ActualHeight >= 85, "Use a real vertical fader with its full travel in the dark theme");
119
+ double thumbBefore = track.Thumb.TransformToAncestor(fader).Transform(new Point()).Y;
120
+ fader.Value = 6; fader.Value = 7; main.NativeWindow.Content.AsFramework().UpdateLayout();
121
+ Check(editor.SessionForTest.Tracks[0].GainDb == 7 && track.Thumb.TransformToAncestor(fader).Transform(new Point()).Y < thumbBefore, "Moving the visible fader upward changes the real track gain");
122
+ RunUi(() => editor.ExecuteAction("undo")); Check(editor.SessionForTest.Tracks[0].GainDb == 0, "Coalesce one fader gesture into a single undo operation");
123
+ RunUi(() => editor.ExecuteAction("redo")); Check(editor.SessionForTest.Tracks[0].GainDb == 7, "Redo a mixer change");
124
+ editor.MixerPanForTest(0, -.5); editor.MixerGainForTest(-1, 3);
125
+ Check(editor.SessionForTest.Tracks[0].Pan == -.5 && editor.SessionForTest.MasterGainDb == 3, "Wire visible pan and master controls to the session model");
126
+ editor.ListenVolumeForTest(0); editor.PlayheadForTest(Audio.Rate);
127
+ RunUi(() => editor.ExecuteAction("play")); long playingAt = editor.PlayheadFrame;
128
+ editor.MixerGainForTest(0, 12); editor.MixerMuteForTest(1, true); editor.MixerSoloForTest(0, true);
129
+ RunUi(async delegate { await Task.Delay(150); });
130
+ Check(editor.PlayingForTest && editor.PlayheadFrame >= playingAt, "Adjust track gain, mute and solo while native playback continues at muted listening volume");
131
+ Check(!editor.MixerPeakForTest(-1).Contains("∞"), "Keep mix meters active even when the monitor listening slider is zero");
132
+ editor.Stop(); editor.ResetMixerForTest();
133
+ var plan = new RecordingPlan(new InputChoice { Name = "Test microphone" }, 2, 24, 48000, 2, 5, 0, new TempoGridOptions());
134
+ editor.BeginRecordingCountdown(plan); editor.UpdateRecordingInputMeters(new[] { .1f, .2f }); editor.MixerGainForTest(5, 24);
135
+ Check(editor.RecordingMixerPeakForTest(5) == .2f && editor.RecordingMixerPeakForTest(0) == 0 && editor.SessionForTest.Tracks[5].GainDb == 0, "Route the selected recording input to its registered mixer strip and lock playback-only faders during capture");
136
+ editor.SetRecordingState(false); plan = new RecordingPlan(new InputChoice { Name = "Stereo microphone" }, 0, 24, 48000, 2, 5, 0, new TempoGridOptions());
137
+ editor.BeginRecordingCountdown(plan); editor.UpdateRecordingInputMeters(new[] { .1f, .3f });
138
+ Check(editor.RecordingMixerLeftForTest(5) == .1f && editor.RecordingMixerRightForTest(5) == .3f, "Keep stereo recording input meters separate on a stereo destination track");
139
+ editor.SetRecordingState(false); editor.ShowRecordingOptions(false);
140
+ var display = State(quiet, strong); display.Tracks[0].Name = "Quiet kick"; display.Tracks[1].Name = "Snare";
141
+ for (int i = 2; i < 16; i++) { display.Tracks[i].Name = "Drum input " + (i + 1); display.Regions.Add(new TimelineRegion { Source = i % 2 == 0 ? quiet.Source : strong.Source, Name = display.Tracks[i].Name, Track = i, Length = Audio.Rate * 8L }); }
142
+ display.Tracks[0].GainDb = 18; display.Tracks[1].GainDb = 6; main.EditorFixtureForTest(display, decoded); editor.ShowMixer(true);
143
+ using (var mix = new TimelineMix(display, decoded)) { mix.Read(new float[512], 0, 512); editor.MixerMeterForTest(mix.DrainMeters()); }
144
+ Picture((FrameworkElement)main.NativeWindow.Content, Path.Combine(root, "mixer-dark.png"), 1280, 800);
145
+ Check(editor.MixerPeakForTest(-1).Contains("dBFS") && editor.SessionForTest.MasterGainDb == 0, "Display actual stereo master peaks while keeping the separate monitor level out of the saved mix");
146
+ main.SetThemeForTest(false); Picture((FrameworkElement)main.NativeWindow.Content, Path.Combine(root, "mixer-light.png"), 1280, 800);
147
+ main.SetThemeForTest(true); Picture((FrameworkElement)main.NativeWindow.Content, Path.Combine(root, "mixer-compact-dark.png"), 850, 560);
148
+ editor.MixerPaneForTest.SetExpanded(false); Picture((FrameworkElement)main.NativeWindow.Content, Path.Combine(root, "mixer-collapsed-dark.png"), 850, 560);
149
+ Check(!editor.MixerPaneForTest.IsExpanded && editor.TimelineHeightForTest > 180, "Collapse the mixer to restore timeline space on a compact window");
150
+ editor.ShowMixer(false); main.EditorFixtureForTest(new EditorSession(), new Dictionary<string, Clip>()); main.NativeWindow.Close();
151
+ Check(original.SequenceEqual(File.ReadAllBytes(quiet.Source)), "Keep the original quiet recording byte-for-byte unchanged through mixing, playback and exports");
152
+ File.WriteAllLines(Path.Combine(root, "mixer-test-results.txt"), log, Encoding.UTF8); return 0;
153
+ }
154
+ catch (Exception ex) { log.Add("FAIL " + ex); File.WriteAllLines(Path.Combine(root, "mixer-test-results.txt"), log, Encoding.UTF8); return 1; }
155
+ finally { InterfacePreferences.Current = old; AudioOutputs.PreferredKey = oldOutput; ThemePalette.Apply(old.Theme == "Dark"); }
156
+ }
157
+ private static FrameworkElement AsFramework(this object value) { return (FrameworkElement)value; }
158
+ }
159
+ }
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
7
7
  using System.Collections.Generic;
8
8
  using System.Diagnostics;
9
9
  using System.Windows;
10
+ using System.Windows.Input;
10
11
  using System.Windows.Threading;
11
12
  using NAudio.Wave;
12
13
 
@@ -74,6 +75,27 @@ namespace DrumBuddy
74
75
  owner.Content = null; owner.Close();
75
76
  Check(original.SequenceEqual(File.ReadAllBytes(file)), "Preserve the original " + kind + " file through import and playback");
76
77
  }
78
+ string navigationFile = Path.Combine(root, "bar-navigation.wav");
79
+ using (var writer = new FloatWaveWriter(navigationFile, 1, Audio.Rate)) writer.Write(new float[Audio.Rate * 12], Audio.Rate * 12, 1);
80
+ var navigationOwner = new Window(); ThemePalette.Install(navigationOwner);
81
+ using (var editor = new EditorView(navigationOwner, Path.Combine(root, "navigation-cache"), delegate(string text) { }, delegate { })) {
82
+ navigationOwner.Content = editor; editor.ListenVolumeForTest(0);
83
+ RunUi(() => editor.AddFiles(new[] { navigationFile })); editor.PlayheadForTest(Audio.Rate * 5L);
84
+ RunUi(() => editor.ExecuteAction("play"));
85
+ RunUi(() => editor.ExecuteAction(InterfacePreferences.Resolve(Key.Left, ModifierKeys.Control)));
86
+ Check(editor.PlayingForTest && Math.Abs(editor.PlayheadFrame - Audio.Rate * 4L) < Audio.Rate / 2, "Ctrl+Left seeks to the preceding bar while playback continues");
87
+ RunUi(() => editor.ExecuteAction(InterfacePreferences.Resolve(Key.Right, ModifierKeys.Control)));
88
+ Check(editor.PlayingForTest && Math.Abs(editor.PlayheadFrame - Audio.Rate * 6L) < Audio.Rate / 2, "Ctrl+Right seeks to the following bar while playback continues");
89
+ editor.Stop(); editor.PlayheadForTest(Audio.Rate * 5L);
90
+ RunUi(() => editor.ExecuteAction("play")); RunUi(() => editor.ExecuteAction("play"));
91
+ RunUi(() => editor.ExecuteAction("previousBar"));
92
+ Check(!editor.PlayingForTest && editor.PlayheadFrame == Audio.Rate * 4L, "A bar jump from paused playback stays silent at the new position");
93
+ RunUi(() => editor.ExecuteAction("play"));
94
+ Check(editor.PlayingForTest && Math.Abs(editor.PlayheadFrame - Audio.Rate * 4L) < Audio.Rate / 2, "Resume after a paused bar jump starts at the new bar");
95
+ RunUi(() => editor.ExecuteAction(InterfacePreferences.Resolve(Key.Enter, ModifierKeys.None)));
96
+ Check(!editor.PlayingForTest && editor.PlayheadFrame == 0, "Enter returns to the very beginning with the same transport behavior as Home");
97
+ }
98
+ navigationOwner.Content = null; navigationOwner.Close();
77
99
  var picker = new AudioOutputPicker(delegate { }, delegate { });
78
100
  AudioOutputs.PreferredKey = "windows:missing-output-for-test"; picker.RefreshForTest();
79
101
  Check(picker.SelectionForTest == "default" && AudioOutputs.PreferredKey == "default", "Keep the displayed default and actual saved routing selection consistent after a device disappears");