Something went wrong. Try again.
Mixxx DJ software with MCP, Subsonic/Navidrome streaming library support — browse and load tracks from your self-hosted music server mixxx.org
subsonic navidrome streaming audio dj mac linux midi
Something went wrong. Try again.
15 kB · 325 lines
C++
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326#include <QSet>#include <QMap>#include <QtDebug>
#include "controllers/learningutils.h"#include "controllers/midi/midiutils.h"#include "control/controlobject.h"
typedef QPair<MidiKey, unsigned char> MidiKeyAndValue;
struct MessageStats { MessageStats() : message_count(0), last_value(0) { }
void addMessage(const MidiKeyAndValue& message) { // If we've never seen a message before, set last_value to this value. if (message_count == 0) { last_value = message.second; }
MidiOpCode opcode = MidiUtils::opCodeFromStatus(message.first.status); unsigned char channel = MidiUtils::channelFromStatus(message.first.status); opcodes.insert(opcode); channels.insert(channel); controls.insert(message.first.control);
message_count++; value_histogram[message.second]++;
// Convert to integers first to get negative values. int absolute_difference = abs(static_cast<int>(message.second) - static_cast<int>(last_value)); abs_diff_histogram[absolute_difference]++; last_value = message.second; }
int message_count; QSet<MidiOpCode> opcodes; QSet<unsigned char> channels; QSet<unsigned char> controls; QMap<unsigned char, int> value_histogram; // The range of differences for unsigned char is -255 through 255. QMap<int, int> abs_diff_histogram; unsigned char last_value;};
// staticMidiInputMappings LearningUtils::guessMidiInputMappings( const ConfigKey& control, const QList<QPair<MidiKey, unsigned char>>& messages) { QMap<unsigned char, MessageStats> stats_by_control; MessageStats stats;
// Analyze the message foreach (const MidiKeyAndValue& message, messages) { stats.addMessage(message); stats_by_control[message.first.control].addMessage(message); }
qDebug() << "LearningUtils guessing MIDI mapping from" << messages.size() << "messages.";
foreach (MidiOpCode opcode, stats.opcodes) { qDebug() << "Opcode:" << opcode; }
foreach (unsigned char channel, stats.channels) { qDebug() << "Channel:" << channel; }
foreach (unsigned char control, stats.controls) { qDebug() << "Control:" << control; }
for (auto it = stats.value_histogram.constBegin(); it != stats.value_histogram.constEnd(); ++it) { qDebug() << "Overall Value:" << it.key() << "count" << it.value(); }
for (auto control_it = stats_by_control.constBegin(); control_it != stats_by_control.constEnd(); ++control_it) { QString controlName = QString("Control %1").arg(control_it.key()); for (auto it = control_it->value_histogram.constBegin(); it != control_it->value_histogram.constEnd(); ++it) { qDebug() << controlName << "Value:" << it.key() << "count" << it.value(); } }
MidiInputMappings mappings;
bool one_control = stats.controls.size() == 1; bool one_channel = stats.channels.size() == 1; bool only_note_on = stats.opcodes.size() == 1 && stats.opcodes.contains(MidiOpCode::NoteOn); bool only_note_on_and_note_off = stats.opcodes.size() == 2 && stats.opcodes.contains(MidiOpCode::NoteOn) && stats.opcodes.contains(MidiOpCode::NoteOff);
bool has_cc = stats.opcodes.contains(MidiOpCode::ControlChange); bool only_cc = stats.opcodes.size() == 1 && has_cc; int num_cc_controls = 0; for (auto it = stats_by_control.constBegin(); it != stats_by_control.constEnd(); ++it) { if (it->opcodes.contains(MidiOpCode::ControlChange)) { num_cc_controls++; } }
bool two_values_7bit_max_and_min = stats.value_histogram.size() == 2 && stats.value_histogram.contains(0x00) && stats.value_histogram.contains(0x7F); bool one_value_7bit_max_or_min = stats.value_histogram.size() == 1 && (stats.value_histogram.contains(0x00) || stats.value_histogram.contains(0x7F)); bool multiple_one_or_7f_values = stats.value_histogram.value(0x01, 0) > 1 || stats.value_histogram.value(0x7F, 0) > 1; bool under_8_distinct_values = stats.value_histogram.size() < 8; bool no_0x00_value = !stats.value_histogram.contains(0x00); bool no_0x40_value = !stats.value_histogram.contains(0x40); bool multiple_values_around_0x40 = stats.value_histogram.value(0x41, 0) > 1 && stats.value_histogram.value(0x3F, 0) > 1 && no_0x40_value && under_8_distinct_values;
// QMap keys are sorted so we can check this easily by checking the last key // is <= 0x7F. bool only_7bit_values = !stats.value_histogram.isEmpty() && stats.value_histogram.lastKey() <= 0x7F;
// A 7-bit two's complement ticker swinging from +1 to -1 can generate // unsigned differences of up to 126 (0x7E). If we see differences in // individual messages above 96 (0x60) that's a good hint that we're looking // at a two's complement ticker. bool abs_differences_above_60 = !stats.abs_diff_histogram.isEmpty() && stats.abs_diff_histogram.lastKey() >= 0x60;
if (one_control && one_channel && two_values_7bit_max_and_min && only_note_on_and_note_off) { // A standard button that sends NOTE_ON commands with 0x7F for // down-press and NOTE_OFF commands with 0x00 for release. MidiOptions options;
MidiKey note_on; note_on.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::NoteOn, *stats.channels.begin()); note_on.control = *stats.controls.begin(); mappings.append(MidiInputMapping(note_on, options, control));
MidiKey note_off; note_off.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::NoteOff, *stats.channels.begin()); note_off.control = note_on.control; mappings.append(MidiInputMapping(note_off, options, control)); } else if (one_control && one_channel && two_values_7bit_max_and_min && only_note_on) { // A standard button that only sends NOTE_ON commands with 0x7F for // down-press and 0x00 for release. MidiOptions options;
MidiKey note_on; note_on.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::NoteOn, *stats.channels.begin()); note_on.control = *stats.controls.begin(); mappings.append(MidiInputMapping(note_on, options, control)); } else if (one_control && one_channel && one_value_7bit_max_or_min && (only_note_on || only_cc)) { // This looks like a toggle switch. If we only got one value and it's // either min or max then this behaves like hard-coded toggle buttons on // the VCI-400. The opcode can be MidiOpCode::NoteOn or MidiOpCode::ControlChange. // Examples: // - VCI-400 vinyl toggle button (NOTE_ON) // - Korg nanoKontrol switches (CC) MidiOptions options; options.setFlag(MidiOption::Switch);
MidiKey note_on; // The predicate ensures only NOTE_ON or CC messages can trigger this // logic. MidiOpCode code = only_note_on ? MidiOpCode::NoteOn : MidiOpCode::ControlChange; note_on.status = MidiUtils::statusFromOpCodeAndChannel(code, *stats.channels.begin()); note_on.control = *stats.controls.begin(); mappings.append(MidiInputMapping(note_on, options, control)); } else if (one_control && one_channel && only_cc && only_7bit_values && no_0x00_value && (abs_differences_above_60 || (under_8_distinct_values && multiple_one_or_7f_values))) { // A two's complement +/- ticker (e.g. selector knobs and some jog // wheels). Values are typically +1 (0x01) and -1 (0x7F) but rapid // changes on some controllers can produce multiple ticks per // message. This must come before the standard knob CC block because it // looks like a standard CC knob other than the large swings in value // and repeats of 0x01 and 0x7F values.
// We have a dedicated MidiOption for processing two's complement (even // though it is called 'selectknob' it is actually only two's complement // processing). MidiOptions options; options.setFlag(MidiOption::SelectKnob); MidiKey knob; knob.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::ControlChange, *stats.channels.begin()); knob.control = *stats.controls.begin(); mappings.append(MidiInputMapping(knob, options, control)); } else if (one_control && one_channel && multiple_values_around_0x40) { // A "spread 64" ticker, where 0x40 is zero, positive jog values are // 0x41 and above, and negative jog values are 0x3F and below. MidiOptions options; options.setFlag(MidiOption::Spread64);
MidiKey knob; knob.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::ControlChange, *stats.channels.begin()); knob.control = *stats.controls.begin(); mappings.append(MidiInputMapping(knob, options, control)); } else if (one_channel && has_cc && num_cc_controls == 1 && only_7bit_values) { // A simple 7-bit knob that may have other messages mixed in. Some // controllers (e.g. the VCI-100) emit a center-point NOTE_ON (with // value 0x7F or 0x00 depending on if you are arriving at or leaving the // center point) instead of a CC value of 0x40. If the control we are // mapping has a reset control then we map the NOTE_ON messages to the // reset control. ConfigKey resetControl = control; resetControl.item.append("_set_default"); bool hasResetControl = ControlObject::getControl(resetControl, ControlFlag::NoWarnIfMissing) != nullptr;
// Find the CC control (based on the predicate one must exist) and add a // binding for it. for (auto it = stats_by_control.constBegin(); it != stats_by_control.constEnd(); ++it) { if (it->opcodes.contains(MidiOpCode::ControlChange)) { MidiKey knob; knob.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::ControlChange, *stats.channels.begin()); knob.control = it.key(); mappings.append(MidiInputMapping(knob, MidiOptions(), control)); }
// If we found a NOTE_ON, map it to reset if the control exists. // TODO(rryan): We need to modularize each recognizer here so we can // run the button recognizer on these messages minus the CC // messages. if (hasResetControl && it->opcodes.contains(MidiOpCode::NoteOn)) { MidiKey note_on; note_on.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::NoteOn, *stats.channels.begin()); note_on.control = it.key(); mappings.append(MidiInputMapping(note_on, MidiOptions(), resetControl)); } } } else if (one_channel && only_cc && stats.controls.size() == 2 && stats_by_control.begin()->message_count > 10 && stats_by_control.begin()->message_count == (++stats_by_control.begin())->message_count) { // If there are two CC controls with the same number of messages then we // assume this is a 14-bit CC knob. Now we need to determine which // control is the LSB and which is the MSB.
// When observing an MSB/LSB sweep, the LSB will be very high frequency // compared to the MSB. Instead of doing an actual FFT on the two // signals, we can hack this by looking at the absolute differences // between messages. We expect to see many high/low wrap-arounds for the // LSB. int control1 = *stats.controls.begin(); int control2 = *(++stats.controls.begin());
int control1_max_abs_diff = stats_by_control[control1].abs_diff_histogram.lastKey(); int control2_max_abs_diff = stats_by_control[control2].abs_diff_histogram.lastKey();
// The control with the larger abs difference in messages is the LSB. If // they are equal we choose one arbitrarily (depends on QSet iteration // order which is undefined). int lsb_control = control1_max_abs_diff > control2_max_abs_diff ? control1 : control2; int msb_control = control1_max_abs_diff > control2_max_abs_diff ? control2 : control1;
// NOTE(rryan): There is an industry convention that a 14-bit CC control // is a pair of controls offset by 32 (the lower is the MSB, the higher // is the LSB). My VCI-400 follows this convention, for example. I don't // use that convention here because it's not universal and we should be // able to come up with reasonable heuristics to identify an LSB and an // MSB.
MidiKey msb; msb.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::ControlChange, *stats.channels.begin()); msb.control = msb_control; MidiOptions msb_option; msb_option.setFlag(MidiOption::FourteenBitMSB); mappings.append(MidiInputMapping(msb, msb_option, control));
MidiKey lsb; lsb.status = MidiUtils::statusFromOpCodeAndChannel( MidiOpCode::ControlChange, *stats.channels.begin()); lsb.control = lsb_control; MidiOptions lsb_option; lsb_option.setFlag(MidiOption::FourteenBitLSB); mappings.append(MidiInputMapping(lsb, lsb_option, control)); }
if (mappings.isEmpty() && !messages.isEmpty()) { // Fall back. Map the first message we got. By dumb luck this might work // for 14-bit faders, for example if the high-order byte is first (it // will just be a 7-bit fader). MidiOptions options;
// TODO(rryan): Feedback to the user that we didn't do anything // intelligent here. mappings.append(MidiInputMapping(messages.first().first, options, control)); }
// Add control and description info to each learned input mapping. for (MidiInputMappings::iterator it = mappings.begin(); it != mappings.end(); ++it) { MidiInputMapping& mapping = *it; mapping.description = QString("MIDI Learned from %1 messages.") .arg(messages.size()); }
return mappings;}