Phase 36: add studio mixer channel strips and refresh screenshots

This commit is contained in:
Johannes Findeisen 2026-10-28 11:23:25 +01:00
commit 2d5bcb6401
22 changed files with 667 additions and 142 deletions

View file

@ -1,10 +1,53 @@
# Mixer, automation and undo
# Studio mixer
Phase 8 provides one selectable channel strip per project track. Open Mixer
(Ctrl+2), select a track, and set Gain, Pan, Mute and Solo. Changes stop transport;
Play prepares independent audio/MIDI snapshots with the accepted settings.
No edit changes a plan that the callback is using. Editing is blocked while
recording. Graph edits retain their existing live publication behavior.
Open **View / Mixer** (Ctrl+2) to see a channel strip for every track and bus.
The horizontal scrollbar reveals additional channels; the master output monitor
stays pinned on the right. The bank supports the full 64-track/bus project limit.
Channel order follows the sequencer, and colors match the track headers.
Each strip offers:
- A name button that selects its track for automation below the bank.
- **Mute** and **Solo** switches using the existing inclusive track/bus solo rules.
- A flat **Pan** knob: left -1, center 0, right +1. Drag to turn it;
double-click to center. The text shows direction and percentage.
- An output dropdown for **Master** or a project bus. Bus outputs stay at Master;
nested bus routing remains unsupported. **Add bus** creates a new bus strip.
- A vertical level fader, displayed in dB. Zero dB is unity gain. The upper limit
is approximately +12.04 dB (linear gain 4); the -60 dB bottom position is silence.
- Independent stereo sample-peak meters, numeric dBFS readouts and sticky clip
indications. **Reset peaks** clears all strip and master clip latches.
Changing a fader or knob commits on release, so a completed gesture is one undo
step. The existing preparation guard stops playback before model edits and
rejects changes while recording. Failed edits restore the displayed model values.
Selections are UI state and create no undo step. Track IDs keep controls attached
to the correct channel after reordering, undo, deletion or project replacement.
The top Track selector provides another way to select automation targets; the
bank scrolls to reveal the selection. M/S toggle the selected track's mute/solo,
Left/Right adjust its linear gain, Shift+Left/Right adjust pan, and Up/Down select
adjacent tracks. The existing automation controls below the bank retain Gain/Pan,
First tick and Linear/Hold interpolation. Click to add a point and right-click
near a point to remove it.
Faders and knobs display **static** mix values. Automation replaces those values
during playback; the controls are not motorized automation displays. Automation
values retain their existing linear gain and pan units. Existing project files
store mix values and automation without a format change. Very small nonzero
gains display at the fader floor; pan/mute/solo edits preserve their actual value.
Moving the fader to its bottom explicitly sets silence.
Meters use the backend's consumed peak snapshot for every track and bus,
including offscreen channels. Track taps are after track controls and before the
bus, bus taps follow bus controls, and the master follows the modular graph and
transport envelope. Missing taps decay to silence. Displays use instant attack,
20 dB/s release and sticky clipping at a linear sample peak of 1 or more.
Numeric displays have a -90 dBFS floor, channel bars a -60 dBFS floor; master
bars cover -90 to +6 dBFS. These are sample-peak meters, not loudness or true-peak
meters. The master strip is an output monitor, not a new gain control.
## Engine mixing behavior
Gain is a linear multiplier from 0 to 4. Pan ranges from -1 (left) to +1 (right):
center has unity on both sides and moving away attenuates the opposite side
@ -62,11 +105,11 @@ The backend retains interval sample-peak maxima until one serialized control
reader consumes them with `xtc_backend_read_meters`. The GUI polls about every
100 ms. Atomic channels are independent (not a simultaneous waveform snapshot);
peaks are quantized to 0.000001 linear and capped at 1000. Legacy backend status
peaks remain last-block linear values. GUI bars display 20*log10(peak) dBFS, floor
-90, instantaneous attack and 20 dB/second release, with a +6 display ceiling.
peaks remain last-block linear values. GUI readouts display 20*log10(peak) dBFS, floor -90, with instantaneous
attack and 20 dB/second release. Channel bars cover -60 to +6 dBFS; master
bars cover -90 to +6 dBFS.
Each channel latches red CLIP at linear peak >=1 (0 dBFS) until Reset peaks.
Reset is session state, not an audio edit. Selection changes reset selected-strip
history. These are sample-peak meters, not RMS, LUFS or true-peak measurements.
Reset is session state, not an audio edit. Selection changes preserve every strip's meter history. These are sample-peak meters, not RMS, LUFS or true-peak measurements.
There is no limiter; summed levels can exceed unity.
## Mix buses and gain staging (Phase 24)
@ -134,6 +177,11 @@ events, use mixer keys and exercise undo/redo, failed installation rollback,
branching, save/reopen and history reset. Live JACK MIDI acceptance verifies
saved/reopened hard-right playback and stereo peaks using only software clients.
A native Xvfb mouse smoke check covers sliders and automation drawing.
Phase 36 adds checks for independent strip callbacks/meters, fader gestures, pan
centering, failed-edit restoration, routing, undo/reload, duplicate names and
stable IDs across reorder/removal. A 64-channel GUI session verifies scrolling
and minimum/large layouts. Screenshots include live JACK meters and a bus strip.
Physical stereo devices, listening quality, realtime endurance and high-density
project performance remain unverified; no new dependency was introduced.

View file

@ -79,3 +79,9 @@ Phase 35 adds Edit / Preferences with an Apply button and `~/.xtc.conf` INI
persistence, configurable plugin search paths and a file library, plus sequencer
ruler loop selection and sample-boundary playback wrapping. See PREFERENCES.md
for settings, reconnect behavior and looping limits.
Phase 36 replaces the single-track mixer panel with a scrolling studio mixer:
vertical dB faders, flat pan knobs, mute/solo, routing and stereo meters on every
track and bus, plus a pinned master monitor. Selected-track automation remains
below the strips. No project format or engine changes. Screenshots are refreshed;
see MIXER.md and screenshots/README.md.

View file

@ -1013,6 +1013,31 @@ See [PREFERENCES.md](PREFERENCES.md) for configuration, workflow and limitations
---
## Phase 36 — Studio Mixer Overview
### Goal
Show every track and bus as a studio-style channel strip with direct level,
pan, routing and metering controls in the flat workspace.
### Tasks and acceptance criteria
- [x] Replace the single-track control panel with a horizontally scrolling bank of all tracks and buses.
- [x] Provide vertical dB faders, flat pan knobs, mute/solo switches and output routing per strip.
- [x] Show simultaneous stereo track/bus meters and keep the master output monitor visible while scrolling.
- [x] Retain selected-track gain/pan automation, interpolation, keyboard control and Add bus.
- [x] Preserve stable track identity, undo/redo, persistence and failure rollback across strip edits.
- [x] Verify empty/full sessions, reorder/remove, minimum/large layouts and independent meters.
- [x] Refresh the view screenshots and document mixer controls and metering limits.
- [x] Build and run GUI/core, live JACK, sanitizer and release checks.
### Implementation status
Implemented in the C++ FLTK GUI using the existing C mixer and backend snapshot
APIs. No audio callback, project format or dependency changes. The fixed master
strip monitors graph output; it does not introduce a separate master gain stage.
Faders edit static gain and do not chase automation. See [MIXER.md](MIXER.md)
and the updated [screenshots](../screenshots/README.md).
---
## Recommended Execution Order
Reorder the phases when the audit reveals a blocking dependency. Do not mark a phase complete merely because code exists: its acceptance criteria must be demonstrated by builds, tests, or documented manual verification.

View file

@ -99,3 +99,12 @@ Verification includes off-grid move/trim, snap Off, dropdown labels, double-clic
coincident notes, per-note slider/lane edits, invalid velocities, undo/reload and
instrument reuse/clear/reorder for all three fixture formats. Manual checks use
the original modern-session example and the flat sequencer/piano windows.
## Phase 36 studio mixer
The Mixer tab now presents all tracks and buses as channel strips with vertical
dB faders, flat pan knobs, mute/solo, output routing and stereo meters. Scroll
horizontally for larger projects; the master output monitor stays visible.
Select a channel name to edit its gain/pan automation below the bank. Existing
undo, keyboard controls and project persistence remain available. See
[MIXER.md](MIXER.md) for fader units, metering, automation and limits.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View file

@ -1,16 +1,18 @@
# xtc screenshots
Captured from the Phase 35 FLTK application on 2026-10-28, using a virtual X11
Captured from the Phase 36 FLTK application on 2026-10-28, using a virtual X11
screen. Images show actual rendered widgets, without mockups or image edits.
[View the contact sheet](overview.png).
The arrangement uses `examples/projects/modern-session.xtc`. The audio view adds
a temporary generated waveform. Mixer automation and loop selections are staged
for these captures. Plugin views use the repository's LADSPA, CLAP, VST2 and
VST3 test fixtures; these are demonstration plugins, not commercial instruments.
Preferences show application defaults and the staged loop. No user configuration
or example project was overwritten.
The arrangement uses `examples/projects/modern-session.xtc`. Mixer captures add
a bus, routing, pan/level settings and automation. The playing view shows actual
JACK callback meters; additional captures show the minimum 1220 × 720 layout and
a larger session scrolled to its final channels, with the master monitor pinned.
The audio view adds a temporary generated waveform. Plugin views use the
repository's LADSPA, CLAP, VST2 and VST3 test fixtures; these are demonstration
plugins, not commercial instruments. Preferences show application defaults and
the staged loop. No user configuration or example project was overwritten.
- [Sequencer loop](01-sequencer-loop.png)
- [Mixer automation](02-mixer-automation.png)
@ -33,3 +35,6 @@ or example project was overwritten.
- [Clap instrument descriptors](19-clap-instrument-descriptors.png)
- [Vst2 instrument descriptors](20-vst2-instrument-descriptors.png)
- [Vst3 instrument descriptors](21-vst3-instrument-descriptors.png)
- [Mixer playing](22-mixer-playing.png)
- [Mixer minimum size](23-mixer-minimum-size.png)
- [Mixer scrolled session](24-mixer-scrolled-session.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Before After
Before After

View file

@ -652,7 +652,7 @@ void MainWindow::refresh_status()
{
refresh_workspace();
update_history_controls();
if (mixer_ && !ready_) { mixer_->peaks(0, 0); mixer_->track_peaks(0, 0); }
if (mixer_ && !ready_) mixer_->meters(xtc_meter_snapshot{});
bool can_play = false, can_record = false;
if (!ready_) {
set_label(indicator_, "Audio: disconnected");
@ -689,14 +689,8 @@ void MainWindow::refresh_status()
: running_ ? fl_rgb_color(125, 225, 160)
: fl_rgb_color(215, 215, 215));
xtc_meter_snapshot meters{};
float track_left = 0, track_right = 0;
if (xtc_backend_read_meters(backend_, &meters) == XTC_OK) {
mixer_->peaks(meters.master_left, meters.master_right);
for (size_t i = 0; i < meters.count; ++i) if (meters.tracks[i].id == mixer_->selected_track()) {
track_left = meters.tracks[i].left; track_right = meters.tracks[i].right; break;
}
} else mixer_->peaks(status.peak_left, status.peak_right);
mixer_->track_peaks(track_left, track_right);
if (xtc_backend_read_meters(backend_, &meters) == XTC_OK) mixer_->meters(meters);
else { meters.master_left=status.peak_left;meters.master_right=status.peak_right;mixer_->meters(meters); }
char text[256];
std::snprintf(
text, sizeof(text),

View file

@ -10,6 +10,8 @@
#include <FL/Fl_Value_Input.H>
#include <FL/Fl_Input_.H>
#include <FL/Fl_Value_Slider.H>
#include <FL/Fl_Dial.H>
#include <FL/Fl_Scroll.H>
#include <FL/fl_ask.H>
#include <FL/fl_draw.H>
#include <algorithm>
@ -162,10 +164,289 @@ class AutomationLane : public Fl_Widget {
}
};
/* Fl_Menu_::add merges duplicate labels and interprets menu paths. Append a
* unique temporary entry, then replace its text literally so IDs stay dense. */
static int literal_item(Fl_Choice *choice, const char *label)
{
std::string key="#";
while (choice->find_item(key.c_str())) key+="#";
int index=choice->add(key.c_str());
choice->replace(index,label);
return index;
}
class VerticalPeak : public Fl_Progress
{
public:
VerticalPeak(int x, int y, int w, int h, const char *label) : Fl_Progress(x, y, w, h, label)
{
labeltype(FL_NO_LABEL);
}
void draw() override
{
fl_color(xtc_ui::background());
fl_rectf(x(), y(), w(), h());
int height = static_cast<int>((std::clamp(value(), -90.0f, 6.0f) + 90) / 96 * (h() - 2));
fl_color(selection_color());
fl_rectf(x() + 1, y() + h() - 1 - height, w() - 2, height);
fl_color(xtc_ui::border());
fl_rect(x(), y(), w(), h());
}
};
/* A flat pan dial: no embossed face, bevels or simulated lighting. */
class PanKnob : public Fl_Dial
{
bool centering_ = false;
public:
PanKnob(int x, int y, int w, int h) : Fl_Dial(x, y, w, h, "Pan")
{
bounds(-1, 1);
step(.01);
type(FL_LINE_DIAL);
when(FL_WHEN_RELEASE);
tooltip("Pan: -1 left, 0 center, +1 right. Double-click to center.");
}
void draw() override
{
fl_color(xtc_ui::background());
fl_rectf(x(), y(), w(), h());
int size = std::min(w(), h()) - 8, cx = x() + w() / 2, cy = y() + h() / 2;
fl_color(xtc_ui::border());
fl_pie(cx - size / 2, cy - size / 2, size, size, 0, 360);
fl_color(active_r() ? xtc_ui::accent() : xtc_ui::muted());
double angle = (value() * 140 - 90) * 3.141592653589793 / 180;
fl_line_style(FL_SOLID, 2);
fl_line(cx, cy, cx + static_cast<int>(std::cos(angle) * (size / 2 - 4)),
cy + static_cast<int>(std::sin(angle) * (size / 2 - 4)));
fl_line_style(0);
if (Fl::focus() == this) {
fl_color(xtc_ui::text());
fl_rect(x(), y(), w(), h());
}
}
int handle(int event) override
{
if (event == FL_PUSH && Fl::event_button() == FL_LEFT_MOUSE && Fl::event_clicks()) {
centering_ = true;
value(0);
redraw();
do_callback();
return 1;
}
if (centering_ && (event == FL_DRAG || event == FL_RELEASE)) {
if (event == FL_RELEASE)
centering_ = false;
return 1;
}
return Fl_Dial::handle(event);
}
};
/* FLTK owns these children. Identity is a model ID, never a dense track index. */
class MixerStrip : public Fl_Group
{
MixerWidget &owner_;
Fl_Button *name_;
Fl_Check_Button *mute_, *solo_;
PanKnob *pan_;
Fl_Value_Slider *gain_;
Fl_Choice *route_;
Fl_Box *pan_text_, *readout_;
std::vector<uint64_t> destinations_;
static void control(Fl_Widget *widget, void *context)
{
auto *s = static_cast<MixerStrip *>(context);
// Copy values before the owner refreshes dependent widgets.
const auto id = s->id;
xtc_track_info track{};
size_t count = 0, clips = 0;
xtc_project_get_counts(s->owner_.project(), &count, &clips);
bool found = false;
for (size_t i = 0; i < count; ++i) {
xtc_project_get_track(s->owner_.project(), i, &track);
if (track.id == id) {
found = true;
break;
}
}
if (!found)
return;
const float gain =
widget == s->gain_
? (s->gain_->value() <= -60 ? 0.0f
: std::min(4.0f, static_cast<float>(std::pow(10, s->gain_->value() / 20))))
: track.gain;
const float pan = widget == s->pan_ ? static_cast<float>(s->pan_->value()) : track.pan;
const int mute = widget == s->mute_ ? s->mute_->value() : track.mute;
const int solo = widget == s->solo_ ? s->solo_->value() : track.solo;
auto &owner = s->owner_;
if (owner.select_track(id))
owner.mix(gain, pan, mute, solo);
}
public:
uint64_t id;
xtc_peak_meter meter[2]{};
MixerStrip(MixerWidget &owner, uint64_t track) : Fl_Group(0, 0, 148, 350), owner_(owner), id(track)
{
box(xtc_ui::flat);
color(xtc_ui::panel());
begin();
name_ = new Fl_Button(0, 0, 120, 26);
name_->callback(
[](Fl_Widget *, void *c) {
auto *s = static_cast<MixerStrip *>(c);
s->owner_.select_track(s->id);
},
this);
mute_ = new Fl_Check_Button(0, 0, 64, 24, "Mute");
solo_ = new Fl_Check_Button(0, 0, 64, 24, "Solo");
pan_ = new PanKnob(0, 0, 48, 48);
pan_text_ = new Fl_Box(0, 0, 124, 18);
route_ = new Fl_Choice(0, 0, 124, 24);
route_->tooltip("Output route: Master or project bus");
route_->callback(
[](Fl_Widget *, void *c) {
auto *s = static_cast<MixerStrip *>(c);
int i = s->route_->value();
if (i < 0 || static_cast<size_t>(i) >= s->destinations_.size())
return;
auto bus = s->destinations_[static_cast<size_t>(i)];
auto &owner = s->owner_;
auto id = s->id;
if (owner.select_track(id))
owner.route(bus);
},
this);
gain_ = new Fl_Value_Slider(0, 0, 42, 150, "dB");
gain_->align(FL_ALIGN_TOP);
gain_->labelsize(10);
gain_->type(FL_VERT_SLIDER);
gain_->slider(FL_FLAT_BOX);
gain_->slider_size(.10);
gain_->bounds(20 * std::log10(4.0), -60);
gain_->step(.1);
gain_->when(FL_WHEN_RELEASE);
gain_->tooltip(
"Level in dB: bottom is silence, 0 dB is unity, top is +12.04 dB. Automation overrides this static value.");
for (auto *w : {static_cast<Fl_Widget *>(gain_), static_cast<Fl_Widget *>(pan_),
static_cast<Fl_Widget *>(mute_), static_cast<Fl_Widget *>(solo_)})
w->callback(control, this);
readout_ = new Fl_Box(0, 0, 132, 24);
readout_->labelsize(10);
end();
xtc_ui::style(this);
pan_->label("");
reset();
resize(0, 0, 148, 350);
}
void reset()
{
xtc_peak_meter_reset(meter);
xtc_peak_meter_reset(meter + 1);
}
void sync(const xtc_track_info &track)
{
name_->copy_label((std::string(track.is_bus ? "Bus: " : "") + track.name).c_str());
name_->tooltip(track.is_bus ? "Bus — select for automation" : "Track — select for automation");
name_->color(owner_.selected_track() == id ? xtc_ui::track(id) : xtc_ui::panel());
gain_->value(track.gain > 0 ? std::max(-60.0, 20 * std::log10(static_cast<double>(track.gain))) : -60);
pan_->value(track.pan);
mute_->value(track.mute);
solo_->value(track.solo);
char text[64];
std::snprintf(text, sizeof(text), "Pan %s %d%%",
track.pan < 0 ? "L"
: track.pan > 0 ? "R"
: "C",
static_cast<int>(std::fabs(track.pan) * 100));
pan_text_->copy_label(text);
route_->clear();
destinations_.clear();
destinations_.push_back(UINT64_MAX);
literal_item(route_,"Master");
route_->value(0);
size_t tracks = 0, clips = 0;
xtc_project_get_counts(owner_.project(), &tracks, &clips);
for (size_t i = 0; i < tracks; ++i) {
xtc_track_info bus{};
xtc_project_get_track(owner_.project(), i, &bus);
if (!bus.is_bus)
continue;
// Literal labels, including FLTK menu metacharacters.
int index = literal_item(route_,(std::string("Bus: ")+bus.name).c_str());
destinations_.push_back(bus.id);
if (bus.id == track.bus_id)
route_->value(index);
}
if (track.is_bus)
route_->deactivate();
else
route_->activate();
redraw();
}
void update(float left, float right, double elapsed)
{
xtc_peak_meter_update(meter, left, elapsed);
xtc_peak_meter_update(meter + 1, right, elapsed);
char text[96];
std::snprintf(text, sizeof(text), "L %.1f R %.1f dBFS%s", static_cast<double>(meter[0].db),
static_cast<double>(meter[1].db), meter[0].clipped || meter[1].clipped ? " CLIP" : "");
readout_->copy_label(text);
readout_->labelcolor(meter[0].clipped || meter[1].clipped ? FL_RED : xtc_ui::muted());
redraw();
}
void resize(int x, int y, int w, int h) override
{
Fl_Widget::resize(x, y, w, h);
name_->resize(x + 8, y + 8, w - 16, 26);
mute_->resize(x + 6, y + 42, 50, 24);
solo_->resize(x + 56, y + 42, 50, 24);
pan_->resize(x + 105, y + 38, 38, 38);
pan_text_->resize(x + 8, y + 78, w - 16, 18);
route_->resize(x + 8, y + 104, w - 16, 24);
gain_->resize(x + 10, y + 150, 44, h - 184);
readout_->resize(x + 4, y + h - 30, w - 8, 24);
redraw();
}
void draw() override
{
Fl_Group::draw();
fl_color(xtc_ui::track(id));
fl_rectf(x() + 1, y() + 1, w() - 2, 3);
fl_font(FL_HELVETICA, 10);
for (int db : {12, 0, -12, -24, -48, -60}) {
char text[12];
std::snprintf(text, sizeof(text), "%d", db);
if (h() < 300 && db != 0 && db != -60)
continue;
int py = y() + 175 + static_cast<int>((12.0412 - db) / 72.0412 * (h() - 212));
fl_color(xtc_ui::muted());
fl_draw(text, x() + 112, py);
}
int top = y() + 150, height = h() - 184;
for (int side = 0; side < 2; ++side) {
int px = x() + 70 + side * 15;
fl_color(xtc_ui::background());
fl_rectf(px, top, 11, height);
const float db = std::clamp(meter[side].db, -60.0f, 6.0f);
int pixels = static_cast<int>((db + 60) / 66 * height);
fl_color(meter[side].clipped ? FL_RED : xtc_ui::accent());
fl_rectf(px, top + height - pixels, 11, pixels);
fl_color(xtc_ui::border());
fl_rect(px, top, 11, height);
fl_color(meter[side].clipped ? FL_RED : xtc_ui::border());
fl_rectf(px, top - 6, 11, 3);
}
}
};
/**
* @brief Creates the mixer panel and its child controls.
*
* Builds the track chooser, gain/pan sliders, mute/solo buttons, output
* Builds the track chooser, channel strips, pan knobs, mute/solo buttons, output
* meters, automation controls, and lane, then displays the given project.
*
* @param x Left edge in parent coordinates.
@ -182,41 +463,39 @@ MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit
begin();
tracks_ = new Fl_Choice(x + 70, y + 8, 240, 26, "Track");
tracks_->callback(select, this);
gain_ = new Fl_Value_Slider(x + 70, y + 48, 320, 28, "Gain");
gain_->type(FL_HOR_FILL_SLIDER);
gain_->bounds(0, 4);
gain_->step(0.01);
gain_->when(FL_WHEN_RELEASE);
gain_->callback(control, this);
pan_ = new Fl_Value_Slider(x + 480, y + 48, 320, 28, "Pan");
pan_->type(FL_HOR_SLIDER);
pan_->bounds(-1, 1);
pan_->step(0.01);
pan_->when(FL_WHEN_RELEASE);
pan_->callback(control, this);
mute_ = new Fl_Check_Button(x + 340, y + 8, 90, 26, "Mute");
solo_ = new Fl_Check_Button(x + 440, y + 8, 90, 26, "Solo");
mute_->callback(control, this);
solo_->callback(control, this);
routes_ = new Fl_Choice(x + 600, y + 8, 180, 26, "Route");
routes_->callback([](Fl_Widget *, void *c) {
auto *m = static_cast<MixerWidget *>(c);
int index = m->routes_->value();
if (index >= 0 && static_cast<size_t>(index) < m->route_ids_.size()) m->route(m->route_ids_[static_cast<size_t>(index)]);
}, this);
auto *bus = new Fl_Button(x + 820, y + 8, 110, 26, "Add bus");
bus->callback([](Fl_Widget *, void *c) { static_cast<MixerWidget *>(c)->add_bus(); }, this);
left_ = new Fl_Progress(x + 70, y + 90, 320, 20, "Output L");
right_ = new Fl_Progress(x + 480, y + 90, 320, 20, "Output R");
track_left_ = new Fl_Progress(x + 70, y + 116, 320, 20, "Track L");
track_right_ = new Fl_Progress(x + 480, y + 116, 320, 20, "Track R");
for (auto *meter : {left_, right_, track_left_, track_right_}) {
routes_->callback(
[](Fl_Widget *, void *c) {
auto *m = static_cast<MixerWidget *>(c);
int index = m->routes_->value();
if (index >= 0 && static_cast<size_t>(index) < m->route_ids_.size())
m->route(m->route_ids_[static_cast<size_t>(index)]);
},
this);
bus_ = new Fl_Button(x + 820, y + 8, 110, 26, "Add bus");
bus_->callback([](Fl_Widget *, void *c) { static_cast<MixerWidget *>(c)->add_bus(); }, this);
left_ = new VerticalPeak(x + 70, y + 90, 320, 20, "Output L");
right_ = new VerticalPeak(x + 480, y + 90, 320, 20, "Output R");
for (auto *meter : {left_, right_}) {
meter->minimum(-90);
meter->maximum(6);
meter->selection_color(fl_rgb_color(80, 180, 120));
}
auto *reset = new Fl_Button(x + 820, y + 90, 110, 46, "Reset peaks");
reset->callback([](Fl_Widget *, void *c) { static_cast<MixerWidget *>(c)->reset_meters(); }, this);
reset_ = new Fl_Button(x, y, 120, 26, "Reset peaks");
reset_->callback([](Fl_Widget *, void *c) { static_cast<MixerWidget *>(c)->reset_meters(); }, this);
bank_ = new Fl_Scroll(x, y + 40, w - 154, h - 172);
bank_->type(Fl_Scroll::HORIZONTAL_ALWAYS);
bank_->end();
master_panel_ = new Fl_Group(x + w - 144, y + 40, 144, h - 172);
master_panel_->box(xtc_ui::flat);
auto *master_name = new Fl_Box(x + w - 140, y + 48, 136, 26, "MASTER");
master_name->labelfont(FL_HELVETICA_BOLD);
auto *master_hint = new Fl_Box(x + w - 140, y + 80, 136, 24, "Output monitor");
master_hint->labelsize(11);
master_readout_ = new Fl_Box(x + w - 140, y + 340, 136, 40);
master_readout_->labelsize(10);
master_panel_->add(left_);
master_panel_->add(right_);
master_panel_->end();
reset_meters();
parameter_ = new Fl_Choice(x + 110, y + 146, 150, 26, "Automation");
parameter_->add("Gain|Pan");
@ -227,14 +506,16 @@ MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit
start_->step(3840);
start_->callback(select, this);
mode_ = new Fl_Choice(x + 705, y + 146, 150, 26, "Interpolation");
mode_->add("Linear|Hold (step)"); mode_->value(0);
auto *hint = new Fl_Box(x + 10, y + 180, w - 20, 24,
"Gain 0..4; pan -1 left / +1 right. M/S toggles; arrows change gain, "
"Shift+arrows pan; Up/Down selects track.");
hint->labelsize(12);
lane_ = new AutomationLane(x + 10, y + 212, w - 20, h - 242, *this);
mode_->add("Linear|Hold (step)");
mode_->value(0);
hint_ = new Fl_Box(x, y, w, 18,
"Select a strip for automation. M/S toggles; arrows adjust gain, Shift+arrows pan. Faders show "
"static dB gain.");
hint_->labelsize(11);
lane_ = new AutomationLane(x + 10, y + h - 80, w - 20, 76, *this);
end();
set_project(p);
layout();
}
/**
@ -245,8 +526,7 @@ MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit
*/
uint64_t MixerWidget::selected_track() const
{
xtc_track_info t{};
return xtc_project_get_track(project_, static_cast<size_t>(tracks_->value()), &t) == XTC_OK ? t.id : UINT64_MAX;
return selected_id_;
}
/**
@ -284,6 +564,7 @@ uint64_t MixerWidget::lane_start() const
*/
void MixerWidget::set_project(xtc_project *p)
{
bool reset = project_ != p;
uint64_t selected = project_ == p ? selected_track() : UINT64_MAX;
project_ = p;
tracks_->clear();
@ -295,18 +576,15 @@ void MixerWidget::set_project(xtc_project *p)
for (size_t i = 0; i < n; ++i) {
xtc_track_info t{};
xtc_project_get_track(p, i, &t);
std::string label;
for (char ch : std::string(t.name)) {
if (ch == '/' || ch == '\\' || ch == '_' || ch == '&')
label += '\\';
label += ch;
}
tracks_->add((std::string(t.is_bus ? "Bus: " : "Track: ") + label).c_str());
if (t.is_bus) { routes_->add(label.c_str()); route_ids_.push_back(t.id); }
literal_item(tracks_,(std::string(t.is_bus?"Bus: ":"Track: ")+t.name).c_str());
if(t.is_bus){literal_item(routes_,(std::string("Bus: ")+t.name).c_str());route_ids_.push_back(t.id);}
if (t.id == selected)
index = static_cast<int>(i);
}
tracks_->value(n ? index : -1);
selected_id_=UINT64_MAX;
if(n){xtc_track_info track{};xtc_project_get_track(p,static_cast<size_t>(index),&track);selected_id_=track.id;}
sync_strips(reset);
selection();
redraw();
}
@ -314,32 +592,44 @@ void MixerWidget::set_project(xtc_project *p)
/**
* @brief Synchronizes the child controls with the selected track.
*
* Controls are deactivated when no valid track is selected. Gain falls
* back to 1, and the remaining values come from the track info or zero when
* unresolved.
* Reconcile strip values and routing with model IDs, highlight the selected
* track, and keep its automation controls in sync.
*/
void MixerWidget::selection()
{
xtc_track_info t{};
bool valid = xtc_project_get_track(project_, static_cast<size_t>(tracks_->value()), &t) == XTC_OK;
for (Fl_Widget *w :
{static_cast<Fl_Widget *>(gain_), static_cast<Fl_Widget *>(pan_), static_cast<Fl_Widget *>(mute_),
static_cast<Fl_Widget *>(solo_), static_cast<Fl_Widget *>(lane_)}) {
if (valid)
w->activate();
else
w->deactivate();
if (valid)
lane_->activate();
else
lane_->deactivate();
for (auto *s : strips_) {
xtc_track_info track{};
size_t count = 0, clips = 0;
xtc_project_get_counts(project_, &count, &clips);
for (size_t i = 0; i < count; ++i) {
xtc_project_get_track(project_, i, &track);
if (track.id == s->id) {
s->sync(track);
break;
}
}
}
gain_->value(valid ? t.gain : 1);
pan_->value(t.pan);
mute_->value(t.mute);
solo_->value(t.solo);
if (valid && !t.is_bus) routes_->activate(); else routes_->deactivate();
if (valid && !t.is_bus)
routes_->activate();
else
routes_->deactivate();
routes_->value(0);
for (size_t i = 0; i < route_ids_.size(); ++i) if (route_ids_[i] == t.bus_id) routes_->value(static_cast<int>(i));
if (meter_track_ != selected_track()) {
meter_track_ = selected_track();
xtc_peak_meter_reset(track_meter_); xtc_peak_meter_reset(track_meter_+1);
for (size_t i = 0; i < route_ids_.size(); ++i)
if (route_ids_[i] == t.bus_id)
routes_->value(static_cast<int>(i));
if (auto *selected = strip(selected_track())) {
int target = bank_->xposition();
if (selected->x() < bank_->x())
target -= bank_->x() - selected->x();
else if (selected->x() + selected->w() > bank_->x() + bank_->w() - 12)
target += selected->x() + selected->w() - (bank_->x() + bank_->w() - 12);
bank_->scroll_to(std::max(0, target), 0);
}
lane_->redraw();
}
@ -361,6 +651,8 @@ void MixerWidget::peaks(float left, float right)
(void)xtc_peak_meter_update(master_, left, meter_elapsed_);
(void)xtc_peak_meter_update(master_+1, right, meter_elapsed_);
display_peaks(left_, right_, master_, "Master");
char text[100];std::snprintf(text,sizeof(text),"L %.1f / R %.1f dBFS%s",static_cast<double>(master_[0].db),static_cast<double>(master_[1].db),master_clipped()?" CLIP":"");
master_readout_->copy_label(text);master_readout_->align(FL_ALIGN_WRAP|FL_ALIGN_INSIDE);
}
/**
@ -425,29 +717,18 @@ xtc_result MixerWidget::point(uint64_t tick, float value, bool remove)
return changed(r);
}
/**
* @brief FLTK callback for the gain, pan, mute, and solo controls.
*
* Reads all four controls and applies them together to the selected track
* through mix().
*
* @param c Opaque MixerWidget pointer supplied as callback context.
*/
void MixerWidget::control(Fl_Widget *, void *c)
{
auto *m = static_cast<MixerWidget *>(c);
m->mix(static_cast<float>(m->gain_->value()), static_cast<float>(m->pan_->value()), m->mute_->value(),
m->solo_->value());
}
/**
* @brief FLTK callback for the track, parameter, and first-tick controls.
*
* @param c Opaque MixerWidget pointer supplied as callback context.
*/
void MixerWidget::select(Fl_Widget *, void *c)
void MixerWidget::select(Fl_Widget *widget, void *c)
{
static_cast<MixerWidget *>(c)->selection();
auto *m=static_cast<MixerWidget *>(c);
if(widget==m->tracks_){xtc_track_info track{};
if(xtc_project_get_track(m->project_,static_cast<size_t>(m->tracks_->value()),&track)==XTC_OK)m->selected_id_=track.id;
}
m->selection();
}
/**
@ -481,7 +762,7 @@ int MixerWidget::handle(int e)
size_t n = 0, c = 0;
xtc_project_get_counts(project_, &n, &c);
tracks_->value(std::clamp(tracks_->value() + (key == FL_Up ? -1 : 1), 0, static_cast<int>(n) - 1));
selection();
select(tracks_,this);
return 1;
}
}
@ -498,22 +779,20 @@ void MixerWidget::display_peaks(Fl_Progress *left, Fl_Progress *right, xtc_peak_
static_cast<double>(meters[side].db), meters[side].clipped ? " CLIP" : "");
bar->copy_label(label);
bar->value(std::clamp(meters[side].db, -90.0f, 6.0f));
bar->selection_color(meters[side].clipped ? FL_RED : FL_GREEN);
bar->selection_color(meters[side].clipped ? FL_RED : xtc_ui::accent());
}
}
void MixerWidget::track_peaks(float left, float right)
{
(void)xtc_peak_meter_update(track_meter_, left, meter_elapsed_);
(void)xtc_peak_meter_update(track_meter_+1, right, meter_elapsed_);
xtc_track_info track{};
bool bus = xtc_project_get_track(project_, static_cast<size_t>(tracks_->value()), &track) == XTC_OK && track.is_bus;
display_peaks(track_left_, track_right_, track_meter_, bus ? "Bus" : "Track");
for(auto *s:strips_)if(s->id==selected_track())s->update(left,right,meter_elapsed_);
}
void MixerWidget::reset_meters()
{
for (unsigned int i = 0; i < 2; ++i) { xtc_peak_meter_reset(master_+i); xtc_peak_meter_reset(track_meter_+i); }
for (unsigned int i = 0; i < 2; ++i) { xtc_peak_meter_reset(master_+i); }
display_peaks(left_, right_, master_, "Master");
display_peaks(track_left_, track_right_, track_meter_, "Track");
char text[100];std::snprintf(text,sizeof(text),"L %.1f / R %.1f dBFS%s",static_cast<double>(master_[0].db),static_cast<double>(master_[1].db),master_clipped()?" CLIP":"");
master_readout_->copy_label(text);master_readout_->align(FL_ALIGN_WRAP|FL_ALIGN_INSIDE);
for(auto *s:strips_){s->reset();s->update(0,0,0);}
}
xtc_result MixerWidget::add_bus()
{
@ -534,7 +813,89 @@ bool MixerWidget::select_track(uint64_t id)
size_t n = 0, clips = 0; xtc_project_get_counts(project_, &n, &clips);
for (size_t i = 0; i < n; ++i) {
xtc_track_info track{}; xtc_project_get_track(project_, i, &track);
if (track.id == id) { tracks_->value(static_cast<int>(i)); selection(); return true; }
if (track.id == id) { selected_id_=id;tracks_->value(static_cast<int>(i)); selection(); return true; }
}
return false;
}
Fl_Group *MixerWidget::strip(uint64_t id) const
{
for (auto *s : strips_)
if (s->id == id)
return s;
return nullptr;
}
void MixerWidget::meters(const xtc_meter_snapshot &snapshot)
{
peaks(snapshot.master_left, snapshot.master_right);
for (auto *s : strips_) {
float left = 0, right = 0;
for (size_t i = 0; i < std::min(snapshot.count, static_cast<size_t>(XTC_PROJECT_MAX_TRACKS)); ++i)
if (snapshot.tracks[i].id == s->id) {
left = snapshot.tracks[i].left;
right = snapshot.tracks[i].right;
break;
}
s->update(left, right, meter_elapsed_);
}
}
void MixerWidget::sync_strips(bool reset)
{
size_t n = 0, clips = 0;
xtc_project_get_counts(project_, &n, &clips);
bool rebuild = n != strips_.size();
for (size_t i = 0; i < n && !rebuild; ++i) {
xtc_track_info t{};
xtc_project_get_track(project_, i, &t);
rebuild = t.id != strips_[i]->id;
}
if (rebuild) {
auto *previous = Fl_Group::current();
bank_->clear();
strips_.clear();
bank_->begin();
for (size_t i = 0; i < n; ++i) {
xtc_track_info t{};
xtc_project_get_track(project_, i, &t);
strips_.push_back(new MixerStrip(*this, t.id));
}
bank_->end();
Fl_Group::current(previous);
layout();
}
if (reset)
for (auto *s : strips_)
s->reset();
}
void MixerWidget::layout()
{
const int bank_height = std::max(250, h() - 172);
tracks_->resize(x() + 48, y() + 4, 210, 26);
routes_->resize(x() + 326, y() + 4, 160, 26);
bus_->resize(x() + 500, y() + 4, 100, 26);
reset_->resize(x() + 612, y() + 4, 112, 26);
bank_->resize(x(), y() + 40, w() - 154, bank_height);
int offset = bank_->xposition();
for (size_t i = 0; i < strips_.size(); ++i)
strips_[i]->resize(bank_->x() + static_cast<int>(i) * 156 - offset, bank_->y(), 148, bank_height - 16);
master_panel_->resize(x() + w() - 144, y() + 40, 144, bank_height);
// Master is pinned outside the horizontally scrolling bank.
left_->resize(x() + w() - 118, y() + 118, 32, bank_height - 140);
right_->resize(x() + w() - 66, y() + 118, 32, bank_height - 140);
master_readout_->resize(x() + w() - 140, y() + 40 + bank_height - 52, 136, 42);
left_->align(FL_ALIGN_TOP);
right_->align(FL_ALIGN_TOP);
left_->labelsize(10);
right_->labelsize(10);
parameter_->resize(x() + 84, y() + h() - 120, 100, 26);
start_->resize(x() + 264, y() + h() - 120, 116, 26);
mode_->resize(x() + 488, y() + h() - 120, 136, 26);
hint_->resize(x() + 8, y() + h() - 92, w() - 16, 18);
lane_->resize(x() + 8, y() + h() - 72, w() - 16, 68);
redraw();
}
void MixerWidget::resize(int x, int y, int w, int h)
{
Fl_Widget::resize(x, y, w, h);
layout();
}

View file

@ -1,16 +1,21 @@
#ifndef XTC_MIXER_WIDGET_HPP
#define XTC_MIXER_WIDGET_HPP
#include "xtc/xtc_mixer.h"
#include "xtc/xtc_backend.h"
#include <FL/Fl_Group.H>
#include <chrono>
#include <vector>
class Fl_Choice;
class Fl_Button;
class Fl_Value_Slider;
class Fl_Check_Button;
class Fl_Progress;
class Fl_Value_Input;
class AutomationLane;
class MixerStrip;
class Fl_Scroll;
class Fl_Box;
class MixerWidget : public Fl_Group {
public:
@ -24,7 +29,7 @@ class MixerWidget : public Fl_Group {
/**
* @brief Creates the mixer panel and its child controls.
*
* Builds the track chooser, gain/pan sliders, mute/solo buttons, output
* Builds the track chooser, channel strips, pan knobs, mute/solo buttons, output
* meters, automation controls, and lane, then displays the given project.
*
* @param x Left edge in parent coordinates.
@ -124,6 +129,11 @@ class MixerWidget : public Fl_Group {
xtc_result route(uint64_t bus);
bool select_track(uint64_t id);
void track_peaks(float left, float right);
/* One control-thread snapshot updates all strips; missing taps decay to silence. */
void meters(const xtc_meter_snapshot &snapshot);
Fl_Group *strip(uint64_t id) const;
size_t strip_count() const { return strips_.size(); }
void resize(int x, int y, int w, int h) override;
void reset_meters();
bool master_clipped() const { return master_[0].clipped || master_[1].clipped; }
xtc_result mix(float gain, float pan, int mute, int solo);
@ -145,11 +155,16 @@ class MixerWidget : public Fl_Group {
void *context_;
Fl_Choice *tracks_, *parameter_, *routes_, *mode_;
std::vector<uint64_t> route_ids_;
Fl_Value_Slider *gain_, *pan_;
Fl_Check_Button *mute_, *solo_;
Fl_Progress *left_, *right_, *track_left_, *track_right_;
xtc_peak_meter master_[2]{}, track_meter_[2]{};
uint64_t meter_track_ = UINT64_MAX;
Fl_Progress *left_, *right_;
Fl_Scroll *bank_;
Fl_Group *master_panel_;
Fl_Button *bus_, *reset_;
Fl_Box *hint_, *master_readout_;
std::vector<MixerStrip *> strips_;
void sync_strips(bool reset = false);
void layout();
xtc_peak_meter master_[2]{};
uint64_t selected_id_ = UINT64_MAX;
std::chrono::steady_clock::time_point peak_time_ = std::chrono::steady_clock::now();
double meter_elapsed_ = 0.1;
void display_peaks(Fl_Progress *left, Fl_Progress *right, xtc_peak_meter *meters, const char *prefix);
@ -171,27 +186,15 @@ class MixerWidget : public Fl_Group {
/**
* @brief Synchronizes the child controls with the selected track.
*
* Controls are deactivated when no valid track is selected. Gain falls
* back to 1, and the remaining values come from the track info or zero when
* unresolved.
* Reconcile strip controls with stable model IDs and the selected
* automation target.
*/
void selection();
/**
* @brief FLTK callback for the gain, pan, mute, and solo controls.
*
* Reads all four controls and applies them together to the selected track
* through mix().
*
* @param widget Widget that triggered the callback; unused.
* @param c Opaque MixerWidget pointer supplied as callback context.
*/
static void control(Fl_Widget * widget, void * c);
/**
* @brief FLTK callback for the track, parameter, and first-tick controls.
*
* @param widget Widget that triggered the callback; unused.
* @param widget Track chooser or automation control that changed.
* @param c Opaque MixerWidget pointer supplied as callback context.
*/
static void select(Fl_Widget * widget, void * c);

View file

@ -9,6 +9,9 @@
#include <FL/Fl_Hold_Browser.H>
#include <FL/Fl_Choice.H>
#include <FL/Fl_Value_Slider.H>
#include <FL/Fl_Dial.H>
#include <FL/Fl_Check_Button.H>
#include <FL/Fl_Scroll.H>
#include <unistd.h>
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
@ -1006,6 +1009,77 @@ int main(int argc, char **argv)
}
unlink(path.c_str());rmdir(dir);
}
{
reset();MainWindow window(false,"");window.show();auto *m=window.mixer();
CHECK(m->strip_count()==0 && m->strip(UINT64_MAX)==nullptr);
CHECK(window.create_track(true)==XTC_OK && window.create_track(true)==XTC_OK);
xtc_track_info a{},b{};xtc_project_get_track(m->project(),0,&a);xtc_project_get_track(m->project(),1,&b);
CHECK(m->strip_count()==2 && m->strip(a.id) && m->strip(b.id));
auto *first=m->strip(a.id);
auto *fader=dynamic_cast<Fl_Value_Slider *>(find(first,"dB"));CHECK(fader);
CHECK(fader && fader->type()==FL_VERT_SLIDER && fader->h()>fader->w());
if(fader){fader->value(-6);fader->do_callback();}
xtc_project_get_track(m->project(),0,&a);xtc_project_get_track(m->project(),1,&b);
CHECK(std::fabs(a.gain-std::pow(10.f,-6.f/20))<.0001f && b.gain==1);
CHECK(first==m->strip(a.id)); // Editing must not delete its callback's widget.
if(fader){gesture(fader,20,40,0,30);xtc_project_get_track(m->project(),0,&a);CHECK(a.gain<.5f);CHECK(window.undo()==XTC_OK);}
if(fader){
reserve_result=XTC_ERR_OUT_OF_MEMORY;
Fl::add_timeout(.05,[](void *){auto *dialog=Fl::first_window();CHECK(dialog && contains(dialog,"Mixer edit failed"));if(dialog)dialog->hide();});
fader->value(-12);fader->do_callback();reserve_result=XTC_OK;
xtc_project_get_track(m->project(),0,&a);CHECK(std::fabs(a.gain-std::pow(10.f,-6.f/20))<.0001f);
CHECK(std::fabs(fader->value()+6)<.001);
}
press(find(m->strip(b.id),b.name));CHECK(m->selected_track()==b.id);
CHECK(window.undo()==XTC_OK);xtc_project_get_track(m->project(),0,&a);CHECK(a.gain==1);
CHECK(window.undo(true)==XTC_OK);xtc_project_get_track(m->project(),0,&a);CHECK(a.gain<.51f);
Fl_Dial *pan=nullptr;for(int i=0;i<m->strip(b.id)->children();++i)if(auto *d=dynamic_cast<Fl_Dial *>(m->strip(b.id)->child(i)))pan=d;
CHECK(pan);if(pan){pan->value(-.75);pan->do_callback();}
xtc_project_get_track(m->project(),1,&b);CHECK(b.pan==-.75f && b.gain==1);
auto *mute=dynamic_cast<Fl_Check_Button *>(find(m->strip(b.id),"Mute"));CHECK(mute);
if(mute){mute->value(1);mute->do_callback();}xtc_project_get_track(m->project(),1,&b);CHECK(b.mute && b.pan==-.75f);
if(pan){int old=Fl::e_clicks;Fl::e_clicks=1;gesture(pan,20,20,0,0);Fl::e_clicks=old;}
xtc_project_get_track(m->project(),1,&b);CHECK(b.pan==0 && b.mute);
CHECK(m->add_bus()==XTC_OK);uint64_t bus=m->selected_track();CHECK(m->strip_count()==3 && contains(m->strip(bus),"Bus:"));
Fl_Choice *route=nullptr;for(int i=0;i<m->strip(a.id)->children();++i)if(auto *c=dynamic_cast<Fl_Choice *>(m->strip(a.id)->child(i)))route=c;
CHECK(route && route->size()==3); // Master, bus, terminator.
if(route){route->value(1);route->do_callback();}xtc_project_get_track(m->project(),0,&a);CHECK(a.bus_id==bus);
CHECK(window.undo()==XTC_OK);xtc_project_get_track(m->project(),0,&a);CHECK(a.bus_id==UINT64_MAX);
CHECK(m->add_bus()==XTC_OK);CHECK(m->strip_count()==4);
auto *routes=dynamic_cast<Fl_Choice *>(find(m,"Route"));CHECK(routes && routes->size()==4);
CHECK(window.undo()==XTC_OK);CHECK(m->strip_count()==3);
xtc_meter_snapshot snapshot{};snapshot.count=2;snapshot.tracks[0]={a.id,.5f,.25f};snapshot.tracks[1]={b.id,1.2f,.125f};
m->meters(snapshot);CHECK(contains(m->strip(a.id),"L -6.0") && contains(m->strip(b.id),"CLIP"));
m->select_track(a.id);CHECK(contains(m->strip(b.id),"CLIP"));m->meters(xtc_meter_snapshot{});CHECK(contains(m->strip(b.id),"CLIP"));
m->reset_meters();CHECK(!contains(m->strip(b.id),"CLIP"));
CHECK(window.save_project("/tmp/xtc-mixer-strips.xtc")==XTC_OK && window.open_project("/tmp/xtc-mixer-strips.xtc")==XTC_OK);
CHECK(m->strip_count()==3);xtc_project_get_track(m->project(),1,&b);CHECK(b.pan==0 && b.mute);
CHECK(m->select_track(b.id) && m->mix(.00001f,0,0,0)==XTC_OK);
mute=dynamic_cast<Fl_Check_Button *>(find(m->strip(b.id),"Mute"));CHECK(mute);
if(mute){mute->value(1);mute->do_callback();}xtc_project_get_track(m->project(),1,&b);CHECK(b.gain==.00001f && b.mute);
unlink("/tmp/xtc-mixer-strips.xtc");window.do_callback();
}
{
xtc_project *project=nullptr;CHECK(xtc_project_create(&project)==XTC_OK);
uint64_t first=UINT64_MAX,last=UINT64_MAX;
for(size_t i=0;i<XTC_PROJECT_MAX_TRACKS;++i){uint64_t id;char name[40];std::snprintf(name,sizeof(name),"Duplicate/name & _track");
CHECK(xtc_project_add_track(project,name,&id)==XTC_OK);if(i==0)first=id;last=id;}
{
Fl_Double_Window window(1100,600);window.begin();MixerWidget m(0,0,1032,522,project,nullptr,nullptr);window.end();window.show();
CHECK(m.strip_count()==XTC_PROJECT_MAX_TRACKS && m.select_track(last));
auto *track_choice=dynamic_cast<Fl_Choice *>(find(&m,"Track"));
CHECK(track_choice && track_choice->size()==static_cast<int>(XTC_PROJECT_MAX_TRACKS)+1);
CHECK(track_choice && track_choice->value()==static_cast<int>(XTC_PROJECT_MAX_TRACKS)-1);
CHECK(m.strip(last)->x()>=m.x() && m.strip(last)->x()+m.strip(last)->w()<m.x()+m.w()-144);
CHECK(xtc_project_move_track(project,last,0)==XTC_OK);m.set_project(project);
CHECK(m.selected_track()==last && m.strip_count()==XTC_PROJECT_MAX_TRACKS);
CHECK(xtc_project_remove_track(project,first)==XTC_OK);m.set_project(project);CHECK(!m.strip(first));
m.resize(0,0,972,442);m.select_track(last);auto *f=dynamic_cast<Fl_Value_Slider *>(find(m.strip(last),"dB"));CHECK(f && f->h()>40);
m.resize(0,0,1500,900);CHECK(f && f->h()>400);
window.hide();
}
xtc_project_destroy(project);
}
if (failures != 0) return 1;
std::printf("GUI controls/errors/views/timers/cleanup passed: %u opens, %u closes.\n", opens, closes);
return 0;