xtc/src/gui/mixer_widget.cpp

534 lines
20 KiB
C++
Raw Normal View History

#include "mixer_widget.hpp"
#include <FL/Fl.H>
#include <FL/Fl_Box.H>
#include <FL/Fl_Check_Button.H>
#include <FL/Fl_Button.H>
#include <FL/Fl_Choice.H>
#include <FL/Fl_Progress.H>
#include <FL/Fl_Value_Input.H>
#include <FL/Fl_Value_Slider.H>
#include <FL/fl_ask.H>
#include <FL/fl_draw.H>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <string>
#include <vector>
2026-10-27 01:37:21 +01:00
namespace {
constexpr uint64_t lane_ticks = 15360;
}
2026-10-27 01:37:21 +01:00
class AutomationLane : public Fl_Widget {
MixerWidget &mixer_;
public:
/**
* @brief Creates an automation lane bound to its owning mixer.
*
* @param x Left edge in parent coordinates.
* @param y Top edge in parent coordinates.
* @param w Lane width in pixels; tick math requires a value greater than 1.
* @param h Lane height in pixels.
* @param m Mixer that owns the lane; stored by reference and must outlive it.
*/
2026-10-27 01:37:21 +01:00
AutomationLane(int x, int y, int w, int h, MixerWidget &m) : Fl_Widget(x, y, w, h), mixer_(m)
{
}
/**
* @brief Draws the lane grid, automation curve, points, and empty hint.
*
* Points for the selected track and parameter are sorted by tick, rendered
* as an interpolated curve, and marked within the visible tick range.
*/
void draw() override
{
fl_push_clip(x(), y(), w(), h());
fl_color(fl_rgb_color(32, 35, 40));
fl_rectf(x(), y(), w(), h());
fl_font(FL_HELVETICA, 11);
for (int i = 0; i <= 16; ++i) {
int px = x() + i * (w() - 1) / 16;
fl_color(fl_rgb_color(i % 4 ? 55 : 95, 60, 65));
fl_line(px, y(), px, y() + h());
if (i % 4 == 0) {
char label[32];
std::snprintf(label, sizeof(label), "%llu",
2026-10-27 01:37:21 +01:00
static_cast<unsigned long long>(mixer_.lane_start() + static_cast<uint64_t>(i) * 960));
fl_color(FL_WHITE);
fl_draw(label, px + 3, y() + 14);
}
}
size_t count = 0;
xtc_project_automation_count(mixer_.project(), &count);
std::vector<xtc_automation_point> points;
for (size_t i = 0; i < count; ++i) {
xtc_automation_point a{};
xtc_project_get_automation(mixer_.project(), i, &a);
if (a.track_id == mixer_.selected_track() && a.parameter == mixer_.parameter())
points.push_back(a);
}
2026-10-27 01:37:21 +01:00
std::sort(points.begin(), points.end(), [](const auto &a, const auto &b) {
return a.tick < b.tick;
});
float min = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 0.0f : -1.0f;
float range = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 4.0f : 2.0f;
auto py = [&](float v) {
return y() + h() - 6 - static_cast<int>((v - min) / range * (h() - 28));
};
auto value = [&](uint64_t tick) {
if (points.empty())
return min;
2026-10-27 01:37:21 +01:00
auto it = std::upper_bound(points.begin(), points.end(), tick, [](uint64_t t, const auto &a) {
return t < a.tick;
});
if (it == points.begin())
return it->value;
if (it == points.end())
return points.back().value;
const auto &a = *(it - 1);
2026-10-27 01:37:21 +01:00
return a.value +
static_cast<float>(static_cast<double>(tick - a.tick) / static_cast<double>(it->tick - a.tick)) *
(it->value - a.value);
};
fl_color(fl_rgb_color(105, 200, 150));
if (!points.empty())
for (int px = 1; px < w(); ++px) {
2026-10-27 01:37:21 +01:00
uint64_t a =
mixer_.lane_start() + static_cast<uint64_t>(px - 1) * lane_ticks / static_cast<uint64_t>(w() - 1);
uint64_t b =
mixer_.lane_start() + static_cast<uint64_t>(px) * lane_ticks / static_cast<uint64_t>(w() - 1);
fl_line(x() + px - 1, py(value(a)), x() + px, py(value(b)));
}
for (const auto &a : points)
if (a.tick >= mixer_.lane_start() && a.tick <= mixer_.lane_start() + lane_ticks) {
2026-10-27 01:37:21 +01:00
int px = x() +
static_cast<int>((a.tick - mixer_.lane_start()) * static_cast<uint64_t>(w() - 1) / lane_ticks);
fl_rectf(px - 4, py(a.value) - 4, 8, 8);
}
if (points.empty()) {
fl_color(FL_WHITE);
fl_draw("Click to add automation; right-click a point to remove. Values replace the "
"static control.",
x() + 12, y() + h() / 2);
}
fl_pop_clip();
}
2026-10-27 01:37:21 +01:00
/**
* @brief Handles FLTK input events and delegates unhandled events to the base widget.
*
* @param event FLTK event code.
* @return Nonzero if the event was handled, zero otherwise.
*/
int handle(int event) override
{
if (event != FL_PUSH)
return Fl_Widget::handle(event);
if (Fl::event_button() != FL_LEFT_MOUSE && Fl::event_button() != FL_RIGHT_MOUSE)
return 0;
2026-10-27 01:37:21 +01:00
uint64_t tick = mixer_.lane_start() + static_cast<uint64_t>(std::clamp(Fl::event_x() - x(), 0, w() - 1)) *
lane_ticks / static_cast<uint64_t>(w() - 1);
tick = (tick + 120) / 240 * 240;
tick = std::min(tick, XTC_PROJECT_MAX_TICK);
float min = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 0.0f : -1.0f;
float range = mixer_.parameter() == XTC_AUTOMATION_GAIN ? 4.0f : 2.0f;
2026-10-27 01:37:21 +01:00
float value = min + static_cast<float>(std::clamp(y() + h() - 6 - Fl::event_y(), 0, h() - 28)) /
static_cast<float>(h() - 28) * range;
if (Fl::event_button() == FL_RIGHT_MOUSE) {
size_t count = 0;
xtc_project_automation_count(mixer_.project(), &count);
2026-10-27 01:37:21 +01:00
uint64_t distance = static_cast<uint64_t>(8) * lane_ticks / static_cast<uint64_t>(w() - 1) + 1,
closest = UINT64_MAX;
for (size_t i = 0; i < count; ++i) {
xtc_automation_point a{};
xtc_project_get_automation(mixer_.project(), i, &a);
uint64_t d = a.tick > tick ? a.tick - tick : tick - a.tick;
2026-10-27 01:37:21 +01:00
if (a.track_id == mixer_.selected_track() && a.parameter == mixer_.parameter() && d < distance) {
distance = d;
closest = a.tick;
}
}
if (closest != UINT64_MAX)
mixer_.point(closest, 0, true);
} else
mixer_.point(tick, value);
return 1;
}
};
2026-10-27 01:37:21 +01:00
/**
* @brief Creates the mixer panel and its child controls.
*
* Builds the track chooser, gain/pan sliders, mute/solo buttons, output
* meters, automation controls, and lane, then displays the given project.
*
* @param x Left edge in parent coordinates.
* @param y Top edge in parent coordinates.
* @param w Widget width in pixels.
* @param h Widget height in pixels.
* @param p Project to display; borrowed and must outlive its use.
* @param before Callback invoked before each edit; may be nullptr.
* @param context Opaque pointer forwarded to before.
*/
2026-10-27 01:37:21 +01:00
MixerWidget::MixerWidget(int x, int y, int w, int h, xtc_project *p, BeforeEdit before, void *context)
: Fl_Group(x, y, w, h), project_(p), before_(before), context_(context)
{
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_NICE_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_NICE_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_}) {
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_meters();
parameter_ = new Fl_Choice(x + 110, y + 146, 150, 26, "Automation");
parameter_->add("Gain|Pan");
parameter_->value(0);
parameter_->callback(select, this);
start_ = new Fl_Value_Input(x + 420, y + 146, 140, 26, "First tick");
start_->bounds(0, XTC_PROJECT_MAX_TICK - lane_ticks);
start_->step(3840);
start_->callback(select, this);
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);
end();
set_project(p);
}
2026-10-27 01:37:21 +01:00
/**
* @brief Returns the ID of the track selected in the track chooser.
*
* @return Selected track ID, or UINT64_MAX when the chooser selection does
* not resolve to a project track.
*/
uint64_t MixerWidget::selected_track() const
{
xtc_track_info t{};
2026-10-27 01:37:21 +01:00
return xtc_project_get_track(project_, static_cast<size_t>(tracks_->value()), &t) == XTC_OK ? t.id : UINT64_MAX;
}
2026-10-27 01:37:21 +01:00
/**
* @brief Returns the automation parameter selected in the chooser.
*
* @return XTC_AUTOMATION_PAN when the chooser selects "Pan", otherwise
* XTC_AUTOMATION_GAIN.
*/
xtc_automation_parameter MixerWidget::parameter() const
{
return parameter_->value() == 1 ? XTC_AUTOMATION_PAN : XTC_AUTOMATION_GAIN;
}
2026-10-27 01:37:21 +01:00
/**
* @brief Returns the first tick shown by the automation lane.
*
* @return Clamped first tick in project ticks, or 0 when the input value is
* not finite.
*/
uint64_t MixerWidget::lane_start() const
{
2026-10-27 01:37:21 +01:00
if (!std::isfinite(start_->value()))
return 0;
return static_cast<uint64_t>(
std::clamp(start_->value(), 0.0, static_cast<double>(XTC_PROJECT_MAX_TICK - lane_ticks)));
}
2026-10-27 01:37:21 +01:00
/**
* @brief Displays a different project and refreshes the controls.
*
* The current track selection is preserved only when the same project
* pointer is passed again. The project is borrowed and must outlive use.
*
* @param p Project to display; must remain valid while shown.
*/
void MixerWidget::set_project(xtc_project *p)
{
uint64_t selected = project_ == p ? selected_track() : UINT64_MAX;
project_ = p;
tracks_->clear();
routes_->clear(); routes_->add("Master");
route_ids_.clear(); route_ids_.push_back(UINT64_MAX);
size_t n = 0, c = 0;
xtc_project_get_counts(p, &n, &c);
int index = 0;
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); }
if (t.id == selected)
index = static_cast<int>(i);
}
tracks_->value(n ? index : -1);
selection();
redraw();
}
2026-10-27 01:37:21 +01:00
/**
* @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.
*/
void MixerWidget::selection()
{
xtc_track_info t{};
2026-10-27 01:37:21 +01:00
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();
}
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();
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);
}
lane_->redraw();
}
2026-10-27 01:37:21 +01:00
/**
* @brief Updates the left and right output meters.
*
* Meter values are clamped to 1.0, while the over-range color is chosen
* from the unclamped level.
*
* @param left Left-channel peak level.
* @param right Right-channel peak level.
*/
void MixerWidget::peaks(float left, float right)
{
auto now = std::chrono::steady_clock::now();
meter_elapsed_ = std::clamp(std::chrono::duration<double>(now-peak_time_).count(), 0.0, 60.0);
peak_time_ = now;
(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");
}
2026-10-27 01:37:21 +01:00
/**
* @brief Reports an edit result and refreshes the controls.
*
* A successful result invokes the widget callback so the owner can mark
* the project dirty; a failure shows a modal alert. The controls are
* resynchronized in both cases.
*
* @param r Result of the attempted edit.
* @return The same result passed in.
*/
xtc_result MixerWidget::changed(xtc_result r)
{
if (r == XTC_OK)
do_callback();
else
fl_alert("Mixer edit failed: %s", xtc_result_string(r));
selection();
return r;
}
2026-10-27 01:37:21 +01:00
/**
* @brief Applies gain, pan, mute, and solo to the selected track.
*
* The constructor's before-edit callback runs first; the edit is skipped
* if it returns an error. On success the widget callback is invoked and
* the controls are refreshed.
*
* @param gain Linear gain in the range 0..4.
* @param pan Pan position in the range -1 (left)..1 (right).
* @param mute Mute state: 0 or 1.
* @param solo Solo state: 0 or 1.
* @return XTC_OK on success, or the before-edit or project mutation error.
*/
xtc_result MixerWidget::mix(float gain, float pan, int mute, int solo)
{
xtc_result r = before_ ? before_(context_) : XTC_OK;
if (r == XTC_OK)
r = xtc_project_set_mix(project_, selected_track(), gain, pan, mute, solo);
return changed(r);
}
2026-10-27 01:37:21 +01:00
/**
* @brief Adds, replaces, or removes an automation point.
*
* The constructor's before-edit callback runs first; the edit is skipped
* if it returns an error. On success the widget callback is invoked and
* the controls are refreshed.
*
* @param tick Automation tick to modify; must be within the project range.
* @param value New value; ignored when remove is true.
* @param remove When true, removes the point instead of setting it.
* @return XTC_OK on success, or the before-edit or project mutation error.
*/
xtc_result MixerWidget::point(uint64_t tick, float value, bool remove)
{
xtc_result r = before_ ? before_(context_) : XTC_OK;
xtc_automation_point a{selected_track(), tick, parameter(), value};
if (r == XTC_OK)
r = remove ? xtc_project_remove_automation(project_, a.track_id, a.parameter, tick)
: xtc_project_set_automation(project_, &a);
return changed(r);
}
2026-10-27 01:37:21 +01:00
/**
* @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);
2026-10-27 01:37:21 +01:00
m->mix(static_cast<float>(m->gain_->value()), static_cast<float>(m->pan_->value()), m->mute_->value(),
m->solo_->value());
}
2026-10-27 01:37:21 +01:00
/**
* @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)
{
static_cast<MixerWidget *>(c)->selection();
}
2026-10-27 01:37:21 +01:00
/**
* @brief Handles keyboard shortcuts and forwards other events.
*
* M and S toggle mute and solo, Left/Right adjust gain (Shift adjusts pan),
* and Up/Down change the selected track. Modified key presses fall through.
*
* @param e FLTK event code.
* @return 1 when the event was consumed, otherwise the Fl_Group result.
*/
int MixerWidget::handle(int e)
{
if (e == FL_KEYDOWN && !(Fl::event_state() & (FL_CTRL | FL_ALT | FL_META))) {
int key = Fl::event_key();
xtc_track_info t{};
if (xtc_project_get_track(project_, static_cast<size_t>(tracks_->value()), &t) == XTC_OK) {
if (key == 'm' || key == 's') {
mix(t.gain, t.pan, key == 'm' ? !t.mute : t.mute, key == 's' ? !t.solo : t.solo);
return 1;
}
if (key == FL_Left || key == FL_Right) {
float d = key == FL_Left ? -0.05f : 0.05f;
bool pan = (Fl::event_state() & FL_SHIFT) != 0;
2026-10-27 01:37:21 +01:00
mix(pan ? t.gain : std::clamp(t.gain + d, 0.0f, 4.0f), pan ? std::clamp(t.pan + d, -1.0f, 1.0f) : t.pan,
t.mute, t.solo);
return 1;
}
if (key == FL_Up || key == FL_Down) {
size_t n = 0, c = 0;
xtc_project_get_counts(project_, &n, &c);
2026-10-27 01:37:21 +01:00
tracks_->value(std::clamp(tracks_->value() + (key == FL_Up ? -1 : 1), 0, static_cast<int>(n) - 1));
selection();
return 1;
}
}
}
return Fl_Group::handle(e);
}
void MixerWidget::display_peaks(Fl_Progress *left, Fl_Progress *right, xtc_peak_meter *meters, const char *prefix)
{
for (unsigned int side = 0; side < 2; ++side) {
auto *bar = side ? right : left;
char label[80];
std::snprintf(label, sizeof(label), "%s %c %.1f dBFS%s", prefix, side ? 'R' : 'L',
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);
}
}
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");
}
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); }
display_peaks(left_, right_, master_, "Master");
display_peaks(track_left_, track_right_, track_meter_, "Track");
}
xtc_result MixerWidget::add_bus()
{
uint64_t id;
xtc_result r = before_ ? before_(context_) : XTC_OK;
if (r == XTC_OK) r = xtc_project_add_bus(project_, "Bus", &id);
if (r == XTC_OK) { changed(r); select_track(id); return r; }
return changed(r);
}
xtc_result MixerWidget::route(uint64_t bus)
{
xtc_result r = before_ ? before_(context_) : XTC_OK;
if (r == XTC_OK) r = xtc_project_route_track(project_, selected_track(), bus);
return changed(r);
}
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; }
}
return false;
}