@pro-gram/flux 1.6.1 → 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.
@@ -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");
@@ -0,0 +1,106 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Linq;
4
+ using System.Windows;
5
+ using System.Windows.Controls;
6
+ using System.Windows.Media;
7
+ using System.Windows.Shapes;
8
+
9
+ namespace DrumBuddy
10
+ {
11
+ public sealed partial class EditorView
12
+ {
13
+ private Border recordingBanner;
14
+ private TextBlock countdownText, recordingHeadline, recordingRouting;
15
+ private RecordingPlan recordingPlan;
16
+ private bool captureRunning;
17
+ private readonly List<Rectangle> recordingOverlays = new List<Rectangle>();
18
+
19
+ private void InitializeRecordingFeedback(Panel tools)
20
+ {
21
+ var body = new DockPanel();
22
+ countdownText = WorkspaceUi.Text("", 30); countdownText.FontWeight = FontWeights.Bold;
23
+ countdownText.Foreground = WorkspaceUi.Color("#B52C36"); countdownText.MinWidth = 72;
24
+ countdownText.Margin = new Thickness(0, 0, 12, 0); countdownText.VerticalAlignment = VerticalAlignment.Center;
25
+ DockPanel.SetDock(countdownText, Dock.Left); body.Children.Add(countdownText);
26
+ var details = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; body.Children.Add(details);
27
+ recordingHeadline = WorkspaceUi.Text("", 15); recordingHeadline.FontWeight = FontWeights.SemiBold; details.Children.Add(recordingHeadline);
28
+ recordingRouting = WorkspaceUi.Text("", 12); recordingRouting.Margin = new Thickness(0, 3, 0, 0); details.Children.Add(recordingRouting);
29
+ recordingBanner = new Border { Child = body, BorderBrush = WorkspaceUi.Color("#B52C36"), BorderThickness = new Thickness(2),
30
+ Background = WorkspaceUi.Color("#F3F5F5"), Padding = new Thickness(10, 6, 10, 6), Margin = new Thickness(0, 0, 0, 6), Visibility = Visibility.Collapsed };
31
+ tools.Children.Add(recordingBanner);
32
+ }
33
+ public RecordingPlan PlanRecording(InputChoice input, int mode, int bits, int rate, int availableInputs)
34
+ {
35
+ if (!CommitTempo()) throw new System.IO.IOException("Enter a valid project tempo before recording.");
36
+ return new RecordingPlan(input, mode, bits, rate, availableInputs, track, playhead, session.Grid);
37
+ }
38
+ public void BeginRecordingCountdown(RecordingPlan plan)
39
+ {
40
+ recordingPlan = plan; captureRunning = false; playhead = plan.AnchorFrame;
41
+ SetRecordingState(true); recordButton.Content = "Cancel count-in";
42
+ recordingBanner.Visibility = Visibility.Visible; countdownText.Text = "...";
43
+ recordingHeadline.Text = "Preparing recording input"; recordingRouting.Text = plan.Routing;
44
+ BuildHeaders(); DrawCanvas(); EnsurePlayheadVisible();
45
+ double top = RulerHeight + plan.FirstTrack * RowHeight, bottom = top + plan.TrackCount * RowHeight;
46
+ if (scroll.ViewportHeight > 0) {
47
+ if (top < scroll.VerticalOffset) scroll.ScrollToVerticalOffset(Math.Max(0, top - RowHeight));
48
+ else if (bottom > scroll.VerticalOffset + scroll.ViewportHeight) scroll.ScrollToVerticalOffset(Math.Max(0, bottom - scroll.ViewportHeight));
49
+ }
50
+ }
51
+ public void ShowCountdownBeat(int remaining)
52
+ {
53
+ countdownText.Text = remaining.ToString();
54
+ recordingHeadline.Text = "Recording in " + remaining + (remaining == 1 ? " beat" : " beats");
55
+ }
56
+ public void BeginRecordingCapture()
57
+ {
58
+ captureRunning = true; recordButton.Content = "Stop recording"; countdownText.Text = "REC";
59
+ recordingHeadline.Text = "Recording | 00:00.00"; BuildHeaders(); DrawCanvas();
60
+ }
61
+ public void UpdateRecordingProgress(long capturedFrames, int sampleRate)
62
+ {
63
+ if (!recording || recordingPlan == null) return;
64
+ playhead = recordingPlan.TimelineFrame(capturedFrames, sampleRate);
65
+ timeline.Width = Math.Max(timeline.Width, X(Math.Min(EditorSession.MaxFrame, playhead + Audio.Rate * 15L)));
66
+ recordingHeadline.Text = "Recording | " + WorkspaceUi.Clock(capturedFrames / (double)sampleRate);
67
+ UpdateRecordingOverlays(); UpdatePlayLine(); UpdateTransport();
68
+ if (DateTime.UtcNow >= manualViewUntil) EnsurePlayheadVisible();
69
+ }
70
+ public void ShowRecordingSaving()
71
+ { recordButton.Content = "Saving..."; recordButton.IsEnabled = false; countdownText.Text = "..."; recordingHeadline.Text = "Saving recording"; }
72
+ public void ShowRecordingError(string message)
73
+ {
74
+ recordingBanner.Visibility = Visibility.Visible; countdownText.Text = "!";
75
+ recordingHeadline.Text = "Could not record"; recordingRouting.Text = message;
76
+ }
77
+ private bool RecordingTrack(int index)
78
+ { return recording && recordingPlan != null && index >= recordingPlan.FirstTrack && index < recordingPlan.FirstTrack + recordingPlan.TrackCount; }
79
+ private void DrawRecordingOverlays()
80
+ {
81
+ recordingOverlays.Clear(); if (recordingPlan == null || !recording) return;
82
+ for (int i = 0; i < recordingPlan.TrackCount; i++) {
83
+ var rectangle = new Rectangle { Fill = WorkspaceUi.Color("#B52C36"), Opacity = .24, Stroke = WorkspaceUi.Color("#B52C36"),
84
+ StrokeThickness = 2, Height = RowHeight - 6, IsHitTestVisible = false, Tag = "RecordingPreview" };
85
+ Canvas.SetTop(rectangle, RulerHeight + (recordingPlan.FirstTrack + i) * RowHeight + 3);
86
+ timeline.Children.Add(rectangle); recordingOverlays.Add(rectangle);
87
+ }
88
+ UpdateRecordingOverlays();
89
+ }
90
+ private void UpdateRecordingOverlays()
91
+ {
92
+ if (recordingPlan == null) return;
93
+ double left = Math.Max(scroll.HorizontalOffset, X(recordingPlan.AnchorFrame));
94
+ double right = Math.Min(scroll.HorizontalOffset + Math.Max(900, scroll.ViewportWidth), X(playhead));
95
+ foreach (var rectangle in recordingOverlays) {
96
+ Canvas.SetLeft(rectangle, left); rectangle.Width = Math.Max(2, right - left);
97
+ rectangle.Visibility = right >= left ? Visibility.Visible : Visibility.Collapsed;
98
+ }
99
+ }
100
+ internal string CountdownForTest { get { return countdownText.Text; } }
101
+ internal string RecordingHeadlineForTest { get { return recordingHeadline.Text; } }
102
+ internal string RoutingForTest { get { return recordingRouting.Text; } }
103
+ internal int[] ArmedTracksForTest { get { return Enumerable.Range(0, 16).Where(RecordingTrack).ToArray(); } }
104
+ internal int RecordingOverlaysForTest { get { return recordingOverlays.Count; } }
105
+ }
106
+ }