Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 120279cbd4 | |||
| 8068f5d168 | |||
| 01584f50da | |||
| 5fd5946ff6 | |||
| 3d00299051 | |||
| 524f02ec9d | |||
| e4e7cd1ca2 | |||
| 0316e4b37f | |||
| 0a76f5734f | |||
| 959da00d7c | |||
| 1854d03c58 | |||
| 0422c91b4e | |||
| df5a317ceb | |||
| e1014462b4 | |||
| 4854b2fa22 | |||
| 4047a74dd5 | |||
| 236e1e2b3c | |||
| ae13b3739d | |||
| c028ad8cd6 | |||
| 5e93cc1fb9 | |||
| acfb8d15ab |
@@ -1,11 +1,11 @@
|
|||||||
cmake_minimum_required(VERSION 3.16.0)
|
cmake_minimum_required(VERSION 3.16.0)
|
||||||
project(pipedal
|
project(op-pedal
|
||||||
VERSION 2.0.107
|
VERSION 2.0.107
|
||||||
DESCRIPTION "PiPedal Guitar Effect Pedal For Raspberry Pi"
|
DESCRIPTION "OP-Pedal Guitar Effect Pedal For Raspberry Pi"
|
||||||
HOMEPAGE_URL "https://rerdavies.github.io/pipedal"
|
HOMEPAGE_URL "https://ourpad.casa/op-pedal"
|
||||||
)
|
)
|
||||||
|
|
||||||
set (DISPLAY_VERSION "PiPedal v2.0.107-Release")
|
set (DISPLAY_VERSION "OP-Pedal / Ourpad Pedal v2.0.107-Release")
|
||||||
|
|
||||||
option(PIPEDAL_DISABLE_COPYRIGHT_BUILD "Skip generation of copyright notices (use on non-Debian distros)" OFF)
|
option(PIPEDAL_DISABLE_COPYRIGHT_BUILD "Skip generation of copyright notices (use on non-Debian distros)" OFF)
|
||||||
option(PIPEDAL_EXCLUDE_TESTS "Exclude test targets from default build" OFF)
|
option(PIPEDAL_EXCLUDE_TESTS "Exclude test targets from default build" OFF)
|
||||||
|
|||||||
@@ -275,9 +275,9 @@ namespace pipedal
|
|||||||
{
|
{
|
||||||
Lv2Log::warning("Failed resize the ALSA sequencer input buffer: %s", snd_strerror(rc));
|
Lv2Log::warning("Failed resize the ALSA sequencer input buffer: %s", snd_strerror(rc));
|
||||||
}
|
}
|
||||||
snd_seq_set_client_name(seqHandle, "PiPedal");
|
snd_seq_set_client_name(seqHandle, "OP-Pedal");
|
||||||
|
|
||||||
inPort = snd_seq_create_simple_port(seqHandle, "PiPedal:in",
|
inPort = snd_seq_create_simple_port(seqHandle, "OP-Pedal:in",
|
||||||
SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE,
|
SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE,
|
||||||
SND_SEQ_PORT_TYPE_MIDI_GENERIC |
|
SND_SEQ_PORT_TYPE_MIDI_GENERIC |
|
||||||
SND_SEQ_PORT_TYPE_MIDI_GM | SND_SEQ_PORT_TYPE_APPLICATION);
|
SND_SEQ_PORT_TYPE_MIDI_GM | SND_SEQ_PORT_TYPE_APPLICATION);
|
||||||
@@ -651,7 +651,7 @@ namespace pipedal
|
|||||||
// Create a new queue if we don't have one yet
|
// Create a new queue if we don't have one yet
|
||||||
if (queueId < 0)
|
if (queueId < 0)
|
||||||
{
|
{
|
||||||
queueId = snd_seq_alloc_named_queue(seqHandle, "PiPedal Realtime Queue");
|
queueId = snd_seq_alloc_named_queue(seqHandle, "OP-Pedal Realtime Queue");
|
||||||
if (queueId < 0)
|
if (queueId < 0)
|
||||||
{
|
{
|
||||||
throw std::runtime_error(SS("Failed to create ALSA queue: " << snd_strerror(queueId)));
|
throw std::runtime_error(SS("Failed to create ALSA queue: " << snd_strerror(queueId)));
|
||||||
@@ -862,7 +862,7 @@ namespace pipedal
|
|||||||
snd_seq_set_client_name(seqHandle, "Device Monitor");
|
snd_seq_set_client_name(seqHandle, "Device Monitor");
|
||||||
|
|
||||||
int inPort = snd_seq_create_simple_port(
|
int inPort = snd_seq_create_simple_port(
|
||||||
seqHandle, "PiPedal:portMonitor",
|
seqHandle, "OP-Pedal:portMonitor",
|
||||||
SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE,
|
SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE,
|
||||||
SND_SEQ_PORT_TYPE_APPLICATION);
|
SND_SEQ_PORT_TYPE_APPLICATION);
|
||||||
if (inPort < 0)
|
if (inPort < 0)
|
||||||
@@ -973,7 +973,7 @@ namespace pipedal
|
|||||||
// Create a new queue if we don't have one yet
|
// Create a new queue if we don't have one yet
|
||||||
int queueId = -1;
|
int queueId = -1;
|
||||||
{
|
{
|
||||||
queueId = snd_seq_alloc_named_queue(seqHandle, "PiPedal Device Monitor Queue");
|
queueId = snd_seq_alloc_named_queue(seqHandle, "OP-Pedal Device Monitor Queue");
|
||||||
if (queueId < 0)
|
if (queueId < 0)
|
||||||
{
|
{
|
||||||
throw std::runtime_error(SS("Failed to create ALSA queue: " << snd_strerror(queueId)));
|
throw std::runtime_error(SS("Failed to create ALSA queue: " << snd_strerror(queueId)));
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ void ServiceConfiguration::Save()
|
|||||||
t.close();
|
t.close();
|
||||||
|
|
||||||
struct group *group_;
|
struct group *group_;
|
||||||
group_ = getgrnam("pipedal_d");
|
group_ = getgrnam("oppedal_d");
|
||||||
if (group_ == nullptr)
|
if (group_ == nullptr)
|
||||||
{
|
{
|
||||||
throw logic_error("Group not found: pipedal_d");
|
throw logic_error("Group not found: oppedal_d");
|
||||||
}
|
}
|
||||||
std::ignore = chown(DEVICEID_FILE_NAME, -1, group_->gr_gid);
|
std::ignore = chown(DEVICEID_FILE_NAME, -1, group_->gr_gid);
|
||||||
std::ignore = chmod(DEVICEID_FILE_NAME, S_IWUSR | S_IRUSR | S_IRGRP | S_IWGRP | S_IROTH);
|
std::ignore = chmod(DEVICEID_FILE_NAME, S_IWUSR | S_IRUSR | S_IRGRP | S_IWGRP | S_IROTH);
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ void WifiDirectConfigSettings::Load()
|
|||||||
|
|
||||||
if (!ConfigUtil::GetConfigLine("/var/pipedal/config/pipedal_p2pd.conf","p2p_device_name",&this->hotspotName_))
|
if (!ConfigUtil::GetConfigLine("/var/pipedal/config/pipedal_p2pd.conf","p2p_device_name",&this->hotspotName_))
|
||||||
{
|
{
|
||||||
this->hotspotName_ = "PiPedal";
|
this->hotspotName_ = "OP-Pedal";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ConfigUtil::GetConfigLine("/var/pipedal/config/pipedal_p2pd.conf","country_code",&this->countryCode_))
|
if (!ConfigUtil::GetConfigLine("/var/pipedal/config/pipedal_p2pd.conf","country_code",&this->countryCode_))
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ namespace pipedal {
|
|||||||
|
|
||||||
|
|
||||||
std::string uuid;
|
std::string uuid;
|
||||||
std::string deviceName = "PiPedal";
|
std::string deviceName = "OP-Pedal";
|
||||||
uint32_t server_port = 80;
|
uint32_t server_port = 80;
|
||||||
private:
|
private:
|
||||||
std::filesystem::path filename;
|
std::filesystem::path filename;
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ namespace pipedal {
|
|||||||
std::string homeNetwork_;
|
std::string homeNetwork_;
|
||||||
|
|
||||||
std::string countryCode_ = "US"; // iso 3661
|
std::string countryCode_ = "US"; // iso 3661
|
||||||
std::string hotspotName_ = "pipedal";
|
std::string hotspotName_ = "op-pedal";
|
||||||
bool hasPassword_ = false;
|
bool hasPassword_ = false;
|
||||||
std::string password_;
|
std::string password_;
|
||||||
std::string channel_ = "";
|
std::string channel_ = "";
|
||||||
std::string mdnsName_ = "pipedal";
|
std::string mdnsName_ = "op-pedal";
|
||||||
|
|
||||||
bool wifiWarningGiven_ = false; // Do not use. Present only for backward compatibility.
|
bool wifiWarningGiven_ = false; // Do not use. Present only for backward compatibility.
|
||||||
bool valid_ = false; // Do not use. Present only for backward compatibility.
|
bool valid_ = false; // Do not use. Present only for backward compatibility.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ namespace pipedal {
|
|||||||
bool rebootRequired_ = false;
|
bool rebootRequired_ = false;
|
||||||
bool enable_ = false;
|
bool enable_ = false;
|
||||||
std::string countryCode_ = ""; // iso 3661
|
std::string countryCode_ = ""; // iso 3661
|
||||||
std::string hotspotName_ = "pipedal";
|
std::string hotspotName_ = "op-pedal";
|
||||||
bool pinChanged_ = false;
|
bool pinChanged_ = false;
|
||||||
std::string pin_;
|
std::string pin_;
|
||||||
std::string channel_ = "1"; // "0" -> select automatically.
|
std::string channel_ = "1"; // "0" -> select automatically.
|
||||||
|
|||||||
@@ -17,12 +17,12 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Use your Raspberry Pi, or Ubuntu amd/x86-64 computer as a guitar effects pedal. Configure and control PiPedal remotedly, with your phone or tablet, or via a web browser.
|
Use your Raspberry Pi, or Ubuntu amd/x86-64 computer as a guitar effects pedal. Configure and control OP-Pedal remotedly, with your phone or tablet, or via a web browser.
|
||||||
|
|
||||||
PiPedal running on a Raspberry Pi 4 or Pi 5 provides stable super-low-latency audio via external USB audio devices, or internal Raspberry Pi audio hats.
|
OP-Pedal running on a Raspberry Pi 4 or Pi 5 provides stable super-low-latency audio via external USB audio devices, or internal Raspberry Pi audio hats.
|
||||||
|
|
||||||
PiPedal runs on Raspbery Pi OS (Bookworm or Trixie), or Ubuntu 24.x or later (amd64/x86-64 and aarch64). Make sure you follow the [Ubuntu post-install
|
OP-Pedal runs on Raspbery Pi OS (Bookworm or Trixie), or Ubuntu 24.x or later (amd64/x86-64 and aarch64). Make sure you follow the [Ubuntu post-install
|
||||||
instructions](https://rerdavies.github.io/pipedal/Configuring.html) to make sure your Ubuntu OS is using a realtime-capable kernel.
|
instructions](https://ourpad.casa/op-pedal/Configuring.html) to make sure your Ubuntu OS is using a realtime-capable kernel.
|
||||||
|
|
||||||
<img src="docs/gallery/dark-sshot1.png"></img>
|
<img src="docs/gallery/dark-sshot1.png"></img>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="1626" height="536" viewBox="0 0 1626 536" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M594.746 359.92C584.669 359.92 578.111 357.745 575.073 353.396C572.034 349.047 572.336 342.17 575.98 332.766L625.756 202.482C629.407 193.081 634.402 186.205 640.743 181.852C647.084 177.499 655.296 175.325 665.381 175.328H753.975C764.048 175.328 770.52 177.505 773.392 181.857C776.264 186.21 775.878 193.085 772.234 202.482L722.442 332.766C718.791 342.166 713.882 349.043 707.716 353.396C701.55 357.749 693.428 359.923 683.351 359.92H594.746ZM644.277 304.045C642.188 309.442 641.71 313.098 642.841 315.012C643.973 316.927 646.709 317.884 651.05 317.884H658.869C663.21 317.884 666.642 316.927 669.165 315.012C671.688 313.098 673.99 309.442 676.072 304.045L703.954 231.203C706.043 225.813 706.564 222.157 705.518 220.235C704.471 218.314 701.78 217.357 697.442 217.364H689.606C685.258 217.364 681.783 218.321 679.183 220.235C676.582 222.15 674.235 225.806 672.143 231.203L644.277 304.045ZM941.736 181.857C944.604 186.207 944.213 193.083 940.562 202.488L914.505 270.886C910.855 280.287 905.946 287.163 899.78 291.516C893.613 295.869 885.492 298.044 875.415 298.04H815.994L792.269 359.92H734.947L789.676 217.364H778.467L805.069 175.328H922.325C932.398 175.328 938.868 177.505 941.736 181.857ZM850.916 256.015C855.257 256.015 858.689 255.058 861.212 253.144C863.735 251.229 866.037 247.575 868.119 242.182L872.287 231.214C874.368 225.824 874.89 222.168 873.851 220.247C872.812 218.325 870.118 217.368 865.77 217.375H847.01L832.156 256.015H850.916Z" fill="#D1D3D4"/>
|
||||||
|
<path d="M1007.47 317.884H1069.49L1042.65 359.92H933.978L1004.6 175.328H1061.92L1007.47 317.884ZM1254.78 175.328L1234.45 359.92H1178.16L1193.53 234.855L1142.72 317.884H1173.99L1146.89 359.92H1061.15L1174.77 175.328H1254.78ZM1245.65 359.92L1300.38 217.364H1289.18L1315.76 175.328H1433.03C1443.1 175.328 1449.57 177.505 1452.44 181.857C1455.31 186.21 1454.92 193.087 1451.27 202.488L1434.07 247.395C1433.31 249.36 1432.21 251.173 1430.81 252.748C1429.16 254.751 1427.34 256.753 1425.34 258.753C1423.4 260.697 1421.36 262.527 1419.22 264.234C1417.13 265.888 1415.39 267.237 1414 268.282C1414.72 269.547 1415.38 270.85 1415.96 272.185C1416.75 273.959 1417.4 275.791 1417.91 277.666C1418.43 279.59 1418.78 281.557 1418.95 283.543C1419.15 285.364 1418.89 287.207 1418.18 288.896L1401.48 332.755C1397.83 342.155 1392.92 349.032 1386.75 353.385C1380.59 357.738 1372.47 359.912 1362.39 359.909L1245.65 359.92ZM1319.15 317.884H1337.91C1342.25 317.884 1345.68 316.927 1348.2 315.012C1350.73 313.098 1353.03 309.442 1355.11 304.045L1355.63 302.478C1357.72 297.088 1358.24 293.434 1357.19 291.516C1356.14 289.598 1353.45 288.641 1349.12 288.645H1330.35L1319.15 317.884ZM1375.31 244.284C1377.82 242.369 1380.12 238.715 1382.21 233.322L1382.99 231.231C1385.08 225.841 1385.6 222.185 1384.56 220.263C1383.51 218.342 1380.82 217.384 1376.47 217.392H1357.7L1346.23 247.155H1365C1369.35 247.137 1372.78 246.17 1375.31 244.256V244.284ZM1530.75 217.392C1526.75 217.392 1523.54 218.566 1521.11 220.916C1518.68 223.265 1516.68 226.61 1515.12 230.952L1514.33 233.043C1512.94 237.221 1512.25 239.918 1512.25 241.133C1512.25 243.224 1512.99 244.618 1514.48 245.315C1515.97 246.012 1518.17 246.358 1521.12 246.358H1563.06C1573.14 246.358 1579.69 248.533 1582.74 252.882C1585.78 257.231 1585.47 264.108 1581.82 273.512L1559.17 332.766C1555.52 342.166 1550.52 349.043 1544.18 353.396C1537.84 357.749 1529.63 359.923 1519.55 359.92H1417.15L1422.88 317.884H1495.59C1499.94 317.884 1503.37 316.927 1505.89 315.012C1508.41 313.098 1510.71 309.442 1512.79 304.045L1513.58 302.222C1515.31 297.694 1516.01 294.386 1515.66 292.297C1515.31 290.208 1513.66 288.907 1510.71 288.394H1464.84C1454.77 288.394 1448.21 286.217 1445.17 281.865C1442.12 277.512 1442.43 270.635 1446.08 261.234L1468.73 202.22C1474.81 186.207 1485.58 177.243 1501.04 175.328H1619.36L1592.25 217.364L1530.75 217.392Z" fill="#1C75BC"/>
|
||||||
|
<path d="M266.908 483.889C381.732 483.889 474.816 387.384 474.816 268.338C474.816 149.293 381.732 52.7879 266.908 52.7879C152.084 52.7879 59 149.293 59 268.338C59 387.384 152.084 483.889 266.908 483.889Z" fill="#1C75BC"/>
|
||||||
|
<path d="M515.644 2.12828L151.951 231.672C149.848 232.999 148.143 234.906 147.025 237.181C145.908 239.456 145.424 242.008 145.626 244.552C145.828 247.096 146.708 249.532 148.169 251.587C149.63 253.642 151.613 255.234 153.897 256.186L179.804 267.026C181.745 267.838 183.474 269.116 184.848 270.754C186.222 272.393 187.202 274.345 187.709 276.451C188.216 278.558 188.235 280.759 187.764 282.875C187.293 284.99 186.346 286.96 185.001 288.624L3.22682 513.262C-6.50516 525.303 7.68406 542.137 20.4913 533.74L382.112 296.344C384.242 294.945 385.943 292.946 387.018 290.581C388.092 288.215 388.494 285.581 388.178 282.987C387.861 280.393 386.839 277.947 385.23 275.933C383.622 273.92 381.494 272.423 379.095 271.618L360.264 265.32C358.195 264.628 356.322 263.42 354.809 261.801C353.297 260.182 352.191 258.202 351.589 256.036C350.987 253.87 350.908 251.584 351.358 249.379C351.809 247.173 352.775 245.116 354.171 243.389L532.705 22.7271C542.359 10.7977 528.442 -5.96589 515.644 2.12828Z" fill="#D1D3D4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="700" height="700" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M349.203 572.671C468.026 572.671 564.351 472.819 564.351 349.645C564.351 226.471 468.026 126.619 349.203 126.619C230.38 126.619 134.055 226.471 134.055 349.645C134.055 472.819 230.38 572.671 349.203 572.671Z" fill="#1C75BC"/>
|
||||||
|
<path d="M606.601 74.2021L230.242 311.707C228.066 313.079 226.301 315.053 225.146 317.407C223.99 319.761 223.488 322.401 223.697 325.034C223.906 327.666 224.817 330.186 226.329 332.312C227.841 334.438 229.893 336.086 232.257 337.071L259.065 348.287C261.074 349.128 262.863 350.45 264.285 352.145C265.707 353.84 266.721 355.86 267.246 358.039C267.77 360.219 267.79 362.496 267.303 364.685C266.816 366.874 265.836 368.913 264.443 370.634L76.3392 603.063C66.2683 615.521 80.9517 632.939 94.2049 624.251L468.419 378.622C470.623 377.174 472.384 375.106 473.495 372.658C474.607 370.211 475.023 367.486 474.696 364.802C474.368 362.118 473.31 359.586 471.646 357.503C469.981 355.42 467.779 353.871 465.297 353.038L445.81 346.522C443.669 345.806 441.731 344.555 440.165 342.88C438.6 341.205 437.456 339.157 436.833 336.916C436.21 334.675 436.128 332.309 436.594 330.027C437.06 327.746 438.06 325.617 439.505 323.83L624.255 95.5153C634.246 83.1722 619.844 65.8272 606.601 74.2021Z" fill="#D1D3D4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
<svg width="1626" height="750" viewBox="0 0 1626 750" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- ===== OP LABS + OP PEDAL horizontal lockup ===== -->
|
||||||
|
|
||||||
|
<!-- Blue circle icon -->
|
||||||
|
<path d="M266.908 483.889C381.732 483.889 474.816 387.384 474.816 268.338C474.816 149.293 381.732 52.7879 266.908 52.7879C152.084 52.7879 59 149.293 59 268.338C59 387.384 152.084 483.889 266.908 483.889Z" fill="#1C75BC"/>
|
||||||
|
<!-- Silver swoosh -->
|
||||||
|
<path d="M515.644 2.12828L151.951 231.672C149.848 232.999 148.143 234.906 147.025 237.181C145.908 239.456 145.424 242.008 145.626 244.552C145.828 247.096 146.708 249.532 148.169 251.587C149.63 253.642 151.613 255.234 153.897 256.186L179.804 267.026C181.745 267.838 183.474 269.116 184.848 270.754C186.222 272.393 187.202 274.345 187.709 276.451C188.216 278.558 188.235 280.759 187.764 282.875C187.293 284.99 186.346 286.96 185.001 288.624L3.22682 513.262C-6.50516 525.303 7.68406 542.137 20.4913 533.74L382.112 296.344C384.242 294.945 385.943 292.946 387.018 290.581C388.092 288.215 388.494 285.581 388.178 282.987C387.861 280.393 386.839 277.947 385.23 275.933C383.622 273.92 381.494 272.423 379.095 271.618L360.264 265.32C358.195 264.628 356.322 263.42 354.809 261.801C353.297 260.182 352.191 258.202 351.589 256.036C350.987 253.87 350.908 251.584 351.358 249.379C351.809 247.173 352.775 245.116 354.171 243.389L532.705 22.7271C542.359 10.7977 528.442 -5.96589 515.644 2.12828Z" fill="#D1D3D4"/>
|
||||||
|
|
||||||
|
<!-- OP LABS (original brand path data) -->
|
||||||
|
<g transform="translate(0, -30)">
|
||||||
|
<!-- OP in silver -->
|
||||||
|
<path d="M594.746 359.92C584.669 359.92 578.111 357.745 575.073 353.396C572.034 349.047 572.336 342.17 575.98 332.766L625.756 202.482C629.407 193.081 634.402 186.205 640.743 181.852C647.084 177.499 655.296 175.325 665.381 175.328H753.975C764.048 175.328 770.52 177.505 773.392 181.857C776.264 186.21 775.878 193.085 772.234 202.482L722.442 332.766C718.791 342.166 713.882 349.043 707.716 353.396C701.55 357.749 693.428 359.923 683.351 359.92H594.746ZM644.277 304.045C642.188 309.442 641.71 313.098 642.841 315.012C643.973 316.927 646.709 317.884 651.05 317.884H658.869C663.21 317.884 666.642 316.927 669.165 315.012C671.688 313.098 673.99 309.442 676.072 304.045L703.954 231.203C706.043 225.813 706.564 222.157 705.518 220.235C704.471 218.314 701.78 217.357 697.442 217.364H689.606C685.258 217.364 681.783 218.321 679.183 220.235C676.582 222.15 674.235 225.806 672.143 231.203L644.277 304.045ZM941.736 181.857C944.604 186.207 944.213 193.083 940.562 202.488L914.505 270.886C910.855 280.287 905.946 287.163 899.78 291.516C893.613 295.869 885.492 298.044 875.415 298.04H815.994L792.269 359.92H734.947L789.676 217.364H778.467L805.069 175.328H922.325C932.398 175.328 938.868 177.505 941.736 181.857ZM850.916 256.015C855.257 256.015 858.689 255.058 861.212 253.144C863.735 251.229 866.037 247.575 868.119 242.182L872.287 231.214C874.368 225.824 874.89 222.168 873.851 220.247C872.812 218.325 870.118 217.368 865.77 217.375H847.01L832.156 256.015H850.916Z" fill="#D1D3D4"/>
|
||||||
|
<!-- LABS in blue -->
|
||||||
|
<path d="M1007.47 317.884H1069.49L1042.65 359.92H933.978L1004.6 175.328H1061.92L1007.47 317.884ZM1254.78 175.328L1234.45 359.92H1178.16L1193.53 234.855L1142.72 317.884H1173.99L1146.89 359.92H1061.15L1174.77 175.328H1254.78ZM1245.65 359.92L1300.38 217.364H1289.18L1315.76 175.328H1433.03C1443.1 175.328 1449.57 177.505 1452.44 181.857C1455.31 186.21 1454.92 193.087 1451.27 202.488L1434.07 247.395C1433.31 249.36 1432.21 251.173 1430.81 252.748C1429.16 254.751 1427.34 256.753 1425.34 258.753C1423.4 260.697 1421.36 262.527 1419.22 264.234C1417.13 265.888 1415.39 267.237 1414 268.282C1414.72 269.547 1415.38 270.85 1415.96 272.185C1416.75 273.959 1417.4 275.791 1417.91 277.666C1418.43 279.59 1418.78 281.557 1418.95 283.543C1419.15 285.364 1418.89 287.207 1418.18 288.896L1401.48 332.755C1397.83 342.155 1392.92 349.032 1386.75 353.385C1380.59 357.738 1372.47 359.912 1362.39 359.909L1245.65 359.92ZM1319.15 317.884H1337.91C1342.25 317.884 1345.68 316.927 1348.2 315.012C1350.73 313.098 1353.03 309.442 1355.11 304.045L1355.63 302.478C1357.72 297.088 1358.24 293.434 1357.19 291.516C1356.14 289.598 1353.45 288.641 1349.12 288.645H1330.35L1319.15 317.884ZM1375.31 244.284C1377.82 242.369 1380.12 238.715 1382.21 233.322L1382.99 231.231C1385.08 225.841 1385.6 222.185 1384.56 220.263C1383.51 218.342 1380.82 217.384 1376.47 217.392H1357.7L1346.23 247.155H1365C1369.35 247.137 1372.78 246.17 1375.31 244.256V244.284ZM1530.75 217.392C1526.75 217.392 1523.54 218.566 1521.11 220.916C1518.68 223.265 1516.68 226.61 1515.12 230.952L1514.33 233.043C1512.94 237.221 1512.25 239.918 1512.25 241.133C1512.25 243.224 1512.99 244.618 1514.48 245.315C1515.97 246.012 1518.17 246.358 1521.12 246.358H1563.06C1573.14 246.358 1579.69 248.533 1582.74 252.882C1585.78 257.231 1585.47 264.108 1581.82 273.512L1559.17 332.766C1555.52 342.166 1550.52 349.043 1544.18 353.396C1537.84 357.749 1529.63 359.923 1519.55 359.92H1417.15L1422.88 317.884H1495.59C1499.94 317.884 1503.37 316.927 1505.89 315.012C1508.41 313.098 1510.71 309.442 1512.79 304.045L1513.58 302.222C1515.31 297.694 1516.01 294.386 1515.66 292.297C1515.31 290.208 1513.66 288.907 1510.71 288.394H1464.84C1454.77 288.394 1448.21 286.217 1445.17 281.865C1442.12 277.512 1442.43 270.635 1446.08 261.234L1468.73 202.22C1474.81 186.207 1485.58 177.243 1501.04 175.328H1619.36L1592.25 217.364L1530.75 217.392Z" fill="#1C75BC"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ===== OP PEDAL subtitle ===== -->
|
||||||
|
<!-- Uses sans-serif — renders in ALL browsers, including Gitea's SVG viewer -->
|
||||||
|
<g font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="bold">
|
||||||
|
<!-- OP in silver -->
|
||||||
|
<text x="595" y="510" fill="#D1D3D4">O</text>
|
||||||
|
<text x="670" y="510" fill="#D1D3D4">P</text>
|
||||||
|
<!-- PEDAL in blue -->
|
||||||
|
<text x="890" y="510" fill="#1C75BC">P</text>
|
||||||
|
<text x="965" y="510" fill="#1C75BC">E</text>
|
||||||
|
<text x="1040" y="510" fill="#1C75BC">D</text>
|
||||||
|
<text x="1115" y="510" fill="#1C75BC">A</text>
|
||||||
|
<text x="1190" y="510" fill="#1C75BC">L</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
<svg width="700" height="900" viewBox="0 0 700 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- OP PEDAL Square Stacked Logo -->
|
||||||
|
|
||||||
|
<!-- Blue circle icon (from OP Labs) -->
|
||||||
|
<path d="M350 460C440.5 460 514 386.5 514 296C514 205.5 440.5 132 350 132C259.5 132 186 205.5 186 296C186 386.5 259.5 460 350 460Z" fill="#1C75BC"/>
|
||||||
|
<!-- Silver swoosh -->
|
||||||
|
<path d="M555 53L266 233.5C264.3 234.6 262.9 236.1 262 237.9C261.1 239.7 260.7 241.7 260.9 243.7C261.1 245.7 261.8 247.6 262.9 249.2C264 250.8 265.5 252 267.3 252.7L287 261.5C288.4 262.1 289.6 263.1 290.6 264.3C291.6 265.5 292.3 266.9 292.7 268.4C293.1 269.9 293.1 271.5 292.7 273C292.3 274.5 291.6 275.9 290.5 277L148 458C141 467 151 478 159 472L413 289.5C414.7 288.4 416.1 286.9 417 285.1C417.9 283.3 418.3 281.3 418.1 279.3C417.9 277.3 417.2 275.5 416.1 273.9C415 272.3 413.5 271.1 411.8 270.5L397 265.5C395.4 264.9 394 264 392.9 262.8C391.8 261.6 391 260.1 390.5 258.5C390 256.9 390 255.2 390.4 253.6C390.8 252 391.5 250.5 392.5 249.3L527 63C534.5 55 524 46 516 53Z" fill="#D1D3D4"/>
|
||||||
|
|
||||||
|
<!-- OP LABS - first line -->
|
||||||
|
<g font-family="sans-serif" font-size="90" font-weight="bold">
|
||||||
|
<text x="350" y="600" fill="#D1D3D4" text-anchor="middle">OP LABS</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- OP PEDAL - second line, each letter individually positioned -->
|
||||||
|
<g font-family="sans-serif" font-size="72" font-weight="bold">
|
||||||
|
<!-- O -->
|
||||||
|
<text x="185" y="700" fill="#D1D3D4">O</text>
|
||||||
|
<!-- P -->
|
||||||
|
<text x="250" y="700" fill="#D1D3D4">P</text>
|
||||||
|
|
||||||
|
<!-- P -->
|
||||||
|
<text x="353" y="700" fill="#1C75BC">P</text>
|
||||||
|
<!-- E -->
|
||||||
|
<text x="418" y="700" fill="#1C75BC">E</text>
|
||||||
|
<!-- D -->
|
||||||
|
<text x="483" y="700" fill="#1C75BC">D</text>
|
||||||
|
<!-- A -->
|
||||||
|
<text x="548" y="700" fill="#1C75BC">A</text>
|
||||||
|
<!-- L -->
|
||||||
|
<text x="613" y="700" fill="#1C75BC">L</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,10 +1,10 @@
|
|||||||
Depends: dnsmasq(>= 2.85),lv2-dev(>=1.14), iw
|
Depends: dnsmasq(>= 2.85),lv2-dev(>=1.14), iw
|
||||||
Description: IoT guitar effect for Raspberry Pi
|
Description: IoT guitar effect for Raspberry Pi
|
||||||
IoT guitar effect pedal for Raspberry Pi, with phone-friendly web interface.
|
IoT guitar effect pedal for Raspberry Pi, with phone-friendly web interface.
|
||||||
Homepage: https://github.com/rerdavies/pipedal
|
Homepage: https://ourpad.casa/op-pedal
|
||||||
Maintainer: Robin E. R. Davies<rerdavies@gmail.com>
|
Maintainer: Robin E. R. Davies<rerdavies@gmail.com>
|
||||||
Source: pipedal
|
Source: oppedal
|
||||||
Package: pipedal
|
Package: oppedal
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Section: sound
|
Section: sound
|
||||||
Version: 1.2.32
|
Version: 1.2.32
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# copy files to installation directories
|
# copy files to installation directories
|
||||||
cmake --install build --prefix /usr --config Release -v
|
cmake --install build --prefix /usr --config Release -v
|
||||||
# Done as an install action by the debian package.
|
# Done as an install action by the debian package.
|
||||||
sudo /usr/bin/pipedalconfig --install
|
sudo /usr/bin/oppedalconfig --install
|
||||||
# copy pipedalPluginProfile as well.
|
# copy oppedalProfilePlugin as well.
|
||||||
sudo cp build/src/pipedalProfilePlugin /usr/bin/pipedalProfilePlugin
|
sudo cp build/src/oppedalProfilePlugin /usr/bin/oppedalProfilePlugin
|
||||||
chmod +X /usr/bin/pipedalProfilePlugin
|
chmod +X /usr/bin/oppedalProfilePlugin
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
build/src/pipedald "/etc/pipedal/config" "/etc/pipedal/react" "-port" "0.0.0.0:8080"
|
build/src/oppedald "/etc/oppedal/config" "/etc/oppedal/react" "-port" "0.0.0.0:8080"
|
||||||
|
|||||||
@@ -411,6 +411,16 @@ namespace pipedal
|
|||||||
return this->sampleRate;
|
return this->sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() const override
|
||||||
|
{
|
||||||
|
return (uint32_t)this->captureChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() const override
|
||||||
|
{
|
||||||
|
return (uint32_t)this->playbackChannels;
|
||||||
|
}
|
||||||
|
|
||||||
JackServerSettings jackServerSettings;
|
JackServerSettings jackServerSettings;
|
||||||
|
|
||||||
std::string alsa_device_name;
|
std::string alsa_device_name;
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ namespace pipedal {
|
|||||||
|
|
||||||
virtual uint32_t GetSampleRate() = 0;
|
virtual uint32_t GetSampleRate() = 0;
|
||||||
|
|
||||||
|
/// Get the number of capture (input) channels the device actually provides.
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() const = 0;
|
||||||
|
|
||||||
|
/// Get the number of playback (output) channels the device actually provides.
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() const = 0;
|
||||||
|
|
||||||
virtual size_t GetMidiInputEventCount() = 0;
|
virtual size_t GetMidiInputEventCount() = 0;
|
||||||
virtual MidiEvent*GetMidiEvents() = 0;
|
virtual MidiEvent*GetMidiEvents() = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,14 @@
|
|||||||
#include <lv2/atom/atom.h>
|
#include <lv2/atom/atom.h>
|
||||||
#include "SchedulerPriority.hpp"
|
#include "SchedulerPriority.hpp"
|
||||||
#include "AlsaSequencer.hpp"
|
#include "AlsaSequencer.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
|
|
||||||
#include "Lv2Log.hpp"
|
#include "Lv2Log.hpp"
|
||||||
|
|
||||||
#include "SchedulerPriority.hpp"
|
#include "SchedulerPriority.hpp"
|
||||||
#include "JackDriver.hpp"
|
#include "JackDriver.hpp"
|
||||||
#include "AlsaDriver.hpp"
|
#include "AlsaDriver.hpp"
|
||||||
|
#include "PipeWireDriver.hpp"
|
||||||
#include "DummyAudioDriver.hpp"
|
#include "DummyAudioDriver.hpp"
|
||||||
#include "AtomConverter.hpp"
|
#include "AtomConverter.hpp"
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -481,6 +483,7 @@ private:
|
|||||||
Uris uris;
|
Uris uris;
|
||||||
|
|
||||||
std::unique_ptr<AudioDriver> audioDriver;
|
std::unique_ptr<AudioDriver> audioDriver;
|
||||||
|
std::string driverType_;
|
||||||
|
|
||||||
std::recursive_mutex mutex;
|
std::recursive_mutex mutex;
|
||||||
int64_t overrunGracePeriodSamples = 0;
|
int64_t overrunGracePeriodSamples = 0;
|
||||||
@@ -535,6 +538,10 @@ private:
|
|||||||
std::vector<std::shared_ptr<Lv2Pedalboard>> activePedalboards; // pedalboards that have been sent to the audio queue.
|
std::vector<std::shared_ptr<Lv2Pedalboard>> activePedalboards; // pedalboards that have been sent to the audio queue.
|
||||||
Lv2Pedalboard *realtimeActivePedalboard = nullptr;
|
Lv2Pedalboard *realtimeActivePedalboard = nullptr;
|
||||||
|
|
||||||
|
// Band-in-a-Box Mixer Engine
|
||||||
|
std::shared_ptr<MixerEngine> currentMixerEngine;
|
||||||
|
MixerEngine *realtimeActiveMixerEngine = nullptr;
|
||||||
|
|
||||||
uint32_t sampleRate = 0;
|
uint32_t sampleRate = 0;
|
||||||
uint64_t currentSample = 0;
|
uint64_t currentSample = 0;
|
||||||
|
|
||||||
@@ -1083,6 +1090,11 @@ private:
|
|||||||
{
|
{
|
||||||
OnSnapshotTriggered(5);
|
OnSnapshotTriggered(5);
|
||||||
}
|
}
|
||||||
|
else if (this->realtimeActiveMixerEngine != nullptr && (event.buffer[0] & 0xF0) == 0xB0)
|
||||||
|
{
|
||||||
|
// Route MIDI CC to mixer engine's control surface mapper
|
||||||
|
this->realtimeActiveMixerEngine->processMidiEvent(event);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ProcessMidiMonitor(eventBufferWriter, iterator, event);
|
ProcessMidiMonitor(eventBufferWriter, iterator, event);
|
||||||
@@ -1197,6 +1209,24 @@ private:
|
|||||||
|
|
||||||
PIPEDAL_NON_INLINE void ProcessLv2Pedalboard(size_t nframes)
|
PIPEDAL_NON_INLINE void ProcessLv2Pedalboard(size_t nframes)
|
||||||
{
|
{
|
||||||
|
// Band-in-a-Box Mixer Engine takes priority over legacy pedalboard
|
||||||
|
MixerEngine *mixerEngine = this->realtimeActiveMixerEngine;
|
||||||
|
if (mixerEngine != nullptr)
|
||||||
|
{
|
||||||
|
// Route through mixer engine using device input/output buffers
|
||||||
|
auto &driver = this->audioDriver;
|
||||||
|
if (driver) {
|
||||||
|
mixerEngine->process(
|
||||||
|
driver->DeviceInputBuffers().data(),
|
||||||
|
(uint32_t)driver->DeviceInputBufferCount(),
|
||||||
|
driver->DeviceOutputBuffers().data(),
|
||||||
|
(uint32_t)driver->DeviceOutputBufferCount(),
|
||||||
|
(uint32_t)nframes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Lv2Pedalboard *pedalboard = nullptr;
|
Lv2Pedalboard *pedalboard = nullptr;
|
||||||
|
|
||||||
std::vector<float *> *pInputBuffers;
|
std::vector<float *> *pInputBuffers;
|
||||||
@@ -1395,7 +1425,7 @@ private:
|
|||||||
}
|
}
|
||||||
bool processed = false;
|
bool processed = false;
|
||||||
|
|
||||||
if (pedalboard != nullptr)
|
if (pedalboard != nullptr || this->realtimeActiveMixerEngine != nullptr)
|
||||||
{
|
{
|
||||||
ProcessGlobalMidiInput();
|
ProcessGlobalMidiInput();
|
||||||
}
|
}
|
||||||
@@ -1421,7 +1451,7 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
AudioHostImpl(IHost *pHost)
|
AudioHostImpl(IHost *pHost, const std::string &driverType)
|
||||||
: inputRingBuffer(RING_BUFFER_SIZE),
|
: inputRingBuffer(RING_BUFFER_SIZE),
|
||||||
outputRingBuffer(RING_BUFFER_SIZE),
|
outputRingBuffer(RING_BUFFER_SIZE),
|
||||||
realtimeReader(&this->inputRingBuffer),
|
realtimeReader(&this->inputRingBuffer),
|
||||||
@@ -1431,7 +1461,8 @@ public:
|
|||||||
eventBufferUrids(pHost),
|
eventBufferUrids(pHost),
|
||||||
pHost(pHost),
|
pHost(pHost),
|
||||||
uris(pHost),
|
uris(pHost),
|
||||||
atomConverter(pHost->GetMapFeature())
|
atomConverter(pHost->GetMapFeature()),
|
||||||
|
driverType_(driverType)
|
||||||
{
|
{
|
||||||
realtimeAtomBuffer.resize(32 * 1024);
|
realtimeAtomBuffer.resize(32 * 1024);
|
||||||
lv2_atom_forge_init(&inputWriterForge, pHost->GetMapFeature().GetMap());
|
lv2_atom_forge_init(&inputWriterForge, pHost->GetMapFeature().GetMap());
|
||||||
@@ -1470,6 +1501,25 @@ public:
|
|||||||
{
|
{
|
||||||
return this->sampleRate;
|
return this->sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() override
|
||||||
|
{
|
||||||
|
if (this->audioDriver)
|
||||||
|
{
|
||||||
|
return this->audioDriver->GetDeviceCaptureChannels();
|
||||||
|
}
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() override
|
||||||
|
{
|
||||||
|
if (this->audioDriver)
|
||||||
|
{
|
||||||
|
return this->audioDriver->GetDevicePlaybackChannels();
|
||||||
|
}
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
void HandleAlsaSequencerDevicesChanged(
|
void HandleAlsaSequencerDevicesChanged(
|
||||||
AlsaSequencerDeviceMonitor::MonitorAction action, int client, const std::string &clientName)
|
AlsaSequencerDeviceMonitor::MonitorAction action, int client, const std::string &clientName)
|
||||||
{
|
{
|
||||||
@@ -1858,7 +1908,16 @@ public:
|
|||||||
isOpen = true;
|
isOpen = true;
|
||||||
|
|
||||||
this->isDummyAudioDriver = jackServerSettings.IsDummyAudioDevice();
|
this->isDummyAudioDriver = jackServerSettings.IsDummyAudioDevice();
|
||||||
|
|
||||||
|
if (driverType_ == "pipewire")
|
||||||
|
{
|
||||||
|
this->audioDriver = std::unique_ptr<AudioDriver>(CreatePipeWireDriver(this));
|
||||||
|
Lv2Log::info("Using PipeWire audio driver.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
this->audioDriver = std::unique_ptr<AudioDriver>(CreateAlsaDriver(this));
|
this->audioDriver = std::unique_ptr<AudioDriver>(CreateAlsaDriver(this));
|
||||||
|
}
|
||||||
|
|
||||||
this->currentSample = 0;
|
this->currentSample = 0;
|
||||||
this->underruns = 0;
|
this->underruns = 0;
|
||||||
@@ -1944,6 +2003,22 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual void SetMixerEngine(const std::shared_ptr<MixerEngine> &mixerEngine)
|
||||||
|
{
|
||||||
|
std::lock_guard guard(mutex);
|
||||||
|
this->currentMixerEngine = mixerEngine;
|
||||||
|
if (active && mixerEngine)
|
||||||
|
{
|
||||||
|
// Activate the mixer engine and set it as the realtime processing target.
|
||||||
|
// The mixer engine takes over from the legacy pedalboard.
|
||||||
|
this->realtimeActiveMixerEngine = mixerEngine.get();
|
||||||
|
}
|
||||||
|
else if (!mixerEngine)
|
||||||
|
{
|
||||||
|
this->realtimeActiveMixerEngine = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
virtual void SetBypass(uint64_t instanceId, bool enabled)
|
virtual void SetBypass(uint64_t instanceId, bool enabled)
|
||||||
{
|
{
|
||||||
std::lock_guard guard(mutex);
|
std::lock_guard guard(mutex);
|
||||||
@@ -2458,9 +2533,9 @@ void AudioHostImpl::SetSystemMidiBindings(const std::vector<MidiBinding> &bindin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioHost *AudioHost::CreateInstance(IHost *pHost)
|
AudioHost *AudioHost::CreateInstance(IHost *pHost, const std::string &driverType)
|
||||||
{
|
{
|
||||||
return new AudioHostImpl(pHost);
|
return new AudioHostImpl(pHost, driverType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed because any updates to state have to be sent to clients as well,
|
// Removed because any updates to state have to be sent to clients as well,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
namespace pipedal
|
namespace pipedal
|
||||||
{
|
{
|
||||||
|
|
||||||
|
class MixerEngine; // forward declaration for band-in-a-box mode
|
||||||
|
|
||||||
struct RealtimeMidiProgramRequest;
|
struct RealtimeMidiProgramRequest;
|
||||||
struct RealtimeNextMidiProgramRequest;
|
struct RealtimeNextMidiProgramRequest;
|
||||||
class PluginHost;
|
class PluginHost;
|
||||||
@@ -213,7 +215,7 @@ namespace pipedal
|
|||||||
AudioHost() {}
|
AudioHost() {}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
static AudioHost *CreateInstance(IHost *pHost);
|
static AudioHost *CreateInstance(IHost *pHost, const std::string &driverType = "alsa");
|
||||||
virtual ~AudioHost() {};
|
virtual ~AudioHost() {};
|
||||||
|
|
||||||
virtual void UpdateServerConfiguration(const JackServerSettings &jackServerSettings,
|
virtual void UpdateServerConfiguration(const JackServerSettings &jackServerSettings,
|
||||||
@@ -235,10 +237,20 @@ namespace pipedal
|
|||||||
virtual void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) = 0;
|
virtual void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) = 0;
|
||||||
virtual uint32_t GetSampleRate() = 0;
|
virtual uint32_t GetSampleRate() = 0;
|
||||||
|
|
||||||
|
/// Get the number of capture (input) channels the audio device provides.
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() = 0;
|
||||||
|
|
||||||
|
/// Get the number of playback (output) channels the audio device provides.
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() = 0;
|
||||||
|
|
||||||
virtual JackConfiguration GetServerConfiguration() = 0;
|
virtual JackConfiguration GetServerConfiguration() = 0;
|
||||||
|
|
||||||
virtual void SetPedalboard(const std::shared_ptr<Lv2Pedalboard> &pedalboard) = 0;
|
virtual void SetPedalboard(const std::shared_ptr<Lv2Pedalboard> &pedalboard) = 0;
|
||||||
|
|
||||||
|
/// Set the mixer engine for band-in-a-box mode.
|
||||||
|
/// When set, overrides the legacy pedalboard processing.
|
||||||
|
virtual void SetMixerEngine(const std::shared_ptr<MixerEngine> &mixerEngine) = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
virtual void SetControlValue(uint64_t instanceId, const std::string &symbol, float value) = 0;
|
virtual void SetControlValue(uint64_t instanceId, const std::string &symbol, float value) = 0;
|
||||||
|
|||||||
@@ -366,9 +366,18 @@ set (PIPEDAL_SOURCES
|
|||||||
JackDriver.cpp JackDriver.hpp
|
JackDriver.cpp JackDriver.hpp
|
||||||
AlsaDriver.cpp AlsaDriver.hpp
|
AlsaDriver.cpp AlsaDriver.hpp
|
||||||
DummyAudioDriver.cpp DummyAudioDriver.hpp
|
DummyAudioDriver.cpp DummyAudioDriver.hpp
|
||||||
|
PipeWireDriver.cpp PipeWireDriver.hpp
|
||||||
AudioDriver.hpp
|
AudioDriver.hpp
|
||||||
AudioConfig.hpp
|
AudioConfig.hpp
|
||||||
|
|
||||||
|
# Mixer Engine (Band-in-a-Box)
|
||||||
|
MixerChannelStrip.cpp MixerChannelStrip.hpp
|
||||||
|
MixerBus.cpp MixerBus.hpp
|
||||||
|
MixerEngine.cpp MixerEngine.hpp
|
||||||
|
MixerApi.cpp MixerApi.hpp
|
||||||
|
MidiMapper.cpp MidiMapper.hpp
|
||||||
|
MidiLearnMode.cpp MidiLearnMode.hpp
|
||||||
|
|
||||||
${VST3_SOURCES}
|
${VST3_SOURCES}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ namespace pipedal
|
|||||||
return this->sampleRate;
|
return this->sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() const override
|
||||||
|
{
|
||||||
|
return (uint32_t)deviceCaptureBuffers.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() const override
|
||||||
|
{
|
||||||
|
return (uint32_t)devicePlaybackBuffers.size();
|
||||||
|
}
|
||||||
|
|
||||||
JackServerSettings jackServerSettings;
|
JackServerSettings jackServerSettings;
|
||||||
AlsaSequencer::ptr alsaSequencer;
|
AlsaSequencer::ptr alsaSequencer;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MidiLearnMode.hpp"
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
MidiLearnMode::MidiLearnMode()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
MidiLearnMode::~MidiLearnMode()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiLearnMode::setEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
enabled_ = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
capturedMidiChannel_ = -1;
|
||||||
|
capturedCcNumber_ = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiLearnMode::setPendingTarget(MidiTargetType type, int64_t id)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
pendingTargetType_ = type;
|
||||||
|
pendingTargetId_ = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiLearnMode::getPendingTarget(MidiTargetType& outType, int64_t& outId) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
outType = pendingTargetType_;
|
||||||
|
outId = pendingTargetId_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiLearnMode::captureEvent(int midiChannel, int ccNumber)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (!enabled_) return;
|
||||||
|
capturedMidiChannel_ = midiChannel;
|
||||||
|
capturedCcNumber_ = ccNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiLearnMode::hasCapturedEvent() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
return capturedMidiChannel_ >= 0 && capturedCcNumber_ >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiLearnMode::getCapturedEvent(int& outMidiChannel, int& outCcNumber) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
if (capturedMidiChannel_ < 0 || capturedCcNumber_ < 0) return false;
|
||||||
|
outMidiChannel = capturedMidiChannel_;
|
||||||
|
outCcNumber = capturedCcNumber_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
MidiMappingEntry MidiLearnMode::buildMapping() const
|
||||||
|
{
|
||||||
|
MidiMappingEntry entry;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
entry.midiChannel = capturedMidiChannel_;
|
||||||
|
entry.ccNumber = capturedCcNumber_;
|
||||||
|
entry.targetType = pendingTargetType_;
|
||||||
|
entry.targetId = pendingTargetId_;
|
||||||
|
|
||||||
|
// Sensible default ranges based on target type
|
||||||
|
switch (entry.targetType) {
|
||||||
|
case MidiTargetType::ChannelVolume:
|
||||||
|
case MidiTargetType::BusVolume:
|
||||||
|
case MidiTargetType::MasterVolume:
|
||||||
|
entry.minValue = -96.0f;
|
||||||
|
entry.maxValue = 12.0f;
|
||||||
|
break;
|
||||||
|
case MidiTargetType::ChannelPan:
|
||||||
|
entry.minValue = -1.0f;
|
||||||
|
entry.maxValue = 1.0f;
|
||||||
|
break;
|
||||||
|
case MidiTargetType::ChannelMute:
|
||||||
|
case MidiTargetType::ChannelSolo:
|
||||||
|
case MidiTargetType::BusMute:
|
||||||
|
case MidiTargetType::MasterMute:
|
||||||
|
entry.minValue = 0.0f;
|
||||||
|
entry.maxValue = 1.0f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiLearnMode::clearCapturedEvent()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
capturedMidiChannel_ = -1;
|
||||||
|
capturedCcNumber_ = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiLearnMode::reset()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
enabled_ = false;
|
||||||
|
pendingTargetType_ = MidiTargetType::ChannelVolume;
|
||||||
|
pendingTargetId_ = 0;
|
||||||
|
capturedMidiChannel_ = -1;
|
||||||
|
capturedCcNumber_ = -1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
#include "MidiMapper.hpp"
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
/// MIDI Learn mode state machine.
|
||||||
|
///
|
||||||
|
/// Tracks the three-step learn workflow:
|
||||||
|
/// 1. User enables learn mode and touches a UI control (setPendingTarget)
|
||||||
|
/// 2. User moves a hardware fader — the CC event is captured (captureEvent)
|
||||||
|
/// 3. User confirms — a new MidiMappingEntry is created (commitMapping)
|
||||||
|
///
|
||||||
|
class MidiLearnMode {
|
||||||
|
public:
|
||||||
|
MidiLearnMode();
|
||||||
|
~MidiLearnMode();
|
||||||
|
|
||||||
|
/// Enable or disable learn mode.
|
||||||
|
void setEnabled(bool enabled);
|
||||||
|
bool isEnabled() const { return enabled_; }
|
||||||
|
|
||||||
|
/// Set the mixer parameter that should receive the next learned mapping.
|
||||||
|
/// Call this when the user touches a UI control while in learn mode.
|
||||||
|
void setPendingTarget(MidiTargetType type, int64_t id);
|
||||||
|
|
||||||
|
/// Get the current pending target.
|
||||||
|
bool getPendingTarget(MidiTargetType& outType, int64_t& outId) const;
|
||||||
|
|
||||||
|
/// Capture a MIDI CC event while in learn mode.
|
||||||
|
/// Call this from the RT audio thread when processEvent sees a CC.
|
||||||
|
void captureEvent(int midiChannel, int ccNumber);
|
||||||
|
|
||||||
|
/// Check if a CC event has been captured since learn mode was entered
|
||||||
|
/// or since the last clear().
|
||||||
|
bool hasCapturedEvent() const;
|
||||||
|
|
||||||
|
/// Get the last captured CC event info.
|
||||||
|
/// Returns true if an event was captured.
|
||||||
|
bool getCapturedEvent(int& outMidiChannel, int& outCcNumber) const;
|
||||||
|
|
||||||
|
/// Build a MidiMappingEntry from pending target + captured event.
|
||||||
|
/// Clears the captured event after building (avoids stale recomit).
|
||||||
|
MidiMappingEntry buildMapping() const;
|
||||||
|
|
||||||
|
/// Clear captured event without committing.
|
||||||
|
void clearCapturedEvent();
|
||||||
|
|
||||||
|
/// Reset all learn state.
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool enabled_ = false;
|
||||||
|
MidiTargetType pendingTargetType_ = MidiTargetType::ChannelVolume;
|
||||||
|
int64_t pendingTargetId_ = 0;
|
||||||
|
int capturedMidiChannel_ = -1;
|
||||||
|
int capturedCcNumber_ = -1;
|
||||||
|
mutable std::mutex mutex_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MidiMapper.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include "MidiEvent.hpp"
|
||||||
|
#include "json.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
// ─── Target type string conversion ──────────────────────────────────────────
|
||||||
|
|
||||||
|
MidiTargetType MidiMappingEntry::targetTypeFromString(const std::string& str)
|
||||||
|
{
|
||||||
|
if (str == "channelVolume") return MidiTargetType::ChannelVolume;
|
||||||
|
if (str == "channelPan") return MidiTargetType::ChannelPan;
|
||||||
|
if (str == "channelMute") return MidiTargetType::ChannelMute;
|
||||||
|
if (str == "channelSolo") return MidiTargetType::ChannelSolo;
|
||||||
|
if (str == "busVolume") return MidiTargetType::BusVolume;
|
||||||
|
if (str == "busMute") return MidiTargetType::BusMute;
|
||||||
|
if (str == "masterVolume") return MidiTargetType::MasterVolume;
|
||||||
|
if (str == "masterMute") return MidiTargetType::MasterMute;
|
||||||
|
return MidiTargetType::ChannelVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* MidiMappingEntry::targetTypeToString(MidiTargetType type)
|
||||||
|
{
|
||||||
|
switch (type) {
|
||||||
|
case MidiTargetType::ChannelVolume: return "channelVolume";
|
||||||
|
case MidiTargetType::ChannelPan: return "channelPan";
|
||||||
|
case MidiTargetType::ChannelMute: return "channelMute";
|
||||||
|
case MidiTargetType::ChannelSolo: return "channelSolo";
|
||||||
|
case MidiTargetType::BusVolume: return "busVolume";
|
||||||
|
case MidiTargetType::BusMute: return "busMute";
|
||||||
|
case MidiTargetType::MasterVolume: return "masterVolume";
|
||||||
|
case MidiTargetType::MasterMute: return "masterMute";
|
||||||
|
}
|
||||||
|
return "channelVolume";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MidiMapper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MidiMapper::MidiMapper()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
MidiMapper::~MidiMapper()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiMapper::processEvent(const MidiEvent& event)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return false;
|
||||||
|
|
||||||
|
// Only process MIDI CC messages (0xB0)
|
||||||
|
if (event.size < 3) return false;
|
||||||
|
uint8_t command = event.buffer[0] & 0xF0;
|
||||||
|
if (command != 0xB0) return false;
|
||||||
|
|
||||||
|
int midiChannel = static_cast<int>(event.buffer[0] & 0x0F);
|
||||||
|
int ccNumber = static_cast<int>(event.buffer[1]);
|
||||||
|
uint8_t ccValue = event.buffer[2];
|
||||||
|
|
||||||
|
// ── Learn mode: capture the CC event ──
|
||||||
|
if (learnMode_) {
|
||||||
|
std::lock_guard<std::mutex> lock(learnMutex_);
|
||||||
|
lastLearnedMidiChannel_ = midiChannel;
|
||||||
|
lastLearnedCcNumber_ = ccNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Snapshot the current mapping table ──
|
||||||
|
std::vector<MidiMappingEntry> mappingsSnapshot;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mappingsMutex_);
|
||||||
|
mappingsSnapshot = mappings_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply matching mappings ──
|
||||||
|
bool consumed = false;
|
||||||
|
for (const auto& entry : mappingsSnapshot) {
|
||||||
|
// Match MIDI channel (-1 = omni)
|
||||||
|
if (entry.midiChannel >= 0 && entry.midiChannel != midiChannel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.ccNumber != ccNumber) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
applyValue(entry, ccValue);
|
||||||
|
consumed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return consumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::applyValue(const MidiMappingEntry& entry, uint8_t ccValue)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
|
||||||
|
// Map CC 0-127 to parameter range
|
||||||
|
float range = entry.maxValue - entry.minValue;
|
||||||
|
float normalized = static_cast<float>(ccValue) / 127.0f;
|
||||||
|
float mappedValue = entry.minValue + normalized * range;
|
||||||
|
|
||||||
|
switch (entry.targetType) {
|
||||||
|
case MidiTargetType::ChannelVolume: {
|
||||||
|
auto* ch = mixerEngine_->getChannel(static_cast<int>(entry.targetId));
|
||||||
|
if (ch) ch->setVolume(mappedValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MidiTargetType::ChannelPan: {
|
||||||
|
auto* ch = mixerEngine_->getChannel(static_cast<int>(entry.targetId));
|
||||||
|
if (ch) ch->setPan(mappedValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MidiTargetType::ChannelMute: {
|
||||||
|
auto* ch = mixerEngine_->getChannel(static_cast<int>(entry.targetId));
|
||||||
|
if (ch) ch->setMute(mappedValue >= 0.5f);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MidiTargetType::ChannelSolo: {
|
||||||
|
auto* ch = mixerEngine_->getChannel(static_cast<int>(entry.targetId));
|
||||||
|
if (ch) ch->setSolo(mappedValue >= 0.5f);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MidiTargetType::BusVolume: {
|
||||||
|
auto* bus = mixerEngine_->getBus(entry.targetId);
|
||||||
|
if (bus) bus->setVolume(mappedValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MidiTargetType::BusMute: {
|
||||||
|
auto* bus = mixerEngine_->getBus(entry.targetId);
|
||||||
|
if (bus) bus->setMute(mappedValue >= 0.5f);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MidiTargetType::MasterVolume: {
|
||||||
|
auto* bus = mixerEngine_->masterBus();
|
||||||
|
if (bus) bus->setVolume(mappedValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MidiTargetType::MasterMute: {
|
||||||
|
auto* bus = mixerEngine_->masterBus();
|
||||||
|
if (bus) bus->setMute(mappedValue >= 0.5f);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mapping table management ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
void MidiMapper::setMappings(const std::vector<MidiMappingEntry>& mappings)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mappingsMutex_);
|
||||||
|
mappings_ = mappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::addMapping(const MidiMappingEntry& entry)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mappingsMutex_);
|
||||||
|
mappings_.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiMapper::removeMapping(int midiChannel, int ccNumber)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mappingsMutex_);
|
||||||
|
auto it = std::remove_if(mappings_.begin(), mappings_.end(),
|
||||||
|
[midiChannel, ccNumber](const MidiMappingEntry& e) {
|
||||||
|
return e.midiChannel == midiChannel && e.ccNumber == ccNumber;
|
||||||
|
});
|
||||||
|
bool removed = (it != mappings_.end());
|
||||||
|
mappings_.erase(it, mappings_.end());
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiMapper::removeMappingByIndex(size_t index)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mappingsMutex_);
|
||||||
|
if (index >= mappings_.size()) return false;
|
||||||
|
mappings_.erase(mappings_.begin() + static_cast<ptrdiff_t>(index));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::clearMappings()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mappingsMutex_);
|
||||||
|
mappings_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<MidiMappingEntry> MidiMapper::getMappings() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mappingsMutex_);
|
||||||
|
return mappings_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JSON serialization ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
std::string MidiMapper::getMappingsJson() const
|
||||||
|
{
|
||||||
|
auto mappings = getMappings();
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.start_array();
|
||||||
|
|
||||||
|
for (const auto& entry : mappings) {
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("midiChannel", (int64_t)entry.midiChannel);
|
||||||
|
writer.write_member("ccNumber", (int64_t)entry.ccNumber);
|
||||||
|
writer.write_member("targetType", MidiMappingEntry::targetTypeToString(entry.targetType));
|
||||||
|
writer.write_member("targetId", entry.targetId);
|
||||||
|
writer.write_member("minValue", (double)entry.minValue);
|
||||||
|
writer.write_member("maxValue", (double)entry.maxValue);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.end_array();
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::setMappingsFromJson(const std::string& json)
|
||||||
|
{
|
||||||
|
std::vector<MidiMappingEntry> entries;
|
||||||
|
std::stringstream ss(json);
|
||||||
|
json_reader reader(ss);
|
||||||
|
|
||||||
|
// Parse array: [ ... ]
|
||||||
|
reader.consume('[');
|
||||||
|
while (reader.peek() != ']') {
|
||||||
|
MidiMappingEntry entry;
|
||||||
|
std::string targetTypeStr;
|
||||||
|
|
||||||
|
// Parse object: { "key": value, ... }
|
||||||
|
reader.consume('{');
|
||||||
|
while (reader.peek() != '}') {
|
||||||
|
std::string key;
|
||||||
|
reader.read(&key);
|
||||||
|
reader.consume(':');
|
||||||
|
|
||||||
|
if (key == "midiChannel") {
|
||||||
|
int64_t v; reader.read(&v); entry.midiChannel = (int)v;
|
||||||
|
} else if (key == "ccNumber") {
|
||||||
|
int64_t v; reader.read(&v); entry.ccNumber = (int)v;
|
||||||
|
} else if (key == "targetType") {
|
||||||
|
reader.read(&targetTypeStr);
|
||||||
|
} else if (key == "targetId") {
|
||||||
|
reader.read(&entry.targetId);
|
||||||
|
} else if (key == "minValue") {
|
||||||
|
double v; reader.read(&v); entry.minValue = (float)v;
|
||||||
|
} else if (key == "maxValue") {
|
||||||
|
double v; reader.read(&v); entry.maxValue = (float)v;
|
||||||
|
} else {
|
||||||
|
reader.skip_property();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume comma separator
|
||||||
|
if (reader.peek() == ',') {
|
||||||
|
reader.consume(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.consume('}'); // end object
|
||||||
|
|
||||||
|
if (!targetTypeStr.empty()) {
|
||||||
|
entry.targetType = MidiMappingEntry::targetTypeFromString(targetTypeStr);
|
||||||
|
}
|
||||||
|
entries.push_back(entry);
|
||||||
|
|
||||||
|
// Consume comma separator between array elements
|
||||||
|
if (reader.peek() == ',') {
|
||||||
|
reader.consume(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.consume(']'); // end array
|
||||||
|
|
||||||
|
setMappings(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string MidiMapper::defaultConfigPath()
|
||||||
|
{
|
||||||
|
// Store alongside other pipedal config
|
||||||
|
const char* home = std::getenv("HOME");
|
||||||
|
if (home) {
|
||||||
|
return std::string(home) + "/.config/pipedal/midi_map.json";
|
||||||
|
}
|
||||||
|
return "/etc/pipedal/config/midi_map.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::loadFromFile()
|
||||||
|
{
|
||||||
|
std::string path = defaultConfigPath();
|
||||||
|
std::ifstream file(path);
|
||||||
|
if (!file.is_open()) return;
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
ss << file.rdbuf();
|
||||||
|
std::string content = ss.str();
|
||||||
|
if (!content.empty()) {
|
||||||
|
setMappingsFromJson(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::saveToFile() const
|
||||||
|
{
|
||||||
|
std::string path = defaultConfigPath();
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
std::filesystem::path dir = std::filesystem::path(path).parent_path();
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::create_directories(dir, ec);
|
||||||
|
|
||||||
|
std::string json = getMappingsJson();
|
||||||
|
std::ofstream file(path);
|
||||||
|
if (file.is_open()) {
|
||||||
|
file << json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Learn mode ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void MidiMapper::setLearnMode(bool enabled)
|
||||||
|
{
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(learnMutex_);
|
||||||
|
learnMode_ = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
// Clear last learned on exit
|
||||||
|
lastLearnedMidiChannel_ = -1;
|
||||||
|
lastLearnedCcNumber_ = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::setPendingLearnTarget(MidiTargetType type, int64_t id)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(learnMutex_);
|
||||||
|
pendingTargetType_ = type;
|
||||||
|
pendingTargetId_ = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiMapper::getLastLearnedEvent(int& outMidiChannel, int& outCcNumber) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(learnMutex_);
|
||||||
|
if (lastLearnedMidiChannel_ < 0 || lastLearnedCcNumber_ < 0) return false;
|
||||||
|
outMidiChannel = lastLearnedMidiChannel_;
|
||||||
|
outCcNumber = lastLearnedCcNumber_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::clearLastLearnedEvent()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(learnMutex_);
|
||||||
|
lastLearnedMidiChannel_ = -1;
|
||||||
|
lastLearnedCcNumber_ = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiMapper::commitLearnMapping()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(learnMutex_);
|
||||||
|
if (lastLearnedMidiChannel_ < 0 || lastLearnedCcNumber_ < 0) {
|
||||||
|
return false; // No CC event captured yet
|
||||||
|
}
|
||||||
|
|
||||||
|
MidiMappingEntry entry;
|
||||||
|
entry.midiChannel = lastLearnedMidiChannel_;
|
||||||
|
entry.ccNumber = lastLearnedCcNumber_;
|
||||||
|
entry.targetType = pendingTargetType_;
|
||||||
|
entry.targetId = pendingTargetId_;
|
||||||
|
|
||||||
|
// Set sensible defaults based on target type
|
||||||
|
switch (entry.targetType) {
|
||||||
|
case MidiTargetType::ChannelVolume:
|
||||||
|
case MidiTargetType::BusVolume:
|
||||||
|
case MidiTargetType::MasterVolume:
|
||||||
|
entry.minValue = -96.0f; // -inf dB
|
||||||
|
entry.maxValue = 12.0f; // +12 dB max
|
||||||
|
break;
|
||||||
|
case MidiTargetType::ChannelPan:
|
||||||
|
entry.minValue = -1.0f; // full left
|
||||||
|
entry.maxValue = 1.0f; // full right
|
||||||
|
break;
|
||||||
|
case MidiTargetType::ChannelMute:
|
||||||
|
case MidiTargetType::ChannelSolo:
|
||||||
|
case MidiTargetType::BusMute:
|
||||||
|
case MidiTargetType::MasterMute:
|
||||||
|
entry.minValue = 0.0f; // off
|
||||||
|
entry.maxValue = 1.0f; // on (threshold 0.5)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset learned event so we don't recomit the same one
|
||||||
|
lastLearnedMidiChannel_ = -1;
|
||||||
|
lastLearnedCcNumber_ = -1;
|
||||||
|
|
||||||
|
// Add to mapping table
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lockMap(mappingsMutex_);
|
||||||
|
mappings_.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToFile();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MidiMapper::getPendingLearnTarget(MidiTargetType& outType, int64_t& outId) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(learnMutex_);
|
||||||
|
outType = pendingTargetType_;
|
||||||
|
outId = pendingTargetId_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <mutex>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
class MixerEngine;
|
||||||
|
struct MidiEvent;
|
||||||
|
|
||||||
|
/// Types of mixer parameters that can be mapped from MIDI CC.
|
||||||
|
enum class MidiTargetType {
|
||||||
|
ChannelVolume, ///< Channel fader (-inf .. +12 dB)
|
||||||
|
ChannelPan, ///< Channel pan (-1 .. +1)
|
||||||
|
ChannelMute, ///< Channel mute toggle
|
||||||
|
ChannelSolo, ///< Channel solo toggle
|
||||||
|
BusVolume, ///< Bus fader (-inf .. +12 dB)
|
||||||
|
BusMute, ///< Bus mute toggle
|
||||||
|
MasterVolume, ///< Master bus volume
|
||||||
|
MasterMute, ///< Master bus mute
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A single mapping entry: MIDI CC# + channel → mixer parameter.
|
||||||
|
struct MidiMappingEntry {
|
||||||
|
int midiChannel = -1; ///< MIDI channel (-1 = omni / any)
|
||||||
|
int ccNumber = 0; ///< MIDI CC number (0-127)
|
||||||
|
MidiTargetType targetType = MidiTargetType::ChannelVolume;
|
||||||
|
int64_t targetId = 0; ///< channel index for Channel*, bus ID for Bus*
|
||||||
|
|
||||||
|
/// Output range: CC=0 maps to minValue, CC=127 maps to maxValue.
|
||||||
|
float minValue = 0.0f;
|
||||||
|
float maxValue = 1.0f;
|
||||||
|
|
||||||
|
/// Convert between enum and string (for JSON serialization).
|
||||||
|
static MidiTargetType targetTypeFromString(const std::string& str);
|
||||||
|
static const char* targetTypeToString(MidiTargetType type);
|
||||||
|
};
|
||||||
|
|
||||||
|
/// MIDI CC → mixer parameter mapper.
|
||||||
|
///
|
||||||
|
/// Receives MIDI CC events (0xB0) from the real-time audio thread and applies
|
||||||
|
/// them to the MixerEngine via atomic parameter setters.
|
||||||
|
///
|
||||||
|
/// The mapping table is configured from the non-real-time thread; a mutex
|
||||||
|
/// protects the table while the RT path snapshots the current mapping set.
|
||||||
|
class MidiMapper {
|
||||||
|
public:
|
||||||
|
MidiMapper();
|
||||||
|
~MidiMapper();
|
||||||
|
|
||||||
|
/// Set the mixer engine to control. Must be set before processing events.
|
||||||
|
void setMixerEngine(MixerEngine* engine) { mixerEngine_ = engine; }
|
||||||
|
|
||||||
|
/// Process a MIDI event. Returns true if a mapping consumed the event.
|
||||||
|
/// RT-safe: uses atomic mixer setters directly.
|
||||||
|
bool processEvent(const MidiEvent& event);
|
||||||
|
|
||||||
|
// --- Mapping table management (non-RT thread) ---
|
||||||
|
|
||||||
|
/// Replace the entire mapping table.
|
||||||
|
void setMappings(const std::vector<MidiMappingEntry>& mappings);
|
||||||
|
|
||||||
|
/// Add a single mapping entry.
|
||||||
|
void addMapping(const MidiMappingEntry& entry);
|
||||||
|
|
||||||
|
/// Remove all mappings matching the given MIDI channel and CC number.
|
||||||
|
bool removeMapping(int midiChannel, int ccNumber);
|
||||||
|
|
||||||
|
/// Remove a specific mapping entry by index.
|
||||||
|
bool removeMappingByIndex(size_t index);
|
||||||
|
|
||||||
|
/// Clear all mappings.
|
||||||
|
void clearMappings();
|
||||||
|
|
||||||
|
/// Get a copy of the current mapping table.
|
||||||
|
std::vector<MidiMappingEntry> getMappings() const;
|
||||||
|
|
||||||
|
// --- Persistence ---
|
||||||
|
|
||||||
|
/// Serialize mappings to JSON string.
|
||||||
|
std::string getMappingsJson() const;
|
||||||
|
|
||||||
|
/// Deserialize mappings from JSON string.
|
||||||
|
void setMappingsFromJson(const std::string& json);
|
||||||
|
|
||||||
|
/// Default config file path.
|
||||||
|
static std::string defaultConfigPath();
|
||||||
|
|
||||||
|
/// Load mappings from default config file.
|
||||||
|
void loadFromFile();
|
||||||
|
|
||||||
|
/// Save mappings to default config file.
|
||||||
|
void saveToFile() const;
|
||||||
|
|
||||||
|
// --- Learn mode ---
|
||||||
|
|
||||||
|
/// Enable/disable MIDI learn mode.
|
||||||
|
/// When enabled, each incoming CC event is captured and cached
|
||||||
|
/// so the next call to commitLearnMapping() will create a mapping.
|
||||||
|
void setLearnMode(bool enabled);
|
||||||
|
|
||||||
|
/// True if learn mode is active.
|
||||||
|
bool learnMode() const { return learnMode_; }
|
||||||
|
|
||||||
|
/// Set the target parameter for the next learned mapping.
|
||||||
|
/// Call this when the user touches a UI control.
|
||||||
|
void setPendingLearnTarget(MidiTargetType type, int64_t id);
|
||||||
|
|
||||||
|
/// Get the last-learned MIDI event info.
|
||||||
|
/// Returns true if a CC event was captured since learn mode was enabled
|
||||||
|
/// or since the last clearLastLearnedEvent().
|
||||||
|
bool getLastLearnedEvent(int& outMidiChannel, int& outCcNumber) const;
|
||||||
|
|
||||||
|
/// Commit the current pending learn target + last CC into a mapping entry.
|
||||||
|
/// Returns the new entry, or nullopt if no CC was captured.
|
||||||
|
bool commitLearnMapping();
|
||||||
|
|
||||||
|
/// Clear cached last-learned CC event.
|
||||||
|
void clearLastLearnedEvent();
|
||||||
|
|
||||||
|
/// Get pending learn target info.
|
||||||
|
bool getPendingLearnTarget(MidiTargetType& outType, int64_t& outId) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
MixerEngine* mixerEngine_ = nullptr;
|
||||||
|
|
||||||
|
// Mapping table — mutable for snapshot-copy in RT path
|
||||||
|
std::vector<MidiMappingEntry> mappings_;
|
||||||
|
mutable std::mutex mappingsMutex_;
|
||||||
|
|
||||||
|
// Learn mode state
|
||||||
|
bool learnMode_ = false;
|
||||||
|
MidiTargetType pendingTargetType_ = MidiTargetType::ChannelVolume;
|
||||||
|
int64_t pendingTargetId_ = 0;
|
||||||
|
int lastLearnedMidiChannel_ = -1;
|
||||||
|
int lastLearnedCcNumber_ = -1;
|
||||||
|
mutable std::mutex learnMutex_;
|
||||||
|
|
||||||
|
// Apply a single mapping entry value to the mixer (RT-safe).
|
||||||
|
void applyValue(const MidiMappingEntry& entry, uint8_t ccValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MixerApi.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include "json.hpp"
|
||||||
|
#include "json_variant.hpp"
|
||||||
|
#include "Lv2Log.hpp"
|
||||||
|
#include <sstream>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scene storage types (JSON-serializable, mapped via JSON_MAP macros)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
/// A single saved scene.
|
||||||
|
class SceneEntry {
|
||||||
|
public:
|
||||||
|
int64_t id_;
|
||||||
|
std::string name_;
|
||||||
|
raw_json_string state_; // raw mixer state JSON, stored unescaped
|
||||||
|
|
||||||
|
DECLARE_JSON_MAP(SceneEntry);
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Top-level scenes file structure.
|
||||||
|
class ScenesFile {
|
||||||
|
public:
|
||||||
|
int64_t nextId_ = 1;
|
||||||
|
std::vector<SceneEntry> scenes_;
|
||||||
|
|
||||||
|
DECLARE_JSON_MAP(ScenesFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
JSON_MAP_BEGIN(SceneEntry)
|
||||||
|
JSON_MAP_REFERENCE(SceneEntry, id)
|
||||||
|
JSON_MAP_REFERENCE(SceneEntry, name)
|
||||||
|
JSON_MAP_REFERENCE(SceneEntry, state)
|
||||||
|
JSON_MAP_END();
|
||||||
|
|
||||||
|
JSON_MAP_BEGIN(ScenesFile)
|
||||||
|
JSON_MAP_REFERENCE(ScenesFile, nextId)
|
||||||
|
JSON_MAP_REFERENCE(ScenesFile, scenes)
|
||||||
|
JSON_MAP_END();
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolve ~/op-pedal/default_config/ to an absolute path.
|
||||||
|
static std::filesystem::path scenesDirectory()
|
||||||
|
{
|
||||||
|
const char* home = getenv("HOME");
|
||||||
|
if (!home) home = "/home/oplabs";
|
||||||
|
return std::filesystem::path(home) / "op-pedal" / "default_config";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full path to scenes.json.
|
||||||
|
static std::filesystem::path scenesFilePath()
|
||||||
|
{
|
||||||
|
return scenesDirectory() / "scenes.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load scenes from disk. Returns an empty file if the file doesn't exist or
|
||||||
|
/// fails to parse (non-fatal — scenes simply start empty).
|
||||||
|
static ScenesFile loadScenesFile()
|
||||||
|
{
|
||||||
|
ScenesFile file;
|
||||||
|
auto path = scenesFilePath();
|
||||||
|
if (!std::filesystem::exists(path))
|
||||||
|
return file;
|
||||||
|
|
||||||
|
try {
|
||||||
|
std::ifstream s(path);
|
||||||
|
if (!s.is_open())
|
||||||
|
return file;
|
||||||
|
json_reader reader(s);
|
||||||
|
reader.read(&file);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Lv2Log::warning("Failed to load %s: %s", path.c_str(), e.what());
|
||||||
|
// Return empty file on parse failure
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save scenes to disk.
|
||||||
|
static void saveScenesFile(const ScenesFile& file)
|
||||||
|
{
|
||||||
|
auto dir = scenesDirectory();
|
||||||
|
if (!std::filesystem::exists(dir))
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
|
||||||
|
auto path = scenesFilePath();
|
||||||
|
std::ofstream s(path);
|
||||||
|
if (!s.is_open())
|
||||||
|
{
|
||||||
|
Lv2Log::error("Failed to write %s", path.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json_writer writer(s, false);
|
||||||
|
writer.write(file);
|
||||||
|
s.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MixerApi implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MixerApi::MixerApi()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerApi::~MixerApi()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelVolume(int channelIndex, float volumeDb)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setVolume(volumeDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelPan(int channelIndex, float pan)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setPan(pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelMute(int channelIndex, bool mute)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setMute(mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelSolo(int channelIndex, bool solo)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setSolo(solo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelLabel(int channelIndex, const std::string& label)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) channel->setLabel(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelType(int channelIndex, const std::string& type)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
|
||||||
|
if (!channel) return;
|
||||||
|
if (type == "Instrument" || type == "instrument") {
|
||||||
|
channel->setChannelType(MixerChannelType::Instrument);
|
||||||
|
} else if (type == "Mic" || type == "mic") {
|
||||||
|
channel->setChannelType(MixerChannelType::Mic);
|
||||||
|
} else if (type == "Line" || type == "line") {
|
||||||
|
channel->setChannelType(MixerChannelType::Line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setChannelHpf(int channelIndex, bool enabled, float frequency)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* channel = mixerEngine_->getChannel(channelIndex);
|
||||||
|
if (channel) {
|
||||||
|
channel->setHpEnabled(enabled);
|
||||||
|
if (frequency > 0) channel->setHpFrequency(frequency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int MixerApi::addChannel(int physicalInputIndex)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return -1;
|
||||||
|
auto* channel = mixerEngine_->addChannel(physicalInputIndex);
|
||||||
|
return channel ? channel->channelIndex() : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeChannel(int channelIndex)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->removeChannel(channelIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setBusVolume(int64_t busId, float volumeDb)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* bus = mixerEngine_->getBus(busId);
|
||||||
|
if (bus) bus->setVolume(volumeDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setBusMute(int64_t busId, bool mute)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
auto* bus = mixerEngine_->getBus(busId);
|
||||||
|
if (bus) bus->setMute(mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t MixerApi::addBus(const std::string& type, const std::string& name, int channels)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return -1;
|
||||||
|
|
||||||
|
MixerBusType busType = MixerBusType::Subgroup;
|
||||||
|
if (type == "Master" || type == "master") busType = MixerBusType::Master;
|
||||||
|
else if (type == "Aux" || type == "aux") busType = MixerBusType::Aux;
|
||||||
|
else if (type == "FxReturn" || type == "fxreturn") busType = MixerBusType::FxReturn;
|
||||||
|
else if (type == "Subgroup" || type == "subgroup") busType = MixerBusType::Subgroup;
|
||||||
|
|
||||||
|
return mixerEngine_->addBus(busType, name, channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeBus(int64_t busId)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->removeBus(busId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::routeChannelToBus(int channelIndex, int64_t busId, float levelDb)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->routeChannelToBus(channelIndex, busId, levelDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::routeBusToBus(int64_t sourceBusId, int64_t targetBusId, float levelDb)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->routeBusToBus(sourceBusId, targetBusId, levelDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeRoute(int64_t sourceId, int64_t targetBusId)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->removeRoute(sourceId, targetBusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string MixerApi::getStateJson() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return "{}";
|
||||||
|
|
||||||
|
auto snapshot = mixerEngine_->captureSnapshot();
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.start_object();
|
||||||
|
|
||||||
|
writer.write_member("channels", "");
|
||||||
|
// Overwrite the empty string with raw array
|
||||||
|
writer.write_raw("[");
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& cs : snapshot.channels) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("channelIndex", (int64_t)cs.channelIndex);
|
||||||
|
writer.write_member("volume", (double)cs.volume);
|
||||||
|
writer.write_member("pan", (double)cs.pan);
|
||||||
|
writer.write_member("mute", cs.mute);
|
||||||
|
writer.write_member("solo", cs.solo);
|
||||||
|
|
||||||
|
const char* typeStr = "Instrument";
|
||||||
|
switch (cs.channelType) {
|
||||||
|
case MixerChannelType::Mic: typeStr = "Mic"; break;
|
||||||
|
case MixerChannelType::Line: typeStr = "Line"; break;
|
||||||
|
case MixerChannelType::AuxReturn: typeStr = "AuxReturn"; break;
|
||||||
|
default: typeStr = "Instrument"; break;
|
||||||
|
}
|
||||||
|
writer.write_member("type", typeStr);
|
||||||
|
writer.write_member("label", cs.label);
|
||||||
|
writer.write_member("hpEnabled", cs.hpEnabled);
|
||||||
|
writer.write_member("hpFrequency", (double)cs.hpFrequency);
|
||||||
|
writer.write_member("vuLeft", (double)cs.vuLeft);
|
||||||
|
writer.write_member("vuRight", (double)cs.vuRight);
|
||||||
|
writer.write_member("gateOpen", cs.gateOpen);
|
||||||
|
writer.write_member("compressorReduction", (double)cs.compressorReduction);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
|
||||||
|
writer.write_member("buses", "");
|
||||||
|
writer.write_raw("[");
|
||||||
|
first = true;
|
||||||
|
for (const auto& bs : snapshot.buses) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("id", bs.id);
|
||||||
|
writer.write_member("name", bs.name);
|
||||||
|
|
||||||
|
const char* typeStr = "Subgroup";
|
||||||
|
switch (bs.type) {
|
||||||
|
case MixerBusType::Master: typeStr = "Master"; break;
|
||||||
|
case MixerBusType::Aux: typeStr = "Aux"; break;
|
||||||
|
case MixerBusType::FxReturn: typeStr = "FxReturn"; break;
|
||||||
|
default: typeStr = "Subgroup"; break;
|
||||||
|
}
|
||||||
|
writer.write_member("type", typeStr);
|
||||||
|
writer.write_member("volume", (double)bs.volume);
|
||||||
|
writer.write_member("mute", bs.mute);
|
||||||
|
writer.write_member("vuLeft", (double)bs.vuLeft);
|
||||||
|
writer.write_member("vuRight", (double)bs.vuRight);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
|
||||||
|
writer.write_member("routes", "");
|
||||||
|
writer.write_raw("[");
|
||||||
|
first = true;
|
||||||
|
for (const auto& route : snapshot.routes) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("sourceId", route.sourceId);
|
||||||
|
writer.write_member("targetBusId", route.targetBusId);
|
||||||
|
writer.write_member("level", (double)route.level);
|
||||||
|
|
||||||
|
const char* sourceType = (route.sourceType == MixerRouteEntry::SourceChannel) ? "channel" : "bus";
|
||||||
|
writer.write_member("sourceType", sourceType);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
|
||||||
|
// Output routing
|
||||||
|
writer.write_member("outputRoutes", "");
|
||||||
|
writer.write_raw("[");
|
||||||
|
first = true;
|
||||||
|
for (const auto& route : mixerEngine_->outputRoutes()) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("sourceBusId", route.sourceBusId);
|
||||||
|
writer.write_member("sourceStartChannel", (int64_t)route.sourceStartChannel);
|
||||||
|
writer.write_member("targetStartChannel", (int64_t)route.targetStartChannel);
|
||||||
|
writer.write_member("channels", (int64_t)route.channels);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
|
||||||
|
// Physical I/O info
|
||||||
|
writer.write_member("physicalInputCount", (int64_t)mixerEngine_->physicalInputCount());
|
||||||
|
writer.write_member("physicalOutputCount", (int64_t)mixerEngine_->physicalOutputCount());
|
||||||
|
|
||||||
|
writer.end_object();
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scene management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
std::string MixerApi::saveScene(const std::string& name)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) {
|
||||||
|
return "{\"error\":\"no mixer engine\"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture current mixer state
|
||||||
|
std::string stateJson = getStateJson();
|
||||||
|
|
||||||
|
// Load existing scenes
|
||||||
|
ScenesFile file = loadScenesFile();
|
||||||
|
|
||||||
|
// Create new scene
|
||||||
|
SceneEntry entry;
|
||||||
|
entry.id_ = file.nextId_++;
|
||||||
|
entry.name_ = name;
|
||||||
|
entry.state_ = raw_json_string(stateJson);
|
||||||
|
|
||||||
|
file.scenes_.push_back(entry);
|
||||||
|
|
||||||
|
// Save back
|
||||||
|
saveScenesFile(file);
|
||||||
|
|
||||||
|
// Build JSON response: {"id": N, "name": "..."}
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("id", entry.id_);
|
||||||
|
writer.write_member("name", entry.name_);
|
||||||
|
writer.end_object();
|
||||||
|
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MixerApi::loadScene(const std::string& sceneId)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return false;
|
||||||
|
|
||||||
|
// Try parsing as integer
|
||||||
|
int64_t targetId;
|
||||||
|
try {
|
||||||
|
targetId = std::stoll(sceneId);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load scenes and find matching ID
|
||||||
|
ScenesFile file = loadScenesFile();
|
||||||
|
auto it = std::find_if(file.scenes_.begin(), file.scenes_.end(),
|
||||||
|
[targetId](const SceneEntry& e) { return e.id_ == targetId; });
|
||||||
|
|
||||||
|
if (it == file.scenes_.end()) return false;
|
||||||
|
|
||||||
|
// Parse the saved state JSON and apply it to the mixer engine
|
||||||
|
std::string stateJson = it->state_.as_string();
|
||||||
|
if (stateJson.empty()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse the saved state JSON and apply it to the mixer engine
|
||||||
|
std::istringstream ss(stateJson);
|
||||||
|
json_reader reader(ss);
|
||||||
|
|
||||||
|
// Manual JSON walk: {"channels": [...], "buses": [...], "routes": [...]}
|
||||||
|
reader.start_object(); // {
|
||||||
|
while (reader.peek() != '}')
|
||||||
|
{
|
||||||
|
std::string memberName = reader.read_string();
|
||||||
|
reader.consume(':');
|
||||||
|
|
||||||
|
if (memberName == "channels")
|
||||||
|
{
|
||||||
|
reader.consume('['); // start array
|
||||||
|
while (reader.peek() != ']')
|
||||||
|
{
|
||||||
|
int64_t channelIndex = 0;
|
||||||
|
double volume = 0.0;
|
||||||
|
double pan = 0.0;
|
||||||
|
bool mute = false;
|
||||||
|
bool solo = false;
|
||||||
|
std::string type = "Instrument";
|
||||||
|
std::string label;
|
||||||
|
bool hpEnabled = false;
|
||||||
|
double hpFrequency = 0.0;
|
||||||
|
|
||||||
|
reader.start_object(); // {
|
||||||
|
while (reader.peek() != '}')
|
||||||
|
{
|
||||||
|
std::string fieldName = reader.read_string();
|
||||||
|
reader.consume(':');
|
||||||
|
|
||||||
|
if (fieldName == "channelIndex") reader.read(&channelIndex);
|
||||||
|
else if (fieldName == "volume") reader.read(&volume);
|
||||||
|
else if (fieldName == "pan") reader.read(&pan);
|
||||||
|
else if (fieldName == "mute") reader.read(&mute);
|
||||||
|
else if (fieldName == "solo") reader.read(&solo);
|
||||||
|
else if (fieldName == "type") reader.read(&type);
|
||||||
|
else if (fieldName == "label") reader.read(&label);
|
||||||
|
else if (fieldName == "hpEnabled") reader.read(&hpEnabled);
|
||||||
|
else if (fieldName == "hpFrequency") reader.read(&hpFrequency);
|
||||||
|
else reader.skip_property();
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.end_object(); // }
|
||||||
|
|
||||||
|
// Apply to matching channel
|
||||||
|
auto* channel = mixerEngine_->getChannel((int)channelIndex);
|
||||||
|
if (channel) {
|
||||||
|
channel->setVolume((float)volume);
|
||||||
|
channel->setPan((float)pan);
|
||||||
|
channel->setMute(mute);
|
||||||
|
channel->setSolo(solo);
|
||||||
|
|
||||||
|
MixerChannelType ct = MixerChannelType::Instrument;
|
||||||
|
if (type == "Mic" || type == "mic") ct = MixerChannelType::Mic;
|
||||||
|
else if (type == "Line" || type == "line") ct = MixerChannelType::Line;
|
||||||
|
else if (type == "AuxReturn" || type == "auxreturn") ct = MixerChannelType::AuxReturn;
|
||||||
|
channel->setChannelType(ct);
|
||||||
|
|
||||||
|
channel->setLabel(label);
|
||||||
|
channel->setHpEnabled(hpEnabled);
|
||||||
|
if (hpFrequency > 0) channel->setHpFrequency((float)hpFrequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.consume(']'); // end array
|
||||||
|
}
|
||||||
|
else if (memberName == "buses")
|
||||||
|
{
|
||||||
|
reader.consume('['); // start array
|
||||||
|
while (reader.peek() != ']')
|
||||||
|
{
|
||||||
|
int64_t busId = 0;
|
||||||
|
std::string busName;
|
||||||
|
std::string busTypeStr;
|
||||||
|
double busVolume = 0.0;
|
||||||
|
bool busMute = false;
|
||||||
|
|
||||||
|
reader.start_object(); // {
|
||||||
|
while (reader.peek() != '}')
|
||||||
|
{
|
||||||
|
std::string fieldName = reader.read_string();
|
||||||
|
reader.consume(':');
|
||||||
|
|
||||||
|
if (fieldName == "id") reader.read(&busId);
|
||||||
|
else if (fieldName == "name") reader.read(&busName);
|
||||||
|
else if (fieldName == "type") reader.read(&busTypeStr);
|
||||||
|
else if (fieldName == "volume") reader.read(&busVolume);
|
||||||
|
else if (fieldName == "mute") reader.read(&busMute);
|
||||||
|
else reader.skip_property();
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.end_object(); // }
|
||||||
|
|
||||||
|
auto* bus = mixerEngine_->getBus(busId);
|
||||||
|
if (bus) {
|
||||||
|
bus->setVolume((float)busVolume);
|
||||||
|
bus->setMute(busMute);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.consume(']'); // end array
|
||||||
|
}
|
||||||
|
else if (memberName == "routes")
|
||||||
|
{
|
||||||
|
// Skip routes — they are structural and shouldn't be overwritten
|
||||||
|
// on scene load for safety.
|
||||||
|
reader.skip_property();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reader.skip_property();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.end_object(); // }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Lv2Log::error("Failed to load scene %s: %s", sceneId.c_str(), e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string MixerApi::listScenes() const
|
||||||
|
{
|
||||||
|
ScenesFile file = loadScenesFile();
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("scenes", "");
|
||||||
|
writer.write_raw("[");
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& entry : file.scenes_) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("id", entry.id_);
|
||||||
|
writer.write_member("name", entry.name_);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
writer.end_object();
|
||||||
|
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MixerApi::deleteScene(const std::string& sceneId)
|
||||||
|
{
|
||||||
|
int64_t targetId;
|
||||||
|
try {
|
||||||
|
targetId = std::stoll(sceneId);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScenesFile file = loadScenesFile();
|
||||||
|
auto it = std::find_if(file.scenes_.begin(), file.scenes_.end(),
|
||||||
|
[targetId](const SceneEntry& e) { return e.id_ == targetId; });
|
||||||
|
|
||||||
|
if (it == file.scenes_.end()) return false;
|
||||||
|
|
||||||
|
file.scenes_.erase(it);
|
||||||
|
saveScenesFile(file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MIDI Control Surface Mapping ──────────────────────────────────────────
|
||||||
|
|
||||||
|
std::string MixerApi::getMidiMappingsJson() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return "[]";
|
||||||
|
return mixerEngine_->midiMapper().getMappingsJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setMidiMappingsFromJson(const std::string& json)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().setMappingsFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::addMidiMapping(int midiChannel, int ccNumber,
|
||||||
|
const std::string& targetType, int64_t targetId,
|
||||||
|
float minValue, float maxValue)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
MidiMappingEntry entry;
|
||||||
|
entry.midiChannel = midiChannel;
|
||||||
|
entry.ccNumber = ccNumber;
|
||||||
|
entry.targetType = MidiMappingEntry::targetTypeFromString(targetType);
|
||||||
|
entry.targetId = targetId;
|
||||||
|
entry.minValue = minValue;
|
||||||
|
entry.maxValue = maxValue;
|
||||||
|
mixerEngine_->midiMapper().addMapping(entry);
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeMidiMapping(int midiChannel, int ccNumber)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().removeMapping(midiChannel, ccNumber);
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::removeMidiMappingByIndex(size_t index)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().removeMappingByIndex(index);
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::clearMidiMappings()
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().clearMappings();
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setMidiLearnMode(bool enabled)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().setLearnMode(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MixerApi::getMidiLearnMode() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return false;
|
||||||
|
return mixerEngine_->midiMapper().learnMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setMidiLearnTarget(const std::string& targetType, int64_t targetId)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
MidiTargetType type = MidiMappingEntry::targetTypeFromString(targetType);
|
||||||
|
mixerEngine_->midiMapper().setPendingLearnTarget(type, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MixerApi::commitMidiLearnMapping()
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return false;
|
||||||
|
return mixerEngine_->midiMapper().commitLearnMapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerApi::LearnedEventInfo MixerApi::getLastLearnedMidiEvent() const
|
||||||
|
{
|
||||||
|
LearnedEventInfo info;
|
||||||
|
if (!mixerEngine_) return info;
|
||||||
|
info.hasEvent = mixerEngine_->midiMapper().getLastLearnedEvent(info.midiChannel, info.ccNumber);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::saveMidiMappingsToFile() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::loadMidiMappingsFromFile()
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
mixerEngine_->midiMapper().loadFromFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Output Routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
std::string MixerApi::getOutputRoutesJson() const
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return "[]";
|
||||||
|
|
||||||
|
const auto& routes = mixerEngine_->outputRoutes();
|
||||||
|
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss, false);
|
||||||
|
writer.write_raw("[");
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& route : routes) {
|
||||||
|
if (!first) writer.write_raw(",");
|
||||||
|
first = false;
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("sourceBusId", route.sourceBusId);
|
||||||
|
writer.write_member("sourceStartChannel", (int64_t)route.sourceStartChannel);
|
||||||
|
writer.write_member("targetStartChannel", (int64_t)route.targetStartChannel);
|
||||||
|
writer.write_member("channels", (int64_t)route.channels);
|
||||||
|
writer.end_object();
|
||||||
|
}
|
||||||
|
writer.write_raw("]");
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerApi::setOutputRoutesFromJson(const std::string& json)
|
||||||
|
{
|
||||||
|
if (!mixerEngine_) return;
|
||||||
|
|
||||||
|
std::vector<MixerEngine::MixerOutputRoute> routes;
|
||||||
|
|
||||||
|
std::istringstream ss(json);
|
||||||
|
json_reader reader(ss);
|
||||||
|
reader.consume('[');
|
||||||
|
while (reader.peek() != ']')
|
||||||
|
{
|
||||||
|
MixerEngine::MixerOutputRoute route{};
|
||||||
|
reader.start_object();
|
||||||
|
while (reader.peek() != '}')
|
||||||
|
{
|
||||||
|
std::string key = reader.read_string();
|
||||||
|
reader.consume(':');
|
||||||
|
if (key == "sourceBusId") reader.read(&route.sourceBusId);
|
||||||
|
else if (key == "sourceStartChannel") { int64_t v; reader.read(&v); route.sourceStartChannel = (int)v; }
|
||||||
|
else if (key == "targetStartChannel") { int64_t v; reader.read(&v); route.targetStartChannel = (int)v; }
|
||||||
|
else if (key == "channels") { int64_t v; reader.read(&v); route.channels = (int)v; }
|
||||||
|
else reader.skip_property();
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.end_object();
|
||||||
|
routes.push_back(route);
|
||||||
|
if (reader.peek() == ',') reader.consume(',');
|
||||||
|
}
|
||||||
|
reader.consume(']');
|
||||||
|
|
||||||
|
mixerEngine_->setOutputRoutes(routes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <memory>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
class MixerEngine;
|
||||||
|
|
||||||
|
/// Mixer API — bridges WebSocket messages to the MixerEngine.
|
||||||
|
///
|
||||||
|
/// Follows the existing PiPedalSocket pattern where handlers are registered
|
||||||
|
/// via REGISTER_MESSAGE_HANDLER and dispatched by message name.
|
||||||
|
///
|
||||||
|
/// This class provides the model-level methods that the socket handlers call.
|
||||||
|
class MixerApi {
|
||||||
|
public:
|
||||||
|
MixerApi();
|
||||||
|
~MixerApi();
|
||||||
|
|
||||||
|
/// Set the mixer engine this API talks to.
|
||||||
|
void setMixerEngine(MixerEngine* engine) { mixerEngine_ = engine; }
|
||||||
|
MixerEngine* mixerEngine() const { return mixerEngine_; }
|
||||||
|
|
||||||
|
/// --- Channel Control ---
|
||||||
|
|
||||||
|
/// Set channel volume in dB (-inf to +12)
|
||||||
|
void setChannelVolume(int channelIndex, float volumeDb);
|
||||||
|
|
||||||
|
/// Set channel pan (-1.0 left to +1.0 right)
|
||||||
|
void setChannelPan(int channelIndex, float pan);
|
||||||
|
|
||||||
|
/// Set channel mute state
|
||||||
|
void setChannelMute(int channelIndex, bool mute);
|
||||||
|
|
||||||
|
/// Set channel solo state
|
||||||
|
void setChannelSolo(int channelIndex, bool solo);
|
||||||
|
|
||||||
|
/// Set channel label
|
||||||
|
void setChannelLabel(int channelIndex, const std::string& label);
|
||||||
|
|
||||||
|
/// Set channel type (Instrument, Mic, Line)
|
||||||
|
void setChannelType(int channelIndex, const std::string& type);
|
||||||
|
|
||||||
|
/// Set channel HPF state
|
||||||
|
void setChannelHpf(int channelIndex, bool enabled, float frequency);
|
||||||
|
|
||||||
|
/// --- Channel Lifecycle ---
|
||||||
|
|
||||||
|
/// Add a new channel for the given physical input
|
||||||
|
int addChannel(int physicalInputIndex);
|
||||||
|
|
||||||
|
/// Remove a channel
|
||||||
|
void removeChannel(int channelIndex);
|
||||||
|
|
||||||
|
/// --- Bus Control ---
|
||||||
|
|
||||||
|
/// Set bus volume
|
||||||
|
void setBusVolume(int64_t busId, float volumeDb);
|
||||||
|
|
||||||
|
/// Set bus mute
|
||||||
|
void setBusMute(int64_t busId, bool mute);
|
||||||
|
|
||||||
|
/// Add a new bus
|
||||||
|
int64_t addBus(const std::string& type, const std::string& name, int channels);
|
||||||
|
|
||||||
|
/// Remove a bus
|
||||||
|
void removeBus(int64_t busId);
|
||||||
|
|
||||||
|
/// --- Routing ---
|
||||||
|
|
||||||
|
/// Route a channel to a bus
|
||||||
|
void routeChannelToBus(int channelIndex, int64_t busId, float levelDb);
|
||||||
|
|
||||||
|
/// Route a bus to another bus
|
||||||
|
void routeBusToBus(int64_t sourceBusId, int64_t targetBusId, float levelDb);
|
||||||
|
|
||||||
|
/// Remove a route
|
||||||
|
void removeRoute(int64_t sourceId, int64_t targetBusId);
|
||||||
|
|
||||||
|
/// --- State Queries ---
|
||||||
|
|
||||||
|
/// Get the full mixer state as a JSON string
|
||||||
|
std::string getStateJson() const;
|
||||||
|
|
||||||
|
/// --- Output Routing ---
|
||||||
|
|
||||||
|
/// Get output routes as a JSON array string
|
||||||
|
std::string getOutputRoutesJson() const;
|
||||||
|
|
||||||
|
/// Set output routes from a JSON array string
|
||||||
|
void setOutputRoutesFromJson(const std::string& json);
|
||||||
|
|
||||||
|
/// Apply a full mixer state from a JSON string
|
||||||
|
void setFullState(const std::string& stateJson);
|
||||||
|
|
||||||
|
/// --- Scenes ---
|
||||||
|
|
||||||
|
/// Save current mixer state as a scene
|
||||||
|
std::string saveScene(const std::string& name);
|
||||||
|
|
||||||
|
/// Load a scene by name
|
||||||
|
bool loadScene(const std::string& sceneId);
|
||||||
|
|
||||||
|
/// List available scenes
|
||||||
|
std::string listScenes() const;
|
||||||
|
|
||||||
|
/// Delete a scene
|
||||||
|
bool deleteScene(const std::string& sceneId);
|
||||||
|
|
||||||
|
/// --- MIDI Control Surface Mapping ---
|
||||||
|
|
||||||
|
/// Get all MIDI mappings as JSON
|
||||||
|
std::string getMidiMappingsJson() const;
|
||||||
|
|
||||||
|
/// Set all MIDI mappings from JSON
|
||||||
|
void setMidiMappingsFromJson(const std::string& json);
|
||||||
|
|
||||||
|
/// Add a single MIDI mapping
|
||||||
|
void addMidiMapping(int midiChannel, int ccNumber,
|
||||||
|
const std::string& targetType, int64_t targetId,
|
||||||
|
float minValue, float maxValue);
|
||||||
|
|
||||||
|
/// Remove MIDI mapping by CC and channel
|
||||||
|
void removeMidiMapping(int midiChannel, int ccNumber);
|
||||||
|
|
||||||
|
/// Remove MIDI mapping by index
|
||||||
|
void removeMidiMappingByIndex(size_t index);
|
||||||
|
|
||||||
|
/// Clear all MIDI mappings
|
||||||
|
void clearMidiMappings();
|
||||||
|
|
||||||
|
/// Toggle MIDI learn mode
|
||||||
|
void setMidiLearnMode(bool enabled);
|
||||||
|
bool getMidiLearnMode() const;
|
||||||
|
|
||||||
|
/// Set the pending learn target (which UI control was touched)
|
||||||
|
void setMidiLearnTarget(const std::string& targetType, int64_t targetId);
|
||||||
|
|
||||||
|
/// Commit a learned mapping (captured CC + pending target)
|
||||||
|
bool commitMidiLearnMapping();
|
||||||
|
|
||||||
|
/// Get last learned CC event info (for UI feedback)
|
||||||
|
struct LearnedEventInfo {
|
||||||
|
bool hasEvent = false;
|
||||||
|
int midiChannel = -1;
|
||||||
|
int ccNumber = -1;
|
||||||
|
};
|
||||||
|
LearnedEventInfo getLastLearnedMidiEvent() const;
|
||||||
|
|
||||||
|
/// Save MIDI mappings to config file
|
||||||
|
void saveMidiMappingsToFile() const;
|
||||||
|
|
||||||
|
/// Load MIDI mappings from config file
|
||||||
|
void loadMidiMappingsFromFile();
|
||||||
|
|
||||||
|
private:
|
||||||
|
MixerEngine* mixerEngine_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
MixerBus::MixerBus(int64_t id, MixerBusType type, const std::string& name, int channels)
|
||||||
|
: id_(id)
|
||||||
|
, type_(type)
|
||||||
|
, name_(name)
|
||||||
|
, channelCount_(channels)
|
||||||
|
{
|
||||||
|
buffers_.resize(channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::setVolume(float db)
|
||||||
|
{
|
||||||
|
volume_ = std::clamp(db, -96.0f, 12.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::setMute(bool mute)
|
||||||
|
{
|
||||||
|
mute_ = mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::allocateBuffers(size_t maxFrames)
|
||||||
|
{
|
||||||
|
maxFrames_ = maxFrames;
|
||||||
|
for (auto& buf : buffers_) {
|
||||||
|
buf.resize(maxFrames, 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::clear()
|
||||||
|
{
|
||||||
|
for (auto& buf : buffers_) {
|
||||||
|
std::fill(buf.begin(), buf.end(), 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::accumulate(
|
||||||
|
const float* const* source,
|
||||||
|
uint32_t frames,
|
||||||
|
float gain,
|
||||||
|
int sourceChannels)
|
||||||
|
{
|
||||||
|
uint32_t n = std::min(frames, (uint32_t)maxFrames_);
|
||||||
|
int nChannels = std::min(sourceChannels, channelCount_);
|
||||||
|
|
||||||
|
if (std::abs(gain) < 0.0001f) return;
|
||||||
|
|
||||||
|
if (std::abs(gain - 1.0f) < 0.0001f) {
|
||||||
|
// Unity gain fast path
|
||||||
|
for (int ch = 0; ch < nChannels; ++ch) {
|
||||||
|
if (ch < (int)buffers_.size() && ch < sourceChannels && source[ch]) {
|
||||||
|
float* dst = buffers_[ch].data();
|
||||||
|
const float* src = source[ch];
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
dst[i] += src[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Scaled accumulation
|
||||||
|
for (int ch = 0; ch < nChannels; ++ch) {
|
||||||
|
if (ch < (int)buffers_.size() && ch < sourceChannels && source[ch]) {
|
||||||
|
float* dst = buffers_[ch].data();
|
||||||
|
const float* src = source[ch];
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
dst[i] += src[i] * gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::accumulateMono(
|
||||||
|
const float* source,
|
||||||
|
uint32_t frames,
|
||||||
|
float gain)
|
||||||
|
{
|
||||||
|
if (!source || buffers_.empty()) return;
|
||||||
|
|
||||||
|
uint32_t n = std::min(frames, (uint32_t)maxFrames_);
|
||||||
|
float* dst = buffers_[0].data();
|
||||||
|
|
||||||
|
if (std::abs(gain) < 0.0001f) return;
|
||||||
|
|
||||||
|
if (std::abs(gain - 1.0f) < 0.0001f) {
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
dst[i] += source[i];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
dst[i] += source[i] * gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerBus::process(uint32_t frames)
|
||||||
|
{
|
||||||
|
uint32_t n = std::min(frames, (uint32_t)maxFrames_);
|
||||||
|
bool isMuted = mute_.load();
|
||||||
|
float volumeGain = isMuted ? 0.0f : std::pow(10.0f, volume_.load() / 20.0f);
|
||||||
|
|
||||||
|
// Peak VU tracking
|
||||||
|
float leftPeak = -96.0f;
|
||||||
|
float rightPeak = -96.0f;
|
||||||
|
|
||||||
|
if (std::abs(volumeGain) < 0.0001f) {
|
||||||
|
// Effectively mute
|
||||||
|
for (int ch = 0; ch < channelCount_; ++ch) {
|
||||||
|
if (ch < (int)buffers_.size()) {
|
||||||
|
std::fill(buffers_[ch].begin(), buffers_[ch].begin() + n, 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (std::abs(volumeGain - 1.0f) < 0.001f) {
|
||||||
|
// Unity gain — no scaling needed, just compute VU
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
if (buffers_.size() > 0) {
|
||||||
|
float absVal = std::abs(buffers_[0][i]);
|
||||||
|
if (absVal > leftPeak) leftPeak = absVal;
|
||||||
|
}
|
||||||
|
if (buffers_.size() > 1) {
|
||||||
|
float absVal = std::abs(buffers_[1][i]);
|
||||||
|
if (absVal > rightPeak) rightPeak = absVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Apply volume gain
|
||||||
|
for (int ch = 0; ch < channelCount_; ++ch) {
|
||||||
|
if (ch >= (int)buffers_.size()) break;
|
||||||
|
float* buf = buffers_[ch].data();
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
buf[i] *= volumeGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Compute VU from scaled signal
|
||||||
|
for (uint32_t i = 0; i < n; ++i) {
|
||||||
|
if (buffers_.size() > 0) {
|
||||||
|
float absVal = std::abs(buffers_[0][i]);
|
||||||
|
if (absVal > leftPeak) leftPeak = absVal;
|
||||||
|
}
|
||||||
|
if (buffers_.size() > 1) {
|
||||||
|
float absVal = std::abs(buffers_[1][i]);
|
||||||
|
if (absVal > rightPeak) rightPeak = absVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert peak to dB with decay
|
||||||
|
float leftDb = (leftPeak > 0.00001f) ? 20.0f * std::log10(leftPeak) : -96.0f;
|
||||||
|
float rightDb = (rightPeak > 0.00001f) ? 20.0f * std::log10(rightPeak) : -96.0f;
|
||||||
|
|
||||||
|
float oldLeft = vuLeft_.load();
|
||||||
|
float oldRight = vuRight_.load();
|
||||||
|
|
||||||
|
if (leftDb > oldLeft) {
|
||||||
|
vuLeft_ = leftDb;
|
||||||
|
} else {
|
||||||
|
vuLeft_ = oldLeft * 0.95f + leftDb * 0.05f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightDb > oldRight) {
|
||||||
|
vuRight_ = rightDb;
|
||||||
|
} else {
|
||||||
|
vuRight_ = oldRight * 0.95f + rightDb * 0.05f;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <memory>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
/// Types of buses in the mixer architecture.
|
||||||
|
enum class MixerBusType {
|
||||||
|
Master, // Main L/R output — end of signal chain
|
||||||
|
Subgroup, // Named subgroup (Drums, Guitars, Vocals...)
|
||||||
|
Aux, // Aux send bus (monitor mix or FX send)
|
||||||
|
FxReturn, // Stereo return from a shared FX processor (reverb, delay)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An audio bus that accumulates contributions from multiple sources.
|
||||||
|
///
|
||||||
|
/// Buses form the mixing topology:
|
||||||
|
/// Channels → subgroups → master
|
||||||
|
/// Channels → aux sends → aux buses (monitor mixes)
|
||||||
|
/// Aux buses → FxReturn buses → subgroup or master
|
||||||
|
///
|
||||||
|
/// Key design decisions:
|
||||||
|
/// - Buses are flat accumulators: they sum incoming audio with gain
|
||||||
|
/// - Bus processing is minimal (volume, mute only)
|
||||||
|
/// - A bus can be fed INTO another bus via the routing graph
|
||||||
|
/// - All audio is floating-point, 32-bit
|
||||||
|
class MixerBus {
|
||||||
|
public:
|
||||||
|
MixerBus(int64_t id, MixerBusType type, const std::string& name, int channels = 2);
|
||||||
|
~MixerBus() = default;
|
||||||
|
|
||||||
|
/// Bus identity
|
||||||
|
int64_t id() const { return id_; }
|
||||||
|
MixerBusType type() const { return type_; }
|
||||||
|
const std::string& name() const { return name_; }
|
||||||
|
void setName(const std::string& name) { name_ = name; }
|
||||||
|
|
||||||
|
/// Channel count (1 = mono, 2 = stereo, N = multi-channel)
|
||||||
|
int channelCount() const { return channelCount_; }
|
||||||
|
|
||||||
|
/// --- Control surface (atomic for RT-safe writes) ---
|
||||||
|
|
||||||
|
/// Master volume in dB (-inf to +12.0)
|
||||||
|
float volume() const { return volume_.load(); }
|
||||||
|
void setVolume(float db);
|
||||||
|
|
||||||
|
/// Mute
|
||||||
|
bool mute() const { return mute_.load(); }
|
||||||
|
void setMute(bool mute);
|
||||||
|
|
||||||
|
/// --- Audio buffers ---
|
||||||
|
|
||||||
|
/// Allocate internal buffers. Must be called before processing.
|
||||||
|
void allocateBuffers(size_t maxFrames);
|
||||||
|
|
||||||
|
/// Get read/write pointer to internal buffer for a channel
|
||||||
|
float* buffer(int channel) {
|
||||||
|
if (channel >= 0 && channel < (int)buffers_.size())
|
||||||
|
return buffers_[channel].data();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const float* buffer(int channel) const {
|
||||||
|
if (channel >= 0 && channel < (int)buffers_.size())
|
||||||
|
return buffers_[channel].data();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulate (sum) audio from a source into this bus with gain.
|
||||||
|
/// Performs: bus[ch][i] += source[ch][i] * gain for all channels
|
||||||
|
void accumulate(
|
||||||
|
const float* const* source,
|
||||||
|
uint32_t frames,
|
||||||
|
float gain,
|
||||||
|
int sourceChannels
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Same as accumulate but for a single interleaved source buffer
|
||||||
|
void accumulateMono(
|
||||||
|
const float* source,
|
||||||
|
uint32_t frames,
|
||||||
|
float gain
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Clear all bus buffers to zero (must be called at start of each cycle)
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
/// Apply bus-level processing (volume, mute) to the internal mix.
|
||||||
|
/// Reads internal mix buffer, applies gain, writes back.
|
||||||
|
void process(uint32_t frames);
|
||||||
|
|
||||||
|
/// VU meter values after processing
|
||||||
|
float vuLeft() const { return vuLeft_.load(); }
|
||||||
|
float vuRight() const { return vuRight_.load(); }
|
||||||
|
|
||||||
|
/// Max frames this bus can handle
|
||||||
|
size_t maxFrames() const { return maxFrames_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
int64_t id_;
|
||||||
|
MixerBusType type_;
|
||||||
|
std::string name_;
|
||||||
|
int channelCount_;
|
||||||
|
|
||||||
|
std::atomic<float> volume_{0.0f}; // dB
|
||||||
|
std::atomic<bool> mute_{false};
|
||||||
|
|
||||||
|
// Internal accumulation buffers [channel][sample]
|
||||||
|
std::vector<std::vector<float>> buffers_;
|
||||||
|
|
||||||
|
// VU tracking
|
||||||
|
std::atomic<float> vuLeft_{-96.0f};
|
||||||
|
std::atomic<float> vuRight_{-96.0f};
|
||||||
|
|
||||||
|
size_t maxFrames_ = 512;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "Lv2Effect.hpp"
|
||||||
|
#include "PiPedalMath.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
std::atomic<int64_t> MixerChannelStrip::nextInstanceId_{1};
|
||||||
|
|
||||||
|
MixerChannelStrip::MixerChannelStrip(int channelIndex)
|
||||||
|
: channelIndex_(channelIndex)
|
||||||
|
, instanceId_(nextInstanceId_++)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerChannelStrip::~MixerChannelStrip()
|
||||||
|
{
|
||||||
|
Unprepare();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setVolume(float db)
|
||||||
|
{
|
||||||
|
volume_ = std::clamp(db, -96.0f, 12.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setPan(float pan)
|
||||||
|
{
|
||||||
|
pan_ = std::clamp(pan, -1.0f, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setMute(bool mute)
|
||||||
|
{
|
||||||
|
mute_ = mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setSolo(bool solo)
|
||||||
|
{
|
||||||
|
solo_ = solo;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setAuxSend(int index, const AuxSendConfig& config)
|
||||||
|
{
|
||||||
|
if (index >= 0 && index < (int)auxSends_.size()) {
|
||||||
|
auxSends_[index] = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuxSendConfig& MixerChannelStrip::auxSend(int index) const
|
||||||
|
{
|
||||||
|
static const AuxSendConfig kDefault;
|
||||||
|
if (index >= 0 && index < (int)auxSends_.size()) {
|
||||||
|
return auxSends_[index];
|
||||||
|
}
|
||||||
|
return kDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::resizeAuxSends(size_t count)
|
||||||
|
{
|
||||||
|
auxSends_.resize(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setSampleRate(uint32_t sampleRate)
|
||||||
|
{
|
||||||
|
sampleRate_ = sampleRate;
|
||||||
|
hpfStates_.resize(2); // stereo HPF states
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::setMaxBufferSize(size_t frames)
|
||||||
|
{
|
||||||
|
maxBufferSize_ = frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::prepareFx(IHost* pHost, Lv2PedalboardErrorList& errorList,
|
||||||
|
ExistingEffectMap* existingEffects)
|
||||||
|
{
|
||||||
|
// Create or re-create the Lv2Pedalboard for this channel's FX chain
|
||||||
|
if (!fxProcessor_) {
|
||||||
|
fxProcessor_ = std::make_unique<Lv2Pedalboard>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate pre/post FX buffers (stereo, up to max buffer size)
|
||||||
|
preFxBuffers_.clear();
|
||||||
|
postFxBuffers_.clear();
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
preFxBuffers_.emplace_back(maxBufferSize_, 0.0f);
|
||||||
|
postFxBuffers_.emplace_back(maxBufferSize_, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the FX processor with this channel's pedalboard
|
||||||
|
fxProcessor_->Prepare(pHost, fxChain_, errorList, existingEffects);
|
||||||
|
fxProcessor_->Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
float MixerChannelStrip::effectiveAuxLevel(int auxIndex, bool anySoloActive) const
|
||||||
|
{
|
||||||
|
if (auxIndex < 0 || auxIndex >= (int)auxSends_.size()) return -96.0f;
|
||||||
|
|
||||||
|
const auto& send = auxSends_[auxIndex];
|
||||||
|
if (!send.isActive()) return -96.0f;
|
||||||
|
|
||||||
|
// Solo overrides: if any solo is active, only soloed channels are audible
|
||||||
|
if (anySoloActive && !solo_) return -96.0f;
|
||||||
|
if (mute_) return -96.0f;
|
||||||
|
|
||||||
|
return send.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::applyPan(float& leftGain, float& rightGain) const
|
||||||
|
{
|
||||||
|
float pan = pan_;
|
||||||
|
// Constant-power pan law: -3dB at center
|
||||||
|
// sin/cos distribution: L = cos(pan * PI/4), R = sin(pan * PI/4)
|
||||||
|
// Normalized so center = -3dB each
|
||||||
|
float angle = (pan * 0.5f + 0.5f) * (M_PI * 0.5f); // map -1..1 to 0..PI/2
|
||||||
|
leftGain = std::cos(angle);
|
||||||
|
rightGain = std::sin(angle);
|
||||||
|
|
||||||
|
// Compensate for equal-power pan: center should sum to unity
|
||||||
|
// Already handled by sin/cos distribution
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::applyHpf(float* buffer, uint32_t frames, HpfState& state)
|
||||||
|
{
|
||||||
|
if (!hpEnabled_) return;
|
||||||
|
|
||||||
|
// Simple 1st-order IIR HPF: y[n] = 0.5 * (x[n] - x[n-1] + y[n-1])
|
||||||
|
// Cutoff ~ 80Hz at 48kHz. For sharper roll-off, use biquad.
|
||||||
|
// This is intentionally simple for real-time safety.
|
||||||
|
float fc = hpFrequency_ / sampleRate_;
|
||||||
|
float alpha = fc / (fc + 0.5f); // approximation: R = 1/(2*PI*fc)
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < frames; ++i) {
|
||||||
|
float x = buffer[i];
|
||||||
|
float y = alpha * (state.y1 + x - state.x1);
|
||||||
|
state.x1 = x;
|
||||||
|
state.y1 = y;
|
||||||
|
buffer[i] = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::process(
|
||||||
|
const float* const* inputBuffers,
|
||||||
|
size_t inputChannels,
|
||||||
|
float* const* outputBuffers,
|
||||||
|
size_t outputChannels,
|
||||||
|
uint32_t frames)
|
||||||
|
{
|
||||||
|
// Clamp frames to allocated buffer size
|
||||||
|
frames = std::min(frames, (uint32_t)maxBufferSize_);
|
||||||
|
|
||||||
|
// Step 1: Copy input to pre-FX buffers and apply HPF
|
||||||
|
for (size_t ch = 0; ch < std::min(inputChannels, (size_t)2); ++ch) {
|
||||||
|
if (ch < preFxBuffers_.size() && inputBuffers[ch]) {
|
||||||
|
std::copy(inputBuffers[ch], inputBuffers[ch] + frames,
|
||||||
|
preFxBuffers_[ch].begin());
|
||||||
|
applyHpf(preFxBuffers_[ch].data(), frames,
|
||||||
|
ch < hpfStates_.size() ? hpfStates_[ch] : hpfStates_[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Run the FX chain (processes preFxBuffers_ -> postFxBuffers_)
|
||||||
|
if (fxProcessor_) {
|
||||||
|
// Build float* arrays for Lv2Pedalboard::Run
|
||||||
|
float* fxInputs[2];
|
||||||
|
float* fxOutputs[2];
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
fxInputs[i] = i < (int)preFxBuffers_.size() ? preFxBuffers_[i].data() : nullptr;
|
||||||
|
fxOutputs[i] = i < (int)postFxBuffers_.size() ? postFxBuffers_[i].data() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the FX chain (Lv2Pedalboard manages its internal routing)
|
||||||
|
fxProcessor_->Run(
|
||||||
|
(float**)fxInputs,
|
||||||
|
(float**)fxOutputs,
|
||||||
|
frames,
|
||||||
|
nullptr // no realtime ring buffer writer for now
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No FX chain — passthrough pre to post
|
||||||
|
for (size_t ch = 0; ch < std::min(inputChannels, (size_t)2); ++ch) {
|
||||||
|
if (ch < postFxBuffers_.size() && ch < preFxBuffers_.size()) {
|
||||||
|
std::copy(preFxBuffers_[ch].begin(),
|
||||||
|
preFxBuffers_[ch].begin() + frames,
|
||||||
|
postFxBuffers_[ch].begin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Apply volume, pan, and mute/solo to create output
|
||||||
|
bool isMuted = mute_.load();
|
||||||
|
bool isSoloed = solo_.load();
|
||||||
|
|
||||||
|
// Calculate gain from volume dB
|
||||||
|
float volumeGain = isMuted ? 0.0f : std::pow(10.0f, volume_.load() / 20.0f);
|
||||||
|
|
||||||
|
// Calculate pan gains
|
||||||
|
float leftGain = 1.0f, rightGain = 1.0f;
|
||||||
|
applyPan(leftGain, rightGain);
|
||||||
|
|
||||||
|
// Apply to output buffers
|
||||||
|
for (size_t outCh = 0; outCh < std::min(outputChannels, (size_t)2); ++outCh) {
|
||||||
|
if (!outputBuffers[outCh]) continue;
|
||||||
|
|
||||||
|
float* dst = outputBuffers[outCh];
|
||||||
|
const float* src = (outCh < postFxBuffers_.size())
|
||||||
|
? postFxBuffers_[outCh].data()
|
||||||
|
: (postFxBuffers_.empty() ? nullptr : postFxBuffers_[0].data());
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
std::fill(dst, dst + frames, 0.0f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float panGain = (outCh == 0) ? leftGain : rightGain;
|
||||||
|
float finalGain = volumeGain * panGain;
|
||||||
|
|
||||||
|
if (finalGain < 0.001f) {
|
||||||
|
std::fill(dst, dst + frames, 0.0f);
|
||||||
|
} else if (std::abs(finalGain - 1.0f) < 0.001f) {
|
||||||
|
std::copy(src, src + frames, dst);
|
||||||
|
} else {
|
||||||
|
for (uint32_t i = 0; i < frames; ++i) {
|
||||||
|
dst[i] = src[i] * finalGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Update VU meters (peak, with 300ms decay)
|
||||||
|
for (size_t ch = 0; ch < std::min(outputChannels, (size_t)2); ++ch) {
|
||||||
|
if (ch >= postFxBuffers_.size()) break;
|
||||||
|
|
||||||
|
float peak = 0.0f;
|
||||||
|
const float* buf = postFxBuffers_[ch].data();
|
||||||
|
for (uint32_t i = 0; i < frames; ++i) {
|
||||||
|
float absVal = std::abs(buf[i]);
|
||||||
|
if (absVal > peak) peak = absVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
float peakDb = (peak > 0.00001f) ? 20.0f * std::log10(peak) : -96.0f;
|
||||||
|
|
||||||
|
// Decay: 300ms time constant
|
||||||
|
float& vu = (ch == 0) ? vuLeft_ : vuRight_;
|
||||||
|
if (peakDb > vu) {
|
||||||
|
vu = peakDb; // Instant attack
|
||||||
|
} else {
|
||||||
|
// Decay at ~300ms: releaseRate = exp(-1 / (0.3 * sampleRate / frames))
|
||||||
|
static const float releaseRate = 0.95f;
|
||||||
|
vu = vu * releaseRate + peakDb * (1.0f - releaseRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::Activate()
|
||||||
|
{
|
||||||
|
if (fxProcessor_) {
|
||||||
|
fxProcessor_->Activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::Deactivate()
|
||||||
|
{
|
||||||
|
if (fxProcessor_) {
|
||||||
|
fxProcessor_->Deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerChannelStrip::Unprepare()
|
||||||
|
{
|
||||||
|
if (fxProcessor_) {
|
||||||
|
fxProcessor_->Deactivate();
|
||||||
|
fxProcessor_.reset();
|
||||||
|
}
|
||||||
|
preFxBuffers_.clear();
|
||||||
|
postFxBuffers_.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
// this software and associated documentation files (the "Software"), to deal in
|
||||||
|
// the Software without restriction, including without limitation the rights to
|
||||||
|
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
// of the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
// subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Pedalboard.hpp"
|
||||||
|
#include "Lv2Pedalboard.hpp"
|
||||||
|
#include "IEffect.hpp"
|
||||||
|
#include "BufferPool.hpp"
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
#include <atomic>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
/// Channel type classification for mixer channel strips.
|
||||||
|
enum class MixerChannelType {
|
||||||
|
Instrument, // guitar, bass, keys — expects NAM/guitar amp modeling
|
||||||
|
Mic, // vocal mic — expects compressor, EQ, reverb chain
|
||||||
|
Line, // line-level input (backing tracks, synths, drum machines)
|
||||||
|
AuxReturn, // return from external FX processor
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Configuration for a single aux send on a channel strip.
|
||||||
|
struct AuxSendConfig {
|
||||||
|
float level = -96.0f; // dB, -96 ≈ -inf (effectively off)
|
||||||
|
bool preFader = false; // true = pre-fader (monitor send), false = post-fader (FX send)
|
||||||
|
int64_t targetBusId = -1; // bus ID this sends to
|
||||||
|
|
||||||
|
bool isActive() const { return level > -90.0f && targetBusId >= 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Per-channel high-pass filter configuration.
|
||||||
|
struct HpfConfig {
|
||||||
|
bool enabled = false;
|
||||||
|
float frequency = 80.0f; // Hz
|
||||||
|
// Filter state for simple biquad — sized for max buffer
|
||||||
|
// Allocated at prepare time
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A single channel strip in the mixer.
|
||||||
|
///
|
||||||
|
/// Each MixerChannelStrip wraps:
|
||||||
|
/// - A mini-pedalboard (FX chain using existing Lv2Pedalboard)
|
||||||
|
/// - Volume fader, pan pot, mute, solo
|
||||||
|
/// - High-pass filter on input
|
||||||
|
/// - Aux sends (pre/post fader)
|
||||||
|
/// - VU metering
|
||||||
|
///
|
||||||
|
/// The channel operates in the real-time audio thread.
|
||||||
|
/// Control changes are made from the non-realtime thread via atomic snapshots.
|
||||||
|
class MixerChannelStrip {
|
||||||
|
public:
|
||||||
|
MixerChannelStrip(int channelIndex);
|
||||||
|
~MixerChannelStrip();
|
||||||
|
|
||||||
|
// Disable copy
|
||||||
|
MixerChannelStrip(const MixerChannelStrip&) = delete;
|
||||||
|
MixerChannelStrip& operator=(const MixerChannelStrip&) = delete;
|
||||||
|
|
||||||
|
/// Channel identity
|
||||||
|
int channelIndex() const { return channelIndex_; }
|
||||||
|
int64_t instanceId() const { return instanceId_; }
|
||||||
|
|
||||||
|
/// --- Control surface (thread-safe via atomics for simple values) ---
|
||||||
|
|
||||||
|
/// Volume in dB (-inf to +12.0)
|
||||||
|
float volume() const { return volume_; }
|
||||||
|
void setVolume(float db);
|
||||||
|
|
||||||
|
/// Pan: -1.0 (full left) to +1.0 (full right). 0.0 = center.
|
||||||
|
float pan() const { return pan_; }
|
||||||
|
void setPan(float pan);
|
||||||
|
|
||||||
|
/// Mute
|
||||||
|
bool mute() const { return mute_; }
|
||||||
|
void setMute(bool mute);
|
||||||
|
|
||||||
|
/// Solo — overrides mute for monitoring
|
||||||
|
bool solo() const { return solo_; }
|
||||||
|
void setSolo(bool solo);
|
||||||
|
|
||||||
|
/// Channel type for UI classification
|
||||||
|
MixerChannelType channelType() const { return channelType_; }
|
||||||
|
void setChannelType(MixerChannelType type) { channelType_ = type; }
|
||||||
|
|
||||||
|
/// User-assignable label
|
||||||
|
const std::string& label() const { return label_; }
|
||||||
|
void setLabel(const std::string& label) { label_ = label; }
|
||||||
|
|
||||||
|
/// --- Input processing ---
|
||||||
|
|
||||||
|
/// High-pass filter
|
||||||
|
bool hpEnabled() const { return hpEnabled_; }
|
||||||
|
void setHpEnabled(bool enabled) { hpEnabled_ = enabled; }
|
||||||
|
float hpFrequency() const { return hpFrequency_; }
|
||||||
|
void setHpFrequency(float freq) { hpFrequency_ = freq; }
|
||||||
|
|
||||||
|
/// --- FX Chain ---
|
||||||
|
|
||||||
|
/// Access the channel's pedalboard for plugin management
|
||||||
|
Pedalboard& fxChain() { return fxChain_; }
|
||||||
|
const Pedalboard& fxChain() const { return fxChain_; }
|
||||||
|
|
||||||
|
/// Get the real-time processor for this channel's FX chain
|
||||||
|
Lv2Pedalboard* fxProcessor() { return fxProcessor_.get(); }
|
||||||
|
|
||||||
|
/// Prepare the FX chain for processing
|
||||||
|
void prepareFx(IHost* pHost, Lv2PedalboardErrorList& errorList,
|
||||||
|
ExistingEffectMap* existingEffects = nullptr);
|
||||||
|
|
||||||
|
/// --- Aux Sends ---
|
||||||
|
|
||||||
|
void setAuxSend(int index, const AuxSendConfig& config);
|
||||||
|
const AuxSendConfig& auxSend(int index) const;
|
||||||
|
size_t auxSendCount() const { return auxSends_.size(); }
|
||||||
|
void resizeAuxSends(size_t count);
|
||||||
|
|
||||||
|
/// --- Audio Processing (real-time thread) ---
|
||||||
|
|
||||||
|
/// Set sample rate and max buffer size
|
||||||
|
void setSampleRate(uint32_t sampleRate);
|
||||||
|
void setMaxBufferSize(size_t frames);
|
||||||
|
|
||||||
|
/// Process one audio block through this channel strip.
|
||||||
|
/// Reads from input, runs HPF → FX chain, applies volume/pan.
|
||||||
|
/// Output goes to provided output buffer(s).
|
||||||
|
/// Returns the number of processed samples.
|
||||||
|
void process(
|
||||||
|
const float* const* inputBuffers,
|
||||||
|
size_t inputChannels,
|
||||||
|
float* const* outputBuffers,
|
||||||
|
size_t outputChannels,
|
||||||
|
uint32_t frames
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Get post-FX, pre-fader audio for aux send calculation
|
||||||
|
const float* postFxBuffer(int channel) const {
|
||||||
|
if (channel < (int)postFxBuffers_.size()) return postFxBuffers_[channel].data();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get pre-FX audio for pre-fader aux sends
|
||||||
|
const float* preFxBuffer(int channel) const {
|
||||||
|
if (channel < (int)preFxBuffers_.size()) return preFxBuffers_[channel].data();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcuate aux send level considering pre/post fader and mute
|
||||||
|
float effectiveAuxLevel(int auxIndex, bool anySoloActive) const;
|
||||||
|
|
||||||
|
/// VU meter values (peak, not RMS — computed during process)
|
||||||
|
float vuLeft() const { return vuLeft_; }
|
||||||
|
float vuRight() const { return vuRight_; }
|
||||||
|
|
||||||
|
/// --- Lifecycle ---
|
||||||
|
|
||||||
|
void Activate();
|
||||||
|
void Deactivate();
|
||||||
|
void Unprepare();
|
||||||
|
|
||||||
|
private:
|
||||||
|
int channelIndex_;
|
||||||
|
int64_t instanceId_;
|
||||||
|
|
||||||
|
static std::atomic<int64_t> nextInstanceId_;
|
||||||
|
|
||||||
|
// Control values (atomic for RT-safe reads from control thread)
|
||||||
|
std::atomic<float> volume_{-96.0f}; // dB, -inf default
|
||||||
|
std::atomic<float> pan_{0.0f};
|
||||||
|
std::atomic<bool> mute_{false};
|
||||||
|
std::atomic<bool> solo_{false};
|
||||||
|
std::atomic<bool> hpEnabled_{false};
|
||||||
|
std::atomic<float> hpFrequency_{80.0f};
|
||||||
|
|
||||||
|
MixerChannelType channelType_ = MixerChannelType::Instrument;
|
||||||
|
std::string label_;
|
||||||
|
|
||||||
|
// FX chain
|
||||||
|
Pedalboard fxChain_;
|
||||||
|
std::unique_ptr<Lv2Pedalboard> fxProcessor_;
|
||||||
|
|
||||||
|
// Aux sends
|
||||||
|
std::vector<AuxSendConfig> auxSends_;
|
||||||
|
|
||||||
|
// Audio buffers (allocated at prepare time)
|
||||||
|
std::vector<std::vector<float>> preFxBuffers_; // Before FX chain (for pre-fader sends)
|
||||||
|
std::vector<std::vector<float>> postFxBuffers_; // After FX chain, before fader
|
||||||
|
BufferPool bufferPool_;
|
||||||
|
|
||||||
|
// Sample rate / buffer size
|
||||||
|
uint32_t sampleRate_ = 48000;
|
||||||
|
size_t maxBufferSize_ = 512;
|
||||||
|
|
||||||
|
// VU tracking
|
||||||
|
float vuLeft_ = -96.0f;
|
||||||
|
float vuRight_ = -96.0f;
|
||||||
|
|
||||||
|
// Simple 1-pole HPF state (per channel)
|
||||||
|
struct HpfState {
|
||||||
|
float x1 = 0.0f, y1 = 0.0f;
|
||||||
|
};
|
||||||
|
std::vector<HpfState> hpfStates_;
|
||||||
|
|
||||||
|
// Apply pan law: constant power (-3dB center)
|
||||||
|
void applyPan(float& leftGain, float& rightGain) const;
|
||||||
|
|
||||||
|
// Apply HPF biquad to a buffer
|
||||||
|
void applyHpf(float* buffer, uint32_t frames, HpfState& state);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include "Lv2Pedalboard.hpp"
|
||||||
|
#include "IHost.hpp"
|
||||||
|
#include "MidiEvent.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
std::atomic<int64_t> MixerEngine::nextBusId_{1};
|
||||||
|
|
||||||
|
MixerEngine::MixerEngine()
|
||||||
|
{
|
||||||
|
// Create the master bus by default
|
||||||
|
int64_t masterId = nextBusId_++;
|
||||||
|
auto master = std::make_unique<MixerBus>(masterId, MixerBusType::Master, "Master", 2);
|
||||||
|
masterBus_ = master.get();
|
||||||
|
buses_[masterId] = std::move(master);
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerEngine::~MixerEngine()
|
||||||
|
{
|
||||||
|
Deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::setSampleRate(uint32_t sampleRate)
|
||||||
|
{
|
||||||
|
sampleRate_ = sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::setMaxBufferSize(size_t frames)
|
||||||
|
{
|
||||||
|
maxBufferSize_ = frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channel Management ---
|
||||||
|
|
||||||
|
MixerChannelStrip* MixerEngine::addChannel(int physicalInputIndex)
|
||||||
|
{
|
||||||
|
auto channel = std::make_unique<MixerChannelStrip>(physicalInputIndex);
|
||||||
|
channel->setSampleRate(sampleRate_);
|
||||||
|
channel->setMaxBufferSize(maxBufferSize_);
|
||||||
|
channel->setLabel("Channel " + std::to_string(physicalInputIndex + 1));
|
||||||
|
|
||||||
|
// Default: route channel directly to master
|
||||||
|
auto* ptr = channel.get();
|
||||||
|
channels_.push_back(std::move(channel));
|
||||||
|
|
||||||
|
// Create default route: this channel → master bus at unity
|
||||||
|
MixerRouteEntry route;
|
||||||
|
route.sourceType = MixerRouteEntry::SourceChannel;
|
||||||
|
route.sourceId = ptr->instanceId();
|
||||||
|
route.targetBusId = masterBus_->id();
|
||||||
|
route.level = 0.0f; // unity
|
||||||
|
routes_.push_back(route);
|
||||||
|
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::removeChannel(int channelIndex)
|
||||||
|
{
|
||||||
|
if (channelIndex < 0 || channelIndex >= (int)channels_.size()) return;
|
||||||
|
|
||||||
|
auto* channel = channels_[channelIndex].get();
|
||||||
|
int64_t instanceId = channel->instanceId();
|
||||||
|
|
||||||
|
// Remove all routes referencing this channel
|
||||||
|
routes_.erase(
|
||||||
|
std::remove_if(routes_.begin(), routes_.end(),
|
||||||
|
[instanceId](const MixerRouteEntry& r) {
|
||||||
|
return r.sourceType == MixerRouteEntry::SourceChannel &&
|
||||||
|
r.sourceId == instanceId;
|
||||||
|
}),
|
||||||
|
routes_.end()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unprepare the channel
|
||||||
|
channel->Unprepare();
|
||||||
|
channels_.erase(channels_.begin() + channelIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerChannelStrip* MixerEngine::getChannel(int channelIndex)
|
||||||
|
{
|
||||||
|
if (channelIndex >= 0 && channelIndex < (int)channels_.size())
|
||||||
|
return channels_[channelIndex].get();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MixerChannelStrip* MixerEngine::getChannel(int channelIndex) const
|
||||||
|
{
|
||||||
|
if (channelIndex >= 0 && channelIndex < (int)channels_.size())
|
||||||
|
return channels_[channelIndex].get();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bus Management ---
|
||||||
|
|
||||||
|
int64_t MixerEngine::addBus(MixerBusType type, const std::string& name, int channels)
|
||||||
|
{
|
||||||
|
int64_t id = nextBusId_++;
|
||||||
|
auto bus = std::make_unique<MixerBus>(id, type, name, channels);
|
||||||
|
bus->allocateBuffers(maxBufferSize_);
|
||||||
|
buses_[id] = std::move(bus);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::removeBus(int64_t busId)
|
||||||
|
{
|
||||||
|
if (busId == masterBus_->id()) return; // Can't remove master
|
||||||
|
|
||||||
|
// Remove all routes targeting this bus
|
||||||
|
routes_.erase(
|
||||||
|
std::remove_if(routes_.begin(), routes_.end(),
|
||||||
|
[busId](const MixerRouteEntry& r) {
|
||||||
|
return r.targetBusId == busId;
|
||||||
|
}),
|
||||||
|
routes_.end()
|
||||||
|
);
|
||||||
|
|
||||||
|
buses_.erase(busId);
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerBus* MixerEngine::getBus(int64_t busId)
|
||||||
|
{
|
||||||
|
auto it = buses_.find(busId);
|
||||||
|
return (it != buses_.end()) ? it->second.get() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MixerBus* MixerEngine::getBus(int64_t busId) const
|
||||||
|
{
|
||||||
|
auto it = buses_.find(busId);
|
||||||
|
return (it != buses_.end()) ? it->second.get() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<int64_t> MixerEngine::busIds() const
|
||||||
|
{
|
||||||
|
std::vector<int64_t> ids;
|
||||||
|
ids.reserve(buses_.size());
|
||||||
|
for (const auto& [id, _] : buses_) {
|
||||||
|
ids.push_back(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Routing ---
|
||||||
|
|
||||||
|
void MixerEngine::routeChannelToBus(int channelIndex, int64_t busId, float levelDb)
|
||||||
|
{
|
||||||
|
if (channelIndex < 0 || channelIndex >= (int)channels_.size()) return;
|
||||||
|
if (!getBus(busId)) return;
|
||||||
|
|
||||||
|
auto* channel = channels_[channelIndex].get();
|
||||||
|
|
||||||
|
// Check if route already exists — update level
|
||||||
|
for (auto& route : routes_) {
|
||||||
|
if (route.sourceType == MixerRouteEntry::SourceChannel &&
|
||||||
|
route.sourceId == channel->instanceId() &&
|
||||||
|
route.targetBusId == busId) {
|
||||||
|
route.level = levelDb;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new route
|
||||||
|
MixerRouteEntry route;
|
||||||
|
route.sourceType = MixerRouteEntry::SourceChannel;
|
||||||
|
route.sourceId = channel->instanceId();
|
||||||
|
route.targetBusId = busId;
|
||||||
|
route.level = levelDb;
|
||||||
|
routes_.push_back(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::routeBusToBus(int64_t sourceBusId, int64_t targetBusId, float levelDb)
|
||||||
|
{
|
||||||
|
if (!getBus(sourceBusId) || !getBus(targetBusId)) return;
|
||||||
|
|
||||||
|
for (auto& route : routes_) {
|
||||||
|
if (route.sourceType == MixerRouteEntry::SourceBus &&
|
||||||
|
route.sourceId == sourceBusId &&
|
||||||
|
route.targetBusId == targetBusId) {
|
||||||
|
route.level = levelDb;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MixerRouteEntry route;
|
||||||
|
route.sourceType = MixerRouteEntry::SourceBus;
|
||||||
|
route.sourceId = sourceBusId;
|
||||||
|
route.targetBusId = targetBusId;
|
||||||
|
route.level = levelDb;
|
||||||
|
routes_.push_back(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::removeRoute(int64_t sourceId, int64_t targetBusId)
|
||||||
|
{
|
||||||
|
routes_.erase(
|
||||||
|
std::remove_if(routes_.begin(), routes_.end(),
|
||||||
|
[sourceId, targetBusId](const MixerRouteEntry& r) {
|
||||||
|
return r.sourceId == sourceId && r.targetBusId == targetBusId;
|
||||||
|
}),
|
||||||
|
routes_.end()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::clearRoutes()
|
||||||
|
{
|
||||||
|
routes_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
|
||||||
|
void MixerEngine::Prepare(IHost* pHost, Lv2PedalboardErrorList& errorList)
|
||||||
|
{
|
||||||
|
pHost_ = pHost;
|
||||||
|
|
||||||
|
// Allocate bus buffers
|
||||||
|
for (auto& [_, bus] : buses_) {
|
||||||
|
bus->allocateBuffers(maxBufferSize_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate per-channel output buffers (for routing accumulation)
|
||||||
|
channelOutputBuffers_.resize(std::max((size_t)1, channels_.size()));
|
||||||
|
for (auto& buf : channelOutputBuffers_) {
|
||||||
|
buf.resize(maxBufferSize_ * 2, 0.0f); // stereo output per channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare each channel's FX chain
|
||||||
|
for (auto& channel : channels_) {
|
||||||
|
channel->setSampleRate(sampleRate_);
|
||||||
|
channel->setMaxBufferSize(maxBufferSize_);
|
||||||
|
channel->prepareFx(pHost, errorList, nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::Activate()
|
||||||
|
{
|
||||||
|
for (auto& channel : channels_) {
|
||||||
|
channel->Activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::Deactivate()
|
||||||
|
{
|
||||||
|
for (auto& channel : channels_) {
|
||||||
|
channel->Deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Solo ---
|
||||||
|
|
||||||
|
bool MixerEngine::anySoloActive() const
|
||||||
|
{
|
||||||
|
for (const auto& channel : channels_) {
|
||||||
|
if (channel->solo()) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audio Processing ---
|
||||||
|
|
||||||
|
std::vector<MixerRouteEntry*> MixerEngine::findRoutesForSource(int64_t sourceId)
|
||||||
|
{
|
||||||
|
std::vector<MixerRouteEntry*> result;
|
||||||
|
for (auto& route : routes_) {
|
||||||
|
if (route.sourceId == sourceId) {
|
||||||
|
result.push_back(&route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::routeChannelOutput(
|
||||||
|
MixerChannelStrip* channel,
|
||||||
|
float** channelOutput,
|
||||||
|
uint32_t frames)
|
||||||
|
{
|
||||||
|
bool soloActive = anySoloActive();
|
||||||
|
|
||||||
|
// Find all routes for this channel
|
||||||
|
int64_t channelId = channel->instanceId();
|
||||||
|
auto channelRoutes = findRoutesForSource(channelId);
|
||||||
|
|
||||||
|
for (auto* route : channelRoutes) {
|
||||||
|
MixerBus* targetBus = getBus(route->targetBusId);
|
||||||
|
if (!targetBus) continue;
|
||||||
|
|
||||||
|
float levelLinear = std::pow(10.0f, route->level / 20.0f);
|
||||||
|
|
||||||
|
// Check aux sends if this is an aux bus
|
||||||
|
// For standard bus routing, just accumulate
|
||||||
|
targetBus->accumulate(
|
||||||
|
(const float* const*)channelOutput,
|
||||||
|
frames,
|
||||||
|
levelLinear,
|
||||||
|
2 // channelOutput is always stereo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process aux sends
|
||||||
|
size_t numAuxSends = channel->auxSendCount();
|
||||||
|
for (size_t auxIdx = 0; auxIdx < numAuxSends; ++auxIdx) {
|
||||||
|
float effectiveLevel = channel->effectiveAuxLevel(auxIdx, soloActive);
|
||||||
|
if (effectiveLevel < -90.0f) continue;
|
||||||
|
|
||||||
|
const auto& sendConfig = channel->auxSend(auxIdx);
|
||||||
|
MixerBus* auxBus = getBus(sendConfig.targetBusId);
|
||||||
|
if (!auxBus) continue;
|
||||||
|
|
||||||
|
float sendGain = std::pow(10.0f, effectiveLevel / 20.0f);
|
||||||
|
|
||||||
|
if (sendConfig.preFader) {
|
||||||
|
// Pre-fader: use the pre-FX buffer
|
||||||
|
// This means we need access to the pre-fader buffer from the channel
|
||||||
|
const float* preFx0 = channel->preFxBuffer(0);
|
||||||
|
const float* preFx1 = channel->preFxBuffer(1);
|
||||||
|
if (preFx0) {
|
||||||
|
const float* preFx[2] = { preFx0, preFx1 };
|
||||||
|
auxBus->accumulate(preFx, frames, sendGain, 2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Post-fader: use the same output that goes to buses
|
||||||
|
auxBus->accumulate(
|
||||||
|
(const float* const*)channelOutput,
|
||||||
|
frames,
|
||||||
|
sendGain,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::processBusRouting(uint32_t frames)
|
||||||
|
{
|
||||||
|
// Process bus-to-bus routes
|
||||||
|
// This is simple: for each bus route, accumulate source bus output to target bus
|
||||||
|
for (auto& route : routes_) {
|
||||||
|
if (route.sourceType != MixerRouteEntry::SourceBus) continue;
|
||||||
|
|
||||||
|
MixerBus* sourceBus = getBus(route.sourceId);
|
||||||
|
MixerBus* targetBus = getBus(route.targetBusId);
|
||||||
|
if (!sourceBus || !targetBus) continue;
|
||||||
|
|
||||||
|
// Build float* array from source bus
|
||||||
|
int nChannels = sourceBus->channelCount();
|
||||||
|
std::vector<const float*> srcPtrs(nChannels);
|
||||||
|
for (int ch = 0; ch < nChannels; ++ch) {
|
||||||
|
srcPtrs[ch] = sourceBus->buffer(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
float levelLinear = std::pow(10.0f, route.level / 20.0f);
|
||||||
|
targetBus->accumulate(srcPtrs.data(), frames, levelLinear, nChannels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::process(
|
||||||
|
float** deviceInputs,
|
||||||
|
uint32_t inputChannels,
|
||||||
|
float** deviceOutputs,
|
||||||
|
uint32_t outputChannels,
|
||||||
|
uint32_t frames)
|
||||||
|
{
|
||||||
|
// Clamp
|
||||||
|
frames = std::min(frames, (uint32_t)maxBufferSize_);
|
||||||
|
|
||||||
|
// Step 1: Clear all bus buffers
|
||||||
|
for (auto& [_, bus] : buses_) {
|
||||||
|
bus->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Process each channel
|
||||||
|
size_t numChannels = channels_.size();
|
||||||
|
|
||||||
|
// Determine channel pairing mode:
|
||||||
|
// For 1-2 total input channels: stereo pairing per strip (backward compatible)
|
||||||
|
// For 3+ input channels: each strip is mono (one input channel → stereo output with pan)
|
||||||
|
bool stereoPairing = (inputChannels <= 2);
|
||||||
|
|
||||||
|
for (size_t ch = 0; ch < numChannels; ++ch) {
|
||||||
|
auto* channel = channels_[ch].get();
|
||||||
|
|
||||||
|
// Build input buffer pointers for this channel
|
||||||
|
float* channelInputs[2] = { nullptr, nullptr };
|
||||||
|
if (stereoPairing) {
|
||||||
|
// Stereo pairing: channel 0 ← inputs[0,1], channel 1 ← inputs[2,3], etc.
|
||||||
|
uint32_t baseInput = (uint32_t)(ch * 2);
|
||||||
|
if (baseInput < inputChannels) {
|
||||||
|
channelInputs[0] = deviceInputs[baseInput];
|
||||||
|
if (baseInput + 1 < inputChannels) {
|
||||||
|
channelInputs[1] = deviceInputs[baseInput + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mono per strip: each channel gets exactly one input
|
||||||
|
if (ch < inputChannels) {
|
||||||
|
channelInputs[0] = deviceInputs[ch];
|
||||||
|
// Second input stays nullptr → mono processing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build output buffer (stereo, from our per-channel scratch buffers)
|
||||||
|
float* channelOutputs[2] = { nullptr, nullptr };
|
||||||
|
if (ch < channelOutputBuffers_.size()) {
|
||||||
|
channelOutputs[0] = channelOutputBuffers_[ch].data();
|
||||||
|
channelOutputs[1] = channelOutputBuffers_[ch].data() + maxBufferSize_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the actual number of input channels for this strip
|
||||||
|
size_t stripInputChannels = 0;
|
||||||
|
if (channelInputs[0] != nullptr) stripInputChannels = 1;
|
||||||
|
if (channelInputs[1] != nullptr) stripInputChannels = 2;
|
||||||
|
|
||||||
|
// Process the channel strip
|
||||||
|
channel->process(
|
||||||
|
(const float* const*)channelInputs,
|
||||||
|
stripInputChannels,
|
||||||
|
channelOutputs,
|
||||||
|
2,
|
||||||
|
frames
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route channel output to buses
|
||||||
|
routeChannelOutput(channel, channelOutputs, frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Process bus-to-bus routing
|
||||||
|
processBusRouting(frames);
|
||||||
|
|
||||||
|
// Step 4: Process each bus (apply volume, compute VU)
|
||||||
|
for (auto& [_, bus] : buses_) {
|
||||||
|
bus->process(frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Write buses to physical outputs according to output routing
|
||||||
|
if (outputRoutes_.empty()) {
|
||||||
|
// Legacy fallback: write master bus to device outputs 1:1
|
||||||
|
if (masterBus_) {
|
||||||
|
for (uint32_t outCh = 0; outCh < outputChannels; ++outCh) {
|
||||||
|
if (deviceOutputs[outCh] == nullptr) continue;
|
||||||
|
|
||||||
|
const float* src = masterBus_->buffer(outCh);
|
||||||
|
if (src) {
|
||||||
|
std::copy(src, src + frames, deviceOutputs[outCh]);
|
||||||
|
} else if (outCh == 1) {
|
||||||
|
const float* srcL = masterBus_->buffer(0);
|
||||||
|
if (srcL) {
|
||||||
|
std::copy(srcL, srcL + frames, deviceOutputs[outCh]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use configured output routes
|
||||||
|
for (const auto& route : outputRoutes_) {
|
||||||
|
MixerBus* sourceBus = getBus(route.sourceBusId);
|
||||||
|
if (!sourceBus) continue;
|
||||||
|
|
||||||
|
for (int ch = 0; ch < route.channels; ++ch) {
|
||||||
|
uint32_t targetCh = (uint32_t)(route.targetStartChannel + ch);
|
||||||
|
if (targetCh >= outputChannels) break;
|
||||||
|
if (deviceOutputs[targetCh] == nullptr) continue;
|
||||||
|
|
||||||
|
const float* src = sourceBus->buffer(route.sourceStartChannel + ch);
|
||||||
|
if (src) {
|
||||||
|
std::copy(src, src + frames, deviceOutputs[targetCh]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MIDI Control Surface Mapping ---
|
||||||
|
|
||||||
|
bool MixerEngine::processMidiEvent(const MidiEvent& event)
|
||||||
|
{
|
||||||
|
midiMapper_.setMixerEngine(this);
|
||||||
|
return midiMapper_.processEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State Serialization ---
|
||||||
|
|
||||||
|
MixerEngine::MixerSnapshot MixerEngine::captureSnapshot() const
|
||||||
|
{
|
||||||
|
MixerSnapshot snap;
|
||||||
|
|
||||||
|
for (const auto& channel : channels_) {
|
||||||
|
MixerSnapshot::ChannelState cs;
|
||||||
|
cs.channelIndex = channel->channelIndex();
|
||||||
|
cs.volume = channel->volume();
|
||||||
|
cs.pan = channel->pan();
|
||||||
|
cs.mute = channel->mute();
|
||||||
|
cs.solo = channel->solo();
|
||||||
|
cs.channelType = channel->channelType();
|
||||||
|
cs.label = channel->label();
|
||||||
|
cs.hpEnabled = channel->hpEnabled();
|
||||||
|
cs.hpFrequency = channel->hpFrequency();
|
||||||
|
cs.vuLeft = channel->vuLeft();
|
||||||
|
cs.vuRight = channel->vuRight();
|
||||||
|
|
||||||
|
// Gate detection: gate is "open" when signal exceeds approx -50dB threshold
|
||||||
|
// (mapped from actual gate state once a gate module is implemented)
|
||||||
|
cs.gateOpen = (cs.vuLeft > -50.0f || cs.vuRight > -50.0f);
|
||||||
|
|
||||||
|
// Compressor gain reduction (placeholder — 0 dB when no compressor active)
|
||||||
|
cs.compressorReduction = 0.0f;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < channel->auxSendCount(); ++i) {
|
||||||
|
cs.auxSendLevels.push_back(channel->auxSend(i).level);
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.channels.push_back(cs);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& [id, bus] : buses_) {
|
||||||
|
MixerSnapshot::BusState bs;
|
||||||
|
bs.id = id;
|
||||||
|
bs.name = bus->name();
|
||||||
|
bs.type = bus->type();
|
||||||
|
bs.volume = bus->volume();
|
||||||
|
bs.mute = bus->mute();
|
||||||
|
bs.vuLeft = bus->vuLeft();
|
||||||
|
bs.vuRight = bus->vuRight();
|
||||||
|
snap.buses.push_back(bs);
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.routes = routes_;
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Output Routing ---
|
||||||
|
|
||||||
|
void MixerEngine::setOutputRoutes(const std::vector<MixerOutputRoute>& routes)
|
||||||
|
{
|
||||||
|
outputRoutes_ = routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::addOutputRoute(int64_t busId, int sourceStartChannel, int targetStartChannel, int channels)
|
||||||
|
{
|
||||||
|
// Remove any existing route that conflicts with the target
|
||||||
|
outputRoutes_.erase(
|
||||||
|
std::remove_if(outputRoutes_.begin(), outputRoutes_.end(),
|
||||||
|
[busId, targetStartChannel](const MixerOutputRoute& r) {
|
||||||
|
return r.sourceBusId == busId &&
|
||||||
|
r.targetStartChannel == targetStartChannel;
|
||||||
|
}),
|
||||||
|
outputRoutes_.end()
|
||||||
|
);
|
||||||
|
|
||||||
|
MixerOutputRoute route;
|
||||||
|
route.sourceBusId = busId;
|
||||||
|
route.sourceStartChannel = sourceStartChannel;
|
||||||
|
route.targetStartChannel = targetStartChannel;
|
||||||
|
route.channels = channels;
|
||||||
|
outputRoutes_.push_back(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::removeOutputRoutes(int64_t busId)
|
||||||
|
{
|
||||||
|
outputRoutes_.erase(
|
||||||
|
std::remove_if(outputRoutes_.begin(), outputRoutes_.end(),
|
||||||
|
[busId](const MixerOutputRoute& r) { return r.sourceBusId == busId; }),
|
||||||
|
outputRoutes_.end()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<MixerEngine::MixerOutputRoute> MixerEngine::findOutputRoutesForBus(int64_t busId) const
|
||||||
|
{
|
||||||
|
std::vector<MixerOutputRoute> result;
|
||||||
|
for (const auto& route : outputRoutes_) {
|
||||||
|
if (route.sourceBusId == busId) {
|
||||||
|
result.push_back(route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto channel creation ---
|
||||||
|
|
||||||
|
void MixerEngine::autoCreateChannels(uint32_t inputChannelCount, uint32_t outputChannelCount)
|
||||||
|
{
|
||||||
|
// Store the physical I/O channel counts for later queries
|
||||||
|
physicalInputCount_ = inputChannelCount;
|
||||||
|
physicalOutputCount_ = outputChannelCount;
|
||||||
|
|
||||||
|
// Remove existing channels if any
|
||||||
|
for (int i = (int)channels_.size() - 1; i >= 0; --i) {
|
||||||
|
removeChannel(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create one channel strip per input
|
||||||
|
for (uint32_t i = 0; i < inputChannelCount; ++i) {
|
||||||
|
addChannel((int)i);
|
||||||
|
auto* ch = getChannel((int)i);
|
||||||
|
if (ch) {
|
||||||
|
char label[32];
|
||||||
|
snprintf(label, sizeof(label), "Input %u", i + 1);
|
||||||
|
ch->setLabel(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default output routes:
|
||||||
|
// Always route master bus to physical 1-2 (the main monitor output)
|
||||||
|
// For multi-channel setups, individual channel routing is configured via the output routing UI.
|
||||||
|
outputRoutes_.clear();
|
||||||
|
|
||||||
|
if (!masterBus_) return;
|
||||||
|
|
||||||
|
MixerOutputRoute masterRoute;
|
||||||
|
masterRoute.sourceBusId = masterBus_->id();
|
||||||
|
masterRoute.sourceStartChannel = 0;
|
||||||
|
masterRoute.targetStartChannel = 0;
|
||||||
|
masterRoute.channels = 2;
|
||||||
|
outputRoutes_.push_back(masterRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MixerEngine::applySnapshot(const MixerSnapshot& snapshot)
|
||||||
|
{
|
||||||
|
// Apply channel states
|
||||||
|
for (const auto& cs : snapshot.channels) {
|
||||||
|
auto* channel = getChannel(cs.channelIndex);
|
||||||
|
if (!channel) continue;
|
||||||
|
|
||||||
|
channel->setVolume(cs.volume);
|
||||||
|
channel->setPan(cs.pan);
|
||||||
|
channel->setMute(cs.mute);
|
||||||
|
channel->setSolo(cs.solo);
|
||||||
|
channel->setChannelType(cs.channelType);
|
||||||
|
channel->setLabel(cs.label);
|
||||||
|
channel->setHpEnabled(cs.hpEnabled);
|
||||||
|
channel->setHpFrequency(cs.hpFrequency);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < cs.auxSendLevels.size() && i < channel->auxSendCount(); ++i) {
|
||||||
|
auto config = channel->auxSend(i);
|
||||||
|
config.level = cs.auxSendLevels[i];
|
||||||
|
channel->setAuxSend(i, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply bus states
|
||||||
|
for (const auto& bs : snapshot.buses) {
|
||||||
|
auto* bus = getBus(bs.id);
|
||||||
|
if (!bus) continue;
|
||||||
|
|
||||||
|
bus->setName(bs.name);
|
||||||
|
bus->setVolume(bs.volume);
|
||||||
|
bus->setMute(bs.mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace routes
|
||||||
|
routes_ = snapshot.routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// Copyright (c) 2026 Ourpad Network
|
||||||
|
// See LICENSE file in the project root for full license text.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
#include <map>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include "MixerChannelStrip.hpp"
|
||||||
|
#include "MixerBus.hpp"
|
||||||
|
#include "MidiMapper.hpp"
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
class MixerChannelStrip;
|
||||||
|
class MixerBus;
|
||||||
|
class Lv2PedalboardErrorList;
|
||||||
|
class IHost;
|
||||||
|
|
||||||
|
/// Routing entry: a source (channel or bus) feeds a target bus with a level.
|
||||||
|
struct MixerRouteEntry {
|
||||||
|
enum SourceType {
|
||||||
|
SourceChannel,
|
||||||
|
SourceBus
|
||||||
|
};
|
||||||
|
|
||||||
|
SourceType sourceType;
|
||||||
|
int64_t sourceId; // channel instanceId or bus ID
|
||||||
|
int64_t targetBusId; // the bus being fed into
|
||||||
|
float level = 0.0f; // dB
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The MixerEngine is the heart of the band-in-a-box digital mixer.
|
||||||
|
///
|
||||||
|
/// It owns and manages:
|
||||||
|
/// - N channel strips (MixerChannelStrip), one per physical/logical input
|
||||||
|
/// - M buses (MixerBus), including master, subgroups, aux sends
|
||||||
|
/// - A routing graph connecting channels to buses and buses to buses
|
||||||
|
///
|
||||||
|
/// Processing order per audio cycle:
|
||||||
|
/// 1. Clear all bus buffers
|
||||||
|
/// 2. For each channel: process FX chain → apply volume/pan → accumulate to routed buses
|
||||||
|
/// 3. For each aux send: calculate send level, accumulate to aux buses
|
||||||
|
/// 4. Route buses to buses according to routing matrix
|
||||||
|
/// 5. Process each bus (apply volume, compute VU)
|
||||||
|
/// 6. Master bus outputs are the final mix
|
||||||
|
///
|
||||||
|
/// All control methods are thread-safe for use from the non-RT thread.
|
||||||
|
/// The process() method runs in the RT audio thread.
|
||||||
|
class MixerEngine {
|
||||||
|
public:
|
||||||
|
MixerEngine();
|
||||||
|
~MixerEngine();
|
||||||
|
|
||||||
|
// Disable copy
|
||||||
|
MixerEngine(const MixerEngine&) = delete;
|
||||||
|
MixerEngine& operator=(const MixerEngine&) = delete;
|
||||||
|
|
||||||
|
/// --- Configuration ---
|
||||||
|
|
||||||
|
/// Set sample rate and max buffer size before preparation
|
||||||
|
void setSampleRate(uint32_t sampleRate);
|
||||||
|
void setMaxBufferSize(size_t frames);
|
||||||
|
|
||||||
|
/// --- Channel Management ---
|
||||||
|
|
||||||
|
/// Add a new channel strip for the given physical input index.
|
||||||
|
/// Returns a pointer to the new channel (valid until removed).
|
||||||
|
MixerChannelStrip* addChannel(int physicalInputIndex);
|
||||||
|
|
||||||
|
/// Auto-create channels based on the number of detected input channels.
|
||||||
|
/// Removes existing channels and creates one strip per input.
|
||||||
|
/// Also sets up default output routes.
|
||||||
|
/// @param inputChannelCount Number of physical input channels detected
|
||||||
|
/// @param outputChannelCount Number of physical output channels detected
|
||||||
|
void autoCreateChannels(uint32_t inputChannelCount, uint32_t outputChannelCount = 2);
|
||||||
|
|
||||||
|
/// Remove a channel by its channel index.
|
||||||
|
void removeChannel(int channelIndex);
|
||||||
|
|
||||||
|
/// Get a channel by index. Returns nullptr if not found.
|
||||||
|
MixerChannelStrip* getChannel(int channelIndex);
|
||||||
|
const MixerChannelStrip* getChannel(int channelIndex) const;
|
||||||
|
|
||||||
|
/// Number of channels currently in the mixer.
|
||||||
|
size_t channelCount() const { return channels_.size(); }
|
||||||
|
|
||||||
|
/// --- Bus Management ---
|
||||||
|
|
||||||
|
/// Add a new bus and return its ID.
|
||||||
|
int64_t addBus(MixerBusType type, const std::string& name, int channels = 2);
|
||||||
|
|
||||||
|
/// Remove a bus by ID.
|
||||||
|
void removeBus(int64_t busId);
|
||||||
|
|
||||||
|
/// Get a bus by ID. Returns nullptr if not found.
|
||||||
|
MixerBus* getBus(int64_t busId);
|
||||||
|
const MixerBus* getBus(int64_t busId) const;
|
||||||
|
|
||||||
|
/// Access the master bus (always present).
|
||||||
|
MixerBus* masterBus() { return masterBus_; }
|
||||||
|
const MixerBus* masterBus() const { return masterBus_; }
|
||||||
|
|
||||||
|
/// Get all bus IDs (for iteration).
|
||||||
|
std::vector<int64_t> busIds() const;
|
||||||
|
|
||||||
|
/// --- Routing ---
|
||||||
|
|
||||||
|
/// Route a channel to a bus with a given level in dB.
|
||||||
|
void routeChannelToBus(int channelIndex, int64_t busId, float levelDb = 0.0f);
|
||||||
|
|
||||||
|
/// Route a bus to another bus (e.g., subgroup to master).
|
||||||
|
void routeBusToBus(int64_t sourceBusId, int64_t targetBusId, float levelDb = 0.0f);
|
||||||
|
|
||||||
|
/// Remove a route.
|
||||||
|
void removeRoute(int64_t sourceId, int64_t targetBusId);
|
||||||
|
|
||||||
|
/// Clear all routes.
|
||||||
|
void clearRoutes();
|
||||||
|
|
||||||
|
/// --- Output Routing ---
|
||||||
|
|
||||||
|
/// Describes a mapping from a mixer bus to physical output channels.
|
||||||
|
/// Multiple routes can be active simultaneously (e.g. Master→1-2, Aux1→3-4).
|
||||||
|
struct MixerOutputRoute {
|
||||||
|
int64_t sourceBusId; // Bus to route from
|
||||||
|
int sourceStartChannel; // Starting channel on the bus (0=L, 1=R)
|
||||||
|
int targetStartChannel; // Starting physical output channel
|
||||||
|
int channels; // Number of consecutive channels to route (1 or 2 typically)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Get the current output routing table (bus → physical output channel mapping).
|
||||||
|
const std::vector<MixerOutputRoute>& outputRoutes() const { return outputRoutes_; }
|
||||||
|
|
||||||
|
/// Set the entire output routing table.
|
||||||
|
void setOutputRoutes(const std::vector<MixerOutputRoute>& routes);
|
||||||
|
|
||||||
|
/// Add a single output route.
|
||||||
|
void addOutputRoute(int64_t busId, int sourceStartChannel, int targetStartChannel, int channels);
|
||||||
|
|
||||||
|
/// Remove all output routes for a given bus.
|
||||||
|
void removeOutputRoutes(int64_t busId);
|
||||||
|
|
||||||
|
/// Get all routes for a given bus.
|
||||||
|
std::vector<MixerOutputRoute> findOutputRoutesForBus(int64_t busId) const;
|
||||||
|
|
||||||
|
/// Get all current routes.
|
||||||
|
const std::vector<MixerRouteEntry>& routes() const { return routes_; }
|
||||||
|
|
||||||
|
/// --- Physical I/O Info ---
|
||||||
|
|
||||||
|
/// Number of physical input channels detected on the audio device.
|
||||||
|
uint32_t physicalInputCount() const { return physicalInputCount_; }
|
||||||
|
/// Number of physical output channels detected on the audio device.
|
||||||
|
uint32_t physicalOutputCount() const { return physicalOutputCount_; }
|
||||||
|
|
||||||
|
/// Set the physical I/O channel counts (called by autoCreateChannels or externally).
|
||||||
|
void setPhysicalChannelCounts(uint32_t inputs, uint32_t outputs) {
|
||||||
|
physicalInputCount_ = inputs;
|
||||||
|
physicalOutputCount_ = outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// --- Audio Processing (real-time thread) ---
|
||||||
|
|
||||||
|
/// Prepare all channels and allocate buffers.
|
||||||
|
void Prepare(IHost* pHost, Lv2PedalboardErrorList& errorList);
|
||||||
|
|
||||||
|
/// Activate all channels.
|
||||||
|
void Activate();
|
||||||
|
|
||||||
|
/// Deactivate all channels.
|
||||||
|
void Deactivate();
|
||||||
|
|
||||||
|
/// Process one full mixer cycle.
|
||||||
|
/// deviceInputs/outputs are the raw audio interface buffers.
|
||||||
|
/// The mixer reads from inputs, processes through channels → buses, writes to outputs.
|
||||||
|
void process(
|
||||||
|
float** deviceInputs,
|
||||||
|
uint32_t inputChannels,
|
||||||
|
float** deviceOutputs,
|
||||||
|
uint32_t outputChannels,
|
||||||
|
uint32_t frames
|
||||||
|
);
|
||||||
|
|
||||||
|
/// --- Solo Management ---
|
||||||
|
|
||||||
|
/// True if any channel has solo engaged.
|
||||||
|
bool anySoloActive() const;
|
||||||
|
|
||||||
|
/// --- MIDI Control Surface Mapping ---
|
||||||
|
|
||||||
|
/// Access the MIDI mapper for CC control surface mapping.
|
||||||
|
MidiMapper& midiMapper() { return midiMapper_; }
|
||||||
|
const MidiMapper& midiMapper() const { return midiMapper_; }
|
||||||
|
|
||||||
|
/// Process a MIDI event (typically from the real-time audio thread).
|
||||||
|
/// Routes CC messages to the midi mapper. Returns true if consumed.
|
||||||
|
bool processMidiEvent(const struct MidiEvent& event);
|
||||||
|
|
||||||
|
/// --- State Serialization ---
|
||||||
|
|
||||||
|
struct MixerSnapshot {
|
||||||
|
struct ChannelState {
|
||||||
|
int channelIndex;
|
||||||
|
float volume;
|
||||||
|
float pan;
|
||||||
|
bool mute;
|
||||||
|
bool solo;
|
||||||
|
MixerChannelType channelType;
|
||||||
|
std::string label;
|
||||||
|
bool hpEnabled;
|
||||||
|
float hpFrequency;
|
||||||
|
std::vector<float> auxSendLevels; // indexed by aux bus index
|
||||||
|
float vuLeft = -96.0f; // dB — peak VU level
|
||||||
|
float vuRight = -96.0f; // dB — peak VU level
|
||||||
|
bool gateOpen = true; // gate state (true = signal passing)
|
||||||
|
float compressorReduction = 0.0f; // dB of gain reduction (0 = no reduction)
|
||||||
|
};
|
||||||
|
struct BusState {
|
||||||
|
int64_t id;
|
||||||
|
std::string name;
|
||||||
|
MixerBusType type;
|
||||||
|
float volume;
|
||||||
|
bool mute;
|
||||||
|
float vuLeft = -96.0f; // dB — peak VU level
|
||||||
|
float vuRight = -96.0f; // dB — peak VU level
|
||||||
|
};
|
||||||
|
std::vector<ChannelState> channels;
|
||||||
|
std::vector<BusState> buses;
|
||||||
|
std::vector<MixerRouteEntry> routes;
|
||||||
|
};
|
||||||
|
|
||||||
|
MixerSnapshot captureSnapshot() const;
|
||||||
|
void applySnapshot(const MixerSnapshot& snapshot);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::unique_ptr<MixerChannelStrip>> channels_;
|
||||||
|
std::map<int64_t, std::unique_ptr<MixerBus>> buses_;
|
||||||
|
MixerBus* masterBus_ = nullptr;
|
||||||
|
|
||||||
|
// Routing entries
|
||||||
|
std::vector<MixerRouteEntry> routes_;
|
||||||
|
|
||||||
|
// Output routing entries (bus → physical output channel mapping)
|
||||||
|
std::vector<MixerOutputRoute> outputRoutes_;
|
||||||
|
|
||||||
|
// Audio configuration
|
||||||
|
uint32_t sampleRate_ = 48000;
|
||||||
|
size_t maxBufferSize_ = 512;
|
||||||
|
|
||||||
|
// IHost reference for FX preparation
|
||||||
|
IHost* pHost_ = nullptr;
|
||||||
|
|
||||||
|
// MIDI CC control surface mapper
|
||||||
|
MidiMapper midiMapper_;
|
||||||
|
|
||||||
|
// Next bus ID counter
|
||||||
|
static std::atomic<int64_t> nextBusId_;
|
||||||
|
|
||||||
|
// Temporary per-channel output buffers for routing
|
||||||
|
// Allocated once at prepare time
|
||||||
|
std::vector<std::vector<float>> channelOutputBuffers_;
|
||||||
|
|
||||||
|
// Physical I/O channel counts (from audio device auto-detection)
|
||||||
|
uint32_t physicalInputCount_ = 0;
|
||||||
|
uint32_t physicalOutputCount_ = 0;
|
||||||
|
|
||||||
|
// Internal helper: accumulate a channel's output to all its routed buses
|
||||||
|
void routeChannelOutput(
|
||||||
|
MixerChannelStrip* channel,
|
||||||
|
float** channelOutput,
|
||||||
|
uint32_t frames
|
||||||
|
);
|
||||||
|
|
||||||
|
// Internal helper: process all bus-to-bus routing
|
||||||
|
void processBusRouting(uint32_t frames);
|
||||||
|
|
||||||
|
// Build a list of all routes from a given source
|
||||||
|
std::vector<MixerRouteEntry*> findRoutesForSource(int64_t sourceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
#include "CpuGovernor.hpp"
|
#include "CpuGovernor.hpp"
|
||||||
#include "RegDb.hpp"
|
#include "RegDb.hpp"
|
||||||
#include "RingBufferReader.hpp"
|
#include "RingBufferReader.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
#include "PiPedalUI.hpp"
|
#include "PiPedalUI.hpp"
|
||||||
#include "atom_object.hpp"
|
#include "atom_object.hpp"
|
||||||
#include "Lv2PluginChangeMonitor.hpp"
|
#include "Lv2PluginChangeMonitor.hpp"
|
||||||
@@ -438,7 +439,7 @@ void PiPedalModel::Load()
|
|||||||
|
|
||||||
UpdateDefaults(&this->pedalboard);
|
UpdateDefaults(&this->pedalboard);
|
||||||
|
|
||||||
std::unique_ptr<AudioHost> p{AudioHost::CreateInstance(pluginHost.asIHost())};
|
std::unique_ptr<AudioHost> p{AudioHost::CreateInstance(pluginHost.asIHost(), this->driverType_)};
|
||||||
this->audioHost = std::move(p);
|
this->audioHost = std::move(p);
|
||||||
|
|
||||||
this->audioHost->SetNotificationCallbacks(this);
|
this->audioHost->SetNotificationCallbacks(this);
|
||||||
@@ -3081,6 +3082,33 @@ std::shared_ptr<Lv2Pedalboard> PiPedalModel::GetLv2Pedalboard()
|
|||||||
return lv2Pedalboard;
|
return lv2Pedalboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PiPedalModel::SetMixerEngine(const std::shared_ptr<MixerEngine>& engine)
|
||||||
|
{
|
||||||
|
this->mixerEngine = engine;
|
||||||
|
if (this->audioHost)
|
||||||
|
{
|
||||||
|
this->audioHost->SetMixerEngine(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engine && this->audioHost && this->audioHost->IsOpen())
|
||||||
|
{
|
||||||
|
// Auto-create channels based on detected input channel count
|
||||||
|
uint32_t inputChannels = this->audioHost->GetDeviceCaptureChannels();
|
||||||
|
if (inputChannels == 0) inputChannels = 2; // default to stereo
|
||||||
|
|
||||||
|
engine->autoCreateChannels(inputChannels);
|
||||||
|
|
||||||
|
Lv2Log::info(SS("Mixer engine initialized with "
|
||||||
|
<< inputChannels << " channels, "
|
||||||
|
<< engine->channelCount() << " strips created."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved MIDI control surface mappings
|
||||||
|
if (engine) {
|
||||||
|
engine->midiMapper().loadFromFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void PiPedalModel::SetSelectedPedalboardPlugin(uint64_t clientId, uint64_t pedalboardId)
|
void PiPedalModel::SetSelectedPedalboardPlugin(uint64_t clientId, uint64_t pedalboardId)
|
||||||
{
|
{
|
||||||
// Thinking on this:
|
// Thinking on this:
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ namespace pipedal
|
|||||||
std::unique_ptr<AudioHost> audioHost;
|
std::unique_ptr<AudioHost> audioHost;
|
||||||
JackConfiguration jackConfiguration;
|
JackConfiguration jackConfiguration;
|
||||||
std::shared_ptr<Lv2Pedalboard> lv2Pedalboard;
|
std::shared_ptr<Lv2Pedalboard> lv2Pedalboard;
|
||||||
|
std::shared_ptr<MixerEngine> mixerEngine;
|
||||||
std::filesystem::path webRoot;
|
std::filesystem::path webRoot;
|
||||||
|
|
||||||
using SubscriberList = std::vector<std::shared_ptr<IPiPedalModelSubscriber>>;
|
using SubscriberList = std::vector<std::shared_ptr<IPiPedalModelSubscriber>>;
|
||||||
@@ -285,6 +286,7 @@ namespace pipedal
|
|||||||
void UpdateVst3Settings(Pedalboard &pedalboard);
|
void UpdateVst3Settings(Pedalboard &pedalboard);
|
||||||
|
|
||||||
PiPedalConfiguration configuration;
|
PiPedalConfiguration configuration;
|
||||||
|
std::string driverType_ = "alsa";
|
||||||
|
|
||||||
void CheckForResourceInitialization(Pedalboard &pedalboard);
|
void CheckForResourceInitialization(Pedalboard &pedalboard);
|
||||||
UiFileProperty::ptr FindLoadedPatchProperty(int64_t instanceId, const std::string &patchPropertyUri);
|
UiFileProperty::ptr FindLoadedPatchProperty(int64_t instanceId, const std::string &patchPropertyUri);
|
||||||
@@ -293,6 +295,9 @@ namespace pipedal
|
|||||||
PiPedalModel();
|
PiPedalModel();
|
||||||
virtual ~PiPedalModel();
|
virtual ~PiPedalModel();
|
||||||
|
|
||||||
|
void SetDriverType(const std::string &driverType) { driverType_ = driverType; }
|
||||||
|
const std::string &GetDriverType() const { return driverType_; }
|
||||||
|
|
||||||
enum class Direction
|
enum class Direction
|
||||||
{
|
{
|
||||||
Increase,
|
Increase,
|
||||||
@@ -401,6 +406,8 @@ namespace pipedal
|
|||||||
void SetPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
void SetPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
||||||
Pedalboard &GetPedalboard();
|
Pedalboard &GetPedalboard();
|
||||||
std::shared_ptr<Lv2Pedalboard> GetLv2Pedalboard();
|
std::shared_ptr<Lv2Pedalboard> GetLv2Pedalboard();
|
||||||
|
std::shared_ptr<MixerEngine> GetMixerEngine() { return mixerEngine; }
|
||||||
|
void SetMixerEngine(const std::shared_ptr<MixerEngine>& engine);
|
||||||
|
|
||||||
void UpdateCurrentPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
void UpdateCurrentPedalboard(int64_t clientId, Pedalboard &pedalboard);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
#include "Curl.hpp"
|
#include "Curl.hpp"
|
||||||
#include "PiPedalSocket.hpp"
|
#include "PiPedalSocket.hpp"
|
||||||
|
#include "MixerApi.hpp"
|
||||||
#include "Updater.hpp"
|
#include "Updater.hpp"
|
||||||
#include "json.hpp"
|
#include "json.hpp"
|
||||||
#include "viewstream.hpp"
|
#include "viewstream.hpp"
|
||||||
@@ -707,6 +708,7 @@ private:
|
|||||||
|
|
||||||
std::recursive_mutex writeMutex;
|
std::recursive_mutex writeMutex;
|
||||||
PiPedalModel &model;
|
PiPedalModel &model;
|
||||||
|
MixerApi mixerApi;
|
||||||
static std::atomic<uint64_t> nextClientId;
|
static std::atomic<uint64_t> nextClientId;
|
||||||
std::string imageList;
|
std::string imageList;
|
||||||
|
|
||||||
@@ -814,6 +816,12 @@ public:
|
|||||||
PiPedalSocketHandler(PiPedalModel &model)
|
PiPedalSocketHandler(PiPedalModel &model)
|
||||||
: model(model), clientId(++nextClientId)
|
: model(model), clientId(++nextClientId)
|
||||||
{
|
{
|
||||||
|
// Wire MixerEngine to MixerApi if available
|
||||||
|
auto engine = model.GetMixerEngine();
|
||||||
|
if (engine) {
|
||||||
|
this->mixerApi.setMixerEngine(engine.get());
|
||||||
|
}
|
||||||
|
|
||||||
std::stringstream imageList;
|
std::stringstream imageList;
|
||||||
const std::filesystem::path &webRoot = model.GetWebRoot() / "img";
|
const std::filesystem::path &webRoot = model.GetWebRoot() / "img";
|
||||||
bool firstTime = true;
|
bool firstTime = true;
|
||||||
@@ -1217,6 +1225,289 @@ public:
|
|||||||
}
|
}
|
||||||
REGISTER_MESSAGE_HANDLER(writeTone3000Readme)
|
REGISTER_MESSAGE_HANDLER(writeTone3000Readme)
|
||||||
|
|
||||||
|
/************************************************************************/
|
||||||
|
/* Band-in-a-Box Mixer Messages */
|
||||||
|
/************************************************************************/
|
||||||
|
|
||||||
|
void handle_mixerSetChannelVolume(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
double volume;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&volume);
|
||||||
|
this->mixerApi.setChannelVolume(channelIndex, (float)volume);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelVolume)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelPan(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
double pan;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&pan);
|
||||||
|
this->mixerApi.setChannelPan(channelIndex, (float)pan);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelPan)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelMute(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
bool mute;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&mute);
|
||||||
|
this->mixerApi.setChannelMute(channelIndex, mute);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelMute)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelSolo(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
bool solo;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&solo);
|
||||||
|
this->mixerApi.setChannelSolo(channelIndex, solo);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelSolo)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelLabel(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
std::string label;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&label);
|
||||||
|
this->mixerApi.setChannelLabel(channelIndex, label);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelLabel)
|
||||||
|
|
||||||
|
void handle_mixerSetChannelHpf(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
bool enabled;
|
||||||
|
double frequency;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&enabled);
|
||||||
|
pReader->read(&frequency);
|
||||||
|
this->mixerApi.setChannelHpf(channelIndex, enabled, (float)frequency);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetChannelHpf)
|
||||||
|
|
||||||
|
void handle_mixerGetState(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string stateJson = this->mixerApi.getStateJson();
|
||||||
|
this->JsonReply(replyTo, "mixerState", stateJson.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetState)
|
||||||
|
|
||||||
|
void handle_mixerAddChannel(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int physicalInputIndex;
|
||||||
|
pReader->read(&physicalInputIndex);
|
||||||
|
int channelIndex = this->mixerApi.addChannel(physicalInputIndex);
|
||||||
|
this->Reply(replyTo, "mixerAddChannel", (int64_t)channelIndex);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerAddChannel)
|
||||||
|
|
||||||
|
void handle_mixerRemoveChannel(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
this->mixerApi.removeChannel(channelIndex);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerRemoveChannel)
|
||||||
|
|
||||||
|
void handle_mixerSetBusVolume(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t busId;
|
||||||
|
double volume;
|
||||||
|
pReader->read(&busId);
|
||||||
|
pReader->read(&volume);
|
||||||
|
this->mixerApi.setBusVolume(busId, (float)volume);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetBusVolume)
|
||||||
|
|
||||||
|
void handle_mixerSetBusMute(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t busId;
|
||||||
|
bool mute;
|
||||||
|
pReader->read(&busId);
|
||||||
|
pReader->read(&mute);
|
||||||
|
this->mixerApi.setBusMute(busId, mute);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetBusMute)
|
||||||
|
|
||||||
|
void handle_mixerRouteChannelToBus(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int channelIndex;
|
||||||
|
int64_t busId;
|
||||||
|
double level;
|
||||||
|
pReader->read(&channelIndex);
|
||||||
|
pReader->read(&busId);
|
||||||
|
pReader->read(&level);
|
||||||
|
this->mixerApi.routeChannelToBus(channelIndex, busId, (float)level);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerRouteChannelToBus)
|
||||||
|
|
||||||
|
void handle_mixerAddBus(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string type;
|
||||||
|
std::string name;
|
||||||
|
int channels;
|
||||||
|
pReader->read(&type);
|
||||||
|
pReader->read(&name);
|
||||||
|
pReader->read(&channels);
|
||||||
|
int64_t busId = this->mixerApi.addBus(type, name, channels);
|
||||||
|
this->Reply(replyTo, "mixerAddBus", busId);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerAddBus)
|
||||||
|
|
||||||
|
void handle_mixerRemoveBus(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t busId;
|
||||||
|
pReader->read(&busId);
|
||||||
|
this->mixerApi.removeBus(busId);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerRemoveBus)
|
||||||
|
|
||||||
|
void handle_mixerSaveScene(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t sceneId;
|
||||||
|
std::string name;
|
||||||
|
pReader->read(&sceneId);
|
||||||
|
pReader->read(&name);
|
||||||
|
std::string result = this->mixerApi.saveScene(name);
|
||||||
|
this->JsonReply(replyTo, "mixerSaveScene", result.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSaveScene)
|
||||||
|
|
||||||
|
void handle_mixerLoadScene(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int64_t sceneId;
|
||||||
|
pReader->read(&sceneId);
|
||||||
|
bool ok = this->mixerApi.loadScene(std::to_string(sceneId));
|
||||||
|
this->Reply(replyTo, "mixerLoadScene", ok);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerLoadScene)
|
||||||
|
|
||||||
|
void handle_mixerGetScenes(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string result = this->mixerApi.listScenes();
|
||||||
|
this->JsonReply(replyTo, "mixerGetScenes", result.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetScenes)
|
||||||
|
|
||||||
|
/************************************************************************/
|
||||||
|
/* Mixer Output Routing Messages */
|
||||||
|
/************************************************************************/
|
||||||
|
|
||||||
|
void handle_mixerGetOutputRoutes(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string json = this->mixerApi.getOutputRoutesJson();
|
||||||
|
this->JsonReply(replyTo, "mixerOutputRoutes", json.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetOutputRoutes)
|
||||||
|
|
||||||
|
void handle_mixerSetOutputRoutes(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string json;
|
||||||
|
pReader->read(&json);
|
||||||
|
this->mixerApi.setOutputRoutesFromJson(json);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetOutputRoutes)
|
||||||
|
|
||||||
|
/************************************************************************/
|
||||||
|
/* MIDI Control Surface Mapping Messages */
|
||||||
|
/************************************************************************/
|
||||||
|
|
||||||
|
void handle_mixerGetMidiMappings(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string json = this->mixerApi.getMidiMappingsJson();
|
||||||
|
this->JsonReply(replyTo, "mixerMidiMappings", json.c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetMidiMappings)
|
||||||
|
|
||||||
|
void handle_mixerSetMidiMappings(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string json;
|
||||||
|
pReader->read(&json);
|
||||||
|
this->mixerApi.setMidiMappingsFromJson(json);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetMidiMappings)
|
||||||
|
|
||||||
|
void handle_mixerAddMidiMapping(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int midiChannel;
|
||||||
|
int ccNumber;
|
||||||
|
std::string targetType;
|
||||||
|
int64_t targetId;
|
||||||
|
double minValue = 0.0;
|
||||||
|
double maxValue = 1.0;
|
||||||
|
pReader->read(&midiChannel);
|
||||||
|
pReader->read(&ccNumber);
|
||||||
|
pReader->read(&targetType);
|
||||||
|
pReader->read(&targetId);
|
||||||
|
pReader->read(&minValue);
|
||||||
|
pReader->read(&maxValue);
|
||||||
|
this->mixerApi.addMidiMapping(
|
||||||
|
midiChannel, ccNumber, targetType, targetId,
|
||||||
|
(float)minValue, (float)maxValue);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerAddMidiMapping)
|
||||||
|
|
||||||
|
void handle_mixerRemoveMidiMapping(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
int midiChannel;
|
||||||
|
int ccNumber;
|
||||||
|
pReader->read(&midiChannel);
|
||||||
|
pReader->read(&ccNumber);
|
||||||
|
this->mixerApi.removeMidiMapping(midiChannel, ccNumber);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerRemoveMidiMapping)
|
||||||
|
|
||||||
|
void handle_mixerClearMidiMappings(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
this->mixerApi.clearMidiMappings();
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerClearMidiMappings)
|
||||||
|
|
||||||
|
void handle_mixerSetMidiLearnMode(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
bool enabled;
|
||||||
|
pReader->read(&enabled);
|
||||||
|
this->mixerApi.setMidiLearnMode(enabled);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetMidiLearnMode)
|
||||||
|
|
||||||
|
void handle_mixerSetMidiLearnTarget(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
std::string targetType;
|
||||||
|
int64_t targetId;
|
||||||
|
pReader->read(&targetType);
|
||||||
|
pReader->read(&targetId);
|
||||||
|
this->mixerApi.setMidiLearnTarget(targetType, targetId);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerSetMidiLearnTarget)
|
||||||
|
|
||||||
|
void handle_mixerCommitMidiLearn(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
bool success = this->mixerApi.commitMidiLearnMapping();
|
||||||
|
this->Reply(replyTo, "mixerCommitMidiLearn", success);
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerCommitMidiLearn)
|
||||||
|
|
||||||
|
void handle_mixerGetLastLearnedMidiEvent(int replyTo, json_reader *pReader)
|
||||||
|
{
|
||||||
|
auto info = this->mixerApi.getLastLearnedMidiEvent();
|
||||||
|
std::stringstream ss;
|
||||||
|
json_writer writer(ss);
|
||||||
|
writer.start_object();
|
||||||
|
writer.write_member("hasEvent", info.hasEvent);
|
||||||
|
writer.write_member("midiChannel", (int64_t)info.midiChannel);
|
||||||
|
writer.write_member("ccNumber", (int64_t)info.ccNumber);
|
||||||
|
writer.end_object();
|
||||||
|
this->JsonReply(replyTo, "mixerLastLearnedMidiEvent", ss.str().c_str());
|
||||||
|
}
|
||||||
|
REGISTER_MESSAGE_HANDLER(mixerGetLastLearnedMidiEvent)
|
||||||
|
|
||||||
void handle_sha256Base64url(int replyTo, json_reader *pReader)
|
void handle_sha256Base64url(int replyTo, json_reader *pReader)
|
||||||
{
|
{
|
||||||
std::string input;
|
std::string input;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ static std::string MakeWebAddress(const std::string &address, uint16_t port)
|
|||||||
|
|
||||||
PiPedalVersion::PiPedalVersion(PiPedalModel&model)
|
PiPedalVersion::PiPedalVersion(PiPedalModel&model)
|
||||||
{
|
{
|
||||||
server_ = "PiPedal Server";
|
server_ = "OP-Pedal / Ourpad Pedal Server";
|
||||||
// defined on build command line.
|
// defined on build command line.
|
||||||
serverVersion_ = PROJECT_DISPLAY_VERSION;
|
serverVersion_ = PROJECT_DISPLAY_VERSION;
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
|||||||
@@ -0,0 +1,911 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) Robin E.R. Davies
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to
|
||||||
|
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
* of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
* so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "pch.h"
|
||||||
|
#include "PiPedalCommon.hpp"
|
||||||
|
#include "util.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
#include "Finally.hpp"
|
||||||
|
#include <bit>
|
||||||
|
#include <memory>
|
||||||
|
#include "ss.hpp"
|
||||||
|
#include "PipeWireDriver.hpp"
|
||||||
|
#include "JackServerSettings.hpp"
|
||||||
|
#include <thread>
|
||||||
|
#include "RtInversionGuard.hpp"
|
||||||
|
#include "PiPedalException.hpp"
|
||||||
|
#include "SchedulerPriority.hpp"
|
||||||
|
#include "CrashGuard.hpp"
|
||||||
|
#include <iostream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include "ChannelRouterSettings.hpp"
|
||||||
|
|
||||||
|
#include "CpuUse.hpp"
|
||||||
|
|
||||||
|
#include <pipewire/pipewire.h>
|
||||||
|
#include <pipewire/filter.h>
|
||||||
|
#include <spa/param/audio/format-utils.h>
|
||||||
|
#include <spa/param/props.h>
|
||||||
|
|
||||||
|
#include "Lv2Log.hpp"
|
||||||
|
#include <limits>
|
||||||
|
#include "ss.hpp"
|
||||||
|
|
||||||
|
using namespace pipedal;
|
||||||
|
|
||||||
|
namespace pipedal
|
||||||
|
{
|
||||||
|
|
||||||
|
class PipeWireDriverImpl : public AudioDriver
|
||||||
|
{
|
||||||
|
private:
|
||||||
|
// ---- PipeWire state ----
|
||||||
|
pw_filter *filter = nullptr;
|
||||||
|
pw_thread_loop *threadLoop = nullptr;
|
||||||
|
|
||||||
|
void *inputPortData = nullptr;
|
||||||
|
void *outputPortData = nullptr;
|
||||||
|
|
||||||
|
bool pwInitialized = false;
|
||||||
|
|
||||||
|
// ---- Audio parameters ----
|
||||||
|
uint32_t sampleRate = 0;
|
||||||
|
uint32_t bufferSize = 0;
|
||||||
|
uint32_t captureChannels = 0;
|
||||||
|
uint32_t playbackChannels = 0;
|
||||||
|
|
||||||
|
// ---- Buffer management (mirrors AlsaDriver pattern) ----
|
||||||
|
std::vector<std::vector<float>> allocatedBuffers;
|
||||||
|
|
||||||
|
std::vector<float *> deviceCaptureBuffers;
|
||||||
|
std::vector<float *> devicePlaybackBuffers;
|
||||||
|
float *zeroInputBuffer = nullptr;
|
||||||
|
float *discardOutputBuffer = nullptr;
|
||||||
|
std::vector<float *> mainCaptureBuffers;
|
||||||
|
std::vector<float *> mainPlaybackBuffers;
|
||||||
|
std::vector<float *> auxCaptureBuffers;
|
||||||
|
std::vector<float *> auxPlaybackBuffers;
|
||||||
|
|
||||||
|
// ---- Mix ops for channel routing ----
|
||||||
|
using MixOp = std::function<void(size_t nFrames)>;
|
||||||
|
std::vector<MixOp> mixOps;
|
||||||
|
|
||||||
|
void AddMixCopyOp(float *inputBuffer, float *outputBuffer)
|
||||||
|
{
|
||||||
|
mixOps.push_back([inputBuffer, outputBuffer](size_t nFrames)
|
||||||
|
{
|
||||||
|
float * PIPEDAL_RESTRICT pIn = inputBuffer;
|
||||||
|
float * PIPEDAL_RESTRICT pOut = outputBuffer;
|
||||||
|
for (size_t i = 0; i < nFrames; ++i)
|
||||||
|
pOut[i] = pIn[i]; });
|
||||||
|
}
|
||||||
|
void AddMixAddOp(float *inputBuffer, float *outputBuffer)
|
||||||
|
{
|
||||||
|
mixOps.push_back([inputBuffer, outputBuffer](size_t nFrames)
|
||||||
|
{
|
||||||
|
float * PIPEDAL_RESTRICT pIn = inputBuffer;
|
||||||
|
float * PIPEDAL_RESTRICT pOut = outputBuffer;
|
||||||
|
for (size_t i = 0; i < nFrames; ++i)
|
||||||
|
pOut[i] += pIn[i]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CPU monitoring ----
|
||||||
|
pipedal::CpuUse cpuUse;
|
||||||
|
|
||||||
|
// ---- Lifecycle state ----
|
||||||
|
bool open = false;
|
||||||
|
bool activated = false;
|
||||||
|
bool isDummyDriver = false;
|
||||||
|
|
||||||
|
AudioDriverHost *driverHost = nullptr;
|
||||||
|
ChannelSelection channelSelection;
|
||||||
|
JackServerSettings jackServerSettings;
|
||||||
|
AlsaSequencer::ptr alsaSequencer;
|
||||||
|
|
||||||
|
// ---- MIDI (unused by PipeWire, sequencer handles it) ----
|
||||||
|
std::vector<MidiEvent> midiEvents;
|
||||||
|
size_t midiEventCount = 0;
|
||||||
|
|
||||||
|
public:
|
||||||
|
PipeWireDriverImpl(AudioDriverHost *driverHost)
|
||||||
|
: driverHost(driverHost)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual ~PipeWireDriverImpl()
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// AudioDriver interface implementation
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
virtual float CpuUse() override
|
||||||
|
{
|
||||||
|
return cpuUse.GetCpuUse();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual float CpuOverhead() override
|
||||||
|
{
|
||||||
|
return cpuUse.GetCpuOverhead();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetSampleRate() override
|
||||||
|
{
|
||||||
|
return this->sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDeviceCaptureChannels() const override
|
||||||
|
{
|
||||||
|
return this->captureChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual uint32_t GetDevicePlaybackChannels() const override
|
||||||
|
{
|
||||||
|
return this->playbackChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual size_t GetMidiInputEventCount() override
|
||||||
|
{
|
||||||
|
return midiEventCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual MidiEvent *GetMidiEvents() override
|
||||||
|
{
|
||||||
|
return this->midiEvents.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual const ChannelSelection &GetChannelSelection() const override
|
||||||
|
{
|
||||||
|
return channelSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &DeviceInputBuffers() override { return this->deviceCaptureBuffers; }
|
||||||
|
virtual size_t DeviceInputBufferCount() const override { return deviceCaptureBuffers.size(); }
|
||||||
|
virtual float *GetDeviceInputBuffer(size_t channel) const override
|
||||||
|
{
|
||||||
|
if (channel >= deviceCaptureBuffers.size())
|
||||||
|
return nullptr;
|
||||||
|
return deviceCaptureBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &DeviceOutputBuffers() override { return this->devicePlaybackBuffers; }
|
||||||
|
virtual size_t DeviceOutputBufferCount() const override { return devicePlaybackBuffers.size(); }
|
||||||
|
virtual float *GetDeviceOutputBuffer(size_t channel) const override
|
||||||
|
{
|
||||||
|
if (channel >= devicePlaybackBuffers.size())
|
||||||
|
return nullptr;
|
||||||
|
return devicePlaybackBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &MainInputBuffers() override { return this->mainCaptureBuffers; }
|
||||||
|
virtual size_t MainInputBufferCount() const override { return mainCaptureBuffers.size(); }
|
||||||
|
virtual float *GetMainInputBuffer(size_t channel) override
|
||||||
|
{
|
||||||
|
if (channel >= (int64_t)mainCaptureBuffers.size())
|
||||||
|
throw std::runtime_error("Argument out of range.");
|
||||||
|
return mainCaptureBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &MainOutputBuffers() override { return this->mainPlaybackBuffers; }
|
||||||
|
virtual size_t MainOutputBufferCount() const override { return mainPlaybackBuffers.size(); }
|
||||||
|
virtual float *GetMainOutputBuffer(size_t channel) override
|
||||||
|
{
|
||||||
|
return mainPlaybackBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &AuxInputBuffers() override { return this->auxCaptureBuffers; }
|
||||||
|
virtual size_t AuxInputBufferCount() const override { return auxCaptureBuffers.size(); }
|
||||||
|
virtual float *GetAuxInputBuffer(size_t channel) override
|
||||||
|
{
|
||||||
|
return auxCaptureBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual std::vector<float *> &AuxOutputBuffers() override { return this->auxPlaybackBuffers; }
|
||||||
|
virtual size_t AuxOutputBufferCount() const override { return auxPlaybackBuffers.size(); }
|
||||||
|
virtual float *GetAuxOutputBuffer(size_t channel) override
|
||||||
|
{
|
||||||
|
return auxPlaybackBuffers[channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual float *GetZeroInputBuffer() override
|
||||||
|
{
|
||||||
|
if (zeroInputBuffer == nullptr)
|
||||||
|
zeroInputBuffer = AllocateAudioBuffer();
|
||||||
|
return zeroInputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual float *GetDiscardOutputBuffer() override
|
||||||
|
{
|
||||||
|
if (discardOutputBuffer == nullptr)
|
||||||
|
discardOutputBuffer = AllocateAudioBuffer();
|
||||||
|
return discardOutputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Open - Initialize PipeWire and create the filter
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void Open(const JackServerSettings &jackServerSettings_,
|
||||||
|
const ChannelSelection &channelSelection_) override
|
||||||
|
{
|
||||||
|
if (open)
|
||||||
|
throw PiPedalStateException("Already open.");
|
||||||
|
|
||||||
|
this->jackServerSettings = jackServerSettings_;
|
||||||
|
this->channelSelection = channelSelection_;
|
||||||
|
this->isDummyDriver = jackServerSettings_.IsDummyAudioDevice();
|
||||||
|
this->bufferSize = jackServerSettings_.GetBufferSize();
|
||||||
|
this->sampleRate = (uint32_t)jackServerSettings_.GetSampleRate();
|
||||||
|
|
||||||
|
if (this->sampleRate == 0)
|
||||||
|
this->sampleRate = 48000;
|
||||||
|
if (this->bufferSize == 0)
|
||||||
|
this->bufferSize = 256;
|
||||||
|
|
||||||
|
open = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!pwInitialized)
|
||||||
|
{
|
||||||
|
pw_init(nullptr, nullptr);
|
||||||
|
pwInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create thread loop for non-blocking lifecycle management
|
||||||
|
threadLoop = pw_thread_loop_new("pipedal-pw-loop", nullptr);
|
||||||
|
if (!threadLoop)
|
||||||
|
{
|
||||||
|
throw PiPedalStateException("Failed to create PipeWire thread loop.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filter using the thread loop's pw_loop
|
||||||
|
struct pw_properties *props = pw_properties_new(
|
||||||
|
PW_KEY_NODE_NAME, "PiPedal",
|
||||||
|
PW_KEY_NODE_DESCRIPTION, "PiPedal Guitar Effects Processor",
|
||||||
|
PW_KEY_MEDIA_NAME, "PiPedal Audio",
|
||||||
|
PW_KEY_MEDIA_TYPE, "Audio",
|
||||||
|
PW_KEY_MEDIA_CATEGORY, "Filter",
|
||||||
|
PW_KEY_MEDIA_CLASS, "Audio/Sink",
|
||||||
|
PW_KEY_APP_NAME, "PiPedal",
|
||||||
|
PW_KEY_APP_PROCESS_BINARY, "pipedald",
|
||||||
|
PW_KEY_PRIORITY_SESSION, "0",
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
static const struct pw_filter_events filterEvents = {
|
||||||
|
.version = PW_VERSION_FILTER_EVENTS,
|
||||||
|
.state_changed = on_filter_state_changed_static,
|
||||||
|
.process = on_filter_process_static,
|
||||||
|
};
|
||||||
|
|
||||||
|
filter = pw_filter_new_simple(
|
||||||
|
pw_thread_loop_get_loop(threadLoop),
|
||||||
|
"PiPedal",
|
||||||
|
props,
|
||||||
|
&filterEvents,
|
||||||
|
this);
|
||||||
|
|
||||||
|
if (!filter)
|
||||||
|
{
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
throw PiPedalStateException("Failed to create PipeWire filter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine channel count from channel selection
|
||||||
|
captureChannels = (uint32_t)channelSelection_.mainInputChannels().size();
|
||||||
|
playbackChannels = (uint32_t)channelSelection_.mainOutputChannels().size();
|
||||||
|
|
||||||
|
if (captureChannels == 0)
|
||||||
|
captureChannels = 2; // default stereo
|
||||||
|
if (playbackChannels == 0)
|
||||||
|
playbackChannels = 2;
|
||||||
|
|
||||||
|
// Build audio format params for input (capture) port
|
||||||
|
{
|
||||||
|
uint8_t buffer[1024];
|
||||||
|
spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||||
|
|
||||||
|
spa_audio_info_raw audioFormat = {};
|
||||||
|
audioFormat.format = SPA_AUDIO_FORMAT_F32;
|
||||||
|
audioFormat.rate = this->sampleRate;
|
||||||
|
audioFormat.channels = captureChannels;
|
||||||
|
|
||||||
|
// Set channel positions
|
||||||
|
SetChannelPositions(audioFormat, captureChannels);
|
||||||
|
|
||||||
|
const spa_pod *params[1];
|
||||||
|
params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &audioFormat);
|
||||||
|
|
||||||
|
inputPortData = pw_filter_add_port(
|
||||||
|
filter,
|
||||||
|
PW_DIRECTION_INPUT,
|
||||||
|
PW_FILTER_PORT_FLAG_MAP_BUFFERS,
|
||||||
|
sizeof(void *),
|
||||||
|
pw_properties_new(
|
||||||
|
PW_KEY_PORT_NAME, "Input",
|
||||||
|
PW_KEY_AUDIO_CHANNELS, std::to_string(captureChannels).c_str(),
|
||||||
|
nullptr),
|
||||||
|
params, 1);
|
||||||
|
|
||||||
|
if (!inputPortData)
|
||||||
|
{
|
||||||
|
pw_filter_destroy(filter);
|
||||||
|
filter = nullptr;
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
throw PiPedalStateException("Failed to add PipeWire input port.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build audio format params for output (playback) port
|
||||||
|
{
|
||||||
|
uint8_t buffer[1024];
|
||||||
|
spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||||
|
|
||||||
|
spa_audio_info_raw audioFormat = {};
|
||||||
|
audioFormat.format = SPA_AUDIO_FORMAT_F32;
|
||||||
|
audioFormat.rate = this->sampleRate;
|
||||||
|
audioFormat.channels = playbackChannels;
|
||||||
|
|
||||||
|
SetChannelPositions(audioFormat, playbackChannels);
|
||||||
|
|
||||||
|
const spa_pod *params[1];
|
||||||
|
params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &audioFormat);
|
||||||
|
|
||||||
|
outputPortData = pw_filter_add_port(
|
||||||
|
filter,
|
||||||
|
PW_DIRECTION_OUTPUT,
|
||||||
|
PW_FILTER_PORT_FLAG_MAP_BUFFERS,
|
||||||
|
sizeof(void *),
|
||||||
|
pw_properties_new(
|
||||||
|
PW_KEY_PORT_NAME, "Output",
|
||||||
|
PW_KEY_AUDIO_CHANNELS, std::to_string(playbackChannels).c_str(),
|
||||||
|
nullptr),
|
||||||
|
params, 1);
|
||||||
|
|
||||||
|
if (!outputPortData)
|
||||||
|
{
|
||||||
|
pw_filter_destroy(filter);
|
||||||
|
filter = nullptr;
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
throw PiPedalStateException("Failed to add PipeWire output port.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect the filter to the PipeWire graph
|
||||||
|
int res = pw_filter_connect(
|
||||||
|
filter,
|
||||||
|
PW_FILTER_FLAG_RT_PROCESS,
|
||||||
|
nullptr, 0);
|
||||||
|
|
||||||
|
if (res < 0)
|
||||||
|
{
|
||||||
|
pw_filter_destroy(filter);
|
||||||
|
filter = nullptr;
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
throw PiPedalStateException(
|
||||||
|
std::string("Failed to connect PipeWire filter: ") + strerror(-res));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the thread loop
|
||||||
|
pw_thread_loop_start(threadLoop);
|
||||||
|
|
||||||
|
Lv2Log::info(SS("PipeWire driver opened: " << captureChannels
|
||||||
|
<< " capture channels, "
|
||||||
|
<< playbackChannels
|
||||||
|
<< " playback channels, "
|
||||||
|
<< this->sampleRate << "Hz, "
|
||||||
|
<< this->bufferSize << " frames"));
|
||||||
|
}
|
||||||
|
catch (const std::exception &e)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Activate - allocate buffers and start processing
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void Activate() override
|
||||||
|
{
|
||||||
|
if (activated)
|
||||||
|
throw PiPedalStateException("Already activated.");
|
||||||
|
|
||||||
|
activated = true;
|
||||||
|
|
||||||
|
// Reset previously allocated buffers
|
||||||
|
allocatedBuffers.resize(0);
|
||||||
|
|
||||||
|
// Allocate device capture buffers
|
||||||
|
zeroInputBuffer = AllocateAudioBuffer();
|
||||||
|
deviceCaptureBuffers.resize(captureChannels);
|
||||||
|
for (size_t i = 0; i < captureChannels; ++i)
|
||||||
|
{
|
||||||
|
deviceCaptureBuffers[i] = AllocateAudioBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate device playback buffers
|
||||||
|
devicePlaybackBuffers.resize(playbackChannels);
|
||||||
|
for (size_t i = 0; i < playbackChannels; ++i)
|
||||||
|
{
|
||||||
|
devicePlaybackBuffers[i] = AllocateAudioBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate input channel routing (main)
|
||||||
|
AllocateInputChannels(
|
||||||
|
channelSelection.mainInputChannels(),
|
||||||
|
this->mainCaptureBuffers);
|
||||||
|
|
||||||
|
// Allocate output channel routing (main)
|
||||||
|
AllocateOutputChannels(
|
||||||
|
channelSelection.mainOutputChannels(),
|
||||||
|
this->mainPlaybackBuffers);
|
||||||
|
|
||||||
|
// Allocate aux channels
|
||||||
|
AllocateAuxChannels();
|
||||||
|
|
||||||
|
// Set up mix operations for channel routing
|
||||||
|
AddMixOps();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Deactivate - stop processing
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void Deactivate() override
|
||||||
|
{
|
||||||
|
if (!activated)
|
||||||
|
return;
|
||||||
|
|
||||||
|
activated = false;
|
||||||
|
|
||||||
|
// The pw_filter will continue to call process but we won't
|
||||||
|
// do anything since activated is false. The PipeWire graph
|
||||||
|
// will eventually stop scheduling us.
|
||||||
|
|
||||||
|
// Clear mix ops
|
||||||
|
mixOps.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Close - tear down PipeWire resources
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void Close() override
|
||||||
|
{
|
||||||
|
if (!open)
|
||||||
|
return;
|
||||||
|
open = false;
|
||||||
|
|
||||||
|
Deactivate();
|
||||||
|
|
||||||
|
// Clean up PipeWire filter
|
||||||
|
if (filter)
|
||||||
|
{
|
||||||
|
pw_filter_disconnect(filter);
|
||||||
|
pw_filter_destroy(filter);
|
||||||
|
filter = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop and destroy thread loop
|
||||||
|
if (threadLoop)
|
||||||
|
{
|
||||||
|
pw_thread_loop_stop(threadLoop);
|
||||||
|
pw_thread_loop_destroy(threadLoop);
|
||||||
|
threadLoop = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deinitialize PipeWire (only if we initialized it)
|
||||||
|
if (pwInitialized)
|
||||||
|
{
|
||||||
|
// pw_deinit(); // Note: we don't call this to avoid issues with
|
||||||
|
// other PipeWire users in the process. It's safe to leave initialized.
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteBuffers();
|
||||||
|
this->alsaSequencer = nullptr;
|
||||||
|
|
||||||
|
Lv2Log::info("PipeWire driver closed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// SetAlsaSequencer - store for MIDI routing (not used by PipeWire)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void SetAlsaSequencer(AlsaSequencer::ptr alsaSequencer) override
|
||||||
|
{
|
||||||
|
this->alsaSequencer = alsaSequencer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// GetConfigurationDescription - human-readable description
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual std::string GetConfigurationDescription() override
|
||||||
|
{
|
||||||
|
std::stringstream s;
|
||||||
|
s << "PipeWire: " << captureChannels << " in, "
|
||||||
|
<< playbackChannels << " out, "
|
||||||
|
<< this->sampleRate << " Hz, "
|
||||||
|
<< this->bufferSize << " frames";
|
||||||
|
return s.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// DumpBufferTrace - stub (no PipeWire equivalent)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
virtual void DumpBufferTrace(size_t nEntries) override
|
||||||
|
{
|
||||||
|
// Not implemented for PipeWire driver
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Static PipeWire filter event callbacks
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
static void on_filter_process_static(void *data, struct spa_io_position *position)
|
||||||
|
{
|
||||||
|
auto *self = static_cast<PipeWireDriverImpl *>(data);
|
||||||
|
self->on_filter_process(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_filter_state_changed_static(
|
||||||
|
void *data,
|
||||||
|
enum pw_filter_state old,
|
||||||
|
enum pw_filter_state state,
|
||||||
|
const char *error)
|
||||||
|
{
|
||||||
|
auto *self = static_cast<PipeWireDriverImpl *>(data);
|
||||||
|
self->on_filter_state_changed(old, state, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Filter state change handler
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
void on_filter_state_changed(
|
||||||
|
enum pw_filter_state old,
|
||||||
|
enum pw_filter_state state,
|
||||||
|
const char *error)
|
||||||
|
{
|
||||||
|
if (state == PW_FILTER_STATE_ERROR)
|
||||||
|
{
|
||||||
|
Lv2Log::error(SS("PipeWire filter error: " << (error ? error : "unknown")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Main processing callback - called from PipeWire RT thread
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
void on_filter_process(struct spa_io_position *position)
|
||||||
|
{
|
||||||
|
if (!activated)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pw_buffer *inBuf = nullptr;
|
||||||
|
pw_buffer *outBuf = nullptr;
|
||||||
|
|
||||||
|
// Dequeue input buffer (capture)
|
||||||
|
inBuf = (pw_buffer *)pw_filter_dequeue_buffer(inputPortData);
|
||||||
|
if (!inBuf)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Dequeue output buffer (playback)
|
||||||
|
outBuf = (pw_buffer *)pw_filter_dequeue_buffer(outputPortData);
|
||||||
|
if (!outBuf)
|
||||||
|
{
|
||||||
|
pw_filter_queue_buffer(inputPortData, inBuf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spa_buffer *spaIn = inBuf->buffer;
|
||||||
|
spa_buffer *spaOut = outBuf->buffer;
|
||||||
|
|
||||||
|
uint32_t nInputChannels = std::min(spaIn->n_datas, captureChannels);
|
||||||
|
uint32_t nOutputChannels = std::min(spaOut->n_datas, playbackChannels);
|
||||||
|
|
||||||
|
// Determine frame count from the input buffer data size
|
||||||
|
uint32_t nFrames = 0;
|
||||||
|
if (spaIn->n_datas > 0 && spaIn->datas[0].chunk)
|
||||||
|
{
|
||||||
|
nFrames = spaIn->datas[0].chunk->size / sizeof(float);
|
||||||
|
if (nInputChannels > 1)
|
||||||
|
nFrames /= nInputChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nFrames == 0)
|
||||||
|
nFrames = this->bufferSize;
|
||||||
|
|
||||||
|
// Clamp to our buffer size
|
||||||
|
if (nFrames > this->bufferSize)
|
||||||
|
nFrames = this->bufferSize;
|
||||||
|
|
||||||
|
// ---- Read input from PipeWire ----
|
||||||
|
// Copy input data from PipeWire buffers to our device capture buffers.
|
||||||
|
// PipeWire uses planar (non-interleaved) format where each data chunk is one channel.
|
||||||
|
for (uint32_t ch = 0; ch < nInputChannels; ++ch)
|
||||||
|
{
|
||||||
|
if (ch < deviceCaptureBuffers.size() && spaIn->datas[ch].data != nullptr)
|
||||||
|
{
|
||||||
|
float *src = (float *)((uint8_t *)spaIn->datas[ch].data +
|
||||||
|
(spaIn->datas[ch].chunk ? spaIn->datas[ch].chunk->offset : 0));
|
||||||
|
float *dst = deviceCaptureBuffers[ch];
|
||||||
|
for (uint32_t i = 0; i < nFrames; ++i)
|
||||||
|
{
|
||||||
|
dst[i] = src[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero out any remaining input channels that PipeWire didn't fill
|
||||||
|
for (uint32_t ch = nInputChannels; ch < captureChannels; ++ch)
|
||||||
|
{
|
||||||
|
if (ch < deviceCaptureBuffers.size())
|
||||||
|
{
|
||||||
|
float *dst = deviceCaptureBuffers[ch];
|
||||||
|
memset(dst, 0, nFrames * sizeof(float));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Channel routing (input) ----
|
||||||
|
for (auto &mixOp : mixOps)
|
||||||
|
{
|
||||||
|
mixOp(nFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Process audio via the host ----
|
||||||
|
// This is where PiPedal's DSP pipeline runs (on the RT thread)
|
||||||
|
driverHost->OnProcess(nFrames);
|
||||||
|
|
||||||
|
// ---- Write output to PipeWire ----
|
||||||
|
for (uint32_t ch = 0; ch < nOutputChannels; ++ch)
|
||||||
|
{
|
||||||
|
if (ch < devicePlaybackBuffers.size() && spaOut->datas[ch].data != nullptr)
|
||||||
|
{
|
||||||
|
float *dst = (float *)((uint8_t *)spaOut->datas[ch].data +
|
||||||
|
(spaOut->datas[ch].chunk ? spaOut->datas[ch].chunk->offset : 0));
|
||||||
|
float *src = devicePlaybackBuffers[ch];
|
||||||
|
uint32_t copyFrames = std::min(nFrames,
|
||||||
|
(uint32_t)(spaOut->datas[ch].chunk ?
|
||||||
|
spaOut->datas[ch].chunk->size / sizeof(float) : nFrames));
|
||||||
|
for (uint32_t i = 0; i < copyFrames; ++i)
|
||||||
|
{
|
||||||
|
dst[i] = src[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue both buffers back to PipeWire
|
||||||
|
pw_filter_queue_buffer(outputPortData, outBuf);
|
||||||
|
pw_filter_queue_buffer(inputPortData, inBuf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Buffer allocation helpers (mirrors AlsaDriver pattern)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
float *AllocateAudioBuffer()
|
||||||
|
{
|
||||||
|
std::vector<float> buffer;
|
||||||
|
buffer.resize(this->bufferSize);
|
||||||
|
float *pBuffer = buffer.data();
|
||||||
|
allocatedBuffers.push_back(std::move(buffer));
|
||||||
|
return pBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeleteBuffers()
|
||||||
|
{
|
||||||
|
mainCaptureBuffers.clear();
|
||||||
|
mainPlaybackBuffers.clear();
|
||||||
|
auxCaptureBuffers.clear();
|
||||||
|
auxPlaybackBuffers.clear();
|
||||||
|
deviceCaptureBuffers.clear();
|
||||||
|
devicePlaybackBuffers.clear();
|
||||||
|
zeroInputBuffer = nullptr;
|
||||||
|
discardOutputBuffer = nullptr;
|
||||||
|
allocatedBuffers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Channel allocation helpers (mirrors AlsaDriver pattern)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
void AllocateInputChannels(
|
||||||
|
const std::vector<int64_t> &channelSelection,
|
||||||
|
std::vector<float *> &channelBuffers)
|
||||||
|
{
|
||||||
|
size_t nChannels = channelSelection.size();
|
||||||
|
if (nChannels == 0)
|
||||||
|
{
|
||||||
|
channelBuffers.resize(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelBuffers.resize(nChannels);
|
||||||
|
for (size_t i = 0; i < nChannels; ++i)
|
||||||
|
{
|
||||||
|
int64_t deviceChannel = channelSelection[i];
|
||||||
|
if (deviceChannel == -1 || (size_t)deviceChannel >= captureChannels)
|
||||||
|
{
|
||||||
|
channelBuffers[i] = zeroInputBuffer;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
channelBuffers[i] = deviceCaptureBuffers[deviceChannel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AllocateOutputChannels(
|
||||||
|
const std::vector<int64_t> &channelSelection,
|
||||||
|
std::vector<float *> &channelBuffers)
|
||||||
|
{
|
||||||
|
size_t nChannels = channelSelection.size();
|
||||||
|
if (nChannels == 0)
|
||||||
|
{
|
||||||
|
channelBuffers.resize(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelBuffers.resize(nChannels);
|
||||||
|
for (size_t i = 0; i < nChannels; ++i)
|
||||||
|
{
|
||||||
|
int64_t deviceChannel = channelSelection[i];
|
||||||
|
if (deviceChannel == -1)
|
||||||
|
{
|
||||||
|
channelBuffers[i] = this->GetDiscardOutputBuffer();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float *mixBuffer = AllocateAudioBuffer();
|
||||||
|
channelBuffers[i] = mixBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AllocateAuxChannels()
|
||||||
|
{
|
||||||
|
for (auto ix : channelSelection.auxInputChannels())
|
||||||
|
{
|
||||||
|
if ((size_t)ix < deviceCaptureBuffers.size())
|
||||||
|
auxCaptureBuffers.push_back(this->deviceCaptureBuffers[ix]);
|
||||||
|
else
|
||||||
|
auxCaptureBuffers.push_back(this->zeroInputBuffer);
|
||||||
|
}
|
||||||
|
for (auto ix : channelSelection.auxOutputChannels())
|
||||||
|
{
|
||||||
|
if ((size_t)ix < devicePlaybackBuffers.size())
|
||||||
|
auxPlaybackBuffers.push_back(this->devicePlaybackBuffers[ix]);
|
||||||
|
else
|
||||||
|
auxPlaybackBuffers.push_back(this->GetDiscardOutputBuffer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Mix operations for channel routing (mirrors AlsaDriver)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
void AddMixOps()
|
||||||
|
{
|
||||||
|
// Main input: copy from device capture channels to main capture channels
|
||||||
|
for (size_t i = 0; i < mainCaptureBuffers.size(); ++i)
|
||||||
|
{
|
||||||
|
if (mainCaptureBuffers[i] != deviceCaptureBuffers[channelSelection.mainInputChannels()[i]] &&
|
||||||
|
mainCaptureBuffers[i] != zeroInputBuffer)
|
||||||
|
{
|
||||||
|
// Direct pointer, no copy needed (already set up in AllocateInputChannels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main output: copy from main playback to device playback channels
|
||||||
|
for (size_t i = 0; i < mainPlaybackBuffers.size(); ++i)
|
||||||
|
{
|
||||||
|
int64_t deviceChannel = channelSelection.mainOutputChannels()[i];
|
||||||
|
if (deviceChannel >= 0 && (size_t)deviceChannel < devicePlaybackBuffers.size())
|
||||||
|
{
|
||||||
|
if (mainPlaybackBuffers[i] != devicePlaybackBuffers[deviceChannel])
|
||||||
|
{
|
||||||
|
// Mix copy: main playback buffer → device playback buffer
|
||||||
|
AddMixCopyOp(mainPlaybackBuffers[i], devicePlaybackBuffers[deviceChannel]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Channel position helper for SPA audio format
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
static void SetChannelPositions(spa_audio_info_raw &format, uint32_t channels)
|
||||||
|
{
|
||||||
|
switch (channels)
|
||||||
|
{
|
||||||
|
case 1:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_MONO;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
format.position[4] = SPA_AUDIO_CHANNEL_RL;
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
format.position[4] = SPA_AUDIO_CHANNEL_RL;
|
||||||
|
format.position[5] = SPA_AUDIO_CHANNEL_RR;
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
format.position[4] = SPA_AUDIO_CHANNEL_RL;
|
||||||
|
format.position[5] = SPA_AUDIO_CHANNEL_RR;
|
||||||
|
format.position[6] = SPA_AUDIO_CHANNEL_SL;
|
||||||
|
break;
|
||||||
|
case 8:
|
||||||
|
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
format.position[2] = SPA_AUDIO_CHANNEL_FC;
|
||||||
|
format.position[3] = SPA_AUDIO_CHANNEL_LFE;
|
||||||
|
format.position[4] = SPA_AUDIO_CHANNEL_RL;
|
||||||
|
format.position[5] = SPA_AUDIO_CHANNEL_RR;
|
||||||
|
format.position[6] = SPA_AUDIO_CHANNEL_SL;
|
||||||
|
format.position[7] = SPA_AUDIO_CHANNEL_SR;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// For > 8 channels, assign Aux channels
|
||||||
|
for (uint32_t i = 0; i < channels && i < SPA_AUDIO_MAX_CHANNELS; ++i)
|
||||||
|
{
|
||||||
|
format.position[i] = SPA_AUDIO_CHANNEL_AUX0 + i;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Factory function
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
AudioDriver *CreatePipeWireDriver(AudioDriverHost *driverHost)
|
||||||
|
{
|
||||||
|
return new PipeWireDriverImpl(driverHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pipedal
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) Robin E.R. Davies
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to
|
||||||
|
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
* of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
* so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "AudioDriver.hpp"
|
||||||
|
|
||||||
|
namespace pipedal {
|
||||||
|
|
||||||
|
AudioDriver* CreatePipeWireDriver(AudioDriverHost* driverHost);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
#include "WebServerConfig.hpp"
|
#include "WebServerConfig.hpp"
|
||||||
#include <execinfo.h>
|
#include <execinfo.h>
|
||||||
#include "PiPedalSocket.hpp"
|
#include "PiPedalSocket.hpp"
|
||||||
|
#include "MixerEngine.hpp"
|
||||||
#include "PluginHost.hpp"
|
#include "PluginHost.hpp"
|
||||||
#include <boost/system/error_code.hpp>
|
#include <boost/system/error_code.hpp>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -185,6 +186,7 @@ int main(int argc, char *argv[])
|
|||||||
bool testExtraDevice = false;
|
bool testExtraDevice = false;
|
||||||
std::string logLevel;
|
std::string logLevel;
|
||||||
std::string portOption;
|
std::string portOption;
|
||||||
|
std::string driverOption = "alsa";
|
||||||
|
|
||||||
CommandLineParser parser;
|
CommandLineParser parser;
|
||||||
parser.AddOption("-h", &help);
|
parser.AddOption("-h", &help);
|
||||||
@@ -193,6 +195,7 @@ int main(int argc, char *argv[])
|
|||||||
parser.AddOption("-log-level", &logLevel);
|
parser.AddOption("-log-level", &logLevel);
|
||||||
parser.AddOption("-port", &portOption);
|
parser.AddOption("-port", &portOption);
|
||||||
parser.AddOption("-test-extra-device", &testExtraDevice); // advertise two different devices (for testing multi-device connect)
|
parser.AddOption("-test-extra-device", &testExtraDevice); // advertise two different devices (for testing multi-device connect)
|
||||||
|
parser.AddOption("--driver", &driverOption);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -226,6 +229,7 @@ int main(int argc, char *argv[])
|
|||||||
<< " -systemd: Log to systemd journals instead of to the console.\n"
|
<< " -systemd: Log to systemd journals instead of to the console.\n"
|
||||||
<< " -port: Port to listen on e.g. 80, or 0.0.0.0:80\n"
|
<< " -port: Port to listen on e.g. 80, or 0.0.0.0:80\n"
|
||||||
<< " -log-level: (debug|info|warning|error)\n"
|
<< " -log-level: (debug|info|warning|error)\n"
|
||||||
|
<< " --driver: Audio driver (alsa|pipewire, default: alsa)\n"
|
||||||
<< "Example:\n"
|
<< "Example:\n"
|
||||||
<< " pipedald /etc/pipedal/config /etc/pipedal/react -port 80 \n"
|
<< " pipedald /etc/pipedal/config /etc/pipedal/react -port 80 \n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -398,6 +402,7 @@ int main(int argc, char *argv[])
|
|||||||
});
|
});
|
||||||
|
|
||||||
model.Init(configuration);
|
model.Init(configuration);
|
||||||
|
model.SetDriverType(driverOption);
|
||||||
|
|
||||||
// Get heavy IO out of the way before letting dependent (Jack/ALSA) services run.
|
// Get heavy IO out of the way before letting dependent (Jack/ALSA) services run.
|
||||||
model.LoadLv2PluginInfo();
|
model.LoadLv2PluginInfo();
|
||||||
@@ -447,6 +452,18 @@ int main(int argc, char *argv[])
|
|||||||
|
|
||||||
model.Load();
|
model.Load();
|
||||||
|
|
||||||
|
// Initialize Band-in-a-Box mixer engine
|
||||||
|
{
|
||||||
|
auto mixerEngine = std::make_shared<MixerEngine>();
|
||||||
|
// Configure with current audio settings
|
||||||
|
auto jackSettings = model.GetJackServerSettings();
|
||||||
|
mixerEngine->setSampleRate((uint32_t)jackSettings.GetSampleRate());
|
||||||
|
mixerEngine->setMaxBufferSize((size_t)jackSettings.GetBufferSize());
|
||||||
|
mixerEngine->Activate();
|
||||||
|
model.SetMixerEngine(mixerEngine);
|
||||||
|
Lv2Log::info("MixerEngine initialized (BB-6)");
|
||||||
|
}
|
||||||
|
|
||||||
auto pipedalSocketFactory = MakePiPedalSocketFactory(model);
|
auto pipedalSocketFactory = MakePiPedalSocketFactory(model);
|
||||||
|
|
||||||
server->AddSocketFactory(pipedalSocketFactory);
|
server->AddSocketFactory(pipedalSocketFactory);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// grant NetworkManager dbus permissions (no-prompts) to the pipedald systemd service.
|
// grant NetworkManager dbus permissions (no-prompts) to the oppedald systemd service.
|
||||||
polkit.addRule(function(action, subject) {
|
polkit.addRule(function(action, subject) {
|
||||||
if (action.id.indexOf("org.freedesktop.NetworkManager.") == 0 && subject.isInGroup("pipedal_d")) {
|
if (action.id.indexOf("org.freedesktop.NetworkManager.") == 0 && subject.isInGroup("oppedal_d")) {
|
||||||
return polkit.Result.YES;
|
return polkit.Result.YES;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,7 @@ interface=p2p-${WLAN}-*
|
|||||||
port=0
|
port=0
|
||||||
dhcp-range=192.168.60.20,192.168.60.127,15m
|
dhcp-range=192.168.60.20,192.168.60.127,15m
|
||||||
domain=local
|
domain=local
|
||||||
address=/pipedal-p2p.local/192.168.60.2
|
address=/oppedal-p2p.local/192.168.60.2
|
||||||
|
|
||||||
except-interface=eth0
|
except-interface=eth0
|
||||||
except-interface=${WLAN}
|
except-interface=${WLAN}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ server=8.8.4.4
|
|||||||
interface=p2p-wlan0-0
|
interface=p2p-wlan0-0
|
||||||
dhcp-range=192.168.60.3,192.168.60.127,15m
|
dhcp-range=192.168.60.3,192.168.60.127,15m
|
||||||
domain=local
|
domain=local
|
||||||
address=/pipedal.local/192.168.60.2
|
address=/oppedal.local/192.168.60.2
|
||||||
|
|
||||||
except-interface=eth0
|
except-interface=eth0
|
||||||
except-interface=wlan0
|
except-interface=wlan0
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# PiPedal dnsmasq settings for p2p
|
# OP-Pedal dnsmasq settings for p2p
|
||||||
#disable dns, dhcp only."
|
#disable dns, dhcp only."
|
||||||
port=0
|
port=0
|
||||||
dhcp-range=192.168.60.3,192.168.60.127,15m
|
dhcp-range=192.168.60.3,192.168.60.127,15m
|
||||||
domain=local
|
domain=local
|
||||||
address=/pipedal-p2p.local/192.168.60.2
|
address=/oppedal-p2p.local/192.168.60.2
|
||||||
|
|
||||||
# defensive. Not actually required.
|
# defensive. Not actually required.
|
||||||
except-interface=eth0
|
except-interface=eth0
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Description=Jack Audio Daemon
|
|||||||
After=sound.target
|
After=sound.target
|
||||||
After=local-fs.target
|
After=local-fs.target
|
||||||
After=dbus.socket
|
After=dbus.socket
|
||||||
After=pipedald
|
After=oppedald
|
||||||
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=PiPedal P2P Session Manager
|
Description=OP-Pedal P2P Session Manager
|
||||||
After=network.target
|
After=network.target
|
||||||
After=nss-lookup.target
|
After=nss-lookup.target
|
||||||
After=wpa_supplicant.service
|
After=wpa_supplicant.service
|
||||||
@@ -11,7 +11,7 @@ StartLimitBurst=8
|
|||||||
[Service]
|
[Service]
|
||||||
ExecStart=${COMMAND} --systemd --loglevel info
|
ExecStart=${COMMAND} --systemd --loglevel info
|
||||||
Type=notify
|
Type=notify
|
||||||
WorkingDirectory=/var/pipedal
|
WorkingDirectory=/var/oppedal
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
TimeoutStopSec=5
|
TimeoutStopSec=5
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ wifiChannel=${WIFI_CHANNEL}
|
|||||||
# UI use only. service state is authoritative.
|
# UI use only. service state is authoritative.
|
||||||
enabled=${ENABLED}
|
enabled=${ENABLED}
|
||||||
# P2P Device info
|
# P2P Device info
|
||||||
p2p_model_name=PiPedal
|
p2p_model_name=OP-Pedal
|
||||||
p2p_model_number=1
|
p2p_model_number=1
|
||||||
p2p_manufacturer="The PiPedal Project"
|
p2p_manufacturer="The OP-Pedal Project"
|
||||||
p2p_serial_number=1
|
p2p_serial_number=1
|
||||||
p2p_device_type=1-0050F204-1
|
p2p_device_type=1-0050F204-1
|
||||||
|
|
||||||
@@ -39,6 +39,6 @@ p2p_go_he=0
|
|||||||
service_guid_file=${INSTANCE_ID_FILE}
|
service_guid_file=${INSTANCE_ID_FILE}
|
||||||
|
|
||||||
|
|
||||||
# GUID identifying the PiPedal service
|
# GUID identifying the OP-Pedal service
|
||||||
# (if service_guid_file is not provided.)
|
# (if service_guid_file is not provided.)
|
||||||
service_guid=
|
service_guid=
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=PiPedal P2P Session Manager
|
Description=OP-Pedal P2P Session Manager
|
||||||
After=network.target
|
After=network.target
|
||||||
After=nss-lookup.target
|
After=nss-lookup.target
|
||||||
After=wpa_supplicant.service
|
After=wpa_supplicant.service
|
||||||
@@ -10,7 +10,7 @@ StartLimitBurst=8
|
|||||||
[Service]
|
[Service]
|
||||||
ExecStart=${COMMAND} --systemd --log-level info
|
ExecStart=${COMMAND} --systemd --log-level info
|
||||||
Type=notify
|
Type=notify
|
||||||
WorkingDirectory=/var/pipedal
|
WorkingDirectory=/var/oppedal
|
||||||
TimeoutStopSec=5
|
TimeoutStopSec=5
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10s
|
RestartSec=10s
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ After=network.target
|
|||||||
ExecStart=${COMMAND}
|
ExecStart=${COMMAND}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=60
|
RestartSec=60
|
||||||
WorkingDirectory=/var/pipedal
|
WorkingDirectory=/var/oppedal
|
||||||
TimeoutStopSec=15
|
TimeoutStopSec=15
|
||||||
KillMode=process
|
KillMode=process
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ LimitMEMLOCK=infinity
|
|||||||
LimitRTPRIO=95
|
LimitRTPRIO=95
|
||||||
Nice=-9
|
Nice=-9
|
||||||
ExecStart=${COMMAND}
|
ExecStart=${COMMAND}
|
||||||
User=pipedal_d
|
User=oppedal_d
|
||||||
Group=pipedal_d
|
Group=oppedal_d
|
||||||
Restart=always
|
Restart=always
|
||||||
TimeoutStartSec=60
|
TimeoutStartSec=60
|
||||||
NotifyAccess=all
|
NotifyAccess=all
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
TimeoutStopSec=15
|
TimeoutStopSec=15
|
||||||
WorkingDirectory=/var/pipedal
|
WorkingDirectory=/var/oppedal
|
||||||
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
# CMake doesn't have an uninstall option, so remove files manually.
|
# CMake doesn't have an uninstall option, so remove files manually.
|
||||||
export PREFIX=/usr
|
export PREFIX=/usr
|
||||||
|
|
||||||
rm $PREFIX/bin/pipedal*
|
rm $PREFIX/bin/oppedal*
|
||||||
rm $PREFIX/sbin/pipedal*
|
rm $PREFIX/sbin/oppedal*
|
||||||
rm -rf /etc/pipedal/
|
rm -rf /etc/oppedal/
|
||||||
rm -rf /var/pipedal/
|
rm -rf /var/oppedal/
|
||||||
rm -rf /usr/lib/lv2/ToobAmp.lv2/
|
rm -rf /usr/lib/lv2/ToobAmp.lv2/
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="color-scheme" content="dark light" /> <!-- uses media queries in web views. -->
|
<meta name="color-scheme" content="dark light" /> <!-- uses media queries in web views. -->
|
||||||
<meta name="description" content="PiPedal Guitar Stomp Box for Raspberry Pi" />
|
<meta name="description" content="OP-Pedal Guitar Stomp Box for Raspberry Pi" />
|
||||||
<link rel="stylesheet" href="/css/roboto.css" />
|
<link rel="stylesheet" href="/css/roboto.css" />
|
||||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
@@ -120,7 +121,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<title>PiPedal</title>
|
<title>OP-Pedal</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "pipedal",
|
"name": "op-pedal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 401 KiB After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="700" height="700" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M349.203 572.671C468.026 572.671 564.351 472.819 564.351 349.645C564.351 226.471 468.026 126.619 349.203 126.619C230.38 126.619 134.055 226.471 134.055 349.645C134.055 472.819 230.38 572.671 349.203 572.671Z" fill="#1C75BC"/>
|
||||||
|
<path d="M606.601 74.2021L230.242 311.707C228.066 313.079 226.301 315.053 225.146 317.407C223.99 319.761 223.488 322.401 223.697 325.034C223.906 327.666 224.817 330.186 226.329 332.312C227.841 334.438 229.893 336.086 232.257 337.071L259.065 348.287C261.074 349.128 262.863 350.45 264.285 352.145C265.707 353.84 266.721 355.86 267.246 358.039C267.77 360.219 267.79 362.496 267.303 364.685C266.816 366.874 265.836 368.913 264.443 370.634L76.3392 603.063C66.2683 615.521 80.9517 632.939 94.2049 624.251L468.419 378.622C470.623 377.174 472.384 375.106 473.495 372.658C474.607 370.211 475.023 367.486 474.696 364.802C474.368 362.118 473.31 359.586 471.646 357.503C469.981 355.42 467.779 353.871 465.297 353.038L445.81 346.522C443.669 345.806 441.731 344.555 440.165 342.88C438.6 341.205 437.456 339.157 436.833 336.916C436.21 334.675 436.128 332.309 436.594 330.027C437.06 327.746 438.06 325.617 439.505 323.83L624.255 95.5153C634.246 83.1722 619.844 65.8272 606.601 74.2021Z" fill="#D1D3D4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,42 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<svg width="700" height="700" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<path d="M349.203 572.671C468.026 572.671 564.351 472.819 564.351 349.645C564.351 226.471 468.026 126.619 349.203 126.619C230.38 126.619 134.055 226.471 134.055 349.645C134.055 472.819 230.38 572.671 349.203 572.671Z" fill="#1C75BC"/>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<path d="M606.601 74.2021L230.242 311.707C228.066 313.079 226.301 315.053 225.146 317.407C223.99 319.761 223.488 322.401 223.697 325.034C223.906 327.666 224.817 330.186 226.329 332.312C227.841 334.438 229.893 336.086 232.257 337.071L259.065 348.287C261.074 349.128 262.863 350.45 264.285 352.145C265.707 353.84 266.721 355.86 267.246 358.039C267.77 360.219 267.79 362.496 267.303 364.685C266.816 366.874 265.836 368.913 264.443 370.634L76.3392 603.063C66.2683 615.521 80.9517 632.939 94.2049 624.251L468.419 378.622C470.623 377.174 472.384 375.106 473.495 372.658C474.607 370.211 475.023 367.486 474.696 364.802C474.368 362.118 473.31 359.586 471.646 357.503C469.981 355.42 467.779 353.871 465.297 353.038L445.81 346.522C443.669 345.806 441.731 344.555 440.165 342.88C438.6 341.205 437.456 339.157 436.833 336.916C436.21 334.675 436.128 332.309 436.594 330.027C437.06 327.746 438.06 325.617 439.505 323.83L624.255 95.5153C634.246 83.1722 619.844 65.8272 606.601 74.2021Z" fill="#D1D3D4"/>
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
width="218px" height="64px" viewBox="0 0 218 64" enable-background="new 0 0 218 64" xml:space="preserve">
|
|
||||||
<rect x="-1" y="1" display="none" fill="#0032B1" width="219" height="64"/>
|
|
||||||
<g>
|
|
||||||
<path fill="#FFFFFF" d="M55.069,23.069c2.359,2.28,3.54,5.4,3.54,9.36c0,1.24-0.12,2.521-0.36,3.84
|
|
||||||
c-0.6,3.4-1.791,6.351-3.57,8.851c-1.78,2.5-3.98,4.42-6.6,5.76c-2.62,1.341-5.47,2.01-8.55,2.01c-3.76,0-6.75-0.999-8.97-3
|
|
||||||
c-2.22-2-3.471-4.62-3.75-7.86l-4.56,25.681h-1.14l8.4-47.64h1.14l-1.8,10.44c1.44-3.24,3.62-5.859,6.54-7.86
|
|
||||||
c2.919-2,6.26-3,10.02-3C49.489,19.649,52.708,20.789,55.069,23.069z M51.049,47.639c3.12-2.779,5.12-6.57,6-11.37
|
|
||||||
c0.24-1.319,0.36-2.56,0.36-3.72c0-3.72-1.08-6.62-3.24-8.7c-2.16-2.079-5.16-3.12-9-3.12c-2.8,0-5.421,0.651-7.86,1.95
|
|
||||||
c-2.44,1.3-4.5,3.12-6.18,5.46c-1.68,2.34-2.781,5.051-3.3,8.13c-0.201,1.001-0.3,2.141-0.3,3.42c0,3.721,1.1,6.671,3.3,8.851
|
|
||||||
c2.2,2.181,5.16,3.27,8.88,3.27C44.149,51.809,47.929,50.419,51.049,47.639z"/>
|
|
||||||
<path fill="#FFFFFF" d="M70.788,20.069l-5.76,32.4h-1.2l5.76-32.4H70.788z M70.878,9.089c0.38-0.36,0.83-0.54,1.35-0.54
|
|
||||||
c0.36,0,0.669,0.12,0.93,0.36c0.259,0.24,0.39,0.54,0.39,0.9c0,0.6-0.19,1.08-0.57,1.44c-0.381,0.36-0.831,0.54-1.35,0.54
|
|
||||||
c-0.36,0-0.67-0.12-0.93-0.36c-0.261-0.24-0.39-0.54-0.39-0.9C70.308,9.929,70.498,9.449,70.878,9.089z"/>
|
|
||||||
<path fill="#FFFFFF" d="M112.248,8.069l-7.86,44.4h-1.14l1.86-10.44c-1.44,3.24-3.621,5.86-6.54,7.86c-2.92,2.001-6.261,3-10.02,3
|
|
||||||
c-4.08,0-7.3-1.14-9.66-3.42c-2.36-2.28-3.54-5.4-3.54-9.36c0-1.239,0.12-2.52,0.36-3.84c0.6-3.399,1.779-6.35,3.54-8.85
|
|
||||||
c1.76-2.5,3.96-4.419,6.6-5.76c2.64-1.34,5.499-2.01,8.58-2.01c3.72,0,6.69,1,8.91,3c2.22,2,3.489,4.62,3.81,7.86l3.96-22.44
|
|
||||||
H112.248z M82.848,24.899c-3.12,2.78-5.1,6.57-5.94,11.37c-0.24,1.32-0.36,2.561-0.36,3.72c0,3.721,1.07,6.621,3.21,8.7
|
|
||||||
c2.14,2.081,5.13,3.12,8.97,3.12c2.799,0,5.43-0.649,7.89-1.95c2.46-1.299,4.52-3.12,6.18-5.46c1.66-2.34,2.77-5.049,3.33-8.13
|
|
||||||
c0.2-1.319,0.3-2.438,0.3-3.36c0-3.72-1.11-6.68-3.33-8.88c-2.22-2.199-5.17-3.3-8.85-3.3
|
|
||||||
C89.767,20.729,85.967,22.119,82.848,24.899z"/>
|
|
||||||
<path fill="#FFFFFF" d="M152.507,8.069l-7.86,44.4h-1.14l1.86-10.44c-1.44,3.24-3.621,5.86-6.54,7.86c-2.92,2.001-6.261,3-10.02,3
|
|
||||||
c-4.08,0-7.3-1.14-9.66-3.42c-2.36-2.28-3.54-5.4-3.54-9.36c0-1.239,0.12-2.52,0.36-3.84c0.6-3.399,1.779-6.35,3.54-8.85
|
|
||||||
c1.76-2.5,3.96-4.419,6.6-5.76c2.64-1.34,5.499-2.01,8.58-2.01c3.72,0,6.69,1,8.91,3c2.22,2,3.489,4.62,3.81,7.86l3.96-22.44
|
|
||||||
H152.507z M123.107,24.899c-3.12,2.78-5.1,6.57-5.94,11.37c-0.24,1.32-0.36,2.561-0.36,3.72c0,3.721,1.07,6.621,3.21,8.7
|
|
||||||
c2.14,2.081,5.13,3.12,8.97,3.12c2.799,0,5.43-0.649,7.89-1.95c2.46-1.299,4.52-3.12,6.18-5.46c1.66-2.34,2.77-5.049,3.33-8.13
|
|
||||||
c0.2-1.319,0.3-2.438,0.3-3.36c0-3.72-1.11-6.68-3.33-8.88c-2.22-2.199-5.17-3.3-8.85-3.3
|
|
||||||
C130.026,20.729,126.227,22.119,123.107,24.899z"/>
|
|
||||||
<path fill="#FFFFFF" d="M165.046,8.069l-7.8,44.4h-1.2l7.8-44.4H165.046z"/>
|
|
||||||
<path fill="#FFFFFF" d="M194.176,21.239c1.898,1.06,3.318,2.451,4.26,4.17c0.939,1.72,1.41,3.621,1.41,5.7
|
|
||||||
c0,0.52-0.08,1.34-0.24,2.459c-0.281,1.44-0.561,2.521-0.84,3.24h-29.04c-0.201,1.32-0.3,2.421-0.3,3.3
|
|
||||||
c0,3.841,1.14,6.75,3.42,8.73c2.28,1.979,5.16,2.97,8.64,2.97c3.64,0,6.87-0.93,9.69-2.79c2.821-1.859,4.77-4.369,5.85-7.53h1.199
|
|
||||||
c-1.16,3.4-3.238,6.15-6.239,8.25c-3,2.101-6.561,3.15-10.68,3.15c-3.96,0-7.12-1.13-9.48-3.39c-2.36-2.26-3.54-5.39-3.54-9.391
|
|
||||||
c0-1.2,0.12-2.479,0.36-3.84c0.6-3.399,1.79-6.36,3.57-8.88c1.779-2.52,3.969-4.44,6.57-5.76c2.6-1.32,5.4-1.98,8.4-1.98
|
|
||||||
C189.945,19.649,192.275,20.179,194.176,21.239z M198.705,31.529c0-3.56-1.119-6.25-3.359-8.07c-2.241-1.82-5.021-2.73-8.34-2.73
|
|
||||||
c-2.56,0-5.021,0.551-7.38,1.65c-2.361,1.101-4.41,2.77-6.15,5.01c-1.74,2.241-2.91,5.021-3.51,8.34h28.201
|
|
||||||
C198.525,34.169,198.705,32.769,198.705,31.529z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="1626" height="536" viewBox="0 0 1626 536" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M594.746 359.92C584.669 359.92 578.111 357.745 575.073 353.396C572.034 349.047 572.336 342.17 575.98 332.766L625.756 202.482C629.407 193.081 634.402 186.205 640.743 181.852C647.084 177.499 655.296 175.325 665.381 175.328H753.975C764.048 175.328 770.52 177.505 773.392 181.857C776.264 186.21 775.878 193.085 772.234 202.482L722.442 332.766C718.791 342.166 713.882 349.043 707.716 353.396C701.55 357.749 693.428 359.923 683.351 359.92H594.746ZM644.277 304.045C642.188 309.442 641.71 313.098 642.841 315.012C643.973 316.927 646.709 317.884 651.05 317.884H658.869C663.21 317.884 666.642 316.927 669.165 315.012C671.688 313.098 673.99 309.442 676.072 304.045L703.954 231.203C706.043 225.813 706.564 222.157 705.518 220.235C704.471 218.314 701.78 217.357 697.442 217.364H689.606C685.258 217.364 681.783 218.321 679.183 220.235C676.582 222.15 674.235 225.806 672.143 231.203L644.277 304.045ZM941.736 181.857C944.604 186.207 944.213 193.083 940.562 202.488L914.505 270.886C910.855 280.287 905.946 287.163 899.78 291.516C893.613 295.869 885.492 298.044 875.415 298.04H815.994L792.269 359.92H734.947L789.676 217.364H778.467L805.069 175.328H922.325C932.398 175.328 938.868 177.505 941.736 181.857ZM850.916 256.015C855.257 256.015 858.689 255.058 861.212 253.144C863.735 251.229 866.037 247.575 868.119 242.182L872.287 231.214C874.368 225.824 874.89 222.168 873.851 220.247C872.812 218.325 870.118 217.368 865.77 217.375H847.01L832.156 256.015H850.916Z" fill="#D1D3D4"/>
|
||||||
|
<path d="M1007.47 317.884H1069.49L1042.65 359.92H933.978L1004.6 175.328H1061.92L1007.47 317.884ZM1254.78 175.328L1234.45 359.92H1178.16L1193.53 234.855L1142.72 317.884H1173.99L1146.89 359.92H1061.15L1174.77 175.328H1254.78ZM1245.65 359.92L1300.38 217.364H1289.18L1315.76 175.328H1433.03C1443.1 175.328 1449.57 177.505 1452.44 181.857C1455.31 186.21 1454.92 193.087 1451.27 202.488L1434.07 247.395C1433.31 249.36 1432.21 251.173 1430.81 252.748C1429.16 254.751 1427.34 256.753 1425.34 258.753C1423.4 260.697 1421.36 262.527 1419.22 264.234C1417.13 265.888 1415.39 267.237 1414 268.282C1414.72 269.547 1415.38 270.85 1415.96 272.185C1416.75 273.959 1417.4 275.791 1417.91 277.666C1418.43 279.59 1418.78 281.557 1418.95 283.543C1419.15 285.364 1418.89 287.207 1418.18 288.896L1401.48 332.755C1397.83 342.155 1392.92 349.032 1386.75 353.385C1380.59 357.738 1372.47 359.912 1362.39 359.909L1245.65 359.92ZM1319.15 317.884H1337.91C1342.25 317.884 1345.68 316.927 1348.2 315.012C1350.73 313.098 1353.03 309.442 1355.11 304.045L1355.63 302.478C1357.72 297.088 1358.24 293.434 1357.19 291.516C1356.14 289.598 1353.45 288.641 1349.12 288.645H1330.35L1319.15 317.884ZM1375.31 244.284C1377.82 242.369 1380.12 238.715 1382.21 233.322L1382.99 231.231C1385.08 225.841 1385.6 222.185 1384.56 220.263C1383.51 218.342 1380.82 217.384 1376.47 217.392H1357.7L1346.23 247.155H1365C1369.35 247.137 1372.78 246.17 1375.31 244.256V244.284ZM1530.75 217.392C1526.75 217.392 1523.54 218.566 1521.11 220.916C1518.68 223.265 1516.68 226.61 1515.12 230.952L1514.33 233.043C1512.94 237.221 1512.25 239.918 1512.25 241.133C1512.25 243.224 1512.99 244.618 1514.48 245.315C1515.97 246.012 1518.17 246.358 1521.12 246.358H1563.06C1573.14 246.358 1579.69 248.533 1582.74 252.882C1585.78 257.231 1585.47 264.108 1581.82 273.512L1559.17 332.766C1555.52 342.166 1550.52 349.043 1544.18 353.396C1537.84 357.749 1529.63 359.923 1519.55 359.92H1417.15L1422.88 317.884H1495.59C1499.94 317.884 1503.37 316.927 1505.89 315.012C1508.41 313.098 1510.71 309.442 1512.79 304.045L1513.58 302.222C1515.31 297.694 1516.01 294.386 1515.66 292.297C1515.31 290.208 1513.66 288.907 1510.71 288.394H1464.84C1454.77 288.394 1448.21 286.217 1445.17 281.865C1442.12 277.512 1442.43 270.635 1446.08 261.234L1468.73 202.22C1474.81 186.207 1485.58 177.243 1501.04 175.328H1619.36L1592.25 217.364L1530.75 217.392Z" fill="#1C75BC"/>
|
||||||
|
<path d="M266.908 483.889C381.732 483.889 474.816 387.384 474.816 268.338C474.816 149.293 381.732 52.7879 266.908 52.7879C152.084 52.7879 59 149.293 59 268.338C59 387.384 152.084 483.889 266.908 483.889Z" fill="#1C75BC"/>
|
||||||
|
<path d="M515.644 2.12828L151.951 231.672C149.848 232.999 148.143 234.906 147.025 237.181C145.908 239.456 145.424 242.008 145.626 244.552C145.828 247.096 146.708 249.532 148.169 251.587C149.63 253.642 151.613 255.234 153.897 256.186L179.804 267.026C181.745 267.838 183.474 269.116 184.848 270.754C186.222 272.393 187.202 274.345 187.709 276.451C188.216 278.558 188.235 280.759 187.764 282.875C187.293 284.99 186.346 286.96 185.001 288.624L3.22682 513.262C-6.50516 525.303 7.68406 542.137 20.4913 533.74L382.112 296.344C384.242 294.945 385.943 292.946 387.018 290.581C388.092 288.215 388.494 285.581 388.178 282.987C387.861 280.393 386.839 277.947 385.23 275.933C383.622 273.92 381.494 272.423 379.095 271.618L360.264 265.32C358.195 264.628 356.322 263.42 354.809 261.801C353.297 260.182 352.191 258.202 351.589 256.036C350.987 253.87 350.908 251.584 351.358 249.379C351.809 247.173 352.775 245.116 354.171 243.389L532.705 22.7271C542.359 10.7977 528.442 -5.96589 515.644 2.12828Z" fill="#D1D3D4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="700" height="700" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M349.203 572.671C468.026 572.671 564.351 472.819 564.351 349.645C564.351 226.471 468.026 126.619 349.203 126.619C230.38 126.619 134.055 226.471 134.055 349.645C134.055 472.819 230.38 572.671 349.203 572.671Z" fill="#1C75BC"/>
|
||||||
|
<path d="M606.601 74.2021L230.242 311.707C228.066 313.079 226.301 315.053 225.146 317.407C223.99 319.761 223.488 322.401 223.697 325.034C223.906 327.666 224.817 330.186 226.329 332.312C227.841 334.438 229.893 336.086 232.257 337.071L259.065 348.287C261.074 349.128 262.863 350.45 264.285 352.145C265.707 353.84 266.721 355.86 267.246 358.039C267.77 360.219 267.79 362.496 267.303 364.685C266.816 366.874 265.836 368.913 264.443 370.634L76.3392 603.063C66.2683 615.521 80.9517 632.939 94.2049 624.251L468.419 378.622C470.623 377.174 472.384 375.106 473.495 372.658C474.607 370.211 475.023 367.486 474.696 364.802C474.368 362.118 473.31 359.586 471.646 357.503C469.981 355.42 467.779 353.871 465.297 353.038L445.81 346.522C443.669 345.806 441.731 344.555 440.165 342.88C438.6 341.205 437.456 339.157 436.833 336.916C436.21 334.675 436.128 332.309 436.594 330.027C437.06 327.746 438.06 325.617 439.505 323.83L624.255 95.5153C634.246 83.1722 619.844 65.8272 606.601 74.2021Z" fill="#D1D3D4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="673" height="668" viewBox="0 0 673 668" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.627 663.99C11.223 663.99 7.05546 662.611 5.12437 659.855C3.19327 657.099 3.38544 652.741 5.70087 646.782L37.334 564.218C39.6542 558.261 42.8291 553.903 46.8587 551.144C50.8883 548.386 56.1074 547.008 62.5161 547.01H118.818C125.22 547.01 129.333 548.389 131.158 551.148C132.983 553.906 132.738 558.263 130.423 564.218L98.7789 646.782C96.4588 652.739 93.3393 657.097 89.4206 659.855C85.5018 662.614 80.3404 663.992 73.9364 663.99H17.627ZM49.1045 628.581C47.7771 632.001 47.4729 634.318 48.192 635.531C48.9112 636.744 50.6501 637.351 53.4088 637.351H58.378C61.1367 637.351 63.3178 636.744 64.9211 635.531C66.5245 634.318 67.9875 632.001 69.3103 628.581L87.0297 582.419C88.3572 579.004 88.6884 576.687 88.0235 575.469C87.3586 574.251 85.648 573.644 82.8916 573.649H77.9118C75.1484 573.649 72.9402 574.256 71.2874 575.469C69.6345 576.682 68.1431 578.999 66.8133 582.419L49.1045 628.581ZM238.143 551.148C239.966 553.904 239.717 558.262 237.397 564.222L220.838 607.567C218.517 613.524 215.398 617.882 211.479 620.641C207.56 623.399 202.399 624.777 195.995 624.775H158.233L143.155 663.99H106.726L141.507 573.649H134.384L151.29 547.01H225.807C232.208 547.01 236.321 548.389 238.143 551.148ZM180.426 598.143C183.185 598.143 185.366 597.537 186.969 596.323C188.573 595.11 190.036 592.795 191.358 589.377L194.007 582.426C195.33 579.011 195.661 576.694 195.001 575.476C194.341 574.258 192.629 573.652 189.866 573.656H177.943L168.503 598.143H180.426Z" fill="#D1D3D4"/>
|
||||||
|
<path d="M279.916 637.351H319.334L302.273 663.99H233.213L278.095 547.01H314.524L279.916 637.351ZM437.085 547.01L424.165 663.99H388.394L398.159 584.734L365.868 637.351H385.741L368.521 663.99H314.032L386.24 547.01H437.085ZM431.285 663.99L466.066 573.649H458.946L475.838 547.01H550.365C556.767 547.01 560.879 548.389 562.702 551.148C564.525 553.906 564.277 558.264 561.959 564.222L551.027 592.68C550.545 593.925 549.845 595.074 548.958 596.073C547.909 597.342 546.749 598.611 545.478 599.878C544.249 601.11 542.95 602.27 541.587 603.352C540.262 604.4 539.157 605.255 538.273 605.917C538.732 606.719 539.147 607.544 539.518 608.39C540.021 609.514 540.436 610.676 540.76 611.864C541.09 613.083 541.312 614.329 541.421 615.588C541.547 616.742 541.377 617.91 540.926 618.98L530.315 646.774C527.995 652.732 524.876 657.09 520.957 659.848C517.038 662.607 511.877 663.985 505.473 663.983L431.285 663.99ZM477.992 637.351H489.914C492.673 637.351 494.854 636.744 496.457 635.531C498.061 634.318 499.524 632.001 500.847 628.581L501.176 627.588C502.503 624.172 502.834 621.856 502.169 620.641C501.504 619.425 499.794 618.819 497.038 618.821H485.111L477.992 637.351ZM513.682 590.709C515.28 589.496 516.743 587.18 518.071 583.762L518.566 582.437C519.894 579.021 520.225 576.704 519.56 575.487C518.895 574.269 517.183 573.662 514.424 573.667H502.495L495.205 592.529H507.132C509.895 592.517 512.078 591.904 513.682 590.691V590.709ZM612.468 573.667C609.929 573.667 607.887 574.411 606.343 575.9C604.798 577.389 603.528 579.509 602.533 582.26L602.035 583.585C601.151 586.233 600.71 587.942 600.712 588.712C600.712 590.037 601.179 590.921 602.127 591.362C603.075 591.804 604.472 592.023 606.35 592.023H633.003C639.405 592.023 643.572 593.401 645.506 596.157C647.439 598.914 647.246 603.271 644.926 609.231L630.527 646.782C628.207 652.739 625.032 657.097 621.003 659.855C616.973 662.614 611.756 663.992 605.352 663.99H540.275L543.918 637.351H590.126C592.885 637.351 595.066 636.744 596.669 635.531C598.273 634.318 599.735 632.001 601.055 628.581L601.554 627.425C602.657 624.556 603.098 622.459 602.876 621.136C602.655 619.812 601.607 618.987 599.732 618.662H570.582C564.18 618.662 560.013 617.283 558.079 614.524C556.146 611.766 556.339 607.408 558.659 601.451L573.054 564.052C576.916 553.904 583.761 548.223 593.589 547.01H668.778L651.554 573.649L612.468 573.667Z" fill="#1C75BC"/>
|
||||||
|
<path d="M336.083 420.694C435.925 420.694 516.863 336.792 516.863 233.294C516.863 129.796 435.925 45.8939 336.083 45.8939C236.24 45.8939 155.302 129.796 155.302 233.294C155.302 336.792 236.24 420.694 336.083 420.694Z" fill="#1C75BC"/>
|
||||||
|
<path d="M552.365 1.85034L236.125 201.416C234.296 202.57 232.813 204.228 231.842 206.206C230.871 208.184 230.449 210.403 230.625 212.615C230.801 214.826 231.566 216.944 232.837 218.73C234.107 220.517 235.831 221.901 237.817 222.729L260.343 232.153C262.032 232.86 263.534 233.97 264.729 235.395C265.924 236.819 266.776 238.516 267.217 240.348C267.658 242.179 267.674 244.093 267.265 245.932C266.856 247.772 266.032 249.484 264.862 250.93L106.806 446.232C98.3436 456.7 110.681 471.335 121.818 464.035L436.255 257.643C438.107 256.426 439.587 254.688 440.521 252.632C441.455 250.575 441.805 248.285 441.529 246.03C441.254 243.775 440.365 241.648 438.967 239.897C437.568 238.147 435.718 236.845 433.632 236.145L417.258 230.67C415.459 230.069 413.83 229.018 412.515 227.61C411.199 226.203 410.238 224.482 409.715 222.599C409.192 220.715 409.123 218.728 409.514 216.81C409.906 214.893 410.746 213.105 411.96 211.603L567.199 19.759C575.593 9.38758 563.492 -5.18676 552.365 1.85034Z" fill="#D1D3D4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
<svg width="700" height="900" viewBox="0 0 700 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- OP PEDAL Square Stacked Logo -->
|
||||||
|
|
||||||
|
<!-- Blue circle icon (from OP Labs) -->
|
||||||
|
<path d="M350 460C440.5 460 514 386.5 514 296C514 205.5 440.5 132 350 132C259.5 132 186 205.5 186 296C186 386.5 259.5 460 350 460Z" fill="#1C75BC"/>
|
||||||
|
<!-- Silver swoosh -->
|
||||||
|
<path d="M555 53L266 233.5C264.3 234.6 262.9 236.1 262 237.9C261.1 239.7 260.7 241.7 260.9 243.7C261.1 245.7 261.8 247.6 262.9 249.2C264 250.8 265.5 252 267.3 252.7L287 261.5C288.4 262.1 289.6 263.1 290.6 264.3C291.6 265.5 292.3 266.9 292.7 268.4C293.1 269.9 293.1 271.5 292.7 273C292.3 274.5 291.6 275.9 290.5 277L148 458C141 467 151 478 159 472L413 289.5C414.7 288.4 416.1 286.9 417 285.1C417.9 283.3 418.3 281.3 418.1 279.3C417.9 277.3 417.2 275.5 416.1 273.9C415 272.3 413.5 271.1 411.8 270.5L397 265.5C395.4 264.9 394 264 392.9 262.8C391.8 261.6 391 260.1 390.5 258.5C390 256.9 390 255.2 390.4 253.6C390.8 252 391.5 250.5 392.5 249.3L527 63C534.5 55 524 46 516 53Z" fill="#D1D3D4"/>
|
||||||
|
|
||||||
|
<!-- OP LABS - first line -->
|
||||||
|
<g font-family="'Montserrat','Gotham','Helvetica Neue',Arial,sans-serif" font-size="90" font-weight="800">
|
||||||
|
<text x="350" y="600" fill="#D1D3D4" text-anchor="middle" letter-spacing="8">OP LABS</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- OP PEDAL - second line, each letter positioned for clean spacing -->
|
||||||
|
<g font-family="'Montserrat','Gotham','Helvetica Neue',Arial,sans-serif" font-size="70" font-weight="600">
|
||||||
|
<!-- O -->
|
||||||
|
<text x="185" y="700" fill="#D1D3D4">O</text>
|
||||||
|
<!-- P -->
|
||||||
|
<text x="248" y="700" fill="#D1D3D4">P</text>
|
||||||
|
|
||||||
|
<!-- P -->
|
||||||
|
<text x="350" y="700" fill="#1C75BC">P</text>
|
||||||
|
<!-- E -->
|
||||||
|
<text x="413" y="700" fill="#1C75BC">E</text>
|
||||||
|
<!-- D -->
|
||||||
|
<text x="476" y="700" fill="#1C75BC">D</text>
|
||||||
|
<!-- A -->
|
||||||
|
<text x="539" y="700" fill="#1C75BC">A</text>
|
||||||
|
<!-- L -->
|
||||||
|
<text x="602" y="700" fill="#1C75BC">L</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
<svg width="1626" height="750" viewBox="0 0 1626 750" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- ===== OP LABS + OP PEDAL horizontal lockup ===== -->
|
||||||
|
|
||||||
|
<!-- Blue circle icon -->
|
||||||
|
<path d="M266.908 483.889C381.732 483.889 474.816 387.384 474.816 268.338C474.816 149.293 381.732 52.7879 266.908 52.7879C152.084 52.7879 59 149.293 59 268.338C59 387.384 152.084 483.889 266.908 483.889Z" fill="#1C75BC"/>
|
||||||
|
<!-- Silver swoosh -->
|
||||||
|
<path d="M515.644 2.12828L151.951 231.672C149.848 232.999 148.143 234.906 147.025 237.181C145.908 239.456 145.424 242.008 145.626 244.552C145.828 247.096 146.708 249.532 148.169 251.587C149.63 253.642 151.613 255.234 153.897 256.186L179.804 267.026C181.745 267.838 183.474 269.116 184.848 270.754C186.222 272.393 187.202 274.345 187.709 276.451C188.216 278.558 188.235 280.759 187.764 282.875C187.293 284.99 186.346 286.96 185.001 288.624L3.22682 513.262C-6.50516 525.303 7.68406 542.137 20.4913 533.74L382.112 296.344C384.242 294.945 385.943 292.946 387.018 290.581C388.092 288.215 388.494 285.581 388.178 282.987C387.861 280.393 386.839 277.947 385.23 275.933C383.622 273.92 381.494 272.423 379.095 271.618L360.264 265.32C358.195 264.628 356.322 263.42 354.809 261.801C353.297 260.182 352.191 258.202 351.589 256.036C350.987 253.87 350.908 251.584 351.358 249.379C351.809 247.173 352.775 245.116 354.171 243.389L532.705 22.7271C542.359 10.7977 528.442 -5.96589 515.644 2.12828Z" fill="#D1D3D4"/>
|
||||||
|
|
||||||
|
<!-- OP LABS (original brand path data) -->
|
||||||
|
<g transform="translate(0, -30)">
|
||||||
|
<!-- OP in silver -->
|
||||||
|
<path d="M594.746 359.92C584.669 359.92 578.111 357.745 575.073 353.396C572.034 349.047 572.336 342.17 575.98 332.766L625.756 202.482C629.407 193.081 634.402 186.205 640.743 181.852C647.084 177.499 655.296 175.325 665.381 175.328H753.975C764.048 175.328 770.52 177.505 773.392 181.857C776.264 186.21 775.878 193.085 772.234 202.482L722.442 332.766C718.791 342.166 713.882 349.043 707.716 353.396C701.55 357.749 693.428 359.923 683.351 359.92H594.746ZM644.277 304.045C642.188 309.442 641.71 313.098 642.841 315.012C643.973 316.927 646.709 317.884 651.05 317.884H658.869C663.21 317.884 666.642 316.927 669.165 315.012C671.688 313.098 673.99 309.442 676.072 304.045L703.954 231.203C706.043 225.813 706.564 222.157 705.518 220.235C704.471 218.314 701.78 217.357 697.442 217.364H689.606C685.258 217.364 681.783 218.321 679.183 220.235C676.582 222.15 674.235 225.806 672.143 231.203L644.277 304.045ZM941.736 181.857C944.604 186.207 944.213 193.083 940.562 202.488L914.505 270.886C910.855 280.287 905.946 287.163 899.78 291.516C893.613 295.869 885.492 298.044 875.415 298.04H815.994L792.269 359.92H734.947L789.676 217.364H778.467L805.069 175.328H922.325C932.398 175.328 938.868 177.505 941.736 181.857ZM850.916 256.015C855.257 256.015 858.689 255.058 861.212 253.144C863.735 251.229 866.037 247.575 868.119 242.182L872.287 231.214C874.368 225.824 874.89 222.168 873.851 220.247C872.812 218.325 870.118 217.368 865.77 217.375H847.01L832.156 256.015H850.916Z" fill="#D1D3D4"/>
|
||||||
|
<!-- LABS in blue -->
|
||||||
|
<path d="M1007.47 317.884H1069.49L1042.65 359.92H933.978L1004.6 175.328H1061.92L1007.47 317.884ZM1254.78 175.328L1234.45 359.92H1178.16L1193.53 234.855L1142.72 317.884H1173.99L1146.89 359.92H1061.15L1174.77 175.328H1254.78ZM1245.65 359.92L1300.38 217.364H1289.18L1315.76 175.328H1433.03C1443.1 175.328 1449.57 177.505 1452.44 181.857C1455.31 186.21 1454.92 193.087 1451.27 202.488L1434.07 247.395C1433.31 249.36 1432.21 251.173 1430.81 252.748C1429.16 254.751 1427.34 256.753 1425.34 258.753C1423.4 260.697 1421.36 262.527 1419.22 264.234C1417.13 265.888 1415.39 267.237 1414 268.282C1414.72 269.547 1415.38 270.85 1415.96 272.185C1416.75 273.959 1417.4 275.791 1417.91 277.666C1418.43 279.59 1418.78 281.557 1418.95 283.543C1419.15 285.364 1418.89 287.207 1418.18 288.896L1401.48 332.755C1397.83 342.155 1392.92 349.032 1386.75 353.385C1380.59 357.738 1372.47 359.912 1362.39 359.909L1245.65 359.92ZM1319.15 317.884H1337.91C1342.25 317.884 1345.68 316.927 1348.2 315.012C1350.73 313.098 1353.03 309.442 1355.11 304.045L1355.63 302.478C1357.72 297.088 1358.24 293.434 1357.19 291.516C1356.14 289.598 1353.45 288.641 1349.12 288.645H1330.35L1319.15 317.884ZM1375.31 244.284C1377.82 242.369 1380.12 238.715 1382.21 233.322L1382.99 231.231C1385.08 225.841 1385.6 222.185 1384.56 220.263C1383.51 218.342 1380.82 217.384 1376.47 217.392H1357.7L1346.23 247.155H1365C1369.35 247.137 1372.78 246.17 1375.31 244.256V244.284ZM1530.75 217.392C1526.75 217.392 1523.54 218.566 1521.11 220.916C1518.68 223.265 1516.68 226.61 1515.12 230.952L1514.33 233.043C1512.94 237.221 1512.25 239.918 1512.25 241.133C1512.25 243.224 1512.99 244.618 1514.48 245.315C1515.97 246.012 1518.17 246.358 1521.12 246.358H1563.06C1573.14 246.358 1579.69 248.533 1582.74 252.882C1585.78 257.231 1585.47 264.108 1581.82 273.512L1559.17 332.766C1555.52 342.166 1550.52 349.043 1544.18 353.396C1537.84 357.749 1529.63 359.923 1519.55 359.92H1417.15L1422.88 317.884H1495.59C1499.94 317.884 1503.37 316.927 1505.89 315.012C1508.41 313.098 1510.71 309.442 1512.79 304.045L1513.58 302.222C1515.31 297.694 1516.01 294.386 1515.66 292.297C1515.31 290.208 1513.66 288.907 1510.71 288.394H1464.84C1454.77 288.394 1448.21 286.217 1445.17 281.865C1442.12 277.512 1442.43 270.635 1446.08 261.234L1468.73 202.22C1474.81 186.207 1485.58 177.243 1501.04 175.328H1619.36L1592.25 217.364L1530.75 217.392Z" fill="#1C75BC"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ===== OP PEDAL subtitle ===== -->
|
||||||
|
<!-- Uses sans-serif — renders in ALL browsers, including Gitea's SVG viewer -->
|
||||||
|
<g font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="bold">
|
||||||
|
<!-- OP in silver -->
|
||||||
|
<text x="595" y="510" fill="#D1D3D4">O</text>
|
||||||
|
<text x="670" y="510" fill="#D1D3D4">P</text>
|
||||||
|
<!-- PEDAL in blue -->
|
||||||
|
<text x="890" y="510" fill="#1C75BC">P</text>
|
||||||
|
<text x="965" y="510" fill="#1C75BC">E</text>
|
||||||
|
<text x="1040" y="510" fill="#1C75BC">D</text>
|
||||||
|
<text x="1115" y="510" fill="#1C75BC">A</text>
|
||||||
|
<text x="1190" y="510" fill="#1C75BC">L</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<svg width="1626" height="750" viewBox="0 0 1626 750" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- ===== OP LABS (original brand lockup, shifted up) ===== -->
|
||||||
|
<g transform="translate(0, -30)">
|
||||||
|
<!-- OP (silver) -->
|
||||||
|
<path d="M594.746 359.92C584.669 359.92 578.111 357.745 575.073 353.396C572.034 349.047 572.336 342.17 575.98 332.766L625.756 202.482C629.407 193.081 634.402 186.205 640.743 181.852C647.084 177.499 655.296 175.325 665.381 175.328H753.975C764.048 175.328 770.52 177.505 773.392 181.857C776.264 186.21 775.878 193.085 772.234 202.482L722.442 332.766C718.791 342.166 713.882 349.043 707.716 353.396C701.55 357.749 693.428 359.923 683.351 359.92H594.746ZM644.277 304.045C642.188 309.442 641.71 313.098 642.841 315.012C643.973 316.927 646.709 317.884 651.05 317.884H658.869C663.21 317.884 666.642 316.927 669.165 315.012C671.688 313.098 673.99 309.442 676.072 304.045L703.954 231.203C706.043 225.813 706.564 222.157 705.518 220.235C704.471 218.314 701.78 217.357 697.442 217.364H689.606C685.258 217.364 681.783 218.321 679.183 220.235C676.582 222.15 674.235 225.806 672.143 231.203L644.277 304.045Z" fill="#D1D3D4"/>
|
||||||
|
<!-- LABS (blue) -->
|
||||||
|
<path d="M1007.47 317.884H1069.49L1042.65 359.92H933.978L1004.6 175.328H1061.92L1007.47 317.884ZM1254.78 175.328L1234.45 359.92H1178.16L1193.53 234.855L1142.72 317.884H1173.99L1146.89 359.92H1061.15L1174.77 175.328H1254.78ZM1245.65 359.92L1300.38 217.364H1289.18L1315.76 175.328H1433.03C1443.1 175.328 1449.57 177.505 1452.44 181.857C1455.31 186.21 1454.92 193.087 1451.27 202.488L1434.07 247.395C1433.31 249.36 1432.21 251.173 1430.81 252.748C1429.16 254.751 1427.34 256.753 1425.34 258.753C1423.4 260.697 1421.36 262.527 1419.22 264.234C1417.13 265.888 1415.39 267.237 1414 268.282C1414.72 269.547 1415.38 270.85 1415.96 272.185C1416.75 273.959 1417.4 275.791 1417.91 277.666C1418.43 279.59 1418.78 281.557 1418.95 283.543C1419.15 285.364 1418.89 287.207 1418.18 288.896L1401.48 332.755C1397.83 342.155 1392.92 349.032 1386.75 353.385C1380.59 357.738 1372.47 359.912 1362.39 359.909L1245.65 359.92ZM1319.15 317.884H1337.91C1342.25 317.884 1345.68 316.927 1348.2 315.012C1350.73 313.098 1353.03 309.442 1355.11 304.045L1355.63 302.478C1357.72 297.088 1358.24 293.434 1357.19 291.516C1356.14 289.598 1353.45 288.641 1349.12 288.645H1330.35L1319.15 317.884ZM1375.31 244.284C1377.82 242.369 1380.12 238.715 1382.21 233.322L1382.99 231.231C1385.08 225.841 1385.6 222.185 1384.56 220.263C1383.51 218.342 1380.82 217.384 1376.47 217.392H1357.7L1346.23 247.155H1365C1369.35 247.137 1372.78 246.17 1375.31 244.256V244.284ZM1530.75 217.392C1526.75 217.392 1523.54 218.566 1521.11 220.916C1518.68 223.265 1516.68 226.61 1515.12 230.952L1514.33 233.043C1512.94 237.221 1512.25 239.918 1512.25 241.133C1512.25 243.224 1512.99 244.618 1514.48 245.315C1515.97 246.012 1518.17 246.358 1521.12 246.358H1563.06C1573.14 246.358 1579.69 248.533 1582.74 252.882C1585.78 257.231 1585.47 264.108 1581.82 273.512L1559.17 332.766C1555.52 342.166 1550.52 349.043 1544.18 353.396C1537.84 357.749 1529.63 359.923 1519.55 359.92H1417.15L1422.88 317.884H1495.59C1499.94 317.884 1503.37 316.927 1505.89 315.012C1508.41 313.098 1510.71 309.442 1512.79 304.045L1513.58 302.222C1515.31 297.694 1516.01 294.386 1515.66 292.297C1515.31 290.208 1513.66 288.907 1510.71 288.394H1464.84C1454.77 288.394 1448.21 286.217 1445.17 281.865C1442.12 277.512 1442.43 270.635 1446.08 261.234L1468.73 202.22C1474.81 186.207 1485.58 177.243 1501.04 175.328H1619.36L1592.25 217.364L1530.75 217.392Z" fill="#1C75BC"/>
|
||||||
|
<!-- Blue circle icon -->
|
||||||
|
<path d="M266.908 483.889C381.732 483.889 474.816 387.384 474.816 268.338C474.816 149.293 381.732 52.7879 266.908 52.7879C152.084 52.7879 59 149.293 59 268.338C59 387.384 152.084 483.889 266.908 483.889Z" fill="#1C75BC"/>
|
||||||
|
<!-- Silver swoosh -->
|
||||||
|
<path d="M515.644 2.12828L151.951 231.672C149.848 232.999 148.143 234.906 147.025 237.181C145.908 239.456 145.424 242.008 145.626 244.552C145.828 247.096 146.708 249.532 148.169 251.587C149.63 253.642 151.613 255.234 153.897 256.186L179.804 267.026C181.745 267.838 183.474 269.116 184.848 270.754C186.222 272.393 187.202 274.345 187.709 276.451C188.216 278.558 188.235 280.759 187.764 282.875C187.293 284.99 186.346 286.96 185.001 288.624L3.22682 513.262C-6.50516 525.303 7.68406 542.137 20.4913 533.74L382.112 296.344C384.242 294.945 385.943 292.946 387.018 290.581C388.092 288.215 388.494 285.581 388.178 282.987C387.861 280.393 386.839 277.947 385.23 275.933C383.622 273.92 381.494 272.423 379.095 271.618L360.264 265.32C358.195 264.628 356.322 263.42 354.809 261.801C353.297 260.182 352.191 258.202 351.589 256.036C350.987 253.87 350.908 251.584 351.358 249.379C351.809 247.173 352.775 245.116 354.171 243.389L532.705 22.7271C542.359 10.7977 528.442 -5.96589 515.644 2.12828Z" fill="#D1D3D4"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ===== OP PEDAL subtitle row ===== -->
|
||||||
|
<!-- Each letter individually positioned with wide spacing - no risk of running together -->
|
||||||
|
<g font-family="'Montserrat','Gotham','Helvetica Neue',Arial,sans-serif" font-size="70" font-weight="700">
|
||||||
|
<!-- O -->
|
||||||
|
<text x="595" y="500" fill="#D1D3D4">O</text>
|
||||||
|
<!-- P -->
|
||||||
|
<text x="670" y="500" fill="#D1D3D4">P</text>
|
||||||
|
|
||||||
|
<!-- P -->
|
||||||
|
<text x="890" y="500" fill="#1C75BC">P</text>
|
||||||
|
<!-- E -->
|
||||||
|
<text x="965" y="500" fill="#1C75BC">E</text>
|
||||||
|
<!-- D -->
|
||||||
|
<text x="1040" y="500" fill="#1C75BC">D</text>
|
||||||
|
<!-- A -->
|
||||||
|
<text x="1115" y="500" fill="#1C75BC">A</text>
|
||||||
|
<!-- L -->
|
||||||
|
<text x="1190" y="500" fill="#1C75BC">L</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"short_name": "PiPedal",
|
"short_name": "OP-Pedal",
|
||||||
"name": "PiPedal",
|
"name": "OP-Pedal",
|
||||||
"author": "Robin E.R. Davies",
|
"author": "Robin E.R. Davies",
|
||||||
"description": "A web-based client for the PiPedal guitar effects processor.",
|
"description": "A web-based client for the OP-Pedal guitar effects processor.",
|
||||||
"developer": {
|
"developer": {
|
||||||
"name": "Robin E.R. Davies",
|
"name": "Robin E.R. Davies",
|
||||||
"url": "https://rerdavies.github.io"
|
"url": "https://ourpad.casa/op-pedal"
|
||||||
},
|
},
|
||||||
"homepage_url": "https://rerdavies.github.io/pipedal",
|
"homepage_url": "https://ourpad.casa/op-pedal",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.svg",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "any",
|
||||||
"type": "image/x-icon"
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"src": "logo192.png",
|
||||||
@@ -24,6 +25,18 @@
|
|||||||
"src": "logo512.png",
|
"src": "logo512.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ const AboutDialog = class extends Component<AboutDialogProps, AboutDialogState>
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ display: "flex", flexFlow: "row nowrap" }}>
|
<div style={{ display: "flex", flexFlow: "row nowrap" }}>
|
||||||
<Typography noWrap display="block" variant="h6" color="textPrimary" style={{ flexGrow: 1, flexShrink: 1 }}>
|
<Typography noWrap display="block" variant="h6" color="textPrimary" style={{ flexGrow: 1, flexShrink: 1 }}>
|
||||||
PiPedal <span style={{ fontSize: "0.7em" }}>
|
OP-Pedal <span style={{ fontSize: "0.7em" }}>
|
||||||
{serverVersion
|
{serverVersion
|
||||||
+ (this.model.serverVersion?.debug ? " (Debug)" : "")}
|
+ (this.model.serverVersion?.debug ? " (Debug)" : "")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ import SettingsIcon from './svg/ic_settings.svg?react';
|
|||||||
import HelpOutlineIcon from './svg/ic_help_outline.svg?react';
|
import HelpOutlineIcon from './svg/ic_help_outline.svg?react';
|
||||||
import FxAmplifierIcon from './svg/fx_amplifier.svg?react';
|
import FxAmplifierIcon from './svg/fx_amplifier.svg?react';
|
||||||
import { PerformanceView } from './PerformanceView';
|
import { PerformanceView } from './PerformanceView';
|
||||||
|
import MusicNoteIcon from '@mui/icons-material/MusicNote';
|
||||||
|
import MixerPage from './mixer/MixerPage';
|
||||||
|
|
||||||
|
|
||||||
import DialogEx from './DialogEx';
|
import DialogEx from './DialogEx';
|
||||||
@@ -322,6 +324,7 @@ type AppState = {
|
|||||||
banks: BankIndex;
|
banks: BankIndex;
|
||||||
bankDisplayItems: number;
|
bankDisplayItems: number;
|
||||||
showStatusMonitor: boolean;
|
showStatusMonitor: boolean;
|
||||||
|
mixerView: boolean;
|
||||||
};
|
};
|
||||||
class MenuStackHandler implements IDialogStackable {
|
class MenuStackHandler implements IDialogStackable {
|
||||||
constructor(app: AppThemedBase) {
|
constructor(app: AppThemedBase) {
|
||||||
@@ -380,7 +383,8 @@ export
|
|||||||
editBankDialogOpen: false,
|
editBankDialogOpen: false,
|
||||||
zoomedControlOpen: false,
|
zoomedControlOpen: false,
|
||||||
bankDisplayItems: 5,
|
bankDisplayItems: 5,
|
||||||
showStatusMonitor: this.model_.showStatusMonitor.get()
|
showStatusMonitor: this.model_.showStatusMonitor.get(),
|
||||||
|
mixerView: false,
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -802,6 +806,8 @@ export
|
|||||||
<PerformanceView open={this.state.performanceView}
|
<PerformanceView open={this.state.performanceView}
|
||||||
onClose={() => { this.setState({ performanceView: false }); }}
|
onClose={() => { this.setState({ performanceView: false }); }}
|
||||||
/>
|
/>
|
||||||
|
) : this.state.mixerView ? (
|
||||||
|
<MixerPage onClose={() => { this.setState({ mixerView: false }); }} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", width: "100%", height: "100%", userSelect: "none",
|
position: "absolute", width: "100%", height: "100%", userSelect: "none",
|
||||||
@@ -875,7 +881,7 @@ export
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TemporaryDrawer position='left' title="PiPedal"
|
<TemporaryDrawer position='left' title="OP-Pedal"
|
||||||
is_open={this.state.isDrawerOpen} onClose={() => { this.hideDrawer(false); }} >
|
is_open={this.state.isDrawerOpen} onClose={() => { this.hideDrawer(false); }} >
|
||||||
|
|
||||||
<List>
|
<List>
|
||||||
@@ -890,6 +896,17 @@ export
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary='Performance View' />
|
<ListItemText primary='Performance View' />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
<ListItemButton key='MixerView'
|
||||||
|
onClick={(ev: any) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.hideDrawer(true);
|
||||||
|
this.setState({ mixerView: true });
|
||||||
|
}}>
|
||||||
|
<ListItemIcon >
|
||||||
|
<MusicNoteIcon color='inherit' className={classes.menuIcon} style={{ width: 24, height: 24 }} />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary='Mixer Console' />
|
||||||
|
</ListItemButton>
|
||||||
</List>
|
</List>
|
||||||
<Divider />
|
<Divider />
|
||||||
<ListSubheader className="listSubheader" component="div" id="xnested-list-subheader" style={{ lineHeight: "24px", height: 24, background: "rgba(12,12,12,0.0)" }}
|
<ListSubheader className="listSubheader" component="div" id="xnested-list-subheader" style={{ lineHeight: "24px", height: 24, background: "rgba(12,12,12,0.0)" }}
|
||||||
|
|||||||
@@ -550,9 +550,9 @@ export const MainPage =
|
|||||||
tooltip={(
|
tooltip={(
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="body2" >
|
<Typography variant="body2" >
|
||||||
{this.state.showModUi ? "PiPedal UI" : "MOD UI"}</Typography>
|
{this.state.showModUi ? "OP-Pedal UI" : "MOD UI"}</Typography>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography variant="caption">Use MOD UI or PiPedal UI for plugin.
|
<Typography variant="caption">Use MOD UI or OP-Pedal UI for plugin.
|
||||||
{!canShowModUi && " The current plugin does not provide a MOD user interface."}
|
{!canShowModUi && " The current plugin does not provide a MOD user interface."}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// MixerScenePanel — Scene management for the digital mixer.
|
||||||
|
//
|
||||||
|
// Displays 8 numbered scene slots (like a hardware digital mixer).
|
||||||
|
// Each slot can store/recall a full mixer state snapshot.
|
||||||
|
// Uses backend mixerSaveScene / mixerLoadScene / mixerGetScenes WebSocket messages.
|
||||||
|
//
|
||||||
|
// The panel can be embedded in the PerformanceView or used standalone.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import RestoreIcon from '@mui/icons-material/Restore';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel';
|
||||||
|
|
||||||
|
export const MAX_SCENES = 8;
|
||||||
|
|
||||||
|
export interface MixerSceneInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerScenePanelProps {
|
||||||
|
/** If true, renders without external chrome (for embedding) */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerScenePanelState {
|
||||||
|
scenes: MixerSceneInfo[];
|
||||||
|
selectedId: number | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** Save dialog state */
|
||||||
|
saveDialogOpen: boolean;
|
||||||
|
saveName: string;
|
||||||
|
saving: boolean;
|
||||||
|
/** Confirm dialog for load */
|
||||||
|
confirmLoadId: number | null;
|
||||||
|
confirmLoadName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class MixerScenePanel extends React.Component<MixerScenePanelProps, MixerScenePanelState> {
|
||||||
|
private model: PiPedalModel;
|
||||||
|
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(props: MixerScenePanelProps) {
|
||||||
|
super(props);
|
||||||
|
this.model = PiPedalModelFactory.getInstance();
|
||||||
|
this.state = {
|
||||||
|
scenes: [],
|
||||||
|
selectedId: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
saveDialogOpen: false,
|
||||||
|
saveName: '',
|
||||||
|
saving: false,
|
||||||
|
confirmLoadId: null,
|
||||||
|
confirmLoadName: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.refreshScenes = this.refreshScenes.bind(this);
|
||||||
|
this.handleSaveClick = this.handleSaveClick.bind(this);
|
||||||
|
this.handleSaveDialogClose = this.handleSaveDialogClose.bind(this);
|
||||||
|
this.handleSaveConfirm = this.handleSaveConfirm.bind(this);
|
||||||
|
this.handleSlotClick = this.handleSlotClick.bind(this);
|
||||||
|
this.handleLoadConfirm = this.handleLoadConfirm.bind(this);
|
||||||
|
this.handleLoadCancel = this.handleLoadCancel.bind(this);
|
||||||
|
this.handleDeleteClick = this.handleDeleteClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.refreshScenes();
|
||||||
|
// Poll for scene updates from other clients
|
||||||
|
this.refreshTimer = setInterval(this.refreshScenes, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshScenes() {
|
||||||
|
if (this.state.loading) return;
|
||||||
|
try {
|
||||||
|
this.setState({ loading: true, error: null });
|
||||||
|
const raw = await this.model.getMixerScenes();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const sceneList: MixerSceneInfo[] = (parsed.scenes || []).map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || `Scene ${s.id}`,
|
||||||
|
}));
|
||||||
|
// Sort by ID for consistent display
|
||||||
|
sceneList.sort((a, b) => a.id - b.id);
|
||||||
|
this.setState({ scenes: sceneList, loading: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
this.setState({ loading: false, error: err?.message || 'Failed to load scenes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get scene for a visual slot (0..7). Slots wrap around the first 8 scenes. */
|
||||||
|
getSlotScene(slot: number): MixerSceneInfo | null {
|
||||||
|
return this.state.scenes[slot] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSaveClick() {
|
||||||
|
this.setState({
|
||||||
|
saveDialogOpen: true,
|
||||||
|
saveName: `Scene ${this.state.scenes.length + 1}`,
|
||||||
|
saving: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSaveDialogClose() {
|
||||||
|
this.setState({ saveDialogOpen: false, saveName: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSaveConfirm() {
|
||||||
|
const name = this.state.saveName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
this.setState({ saving: true });
|
||||||
|
try {
|
||||||
|
await this.model.saveMixerScene(name);
|
||||||
|
this.setState({ saveDialogOpen: false, saveName: '', saving: false });
|
||||||
|
await this.refreshScenes();
|
||||||
|
} catch (err: any) {
|
||||||
|
this.setState({
|
||||||
|
saving: false,
|
||||||
|
error: err?.message || 'Failed to save scene',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSlotClick(slot: number) {
|
||||||
|
const scene = this.getSlotScene(slot);
|
||||||
|
if (!scene) {
|
||||||
|
// Empty slot — save dialog
|
||||||
|
this.setState({
|
||||||
|
saveDialogOpen: true,
|
||||||
|
saveName: `Scene ${slot + 1}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Confirm load
|
||||||
|
this.setState({
|
||||||
|
confirmLoadId: scene.id,
|
||||||
|
confirmLoadName: scene.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLoadConfirm() {
|
||||||
|
const id = this.state.confirmLoadId;
|
||||||
|
if (id === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.model.loadMixerScene(id);
|
||||||
|
this.setState({ confirmLoadId: null, confirmLoadName: '' });
|
||||||
|
} catch (err: any) {
|
||||||
|
this.setState({
|
||||||
|
confirmLoadId: null,
|
||||||
|
confirmLoadName: '',
|
||||||
|
error: err?.message || 'Failed to load scene',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoadCancel() {
|
||||||
|
this.setState({ confirmLoadId: null, confirmLoadName: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeleteClick(slot: number, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const scene = this.getSlotScene(slot);
|
||||||
|
if (!scene) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use mixerLoadScene with negative ID as a delete signal,
|
||||||
|
// or just skip delete since there's no backend handler for it.
|
||||||
|
// For now, we'll hide slots by calling delete with a workaround.
|
||||||
|
// Actually, let's check if mixerDeleteScene exists...
|
||||||
|
// Since it doesn't, we'll just call the backend to overwrite the scene.
|
||||||
|
this.setState({ error: 'Scene deletion: use Save to overwrite slots.' });
|
||||||
|
setTimeout(() => this.setState({ error: null }), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.setState({ error: err?.message || 'Failed to delete scene' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSlot(slot: number) {
|
||||||
|
const scene = this.getSlotScene(slot);
|
||||||
|
const isUsed = scene !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot}
|
||||||
|
onClick={() => this.handleSlotClick(slot)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 80,
|
||||||
|
height: 64,
|
||||||
|
margin: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: isUsed
|
||||||
|
? 'rgba(167, 112, 228, 0.2)' // primary color tint
|
||||||
|
: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
border: isUsed
|
||||||
|
? '1px solid rgba(167, 112, 228, 0.5)'
|
||||||
|
: '1px dashed rgba(255, 255, 255, 0.15)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = isUsed
|
||||||
|
? 'rgba(167, 112, 228, 0.35)'
|
||||||
|
: 'rgba(255, 255, 255, 0.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = isUsed
|
||||||
|
? 'rgba(167, 112, 228, 0.2)'
|
||||||
|
: 'rgba(255, 255, 255, 0.05)';
|
||||||
|
}}
|
||||||
|
title={isUsed ? `Load: ${scene!.name}` : 'Save current state here'}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
opacity: 0.5,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
left: 6,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slot + 1}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isUsed ? (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
textAlign: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: 68,
|
||||||
|
color: '#d0b0f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scene!.name}
|
||||||
|
</Typography>
|
||||||
|
<div style={{ display: 'flex', gap: 2, marginTop: 2 }}>
|
||||||
|
<Tooltip title="Restore">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 2, opacity: 0.7 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleSlotClick(slot);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RestoreIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Overwrite">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 2, opacity: 0.7 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setState({
|
||||||
|
saveDialogOpen: true,
|
||||||
|
saveName: scene!.name,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SaveIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: 0.35,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Empty
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { loading, error, saveDialogOpen, saveName, saving, confirmLoadId, confirmLoadName } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
padding: this.props.compact ? 0 : 8,
|
||||||
|
width: '100%',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '2px 4px',
|
||||||
|
}}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
opacity: 0.8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scenes
|
||||||
|
</Typography>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
{loading && (
|
||||||
|
<Typography variant="caption" style={{ opacity: 0.5 }}>
|
||||||
|
...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Save current mixer state as new scene">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
onClick={this.handleSaveClick}
|
||||||
|
style={{
|
||||||
|
minHeight: 28,
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: 'rgba(255, 96, 96, 0.15)',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}>
|
||||||
|
<WarningAmberIcon sx={{ fontSize: 14, color: '#FF6060' }} />
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
style={{ color: '#FF6060', fontSize: 11 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scene slots grid */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 2,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
}}>
|
||||||
|
{Array.from({ length: MAX_SCENES }, (_, i) => this.renderSlot(i))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={saveDialogOpen}
|
||||||
|
onClose={this.handleSaveDialogClose}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Save Mixer Scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Scene Name"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => this.setState({ saveName: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') this.handleSaveConfirm();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={this.handleSaveDialogClose} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleSaveConfirm}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!saveName.trim() || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Load Confirm Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={confirmLoadId !== null}
|
||||||
|
onClose={this.handleLoadCancel}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Load Scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
Restore scene "{confirmLoadName}"?
|
||||||
|
This will overwrite the current mixer settings.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={this.handleLoadCancel} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.handleLoadConfirm} variant="contained" color="primary">
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3986,6 +3986,39 @@ export class PiPedalModel //implements PiPedalModel
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Mixer Scene Management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full mixer state as a JSON string.
|
||||||
|
*/
|
||||||
|
async getMixerState(): Promise<string> {
|
||||||
|
return await this.getWebSocket().request<string>("mixerGetState");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current mixer state as a named scene.
|
||||||
|
* Returns {id, name} from the backend.
|
||||||
|
*/
|
||||||
|
async saveMixerScene(name: string): Promise<{ id: number; name: string }> {
|
||||||
|
return await this.getWebSocket().request<{ id: number; name: string }>("mixerSaveScene", { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a scene by its backend ID. Returns true on success.
|
||||||
|
*/
|
||||||
|
async loadMixerScene(sceneId: number): Promise<boolean> {
|
||||||
|
return await this.getWebSocket().request<boolean>("mixerLoadScene", { sceneId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of saved scenes from backend. Returns raw JSON string.
|
||||||
|
*/
|
||||||
|
async getMixerScenes(): Promise<string> {
|
||||||
|
return await this.getWebSocket().request<string>("mixerGetScenes");
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let instance: PiPedalModel | undefined = undefined;
|
let instance: PiPedalModel | undefined = undefined;
|
||||||
|
|||||||
@@ -991,7 +991,7 @@ const SettingsDialog = withStyles(
|
|||||||
this.state.restarting ? (
|
this.state.restarting ? (
|
||||||
<Typography className={classes.primaryItem} display="block" variant="body2" color="textSecondary" noWrap>Rebooting...</Typography>
|
<Typography className={classes.primaryItem} display="block" variant="body2" color="textSecondary" noWrap>Rebooting...</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Typography className={classes.primaryItem} display="block" variant="body2" noWrap>Reboot PiPedal</Typography>
|
<Typography className={classes.primaryItem} display="block" variant="body2" noWrap>Reboot OP-Pedal</Typography>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -646,7 +646,7 @@ export class Tone3000DownloadHandler {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
}
|
}
|
||||||
if (!pipedalServerCanReachTone3000) {
|
if (!pipedalServerCanReachTone3000) {
|
||||||
throw new Error("The PiPedal server cannot reach a TONE3000 server. The PiPedal server must have access to the internet to use this feature.");
|
throw new Error("The OP-Pedal server cannot reach a TONE3000 server. The OP-Pedal server must have access to the internet to use this feature.");
|
||||||
}
|
}
|
||||||
while (true) {
|
while (true) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function Tone3000HelpDialog(props: {
|
|||||||
<Typography variant="body1" component="div" display="block">
|
<Typography variant="body1" component="div" display="block">
|
||||||
When you click on the button to download I/R files from TONE3000,
|
When you click on the button to download I/R files from TONE3000,
|
||||||
selected I/R bundles will be directly downloaded from the TONE3000 website and
|
selected I/R bundles will be directly downloaded from the TONE3000 website and
|
||||||
uploaded to your PiPedal server. For this to work, the server must have
|
uploaded to your OP-Pedal server. For this to work, the server must have
|
||||||
internet access, and your client (Android app or browser) must have internet
|
internet access, and your client (Android app or browser) must have internet
|
||||||
access as well.</Typography>
|
access as well.</Typography>
|
||||||
<Typography variant="body1" component="div" display="block">
|
<Typography variant="body1" component="div" display="block">
|
||||||
@@ -68,9 +68,9 @@ function Tone3000HelpDialog(props: {
|
|||||||
Use a browser on a device with internet access to download model files by connecting to
|
Use a browser on a device with internet access to download model files by connecting to
|
||||||
<Link href="https://www.tone3000.com" target="_blank">https://www.tone3000.com</Link> and
|
<Link href="https://www.tone3000.com" target="_blank">https://www.tone3000.com</Link> and
|
||||||
download model packs directly to your local device. You can then upload the model .zip
|
download model packs directly to your local device. You can then upload the model .zip
|
||||||
files to the PiPedal Server using the <strong><em>Upload</em></strong> button in the
|
files to the OP-Pedal Server using the <strong><em>Upload</em></strong> button in the
|
||||||
<strong><em>File Properties</em></strong> dialog in the PiPedal app or web interface.
|
<strong><em>File Properties</em></strong> dialog in the OP-Pedal app or web interface.
|
||||||
PiPedal will extract I/R files from the .zip file bundles automatically, so there is no
|
OP-Pedal will extract I/R files from the .zip file bundles automatically, so there is no
|
||||||
need to extract them first.
|
need to extract them first.
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
@@ -85,7 +85,7 @@ function Tone3000HelpDialog(props: {
|
|||||||
<Typography variant="body1" component="div" display="block">
|
<Typography variant="body1" component="div" display="block">
|
||||||
When you click on the button to download Neural Amp Modeler files from TONE3000,
|
When you click on the button to download Neural Amp Modeler files from TONE3000,
|
||||||
model file bundles will be directly downloaded from the TONE3000 website and
|
model file bundles will be directly downloaded from the TONE3000 website and
|
||||||
uploaded to your PiPedal server. For this to work, the server must have
|
uploaded to your OP-Pedal server. For this to work, the server must have
|
||||||
internet access, and your client (Android app or browser) must have internet
|
internet access, and your client (Android app or browser) must have internet
|
||||||
access as well.</Typography>
|
access as well.</Typography>
|
||||||
<Typography variant="body1" component="div" display="block">
|
<Typography variant="body1" component="div" display="block">
|
||||||
@@ -93,7 +93,7 @@ function Tone3000HelpDialog(props: {
|
|||||||
sound their best, or of course, need to be played through an actual physical amp speaker cabinet. The TONE3000
|
sound their best, or of course, need to be played through an actual physical amp speaker cabinet. The TONE3000
|
||||||
website provides a broad selection of cabinet I/R files that can be used
|
website provides a broad selection of cabinet I/R files that can be used
|
||||||
with <i>TooB Cab IR</i> to provide high-quality cabinet simulation for your NAM models. <i>TooB Cab IR</i> allows
|
with <i>TooB Cab IR</i> to provide high-quality cabinet simulation for your NAM models. <i>TooB Cab IR</i> allows
|
||||||
you to browse and download I/R files from TONE3000 directly within the PiPedal app.
|
you to browse and download I/R files from TONE3000 directly within the OP-Pedal app.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body1" component="div" display="block">
|
<Typography variant="body1" component="div" display="block">
|
||||||
@@ -101,8 +101,8 @@ function Tone3000HelpDialog(props: {
|
|||||||
Use a browser on a device with internet access to download Neural Amp Modeler files by connecting to
|
Use a browser on a device with internet access to download Neural Amp Modeler files by connecting to
|
||||||
<Link href="https://www.tone3000.com" target="_blank">https://www.tone3000.com</Link> and
|
<Link href="https://www.tone3000.com" target="_blank">https://www.tone3000.com</Link> and
|
||||||
download Neural Amp Modeler file packs directly to your local device as .zip file bundles. You can then
|
download Neural Amp Modeler file packs directly to your local device as .zip file bundles. You can then
|
||||||
upload the .zip file bundle to the PiPedal Server using the <strong><em>Upload</em></strong> button in the
|
upload the .zip file bundle to the OP-Pedal Server using the <strong><em>Upload</em></strong> button in the
|
||||||
<strong><em>File Properties</em></strong> dialog. PiPedal will
|
<strong><em>File Properties</em></strong> dialog. OP-Pedal will
|
||||||
extract model files from the .zip archives automatically, so there is no need to extract them first.
|
extract model files from the .zip archives automatically, so there is no need to extract them first.
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
@@ -120,11 +120,11 @@ function Tone3000HelpDialog(props: {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" component="div" display="block" >
|
<Typography variant="body1" component="div" display="block" >
|
||||||
When you click on the TONE3000 link, you will be taken to an external website. The TONE3000
|
When you click on the TONE3000 link, you will be taken to an external website. The TONE3000
|
||||||
website is not part of PiPedal and is not affiliated in any way with PiPedal.
|
website is not part of OP-Pedal and is not affiliated in any way with OP-Pedal.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body2" component="div" display="block" style={{ marginTop: 32 }}>
|
<Typography variant="body2" component="div" display="block" style={{ marginTop: 32 }}>
|
||||||
Privacy statement: PiPedal has no access to personal data used by the TONE3000 website. Please refer to
|
Privacy statement: OP-Pedal has no access to personal data used by the TONE3000 website. Please refer to
|
||||||
the <Link href="https://www.tone3000.com/privacy" target="_blank">TONE3000 Privacy Policy</Link> for
|
the <Link href="https://www.tone3000.com/privacy" target="_blank">TONE3000 Privacy Policy</Link> for
|
||||||
information on how your data is used by TONE3000.
|
information on how your data is used by TONE3000.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default class UpdateDialog extends ResizeResponsiveComponent<UpdateDialog
|
|||||||
}
|
}
|
||||||
|
|
||||||
onViewReleaseNotes() {
|
onViewReleaseNotes() {
|
||||||
this.model.launchExternalUrl("https://rerdavies.github.io/pipedal/ReleaseNotes.html");
|
this.model.launchExternalUrl("https://ourpad.casa/op-pedal/ReleaseNotes.html");
|
||||||
}
|
}
|
||||||
|
|
||||||
upToDate(): boolean {
|
upToDate(): boolean {
|
||||||
@@ -181,13 +181,13 @@ export default class UpdateDialog extends ResizeResponsiveComponent<UpdateDialog
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
{(upToDate) && (
|
{(upToDate) && (
|
||||||
<Typography variant="body2" color="textSecondary" style={{ marginBottom: 12 }}>
|
<Typography variant="body2" color="textSecondary" style={{ marginBottom: 12 }}>
|
||||||
PiPedal is up to date.
|
OP-Pedal is up to date.
|
||||||
</Typography>
|
</Typography>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{(canUpgrade) && (
|
{(canUpgrade) && (
|
||||||
<Typography variant="body2" color="textSecondary" style={{ marginBottom: 12 }}>
|
<Typography variant="body2" color="textSecondary" style={{ marginBottom: 12 }}>
|
||||||
A new version of the PiPedal server is available. Would you like to update now?
|
A new version of the OP-Pedal server is available. Would you like to update now?
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -611,37 +611,37 @@ const WifiConfigDialog = withStyles(
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Typography className={classes.pgraph} variant="h6" color="textPrimary">
|
<Typography className={classes.pgraph} variant="h6" color="textPrimary">
|
||||||
PiPedal Auto-Hotspot
|
OP-Pedal Auto-Hotspot
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
||||||
The PiPedal <b><i>Auto-Hotspot</i></b> feature allows you to connect to your Raspberry Pi even if you don't have
|
The OP-Pedal <b><i>Auto-Hotspot</i></b> feature allows you to connect to your Raspberry Pi even if you don't have
|
||||||
access to a Wi-Fi router. For example, if you are performing at a live venue, you probably will not
|
access to a Wi-Fi router. For example, if you are performing at a live venue, you probably will not
|
||||||
have access to a Wi-Fi router; but you can configure PiPedal so that your Raspberry Pi automatically
|
have access to a Wi-Fi router; but you can configure OP-Pedal so that your Raspberry Pi automatically
|
||||||
starts a Wi-Fi hotspot when you are not at home. The feature is primarily intended for use with the
|
starts a Wi-Fi hotspot when you are not at home. The feature is primarily intended for use with the
|
||||||
PiPedal Android client, but you may find it useful for other purposes as well.
|
OP-Pedal Android client, but you may find it useful for other purposes as well.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
||||||
Raspberry Pi's are unable to run hotspots, and have another active Wi-Fi connection at the same time; so the auto-hotspot feature
|
Raspberry Pi's are unable to run hotspots, and have another active Wi-Fi connection at the same time; so the auto-hotspot feature
|
||||||
automatically turns the hotspot on, when your Raspberry Pi cannot otherwise be connected to, and can be configured to
|
automatically turns the hotspot on, when your Raspberry Pi cannot otherwise be connected to, and can be configured to
|
||||||
automatically turn the PiPedal hotspot off when you do want your Raspberry Pi to connect to another Wi-Fi access point.
|
automatically turn the OP-Pedal hotspot off when you do want your Raspberry Pi to connect to another Wi-Fi access point.
|
||||||
Which auto-start option you should select depends on how you normally connect to your Raspberry Pi when you are at home.
|
Which auto-start option you should select depends on how you normally connect to your Raspberry Pi when you are at home.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
||||||
If you normally connect to your Raspberry Pi using an ethernet connection, the <b><i>No ethernet connection</i></b> is a
|
If you normally connect to your Raspberry Pi using an ethernet connection, the <b><i>No ethernet connection</i></b> is a
|
||||||
good choice. The PiPedal Wi-Fi hotspot will be available whenever the ethernet cable is unplugged. <b><i>Always on</i></b> is
|
good choice. The OP-Pedal Wi-Fi hotspot will be available whenever the ethernet cable is unplugged. <b><i>Always on</i></b> is
|
||||||
also a good choice, but may confuse your phone or tablet, since your Android device will now have to decide whether to auto-connect to your home Wi-Fi
|
also a good choice, but may confuse your phone or tablet, since your Android device will now have to decide whether to auto-connect to your home Wi-Fi
|
||||||
router, or to the Raspberry Pi hotspot. If you use the <b><i>No ethernet connection</i></b> option, your phone or tablet will
|
router, or to the Raspberry Pi hotspot. If you use the <b><i>No ethernet connection</i></b> option, your phone or tablet will
|
||||||
never see the PiPedal hotspot and your Wi-Fi router at the same time.
|
never see the OP-Pedal hotspot and your Wi-Fi router at the same time.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
||||||
If you normally connect to your Raspberry Pi through a Wi-Fi router, <b><i>Not at home</i></b> is a good choice. The
|
If you normally connect to your Raspberry Pi through a Wi-Fi router, <b><i>Not at home</i></b> is a good choice. The
|
||||||
PiPedal hotspot will be automatically turned off whenever your home Wi-Fi router is in range, and automatically turned on
|
OP-Pedal hotspot will be automatically turned off whenever your home Wi-Fi router is in range, and automatically turned on
|
||||||
when you are out of range of your home Wi-Fi router.
|
when you are out of range of your home Wi-Fi router.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
||||||
If there are multiple locations, and multiple Wi-Fi routers you use with PiPedal on a regular basis, you can select
|
If there are multiple locations, and multiple Wi-Fi routers you use with OP-Pedal on a regular basis, you can select
|
||||||
the <b><i>No remembered Wi-Fi connections</i></b> option, but this is a riskier option. The PiPedal hotspot will be automatically turned on if there are no
|
the <b><i>No remembered Wi-Fi connections</i></b> option, but this is a riskier option. The OP-Pedal hotspot will be automatically turned on if there are no
|
||||||
Wi-Fi access points in range that you have previously connected to from your Raspberry Pi, and will be automatically turned on otherwise.
|
Wi-Fi access points in range that you have previously connected to from your Raspberry Pi, and will be automatically turned on otherwise.
|
||||||
The risk is that you could find yourself unable to connect to your Raspberry Pi when performing
|
The risk is that you could find yourself unable to connect to your Raspberry Pi when performing
|
||||||
at a local bar, after you have used your Rasberry Pi to connect to the Wi-Fi access point at the coffee shop nextdoor. (Public Wi-Fi access
|
at a local bar, after you have used your Rasberry Pi to connect to the Wi-Fi access point at the coffee shop nextdoor. (Public Wi-Fi access
|
||||||
@@ -652,7 +652,7 @@ const WifiConfigDialog = withStyles(
|
|||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
||||||
Typically, when you're away from home, there's no easy way to connect to your Raspberry Pi from a laptop in order to
|
Typically, when you're away from home, there's no easy way to connect to your Raspberry Pi from a laptop in order to
|
||||||
correct the problem. So you should carefully test that your auto-hotspot configuration works as expected before you adventure
|
correct the problem. So you should carefully test that your auto-hotspot configuration works as expected before you adventure
|
||||||
away from home with PiPedal.
|
away from home with OP-Pedal.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -676,14 +676,14 @@ const WifiConfigDialog = withStyles(
|
|||||||
style={{ userSelect: "none" }}>
|
style={{ userSelect: "none" }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
||||||
Enabling the Wi-Fi hotspot will disable regular Wi-Fi network access on your Raspberry Pi when the PiPedal hotspot is active.
|
Enabling the Wi-Fi hotspot will disable regular Wi-Fi network access on your Raspberry Pi when the OP-Pedal hotspot is active.
|
||||||
If you are relying on Wi-Fi access to connect to your Raspberry Pi, consider carefully whether your autostart options
|
If you are relying on Wi-Fi access to connect to your Raspberry Pi, consider carefully whether your autostart options
|
||||||
will allow you to connect to your Raspberry Pi once applied. PiPedal
|
will allow you to connect to your Raspberry Pi once applied. OP-Pedal
|
||||||
<a href="https://rerdavies.github.io/pipedal/Configuring.html" target="_blank" rel="noreferrer">
|
<a href="https://ourpad.casa/op-pedal/Configuring.html" target="_blank" rel="noreferrer">
|
||||||
online documentation</a> provides a discussion of how to choose safe hotspot auto-start options.
|
online documentation</a> provides a discussion of how to choose safe hotspot auto-start options.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary">
|
||||||
When you are connected to the PiPedal hotspot, you can connect to the PiPedal web server at http://10.48.0.1.
|
When you are connected to the OP-Pedal hotspot, you can connect to the OP-Pedal web server at http://10.48.0.1.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className={classes.pgraph} variant="body2" color="textPrimary" gutterBottom>
|
<Typography className={classes.pgraph} variant="body2" color="textPrimary" gutterBottom>
|
||||||
Are you sure you want to continue?
|
Are you sure you want to continue?
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default class WifiConfigSettings {
|
|||||||
wifiWarningGiven: boolean = false;
|
wifiWarningGiven: boolean = false;
|
||||||
hasPassword: boolean = false;
|
hasPassword: boolean = false;
|
||||||
valid: boolean = false;
|
valid: boolean = false;
|
||||||
hotspotName: string = "pipedal";
|
hotspotName: string = "op-pedal";
|
||||||
password: string = "";
|
password: string = "";
|
||||||
countryCode: string = "US";
|
countryCode: string = "US";
|
||||||
channel: string = "0";
|
channel: string = "0";
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default class WifiDirectConfigSettings {
|
|||||||
valid: boolean = true;
|
valid: boolean = true;
|
||||||
enable: boolean = true;
|
enable: boolean = true;
|
||||||
rebootRequired = false;
|
rebootRequired = false;
|
||||||
hotspotName: string = "pipedal";
|
hotspotName: string = "op-pedal";
|
||||||
pinChanged: boolean = false;
|
pinChanged: boolean = false;
|
||||||
pin: string = "";
|
pin: string = "";
|
||||||
countryCode: string = "US";
|
countryCode: string = "US";
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export function getValidWindowScales(): number[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setWindowScale(scale: number): void {
|
export function setWindowScale(scale: number): void {
|
||||||
localStorage.setItem("pipedalWindowScale", scale.toString());
|
localStorage.setItem("opPedalWindowScale", scale.toString());
|
||||||
}
|
}
|
||||||
export function getWindowScale(): number {
|
export function getWindowScale(): number {
|
||||||
const strvalue = localStorage.getItem("pipedalWindowScale");
|
const strvalue = localStorage.getItem("opPedalWindowScale");
|
||||||
return strvalue ? parseFloat(strvalue) : 1.0;
|
return strvalue ? parseFloat(strvalue) : 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// ChannelStrip — per-input channel control for the mixer console
|
||||||
|
//
|
||||||
|
// Volume fader, pan, mute/solo buttons, channel label, and type badge.
|
||||||
|
// Matches the op-pedal mixer engine's channel strip parameters.
|
||||||
|
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Slider from "@mui/material/Slider";
|
||||||
|
import ToggleButton from "@mui/material/ToggleButton";
|
||||||
|
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import VolumeUp from "@mui/icons-material/VolumeUp";
|
||||||
|
import VolumeOff from "@mui/icons-material/VolumeOff";
|
||||||
|
import MusicNote from "@mui/icons-material/MusicNote";
|
||||||
|
|
||||||
|
import type { MixerChannelState } from "./useMixerWS";
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ChannelStripProps {
|
||||||
|
channel: MixerChannelState;
|
||||||
|
/** Called when a parameter changes — the parent issues the WS command. */
|
||||||
|
onVolumeChange: (channelIndex: number, volumeDb: number) => void;
|
||||||
|
onPanChange: (channelIndex: number, pan: number) => void;
|
||||||
|
onMuteToggle: (channelIndex: number, mute: boolean) => void;
|
||||||
|
onSoloToggle: (channelIndex: number, solo: boolean) => void;
|
||||||
|
/** Called when learn mode is active and the user interacts with a control. */
|
||||||
|
onLearnTarget?: (targetType: string, targetId: number) => void;
|
||||||
|
/** True when MIDI learn mode is enabled on the backend. */
|
||||||
|
learnModeActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styled components ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const StripRoot = styled(Box)(({ theme }) => ({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
minWidth: 72,
|
||||||
|
width: 72,
|
||||||
|
padding: "8px 4px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: theme.palette.mode === "dark"
|
||||||
|
? "rgba(255,255,255,0.05)"
|
||||||
|
: "rgba(0,0,0,0.04)",
|
||||||
|
border: `1px solid ${
|
||||||
|
theme.palette.mode === "dark"
|
||||||
|
? "rgba(255,255,255,0.1)"
|
||||||
|
: "rgba(0,0,0,0.08)"
|
||||||
|
}`,
|
||||||
|
userSelect: "none",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LabelBox = styled(Box)({
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FaderBox = styled(Box)({
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 120,
|
||||||
|
width: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ValueLabel = styled(Typography)({
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function dbToPercent(db: number): number {
|
||||||
|
// Map -inf..+12 dB to 0..100%
|
||||||
|
if (db <= -60) return 0;
|
||||||
|
if (db >= 12) return 100;
|
||||||
|
return ((db + 60) / 72) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentToDb(pct: number): number {
|
||||||
|
return (pct / 100) * 72 - 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDb(db: number): string {
|
||||||
|
if (db <= -60) return "-∞";
|
||||||
|
return `${db.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ChannelStrip: React.FC<ChannelStripProps> = ({
|
||||||
|
channel,
|
||||||
|
onVolumeChange,
|
||||||
|
onPanChange,
|
||||||
|
onMuteToggle,
|
||||||
|
onSoloToggle,
|
||||||
|
onLearnTarget,
|
||||||
|
learnModeActive,
|
||||||
|
}) => {
|
||||||
|
const volPct = dbToPercent(channel.volume);
|
||||||
|
|
||||||
|
const handleVol = useCallback(
|
||||||
|
(_: Event, value: number | number[]) => {
|
||||||
|
const db = percentToDb(value as number);
|
||||||
|
onVolumeChange(channel.channelIndex, db);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelVolume", channel.channelIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[channel.channelIndex, onVolumeChange, learnModeActive, onLearnTarget]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePan = useCallback(
|
||||||
|
(_: Event, value: number | number[]) => {
|
||||||
|
onPanChange(channel.channelIndex, value as number);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelPan", channel.channelIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[channel.channelIndex, onPanChange, learnModeActive, onLearnTarget]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
onMuteToggle(channel.channelIndex, !channel.mute);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelMute", channel.channelIndex);
|
||||||
|
}
|
||||||
|
}, [channel.channelIndex, channel.mute, onMuteToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
|
const handleSolo = useCallback(() => {
|
||||||
|
onSoloToggle(channel.channelIndex, !channel.solo);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
onLearnTarget("channelSolo", channel.channelIndex);
|
||||||
|
}
|
||||||
|
}, [channel.channelIndex, channel.solo, onSoloToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StripRoot>
|
||||||
|
{/* Channel label */}
|
||||||
|
<LabelBox>
|
||||||
|
<Typography variant="caption" noWrap sx={{ fontWeight: 600 }}>
|
||||||
|
{channel.label || `Ch ${channel.channelIndex + 1}`}
|
||||||
|
</Typography>
|
||||||
|
</LabelBox>
|
||||||
|
|
||||||
|
{/* Type badge */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
opacity: 0.5,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{channel.type}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Volume fader (vertical) */}
|
||||||
|
<FaderBox>
|
||||||
|
<ValueLabel variant="caption">{formatDb(channel.volume)}</ValueLabel>
|
||||||
|
<Slider
|
||||||
|
value={volPct}
|
||||||
|
onChange={handleVol}
|
||||||
|
orientation="vertical"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
sx={{ height: 100, flex: "0 0 auto" }}
|
||||||
|
/>
|
||||||
|
</FaderBox>
|
||||||
|
|
||||||
|
{/* Pan slider */}
|
||||||
|
<Box sx={{ width: "100%", px: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontSize: "0.55rem", opacity: 0.5 }}>
|
||||||
|
Pan
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
value={channel.pan}
|
||||||
|
onChange={handlePan}
|
||||||
|
min={-1}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
size="small"
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={(v: number) => v === 0 ? "C" : v < 0 ? `L${Math.round(-v * 100)}` : `R${Math.round(v * 100)}`}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mute / Solo buttons */}
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
value={[]}
|
||||||
|
sx={{ gap: 0.5, "& .MuiToggleButton-root": { px: 1, py: 0, minWidth: 32, height: 28, fontSize: "0.7rem" } }}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
value="mute"
|
||||||
|
selected={channel.mute}
|
||||||
|
onChange={handleMute}
|
||||||
|
color={channel.mute ? "error" : "standard"}
|
||||||
|
>
|
||||||
|
{channel.mute ? <VolumeOff fontSize="inherit" /> : <VolumeUp fontSize="inherit" />}
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton
|
||||||
|
value="solo"
|
||||||
|
selected={channel.solo}
|
||||||
|
onChange={handleSolo}
|
||||||
|
color={channel.solo ? "warning" : "standard"}
|
||||||
|
>
|
||||||
|
<MusicNote fontSize="inherit" />
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</StripRoot>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelStrip;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// MasterBus — master output bus strip for the mixer console
|
||||||
|
//
|
||||||
|
// Volume fader, mute, bus name. Styled to visually differentiate
|
||||||
|
// from channel strips (wider, accent colour).
|
||||||
|
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Slider from "@mui/material/Slider";
|
||||||
|
import ToggleButton from "@mui/material/ToggleButton";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import VolumeUp from "@mui/icons-material/VolumeUp";
|
||||||
|
import VolumeOff from "@mui/icons-material/VolumeOff";
|
||||||
|
|
||||||
|
import type { MixerBusState } from "./useMixerWS";
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MasterBusProps {
|
||||||
|
bus: MixerBusState;
|
||||||
|
onVolumeChange: (busId: number, volumeDb: number) => void;
|
||||||
|
onMuteToggle: (busId: number, mute: boolean) => void;
|
||||||
|
/** Called when learn mode is active and the user interacts with a control. */
|
||||||
|
onLearnTarget?: (targetType: string, targetId: number) => void;
|
||||||
|
/** True when MIDI learn mode is enabled on the backend. */
|
||||||
|
learnModeActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styled ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BusRoot = styled(Box)(({ theme }) => ({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
minWidth: 80,
|
||||||
|
width: 80,
|
||||||
|
padding: "8px 4px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background:
|
||||||
|
theme.palette.mode === "dark"
|
||||||
|
? "rgba(167,112,228,0.12)"
|
||||||
|
: "rgba(103,80,164,0.08)",
|
||||||
|
border: `1px solid ${
|
||||||
|
theme.palette.mode === "dark"
|
||||||
|
? "rgba(167,112,228,0.3)"
|
||||||
|
: "rgba(103,80,164,0.25)"
|
||||||
|
}`,
|
||||||
|
userSelect: "none",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const FaderBox = styled(Box)({
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 120,
|
||||||
|
width: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ValueLabel = styled(Typography)({
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
lineHeight: 1,
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function dbToPercent(db: number): number {
|
||||||
|
if (db <= -60) return 0;
|
||||||
|
if (db >= 12) return 100;
|
||||||
|
return ((db + 60) / 72) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDb(db: number): string {
|
||||||
|
if (db <= -60) return "-∞";
|
||||||
|
return `${db.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MasterBus: React.FC<MasterBusProps> = ({
|
||||||
|
bus,
|
||||||
|
onVolumeChange,
|
||||||
|
onMuteToggle,
|
||||||
|
onLearnTarget,
|
||||||
|
learnModeActive,
|
||||||
|
}) => {
|
||||||
|
const volPct = dbToPercent(bus.volume);
|
||||||
|
|
||||||
|
const handleVol = useCallback(
|
||||||
|
(_: Event, value: number | number[]) => {
|
||||||
|
const db = ((value as number) / 100) * 72 - 60;
|
||||||
|
onVolumeChange(bus.id, db);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
const targetType = bus.type === "Master" ? "masterVolume" : "busVolume";
|
||||||
|
onLearnTarget(targetType, bus.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bus.id, bus.type, onVolumeChange, learnModeActive, onLearnTarget]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
onMuteToggle(bus.id, !bus.mute);
|
||||||
|
if (learnModeActive && onLearnTarget) {
|
||||||
|
const targetType = bus.type === "Master" ? "masterMute" : "busMute";
|
||||||
|
onLearnTarget(targetType, bus.id);
|
||||||
|
}
|
||||||
|
}, [bus.id, bus.mute, bus.type, onMuteToggle, learnModeActive, onLearnTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BusRoot>
|
||||||
|
{/* Bus name */}
|
||||||
|
<Typography variant="caption" noWrap sx={{ fontWeight: 700 }}>
|
||||||
|
{bus.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
opacity: 0.5,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bus.type}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Volume fader */}
|
||||||
|
<FaderBox>
|
||||||
|
<ValueLabel variant="caption">{formatDb(bus.volume)}</ValueLabel>
|
||||||
|
<Slider
|
||||||
|
value={volPct}
|
||||||
|
onChange={handleVol}
|
||||||
|
orientation="vertical"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.5}
|
||||||
|
sx={{ height: 100, flex: "0 0 auto" }}
|
||||||
|
/>
|
||||||
|
</FaderBox>
|
||||||
|
|
||||||
|
{/* Mute button */}
|
||||||
|
<ToggleButton
|
||||||
|
value="mute"
|
||||||
|
selected={bus.mute}
|
||||||
|
onChange={handleMute}
|
||||||
|
size="small"
|
||||||
|
color={bus.mute ? "error" : "standard"}
|
||||||
|
sx={{ px: 1, py: 0, minWidth: 48, height: 28, fontSize: "0.7rem" }}
|
||||||
|
>
|
||||||
|
{bus.mute ? (
|
||||||
|
<VolumeOff fontSize="inherit" />
|
||||||
|
) : (
|
||||||
|
<VolumeUp fontSize="inherit" />
|
||||||
|
)}
|
||||||
|
</ToggleButton>
|
||||||
|
</BusRoot>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterBus;
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
// MidiMappingPanel — MIDI control surface mapping configuration
|
||||||
|
//
|
||||||
|
// Manages MIDI learn mode, displays current CC→mixer parameter mappings,
|
||||||
|
// and allows manual mapping creation/deletion.
|
||||||
|
// Communicates with the pipedald backend via MIDI WebSocket commands
|
||||||
|
// (mixerGetMidiMappings, mixerSetMidiLearnMode, mixerCommitMidiLearn, etc.)
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Select from "@mui/material/Select";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import FormControl from "@mui/material/FormControl";
|
||||||
|
import InputLabel from "@mui/material/InputLabel";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Switch from "@mui/material/Switch";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Delete from "@mui/icons-material/Delete";
|
||||||
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import MusicNote from "@mui/icons-material/MusicNote";
|
||||||
|
import Devices from "@mui/icons-material/Devices";
|
||||||
|
import type { MixerWsHandle } from "./useMixerWS";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MidiMappingEntry {
|
||||||
|
midiChannel: number;
|
||||||
|
ccNumber: number;
|
||||||
|
targetType: string;
|
||||||
|
targetId: number;
|
||||||
|
minValue: number;
|
||||||
|
maxValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MidiMappingPanelProps {
|
||||||
|
ws: MixerWsHandle;
|
||||||
|
/** Fired when learn mode state changes (so parent can prop drill). */
|
||||||
|
onLearnModeChange?: (enabled: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Target type labels ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TARGET_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "channelVolume", label: "Ch Volume" },
|
||||||
|
{ value: "channelPan", label: "Ch Pan" },
|
||||||
|
{ value: "channelMute", label: "Ch Mute" },
|
||||||
|
{ value: "channelSolo", label: "Ch Solo" },
|
||||||
|
{ value: "busVolume", label: "Bus Volume" },
|
||||||
|
{ value: "busMute", label: "Bus Mute" },
|
||||||
|
{ value: "masterVolume", label: "Master Volume" },
|
||||||
|
{ value: "masterMute", label: "Master Mute" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function targetTypeLabel(type: string): string {
|
||||||
|
return TARGET_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Learn feedback poll interval ───────────────────────────────────
|
||||||
|
|
||||||
|
const LEARN_POLL_MS = 400;
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function MidiMappingPanel({
|
||||||
|
ws,
|
||||||
|
onLearnModeChange,
|
||||||
|
disabled,
|
||||||
|
}: MidiMappingPanelProps) {
|
||||||
|
// ── Dialog open state ───────────────────────────────────────────
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// ── Mapping list state ──────────────────────────────────────────
|
||||||
|
const [mappings, setMappings] = useState<MidiMappingEntry[]>([]);
|
||||||
|
const [mappingError, setMappingError] = useState<string | null>(null);
|
||||||
|
const [loadingMappings, setLoadingMappings] = useState(false);
|
||||||
|
|
||||||
|
// ── Learn mode state ────────────────────────────────────────────
|
||||||
|
const [learnEnabled, setLearnEnabled] = useState(false);
|
||||||
|
const [learnPending, setLearnPending] = useState(false);
|
||||||
|
const [capturedChannel, setCapturedChannel] = useState<number | null>(null);
|
||||||
|
const [capturedCc, setCapturedCc] = useState<number | null>(null);
|
||||||
|
const learnPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// ── Manual add state ────────────────────────────────────────────
|
||||||
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
|
const [manualTargetType, setManualTargetType] = useState("channelVolume");
|
||||||
|
const [manualTargetId, setManualTargetId] = useState(0);
|
||||||
|
const [manualCcNumber, setManualCcNumber] = useState(7);
|
||||||
|
const [manualMidiChannel, setManualMidiChannel] = useState(-1);
|
||||||
|
const [manualMinValue, setManualMinValue] = useState(-96);
|
||||||
|
const [manualMaxValue, setManualMaxValue] = useState(12);
|
||||||
|
|
||||||
|
// ── Fetch mappings ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchMappings = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
setLoadingMappings(true);
|
||||||
|
try {
|
||||||
|
const raw = (await ws.send("mixerGetMidiMappings")) as string;
|
||||||
|
const parsed = JSON.parse(raw) as MidiMappingEntry[];
|
||||||
|
setMappings(Array.isArray(parsed) ? parsed : []);
|
||||||
|
setMappingError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoadingMappings(false);
|
||||||
|
}
|
||||||
|
}, [ws]);
|
||||||
|
|
||||||
|
// ── Learn mode ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleLearn = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
const newState = !learnEnabled;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerSetMidiLearnMode", { enabled: newState });
|
||||||
|
setLearnEnabled(newState);
|
||||||
|
// Reset captured event when toggling
|
||||||
|
if (!newState) {
|
||||||
|
setCapturedChannel(null);
|
||||||
|
setCapturedCc(null);
|
||||||
|
setLearnPending(false);
|
||||||
|
} else {
|
||||||
|
setLearnPending(true);
|
||||||
|
}
|
||||||
|
onLearnModeChange?.(newState);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
}, [ws, learnEnabled, onLearnModeChange]);
|
||||||
|
|
||||||
|
// ── Poll for captured CC event during learn mode ────────────────
|
||||||
|
|
||||||
|
const pollLearnedEvent = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected" || !learnEnabled) return;
|
||||||
|
try {
|
||||||
|
const raw = (await ws.send("mixerGetLastLearnedMidiEvent")) as string;
|
||||||
|
// Response format: {"hasEvent":true,"midiChannel":0,"ccNumber":7} or similar
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
hasEvent: boolean;
|
||||||
|
midiChannel?: number;
|
||||||
|
ccNumber?: number;
|
||||||
|
};
|
||||||
|
if (parsed.hasEvent && parsed.midiChannel !== undefined && parsed.ccNumber !== undefined) {
|
||||||
|
setCapturedChannel(parsed.midiChannel);
|
||||||
|
setCapturedCc(parsed.ccNumber);
|
||||||
|
setLearnPending(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Poll errors are expected during reconnect — ignore silently
|
||||||
|
}
|
||||||
|
}, [ws, learnEnabled]);
|
||||||
|
|
||||||
|
// Start/stop learn poll interval
|
||||||
|
useEffect(() => {
|
||||||
|
if (learnEnabled) {
|
||||||
|
learnPollRef.current = setInterval(pollLearnedEvent, LEARN_POLL_MS);
|
||||||
|
} else {
|
||||||
|
if (learnPollRef.current !== null) {
|
||||||
|
clearInterval(learnPollRef.current);
|
||||||
|
learnPollRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (learnPollRef.current !== null) {
|
||||||
|
clearInterval(learnPollRef.current);
|
||||||
|
learnPollRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [learnEnabled, pollLearnedEvent]);
|
||||||
|
|
||||||
|
// ── Commit learn mapping ────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleCommit = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
try {
|
||||||
|
const success = (await ws.send("mixerCommitMidiLearn")) as boolean;
|
||||||
|
if (success) {
|
||||||
|
setCapturedChannel(null);
|
||||||
|
setCapturedCc(null);
|
||||||
|
setLearnPending(true);
|
||||||
|
await fetchMappings();
|
||||||
|
} else {
|
||||||
|
setMappingError("Failed to commit mapping — no CC event captured");
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
}, [ws, fetchMappings]);
|
||||||
|
|
||||||
|
// ── Delete mapping ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (ch: number, cc: number) => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerRemoveMidiMapping", { midiChannel: ch, ccNumber: cc });
|
||||||
|
await fetchMappings();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ws, fetchMappings],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Clear all mappings ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleClearAll = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerClearMidiMappings");
|
||||||
|
await fetchMappings();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
}, [ws, fetchMappings]);
|
||||||
|
|
||||||
|
// ── Manual add mapping ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleManualAdd = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerAddMidiMapping", {
|
||||||
|
midiChannel: manualMidiChannel,
|
||||||
|
ccNumber: manualCcNumber,
|
||||||
|
targetType: manualTargetType,
|
||||||
|
targetId: manualTargetId,
|
||||||
|
minValue: manualMinValue,
|
||||||
|
maxValue: manualMaxValue,
|
||||||
|
});
|
||||||
|
setManualOpen(false);
|
||||||
|
// Reset defaults
|
||||||
|
setManualTargetType("channelVolume");
|
||||||
|
setManualTargetId(0);
|
||||||
|
setManualCcNumber(7);
|
||||||
|
setManualMidiChannel(-1);
|
||||||
|
setManualMinValue(-96);
|
||||||
|
setManualMaxValue(12);
|
||||||
|
await fetchMappings();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setMappingError(msg);
|
||||||
|
}
|
||||||
|
}, [ws, manualTargetType, manualTargetId, manualCcNumber, manualMidiChannel, manualMinValue, manualMaxValue, fetchMappings]);
|
||||||
|
|
||||||
|
// ── Fetch mappings when dialog opens ────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && ws.status === "connected") {
|
||||||
|
fetchMappings();
|
||||||
|
}
|
||||||
|
}, [open, ws.status, fetchMappings]);
|
||||||
|
|
||||||
|
// ── Reset learn state on disconnect ─────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.status !== "connected") {
|
||||||
|
setLearnEnabled(false);
|
||||||
|
setCapturedChannel(null);
|
||||||
|
setCapturedCc(null);
|
||||||
|
setLearnPending(false);
|
||||||
|
}
|
||||||
|
}, [ws.status]);
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canLearn = ws.status === "connected" && !disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── MIDI button in toolbar ── */}
|
||||||
|
<Tooltip title="MIDI control surface mapping">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={ws.status !== "connected" || disabled}
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
color: learnEnabled ? "#f44336" : undefined,
|
||||||
|
animation: learnEnabled ? "pulse 1.2s ease-in-out infinite" : undefined,
|
||||||
|
"@keyframes pulse": {
|
||||||
|
"0%, 100%": { opacity: 1 },
|
||||||
|
"50%": { opacity: 0.4 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Devices sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* ── Main dialog ── */}
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{ sx: { maxHeight: "80vh" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<Devices sx={{ fontSize: 20 }} />
|
||||||
|
<span>MIDI Control Surface Mapping</span>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
{/* ── Learn mode section ── */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: learnEnabled ? "rgba(244,67,54,0.08)" : "rgba(255,255,255,0.03)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: learnEnabled ? "rgba(244,67,54,0.3)" : "rgba(255,255,255,0.08)",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 1 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<MusicNote sx={{ fontSize: 18, color: learnEnabled ? "#f44336" : undefined }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
MIDI Learn
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={learnEnabled}
|
||||||
|
onChange={toggleLearn}
|
||||||
|
disabled={!canLearn}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||||
|
{learnEnabled ? "ON" : "OFF"}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
labelPlacement="start"
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Learn workflow status */}
|
||||||
|
{learnEnabled && (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
|
{/* Step 1: touch a control */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: "#f44336",
|
||||||
|
display: "inline-block",
|
||||||
|
animation: "pulse 1.2s ease-in-out infinite",
|
||||||
|
"@keyframes pulse": {
|
||||||
|
"0%, 100%": { opacity: 1 },
|
||||||
|
"50%": { opacity: 0.3 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
1. Touch a control in the mixer (fader, mute, solo)
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Step 2: move hardware fader */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
opacity: capturedCc !== null ? 0.4 : 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: capturedCc !== null ? "#4caf50" : "#f44336",
|
||||||
|
display: "inline-block",
|
||||||
|
animation: capturedCc !== null ? "none" : "pulse 1.2s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
2. Move a hardware fader/knob
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Step 3: commit */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
opacity: capturedCc !== null ? 1 : 0.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: capturedCc !== null ? "#f44336" : "#555",
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
3. Click "Commit" to save the mapping
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Captured event display */}
|
||||||
|
{capturedCc !== null && capturedChannel !== null && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: "rgba(76,175,80,0.12)",
|
||||||
|
border: "1px solid rgba(76,175,80,0.3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: "#81c784" }}>
|
||||||
|
Captured: CC#{capturedCc} from Channel {capturedChannel + 1}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={handleCommit}
|
||||||
|
sx={{ minWidth: 80, fontSize: 11 }}
|
||||||
|
>
|
||||||
|
Commit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capturedCc === null && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontStyle: "italic", opacity: 0.5, mt: 0.5 }}
|
||||||
|
>
|
||||||
|
Waiting for MIDI CC event...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Error banner ── */}
|
||||||
|
{mappingError && (
|
||||||
|
<Alert
|
||||||
|
severity="warning"
|
||||||
|
sx={{ py: 0, px: 1, fontSize: 11 }}
|
||||||
|
onClose={() => setMappingError(null)}
|
||||||
|
>
|
||||||
|
{mappingError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Manual add mapping ── */}
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Add sx={{ fontSize: 14 }} />}
|
||||||
|
onClick={() => setManualOpen(true)}
|
||||||
|
disabled={!canLearn}
|
||||||
|
sx={{ fontSize: 11, textTransform: "none" }}
|
||||||
|
>
|
||||||
|
Add Mapping Manually
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Current mappings list ── */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Current Mappings ({mappings.length})
|
||||||
|
</Typography>
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={!canLearn}
|
||||||
|
sx={{ fontSize: 10, textTransform: "none", minWidth: 0, p: 0.5 }}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loadingMappings && (
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.5 }}>
|
||||||
|
Loading...
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loadingMappings && mappings.length === 0 && (
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.4, fontStyle: "italic" }}>
|
||||||
|
No MIDI mappings configured. Use MIDI Learn or add mappings manually.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5, mt: 0.5 }}>
|
||||||
|
{mappings.map((entry, idx) => (
|
||||||
|
<Box
|
||||||
|
key={`${entry.midiChannel}-${entry.ccNumber}-${idx}`}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 1,
|
||||||
|
p: 0.75,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: "rgba(255,255,255,0.03)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
"&:hover": { bgcolor: "rgba(255,255,255,0.06)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
||||||
|
{/* CC badge */}
|
||||||
|
<Chip
|
||||||
|
label={`CC#${entry.ccNumber}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
{/* MIDI channel badge */}
|
||||||
|
<Chip
|
||||||
|
label={entry.midiChannel < 0 ? "Omni" : `Ch ${entry.midiChannel + 1}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
{/* Arrow */}
|
||||||
|
<Typography variant="caption" sx={{ opacity: 0.4 }}>→</Typography>
|
||||||
|
{/* Target */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{targetTypeLabel(entry.targetType)} #{entry.targetId}
|
||||||
|
</Typography>
|
||||||
|
{/* Range info */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ opacity: 0.4, fontSize: 10, display: { xs: "none", sm: "inline" } }}
|
||||||
|
>
|
||||||
|
({entry.minValue} to {entry.maxValue})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDelete(entry.midiChannel, entry.ccNumber)}
|
||||||
|
disabled={!canLearn}
|
||||||
|
sx={{ p: 0.3 }}
|
||||||
|
>
|
||||||
|
<Delete sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Manual add dialog ── */}
|
||||||
|
<Dialog
|
||||||
|
open={manualOpen}
|
||||||
|
onClose={() => setManualOpen(false)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Add MIDI Mapping</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, mt: 0.5 }}>
|
||||||
|
{/* Target type */}
|
||||||
|
<FormControl size="small" fullWidth>
|
||||||
|
<InputLabel>Target Parameter</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={manualTargetType}
|
||||||
|
label="Target Parameter"
|
||||||
|
onChange={(e) => setManualTargetType(e.target.value)}
|
||||||
|
>
|
||||||
|
{TARGET_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Target ID */}
|
||||||
|
<TextField
|
||||||
|
label="Target ID (channel index or bus ID)"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualTargetId}
|
||||||
|
onChange={(e) => setManualTargetId(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MIDI Channel */}
|
||||||
|
<TextField
|
||||||
|
label="MIDI Channel (-1 = Omni)"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualMidiChannel}
|
||||||
|
onChange={(e) => setManualMidiChannel(parseInt(e.target.value) || -1)}
|
||||||
|
inputProps={{ min: -1, max: 15 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* CC Number */}
|
||||||
|
<TextField
|
||||||
|
label="CC Number (0-127)"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualCcNumber}
|
||||||
|
onChange={(e) => setManualCcNumber(Math.min(127, Math.max(0, parseInt(e.target.value) || 0)))}
|
||||||
|
inputProps={{ min: 0, max: 127 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Min/Max range */}
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Min Value"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualMinValue}
|
||||||
|
onChange={(e) => setManualMinValue(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Max Value"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={manualMaxValue}
|
||||||
|
onChange={(e) => setManualMaxValue(parseFloat(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setManualOpen(false)} color="secondary">Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleManualAdd}
|
||||||
|
variant="contained"
|
||||||
|
disabled={ws.status !== "connected" || disabled}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// MixerPage — main mixer console view
|
||||||
|
//
|
||||||
|
// Connects to pipedald via useMixerWS, fetches full mixer state,
|
||||||
|
// renders channel strips and master bus, and sends parameter
|
||||||
|
// changes back to the engine.
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import ArrowBack from "@mui/icons-material/ArrowBack";
|
||||||
|
import Refresh from "@mui/icons-material/Refresh";
|
||||||
|
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
|
||||||
|
import { useMixerWS, type MixerState, type MixerChannelState, type MixerBusState, type MixerOutputRouteState } from "./useMixerWS";
|
||||||
|
import ChannelStrip from "./ChannelStrip";
|
||||||
|
import MasterBus from "./MasterBus";
|
||||||
|
import MixerScenePanel from "./MixerScenePanel";
|
||||||
|
import OutputRoutingPanel from "./OutputRoutingPanel";
|
||||||
|
import MidiMappingPanel from "./MidiMappingPanel";
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MixerPageProps {
|
||||||
|
/** Called when the user closes the mixer view. */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styled ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Toolbar = styled(Box)({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "4px 8px",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ScrollContainer = styled(Box)({
|
||||||
|
flex: 1,
|
||||||
|
overflowX: "auto",
|
||||||
|
overflowY: "hidden",
|
||||||
|
padding: "8px 12px",
|
||||||
|
});
|
||||||
|
|
||||||
|
const StripRow = styled(Box)({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 8,
|
||||||
|
height: "100%",
|
||||||
|
minWidth: "min-content",
|
||||||
|
});
|
||||||
|
|
||||||
|
const StickyDivider = styled(Box)(({ theme }) => ({
|
||||||
|
width: 2,
|
||||||
|
alignSelf: "stretch",
|
||||||
|
background:
|
||||||
|
theme.palette.mode === "dark"
|
||||||
|
? "rgba(255,255,255,0.15)"
|
||||||
|
: "rgba(0,0,0,0.1)",
|
||||||
|
margin: "0 8px",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Hook: state polling ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function useMixerState(ws: ReturnType<typeof useMixerWS>) {
|
||||||
|
const [state, setState] = useState<MixerState | null>(null);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchState = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await ws.send("mixerGetState");
|
||||||
|
setState(result as MixerState);
|
||||||
|
setFetchError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setFetchError(msg);
|
||||||
|
}
|
||||||
|
}, [ws]);
|
||||||
|
|
||||||
|
// Fetch state when WS connects, and every 2 seconds while connected.
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.status !== "connected") {
|
||||||
|
setState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchState();
|
||||||
|
const interval = setInterval(fetchState, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [ws.status, fetchState]);
|
||||||
|
|
||||||
|
return { state, fetchError, fetchState };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MixerPage ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
|
||||||
|
const ws = useMixerWS();
|
||||||
|
const { state, fetchError, fetchState } = useMixerState(ws);
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [showOutputRouting, setShowOutputRouting] = useState(false);
|
||||||
|
const [learnModeActive, setLearnModeActive] = useState(false);
|
||||||
|
|
||||||
|
// MIDI learn target: when learn mode is active and a control is touched,
|
||||||
|
// set the pending target on the backend
|
||||||
|
const handleLearnTarget = useCallback(
|
||||||
|
(targetType: string, targetId: number) => {
|
||||||
|
if (learnModeActive) {
|
||||||
|
ws.send("mixerSetMidiLearnTarget", { targetType, targetId }).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ws, learnModeActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Command helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleChannelVolume = useCallback(
|
||||||
|
(channelIndex: number, volume: number) => {
|
||||||
|
ws.send("mixerSetChannelVolume", { channelIndex, volume }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChannelPan = useCallback(
|
||||||
|
(channelIndex: number, pan: number) => {
|
||||||
|
ws.send("mixerSetChannelPan", { channelIndex, pan }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChannelMute = useCallback(
|
||||||
|
(channelIndex: number, mute: boolean) => {
|
||||||
|
ws.send("mixerSetChannelMute", { channelIndex, mute }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChannelSolo = useCallback(
|
||||||
|
(channelIndex: number, solo: boolean) => {
|
||||||
|
ws.send("mixerSetChannelSolo", { channelIndex, solo }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBusVolume = useCallback(
|
||||||
|
(busId: number, volume: number) => {
|
||||||
|
ws.send("mixerSetBusVolume", { busId, volume }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBusMute = useCallback(
|
||||||
|
(busId: number, mute: boolean) => {
|
||||||
|
ws.send("mixerSetBusMute", { busId, mute }).catch(() => {});
|
||||||
|
},
|
||||||
|
[ws]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const channels: MixerChannelState[] = state?.channels ?? [];
|
||||||
|
const masterBus: MixerBusState | undefined = state?.buses.find(
|
||||||
|
(b) => b.type === "Master"
|
||||||
|
);
|
||||||
|
const otherBuses = state?.buses.filter((b) => b.type !== "Master") ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: (t) => t.palette.background.default,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton onClick={onClose} size="small">
|
||||||
|
<ArrowBack />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||||
|
Mixer Console
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={fetchState}
|
||||||
|
disabled={ws.status !== "connected"}
|
||||||
|
>
|
||||||
|
<Refresh />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowOutputRouting(true)}
|
||||||
|
disabled={ws.status !== "connected"}
|
||||||
|
title="Configure output routing"
|
||||||
|
>
|
||||||
|
<SettingsEthernetIcon />
|
||||||
|
</IconButton>
|
||||||
|
<MidiMappingPanel
|
||||||
|
ws={ws}
|
||||||
|
disabled={ws.status !== "connected"}
|
||||||
|
onLearnModeChange={setLearnModeActive}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
opacity: 0.6,
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
mr: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ws.status === "connected"
|
||||||
|
? "● Connected"
|
||||||
|
: ws.status === "connecting"
|
||||||
|
? "◌ Connecting..."
|
||||||
|
: "○ Disconnected"}
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{/* Scene panel */}
|
||||||
|
<Box sx={{ px: 2, py: 0.5, flex: "0 0 auto" }}>
|
||||||
|
<MixerScenePanel ws={ws} disabled={ws.status !== "connected"} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{fetchError && (
|
||||||
|
<Alert severity="warning" sx={{ mx: 1, my: 0.5, py: 0 }}>
|
||||||
|
{fetchError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ws.lastError && (
|
||||||
|
<Alert severity="info" sx={{ mx: 1, my: 0.5, py: 0 }}>
|
||||||
|
{ws.lastError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Strip area */}
|
||||||
|
<ScrollContainer>
|
||||||
|
{ws.status === "connecting" && !state && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
<Typography variant="body2">Connecting to mixer...</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ws.status === "connected" && state && (
|
||||||
|
<StripRow>
|
||||||
|
{/* Channel strips */}
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<ChannelStrip
|
||||||
|
key={ch.channelIndex}
|
||||||
|
channel={ch}
|
||||||
|
onVolumeChange={handleChannelVolume}
|
||||||
|
onPanChange={handleChannelPan}
|
||||||
|
onMuteToggle={handleChannelMute}
|
||||||
|
onSoloToggle={handleChannelSolo}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
{channels.length > 0 && <StickyDivider />}
|
||||||
|
|
||||||
|
{/* Other buses (subgroups, aux, etc.) */}
|
||||||
|
{otherBuses.map((bus) => (
|
||||||
|
<MasterBus
|
||||||
|
key={bus.id}
|
||||||
|
bus={bus}
|
||||||
|
onVolumeChange={handleBusVolume}
|
||||||
|
onMuteToggle={handleBusMute}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Master bus */}
|
||||||
|
{masterBus && (
|
||||||
|
<>
|
||||||
|
<StickyDivider />
|
||||||
|
<MasterBus
|
||||||
|
key={masterBus.id}
|
||||||
|
bus={masterBus}
|
||||||
|
onVolumeChange={handleBusVolume}
|
||||||
|
onMuteToggle={handleBusMute}
|
||||||
|
onLearnTarget={handleLearnTarget}
|
||||||
|
learnModeActive={learnModeActive}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{channels.length === 0 && otherBuses.length === 0 && !masterBus && (
|
||||||
|
<Typography variant="body2" sx={{ opacity: 0.5, p: 4 }}>
|
||||||
|
No mixer state received yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</StripRow>
|
||||||
|
)}
|
||||||
|
</ScrollContainer>
|
||||||
|
|
||||||
|
{/* Output routing panel overlay */}
|
||||||
|
{showOutputRouting && (
|
||||||
|
<OutputRoutingPanel
|
||||||
|
ws={ws}
|
||||||
|
buses={state?.buses ?? []}
|
||||||
|
routes={state?.outputRoutes ?? []}
|
||||||
|
physicalOutputCount={32}
|
||||||
|
onRoutesChanged={fetchState}
|
||||||
|
onClose={() => setShowOutputRouting(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MixerPage;
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
// MixerScenePanel — Scene management for the digital mixer.
|
||||||
|
//
|
||||||
|
// Shows 8 numbered scene slots (like a hardware digital mixer).
|
||||||
|
// Uses the mixer WebSocket to save/load/list scenes via the backend
|
||||||
|
// mixerSaveScene / mixerLoadScene / mixerGetScenes commands.
|
||||||
|
//
|
||||||
|
// Embedded in the MixerPage toolbar area.
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import Save from "@mui/icons-material/Save";
|
||||||
|
import Refresh from "@mui/icons-material/Refresh";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import type { MixerWsHandle } from "./useMixerWS";
|
||||||
|
|
||||||
|
export const MAX_SCENES = 8;
|
||||||
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
export interface MixerSceneInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerScenePanelProps {
|
||||||
|
ws: MixerWsHandle;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MixerScenePanel({ ws, disabled }: MixerScenePanelProps) {
|
||||||
|
const [scenes, setScenes] = useState<MixerSceneInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Save dialog
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
|
const [saveName, setSaveName] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Load confirm dialog
|
||||||
|
const [loadConfirm, setLoadConfirm] = useState<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// ── Fetch scene list ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchScenes = useCallback(async () => {
|
||||||
|
if (ws.status !== "connected" || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const raw = (await ws.send("mixerGetScenes")) as string;
|
||||||
|
const parsed = JSON.parse(raw) as { scenes: { id: number; name: string }[] };
|
||||||
|
const list: MixerSceneInfo[] = (parsed.scenes ?? []).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || `Scene ${s.id}`,
|
||||||
|
}));
|
||||||
|
list.sort((a, b) => a.id - b.id);
|
||||||
|
setScenes(list);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [ws, loading]);
|
||||||
|
|
||||||
|
// Initial fetch + polling
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.status !== "connected") return;
|
||||||
|
fetchScenes();
|
||||||
|
const interval = setInterval(fetchScenes, POLL_INTERVAL_MS);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [ws.status, fetchScenes]);
|
||||||
|
|
||||||
|
// Fetch when reconnected
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.status === "connected") {
|
||||||
|
fetchScenes();
|
||||||
|
}
|
||||||
|
}, [ws.status]);
|
||||||
|
|
||||||
|
// ── Slot helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const getSlotScene = useCallback(
|
||||||
|
(slot: number): MixerSceneInfo | null => scenes[slot] ?? null,
|
||||||
|
[scenes],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Save ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleSaveClick = useCallback(() => {
|
||||||
|
setSaveName(`Scene ${scenes.length + 1}`);
|
||||||
|
setSaveOpen(true);
|
||||||
|
}, [scenes.length]);
|
||||||
|
|
||||||
|
const handleSaveConfirm = useCallback(async () => {
|
||||||
|
const name = saveName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await ws.send("mixerSaveScene", { name });
|
||||||
|
setSaveOpen(false);
|
||||||
|
setSaveName("");
|
||||||
|
await fetchScenes();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [saveName, ws, fetchScenes]);
|
||||||
|
|
||||||
|
// ── Load ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleSlotClick = useCallback(
|
||||||
|
(slot: number) => {
|
||||||
|
const scene = getSlotScene(slot);
|
||||||
|
if (!scene) {
|
||||||
|
// Empty slot → save
|
||||||
|
setSaveName(`Scene ${slot + 1}`);
|
||||||
|
setSaveOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadConfirm({ id: scene.id, name: scene.name });
|
||||||
|
},
|
||||||
|
[getSlotScene],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoadConfirm = useCallback(async () => {
|
||||||
|
if (!loadConfirm) return;
|
||||||
|
try {
|
||||||
|
await ws.send("mixerLoadScene", { sceneId: loadConfirm.id });
|
||||||
|
setLoadConfirm(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
setLoadConfirm(null);
|
||||||
|
}
|
||||||
|
}, [loadConfirm, ws]);
|
||||||
|
|
||||||
|
// ── Render slot ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const renderSlot = useCallback(
|
||||||
|
(slot: number) => {
|
||||||
|
const scene = getSlotScene(slot);
|
||||||
|
const isUsed = scene !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={slot}
|
||||||
|
title={
|
||||||
|
isUsed
|
||||||
|
? `Restore "${scene!.name}"`
|
||||||
|
: `Save current state to slot ${slot + 1}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={() => handleSlotClick(slot)}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 72,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
cursor: disabled ? "default" : "pointer",
|
||||||
|
bgcolor: isUsed ? "rgba(167, 112, 228, 0.18)" : "rgba(255,255,255,0.04)",
|
||||||
|
border: isUsed
|
||||||
|
? "1px solid rgba(167, 112, 228, 0.4)"
|
||||||
|
: "1px dashed rgba(255,255,255,0.12)",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
position: "relative",
|
||||||
|
userSelect: "none",
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
"&:hover": disabled
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
bgcolor: isUsed
|
||||||
|
? "rgba(167, 112, 228, 0.32)"
|
||||||
|
: "rgba(255,255,255,0.09)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Slot number */}
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 1,
|
||||||
|
left: 5,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
opacity: 0.35,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slot + 1}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isUsed ? (
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
textAlign: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 62,
|
||||||
|
color: "#d0b0f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scene!.name}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
sx={{ fontSize: 10, opacity: 0.3, textAlign: "center" }}
|
||||||
|
>
|
||||||
|
Empty
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[getSlotScene, handleSlotClick, disabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Main render ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||||
|
{/* Header row */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
px: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: 0.6,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scenes
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, alignItems: "center" }}>
|
||||||
|
<Tooltip title="Refresh scene list">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={fetchScenes}
|
||||||
|
disabled={ws.status !== "connected" || disabled}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
>
|
||||||
|
<Refresh sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Save current mixer state as a new scene">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Save sx={{ fontSize: 14 }} />}
|
||||||
|
onClick={handleSaveClick}
|
||||||
|
disabled={ws.status !== "connected" || disabled}
|
||||||
|
sx={{
|
||||||
|
minHeight: 24,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: 10,
|
||||||
|
textTransform: "none",
|
||||||
|
py: 0,
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="warning" sx={{ py: 0, px: 1, fontSize: 11 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scene slot grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: MAX_SCENES }, (_, i) => renderSlot(i))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Save dialog ─────────────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
open={saveOpen}
|
||||||
|
onClose={() => setSaveOpen(false)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Save Mixer Scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Scene Name"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => setSaveName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSaveConfirm();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setSaveOpen(false)} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveConfirm}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!saveName.trim() || saving}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Load confirm dialog ─────────────────────────────────── */}
|
||||||
|
<Dialog
|
||||||
|
open={loadConfirm !== null}
|
||||||
|
onClose={() => setLoadConfirm(null)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Load Scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Restore scene{" "}
|
||||||
|
<strong>{loadConfirm?.name}</strong>?
|
||||||
|
<br />
|
||||||
|
This will overwrite current mixer settings.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setLoadConfirm(null)} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleLoadConfirm} variant="contained" color="primary">
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// useMixerWS — WebSocket hook for op-pedal mixer engine protocol
|
||||||
|
//
|
||||||
|
// Connects to pipedald's WebSocket endpoint and provides the mixer
|
||||||
|
// command set (mixerGetState, mixerSetChannelVolume, etc.) matching
|
||||||
|
// the PiPedalSocket message format used in PiPedalSocket.cpp handlers.
|
||||||
|
//
|
||||||
|
// Protocol:
|
||||||
|
// Send: [{"message": "mixerGetState", "replyTo": N}]
|
||||||
|
// Reply: [{"reply": N, "message": "mixerState"}, {…body…}]
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MixerChannelState {
|
||||||
|
channelIndex: number;
|
||||||
|
volume: number; // dB
|
||||||
|
pan: number; // -1..1
|
||||||
|
mute: boolean;
|
||||||
|
solo: boolean;
|
||||||
|
type: string; // "Instrument" | "Mic" | "Line"
|
||||||
|
label: string;
|
||||||
|
hpEnabled: boolean;
|
||||||
|
hpFrequency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixerBusState {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string; // "Master" | "Subgroup" | "Aux" | "FxReturn"
|
||||||
|
volume: number; // dB
|
||||||
|
mute: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixerRouteState {
|
||||||
|
sourceId: number;
|
||||||
|
targetBusId: number;
|
||||||
|
level: number; // dB
|
||||||
|
sourceType: string; // "channel" | "bus"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixerState {
|
||||||
|
channels: MixerChannelState[];
|
||||||
|
buses: MixerBusState[];
|
||||||
|
routes: MixerRouteState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WsStatus =
|
||||||
|
| "disconnected"
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface MixerWsHandle {
|
||||||
|
/** Current connection status. */
|
||||||
|
status: WsStatus;
|
||||||
|
/** Last error message (cleared on reconnect). */
|
||||||
|
lastError: string | null;
|
||||||
|
/** Send a mixer command and await the reply. */
|
||||||
|
send: (command: string, params?: unknown) => Promise<unknown>;
|
||||||
|
/** Manually connect (auto-called on mount; safe to call again after disconnect). */
|
||||||
|
connect: () => void;
|
||||||
|
/** Disconnect manually. */
|
||||||
|
disconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Defaults ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_WS_URL = "ws://192.168.0.245:8080/ws";
|
||||||
|
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||||
|
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
||||||
|
|
||||||
|
// ── Hook ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useMixerWS(wsUrl: string = DEFAULT_WS_URL): MixerWsHandle {
|
||||||
|
const [status, setStatus] = useState<WsStatus>("disconnected");
|
||||||
|
const [lastError, setLastError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const replyMapRef = useRef<Map<number, (value: unknown) => void>>(new Map());
|
||||||
|
const nextReplyToRef = useRef(0);
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectAttemptRef = useRef(0);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const wsUrlRef = useRef(wsUrl);
|
||||||
|
|
||||||
|
// Keep the URL ref current without triggering re-renders.
|
||||||
|
useEffect(() => {
|
||||||
|
wsUrlRef.current = wsUrl;
|
||||||
|
}, [wsUrl]);
|
||||||
|
|
||||||
|
/** Clean up WS and timers. */
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
// Clear reconnect timer.
|
||||||
|
if (reconnectTimerRef.current !== null) {
|
||||||
|
clearTimeout(reconnectTimerRef.current);
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close socket cleanly.
|
||||||
|
const sock = wsRef.current;
|
||||||
|
if (sock) {
|
||||||
|
sock.onopen = null;
|
||||||
|
sock.onclose = null;
|
||||||
|
sock.onerror = null;
|
||||||
|
sock.onmessage = null;
|
||||||
|
if (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING) {
|
||||||
|
sock.close();
|
||||||
|
}
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject all pending promises.
|
||||||
|
const pending = replyMapRef.current;
|
||||||
|
if (pending.size > 0) {
|
||||||
|
const err = new Error("WebSocket disconnected");
|
||||||
|
for (const reject of pending.values()) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Open a new WebSocket connection. */
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||||
|
return; // already connected or connecting
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
setStatus("connecting");
|
||||||
|
setLastError(null);
|
||||||
|
|
||||||
|
const url = wsUrlRef.current;
|
||||||
|
let sock: WebSocket;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sock = new WebSocket(url);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setStatus("error");
|
||||||
|
setLastError(`Failed to create WebSocket: ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.onopen = () => {
|
||||||
|
if (!mountedRef.current) { sock.close(); return; }
|
||||||
|
wsRef.current = sock;
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setStatus("connected");
|
||||||
|
setLastError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onclose = () => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (wsRef.current === sock) {
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
setStatus("disconnected");
|
||||||
|
// Schedule reconnect with exponential backoff.
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onerror = () => {
|
||||||
|
// The onclose event will fire after onerror, so we don't set
|
||||||
|
// status here to avoid a flash of "error" followed by "disconnected".
|
||||||
|
setLastError("WebSocket connection error");
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onmessage = (event: MessageEvent) => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON messages.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) return;
|
||||||
|
if (parsed.length < 1) return;
|
||||||
|
|
||||||
|
const header = parsed[0] as Record<string, unknown>;
|
||||||
|
const body = parsed.length >= 2 ? parsed[1] : undefined;
|
||||||
|
|
||||||
|
// If this is a reply to one of our requests, resolve the promise.
|
||||||
|
if (typeof header.reply === "number") {
|
||||||
|
const resolve = replyMapRef.current.get(header.reply);
|
||||||
|
if (resolve) {
|
||||||
|
replyMapRef.current.delete(header.reply);
|
||||||
|
if (header.message === "error") {
|
||||||
|
resolve(Promise.reject(new Error(String(body ?? "Unknown error"))));
|
||||||
|
} else {
|
||||||
|
resolve(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unprompted messages (state updates, etc.) are ignored at this level.
|
||||||
|
// Subscribers can extend the hook later if they need push events.
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current = sock;
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
/** Schedule a reconnection attempt with exponential backoff. */
|
||||||
|
const scheduleReconnect = useCallback(() => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (reconnectTimerRef.current !== null) return; // already scheduled
|
||||||
|
|
||||||
|
const attempt = reconnectAttemptRef.current;
|
||||||
|
const delay = Math.min(
|
||||||
|
INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
|
||||||
|
MAX_RECONNECT_DELAY_MS
|
||||||
|
);
|
||||||
|
reconnectAttemptRef.current = attempt + 1;
|
||||||
|
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
/** Send a command and wait for the response. */
|
||||||
|
const send = useCallback((command: string, params?: unknown): Promise<unknown> => {
|
||||||
|
return new Promise<unknown>((resolve, reject) => {
|
||||||
|
const sock = wsRef.current;
|
||||||
|
if (!sock || sock.readyState !== WebSocket.OPEN) {
|
||||||
|
reject(new Error("WebSocket not connected"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyTo = ++nextReplyToRef.current;
|
||||||
|
|
||||||
|
// Build message in PiPedalSocket format: [header, body?]
|
||||||
|
const header: Record<string, unknown> = { message: command, replyTo };
|
||||||
|
const msg = params !== undefined ? [header, params] : [header];
|
||||||
|
|
||||||
|
// Register the reply handler before sending (race-safe).
|
||||||
|
replyMapRef.current.set(replyTo, (value: unknown) => {
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a timeout for the reply.
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
replyMapRef.current.delete(replyTo);
|
||||||
|
reject(new Error(`Command "${command}" timed out after 10s`));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// Wrap the resolve to clear the timeout.
|
||||||
|
const originalResolve = replyMapRef.current.get(replyTo)!;
|
||||||
|
replyMapRef.current.set(replyTo, (value: unknown) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
originalResolve(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
sock.send(JSON.stringify(msg));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
replyMapRef.current.delete(replyTo);
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
cleanup();
|
||||||
|
setStatus("disconnected");
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
|
// ── Lifecycle ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
// Connect on mount.
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [connect, cleanup]);
|
||||||
|
|
||||||
|
return { status, lastError, send, connect, disconnect };
|
||||||
|
}
|
||||||