diff --git a/.gitmodules b/.gitmodules index b1fae44..58dd958 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,3 +6,6 @@ url = https://github.com/rerdavies/websocketpp.git branch = develop update = merge +[submodule "modules/SQLiteCpp"] + path = modules/SQLiteCpp + url = https://github.com/SRombauts/SQLiteCpp.git diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json index a0f3d56..a709077 100644 --- a/.vscode/c_cpp_properties.json +++ b/.vscode/c_cpp_properties.json @@ -7,6 +7,7 @@ "${workspaceFolder}", "${workspaceFolder}/build/src/**", "${workspaceFolder}/src/**", + "${workspaceFolder}/modules/websocketpp/**", "${workspaceFolder}/**", "/usr/include/lilv-0", "/usr/include/x86_64-linux-gnu", @@ -16,7 +17,9 @@ "cStandard": "c17", "cppStandard": "c++20", "intelliSenseMode": "linux-gcc-arm64", - "compileCommands": "${workspaceFolder}/build/compile_commands.json", + "compileCommands": [ + "${workspaceFolder}/build/compile_commands.json" + ], "configurationProvider": "ms-vscode.cmake-tools" }, { @@ -33,7 +36,9 @@ "cStandard": "c17", "cppStandard": "c++20", "intelliSenseMode": "linux-gcc-x64", - "compileCommands": "${workspaceFolder}/build/compile_commands.json", + "compileCommands": [ + "${workspaceFolder}/build/compile_commands.json" + ], "configurationProvider": "ms-vscode.cmake-tools", "mergeConfigurations": true } diff --git a/.vscode/launch.json b/.vscode/launch.json index 4b516f5..9dce54a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,7 +22,7 @@ // Resolved by CMake Tools: "program": "${command:cmake.launchTargetPath}", "args": [ - "--no-sudo" + "[ProfileThumbnails]" ], "stopAtEntry": false, "cwd": "${workspaceFolder}", @@ -142,7 +142,7 @@ //"[pipedal_alsa_test]" // "[wifi_channels_test]" // "[locale]" - "[modFileTypes]" + "[pipewire_input_stream]" ], "stopAtEntry": false, @@ -177,7 +177,7 @@ "args": [ //"[Dev]" -- all dev-machine tests. //"[promise]" // subtest of your choice, or none to run all of the tests. - "[atom_converter]" + "[json_variants]" ], "stopAtEntry": false, diff --git a/.vscode/settings.json b/.vscode/settings.json index 8e97e94..7f85420 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -102,7 +102,8 @@ "format": "cpp", "locale": "cpp", "stdfloat": "cpp", - "text_encoding": "cpp" + "text_encoding": "cpp", + "forward_list": "cpp" }, "cSpell.words": [ "Alsa", @@ -134,5 +135,25 @@ "cSpell.ignoreWords": [ "nammodel" ], - "cSpell.enabled": false + "cSpell.enabled": false, + + // Disable all automatic completion suggestions - only show when Ctrl+Space is pressed + "editor.quickSuggestions": { + "other": false, + "comments": false, + "strings": false + }, + + // Disable suggestions on trigger characters (like . or :) + "editor.suggestOnTriggerCharacters": false, + + // Disable word-based suggestions from open files + "editor.wordBasedSuggestions": "off", + + // Don't accept suggestions on Enter (optional) + "editor.acceptSuggestionOnEnter": "off", + + // Disable parameter hints popup (optional) + "editor.parameterHints.enabled": false, + } \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index ff425b6..bf21e66 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,22 +1,23 @@ cmake_minimum_required(VERSION 3.16.0) project(pipedal - VERSION 1.4.76 + VERSION 1.4.77 DESCRIPTION "PiPedal Guitar Effect Pedal For Raspberry Pi" HOMEPAGE_URL "https://rerdavies.github.io/pipedal" ) EXECUTE_PROCESS( COMMAND dpkg --print-architecture COMMAND tr -d '\n' OUTPUT_VARIABLE DEBIAN_ARCHITECTURE ) -set (DISPLAY_VERSION "PiPedal v1.4.76-Release") +set (DISPLAY_VERSION "PiPedal v1.4.77-Beta") set (PACKAGE_ARCHITECTURE ${DEBIAN_ARCHITECTURE}) set (CMAKE_INSTALL_PREFIX "/usr/") +set (PIPEDAL_EXCLUDE_TESTS false) include(CTest) enable_testing() #add_subdirectory("submodules/pipedal_p2pd") - +add_subdirectory("modules/SQLiteCpp") add_subdirectory("PiPedalCommon") add_subdirectory("vite") diff --git a/PiPedalCommon/src/AlsaSequencer.cpp b/PiPedalCommon/src/AlsaSequencer.cpp new file mode 100644 index 0000000..e4c11d1 --- /dev/null +++ b/PiPedalCommon/src/AlsaSequencer.cpp @@ -0,0 +1,1035 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 "AlsaSequencer.hpp" +#include +#include +#include +#include "ss.hpp" +#include +#include +#include +#include +#include +#include "Finally.hpp" +#include "Lv2Log.hpp" +#include +#include + +// enumerate alsa sequencer ports + +namespace pipedal +{ + + namespace impl + { + + enum class ConnectAction + { + Subscribe, + Unsubscribe + }; + + class AlsaSequencerImpl : public AlsaSequencer + { + public: + AlsaSequencerImpl(); + + public: + ~AlsaSequencerImpl(); + + virtual void ConnectPort(int clientId, int portId) override; + virtual void ConnectPort(const std::string &name) override; + virtual void SetConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) override; + + // Read a single MIDI message from the sequencer input port. A timeout of -1 blocks indefinitely. + // A timeout of 0 returns immediately. + virtual bool ReadMessage(AlsaMidiMessage &message, int timeoutMs = -1) override; + + // Get current real-time from the queue (useful for calculating precise timing) + virtual bool GetQueueRealtime(uint64_t *sec, uint32_t *nsec) override; + + virtual void RemoveAllConnections() override; + + private: + void ModifyConnection(int clientId, int portId, ConnectAction action); + + // Get the current queue ID (returns -1 if no queue is active) + int GetQueueId() const { return queueId; } + + bool WaitForMessage(int timeoutMs); + // Create an ALSA input queue with real-time timestamps for the given client/port + int CreateRealtimeInputQueue(); + + struct Connection + { + int clientId; + int portId; + }; + + int myClientId = -1; + + std::mutex connectionsMutex; + std::vector connections; + std::vector pollFds; // For polling input events + snd_seq_t *seqHandle = nullptr; + int inPort = -1; + int queueId = -1; // Queue for real-time timestamps + }; + + class AlsaSequencerDeviceMonitorImpl : public AlsaSequencerDeviceMonitor + { + + public: + virtual ~AlsaSequencerDeviceMonitorImpl() override; + + virtual void StartMonitoring( + Callback &&onChangeCallback) override; + virtual void StopMonitoring() override; + + private: + int CreateInputQueue(snd_seq_t *seqHandle, int inPort); + + bool started = false; + void ServiceProc(); + std::unique_ptr serviceThread; + std::atomic terminateThread{false}; + Callback callback; + }; + + }; + using namespace impl; + + AlsaSequencer::ptr AlsaSequencer::Create() + { + return std::make_shared(); + } + + std::vector AlsaSequencer::EnumeratePorts() + { + std::vector ports; + + snd_seq_t *seq; + if (snd_seq_open(&seq, "default", SND_SEQ_OPEN_DUPLEX, 0) < 0) + { + return ports; + } + + snd_seq_client_info_t *client_info; + snd_seq_port_info_t *port_info; + + snd_seq_client_info_alloca(&client_info); + snd_seq_port_info_alloca(&port_info); + + snd_seq_client_info_set_client(client_info, -1); + + while (snd_seq_query_next_client(seq, client_info) >= 0) + { + int client = snd_seq_client_info_get_client(client_info); + + snd_seq_port_info_set_client(port_info, client); + snd_seq_port_info_set_port(port_info, -1); + + while (snd_seq_query_next_port(seq, port_info) >= 0) + { + unsigned int capability = snd_seq_port_info_get_capability(port_info); + + // Skip system ports and ports without MIDI capability + if (client == SND_SEQ_CLIENT_SYSTEM || + !(capability & (SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_WRITE))) + { + continue; + } + if ((capability & (SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ)) != (SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ)) + { + continue; // Skip ports that are not readable AND subscribable + } + + AlsaSequencerPort port; + port.client = client; + port.isKernelDevice = snd_seq_client_info_get_type(client_info) == SND_SEQ_KERNEL_CLIENT; + port.port = snd_seq_port_info_get_port(port_info); + port.name = snd_seq_port_info_get_name(port_info); + port.clientName = snd_seq_client_info_get_name(client_info); + port.canRead = capability & SND_SEQ_PORT_CAP_READ; + port.canWrite = capability & SND_SEQ_PORT_CAP_WRITE; + port.canReadSubscribe = capability & SND_SEQ_PORT_CAP_SUBS_READ; + port.canWriteSubscribe = capability & SND_SEQ_PORT_CAP_SUBS_WRITE; + auto typeBits = snd_seq_port_info_get_type(port_info); +#ifdef SND_SEQ_PORT_TYPE_MIDI_UMP + port.isUmp = (typeBits & SND_SEQ_PORT_TYPE_MIDI_UMP) != 0; +#else + port.isUmp = false; // UMP support is not available in all versions of ALSA +#endif + port.isSystemAnnounce = (typeBits & SND_SEQ_PORT_SYSTEM_ANNOUNCE) != 0; + port.isMidiSynth = (typeBits & SND_SEQ_PORT_TYPE_MIDI_GENERIC) != 0 || + (typeBits & SND_SEQ_PORT_TYPE_MIDI_GM) != 0 || + (typeBits & SND_SEQ_PORT_TYPE_MIDI_GS) != 0 || + (typeBits & SND_SEQ_PORT_TYPE_MIDI_XG) != 0; + port.isApplication = (typeBits & SND_SEQ_PORT_TYPE_APPLICATION) != 0; + port.isSpecific = (typeBits & SND_SEQ_PORT_TYPE_SPECIFIC) != 0; + port.isSynth = (typeBits & SND_SEQ_PORT_TYPE_SYNTH) != 0; + port.isHardware = (typeBits & SND_SEQ_PORT_TYPE_HARDWARE) != 0; + port.isPort = (typeBits & SND_SEQ_PORT_TYPE_PORT) != 0; + port.isSoftware = (typeBits & SND_SEQ_PORT_TYPE_SOFTWARE) != 0; + port.isVirtual = port.name.starts_with("VirMIDI"); + port.cardNumber = snd_seq_client_info_get_card(client_info); + port.port = snd_seq_port_info_get_port(port_info); + + if (port.isKernelDevice && port.isPort && port.cardNumber >= 0 && port.port >= 0) + { + // For kernel devices, we can construct a raw MIDI device string + std::string rawMidiDevice; + if (port.isVirtual) + { + // "VirMidI 2-1" + std::regex virtualDeviceRegex{"^(VirMIDI) (\\d+)-(\\d+)$"}; + { + // Extract the card and device numbers from the match + std::smatch match; + std::regex_search(port.name, match, virtualDeviceRegex); + if (match.size() == 4) + { + try + { + std::string devName = match[1]; + int card = std::stoi(match[2]); + int device = std::stoi(match[3]); + rawMidiDevice = SS("hw:CARD=" << devName << ",DEV=" << device); + } + catch (const std::exception &ignored) + { + } + } + } + } + else + { + rawMidiDevice = SS("hw:CARD=" << port.clientName << ",DEV=" << 0); + if (port.port > 0) + { + rawMidiDevice += SS("," << port.port); + } + } + port.rawMidiDevice = std::move(rawMidiDevice); + } + port.id = SS("seq:" << port.clientName << "/" << port.name); + + port.displaySortOrder = port.client * 256 + port.port; + if (port.isVirtual) + { + port.displaySortOrder += 1 * 256 * 256; // MIDI virtual ports after real ports. + } + else if (port.clientName == "Midi Through") + { + port.displaySortOrder += 2 * 256 * 256; // MIDI Through at the very end because it's weird. + } + ports.push_back(std::move(port)); + } + } + + snd_seq_close(seq); + return ports; + } + + AlsaSequencerImpl::AlsaSequencerImpl() + { + seqHandle = nullptr; + queueId = -1; + // Open sequencer in input mode + int rc; + + rc = snd_seq_open(&seqHandle, "default", SND_SEQ_OPEN_DUPLEX, 0); + if (rc < 0) + { + // convert rc to message + throw std::runtime_error(SS("Failed to open ALSA sequencer:" << snd_strerror(rc))); + } + size_t inputBufferSize = snd_seq_get_input_buffer_size(seqHandle); + (void)inputBufferSize; + + rc = snd_seq_set_input_buffer_size(seqHandle, 128 * 1024); + if (rc < 0) + { + Lv2Log::warning("Failed resize the ALSA sequencer input buffer: %s", snd_strerror(rc)); + } + snd_seq_set_client_name(seqHandle, "PiPedal"); + + inPort = snd_seq_create_simple_port(seqHandle, "PiPedal:in", + SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE, + SND_SEQ_PORT_TYPE_MIDI_GENERIC | + SND_SEQ_PORT_TYPE_MIDI_GM | SND_SEQ_PORT_TYPE_APPLICATION); + if (inPort < 0) + { + // convert rc to message + throw std::runtime_error(SS("Failed to open ALSA sequencer:" << snd_strerror(inPort))); + } + CreateRealtimeInputQueue(); + + snd_seq_nonblock(seqHandle, 1); // Set sequencer to non-blocking mode + + // Get our client and port numbers for reference + myClientId = snd_seq_client_id(seqHandle); + if (myClientId < 0) + { + throw std::runtime_error(SS("Failed to get client ID: " << snd_strerror(myClientId))); + } + } + void AlsaSequencerImpl::RemoveAllConnections() + { + while (connections.size() != 0) + { + auto connection = connections.back(); + ModifyConnection(connection.clientId, connection.portId, ConnectAction::Unsubscribe); + } + } + AlsaSequencerImpl::~AlsaSequencerImpl() + { + RemoveAllConnections(); + + if (queueId >= 0) + { + snd_seq_free_queue(seqHandle, queueId); + queueId = -1; + } + if (inPort >= 0) + { + snd_seq_delete_port(seqHandle, inPort); + inPort = -1; + } + if (seqHandle) + { + snd_seq_close(seqHandle); + seqHandle = nullptr; + } + } + + void AlsaSequencerImpl::ConnectPort(int clientId, int portId) + { + ModifyConnection(clientId, portId, ConnectAction::Subscribe); + } + + void AlsaSequencerImpl::ConnectPort(const std::string &id) + { + auto ports = EnumeratePorts(); + for (const auto &port : ports) + { + if (port.id == id) + { + ConnectPort(port.client, port.port); + return; + } + } + throw std::runtime_error("ALSA port not found"); + } + void AlsaSequencerImpl::SetConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) + { + this->RemoveAllConnections(); // Currently no configuration options to set + + auto ports = EnumeratePorts(); + for (const auto &connection : alsaSequencerConfiguration.connections()) + { + // Connect to each port specified in the configuration + std::string id = connection.id(); + for (const auto &port : ports) + { + if (port.id == id) + { + ConnectPort(port.client, port.port); + break; + } + } + } + } + + bool AlsaSequencerImpl::WaitForMessage(int timeoutMs) + { + while (true) + { + auto fdCount = snd_seq_poll_descriptors_count(seqHandle, POLLIN); + if (fdCount == 0) + return true; + this->pollFds.resize(fdCount); + + snd_seq_poll_descriptors(seqHandle, pollFds.data(), fdCount, POLLIN); + + int rc = poll(pollFds.data(), fdCount, timeoutMs); + if (rc == 0) + { + return false; // timed out. + } + else if (rc < 0) + { + if (errno == EINTR) + { + continue; + } + else + { + throw std::runtime_error(SS("ALSA sequencer poll error: " << strerror(errno))); + } + } + else + { + return true; + } + } + } + bool AlsaSequencerImpl::ReadMessage(AlsaMidiMessage &message, int timeoutMs) + { + // Event loop + snd_seq_event_t *event = nullptr; + while (true) + { + bool success = false; + int rc = snd_seq_event_input(seqHandle, &event); + if (rc < 0) + { + if (rc == -EAGAIN) + { + if (!WaitForMessage(timeoutMs)) + { + return false; + } + } + else + { + // Handle other errors + throw std::runtime_error(SS("ALSA sequencer input error: " << snd_strerror(rc))); + } + } + else if (event) + { + success = true; + // Extract timestamp information + message.timestamp = event->time.tick; + message.realtime_sec = event->time.time.tv_sec; + message.realtime_nsec = event->time.time.tv_nsec; + + // Process MIDI event here, e.g. NOTEON, NOTEOFF, etc. + switch (event->type) + { + case SND_SEQ_EVENT_NOTEON: + message.Set( + (uint8_t)(0x90 | event->data.note.channel), + (uint8_t)(event->data.note.note), // note + (uint8_t)(event->data.note.velocity)); // velocity + break; + case SND_SEQ_EVENT_NOTEOFF: + // handle note-off + message.Set( + uint8_t(0x80 | event->data.note.channel), + uint8_t(event->data.note.note), + uint8_t(event->data.note.off_velocity)); // off velocity + break; + case SND_SEQ_EVENT_KEYPRESS: + message.Set( + (uint8_t)(0xA0 | event->data.note.channel), // polyphonic key pressure + (uint8_t)(event->data.note.note), // note + (uint8_t)(event->data.note.velocity)); // pressure + break; + case SND_SEQ_EVENT_CONTROLLER: + message.Set( + (uint8_t)(0xB0 | event->data.control.channel), // control change + (uint8_t)(event->data.control.param), // controller number + (uint8_t)(event->data.control.value)); // controller value + break; + case SND_SEQ_EVENT_PGMCHANGE: + message.Set( + (uint8_t)(0xC0 | event->data.control.channel), // program change + (uint8_t)(event->data.control.value)); + break; + + case SND_SEQ_EVENT_CHANPRESS: + message.Set( + uint8_t(0xD0 | event->data.control.channel), + uint8_t(event->data.control.value)); + break; + case SND_SEQ_EVENT_PITCHBEND: + message.Set(uint8_t(0xE0 | event->data.control.channel), + uint8_t((event->data.control.value >> 7) & 0x7F), + uint8_t(event->data.control.value & 0x7F)); + break; + case SND_SEQ_EVENT_CONTROL14: + message.size = 6; + message.data = message.fixedBuffer; + message.fixedBuffer[0] = uint8_t(0xB0 | event->data.control.channel); // Control Change 14-bit + message.fixedBuffer[1] = uint8_t(event->data.control.param); // MSB + message.fixedBuffer[2] = uint8_t(event->data.control.param >> 7) & 0x7F; // MSB + message.fixedBuffer[3] = uint8_t(0xB0 | event->data.control.channel); + message.fixedBuffer[4] = uint8_t(event->data.control.value + 0x20); + message.fixedBuffer[5] = uint8_t(event->data.control.value) & 0x7F; // MSB value + break; + + case SND_SEQ_EVENT_NONREGPARAM: + message.size = 12; + message.data = message.fixedBuffer; + message.fixedBuffer[0] = uint8_t(0xB0 | event->data.control.channel); // Non-registered parameter + message.fixedBuffer[1] = 0x63; // MSB + message.fixedBuffer[2] = uint8_t(event->data.control.param >> 7) & 0x7F; // MSB + message.fixedBuffer[3] = uint8_t(0xB0 | event->data.control.channel); + message.fixedBuffer[4] = 0x62; // LSB + message.fixedBuffer[5] = uint8_t(event->data.control.param) & 0x7F; // LSB + message.fixedBuffer[6] = uint8_t(0xB0 | event->data.control.channel); + message.fixedBuffer[7] = uint8_t(0x06); // Non-registered parameter value MSB + message.fixedBuffer[8] = uint8_t(event->data.control.value >> 7) & 0x7F; // MSB value + message.fixedBuffer[9] = uint8_t(0xB0 | event->data.control.channel); + message.fixedBuffer[10] = uint8_t(0x26); // Non-registered parameter value LSB + message.fixedBuffer[11] = uint8_t(event->data.control.value) & 0x7F; // LSb + break; + case SND_SEQ_EVENT_REGPARAM: + message.size = 12; + message.data = message.fixedBuffer; + message.fixedBuffer[0] = uint8_t(0xB0 | event->data.control.channel); // Registered parameter + message.fixedBuffer[1] = uint8_t(0x65); // MSB + message.fixedBuffer[2] = uint8_t(event->data.control.param >> 7) & 0x7F; // MSB + message.fixedBuffer[3] = uint8_t(0xB0 | event->data.control.channel); + message.fixedBuffer[4] = uint8_t(0x64); // LSB + message.fixedBuffer[5] = uint8_t(event->data.control.param) & 0x7F; // LSB + message.fixedBuffer[6] = uint8_t(0xB0 | event->data.control.channel); + message.fixedBuffer[7] = uint8_t(0x6); // Registered parameter value MSB + message.fixedBuffer[8] = uint8_t(event->data.control.value >> 7) & 0x7F; // MSB value + message.fixedBuffer[9] = uint8_t(0xB0 | event->data.control.channel); + message.fixedBuffer[10] = uint8_t(0x26); // Registered parameter value LSB + message.fixedBuffer[11] = uint8_t(event->data.control.value) & 0x7F; // LSB value + break; + case SND_SEQ_EVENT_SONGPOS: + message.Set( + 0xF2, + (uint8_t)((event->data.control.value >> 7) & 0x7F), // MSB + (uint8_t)(event->data.control.value & 0x7F) // LSB + ); + break; + case SND_SEQ_EVENT_SONGSEL: + message.Set( + 0xF3, + (uint8_t)((event->data.control.value >> 7) & 0x7F), // MSB + (uint8_t)(event->data.control.value & 0x7F) // LSB + ); + break; + case SND_SEQ_EVENT_QFRAME: + message.Set( + 0xF1, + (uint8_t)((event->data.control.value >> 7) & 0x7F), // MSB + (uint8_t)(event->data.control.value & 0x7F) // LSB + ); + break; + case SND_SEQ_EVENT_START: + message.Set(0xFA); // MIDI Real Time Start + break; + case SND_SEQ_EVENT_CONTINUE: + message.Set(0xFB); // MIDI Real Time Continue + break; + case SND_SEQ_EVENT_STOP: + message.Set(0xFC); // MIDI Real Time Stop + break; + case SND_SEQ_EVENT_TICK: + message.Set(0xF8); // MIDI Real Time Clock Tick + break; + case SND_SEQ_EVENT_SENSING: + message.Set(0xFE); // MIDI Real Time Active Sensing + break; + case SND_SEQ_EVENT_RESET: + message.Set(0xFF); // MIDI Real Time System Reset + break; + case SND_SEQ_EVENT_SYSEX: + // Handle SysEx messages + if (event->data.ext.len > 0 && event->data.ext.len + 2 <= sizeof(message.fixedBuffer)) + { + message.size = event->data.ext.len + 1; // +1 for SysEx + message.data = message.fixedBuffer; + message.fixedBuffer[0] = 0xF0; // Start of SysEx + memcpy(message.fixedBuffer + 1, event->data.ext.ptr, event->data.ext.len); + message.fixedBuffer[event->data.ext.len + 1] = 0xF7; // End of SysEx + } + else + { + message.size = event->data.ext.len; + message.data = (uint8_t *)event->data.ext.ptr; + if (message.data[0] != 0xF0) + { + throw std::logic_error("Invalid SysEx message: does not start with 0xF0"); + } + } + break; + case SND_SEQ_EVENT_SETPOS_TICK: + message.size = 3 + sizeof(event->data.queue.param.value); // Tempo events are usually 3 bytes + message.data = message.fixedBuffer; + message.fixedBuffer[0] = 0xFF; // Meta event type for tempo + message.fixedBuffer[1] = (uint8_t)MetaEventType::SetPositionTick; // Meta event subtype for tempo + message.fixedBuffer[2] = (uint8_t)(event->data.queue.queue); // MSB + memcpy(message.fixedBuffer + 3, &event->data.queue.param.value, sizeof(event->data.queue.param.value)); + break; + case SND_SEQ_EVENT_SETPOS_TIME: + message.size = 3 + sizeof(event->data.queue.param.time); // Tempo events are usually 3 bytes + message.data = message.fixedBuffer; + message.fixedBuffer[0] = 0xFF; // Meta event type for tempo + message.fixedBuffer[1] = (uint8_t)MetaEventType::SetPositionTime; // Meta event subtype for tempo + message.fixedBuffer[2] = (uint8_t)(event->data.queue.queue); + memcpy(message.fixedBuffer + 3, &event->data.queue.param.time, sizeof(event->data.queue.param.time)); + break; + case SND_SEQ_EVENT_TEMPO: + // Handle tempo events + message.size = sizeof(event->data.queue.param.value) + 3; // Tempo events are usually 3 bytes + if (message.size > sizeof(message.fixedBuffer)) + { + throw std::logic_error("Tempo event size exceeds fixed buffer size"); + } + message.data = message.fixedBuffer; + message.fixedBuffer[0] = 0xFF; // Meta event type for tempo + message.fixedBuffer[1] = (uint8_t)MetaEventType::Tempo; // Meta event subtype for tempo + message.fixedBuffer[2] = (uint8_t)(event->data.queue.queue); + memcpy(message.fixedBuffer + 3, &event->data.queue.param.value, sizeof(event->data.queue.param.value)); + break; + + case SND_SEQ_EVENT_CLOCK: + message.Set(0xF8); // MIDI Real Time Clock Tick + break; +#ifndef NDEBUG +#define MSG_DEBUG_LOG(x) \ + case x: \ + Lv2Log::debug("ALSA Sequencer Message" #x); \ + break; +#else +#define MSG_DEBUG_LOG(x) +#endif + MSG_DEBUG_LOG(SND_SEQ_EVENT_CLIENT_START) + MSG_DEBUG_LOG(SND_SEQ_EVENT_CLIENT_EXIT) + MSG_DEBUG_LOG(SND_SEQ_EVENT_CLIENT_CHANGE) + MSG_DEBUG_LOG(SND_SEQ_EVENT_PORT_START) + MSG_DEBUG_LOG(SND_SEQ_EVENT_PORT_EXIT) + MSG_DEBUG_LOG(SND_SEQ_EVENT_PORT_CHANGE) + MSG_DEBUG_LOG(SND_SEQ_EVENT_PORT_SUBSCRIBED) + MSG_DEBUG_LOG(SND_SEQ_EVENT_PORT_UNSUBSCRIBED) + + case SND_SEQ_EVENT_KEYSIGN: + case SND_SEQ_EVENT_TIMESIGN: + // and a PASSEL of others! + default: + success = false; + break; + } + snd_seq_free_event(event); + } + if (success) + { + return true; + } + } + } + + int AlsaSequencerImpl::CreateRealtimeInputQueue() + { + if (!seqHandle) + { + throw std::runtime_error("ALSA sequencer not initialized"); + } + + // Create a new queue if we don't have one yet + if (queueId < 0) + { + queueId = snd_seq_alloc_named_queue(seqHandle, "PiPedal Realtime Queue"); + if (queueId < 0) + { + throw std::runtime_error(SS("Failed to create ALSA queue: " << snd_strerror(queueId))); + } + + // Set queue timing to real-time mode + snd_seq_queue_tempo_t *tempo; + snd_seq_queue_tempo_alloca(&tempo); + snd_seq_queue_tempo_set_tempo(tempo, 120); // 120 BPM default + snd_seq_queue_tempo_set_ppq(tempo, 96); // 96 ticks per quarter note + + int rc = snd_seq_set_queue_tempo(seqHandle, queueId, tempo); + if (rc < 0) + { + snd_seq_free_queue(seqHandle, queueId); + queueId = -1; + throw std::runtime_error(SS("Failed to set queue tempo: " << snd_strerror(rc))); + } + + // Start the queue + rc = snd_seq_start_queue(seqHandle, queueId, nullptr); + if (rc < 0) + { + snd_seq_free_queue(seqHandle, queueId); + queueId = -1; + throw std::runtime_error(SS("Failed to start queue: " << snd_strerror(rc))); + } + + // Set the queue for input timestamping + snd_seq_port_info_t *port_info; + snd_seq_port_info_alloca(&port_info); + + rc = snd_seq_get_port_info(seqHandle, inPort, port_info); + if (rc < 0) + { + snd_seq_free_queue(seqHandle, queueId); + queueId = -1; + throw std::runtime_error(SS("Failed to get port info: " << snd_strerror(rc))); + } + + // Enable timestamping on the input port + snd_seq_port_info_set_timestamping(port_info, 1); + snd_seq_port_info_set_timestamp_real(port_info, 1); + snd_seq_port_info_set_timestamp_queue(port_info, queueId); + + rc = snd_seq_set_port_info(seqHandle, inPort, port_info); + if (rc < 0) + { + snd_seq_free_queue(seqHandle, queueId); + queueId = -1; + throw std::runtime_error(SS("Failed to set port timestamping: " << snd_strerror(rc))); + } + } + + return queueId; + } + bool AlsaSequencerImpl::GetQueueRealtime(uint64_t *sec, uint32_t *nsec) + { + if (!seqHandle || queueId < 0) + { + return false; + } + + snd_seq_queue_status_t *status; + snd_seq_queue_status_alloca(&status); + + int rc = snd_seq_get_queue_status(seqHandle, queueId, status); + if (rc < 0) + { + return false; + } + + const snd_seq_real_time_t *realtime = snd_seq_queue_status_get_real_time(status); + if (sec) + *sec = realtime->tv_sec; + if (nsec) + *nsec = realtime->tv_nsec; + + return true; + } + + std::string RawMidiIdToSequencerId(const std::vector &seqDevices, const std::string &rawMidiId) + { + for (const auto &device : seqDevices) + { + if (device.rawMidiDevice == rawMidiId) + { + return device.id; + } + } + return {}; + } + + void AlsaSequencerImpl::ModifyConnection(int clientId, int portId, ConnectAction action) + { + std::lock_guard lock(connectionsMutex); + + // use our own seq handle so that we can do this free-threaded. + snd_seq_t *seq; + if (snd_seq_open(&seq, "default", SND_SEQ_OPEN_DUPLEX, 0) < 0) + { + throw std::runtime_error("Failed to open ALSA sequencer for connection"); + } + Finally seq_finally([seq]() + { snd_seq_close(seq); }); + + + snd_seq_addr_t sender, dest; + dest.client = myClientId; + dest.port = 0; + sender.client = clientId; + sender.port = portId; + + snd_seq_port_subscribe_t *subs; + int queue = this->queueId; + + snd_seq_port_subscribe_alloca(&subs); + snd_seq_port_subscribe_set_sender(subs, &sender); + snd_seq_port_subscribe_set_dest(subs, &dest); + snd_seq_port_subscribe_set_queue(subs, queue); + snd_seq_port_subscribe_set_exclusive(subs, 0); + + if (action == ConnectAction::Unsubscribe) + { + if (snd_seq_get_port_subscription(seq, subs) < 0) + { + Lv2Log::warning( + "Failed to disconnect ALSA sequencer port %d:%d. Subscripton not found.", + (int)clientId, + (int)portId); + } + else + { + int rc = snd_seq_unsubscribe_port(seq, subs); + if (rc < 0) + { + Lv2Log::warning( + "Failed to disconnect ALSA sequencer port %d:%d. (%s)", + (int)clientId, + (int)portId, + snd_strerror(rc)); + } + } + for (auto it = this->connections.begin(); it != this->connections.end(); ++it) + { + if (it->clientId == clientId && it->portId == portId) + { + it = this->connections.erase(it); + break; + } + } + } + else + { + if (snd_seq_get_port_subscription(seq, subs) == 0) + { + Lv2Log::warning("ALSA sequencer port %d:%d is already subscribed.", (int)clientId, (int)portId); + return; + } + int rc = snd_seq_subscribe_port(seq, subs); + if (rc < 0) + { + Lv2Log::error("Failed to connect ALSA sequencer port %d:%d. (%s)", + (int)clientId, (int)portId, + snd_strerror(rc)); + return; + } + this->connections.push_back({clientId, portId}); + } + } + + AlsaSequencerDeviceMonitor::ptr AlsaSequencerDeviceMonitor::Create() + { + return std::make_shared(); + } + + void AlsaSequencerDeviceMonitorImpl::StartMonitoring( + Callback &&onChangeCallback) + { + started = true; + this->callback = std::move(onChangeCallback); + this->serviceThread = std::make_unique( + [this]() + { + ServiceProc(); + }); + } + void AlsaSequencerDeviceMonitorImpl::ServiceProc() + { + int err; + snd_seq_t *seqHandle; + + err = snd_seq_open(&seqHandle, "default", SND_SEQ_OPEN_DUPLEX, 0); + + Finally seq_finally( + [seqHandle]() + { + snd_seq_close(seqHandle); + }); + if (err < 0) + { + Lv2Log::error("Error opening ALSA Device Monitor sequencer: %s", snd_strerror(err)); + return; + } + snd_seq_set_client_name(seqHandle, "Device Monitor"); + + int inPort = snd_seq_create_simple_port( + seqHandle, "PiPedal:portMonitor", + SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE, + SND_SEQ_PORT_TYPE_APPLICATION); + if (inPort < 0) + { + Lv2Log::error("Error creating ALSA Device Monitor port: %s", snd_strerror(inPort)); + return; + } + Finally inPort_finaly { + [seqHandle, inPort]() + { + snd_seq_delete_port(seqHandle, inPort); + } + }; + + // Set client name + + int queueId = CreateInputQueue(seqHandle,inPort); + if (queueId < 0) + { + Lv2Log::error("Error creating ALSA Device Monitor queue: %s", snd_strerror(queueId)); + return; + } + Finally queue_finally( + [seqHandle, queueId]() + { + snd_seq_free_queue(seqHandle, queueId); + }); + + // Subscribe to system announcements + snd_seq_port_subscribe_t *subscription; + snd_seq_port_subscribe_alloca(&subscription); + snd_seq_addr_t sender, dest; + + sender.client = SND_SEQ_CLIENT_SYSTEM; + sender.port = SND_SEQ_PORT_SYSTEM_ANNOUNCE; + dest.client = snd_seq_client_id(seqHandle); + dest.port = inPort; + + snd_seq_port_subscribe_set_sender(subscription, &sender); + snd_seq_port_subscribe_set_dest(subscription, &dest); + err = snd_seq_subscribe_port(seqHandle, subscription); + if (err < 0) + { + Lv2Log::error("Failed to subscribe to ALSA sequencer announcements: %s", snd_strerror(err)); + return; + } + + // Create poll descriptors + std::vector pollFds; + + snd_seq_nonblock(seqHandle, 1); // Set sequencer to non-blocking mode + while (!terminateThread) + { + int nPollFds = snd_seq_poll_descriptors_count(seqHandle, POLLIN); + pollFds.resize(nPollFds); + snd_seq_poll_descriptors(seqHandle, pollFds.data(), nPollFds, POLLIN); + + // Poll for events + if (poll(pollFds.data(), nPollFds, 100) > 0) + { + snd_seq_event_t *event; + while (snd_seq_event_input(seqHandle, &event) > 0) + { + if (event->type == SND_SEQ_EVENT_CLIENT_START) + { + // Get the client name for logging/debugging + snd_seq_client_info_t *client_info; + snd_seq_client_info_alloca(&client_info); + if (snd_seq_get_any_client_info(seqHandle, event->data.addr.client, client_info) >= 0) { + std::string clientName = snd_seq_client_info_get_name(client_info); + callback(MonitorAction::DeviceAdded, event->data.addr.client, clientName); + } + } + else if (event->type == SND_SEQ_EVENT_CLIENT_EXIT) + { + callback(MonitorAction::DeviceRemoved, event->data.addr.client,""); + } + snd_seq_free_event(event); + } + } + } + + return; + } + + void AlsaSequencerDeviceMonitorImpl::StopMonitoring() + { + if (started) + { + started = false; + terminateThread = true; + serviceThread = nullptr; // (joins) + } + } + + AlsaSequencerDeviceMonitorImpl::~AlsaSequencerDeviceMonitorImpl() + { + StopMonitoring(); + } + + int AlsaSequencerDeviceMonitorImpl::CreateInputQueue(snd_seq_t *seqHandle, int inPort) + { + if (!seqHandle) + { + throw std::runtime_error("ALSA sequencer not initialized"); + } + + // Create a new queue if we don't have one yet + int queueId = -1; + { + queueId = snd_seq_alloc_named_queue(seqHandle, "PiPedal Device Monitor Queue"); + if (queueId < 0) + { + throw std::runtime_error(SS("Failed to create ALSA queue: " << snd_strerror(queueId))); + } + + // Set queue timing to real-time mode + snd_seq_queue_tempo_t *tempo; + snd_seq_queue_tempo_alloca(&tempo); + snd_seq_queue_tempo_set_tempo(tempo, 120); // 120 BPM default + snd_seq_queue_tempo_set_ppq(tempo, 96); // 96 ticks per quarter note + + int rc = snd_seq_set_queue_tempo(seqHandle, queueId, tempo); + if (rc < 0) + { + snd_seq_free_queue(seqHandle, queueId); + throw std::runtime_error(SS("Failed to set queue tempo: " << snd_strerror(rc))); + } + + // Start the queue + rc = snd_seq_start_queue(seqHandle, queueId, nullptr); + if (rc < 0) + { + snd_seq_free_queue(seqHandle, queueId); + throw std::runtime_error(SS("Failed to start queue: " << snd_strerror(rc))); + } + + // Set the queue for input timestamping + snd_seq_port_info_t *port_info; + snd_seq_port_info_alloca(&port_info); + + rc = snd_seq_get_port_info(seqHandle, inPort, port_info); + if (rc < 0) + { + snd_seq_free_queue(seqHandle, queueId); + throw std::runtime_error(SS("Failed to get port info: " << snd_strerror(rc))); + } + + // Enable timestamping on the input port + snd_seq_port_info_set_timestamping(port_info, 1); + snd_seq_port_info_set_timestamp_real(port_info, 1); + snd_seq_port_info_set_timestamp_queue(port_info, queueId); + + rc = snd_seq_set_port_info(seqHandle, inPort, port_info); + if (rc < 0) + { + snd_seq_free_queue(seqHandle, queueId); + throw std::runtime_error(SS("Failed to set port timestamping: " << snd_strerror(rc))); + } + return queueId; + } + + return queueId; + } + + JSON_MAP_BEGIN(AlsaSequencerPortSelection) + JSON_MAP_REFERENCE(AlsaSequencerPortSelection, id) + JSON_MAP_REFERENCE(AlsaSequencerPortSelection, name) + JSON_MAP_REFERENCE(AlsaSequencerPortSelection, sortOrder) + JSON_MAP_END() + + JSON_MAP_BEGIN(AlsaSequencerConfiguration) + JSON_MAP_REFERENCE(AlsaSequencerConfiguration, connections) + JSON_MAP_END() + +} // namespace pipedal diff --git a/PiPedalCommon/src/CMakeLists.txt b/PiPedalCommon/src/CMakeLists.txt index 9ab01b6..99e0795 100644 --- a/PiPedalCommon/src/CMakeLists.txt +++ b/PiPedalCommon/src/CMakeLists.txt @@ -13,8 +13,8 @@ set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror") if (CMAKE_BUILD_TYPE MATCHES Debug) message(STATUS "Debug build") - - set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -DDEBUG -D_GLIBCXX_DEBUG" ) + # Must not use -D_GLIBCXX_DEBUG (incompatible with SQLiteCpp) + set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -DDEBUG " ) endif() # Can't get the pkg_check to work. @@ -41,6 +41,7 @@ message(STATUS "NMPIPEDAL CMAKE_CXX_FLAGS: ${CMAKE_CXX_FLAGS}") # Use the sdbus-c++ target in SDBusCpp namespace add_library(PiPedalCommon STATIC + include/Finally.hpp include/dbus/org.freedesktop.NetworkManager.Device.Statistics.hpp include/dbus/org.freedesktop.NetworkManager.Settings.Connection.hpp include/dbus/org.freedesktop.NetworkManager.IP6Config.hpp @@ -57,6 +58,7 @@ add_library(PiPedalCommon STATIC include/dbus/org.freedesktop.NetworkManager.Device.Wireless.hpp include/dbus/org.freedesktop.NetworkManager.DHCP4Config.hpp + AlsaSequencer.cpp include/AlsaSequencer.hpp Utf8Utils.cpp include/Utf8Utils.hpp NetworkManagerInterfaces.cpp include/NetworkManagerInterfaces.hpp diff --git a/PiPedalCommon/src/HtmlHelper.cpp b/PiPedalCommon/src/HtmlHelper.cpp index 9944bd5..607c7aa 100644 --- a/PiPedalCommon/src/HtmlHelper.cpp +++ b/PiPedalCommon/src/HtmlHelper.cpp @@ -39,6 +39,17 @@ std::string HtmlHelper::timeToHttpDate() return timeToHttpDate(time(nullptr)); } + +std::string HtmlHelper::timeToHttpDate(std::filesystem::file_time_type time) +{ + + // Convert to time_t. + auto sctp = std::chrono::time_point_cast(time - std::filesystem::file_time_type::clock::now() + std::chrono::system_clock::now()); + + auto time_t_value = std::chrono::system_clock::to_time_t(sctp); + return timeToHttpDate(time_t_value); +} + std::string HtmlHelper::timeToHttpDate(time_t time) { // RFC 7231, IMF-fixdate. diff --git a/PiPedalCommon/src/SysExec.cpp b/PiPedalCommon/src/SysExec.cpp index 2244cf3..3ffd657 100644 --- a/PiPedalCommon/src/SysExec.cpp +++ b/PiPedalCommon/src/SysExec.cpp @@ -268,7 +268,7 @@ int pipedal::sysExecWait(ProcessId pid_) } -SysExecOutput pipedal::sysExecForOutput(const std::string &program, const std::string &args) +SysExecOutput pipedal::sysExecForOutput(const std::string &program, const std::string &args, bool discardStderr) { namespace fs = std::filesystem; fs::path fullPath = findOnSystemPath(program); @@ -277,7 +277,13 @@ SysExecOutput pipedal::sysExecForOutput(const std::string &program, const std::s throw std::runtime_error(SS("Path does not exist. " << fullPath)); } std::stringstream s; - s << fullPath.c_str() << " " << args << " 2>&1"; + s << fullPath.c_str() << " " << args; + if (discardStderr) + { + s << " 2>/dev/null"; + } else { + s << " 2>&1"; // redirect stderr to stdout + } std::string fullCommand = s.str(); FILE *output = popen(fullCommand.c_str(), "r"); diff --git a/PiPedalCommon/src/Utf8Utils.cpp b/PiPedalCommon/src/Utf8Utils.cpp index f9f85b9..e9519e8 100644 --- a/PiPedalCommon/src/Utf8Utils.cpp +++ b/PiPedalCommon/src/Utf8Utils.cpp @@ -260,81 +260,123 @@ namespace pipedal auto p = v.begin(); while (p != v.end()) { - uint32_t uc; - uint8_t c = (uint8_t)*p++; - if ((c & UTF8_ONE_BYTE_MASK) == UTF8_ONE_BYTE_BITS) - { - uc = c; - } - else - { - uint32_t c2 = continuation_byte(p, v.end()); - - if ((c & UTF8_TWO_BYTES_MASK) == UTF8_TWO_BYTES_BITS) + try { + uint32_t uc; + uint8_t c = (uint8_t)*p++; + if ((c & UTF8_ONE_BYTE_MASK) == UTF8_ONE_BYTE_BITS) { - uint32_t c1 = c & (uint32_t)(~UTF8_TWO_BYTES_MASK); - if (c1 <= 1 && enforceValidUtf8Encoding) - { - // overlong encoding. - throw_encoding_error(); - } - uc = (c1 << 6) | c2; + uc = c; } else { - uint32_t c3 = continuation_byte(p, v.end()); + uint32_t c2 = continuation_byte(p, v.end()); - if ((c & UTF8_THREE_BYTES_MASK) == UTF8_THREE_BYTES_BITS) + if ((c & UTF8_TWO_BYTES_MASK) == UTF8_TWO_BYTES_BITS) { - uint32_t c1 = c & (uint32_t)~UTF8_THREE_BYTES_MASK; - if (c1 == 0 && c2 < 0x20 && enforceValidUtf8Encoding) + uint32_t c1 = c & (uint32_t)(~UTF8_TWO_BYTES_MASK); + if (c1 <= 1 && enforceValidUtf8Encoding) { // overlong encoding. throw_encoding_error(); } - - uc = (c1) << 12 | (c2 << 6) | c3; + uc = (c1 << 6) | c2; } else { - uint32_t c4 = continuation_byte(p, v.end()); - if ((c & UTF8_FOUR_BYTES_MASK) == UTF8_FOUR_BYTES_BITS) + uint32_t c3 = continuation_byte(p, v.end()); + + if ((c & UTF8_THREE_BYTES_MASK) == UTF8_THREE_BYTES_BITS) { - uint32_t c1 = c & (uint32_t)~UTF8_FOUR_BYTES_MASK; - if (c1 == 0 && c2 < 0x10 && enforceValidUtf8Encoding) + uint32_t c1 = c & (uint32_t)~UTF8_THREE_BYTES_MASK; + if (c1 == 0 && c2 < 0x20 && enforceValidUtf8Encoding) { // overlong encoding. throw_encoding_error(); } - uc = (c1 << 18) | (c2 << 12) | (c3 << 6) | c4; + + uc = (c1) << 12 | (c2 << 6) | c3; } else { - // outside legal UCS range. - throw_encoding_error(); + uint32_t c4 = continuation_byte(p, v.end()); + if ((c & UTF8_FOUR_BYTES_MASK) == UTF8_FOUR_BYTES_BITS) + { + uint32_t c1 = c & (uint32_t)~UTF8_FOUR_BYTES_MASK; + if (c1 == 0 && c2 < 0x10 && enforceValidUtf8Encoding) + { + // overlong encoding. + throw_encoding_error(); + } + uc = (c1 << 18) | (c2 << 12) | (c3 << 6) | c4; + } + else + { + // outside legal UCS range. + throw_encoding_error(); + } } } } - } - if (uc < 0x10000ul) - { - os << (char16_t)uc; - } - else - { - // write UTF-16 surrogate pair. - uc -= 0x10000; + if (uc < 0x10000ul) + { + os << (char16_t)uc; + } + else + { + // write UTF-16 surrogate pair. + uc -= 0x10000; - char16_t s1 = (char16_t)(UTF16_SURROGATE_1_BASE + ((uc >> 10) & 0x3FFu)); - char16_t s2 = (char16_t)(UTF16_SURROGATE_2_BASE + (uc & 0x03FFu)); - // surrogate pair. - os << s1; - os << s2; + char16_t s1 = (char16_t)(UTF16_SURROGATE_1_BASE + ((uc >> 10) & 0x3FFu)); + char16_t s2 = (char16_t)(UTF16_SURROGATE_2_BASE + (uc & 0x03FFu)); + // surrogate pair. + os << s1; + os << s2; + } + } catch (const std::exception &e) + { + os << u'\uFFFD'; // replacement character for invalid sequences. } } return os.str(); } + bool IsValidUtf8(const std::string &text) + { + auto p = text.begin(); + while (p != text.end()) + { + uint8_t c = *p++; + if ((c & UTF8_ONE_BYTE_MASK) == UTF8_ONE_BYTE_BITS) + { + // 1-byte character (ASCII) + continue; + } + else if ((c & UTF8_TWO_BYTES_MASK) == UTF8_TWO_BYTES_BITS) + { + // 2-byte character + if (p == text.end() || ((*p++ & UTF8_CONTINUATION_MASK) != UTF8_CONTINUATION_BITS)) + return false; + } + else if ((c & UTF8_THREE_BYTES_MASK) == UTF8_THREE_BYTES_BITS) + { + // 3-byte character + if (p + 1 >= text.end() || ((*p++ & UTF8_CONTINUATION_MASK) != UTF8_CONTINUATION_BITS) || ((*p++ & UTF8_CONTINUATION_MASK) != UTF8_CONTINUATION_BITS)) + return false; + } + else if ((c & UTF8_FOUR_BYTES_MASK) == UTF8_FOUR_BYTES_BITS) + { + // 4-byte character + if (p + 2 >= text.end() || ((*p++ & UTF8_CONTINUATION_MASK) != UTF8_CONTINUATION_BITS) || ((*p++ & UTF8_CONTINUATION_MASK) != UTF8_CONTINUATION_BITS)) + return false; + } + else + { + // Invalid UTF-8 + return false; + } + } + return true; + } } \ No newline at end of file diff --git a/PiPedalCommon/src/WifiConfigSettings.cpp b/PiPedalCommon/src/WifiConfigSettings.cpp index ddeb005..9b574ac 100644 --- a/PiPedalCommon/src/WifiConfigSettings.cpp +++ b/PiPedalCommon/src/WifiConfigSettings.cpp @@ -89,7 +89,6 @@ bool WifiConfigSettings::ValidateCountryCode(const std::string &text) return std::isalpha(text[0]) && std::isalpha(text[1]); } - void WifiConfigSettings::Load() { try @@ -115,9 +114,9 @@ void WifiConfigSettings::Load() } static void openWithPerms( - pipedal::ofstream_synced &f, - const std::filesystem::path &path, - std::filesystem::perms perms = + pipedal::ofstream_synced &f, + const std::filesystem::path &path, + std::filesystem::perms perms = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write | std::filesystem::perms::group_read | std::filesystem::perms::group_write) { @@ -132,13 +131,16 @@ static void openWithPerms( f.open(path); f.close(); } - try { + try + { // set the perms. std::filesystem::permissions( path, perms, - std::filesystem::perm_options::replace); - } catch (const std::exception&) { + std::filesystem::perm_options::replace); + } + catch (const std::exception &) + { Lv2Log::warning(SS("Failed to set permissions on" << path << ".")); } } @@ -147,10 +149,10 @@ static void openWithPerms( f.open(path); } -void WifiConfigSettings::Save() +void WifiConfigSettings::Save() { - WifiConfigSettings newSettings {*this}; - + WifiConfigSettings newSettings{*this}; + // sync legacy settings, just in case i don't know what. newSettings.mdnsName_ = newSettings.hotspotName_; newSettings.enable_ = newSettings.IsEnabled(); @@ -163,12 +165,12 @@ void WifiConfigSettings::Save() newSettings.password_ = oldSettings.password_; } newSettings.hasSavedPassword_ = newSettings.hasPassword_; - + try { ofstream_synced f; - openWithPerms(f,CONFIG_PATH); - json_writer writer(f,false); + openWithPerms(f, CONFIG_PATH); + json_writer writer(f, false); writer.write(&newSettings); } catch (const std::exception &e) @@ -300,17 +302,14 @@ namespace pipedal::priv connection["ipv4"]["method"] = sdbus::Variant(std::string("shared")); connection["ipv6"]["method"] = sdbus::Variant(std::string("ignore")); - - connection["ipv4"]["address-data"] = sdbus::Variant(std::vector>{{ - {"address", sdbus::Variant("192.168.4.1")}, - {"prefix", sdbus::Variant(uint32_t(24))} - }}); + connection["ipv4"]["address-data"] = sdbus::Variant(std::vector>{{{"address", sdbus::Variant("192.168.4.1")}, + {"prefix", sdbus::Variant(uint32_t(24))}}}); connection["ipv4"]["dhcp-send-hostname"] = sdbus::Variant(true); connection["ipv4"]["dhcp-hostname"] = sdbus::Variant("raspberrypi"); connection["ipv4"]["dhcp-options"] = sdbus::Variant(std::vector{ "option:classless-static-route,192.168.4.0/24,0.0.0.0,0.0.0.0/0,192.168.4.1" //"option:classless-static-route,192.168.4.0/24,192.168.4.1" - }); + }); auto connection_path = AddConnection(connection); return connection_path; @@ -326,7 +325,6 @@ namespace pipedal::priv return active_connection_path; } - void RemoveConnection(const std::string &connection) { auto connection_proxy = sdbus::createProxy( @@ -429,7 +427,6 @@ namespace pipedal::priv }; } - JSON_MAP_BEGIN(WifiConfigSettings) // v0 JSON_MAP_REFERENCE(WifiConfigSettings, valid) @@ -560,52 +557,51 @@ bool WifiConfigSettings::ValidateChannel(const std::string &countryCode, const s return true; } -static const char* trueValues[] { +static const char *trueValues[]{ "true", "on", "yes" "1", - nullptr -}; -static const char* falseValues[] { + nullptr}; +static const char *falseValues[]{ "false", "off", "no", "0", - nullptr -}; + nullptr}; -static bool Matches(const std::string&value, const char**matches) +static bool Matches(const std::string &value, const char **matches) { - while(*matches) + while (*matches) { - if (value == *matches) return true; + if (value == *matches) + return true; ++matches; } return false; } -static bool TryStringToBool(const std::string &value, bool*outputValue) +static bool TryStringToBool(const std::string &value, bool *outputValue) { - if (Matches(value,trueValues)) + if (Matches(value, trueValues)) { *outputValue = true; return true; } - if (Matches(value, falseValues)) { + if (Matches(value, falseValues)) + { *outputValue = false; return true; } *outputValue = false; return false; - } -static WifiConfigSettings::ssid_t readSsid(std::istream&ss) +static WifiConfigSettings::ssid_t readSsid(std::istream &ss) { using ssid_t = WifiConfigSettings::ssid_t; char c; - + ssid_t result; while (ss.peek() == ' ') { @@ -624,28 +620,33 @@ static WifiConfigSettings::ssid_t readSsid(std::istream&ss) break; } ss >> c; - switch (c) { - case 'n': - result.push_back((uint8_t)'\n'); - break; - case 'r': - result.push_back((uint8_t)'\r'); - break; - case 't': - result.push_back((uint8_t)'\t'); - break; - case 'b': - result.push_back((uint8_t)'\b'); - break; - default: - result.push_back((uint8_t)c); - break; + switch (c) + { + case 'n': + result.push_back((uint8_t)'\n'); + break; + case 'r': + result.push_back((uint8_t)'\r'); + break; + case 't': + result.push_back((uint8_t)'\t'); + break; + case 'b': + result.push_back((uint8_t)'\b'); + break; + default: + result.push_back((uint8_t)c); + break; } - } else { + } + else + { result.push_back((uint8_t)c); } } - } else { + } + else + { while (!ss.eof()) { if (c == ':') @@ -662,7 +663,7 @@ static WifiConfigSettings::ssid_t readSsid(std::istream&ss) } return result; } -static std::vector stringToSsidArray(const std::string&value) +static std::vector stringToSsidArray(const std::string &value) { using ssid_t = WifiConfigSettings::ssid_t; std::istringstream ss(value); @@ -671,7 +672,7 @@ static std::vector stringToSsidArray(const std::stri while (true) { ssid_t ssid = readSsid(ss); - if (ssid.size() == 0) + if (ssid.size() == 0) { break; } @@ -683,16 +684,15 @@ static std::vector stringToSsidArray(const std::stri } } return result; - } void WifiConfigSettings::ParseArguments( const std::vector &argv, HotspotAutoStartMode startMode, const std::string homeNetworkSsid - ) +) { - this->valid_ = false ; + this->valid_ = false; if (argv.size() != 4) { throw invalid_argument("Invalid number of arguments."); @@ -714,7 +714,6 @@ void WifiConfigSettings::ParseArguments( this->hasPassword_ = this->password_.length() != 0; this->hasSavedPassword_ = oldSettings.hasSavedPassword_; - if (!ValidateCountryCode(this->countryCode_)) { throw invalid_argument("Invalid country code."); @@ -723,7 +722,7 @@ void WifiConfigSettings::ParseArguments( throw invalid_argument("Hotspot name is too long."); if (this->hotspotName_.length() < 1) throw invalid_argument("Hotspot name is too short."); - + if (this->password_.size() != 0 && this->password_.size() < 8) throw invalid_argument("Passphrase must be at least 8 characters long."); @@ -742,12 +741,12 @@ void WifiConfigSettings::ParseArguments( this->valid_ = true; } -bool WifiConfigSettings::ConfigurationChanged(const WifiConfigSettings&other) const +bool WifiConfigSettings::ConfigurationChanged(const WifiConfigSettings &other) const { return !( this->valid_ == other.valid_ && - //this->rebootRequired_ == other.rebootRequired_ && - //this->enable_ == other.enable_ && + // this->rebootRequired_ == other.rebootRequired_ && + // this->enable_ == other.enable_ && this->countryCode_ == other.countryCode_ && this->hotspotName_ == other.hotspotName_ && this->hasPassword_ == other.hasPassword_ && @@ -756,20 +755,18 @@ bool WifiConfigSettings::ConfigurationChanged(const WifiConfigSettings&other) co this->homeNetwork_ == other.homeNetwork_ && this->autoStartMode_ == other.autoStartMode_ && - this->hasSavedPassword_ == other.hasSavedPassword_ - ); - + this->hasSavedPassword_ == other.hasSavedPassword_); } -bool WifiConfigSettings::operator==(const WifiConfigSettings&other) const +bool WifiConfigSettings::operator==(const WifiConfigSettings &other) const { return !ConfigurationChanged(other) && this->wifiWarningGiven_ == other.wifiWarningGiven_; } -static bool CanSeeHomeNetwork(const std::string&home, const std::vector&availableNetworks) +static bool CanSeeHomeNetwork(const std::string &home, const std::vector &availableNetworks) { - for (const auto &availableNetwork: availableNetworks) + for (const auto &availableNetwork : availableNetworks) { - if (availableNetwork == home) + if (availableNetwork == home) { return true; } @@ -778,38 +775,39 @@ static bool CanSeeHomeNetwork(const std::string&home, const std::vector &availableRememberedNetworks, const std::vector &availableNetworks) { - if ((!this->valid_)) + if ((!this->valid_)) return false; - + HotspotAutoStartMode autoStartMode = (HotspotAutoStartMode)this->autoStartMode_; switch (autoStartMode) { - case HotspotAutoStartMode::Never: - default: - return false; + case HotspotAutoStartMode::Never: + default: + return false; - case HotspotAutoStartMode::NoEthernetConnection: - return !ethernetConnected; - case HotspotAutoStartMode::NotAtHome: - return !CanSeeHomeNetwork(this->homeNetwork_, availableNetworks); - case HotspotAutoStartMode::NoRememberedWifiConections: - return availableRememberedNetworks.size() == 0; - case HotspotAutoStartMode::Always: - return true; + case HotspotAutoStartMode::NoEthernetConnection: + return !ethernetConnected; + case HotspotAutoStartMode::NotAtHome: + return !CanSeeHomeNetwork(this->homeNetwork_, availableNetworks); + case HotspotAutoStartMode::NoRememberedWifiConections: + return availableRememberedNetworks.size() == 0; + case HotspotAutoStartMode::Always: + return true; } } std::string pipedal::ssidToString(const std::vector &ssid) { std::stringstream s; - for (auto v: ssid) + for (auto v : ssid) { - if (v == 0) break; // breaks for some arguably legal ssids, but I can live with that. + if (v == 0) + break; // breaks for some arguably legal ssids, but I can live with that. s << (char)v; } return s.str(); @@ -819,21 +817,43 @@ std::vector pipedal::ssidToStringVector(const std::vector result; result.reserve(ssids.size()); - for (const std::vector &ssid: ssids) + for (const std::vector &ssid : ssids) { result.push_back(ssidToString(ssid)); } return result; } bool WifiConfigSettings::WantsHotspot( - bool ethernetConnected, + bool ethernetConnected, const std::vector> &availableRememberedNetworks, // remembered networks that are currently visible - const std::vector> &availableNetworks // all visible networks. - ) + const std::vector> &availableNetworks // all visible networks. +) { std::vector sAvailableRememberedNetworks = ssidToStringVector(availableRememberedNetworks); std::vector sAvailableNetworks = ssidToStringVector(availableNetworks); - return WantsHotspot(ethernetConnected,sAvailableRememberedNetworks,sAvailableNetworks); + return WantsHotspot(ethernetConnected, sAvailableRememberedNetworks, sAvailableNetworks); +} + +bool WifiConfigSettings::NeedsWifi() const +{ + HotspotAutoStartMode autoStartMode = (HotspotAutoStartMode)this->autoStartMode_; + return autoStartMode != HotspotAutoStartMode::Never; } +bool WifiConfigSettings::NeedsScan() const +{ + HotspotAutoStartMode autoStartMode = (HotspotAutoStartMode)this->autoStartMode_; + switch (autoStartMode) + { + case HotspotAutoStartMode::Never: + case HotspotAutoStartMode::NoEthernetConnection: + case HotspotAutoStartMode::Always: + default: + return false; + + case HotspotAutoStartMode::NotAtHome: + case HotspotAutoStartMode::NoRememberedWifiConections: + return true; + } +} diff --git a/PiPedalCommon/src/include/AlsaSequencer.hpp b/PiPedalCommon/src/include/AlsaSequencer.hpp new file mode 100644 index 0000000..f9a9a78 --- /dev/null +++ b/PiPedalCommon/src/include/AlsaSequencer.hpp @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 +#include +#include +#include +#include "json.hpp" + +namespace pipedal +{ + + // event messages starting with 0xFF are meta events. + // data not in midi format, is variable length, and private and the second byte is the MetaEventType. + enum class MetaEventType + { + None, + Tempo, + TimeSignature, + KeySignature, + SetPositionTick, + SetPositionTime + }; + + class AlsaSequencerPortSelection { + private: + std::string id_; + std::string name_; + int32_t sortOrder_ = 0; + + public: + AlsaSequencerPortSelection() = default; + AlsaSequencerPortSelection(const std::string &id, const std::string &name, int32_t displaySortOrder) + : id_(id), name_(name), sortOrder_(displaySortOrder) {} + AlsaSequencerPortSelection(const AlsaSequencerPortSelection &other) = default; + AlsaSequencerPortSelection(AlsaSequencerPortSelection &&other) = default; + AlsaSequencerPortSelection &operator=(const AlsaSequencerPortSelection &other) = default; + AlsaSequencerPortSelection &operator=(AlsaSequencerPortSelection &&other) = default; + + + const std::string& id() const { return id_; } + void id(const std::string &value) { id_ = value; } + const std::string &name() const { return name_; } + void name(const std::string &value) { name_ = value; } + + DECLARE_JSON_MAP(AlsaSequencerPortSelection); + }; + class AlsaSequencerConfiguration { + private: + std::vector connections_; + public: + const std::vector& connections() const { return connections_; } + std::vector& connections() { return connections_; } + void connections(const std::vector& value) { connections_ = value; } + + + bool operator==(const AlsaSequencerConfiguration &other) const + { + if (connections_.size() != other.connections_.size()) + return false; + for (size_t i = 0; i < connections_.size(); ++i) + { + if (connections_[i].id() != other.connections_[i].id() || + connections_[i].name() != other.connections_[i].name()) + { + return false; + } + } + return true; + } + + DECLARE_JSON_MAP(AlsaSequencerConfiguration); + + }; + + + struct AlsaSequencerPort + { + std::string id; + int client; + int port; + std::string name; + std::string clientName; + bool canRead = false; + bool canWrite = false; + bool canReadSubscribe = false; + bool canWriteSubscribe = false; + + bool isKernelDevice = false; + bool isSystemAnnounce = false; + bool isUmp = false; + bool isMidi = false; + bool isApplication = false; + bool isMidiSynth = false; + bool isSynth = false; + bool isSpecific; + bool isHardware = false; + bool isSoftware = false; + bool isPort = false; + bool isVirtual = false; + int cardNumber = -1; + int32_t displaySortOrder = 0; + std::string rawMidiDevice; // e.g. "hw:0,0,0" for kernel devices + }; + + struct AlsaMidiMessage + { + AlsaMidiMessage() + { + size = 0; + data = fixedBuffer; + } + AlsaMidiMessage(uint8_t cc0) + { + size = 1; + data = fixedBuffer; + fixedBuffer[0] = cc0; + } + AlsaMidiMessage(uint8_t cc0, uint8_t cc1) + { + size = 2; + data = fixedBuffer; + fixedBuffer[0] = cc0; + fixedBuffer[1] = cc1; + } + AlsaMidiMessage(uint8_t cc0, uint8_t cc1, uint8_t cc2) + { + size = 3; + data = fixedBuffer; + fixedBuffer[0] = cc0; + fixedBuffer[1] = cc1; + fixedBuffer[2] = cc2; + } + AlsaMidiMessage(uint8_t *data, size_t size) + { + this->size = size; + this->data = data; + } + AlsaMidiMessage(const AlsaMidiMessage &other) + { + this->timestamp = other.timestamp; + this->realtime_sec = other.realtime_sec; + this->realtime_nsec = other.realtime_nsec; + + if (other.size > 3) + { + this->size = other.size; + this->data = other.data; + } + else + { + this->size = other.size; + this->data = fixedBuffer; + for (size_t i = 0; i < other.size; ++i) + { + fixedBuffer[i] = other.fixedBuffer[i]; + } + } + } + AlsaMidiMessage &operator=(const AlsaMidiMessage &other) + { + this->timestamp = other.timestamp; + this->realtime_sec = other.realtime_sec; + this->realtime_nsec = other.realtime_nsec; + + if (other.size > 3) + { + this->size = other.size; + this->data = other.data; + } + else + { + this->size = other.size; + this->data = fixedBuffer; + for (size_t i = 0; i < other.size; ++i) + { + fixedBuffer[i] = other.fixedBuffer[i]; + } + } + return *this; + } + AlsaMidiMessage(AlsaMidiMessage &&other) + { + this->timestamp = other.timestamp; + this->realtime_sec = other.realtime_sec; + this->realtime_nsec = other.realtime_nsec; + + if (other.size > 3) + { + this->size = other.size; + this->data = other.data; + } + else + { + this->size = other.size; + this->data = fixedBuffer; + for (size_t i = 0; i < other.size; ++i) + { + fixedBuffer[i] = other.fixedBuffer[i]; + } + } + } + AlsaMidiMessage &operator=(AlsaMidiMessage &&other) + { + this->timestamp = other.timestamp; + this->realtime_sec = other.realtime_sec; + this->realtime_nsec = other.realtime_nsec; + + if (other.size > 3) + { + this->size = other.size; + this->data = other.data; + } + else + { + this->size = other.size; + this->data = fixedBuffer; + for (size_t i = 0; i < other.size; ++i) + { + fixedBuffer[i] = other.fixedBuffer[i]; + } + } + return *this; + } + + void Set(uint8_t cc0) + { + size = 1; + data = fixedBuffer; + fixedBuffer[0] = cc0; + } + void Set(uint8_t cc0, uint8_t cc1) + { + size = 2; + data = fixedBuffer; + fixedBuffer[0] = cc0; + fixedBuffer[1] = cc1; + } + void Set(uint8_t cc0, uint8_t cc1, uint8_t cc2) + { + size = 3; + data = fixedBuffer; + fixedBuffer[0] = cc0; + fixedBuffer[1] = cc1; + fixedBuffer[2] = cc2; + } + void Set(uint8_t *data, size_t size) + { + this->size = size; + this->data = data; + } + uint8_t cc0() const { return size > 0 ? data[0] : 0; } + uint8_t cc1() const { return size > 1 ? data[1] : 0; } + uint8_t cc2() const { return size > 2 ? data[2] : 0; } + + + uint32_t timestamp; // in microseconds (tick time if using queue) + uint64_t realtime_sec; // real-time timestamp seconds + uint32_t realtime_nsec; // real-time timestamp nanoseconds + size_t size = 0; + uint8_t *data = fixedBuffer; + + uint8_t fixedBuffer[12]; + }; + + /* + * AlsaSequencer - ALSA MIDI sequencer interface with real-time timestamp support + * + * Example usage with real-time timestamps: + * + * AlsaSequencer sequencer; + * sequencer.connect(clientId, portId); + * + * AlsaMidiMessage message; + * while (sequencer.ReadMessage(&message)) { + * // message.realtime_sec and message.realtime_nsec contain precise real-time timestamps + * // message.timestamp contains the queue tick time + * printf("MIDI event at %lu.%09u seconds\n", message.realtime_sec, message.realtime_nsec); + * } + */ + + class AlsaSequencer + { + protected: + AlsaSequencer() {} + + public: + virtual ~AlsaSequencer() = default; + + using self = AlsaSequencer; + using ptr = std::shared_ptr; + + static ptr Create(); + + static std::vector EnumeratePorts(); + + public: + virtual void ConnectPort(int clientId, int portId) = 0; + virtual void ConnectPort(const std::string &id) = 0; + + virtual void SetConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) = 0; + + // Read a single MIDI message from the sequencer input port. A timeout of -1 blocks indefinitely. + // A timeout of 0 returns immediately. + virtual bool ReadMessage(AlsaMidiMessage &message, int timeoutMs = -1) = 0; + + // currently non-functional + virtual bool GetQueueRealtime(uint64_t *sec, uint32_t *nsec) = 0; + + virtual void RemoveAllConnections() = 0; + }; + + + class AlsaSequencerDeviceMonitor { + protected: + AlsaSequencerDeviceMonitor() {} + public: + virtual ~AlsaSequencerDeviceMonitor() {} + + using self = AlsaSequencerDeviceMonitor; + using ptr = std::shared_ptr; + static ptr Create(); + enum class MonitorAction { + DeviceAdded, + DeviceRemoved + }; + using Callback = std::function; + virtual void StartMonitoring(Callback &&onChangeCallback) = 0; + virtual void StopMonitoring() = 0; + + }; + + std::string RawMidiIdToSequencerId(const std::vector &seqDevices, const std::string &rawMidiId); + +} \ No newline at end of file diff --git a/PiPedalCommon/src/include/Finally.hpp b/PiPedalCommon/src/include/Finally.hpp new file mode 100644 index 0000000..cbbdc7e --- /dev/null +++ b/PiPedalCommon/src/include/Finally.hpp @@ -0,0 +1,39 @@ +// Copyright (c) 2024 Robin 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 + +namespace pipedal { + + class Finally { + public: + Finally(std::function &&fn) + :fn (std::move(fn)) + { + + } + ~Finally() { + fn(); + } + private: + std::function fn; + }; + +} \ No newline at end of file diff --git a/PiPedalCommon/src/include/HtmlHelper.hpp b/PiPedalCommon/src/include/HtmlHelper.hpp index b2bd9e5..215f5ac 100644 --- a/PiPedalCommon/src/include/HtmlHelper.hpp +++ b/PiPedalCommon/src/include/HtmlHelper.hpp @@ -21,6 +21,8 @@ #include #include +#include +#include namespace pipedal { @@ -29,6 +31,7 @@ class HtmlHelper { public: static std::string timeToHttpDate(); static std::string timeToHttpDate(time_t time); + static std::string timeToHttpDate(std::filesystem::file_time_type time); static std::string encode_url_segment(const char*pStart, const char*pEnd, bool isQuerySegment = false); diff --git a/PiPedalCommon/src/include/SysExec.hpp b/PiPedalCommon/src/include/SysExec.hpp index 7b0a1d2..8bfc661 100644 --- a/PiPedalCommon/src/include/SysExec.hpp +++ b/PiPedalCommon/src/include/SysExec.hpp @@ -34,7 +34,7 @@ namespace pipedal std::string output; }; - SysExecOutput sysExecForOutput(const std::string& command, const std::string&args); + SysExecOutput sysExecForOutput(const std::string& command, const std::string&args, bool discardStderr = false); using ProcessId = int64_t; // platform-agnostic wrapper for pid_t; // Returns a pid or -1 on errror. diff --git a/PiPedalCommon/src/include/Utf8Utils.hpp b/PiPedalCommon/src/include/Utf8Utils.hpp index 5b31af4..b0c5d31 100644 --- a/PiPedalCommon/src/include/Utf8Utils.hpp +++ b/PiPedalCommon/src/include/Utf8Utils.hpp @@ -44,6 +44,8 @@ namespace pipedal { std::u16string Utf8ToUtf16(const std::string_view&s); std::string Utf16ToUtf8(const std::u16string_view&s); + + bool IsValidUtf8(const std::string &text); } diff --git a/PiPedalCommon/src/include/WifiConfigSettings.hpp b/PiPedalCommon/src/include/WifiConfigSettings.hpp index 44492aa..ace67af 100644 --- a/PiPedalCommon/src/include/WifiConfigSettings.hpp +++ b/PiPedalCommon/src/include/WifiConfigSettings.hpp @@ -95,6 +95,10 @@ namespace pipedal { bool operator==(const WifiConfigSettings&other) const; bool ConfigurationChanged(const WifiConfigSettings&other) const; + bool WantsHotspot(bool ethernetConnected) + { + return WantsHotspot(ethernetConnected, std::vector{}, std::vector{}); + } bool WantsHotspot( bool ethernetConnected, const std::vector &availableRememberedNetworks, // remembered networks that are currently visible @@ -105,7 +109,8 @@ namespace pipedal { const std::vector> &availableRememberedNetworks, // remembered networks that are currently visible const std::vector> &availableNetworks // all visible networks. ); - + bool NeedsScan() const; + bool NeedsWifi() const; public: DECLARE_JSON_MAP(WifiConfigSettings); }; diff --git a/PiPedalCommon/src/include/json.hpp b/PiPedalCommon/src/include/json.hpp index f47deba..aa193aa 100644 --- a/PiPedalCommon/src/include/json.hpp +++ b/PiPedalCommon/src/include/json.hpp @@ -20,12 +20,9 @@ #pragma once #include #include -#include -#include #include #include #include -#include "HtmlHelper.hpp" #include #include #include @@ -35,6 +32,11 @@ #include #include #include +#include +#include +#include +#include +#include #define DECLARE_JSON_MAP(CLASSNAME) \ static pipedal::json_map::storage_type jmap @@ -228,7 +230,7 @@ namespace pipedal reference(const char *name, MEMBER_TYPE CLASS::*member_pointer) { return new json_member_reference(name, member_pointer); - }; + } template static json_enum_member_reference * enum_reference( @@ -237,14 +239,20 @@ namespace pipedal const ENUM_CONVERTER *converter) { return new json_enum_member_reference(name, member_pointer, converter); - }; + } template static json_conditional_member_reference * conditional_reference(const char *name, MEMBER_TYPE CLASS::*member_pointer, typename JsonConditionFunction::Pointer condition) { return new json_conditional_member_reference(name, member_pointer, condition); - }; + } + + // for cases where the name is not a valid C++ identifier. + #define JSON_MAP_DICTIONARY_REFERENCE(class,name, variable) \ + json_map::reference(name,&class::variable##_), + + }; //---------------------------------------------------------------------------------- @@ -254,7 +262,7 @@ namespace pipedal { template - static std::true_type test(decltype(TYPE::jmap) *) { return std::true_type(); }; + static std::true_type test(decltype(TYPE::jmap) *) { return std::true_type(); } template static std::false_type test(...); @@ -268,7 +276,7 @@ namespace pipedal { template - static std::true_type test() { return std::true_type(); }; + static std::true_type test() { return std::true_type(); } template static std::false_type test(...); @@ -320,7 +328,7 @@ namespace pipedal { os << text; } - using string_view = boost::string_view; + using string_view = std::string_view; json_writer(std::ostream &os, bool compressed = true, bool allowNaN = false) : os(os), compressed(compressed), allowNaN_(allowNaN), indent_level(0) { @@ -1049,6 +1057,7 @@ namespace pipedal if (peek() == ']') { c = get(); + (void)c; break; } T item; @@ -1057,6 +1066,7 @@ namespace pipedal if (peek() == ',') { c = get(); + (void)c; } } *value = std::move(result); @@ -1068,7 +1078,7 @@ namespace pipedal { template - static std::true_type test(decltype(json_reader(std::cin).read((ARG *)nullptr)) *v) { return std::true_type(); }; + static std::true_type test(decltype(json_reader(std::cin).read((ARG *)nullptr)) *v) { return std::true_type(); } template static std::false_type test(...); diff --git a/PiPedalCommon/src/include/json_variant.hpp b/PiPedalCommon/src/include/json_variant.hpp index c972ace..9c4b69b 100644 --- a/PiPedalCommon/src/include/json_variant.hpp +++ b/PiPedalCommon/src/include/json_variant.hpp @@ -30,6 +30,7 @@ #include #include #include +#include #include "json.hpp" namespace pipedal @@ -308,7 +309,11 @@ namespace pipedal iterator end() { return values.end(); } const_iterator begin() const { return values.begin(); } const_iterator end() const { return values.end(); } - + + iterator erase(iterator it) + { + return values.erase(it); + } iterator find(const std::string &key); const_iterator find(const std::string &key) const; diff --git a/PiPedalCommon/src/include/util.hpp b/PiPedalCommon/src/include/util.hpp index 793e187..39b95dc 100644 --- a/PiPedalCommon/src/include/util.hpp +++ b/PiPedalCommon/src/include/util.hpp @@ -71,6 +71,7 @@ namespace pipedal return contains(vector, std::string(value)); } + bool HasWritePermissions(const std::filesystem::path &path); class NoCopy { public: @@ -114,11 +115,14 @@ namespace pipedal return true; } - // C locale to lower. Only does 'A'-'Z'. - std::string ToLower(const std::string&value); std::filesystem::path MakeRelativePath(const std::filesystem::path &path, const std::filesystem::path&parentPath); bool IsSubdirectory(const std::filesystem::path &path, const std::filesystem::path &basePath); + bool HasDotDot(const std::filesystem::path &path); + + + std::string ToLower(const std::string&value); + } diff --git a/PiPedalCommon/src/json.cpp b/PiPedalCommon/src/json.cpp index c25f5ae..d486bf8 100644 --- a/PiPedalCommon/src/json.cpp +++ b/PiPedalCommon/src/json.cpp @@ -22,6 +22,8 @@ #include #include "json_variant.hpp" #include "util.hpp" +#include + using namespace pipedal; @@ -68,63 +70,69 @@ void json_writer::write(string_view v,bool enforceValidUtf8Encoding) { uint32_t uc; uint8_t c = (uint8_t)*p++; - if ((c & UTF8_ONE_BYTE_MASK) == UTF8_ONE_BYTE_BITS) - { - uc = c; - } - else - { - uint32_t c2 = continuation_byte(p, v.end()); - - if ((c & UTF8_TWO_BYTES_MASK) == UTF8_TWO_BYTES_BITS) + try { + if ((c & UTF8_ONE_BYTE_MASK) == UTF8_ONE_BYTE_BITS) { - uint32_t c1 = c & (uint32_t)(~UTF8_TWO_BYTES_MASK); - if (c1 <= 1 && enforceValidUtf8Encoding) - { - // overlong encoding. - throw_encoding_error(); - } - uc = (c1 << 6) | c2; + uc = c; } else { - uint32_t c3 = continuation_byte(p, v.end()); + uint32_t c2 = continuation_byte(p, v.end()); - if ((c & UTF8_THREE_BYTES_MASK) == UTF8_THREE_BYTES_BITS) + if ((c & UTF8_TWO_BYTES_MASK) == UTF8_TWO_BYTES_BITS) { - uint32_t c1 = c & (uint32_t)~UTF8_THREE_BYTES_MASK; - if (c1 == 0 && c2 < 0x20 && enforceValidUtf8Encoding) + uint32_t c1 = c & (uint32_t)(~UTF8_TWO_BYTES_MASK); + if (c1 <= 1 && enforceValidUtf8Encoding) { // overlong encoding. throw_encoding_error(); } - - uc = (c1) << 12 | (c2 << 6) | c3; + uc = (c1 << 6) | c2; } else { - uint32_t c4 = continuation_byte(p, v.end()); - if ((c & UTF8_FOUR_BYTES_MASK) == UTF8_FOUR_BYTES_BITS) + uint32_t c3 = continuation_byte(p, v.end()); + + if ((c & UTF8_THREE_BYTES_MASK) == UTF8_THREE_BYTES_BITS) { - uint32_t c1 = c & (uint32_t)~UTF8_FOUR_BYTES_MASK; - if (c1 == 0 && c2 < 0x10 && enforceValidUtf8Encoding) + uint32_t c1 = c & (uint32_t)~UTF8_THREE_BYTES_MASK; + if (c1 == 0 && c2 < 0x20 && enforceValidUtf8Encoding) { // overlong encoding. throw_encoding_error(); } - uc = (c1 << 18) | (c2 << 12) | (c3 << 6) | c4; - } else { - // outside legal UCS range. - throw_encoding_error(); + + uc = (c1) << 12 | (c2 << 6) | c3; + } + else + { + uint32_t c4 = continuation_byte(p, v.end()); + if ((c & UTF8_FOUR_BYTES_MASK) == UTF8_FOUR_BYTES_BITS) + { + uint32_t c1 = c & (uint32_t)~UTF8_FOUR_BYTES_MASK; + if (c1 == 0 && c2 < 0x10 && enforceValidUtf8Encoding) + { + // overlong encoding. + throw_encoding_error(); + } + uc = (c1 << 18) | (c2 << 12) | (c3 << 6) | c4; + } else { + // outside legal UCS range. + throw_encoding_error(); + } } } } + } catch (const std::exception &e) { + // invalid UTF-8 sequence. + os << "\\uFFFD"; // replacement character for invalid sequences. + continue; } - if ((uc >= UTF16_SURROGATE_1_BASE && uc <= UTF16_SURROGATE_1_BASE + UTF16_SURROGATE_MASK) || (uc >= UTF16_SURROGATE_2_BASE && uc <= UTF16_SURROGATE_2_BASE + UTF16_SURROGATE_MASK)) - { - // MUST not encode UTF16 surrogates in UTF8. - throw_encoding_error(); - } + // if ((uc >= UTF16_SURROGATE_1_BASE && uc <= UTF16_SURROGATE_1_BASE + UTF16_SURROGATE_MASK) || (uc >= UTF16_SURROGATE_2_BASE && uc <= UTF16_SURROGATE_2_BASE + UTF16_SURROGATE_MASK)) + // { + // // MUST not encode UTF16 surrogates in UTF8. + // throw_encoding_error(); + // } if (uc == '"' || uc == '\\') { @@ -268,6 +276,29 @@ void json_reader::skip_whitespace() } } +static void utf32_to_utf8_stream(std::ostream &s, uint32_t uc) +{ + if (uc < 0x80u) + { + s << (char)uc; + } else if (uc < 0x800u) { + s << (char)(0xC0 + (uc >> 6)); + s << (char)(0x80 + (uc & 0x3F)); + + } else if (uc < 0x10000u) { + s << (char)(0xE0 + (uc >> 12)); + s << (char)(0x80 + ((uc >> 6) & 0x3F)); + s << (char)(0x80 + (uc & 0x3F)); + } else if (uc < 0x0110000) { + s << (char)(0xF0 + (uc >> 18)); + s << (char)(0x80 + ((uc >> 12) & 0x3F)); + s << (char)(0x80 + ((uc >> 6) & 0x3F)); + s << (char)(0x80 + (uc & 0x3F)); + } else { + throw std::range_error("Illegal UTF-32 character."); + } +} + std::string json_reader::read_string() { // To completely normalize UTF-32 values we must covert to UTF-16, resolve surrogate pairs, and then convert UTF-32 to UTF-8. @@ -344,7 +375,7 @@ std::string json_reader::read_string() } uc = ((uc & UTF16_SURROGATE_MASK) << 10) + (uc2 & UTF16_SURROGATE_MASK) + 0x10000U; } - HtmlHelper::utf32_to_utf8_stream(s, uc); + utf32_to_utf8_stream(s, uc); } break; } diff --git a/PiPedalCommon/src/json_variant.cpp b/PiPedalCommon/src/json_variant.cpp index aa1b385..6469a78 100644 --- a/PiPedalCommon/src/json_variant.cpp +++ b/PiPedalCommon/src/json_variant.cpp @@ -50,6 +50,9 @@ void json_variant::free() case ContentType::Array: memArray().std::shared_ptr::~shared_ptr(); break; + default: + break; + } content_type = ContentType::Null; } @@ -467,6 +470,15 @@ json_variant &json_variant::operator=(json_variant &&value) case ContentType::Object: std::swap(this->memObject(), value.memObject()); return *this; + case ContentType::Null: + return *this; // nothing to do. + case ContentType::Bool: + std::swap(this->content.bool_value, value.content.bool_value); + return *this; + case ContentType::Number: + std::swap(this->content.double_value, value.content.double_value); + return *this; + } } free(); diff --git a/PiPedalCommon/src/util.cpp b/PiPedalCommon/src/util.cpp index 08de1f2..828ccf5 100644 --- a/PiPedalCommon/src/util.cpp +++ b/PiPedalCommon/src/util.cpp @@ -190,6 +190,22 @@ std::filesystem::path pipedal::MakeRelativePath(const std::filesystem::path &pat } +bool pipedal::HasDotDot(const std::filesystem::path &path) +{ + for (auto &part : path) + { + if (part == "..") + { + return true; + } + if (part == ".") + { + return true; + } + } + return false; +} + bool pipedal::IsSubdirectory(const std::filesystem::path &path, const std::filesystem::path &basePath) { auto iPath = path.begin(); @@ -216,3 +232,13 @@ bool pipedal::IsSubdirectory(const std::filesystem::path &path, const std::files } return true; } + + +bool pipedal::HasWritePermissions(const std::filesystem::path &path) +{ + // posix, but may not work on windows. + // allegedly windows provies an _access function, which is probably a superset of + // access. + return access(path.c_str(), W_OK) == 0; +} + diff --git a/README.md b/README.md index f68d45a..8bcd442 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ -Download: v1.4.76 +Download: v1.4.77 Website: [https://rerdavies.github.io/pipedal](https://rerdavies.github.io/pipedal). Documentation: [https://rerdavies.github.io/pipedal/Documentation.html](https://rerdavies.github.io/pipedal/Documentation.html). -#### NEW version 1.4.76 Release. See the [release notes](https://rerdavies.github.io/pipedal/ReleaseNotes) for details. New Phaser and Graphic Equalizer plugins. +#### NEW version 1.4.77 Release. See the [release notes](https://rerdavies.github.io/pipedal/ReleaseNotes) for details. New Phaser and Graphic Equalizer plugins.   diff --git a/artifacts/Pi-Logo-4.svgz b/artifacts/Pi-Logo-4.svgz new file mode 100644 index 0000000..45b57cf Binary files /dev/null and b/artifacts/Pi-Logo-4.svgz differ diff --git a/artifacts/Pi-Logo-Tweed.svgz b/artifacts/Pi-Logo-Tweed.svgz new file mode 100644 index 0000000..910b075 Binary files /dev/null and b/artifacts/Pi-Logo-Tweed.svgz differ diff --git a/artifacts/Pi-Logo-Tweed2.svgz b/artifacts/Pi-Logo-Tweed2.svgz new file mode 100644 index 0000000..b243b65 Binary files /dev/null and b/artifacts/Pi-Logo-Tweed2.svgz differ diff --git a/artifacts/Pi-Logo-white.svgz b/artifacts/Pi-Logo-white.svgz new file mode 100644 index 0000000..a986454 Binary files /dev/null and b/artifacts/Pi-Logo-white.svgz differ diff --git a/artifacts/TestTracks/Track1.mp3 b/artifacts/TestTracks/Track1.mp3 new file mode 100644 index 0000000..a319312 Binary files /dev/null and b/artifacts/TestTracks/Track1.mp3 differ diff --git a/artifacts/TestTracks/Track2.mp3 b/artifacts/TestTracks/Track2.mp3 new file mode 100644 index 0000000..b5750cc Binary files /dev/null and b/artifacts/TestTracks/Track2.mp3 differ diff --git a/artifacts/TestTracks/Track3.mp3 b/artifacts/TestTracks/Track3.mp3 new file mode 100644 index 0000000..8399af7 Binary files /dev/null and b/artifacts/TestTracks/Track3.mp3 differ diff --git a/artifacts/inkscape Pipedal Logo Tweed.svgz b/artifacts/inkscape Pipedal Logo Tweed.svgz new file mode 100644 index 0000000..20bdf34 Binary files /dev/null and b/artifacts/inkscape Pipedal Logo Tweed.svgz differ diff --git a/artifacts/missing_thumbnail.svgz b/artifacts/missing_thumbnail.svgz new file mode 100644 index 0000000..450410b Binary files /dev/null and b/artifacts/missing_thumbnail.svgz differ diff --git a/cmake/FindPipeWire.cmake b/cmake/FindPipeWire.cmake new file mode 100644 index 0000000..c90c3e0 --- /dev/null +++ b/cmake/FindPipeWire.cmake @@ -0,0 +1,122 @@ +#.rst: +# FindPipeWire +# ------- +# +# Try to find PipeWire on a Unix system. +# +# This will define the following variables: +# +# ``PipeWire_FOUND`` +# True if (the requested version of) PipeWire is available +# ``PipeWire_VERSION`` +# The version of PipeWire +# ``PipeWire_LIBRARIES`` +# This can be passed to target_link_libraries() instead of the ``PipeWire::PipeWire`` +# target +# ``PipeWire_INCLUDE_DIRS`` +# This should be passed to target_include_directories() if the target is not +# used for linking +# ``PipeWire_DEFINITIONS`` +# This should be passed to target_compile_options() if the target is not +# used for linking +# +# If ``PipeWire_FOUND`` is TRUE, it will also define the following imported target: +# +# ``PipeWire::PipeWire`` +# The PipeWire library +# +# In general we recommend using the imported target, as it is easier to use. +# Bear in mind, however, that if the target is in the link interface of an +# exported library, it must be made available by the package config file. + +#============================================================================= +# Copyright 2014 Alex Merry +# Copyright 2014 Martin Gräßlin +# Copyright 2018-2020 Jan Grulich +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. The name of the author may not be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +#============================================================================= + +# Use pkg-config to get the directories and then use these values +# in the FIND_PATH() and FIND_LIBRARY() calls +find_package(PkgConfig QUIET) + +pkg_search_module(PKG_PipeWire QUIET libpipewire-0.3 libpipewire-0.2) +pkg_search_module(PKG_Spa QUIET libspa-0.2 libspa-0.1) + +set(PipeWire_DEFINITIONS "${PKG_PipeWire_CFLAGS}" "${PKG_Spa_CFLAGS}") +set(PipeWire_VERSION "${PKG_PipeWire_VERSION}") + +find_path(PipeWire_INCLUDE_DIRS + NAMES + pipewire/pipewire.h + HINTS + ${PKG_PipeWire_INCLUDE_DIRS} + ${PKG_PipeWire_INCLUDE_DIRS}/pipewire-0.3 +) + +find_path(Spa_INCLUDE_DIRS + NAMES + spa/param/props.h + HINTS + ${PKG_Spa_INCLUDE_DIRS} + ${PKG_Spa_INCLUDE_DIRS}/spa-0.2 +) + +find_library(PipeWire_LIBRARIES + NAMES + pipewire-0.3 + pipewire-0.2 + HINTS + ${PKG_PipeWire_LIBRARY_DIRS} +) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(PipeWire + FOUND_VAR + PipeWire_FOUND + REQUIRED_VARS + PipeWire_LIBRARIES + PipeWire_INCLUDE_DIRS + Spa_INCLUDE_DIRS + VERSION_VAR + PipeWire_VERSION +) + +if(PipeWire_FOUND AND NOT TARGET PipeWire::PipeWire) + add_library(PipeWire::PipeWire UNKNOWN IMPORTED) + set_target_properties(PipeWire::PipeWire PROPERTIES + IMPORTED_LOCATION "${PipeWire_LIBRARIES}" + INTERFACE_COMPILE_OPTIONS "${PipeWire_DEFINITIONS}" + INTERFACE_INCLUDE_DIRECTORIES "${PipeWire_INCLUDE_DIRS};${Spa_INCLUDE_DIRS}" + ) +endif() + +mark_as_advanced(PipeWire_LIBRARIES PipeWire_INCLUDE_DIRS) + +include(FeatureSummary) +set_package_properties(PipeWire PROPERTIES + URL "https://www.pipewire.org" + DESCRIPTION "PipeWire - multimedia processing" +) \ No newline at end of file diff --git a/docs/BuildPrerequisites.md b/docs/BuildPrerequisites.md index 40adbc5..d3c9d28 100644 --- a/docs/BuildPrerequisites.md +++ b/docs/BuildPrerequisites.md @@ -26,7 +26,8 @@ Run the following commands to install dependent libraries required by the PiPeda libsystemd-dev catch libasound2-dev uuid-dev \ authbind libavahi-client-dev libnm-dev libicu-dev \ libsdbus-c++-dev libzip-dev google-perftools \ - libgoogle-perftools-dev + libgoogle-perftools-dev \ + libpipewire-0.3-dev ### Installing Sources diff --git a/docs/Installing.md b/docs/Installing.md index 1e807ec..a5dcf29 100644 --- a/docs/Installing.md +++ b/docs/Installing.md @@ -13,18 +13,18 @@ page_icon: img/Install4.jpg Download the most recent Debian (.deb) package for your platform: -- [Raspberry Pi OS bookworm (aarch64) v1.4.76](https://github.com/rerdavies/pipedal/releases/download/v1.4.76/pipedal_1.4.76_arm64.deb) -- [Ubuntu 24.x (aarch64) v1.4.76](https://github.com/rerdavies/pipedal/releases/download/v1.4.76/pipedal_1.4.76_arm64.deb) -- [Ubuntu 24.x (amd64) v1.4.76](https://github.com/rerdavies/pipedal/releases/download/v1.4.76/pipedal_1.4.76_amd64.deb) +- [Raspberry Pi OS bookworm (aarch64) v1.4.77](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_arm64.deb) +- [Ubuntu 24.x (aarch64) v1.4.77](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_arm64.deb) +- [Ubuntu 24.x (amd64) v1.4.77](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_amd64.deb) -Version 1.4.76 has been tested on Raspberry Pi OS bookworm, Ubuntu 24.04 (amd64), and Ubuntu 24.10 (aarch64). Download the appropriate package for your platform, and install using the following procedure: +Version 1.4.77 has been tested on Raspberry Pi OS bookworm, Ubuntu 24.04 (amd64), and Ubuntu 24.10 (aarch64). Download the appropriate package for your platform, and install using the following procedure: ``` sudo apt update sudo apt upgrade cd ~/Downloads - sudo apt-get install ./pipedal_1.4.76_arm64.deb + sudo apt-get install ./pipedal_1.4.77_arm64.deb ``` You MUST use `apt-get`. `apt` will not install downloaded packages; and `dpkg -i` will not install dependencies. diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 7a2a062..423cfad 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -1,4 +1,57 @@ # Release Notes +## PiPedal 1.4.77 Beta +- **TooB Player**. Play audio files. + +New TooB Player plugin; improvements to MIDI input handling; and a slew of fit-and finish +improvements to controls in the Pipedal user interface. All in all, a surprisingly large +release. + +The big features for this release + +- a new TooB Player plugin that allows you to play (and loop) audio files. + +- Switch from ALSA rawmidi devices to ALSA sequencer devices for MIDI input. This provides + a number of useful improvements. PiPedal now supports Bluetooth MIDI controllers. + PiPedal provides better support for subdevices (multiple MIDI connections on the same device). + Connections to the PiPedal MIDI input port can now be made at runtime by other applications, or + by using`aconnect`. Changing MIDI devices does not require a restart of the audio stream. Disconnected + MIDI devices that have been selected will now be automatically reconnected when they are + plugged in again. + +- MIDI bindings allow users to specify minimum and maximum control values, in order to better support expression pedals. + +- long-press and hover Tooltips are now displayed for all controls in the PiPedal UI. + +- Editable plugin labels. You can now edit the labels of plugins in the PiPedal UI. Tap the plugin label to override + the default label with a custom label. Useful when you have multiple instances of the same plugin in a preset. + +- Multi-select support in file browser dialogs. Allows you to easily delete, copy or move multiple files at once. + +- Control values are displayed in a fly-out when changing contrl values, which makes it easier to determine the + current value of a control when using touch user interfaces. + +- Direct link to TONE3000 website from the TooB Neural Amp Modeler file browser. + +- Double-tap guestures now supported in touch user interfaces. Double-tap a dial to reset it to its default value. Double-tap + a file in the File Browser to select it. Double-tap a plugin label to edit it. + +- Improvements to long-press handling in touch and mouse user interfaces. Long-press a control to display + a tooltip for the control, and triggers multi-select in the file browser dialog. + +- Audio files in the Tracks directory (only) are displayed using audio-file metadata and artwork if available. + +Minor bug fixes: + +- WiFi scanning is only performed when required by the current Auto-Hotspot + configuration. WiFi is not turned on when PiPedal starts, unless the Auto-Hotspot configuration requires it. + +- MIDI controls continue to operate if the current audio stream has stopped. + +- Audio files with invalid UTF-8 sequences in their names are ignored (instead of causing crashes). + +- Media files that are referenced in snapshots are now included in preset and bank file bundles. + +- Changed all references to tonehunt.org to tone3000.com (tonehunt's new name). ## PiPedal 1.4.76 Release - **TooB Phaser**. A loose emulation of the MXR Phase 90 phaser. diff --git a/docs/download.md b/docs/download.md index 5394325..6e4fb8e 100644 --- a/docs/download.md +++ b/docs/download.md @@ -4,9 +4,9 @@ Download the most recent Debian (.deb) package for your platform: -- [Raspberry Pi OS bookworm (aarch64) v1.4.76 Beta](https://github.com/rerdavies/pipedal/releases/download/v1.4.76/pipedal_1.4.76_arm64.deb) -- [Ubuntu 24.x (aarch64) v1.4.76 Alpha](https://github.com/rerdavies/pipedal/releases/download/v1.4.76/pipedal_1.4.76_arm64.deb) -- [Ubuntu 24.x (amd64) v1.4.76 Alpha](https://github.com/rerdavies/pipedal/releases/download/v1.4.76/pipedal_1.4.76_amd64.deb) +- [Raspberry Pi OS bookworm (aarch64) v1.4.77 Beta](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_arm64.deb) +- [Ubuntu 24.x (aarch64) v1.4.77 Alpha](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_arm64.deb) +- [Ubuntu 24.x (amd64) v1.4.77 Alpha](https://github.com/rerdavies/pipedal/releases/download/v1.4.77/pipedal_1.4.77_amd64.deb) Install the package by running @@ -14,7 +14,7 @@ Install the package by running ``` sudo apt update cd ~/Downloads - sudo apt-get install ./pipedal_1.4.76_arm64.deb + sudo apt-get install ./pipedal_1.4.77_arm64.deb ``` You MUST use `apt-get` to install the package. `apt install` will NOT install the package correctly. The message about missing permissions given by `apt-get` is expected, and can be safely ignored. diff --git a/docs/img/ubuntu.jpg b/docs/img/ubuntu.jpg index 419db41..27e2326 100644 Binary files a/docs/img/ubuntu.jpg and b/docs/img/ubuntu.jpg differ diff --git a/lv2/aarch64/ToobAmp.lv2/CabIR.ttl b/lv2/aarch64/ToobAmp.lv2/CabIR.ttl index 5634996..a5cdcb5 100644 --- a/lv2/aarch64/ToobAmp.lv2/CabIR.ttl +++ b/lv2/aarch64/ToobAmp.lv2/CabIR.ttl @@ -93,7 +93,7 @@ cabir:impulseFile3 doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ TooB Cab IR is a convolution-based guitar cabinet impulse response simulator. diff --git a/lv2/aarch64/ToobAmp.lv2/CabSim.ttl b/lv2/aarch64/ToobAmp.lv2/CabSim.ttl index 04e2903..70ba918 100644 --- a/lv2/aarch64/ToobAmp.lv2/CabSim.ttl +++ b/lv2/aarch64/ToobAmp.lv2/CabSim.ttl @@ -50,7 +50,7 @@ toob:frequencyResponseVector doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; mod:brand "TooB"; mod:label "TooB CabSim"; diff --git a/lv2/aarch64/ToobAmp.lv2/ConvolutionReverb.ttl b/lv2/aarch64/ToobAmp.lv2/ConvolutionReverb.ttl index 441992c..3165808 100644 --- a/lv2/aarch64/ToobAmp.lv2/ConvolutionReverb.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ConvolutionReverb.ttl @@ -53,7 +53,7 @@ toobimpulse:impulseFile doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ Convolution reverb is a notoriously compute-intensive effect. If you are having performance issues, use the Max T control to constrain the length of the impulse file to diff --git a/lv2/aarch64/ToobAmp.lv2/ConvolutionReverbStereo.ttl b/lv2/aarch64/ToobAmp.lv2/ConvolutionReverbStereo.ttl index e77c07d..a9ff502 100644 --- a/lv2/aarch64/ToobAmp.lv2/ConvolutionReverbStereo.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ConvolutionReverbStereo.ttl @@ -51,7 +51,7 @@ toobimpulse:impulseFile doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ diff --git a/lv2/aarch64/ToobAmp.lv2/InputStage.ttl b/lv2/aarch64/ToobAmp.lv2/InputStage.ttl index 0872580..6ec1c19 100644 --- a/lv2/aarch64/ToobAmp.lv2/InputStage.ttl +++ b/lv2/aarch64/ToobAmp.lv2/InputStage.ttl @@ -66,7 +66,7 @@ inputStage:filterGroup doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; mod:brand "TooB"; mod:label "TooB Input"; diff --git a/lv2/aarch64/ToobAmp.lv2/PowerStage2.ttl b/lv2/aarch64/ToobAmp.lv2/PowerStage2.ttl index da5f078..eeede7d 100644 --- a/lv2/aarch64/ToobAmp.lv2/PowerStage2.ttl +++ b/lv2/aarch64/ToobAmp.lv2/PowerStage2.ttl @@ -68,7 +68,7 @@ pstage:stage3 doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; mod:brand "TooB"; mod:label "Power Stage"; diff --git a/lv2/aarch64/ToobAmp.lv2/SpectrumAnalyzer.ttl b/lv2/aarch64/ToobAmp.lv2/SpectrumAnalyzer.ttl index 6385edf..70bda8c 100644 --- a/lv2/aarch64/ToobAmp.lv2/SpectrumAnalyzer.ttl +++ b/lv2/aarch64/ToobAmp.lv2/SpectrumAnalyzer.ttl @@ -57,7 +57,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment "TooB spectrum analyzer" ; mod:brand "TooB"; diff --git a/lv2/aarch64/ToobAmp.lv2/ToneStack.ttl b/lv2/aarch64/ToobAmp.lv2/ToneStack.ttl index 7be30fd..f9d51b9 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToneStack.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToneStack.ttl @@ -57,7 +57,7 @@ tonestack:eqGroup doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; uiext:ui ; @@ -131,17 +131,28 @@ tone stack used in Polytone and HiWatt amps. rdfs:label "Baxandall" ; rdf:value 2.0 ]; + ],[ + a lv2:InputPort , + lv2:ControlPort ; + + lv2:index 4 ; + lv2:symbol "gain" ; + lv2:name "Gain"; + lv2:default 0.0 ; + lv2:minimum -40.0 ; + lv2:maximum 30.0; + units:unit units:db; ], [ a lv2:AudioPort , lv2:InputPort ; - lv2:index 4 ; + lv2:index 5 ; lv2:symbol "in" ; lv2:name "In" ], [ a lv2:AudioPort , lv2:OutputPort ; - lv2:index 5 ; + lv2:index 6 ; lv2:symbol "out" ; lv2:name "Out" ],[ @@ -150,7 +161,7 @@ tone stack used in Polytone and HiWatt amps. atom:bufferType atom:Sequence ; # atom:supports patch:Message; lv2:designation lv2:control ; - lv2:index 6 ; + lv2:index 7 ; lv2:symbol "control" ; lv2:name "Control" ; rdfs:comment "Plugin to GUI communication" ; @@ -160,7 +171,7 @@ tone stack used in Polytone and HiWatt amps. atom:bufferType atom:Sequence ; # atom:supports patch:Message; lv2:designation lv2:control ; - lv2:index 7 ; + lv2:index 8 ; lv2:symbol "notify" ; lv2:name "Notify" ; rdfs:comment "Plugin to GUI communication" ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobAmp.so b/lv2/aarch64/ToobAmp.lv2/ToobAmp.so index da96e67..b6357f6 100644 Binary files a/lv2/aarch64/ToobAmp.lv2/ToobAmp.so and b/lv2/aarch64/ToobAmp.lv2/ToobAmp.so differ diff --git a/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so b/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so index ed6ce54..92c76eb 100644 Binary files a/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so and b/lv2/aarch64/ToobAmp.lv2/ToobAmpUI.so differ diff --git a/lv2/aarch64/ToobAmp.lv2/ToobChorus.ttl b/lv2/aarch64/ToobAmp.lv2/ToobChorus.ttl index 299f637..44e6b0b 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobChorus.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobChorus.ttl @@ -40,7 +40,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ Emulation of a Boss CE-2 Chorus. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobDelay.ttl b/lv2/aarch64/ToobAmp.lv2/ToobDelay.ttl index 84ea5bc..97573a7 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobDelay.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobDelay.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ A straightforward no-frills digital delay. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobFlanger.ttl b/lv2/aarch64/ToobAmp.lv2/ToobFlanger.ttl index dfdb899..7b2b30d 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobFlanger.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobFlanger.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ Emulation of a Boss BF-2 Flanger, based on circuit analysis and simulation of the original. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobFlangerStereo.ttl b/lv2/aarch64/ToobAmp.lv2/ToobFlangerStereo.ttl index 46f8776..5a85fdd 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobFlangerStereo.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobFlangerStereo.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ Digital emulation of a Boss BF-2 Flanger. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobFreeverb.ttl b/lv2/aarch64/ToobAmp.lv2/ToobFreeverb.ttl index 4e4202e..94a7c65 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobFreeverb.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobFreeverb.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ Toob Freeverb is an Lv2 implementation of the famous Freeverb reverb effect. FreeVerb delivers a well-balanced reverb with very little tonal coloration. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobGraphicEq.ttl b/lv2/aarch64/ToobAmp.lv2/ToobGraphicEq.ttl index 9279d63..fcf298c 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobGraphicEq.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobGraphicEq.ttl @@ -41,7 +41,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ A 7 octave graphic equalizer, with frequencies chosen to be useful for EQ-ing guitar signals. """ ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobLooperFour.ttl b/lv2/aarch64/ToobAmp.lv2/ToobLooperFour.ttl index 6bae997..cb2c508 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobLooperFour.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobLooperFour.ttl @@ -92,7 +92,7 @@ myprefix:output_group doap:license ; doap:maintainer ; lv2:minorVersion 1 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; ui:ui ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobLooperOne.ttl b/lv2/aarch64/ToobAmp.lv2/ToobLooperOne.ttl index e1fd778..9efd0ca 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobLooperOne.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobLooperOne.ttl @@ -78,7 +78,7 @@ myprefix:output_group doap:license ; doap:maintainer ; lv2:minorVersion 1 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; ui:ui ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobML.ttl b/lv2/aarch64/ToobAmp.lv2/ToobML.ttl index be6b362..5e6e3ee 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobML.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobML.ttl @@ -67,7 +67,7 @@ toobml:sagGroup doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ The TooB ML Amplifier plugin provides emulation of a variety of amplifiers and overdrive pedals that are implemented using neural-network-based machine learning models of real amplifiers. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobMix.ttl b/lv2/aarch64/ToobAmp.lv2/ToobMix.ttl index 4202334..2e397d4 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobMix.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobMix.ttl @@ -40,7 +40,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ Remix a stereo input signal. @@ -57,7 +57,7 @@ Remix a stereo input signal. lv2:index 0 ; lv2:symbol "trimL" ; - lv2:name "Trim L"; + lv2:name "Vol L"; lv2:default 0.0 ; lv2:minimum -60.0 ; @@ -89,7 +89,7 @@ Remix a stereo input signal. lv2:index 2 ; lv2:symbol "trimR" ; - lv2:name "Trim R"; + lv2:name "Vol R"; lv2:default 0.0 ; lv2:minimum -60.0 ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobNeuralAmpModeler.ttl b/lv2/aarch64/ToobAmp.lv2/ToobNeuralAmpModeler.ttl index bd4bf6b..5355066 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobNeuralAmpModeler.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobNeuralAmpModeler.ttl @@ -61,33 +61,43 @@ toobNam:eqGroup doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ A port of Steven Atkinson's Neural Amp Modeler to LV2. -TooB Neural Amp Modeler uses uploadable .nam model files. Download .nam files from http://tonehunt.org, and then load them into TooB Neural Amp Modeler. +TooB Neural Amp Modeler uses uploadable .nam model files. Download .nam model files from http://tone3000.com, and then load them into TooB Neural Amp Modeler. -If you are using TooB Neural Amp Modeler from PiPedal, download the model files to the system on which you are running the client. You must then -upload the file to the PiPedal server. Click on the "Model" control and then search for the Upload button in the file browser. Once uploaded to the server, -you can then select the uploaded file in the browser directly. +The TONE3000 website contains a huge collection of community-developed amp models that can be used with TooB Neural Amp Modeler. You can also use .nam models from +other sources. Using TONE3000 models with TooB Neural Amp Modeler is a two-step process. First, download model files to your local system using a +web browser. Then upload the model files to the PiPedal server using the PiPedal web interface. You can find an "Upload" button in +the file browser when you click on the "Model" control in the PiPedal web interface. -If you are not using PiPedal, just click on the Model control, and use the file browser to select the .nam file on your local system. -TooB Neural Amp Modeler supports a much wider range of amp models than ToobML, but usually uses much more CPU. +If you are not using PiPedal, just click on the Model control, and use the file browser to select the .nam file directly on your local system. + +TooB Neural Amp Modeler supports a much wider range of amp models than ToobML, but usually uses more CPU. You will need at least a Pi 4 to use Toob Neural Amp Modeler, and you may need to increase your audio buffer sizes to prevent overruns. -If you are having trouble with CPU usage, tonehunt.org does contain some smaller amp models. Search for the "feather" tag to find -models that use less CPU. +If you are having trouble with CPU usage, tone3000.com does contain some smaller amp models. Search for the "feather" tag to find +models that use less CPU. Generally, you can use up to three NAM models at once in a given preset on a Pi 4, and up to 5 MAM models +simultaneously on a Pi 5. N100 micro PCs could presumably do even better. However, exact CPU use varies depending on the complexity of the amp models you are using. +Users have reported success using NAM on Raspberry Pi 3 devices, but this is not recommended (or really supported), as CPU usage is very high on a Raspberry Pi 3. +If you are interested in profiling your own amps and effect pedals, please visit https://www.tone3000.com/capture -If you are interested in building your own amp models, please visit https://www.neuralampmodeler.com/ +TooB Neural Amp Modeler contains code optimizations that allow use of a third model on Raspberry PI 4 devices. These optimizations +were offered to the upstream NAM project, but we recommended that they not be merged into upstream sources, because they +significantly affect maintainability of the codebase. and the performance increase they provide (about 30%) is really only +relevant on Raspberry PI 4-class devices. If you are interested in these optimizations, they have been published in ToobAmp +project sources on GitHub. -TooB Neural Amp Modeler uses code from the NeuralAmp Modeler Core project. The TooB Team wishes to express gratitude to Steven Atkinson for -making this extraordinary technology available as open-source code. +TooB Neural Amp Modeler uses code from the NeuralAmp Modeler Core project (https://www.neuralampmodeler.com/). The TooB team wishes +to express gratitude to Steven Atkinson for making this revolutionary technology available as open-source code. -Code from the the NeuralAmpModelerCore project (https://github.com/sdatkinson/NeuralAmpModelerCore) is provided under the following license. +Code from the the NeuralAmpModelerCore project (https://github.com/sdatkinson/NeuralAmpModelerCore) is provided +under the following license. MIT License -Copyright (c) 2023 Steven Atkinson +Copyright (c) 2025 Steven Atkinson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lv2/aarch64/ToobAmp.lv2/ToobNoiseGate.ttl b/lv2/aarch64/ToobAmp.lv2/ToobNoiseGate.ttl index 485851d..932ca56 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobNoiseGate.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobNoiseGate.ttl @@ -54,7 +54,7 @@ noisegate:envelope_group doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ A noise gate is an audio processing tool that controls the volume of an audio signal by allowing it to pass through only when it exceeds a set threshold. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobPhaser.ttl b/lv2/aarch64/ToobAmp.lv2/ToobPhaser.ttl index 5598a62..6963a38 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobPhaser.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobPhaser.ttl @@ -40,7 +40,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ A loose emulation of an MXR® Phase 90 Phaser. diff --git a/lv2/aarch64/ToobAmp.lv2/ToobPlayer.ttl b/lv2/aarch64/ToobAmp.lv2/ToobPlayer.ttl new file mode 100644 index 0000000..000489f --- /dev/null +++ b/lv2/aarch64/ToobAmp.lv2/ToobPlayer.ttl @@ -0,0 +1,307 @@ +@prefix doap: . +@prefix lv2: . +@prefix rdf: . +@prefix rdfs: . +@prefix units: . +@prefix urid: . +@prefix atom: . +@prefix midi: . +@prefix epp: . +@prefix uiext: . +@prefix idpy: . +@prefix foaf: . +@prefix mod: . +@prefix param: . +@prefix work: . +@prefix pg: . + +@prefix atom: . +@prefix patch: . +@prefix rdfs: . +@prefix state: . +@prefix urid: . +@prefix xsd: . +@prefix toobPlayer: . + +toobPlayer:mixGroup + a param:ControlGroup , + pg:InputGroup ; + lv2:name "Mix" ; + lv2:symbol "mixGroup" . + + + + a foaf:Person ; + foaf:name "Robin Davies" ; + foaf:mbox ; + foaf:homepage . + + +toobPlayer:audioFile + a lv2:Parameter; + rdfs:label "File"; + mod:fileTypes "audiotrack,wav,flac,mp3"; + rdfs:range atom:Path; + lv2:index 4 + . +toobPlayer:seek + a lv2:Parameter; + rdfs:label "Seek"; + rdfs:range atom:Float; + . + + + + a lv2:Plugin , + lv2:GeneratorPlugin ; + doap:name "TooB File Player" , + "TooB File Player"@en-gb + ; + doap:license ; + doap:maintainer ; + lv2:minorVersion 0 ; + lv2:microVersion 63 ; + rdfs:comment """ +Play audio files. Audio files can optionally be loooped by pressing the "Set loop" button. + +To skip the start of a file, press the "Set Loop" button, set the "Start" value in the seek dialog, and leave the "Loop Enabled" +checkbox unchecked. + +Note that loop information is stored as per-file metadata, and is not stored in the preset. If you need different loop settings for the +same file, create a copy of the file. Files are copied using hard links, so the copy will not take up significant disk space. + + +TooB Player will display audio metadata for a file (track number, title, album, artist) if available. +If the file's metadata contains album art, that will be displayed as well. If not, the player will look for a folder artwork file in the same directory. The +folder artwork file can be called any of "Folder.jpg", "Cover.jpg", "AlbumArt.jpg" or a number of other common names used by media players and taggers. +You can upload album artwork files using the Upload button in the file browser. The Upload button will allow you to upload audio files, or +folder artwork files that have a .jpg file extension. + + +""" ; + + mod:brand "TooB"; + mod:label "File Player"; + + # ONLY works in PiPedal + lv2:requiredFeature "http://github.com/rerdavies/pipedal#host" ; + + lv2:optionalFeature lv2:hardRTCapable,state::freePath, state:makePath, state:mapPath, work:schedule ; + lv2:extensionData state:interface, work:interface; + + patch:readable + toobPlayer:audioFile,toobPlayer:seek; + patch:writable + toobPlayer:audioFile; + + lv2:port + [ + a lv2:AudioPort , + lv2:InputPort ; + lv2:index 0 ; + lv2:symbol "inl" ; + lv2:name "In L" + ], + [ + a lv2:AudioPort , + lv2:InputPort ; + lv2:index 1 ; + lv2:symbol "inr" ; + lv2:name "In R" + ], + [ + a lv2:AudioPort , + lv2:OutputPort ; + lv2:index 2 ; + lv2:symbol "outl" ; + lv2:name "Out L" + ], + [ + a lv2:AudioPort , + lv2:OutputPort ; + lv2:index 3 ; + lv2:symbol "outr" ; + lv2:name "OutR" + ], + ###################### + [ + a atom:AtomPort , + lv2:InputPort ; + lv2:index 4 ; + atom:bufferType atom:Sequence ; + atom:supports patch:Message; + lv2:designation lv2:control ; + lv2:symbol "controlIn" ; + lv2:name "ControlIn" + ], + [ + a atom:AtomPort , + lv2:OutputPort ; + lv2:index 5 ; + atom:bufferType atom:Sequence ; + atom:supports patch:Message; + lv2:designation lv2:control ; + + lv2:symbol "controlOut" ; + lv2:name "ControlOut" + ], + + ###################### + [ + a lv2:InputPort , + lv2:ControlPort ; + + lv2:index 6; + lv2:symbol "stop" ; + lv2:name "Stop"; + rdf:comment "Stop playback."; + lv2:default 0.0 ; + lv2:minimum 0.0; + lv2:maximum 1.0; + lv2:portProperty lv2:toggled,epp:trigger; + ], + [ + a lv2:InputPort , + lv2:ControlPort ; + + lv2:index 7; + lv2:symbol "pause" ; + lv2:name "Pause"; + rdf:comment "Pause playback."; + lv2:default 0.0 ; + lv2:minimum 0.0; + lv2:maximum 1.0; + lv2:portProperty lv2:toggled,epp:trigger; + ], + [ + a lv2:InputPort , + lv2:ControlPort ; + + lv2:index 8; + lv2:symbol "play" ; + lv2:name "Play"; + rdf:comment "Play selected file."; + lv2:default 0.0 ; + lv2:minimum 0.0; + lv2:maximum 1.0; + lv2:portProperty lv2:toggled,epp:trigger; + ], + + [ + a lv2:InputPort , + lv2:ControlPort ; + + lv2:index 9 ; + lv2:symbol "volIn" ; + lv2:name "Input Vol"; + rdfs:comment "Volume audio passed through from the input. "; + + + lv2:default 0.0 ; + lv2:minimum -40.0 ; + lv2:maximum 30.0 ; + units:unit units:db ; + lv2:scalePoint + [ + rdfs:label "-INF" ; + rdf:value -40.0 + ]; + pg:group toobPlayer:mixGroup ; + + ], + [ + a lv2:InputPort , + lv2:ControlPort ; + + lv2:index 10; + lv2:symbol "panIn" ; + lv2:name "Input Pan"; + rdfs:comment "Pan for passed-through audio."; + + lv2:default 0.0 ; + lv2:minimum -1.0; + lv2:maximum 1.0; + pg:group toobPlayer:mixGroup ; + rdfs:comment "Input Pan"; + ], + [ + a lv2:InputPort , + lv2:ControlPort ; + + lv2:index 11 ; + lv2:symbol "volFile" ; + lv2:name "File Vol"; + rdfs:comment "Volume of the audio file being played."; + + lv2:default 0.0 ; + lv2:minimum -40.0 ; + lv2:maximum 30.0 ; + units:unit units:db ; + lv2:scalePoint + [ + rdfs:label "-INF" ; + rdf:value -40.0 + ]; + pg:group toobPlayer:mixGroup ; + + ], + [ + a lv2:InputPort , + lv2:ControlPort ; + + lv2:index 12; + lv2:symbol "panFile" ; + lv2:name "File Pan"; + rdfs:comment "Pan position of the file being played."; + + lv2:default 0.0 ; + lv2:minimum -1.0; + lv2:maximum 1.0; + pg:group toobPlayer:mixGroup ; + ], + + + + ########################### + [ + a lv2:OutputPort , + lv2:ControlPort ; + + lv2:index 13; + lv2:symbol "state" ; + lv2:name "Play State"; + lv2:portProperty epp:notOnGUI; + + lv2:default 0.0 ; + lv2:minimum 0.0; + lv2:maximum 10.0; + ], + [ + a lv2:OutputPort , + lv2:ControlPort ; + + lv2:index 14; + lv2:symbol "position" ; + lv2:name "Play Position"; + lv2:default 0.0 ; + lv2:minimum 0.0; + lv2:maximum 1.0E+38; + units:unit units:s ; + ], + [ + a lv2:OutputPort , + lv2:ControlPort ; + + lv2:index 15; + lv2:symbol "duration" ; + lv2:name "Duration"; + rdfs:comment "Length of the selected audio file."; + lv2:default 0.0 ; + lv2:minimum 0.0; + lv2:maximum 1.0E+38; + units:unit units:s ; + ] + + . + + diff --git a/lv2/aarch64/ToobAmp.lv2/ToobRecordMono.ttl b/lv2/aarch64/ToobAmp.lv2/ToobRecordMono.ttl index 0d4a731..b127513 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobRecordMono.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobRecordMono.ttl @@ -21,7 +21,6 @@ @prefix urid: . @prefix xsd: . @prefix ui: . -@prefix pprop: . @prefix pipedal_ui: . @@ -52,7 +51,7 @@ recordPrefix:audioFile doap:license ; doap:maintainer ; lv2:minorVersion 1 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; ui:ui ; @@ -61,7 +60,7 @@ recordPrefix:audioFile patch:writable recordPrefix:audioFile; - lv2:extensionData state:interface ; + lv2:extensionData state:interface, work:interface ; rdfs:comment """ Record the plugin's audio input signal to a monophonic file. See also: TooB Record Input (stereo), which records a stereo file. @@ -83,7 +82,7 @@ Record the plugin's audio input signal to a monophonic file. See also: TooB Reco lv2:default 0.0 ; lv2:minimum 0.0; lv2:maximum 1.0; - lv2:portProperty lv2:toggled,pprop:trigger; + lv2:portProperty lv2:toggled,epp:trigger; ], [ a lv2:InputPort , @@ -96,7 +95,7 @@ Record the plugin's audio input signal to a monophonic file. See also: TooB Reco lv2:default 0.0 ; lv2:minimum 0.0; lv2:maximum 1.0; - lv2:portProperty lv2:toggled,pprop:trigger; + lv2:portProperty lv2:toggled,epp:trigger; ], [ a lv2:OutputPort , @@ -117,11 +116,11 @@ Record the plugin's audio input signal to a monophonic file. See also: TooB Reco lv2:index 3; lv2:symbol "play" ; lv2:name "⏵"; - rdfs:comment "Preview recorded file. Click again to stop."; + rdfs:comment "Preview recorded file."; lv2:default 0.0 ; lv2:minimum 0.0; lv2:maximum 1.0; - lv2:portProperty lv2:toggled,pprop:trigger; + lv2:portProperty lv2:toggled,epp:trigger; ], [ a lv2:OutputPort , @@ -186,7 +185,7 @@ Record the plugin's audio input signal to a monophonic file. See also: TooB Reco lv2:index 7; lv2:symbol "level" ; - lv2:name "Level"; + lv2:name "Rec Lvl"; rdfs:comment "Input trim level for recording"; lv2:default 0.0 ; lv2:minimum -60.0; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobRecordStereo.ttl b/lv2/aarch64/ToobAmp.lv2/ToobRecordStereo.ttl index 04fc7d6..b466568 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobRecordStereo.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobRecordStereo.ttl @@ -88,7 +88,7 @@ myprefix:loop3_group doap:license ; doap:maintainer ; lv2:minorVersion 1 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; ui:ui ; @@ -97,7 +97,7 @@ myprefix:loop3_group patch:writable recordPrefix:audioFile; - lv2:extensionData state:interface ; + lv2:extensionData state:interface, work:interface ; rdfs:comment """ @@ -154,7 +154,7 @@ Record the plugin's audio input signal to a stereophonic file. See also: TooB Re lv2:index 3; lv2:symbol "play" ; lv2:name "⏵"; - rdfs:comment "Preview the recorded file. Click again to stop."; + rdfs:comment "Preview the recorded file."; lv2:default 0.0 ; lv2:minimum 0.0; lv2:maximum 1.0; @@ -223,8 +223,8 @@ Record the plugin's audio input signal to a stereophonic file. See also: TooB Re lv2:index 7; lv2:symbol "level" ; - lv2:name "Level"; - lv2:name "Input trim level for recording"; + lv2:name "Rec Lvl"; + rdfs:comment "Input trim level for recording"; lv2:default 0.0 ; lv2:minimum -60.0; lv2:maximum 30.0; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobTuner.ttl b/lv2/aarch64/ToobAmp.lv2/ToobTuner.ttl index 7560605..90b5392 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobTuner.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobTuner.ttl @@ -40,7 +40,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ TooB Tuner is a chromatic guitar tuner. """ ; diff --git a/lv2/aarch64/ToobAmp.lv2/ToobVolume.ttl b/lv2/aarch64/ToobAmp.lv2/ToobVolume.ttl index b3e9e35..d78713b 100644 --- a/lv2/aarch64/ToobAmp.lv2/ToobVolume.ttl +++ b/lv2/aarch64/ToobAmp.lv2/ToobVolume.ttl @@ -31,7 +31,7 @@ a lv2:Plugin , - lv2:UtilityPlugin ; + lv2:MixerPlugin ; doap:name "TooB Volume" , "TooB Volume"@en-gb ; @@ -39,7 +39,7 @@ doap:license ; doap:maintainer ; lv2:minorVersion 0 ; - lv2:microVersion 62 ; + lv2:microVersion 63 ; rdfs:comment """ Volume control. diff --git a/lv2/aarch64/ToobAmp.lv2/manifest.ttl b/lv2/aarch64/ToobAmp.lv2/manifest.ttl index 46a0088..03576f0 100644 --- a/lv2/aarch64/ToobAmp.lv2/manifest.ttl +++ b/lv2/aarch64/ToobAmp.lv2/manifest.ttl @@ -97,6 +97,10 @@ lv2:binary ; rdfs:seeAlso . + a lv2:Plugin ; + lv2:binary ; + rdfs:seeAlso . + a lv2:Plugin ; lv2:binary ; rdfs:seeAlso . diff --git a/lv2/x86_64/toobamp_1.1.62_amd64.deb b/lv2/x86_64/toobamp_1.1.63_amd64.deb similarity index 57% rename from lv2/x86_64/toobamp_1.1.62_amd64.deb rename to lv2/x86_64/toobamp_1.1.63_amd64.deb index bd46f7a..5486602 100644 Binary files a/lv2/x86_64/toobamp_1.1.62_amd64.deb and b/lv2/x86_64/toobamp_1.1.63_amd64.deb differ diff --git a/modules/SQLiteCpp b/modules/SQLiteCpp new file mode 160000 index 0000000..1df7688 --- /dev/null +++ b/modules/SQLiteCpp @@ -0,0 +1 @@ +Subproject commit 1df768817e68529fe47870f8e9913a47343a822d diff --git a/modules/websocketpp b/modules/websocketpp index 2466f9e..508c9b5 160000 --- a/modules/websocketpp +++ b/modules/websocketpp @@ -1 +1 @@ -Subproject commit 2466f9e5f15f6c3f583fb042ff7048a8ad0754a9 +Subproject commit 508c9b5e404df2249e6f0590bc12bd9912889ba4 diff --git a/provisionx64.sh b/provisionx64.sh new file mode 100755 index 0000000..2b175cc --- /dev/null +++ b/provisionx64.sh @@ -0,0 +1,5 @@ +#!/bin/bash +TOOBAMP_DEB=$(ls ../ToobAmp/build/toobamp_*_amd64.deb | sort -V | tail -n1) +echo Provisioning $TOOBAMP_DEB +rm ./lv2/x86_64/*.deb +cp $TOOBAMP_DEB ./lv2/x86_64/ \ No newline at end of file diff --git a/src/AlsaDriver.cpp b/src/AlsaDriver.cpp index bcc4eab..5df7144 100644 --- a/src/AlsaDriver.cpp +++ b/src/AlsaDriver.cpp @@ -144,8 +144,6 @@ namespace pipedal return false; } - - struct AudioFormat { char name[40]; @@ -304,12 +302,9 @@ namespace pipedal AlsaDriverImpl(AudioDriverHost *driverHost) : driverHost(driverHost) { - midiEventMemory.resize(MAX_MIDI_EVENT * MAX_MIDI_EVENT_SIZE); + midiEventMemoryIndex = 0; + midiEventMemory.resize(MIDI_MEMORY_BUFFER_SIZE); midiEvents.resize(MAX_MIDI_EVENT); - for (size_t i = 0; i < midiEvents.size(); ++i) - { - midiEvents[i].buffer = midiEventMemory.data() + i * MAX_MIDI_EVENT_SIZE; - } } virtual ~AlsaDriverImpl() { @@ -415,14 +410,7 @@ namespace pipedal snd_pcm_sw_params_free(playbackSwParams); playbackSwParams = nullptr; } - for (auto &midiState : this->midiDevices) - { - if (midiState) - { - midiState->Close(); - } - } - midiDevices.resize(0); + this->alsaSequencer = nullptr; } std::string discover_alsa_using_apps() @@ -913,7 +901,7 @@ namespace pipedal std::vector &buffers = this->playbackBuffers; int channels = this->playbackChannels; - constexpr double scale = 0x00FFFFFF; + constexpr double scale = 0x00FFFFFF; for (size_t frame = 0; frame < frames; ++frame) { for (int channel = 0; channel < channels; ++channel) @@ -1084,7 +1072,6 @@ namespace pipedal open = true; try { - OpenMidi(jackServerSettings, channelSelection); OpenAudio(jackServerSettings, channelSelection); std::atomic_thread_fence(std::memory_order::release); } @@ -1531,18 +1518,43 @@ namespace pipedal } while (frames > 0); return framesRead; } - + protected: void ReadMidiData(uint32_t audioFrame) { - for (size_t i = 0; i < midiDevices.size(); ++i) + AlsaMidiMessage message; + + midiEventCount = 0; + while(alsaSequencer->ReadMessage(message,0)) { - size_t nRead = midiDevices[i]->ReadMidiEvents( - this->midiEvents, - midiEventCount, - audioFrame); - midiEventCount += nRead; + size_t messageSize = message.size; + if (messageSize == 0) + { + continue; + } + if (midiEventMemoryIndex + messageSize >= this->midiEventMemory.size()) { + continue; + } + if (midiEventCount >= this->midiEvents.size()) { + midiEvents.resize(midiEventCount*2); + } + // for now, prevent META event messages from propagating. + if (message.data[0] == 0xFF && message.size > 1) { + continue; + } + MidiEvent *pEvent = midiEvents.data() + midiEventCount++; + pEvent->time = audioFrame; + pEvent->size = messageSize; + pEvent->buffer = midiEventMemory.data() + midiEventMemoryIndex; + + memcpy( + midiEventMemory.data() + midiEventMemoryIndex, + message.data, + message.size); + midiEventMemoryIndex += messageSize; + } } + private: long WriteBuffer(snd_pcm_t *handle, uint8_t *buf, size_t frames) { @@ -1671,16 +1683,17 @@ namespace pipedal pBuffer[j] = 0; } } - try { + try + { while (!terminateAudio()) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); // zero out input buffers. this->driverHost->OnProcess(this->bufferSize); } - } catch (const std::exception &e) + } + catch (const std::exception &e) { - } } this->driverHost->OnAudioTerminated(); @@ -1766,285 +1779,22 @@ namespace pipedal Lv2Log::debug("Audio thread joined."); } - static constexpr size_t MAX_MIDI_EVENT_SIZE = 3; - static constexpr size_t MIDI_BUFFER_SIZE = 16 * 1024; + static constexpr size_t MIDI_MEMORY_BUFFER_SIZE = 32 * 1024; static constexpr size_t MAX_MIDI_EVENT = 4 * 1024; size_t midiEventCount = 0; std::vector midiEvents; + size_t midiEventMemoryIndex = 0; std::vector midiEventMemory; + AlsaSequencer::ptr alsaSequencer; public: - class AlsaMidiDeviceImpl + + + + virtual void SetAlsaSequencer(AlsaSequencer::ptr alsaSequencer) override { - private: - snd_rawmidi_t *hIn = nullptr; - snd_rawmidi_params_t *hInParams = nullptr; - std::string deviceName; - - // running status state. - uint8_t runningStatus = 0; - int dataLength = 0; - int dataIndex = 0; - size_t statusBytesRemaining = 0; - size_t data0 = 0; - size_t data1 = 0; - - bool inputProcessingSysex = false; - size_t inputSysexBufferCount = 0; - std::vector inputSysexBuffer; - - uint8_t readBuffer[1024]; - - void checkError(int result, const char *message) - { - if (result < 0) - { - throw PiPedalStateException(SS("Unexpected error: " << message << " (" << this->deviceName)); - } - } - - public: - AlsaMidiDeviceImpl() - { - inputSysexBuffer.resize(1024); - } - void Open(const AlsaMidiDeviceInfo &device) - { - runningStatus = 0; - inputProcessingSysex = false; - inputSysexBufferCount = 0; - - dataIndex = 0; - dataLength = 0; - - this->deviceName = device.description_; - - int err = snd_rawmidi_open(&hIn, nullptr, device.name_.c_str(), SND_RAWMIDI_NONBLOCK); - if (err < 0) - { - throw PiPedalStateException(SS("Can't open midi device " << deviceName << ". (" << snd_strerror(err))); - } - - err = snd_rawmidi_params_malloc(&hInParams); - checkError(err, "snd_rawmidi_params_malloc failed."); - - err = snd_rawmidi_params_set_buffer_size(hIn, hInParams, 2048); - checkError(err, "snd_rawmidi_params_set_buffer_size failed."); - - err = snd_rawmidi_params_set_no_active_sensing(hIn, hInParams, 1); - checkError(err, "snd_rawmidi_params_set_no_active_sensing failed."); - } - void Close() - { - if (hIn) - { - snd_rawmidi_close(hIn); - hIn = nullptr; - } - if (hInParams) - { - snd_rawmidi_params_free(hInParams); - hInParams = 0; - } - } - - int GetDataLength(uint8_t cc) - { - static int sDataLength[] = {0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 1, 1, 1, -1}; - return sDataLength[cc >> 4]; - } - - void MidiPut(uint8_t cc, uint8_t d0, uint8_t d1) - { - if (cc == 0) - return; - - // check for overrun. - if (inputEventBufferIndex >= pInputEventBuffer->size()) - { - return; - } - - auto &event = (*pInputEventBuffer)[inputEventBufferIndex]; - - event.time = inputSampleFrame; - event.size = dataLength + 1; - assert(dataLength + 1 <= MAX_MIDI_EVENT_SIZE); - event.buffer[0] = cc; - event.buffer[1] = d0; - event.buffer[2] = d1; - ++inputEventBufferIndex; - } - - void FillInputBuffer() - { - while (true) - { - ssize_t nRead = snd_rawmidi_read(hIn, readBuffer, sizeof(readBuffer)); - if (nRead == -EAGAIN) - return; - if (nRead < 0) - { - checkError(nRead, SS(this->deviceName << "MIDI event read failed. (" << snd_strerror(nRead)).c_str()); - } - ProcessInputBuffer(readBuffer, nRead); // expose write to test code. - } - } - - uint32_t inputSampleFrame = -1; - size_t inputEventBufferIndex; - std::vector *pInputEventBuffer = nullptr; - - size_t ReadMidiEvents( - std::vector &outputBuffer, - size_t startIndex, - uint32_t sampleFrame) - { - inputSampleFrame = sampleFrame; - inputEventBufferIndex = startIndex; - pInputEventBuffer = &outputBuffer; - FillInputBuffer(); - pInputEventBuffer = nullptr; - return inputEventBufferIndex - startIndex; - } - - void FlushSysex() - { - if (inputProcessingSysex) - { - // just discard it. :-/ - // if (this->eventCount != MAX_MIDI_EVENT) - // { - // auto *event = &(events[eventCount++]); - // event->size = this->bufferCount - sysexStartIndex; - // event->buffer = &(this->buffer[this->sysexStartIndex]); - // event->time = 0; - // } - // sysexStartIndex = -1; - } - inputProcessingSysex = false; - } - - int GetSystemCommonLength(uint8_t cc) - { - static int sizes[] = {-1, 1, 2, 1, -1, -1, 0, 0}; - return sizes[(cc >> 4) & 0x07]; - } - void ProcessInputBuffer(uint8_t *readBuffer, size_t nRead) - { - for (ssize_t i = 0; i < nRead; ++i) - { - uint8_t v = readBuffer[i]; - - if (v >= 0x80) - { - if (v >= 0xF0) - { - if (v == 0xF0) - { - inputProcessingSysex = true; - inputSysexBufferCount = 0; - - inputSysexBuffer[inputSysexBufferCount++] = 0xF0; - - runningStatus = 0; // discard subsequent data. - dataLength = -2; // indefinitely. - dataIndex = -1; - } - else if (v >= 0xF8) - { - // don't overwrite running status. - // don't break sysexes on a running status message. - // LV2 standard is ambiguous how realtime messages are handled, so just discard them. - continue; - } - else - { - FlushSysex(); - int length = GetSystemCommonLength(v); - if (length == -1) - break; // ignore illegal messages. - runningStatus = v; - dataLength = length; - dataIndex = 0; - } - } - else - { - FlushSysex(); - int dataLength = GetDataLength(v); - runningStatus = v; - if (dataLength == -1) - { - this->dataLength = dataLength; - dataIndex = -1; - } - else - { - this->dataLength = dataLength; - dataIndex = 0; - } - } - } - else - { - if (inputProcessingSysex) - { - if (inputSysexBufferCount != inputSysexBuffer.size()) - { - inputSysexBuffer[inputSysexBufferCount++] = v; - } - } - else - { - switch (dataIndex) - { - default: - // discard. - break; - case 0: - data0 = v; - dataIndex = 1; - break; - case 1: - data1 = v; - dataIndex = 2; - break; - } - } - } - if (dataIndex == dataLength && dataLength >= 0 && runningStatus != 0) - { - MidiPut(runningStatus, data0, data1); - dataIndex = 0; - } - } - } - }; - - std::vector> midiDevices; - - void OpenMidi(const JackServerSettings &jackServerSettings, const JackChannelSelection &channelSelection) - { - const auto &devices = channelSelection.GetInputMidiDevices(); - - midiDevices.reserve(devices.size()); - - for (size_t i = 0; i < devices.size(); ++i) - { - try - { - const auto &device = devices[i]; - auto midiDevice = std::make_unique(); - midiDevice->Open(device); - midiDevices.push_back(std::move(midiDevice)); - } - catch (const std::exception &e) - { - Lv2Log::error(e.what()); - } - } + this->alsaSequencer = alsaSequencer; } virtual size_t InputBufferCount() const { return activeCaptureBuffers.size(); } diff --git a/src/AudioDriver.hpp b/src/AudioDriver.hpp index 6660761..71b94c2 100644 --- a/src/AudioDriver.hpp +++ b/src/AudioDriver.hpp @@ -25,7 +25,7 @@ #include "JackConfiguration.hpp" #include - +#include "AlsaSequencer.hpp" @@ -74,7 +74,7 @@ namespace pipedal { virtual float*GetOutputBuffer(size_t channe) = 0; virtual void Open(const JackServerSettings & jackServerSettings,const JackChannelSelection &channelSelection) = 0; - + virtual void SetAlsaSequencer(AlsaSequencer::ptr alsaSequencer) = 0; virtual void Activate() = 0; virtual void Deactivate() = 0; virtual void Close() = 0; diff --git a/src/AudioFileMetadata.cpp b/src/AudioFileMetadata.cpp new file mode 100644 index 0000000..b39bf86 --- /dev/null +++ b/src/AudioFileMetadata.cpp @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 "AudioFileMetadata.hpp" + +using namespace pipedal; + +JSON_MAP_BEGIN(AudioFileMetadata) +JSON_MAP_REFERENCE(AudioFileMetadata, duration) +JSON_MAP_REFERENCE(AudioFileMetadata, fileName) +JSON_MAP_REFERENCE(AudioFileMetadata, lastModified) + +JSON_MAP_REFERENCE(AudioFileMetadata, title) +JSON_MAP_REFERENCE(AudioFileMetadata, track) +JSON_MAP_REFERENCE(AudioFileMetadata, album) +JSON_MAP_REFERENCE(AudioFileMetadata, albumArtist) +JSON_MAP_REFERENCE(AudioFileMetadata, artist) + +JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailType) +JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailFile) +JSON_MAP_REFERENCE(AudioFileMetadata, thumbnailLastModified) +JSON_MAP_REFERENCE(AudioFileMetadata, position) + +JSON_MAP_END() diff --git a/src/AudioFileMetadata.hpp b/src/AudioFileMetadata.hpp new file mode 100644 index 0000000..583d2a4 --- /dev/null +++ b/src/AudioFileMetadata.hpp @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 +#include +#include +#include +#include "json.hpp" + + +namespace pipedal { +#define GETTER_SETTER_REF(name) \ + const decltype(name##_) &name() const { return name##_; } \ + void name(const decltype(name##_) &value) { name##_ = value; } + +#define GETTER_SETTER_VEC(name) \ + decltype(name##_) &name() { return name##_; } \ + const decltype(name##_) &name() const { return name##_; } + + // void name(decltype(const name##_) &value) { name##_ = value; } + // void name(decltype(const name##_) &&value) { name##_ = std::move(value); } + +#define GETTER_SETTER(name) \ + decltype(name##_) name() const { return name##_; } \ + void name(decltype(name##_) value) { name##_ = value; } + + + enum ThumbnailType + { + Unknown = 0, + Embedded = 1, // embedded in the file + Folder = 2, // from a albumArt.jpg or similar + None = 3 // Use default thumbnail. + }; + + class AudioFileMetadata + { + private: + std::string fileName_; + int64_t lastModified_ = 0; + std::string title_; + int32_t track_ = -1; // track number, 0-based, -1 if not set + std::string album_; + std::string artist_; + std::string albumArtist_; + float duration_ = 0; + int32_t thumbnailType_ = 0; // 0 = unknown, 1 = embedded, 3 = folder, 4 = none + std::string thumbnailFile_; // only when thumbnailType is 3= folder. + int64_t thumbnailLastModified_ = 0; + int32_t position_ = -1; + + + public: + + AudioFileMetadata() = default; + AudioFileMetadata(const std::filesystem::path &file); + + GETTER_SETTER(fileName) + GETTER_SETTER(lastModified) + GETTER_SETTER_REF(title) + GETTER_SETTER_REF(track) + GETTER_SETTER_REF(album) + GETTER_SETTER_REF(albumArtist) + GETTER_SETTER_REF(artist) + GETTER_SETTER(duration) + ThumbnailType thumbnailType() const { return (ThumbnailType)thumbnailType_;} + void thumbnailType(ThumbnailType value) { thumbnailType_ = (int32_t)value; } + GETTER_SETTER(thumbnailFile) + GETTER_SETTER(thumbnailLastModified) + GETTER_SETTER(position) + + + public: + DECLARE_JSON_MAP(AudioFileMetadata); + + }; + + #undef GETTER_SETTER + #undef GETTER_SETTER_VEC + #undef GETTER_SETTER_REF + + +} \ No newline at end of file diff --git a/src/AudioFileMetadataReader.cpp b/src/AudioFileMetadataReader.cpp new file mode 100644 index 0000000..760e98c --- /dev/null +++ b/src/AudioFileMetadataReader.cpp @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 "AudioFileMetadataReader.hpp" +#include +#include +#include +#include +#include "LRUCache.hpp" +#include +#include +#include "TemporaryFile.hpp" + +using namespace pipedal; +namespace fs = std::filesystem; +namespace chrono = std::chrono; + +// Safely encode a filename for use in a shell command +std::string shell_escape_filename(const std::string &filename) +{ + std::string escaped; + escaped.reserve(filename.size() + 2); // Reserve space for quotes and content + + // Wrap the filename in single quotes + escaped += '\''; + + for (char c : filename) + { + // Escape single quotes by closing the quote, adding escaped quote, and reopening + if (c == '\'') + { + escaped += "'\\''"; + } + else + { + escaped += c; + } + } + + escaped += '\''; + return escaped; +} + +static std::string readPipeToString(FILE *file) +{ + std::string content; + char buffer[4096]; + size_t bytesRead; + while ((bytesRead = fread(buffer, 1, sizeof(buffer), file)) > 0) + { + content.append(buffer, bytesRead); + } + return content; +} + +static std::string GetJsonMetadata(const std::filesystem::path &path) +{ + /* ffprobe -loglevel error -show_streams -show_format -print_format stream_tags -of json + ~/filename.flac */ + + std::stringstream ss; + ss << "/usr/bin/ffprobe -loglevel error -show_streams -show_format -print_format stream_tags -of json " + << shell_escape_filename(path.string()) + << " 2>/dev/null"; + + std::string command = ss.str(); + FILE *output = popen(command.c_str(), "r"); + if (output == nullptr) + { + throw std::runtime_error("Failed to process file."); + } + std::string result; + try + { + result = readPipeToString(output); + } + catch (const std::exception &e) + { + pclose(output); + throw std::runtime_error(e.what()); + } + int retcode = pclose(output); + if (retcode != 0) + { + throw std::runtime_error(result); + } + return result; +} + +static float MetadataFloat(const json_variant &vt, float defaultValue = 0) +{ + if (vt.is_string()) + { + std::string s = vt.as_string(); + std::stringstream ss(s); + float result = defaultValue; + ss >> result; + return result; + } + return defaultValue; +} + +static std::string MetadataString(const json_variant &o, const std::vector &names) +{ + for (const auto &name : names) + { + auto result = (*o.as_object())[name]; + if (result.is_string()) + { + return result.as_string(); + } + } + return ""; +} + +static int32_t MetadataTrackToInt(const std::string &track, const std::string &disc) +{ + if (track.empty()) + { + return -1; + } + try + { + int trackNumber = std::stoi(track); + if (!disc.empty()) + { + if (disc != "" && disc != "1/1" && disc != "1") + { + int discNumber = std::stoi(disc); + trackNumber += discNumber*1000+ trackNumber; // e.g. disc 1 track 2 becomes 1002 + } + + } + return trackNumber; + } + catch (const std::exception &) + { + return -1; // Invalid track or disc number + } +} + +AudioFileMetadata::AudioFileMetadata(const std::filesystem::path &file) +{ + try + { + const std::string json = GetJsonMetadata(file); + + json_variant vt; + + std::stringstream ss(json); + json_reader reader(ss); + + reader.read(&vt); + + auto top = vt.as_object(); + auto format = top->at("format").as_object(); + + this->duration_ = MetadataFloat(format->at("duration"), 0); + + auto tags = (*format)["tags"]; + if (tags.is_object()) + { + // not all of this data gets used (e.g. DATE, YEAR, ALBUM_ARTIST,TOTALTRACKS). But ... write once. + this->album_ = MetadataString(tags, {"ALBUM", "album"}); + this->artist_ = MetadataString(tags, {"ARTIST", "artist"}); + this->albumArtist_ = MetadataString(tags, {"ALBUM ARTIST", "album_artist", "album artist"}); + this->title_ = MetadataString(tags, {"TITLE", "title"}); + //this->date_ = MetadataString(tags, {"DATE", "date"}); + //this->year_ = MetadataString(tags, {"YEAR", "year"}); + this->track_ = + MetadataTrackToInt( + MetadataString(tags, {"track","TRACK"}), + MetadataString(tags, {"disc", "DISC"}) + ); + //this->totalTracks_ = MetadataString(tags, {"TOTALTRACKS"}); + + if (title_ == "") + { + this->title_ = file.stem(); + } + } + } + catch (const std::exception &e) + { + std::string title = file.stem(); + this->title_ = title; + } +} + +namespace +{ + class AudioCacheKey + { + public: + AudioCacheKey(const std::filesystem::path &path) + { + this->path = path.string(); + this->lastWrite = std::filesystem::last_write_time(path); + } + // Equality operator + bool operator==(const AudioCacheKey &other) const + { + return path == other.path && lastWrite == other.lastWrite; + } + + public: + std::string path; + fs::file_time_type lastWrite; + }; +} + +namespace std +{ + template <> + struct hash + { + + size_t operator()(const AudioCacheKey &key) const + { + size_t path_hash = hash{}(key.path); + size_t time_hash = hash{}( + duration_cast(key.lastWrite.time_since_epoch()).count()); + // Combine hashes (boost::hash_combine approach) + return path_hash ^ (time_hash + 0x9e3779b9 + (path_hash << 6) + (path_hash >> 2)); + } + }; +} + +static LRUCache metadataCache{500}; +static std::mutex metadataCacheMutex; + +std::string pipedal::GetAudioFileMetadataString(const std::filesystem::path &path) +{ + std::lock_guard lock{metadataCacheMutex}; + AudioCacheKey key{path}; + std::string result; + if (metadataCache.get(key, result)) + { + return result; + } + auto metadata = AudioFileMetadata(path); + + std::stringstream ss; + json_writer writer(ss); + writer.write(metadata); + result = ss.str(); + + metadataCache.put(key, result); + return result; +} + +TemporaryFile pipedal::GetAudioFileThumbnail(const std::filesystem::path &path, const std::filesystem::path &outputPath) +{ + return GetAudioFileThumbnail(path, 0, 0, outputPath); +} + +std::mutex thumbnailMutex; + +TemporaryFile pipedal::GetAudioFileThumbnail(const std::filesystem::path &path, int32_t width, int32_t height, const std::filesystem::path &tempDirectory) +{ + std::lock_guard lock(thumbnailMutex); + fs::path thumbnailDirectory = tempDirectory / "thumbnails"; + if (!fs::exists(thumbnailDirectory)) + { + fs::create_directories(thumbnailDirectory); + } else { + // Clean up old thumbnails + for (const auto &entry : fs::directory_iterator(thumbnailDirectory)) + { + if (entry.is_regular_file()) + { + fs::remove(entry.path()); + } + } + } + fs::create_directories(tempDirectory); + + TemporaryFile tempFile(tempDirectory); + std::filesystem::path outputPath = thumbnailDirectory / "thumbnail-%03d.jpg"; + // ffmpeg -loglevel error -i "test2.mp3" -vf scale=200:200 -frames:v 1 thumb-%03.jpg -y + + std::stringstream ss; + ss << "/usr/bin/ffmpeg -loglevel error -i " << shell_escape_filename(path.string()); + if (width > 0 && height > 0) + { + ss << " -vf \"scale=" << width << ":" << height << + ":force_original_aspect_ratio=increase,crop=" << width << ":" << height << "\""; + } else { + // -vf "crop=min(iw\,ih):min(iw\,ih)" + ss << " -vf \"crop=min(iw\\,ih):min(iw\\,ih)\""; + } + ss << " " << shell_escape_filename(outputPath.string()); + ss << " -y 2>/dev/null 1>/dev/null"; + std::string command = ss.str(); + int rc = system(command.c_str()); + if (rc < 0) { + throw std::runtime_error("Failed to execute ffmpeg command: " + command); + } + int exitCode = WEXITSTATUS(rc); + if (exitCode != EXIT_SUCCESS) + { throw std::runtime_error("Thumbnail not foud."); + } + fs::remove(tempFile.Path()); + if (fs::exists(thumbnailDirectory / "thumbnail-001.jpg")) + { + // Move the thumbnail to the temporary file. + fs::rename(thumbnailDirectory / "thumbnail-001.jpg", tempFile.Path()); + } + else + { + throw std::runtime_error("Failed to create thumbnail."); + } + return tempFile; +} + diff --git a/src/AudioFileMetadataReader.hpp b/src/AudioFileMetadataReader.hpp new file mode 100644 index 0000000..74cc107 --- /dev/null +++ b/src/AudioFileMetadataReader.hpp @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 +#include +#include "TemporaryFile.hpp" +#include "AudioFileMetadata.hpp" + +namespace pipedal +{ + + + TemporaryFile GetAudioFileThumbnail(const std::filesystem::path &path, int32_t width, int32_t height, const std::filesystem::path &tempDirectory); + TemporaryFile GetAudioFileThumbnail(const std::filesystem::path &path, const std::filesystem::path &tempDirectory); + + std::string GetAudioFileMetadataString(const std::filesystem::path &path); + + + +} diff --git a/src/AudioFiles.cpp b/src/AudioFiles.cpp new file mode 100644 index 0000000..ec67e28 --- /dev/null +++ b/src/AudioFiles.cpp @@ -0,0 +1,937 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 "AudioFiles.hpp" +#include +#include +#include +#include "AudioFileMetadataReader.hpp" +#include "Locale.hpp" +#include "MimeTypes.hpp" +#include +#include "AudioFilesDb.hpp" +#include "Lv2Log.hpp" +#include "ss.hpp" +#include "util.hpp" +#include + +#undef _GLIBCXX_DEBUG // Ensure we are not in debug mode, as this file is not compatible with it. +#include "SQLiteCpp/SQLiteCpp.h" + +using namespace pipedal; +using namespace pipedal::impl; +namespace fs = std::filesystem; + +static constexpr size_t INVALID_INDEX = std::numeric_limits::max(); + + +// All the varous cover art files I found on my personal music collection. +// These are used to find the cover art for a directory. Based on +// scanning a large music collection that have been tagged by various tools +// including iTunes, Windows Media Playe, and a variety of taggers. +// +// Files are listed in order of preference. + +static std::vector COVER_ART_FILES = { + "Folder.jpg", // window media player/explorer. + "Cover.jpg", // itunes. + "folder.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. + "cover.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. + "Artwork.jpg", // itunes. + "Front.jpg", + "front.jpg", // linux. + "AlbumArt.jpg", + "albumArt.jpg", + "Frontcover.jpg", + "AlbumArtSmall.jpg" // windows media player. +}; + +bool pipedal::isArtworkFileName(const std::string &fileName) +{ + for (const auto &coverArtFile : COVER_ART_FILES) + { + if (fileName == coverArtFile) + { + return true; + } + } + return false; +} + + +void AudioDirectoryInfo::SetTemporaryDirectory(const std::filesystem::path &path) +{ + temporaryDirectory = path; +} +void AudioDirectoryInfo::SetResourceDirectory(const std::filesystem::path &path) +{ + resourceDirectory = path; +} +std::filesystem::path AudioDirectoryInfo::GetTemporaryDirectory() +{ + if (temporaryDirectory.empty()) + { + return "/tmp/pipedal/audiofiles"; // used during testing. + } + return temporaryDirectory; +} +std::filesystem::path AudioDirectoryInfo::GetResourceDirectory() +{ + if (resourceDirectory.empty()) + { + fs::path path = fs::current_path() / "vite/public/img"; // take from the source directory during testing. + return "/usr/share/pipedal/audiofiles"; // used during testing. + } + return resourceDirectory; +} + +std::filesystem::path AudioDirectoryInfo::temporaryDirectory; +std::filesystem::path AudioDirectoryInfo::resourceDirectory; + +namespace +{ + + static int64_t fileTimeToInt64(const fs::file_time_type &fileTime) + { + auto sctp = std::chrono::time_point_cast( + fileTime - fs::file_time_type::clock::now() + std::chrono::system_clock::now()); + auto result = std::chrono::duration_cast(sctp.time_since_epoch()).count(); + return result; + } + static int64_t GetLastWriteTime(const fs::path &file) + { + try + { + return fileTimeToInt64(fs::last_write_time(file)); + } + catch (const std::exception &e) + { + return 0; // Return 0 if we cannot get the last write time. + } + } + + class AudioDirectoryInfoImpl : public AudioDirectoryInfo + { + public: + AudioDirectoryInfoImpl(const std::filesystem::path &path, const std::filesystem::path &indexPath) + : path(path), indexPath( + (indexPath.empty() ? path : indexPath) / ".index.pipedal") + { + if (this->indexPath.parent_path() != path) + { + fs::create_directories(this->indexPath.parent_path()); + } + } + virtual ~AudioDirectoryInfoImpl() {} + + virtual std::vector GetFiles() override; + virtual ThumbnailTemporaryFile GetThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) override; + + virtual size_t TestGetNumberOfThumbnails() override; // test use only. + virtual void TestSetIndexPath(const std::filesystem::path &path) override + { + indexPath = path; + } + virtual std::string GetNextAudioFile(const std::string &fileNameOnly) override; + virtual std::string GetPreviousAudioFile(const std::string &fileNameOnly) override; + + virtual void MoveAudioFile( + const std::string &directory, + int32_t fromPosition, + int32_t toPosition) override; + + + private: + std::vector QueryTracks(); + + bool opened = false; + std::filesystem::path indexPath; + void OpenAudioDb(); + ThumbnailTemporaryFile GetUnindexedThunbnail(const std::string &fileNameOnly, int32_t width, int32_t height); + + std::shared_ptr audioFilesDb; + fs::path path; + using id_t = int64_t; + + std::vector UpdateDbFiles(); + void DbSetThumbnailType( + int64_t idFile, + ThumbnailType thumbnailType, + const std::string &thumbnailFile = "", + int64_t thumbnailLastModified = 0); + void DbSetThumbnailType( + const std::string &fileNameOnly, + ThumbnailType thumbnailType, + const std::string &thumbnailFile = "", + int64_t thumbnailLastModified = 0); + + void DbDeleteFile(DbFileInfo *dbFile); + void UpdateMetadata(DbFileInfo *dbFile); + static constexpr int DB_VERSION = 1; + std::filesystem::path GetFolderFile() const; + }; +} + +AudioDirectoryInfo::Ptr AudioDirectoryInfo::Create(const std::filesystem::path &path, const std::filesystem::path &indexPath) +{ + return std::make_shared(path, indexPath); +} + +std::vector AudioDirectoryInfoImpl::QueryTracks() +{ + if (audioFilesDb) + { + return audioFilesDb->QueryTracks(); + } + return std::vector(); +} + +std::vector AudioDirectoryInfoImpl::GetFiles() +{ + OpenAudioDb(); + std::vector dbFiles; + try { + dbFiles = UpdateDbFiles(); + } catch (const std::exception &e) { + Lv2Log::error("Error updating audio file info: %s - %s", + path.string().c_str(), e.what()); + } + std::vector metadataResults; + for (const auto &dbFile : dbFiles) + { + AudioFileMetadata metadata{dbFile}; // slice copy of just the metadata. + metadataResults.push_back(std::move(metadata)); + } + return metadataResults; +} + +static bool isAudioExtension(const std::string &extension) +{ + return MimeTypes::instance().AudioExtensions().contains(extension); +} + +static bool HasPositionInfo(const std::vector &dbFiles) +{ + for (const auto &file : dbFiles) + { + if (file.position() != -1) + { + return true; + } + } + return false; +} +static int DefaultedSortOrder(int track) +{ + if (track < 0) + { + return std::numeric_limits::max(); + } + return track; +} + +static bool isFolderArtwork(const std::string &name) +{ + return name.ends_with(".jpg") || name.ends_with(".png"); +} + +static void SortDbFiles(std::vector &dbFiles, Collator::ptr &collator) +{ + std::sort( + dbFiles.begin(), dbFiles.end(), + [collator](const DbFileInfo &a, const DbFileInfo &b) + { + if (isFolderArtwork(a.fileName()) != isFolderArtwork(b.fileName())) + { + return isFolderArtwork(a.fileName()) < isFolderArtwork(b.fileName()); + } + if (a.position() != b.position()) + { + return DefaultedSortOrder(a.position()) < DefaultedSortOrder(b.position()); + } + if (a.track() != b.track()) + { + return DefaultedSortOrder(a.track()) < DefaultedSortOrder(b.track()); + } + if (a.title() != b.title()) + { + return collator->Compare(a.title(), b.title()) < 0; + } + if (a.album() != b.album()) + { + return collator->Compare(a.title(), b.title()) < 0; + } + return false; + ; + }); +} + +std::vector AudioDirectoryInfoImpl::UpdateDbFiles() +{ + std::shared_ptr transaction; + if (audioFilesDb) + { + transaction = audioFilesDb->transaction(); + } + std::vector dbFiles = QueryTracks(); + std::vector newFiles; + + bool updateRequired = false; + std::map nameToDbRecord; + for (size_t i = 0; i < dbFiles.size(); ++i) + { + auto *pFile = &(dbFiles[i]); + nameToDbRecord[pFile->fileName()] = pFile; + } + + for (auto dirEntry : fs::directory_iterator(path)) + { + bool isDirectory; + try { + isDirectory = dirEntry.is_directory(); + } catch ( + const std::filesystem::filesystem_error &e) { + Lv2Log::error("Error accessing directory entry: %s - %s", dirEntry.path().string().c_str(), e.what()); + continue; // skip this entry if we cannot access it. + } + + if (!isDirectory) + { + auto path = dirEntry.path(); + std::string name = path.filename(); + std::string extension = path.extension(); + if (isAudioExtension(extension) || isArtworkFileName(name)) + { + // If the file is an audio file or a cover art file, we need to check it. + if (name.empty() || name[0] == '.') + { + continue; // skip hidden files. + } + } + else + { + continue; // not an audio file or cover art file. + } + { + int64_t lastModified = fileTimeToInt64(dirEntry.last_write_time()); + + auto f = nameToDbRecord.find(name); + if (f != nameToDbRecord.end()) + { + DbFileInfo *dbFile = f->second; + dbFile->present(true); + if (dbFile->lastModified() != lastModified) + { + if (audioFilesDb) + { + audioFilesDb->DeleteThumbnails(dbFile->idFile()); + } + dbFile->thumbnailType(ThumbnailType::Unknown); + dbFile->thumbnailFile(""); + dbFile->thumbnailLastModified(0); + UpdateMetadata(dbFile); + dbFile->dirty(true); + updateRequired = true; + } + } + else + { + DbFileInfo newFile; + newFile.fileName(name); + newFile.idFile(-1); + newFile.dirty(true); + newFile.present(true); + UpdateMetadata(&newFile); + newFiles.push_back(std::move(newFile)); + updateRequired = true; + } + } + } + } + for (auto i = dbFiles.begin(); i != dbFiles.end(); ++i) + { + if (!i->present()) + { + DbDeleteFile(&*i); + dbFiles.erase(i); + updateRequired = true; + --i; + } + } + // refresh folder file names. + auto folderFile = GetFolderFile(); + int64_t folderLastModified = 0; + std::string folderFileName; + if (!folderFile.empty() && fs::exists(folderFile)) + { + folderLastModified = GetLastWriteTime(folderFile); + folderFileName = folderFile.filename().string(); + } + for (auto &dbFile : dbFiles) + { + if (dbFile.thumbnailType() == ThumbnailType::None) + { + if (!folderFileName.empty()) + { + dbFile.thumbnailType(ThumbnailType::Folder); + dbFile.thumbnailFile(folderFileName); + dbFile.thumbnailLastModified(folderLastModified); + dbFile.dirty(true); + updateRequired = true; + } + } + else if (dbFile.thumbnailType() == ThumbnailType::Folder) + { + // Check if the folder file has changed. + if (folderFileName.empty()) + { + // no more folder file. update accordingly. + dbFile.thumbnailType(ThumbnailType::None); + dbFile.thumbnailFile(""); + dbFile.thumbnailLastModified(0); + dbFile.dirty(true); + updateRequired = true; + } + else + { + // folder file has changed. update accordingly. + if (dbFile.thumbnailFile() != folderFileName || + dbFile.thumbnailLastModified() != folderLastModified) + { + dbFile.thumbnailFile(folderFileName); + dbFile.thumbnailLastModified(folderLastModified); + dbFile.dirty(true); + updateRequired = true; + } + } + } + } + + Locale::ptr locale = Locale::GetInstance(); + Collator::ptr collator = locale->GetCollator(); + if (!HasPositionInfo(dbFiles)) + { + dbFiles.insert(dbFiles.end(), newFiles.begin(), newFiles.end()); + SortDbFiles(dbFiles, collator); + } + else + { + // We have position info, so we need to update the position. + dbFiles.insert(dbFiles.end(), newFiles.begin(), newFiles.end()); + SortDbFiles(dbFiles, collator); + + for (size_t i = 0; i < dbFiles.size(); ++i) + { + auto newPosition = static_cast(i); + if (newPosition != dbFiles[i].position()) + { + dbFiles[i].dirty(true); + } + dbFiles[i].position(newPosition); + } + } + for (auto &dbFile : dbFiles) + { + if (dbFile.dirty()) + { + if (audioFilesDb) + { + audioFilesDb->WriteFile(&dbFile); + } + dbFile.dirty(false); + } + } + if (transaction) + { + transaction->commit(); + transaction = nullptr; + } + return dbFiles; +} + +void AudioDirectoryInfoImpl::DbDeleteFile(DbFileInfo *dbFile) +{ + if (this->audioFilesDb) + { + audioFilesDb->DeleteFile(dbFile); + } +} +ThumbnailTemporaryFile AudioDirectoryInfo::DefaultThumbnailTemporaryFile() +{ + ThumbnailTemporaryFile tempFile; + fs::path defaultThumbnail = GetResourceDirectory() / "img/missing_thumbnail.jpg"; + tempFile.SetNonDeletedPath(defaultThumbnail, "image/jpeg"); + return tempFile; +} + +ThumbnailTemporaryFile AudioDirectoryInfoImpl::GetUnindexedThunbnail( + const std::string &fileNameOnly, int32_t width, int32_t height) +{ + fs::path file = this->path / fileNameOnly; + if (!fs::exists(file)) + { + return DefaultThumbnailTemporaryFile(); + } + try + { + auto tempFile = pipedal::GetAudioFileThumbnail( + file, width, height, + GetTemporaryDirectory()); + auto thumbnailPath = tempFile.Path(); + ThumbnailTemporaryFile result; + result.Attach(tempFile.Detach(), "image/jpeg"); + + // Read the thumbnail data from the temporary file. + std::ifstream thumbnailStream(thumbnailPath, std::ios::binary); + if (!thumbnailStream) + { + throw std::runtime_error("Failed to open thumbnail file: " + thumbnailPath.string()); + } + std::vector thumbnailData( + (std::istreambuf_iterator(thumbnailStream)), + std::istreambuf_iterator()); + + if (!thumbnailData.empty()) + { + audioFilesDb->AddThumbnail( + fileNameOnly, + width, + height, + thumbnailData); + + // Set the MIME type for the thumbnail. + result.SetMimeType("image/jpeg"); + audioFilesDb->UpdateThumbnailInfo( + fileNameOnly, + ThumbnailType::Embedded); + return result; + } + else + { + throw std::runtime_error("Thumbnail data is empty."); + } + } + catch (const std::exception &e) + { + Lv2Log::error("GetUnindexedThunbnail failed: %s", e.what()); + } + return DefaultThumbnailTemporaryFile(); +} + +fs::path AudioDirectoryInfoImpl::GetFolderFile() const +{ + for (const auto &coverArtFile : COVER_ART_FILES) + { + fs::path thumbnailPath = path / coverArtFile; + if (fs::exists(thumbnailPath)) + { + return thumbnailPath; + } + } + return {}; +} + +void AudioDirectoryInfoImpl::DbSetThumbnailType( + int64_t idFile, + ThumbnailType thumbnailType, + const std::string &thumbnailFile, + int64_t thumbnailLastModified) +{ + if (audioFilesDb) + { + audioFilesDb->UpdateThumbnailInfo( + idFile, + thumbnailType, + thumbnailFile, + thumbnailLastModified); + } +} +void AudioDirectoryInfoImpl::DbSetThumbnailType( + const std::string &fileNameOnly, + ThumbnailType thumbnailType, + const std::string &thumbnailFile, + int64_t thumbnailLastModified) +{ + if (audioFilesDb) + { + audioFilesDb->UpdateThumbnailInfo( + fileNameOnly, + thumbnailType, + thumbnailFile, + thumbnailLastModified); + } +} + +static ThumbnailTemporaryFile GetFolderTemporaryFile(const fs::path &folderFile) +{ + ThumbnailTemporaryFile result; + result.SetNonDeletedPath(folderFile, MimeTypes::instance().MimeTypeFromExtension(folderFile.extension().string())); + return result; +} +ThumbnailTemporaryFile AudioDirectoryInfoImpl::GetThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) +{ + fs::path file = this->path / fileNameOnly; + OpenAudioDb(); + if (audioFilesDb) + { + try + { + + ThumbnailInfo thumbnailInfo = audioFilesDb->GetThumbnailInfo(file.filename().string()); + + if (thumbnailInfo.thumbnailType() == ThumbnailType::Folder) + { + fs::path thumbnailFile = path / thumbnailInfo.thumbnailFile(); + if (!fs::exists(thumbnailFile) || + fileTimeToInt64(fs::last_write_time(thumbnailFile)) != + thumbnailInfo.thumbnailLastModified()) + { + // reset the thumbnail type and try again. + audioFilesDb->UpdateThumbnailInfo( + fileNameOnly, + ThumbnailType::Unknown); + return GetThumbnail(fileNameOnly, width, height); + } + fs::path fullFolderPath = this->path / thumbnailInfo.thumbnailFile(); + return GetFolderTemporaryFile(fullFolderPath); + } + if (thumbnailInfo.thumbnailType() == ThumbnailType::Embedded) + { + std::vector blob = audioFilesDb->GetEmbeddedThumbnail(fileNameOnly, width, height); + + if (!blob.empty()) + { + ThumbnailTemporaryFile tempFile = ThumbnailTemporaryFile::CreateTemporaryFile(GetTemporaryDirectory(), "image/jpeg"); + + tempFile.SetMimeType("image/jpeg"); + // write buffer to tempFile.GetPath() + std::ofstream outFile(tempFile.Path(), std::ios::binary); + if (!outFile) + { + throw std::runtime_error("Failed to open temporary file for writing: " + tempFile.Path().string()); + } + outFile.write((const char *)blob.data(), blob.size()); + return tempFile; + } + else + { + // fall through and generate the indexed thumbnail. + } + } + + if (thumbnailInfo.thumbnailType() == ThumbnailType::None) + { + return DefaultThumbnailTemporaryFile(); + } + + try + { + auto tempFile = pipedal::GetAudioFileThumbnail( + this->path / fileNameOnly, + width, height, + GetTemporaryDirectory()); + auto thumbnailPath = tempFile.Path(); + ThumbnailTemporaryFile result; + result.Attach(tempFile.Detach(), "image/jpeg"); + + // Read the thumbnail data from the temporary file. + std::ifstream thumbnailStream(thumbnailPath, std::ios::binary); + if (!thumbnailStream) + { + throw std::runtime_error("Failed to open thumbnail file: " + thumbnailPath.string()); + } + std::vector thumbnailData( + (std::istreambuf_iterator(thumbnailStream)), + std::istreambuf_iterator()); + + if (!thumbnailData.empty()) + { + audioFilesDb->AddThumbnail( + fileNameOnly, + width, + height, + thumbnailData); + + // Set the MIME type for the thumbnail. + result.SetMimeType("image/jpeg"); + audioFilesDb->UpdateThumbnailInfo( + fileNameOnly, + ThumbnailType::Embedded); + return result; + } + else + { + throw std::runtime_error("Thumbnail data is empty."); + } + return result; + } + catch (const std::exception &_) + { + } + auto folderFile = GetFolderFile(); + if (!folderFile.empty()) + { + // We have a folder thumbnail, set the type and return it. + fs::path t = this->path / folderFile; + int64_t lastModified = fileTimeToInt64(fs::last_write_time(t)); + DbSetThumbnailType(fileNameOnly, ThumbnailType::Folder, folderFile, lastModified); + auto fullFolderPath = this->path / folderFile; + return GetFolderTemporaryFile(fullFolderPath); + } + DbSetThumbnailType( + fileNameOnly, + ThumbnailType::None); // No thumbnail available, set to None. + return DefaultThumbnailTemporaryFile(); + } + catch (const std::exception &e) + { + } + } + return GetUnindexedThunbnail(fileNameOnly, width, height); +} + + +void AudioDirectoryInfoImpl::UpdateMetadata(DbFileInfo *dbFile) +{ + fs::path file = this->path / dbFile->fileName(); + AudioFileMetadata metadata(file); + dbFile->title(metadata.title()); + dbFile->track(metadata.track()); + dbFile->duration(metadata.duration()); + dbFile->album(metadata.album()); + dbFile->artist(metadata.artist()); + dbFile->albumArtist(metadata.albumArtist()); + dbFile->lastModified(GetLastWriteTime(file)); + dbFile->duration(metadata.duration()); +} + +ThumbnailTemporaryFile::ThumbnailTemporaryFile(const std::filesystem::path &temporaryDirectory) + : TemporaryFile(temporaryDirectory) +{ + // Set a default MIME type for the thumbnail. + mimeType = "image/jpeg"; // Default MIME type, can be set later. +} + +ThumbnailTemporaryFile ThumbnailTemporaryFile::CreateTemporaryFile(const std::filesystem::path &temporaryDirectory, const std::string &mimeType) +{ + ThumbnailTemporaryFile result(temporaryDirectory); + result.SetMimeType(mimeType); + return result; +} + +ThumbnailTemporaryFile ThumbnailTemporaryFile::FromFile(const std::filesystem::path &filePath, const std::string &mimeType) +{ + ThumbnailTemporaryFile result; + result.Attach(filePath, mimeType); + return result; +} + +void ThumbnailTemporaryFile::Attach(const std::filesystem::path &filePath, const std::string &mimeType) +{ + super::Attach(filePath); + SetMimeType(mimeType); +} + +size_t AudioDirectoryInfoImpl::TestGetNumberOfThumbnails() +{ + if (!audioFilesDb) + { + throw std::logic_error("DB not open"); + } + return this->audioFilesDb->GetNumberOfThumbnails(); +} + +void AudioDirectoryInfoImpl::OpenAudioDb() +{ + + if (!this->audioFilesDb) + { + try + { + this->audioFilesDb = std::make_shared(this->path, indexPath); + } + catch (const SQLite::Exception &e) + { + Lv2Log::debug("Can't create .index.pipedal: %s - %s", e.what(), this->path.c_str()); + } + } +} + + +std::string AudioDirectoryInfoImpl::GetNextAudioFile(const std::string &fileNameOnly) +{ + auto files = this->GetFiles(); + if (files.empty()) + { + return ""; + } + for (size_t i = 0; i < files.size(); ++i) + { + if (files[i].fileName() == fileNameOnly) + { + size_t next = i; + while (true) + { + next = (next + 1) % files.size(); + if (!isFolderArtwork(files[next].fileName())) + { + return files[next].fileName(); + } + if (next == i) + { + break; + } + } + } + } + return ""; +} +std::string AudioDirectoryInfoImpl::GetPreviousAudioFile(const std::string &fileNameOnly) +{ + auto files = this->GetFiles(); + if (files.empty()) + { + return ""; + } + for (size_t i = 0; i < files.size(); ++i) + { + if (files[i].fileName() == fileNameOnly) + { + size_t next = i; + while (true) + { + if (next == 0) + { + next = files.size() - 1; // wrap around to the last file. + } + else + { + --next; // go to the previous file. + } + if (!isFolderArtwork(files[next].fileName())) + { + return files[next].fileName(); + } + if (next == i) + { + break; + } + } + } + } + return ""; +} + +void AudioDirectoryInfoImpl::MoveAudioFile( + const std::string &directory, + int32_t fromPosition, + int32_t toPosition) +{ + OpenAudioDb(); + if (!audioFilesDb) + { + throw std::runtime_error("Directory is not writable."); + } + auto files = this->UpdateDbFiles(); + if (directory.empty() || fromPosition < 0 || toPosition < 0 || + fromPosition >= static_cast(files.size()) || + toPosition > static_cast(files.size())) + { + throw std::invalid_argument("Invalid arguments for MoveAudioFile"); + } + std::shared_ptr transaction; + if (audioFilesDb) + { + transaction = audioFilesDb->transaction(); + } + + if (fromPosition == toPosition) + { + return; // No change needed. + } + if (fromPosition > toPosition) + { + auto t = files[fromPosition]; + files.erase(files.begin() + fromPosition); + files.insert(files.begin() + toPosition, t); + } + else + { + // fromPosition < toPosition + auto t = files[fromPosition]; + files.erase(files.begin() + fromPosition); + files.insert(files.begin() + toPosition, t); // insert before the toPosition. + } + for (size_t i = 0; i < files.size(); ++i) + { + auto &file = files[i]; + if (file.position() != static_cast(i)) + { + file.position(static_cast(i)); + if (this->audioFilesDb) + { + audioFilesDb->UpdateFilePosition(file.idFile(), file.position()); + } + else + { + throw std::runtime_error("Directory is not writable."); + } + } + } + transaction->commit(); +} + +namespace pipedal +{ + std::filesystem::path IndexPathToShadowIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) + { + std::filesystem::path relativePath = MakeRelativePath(path, audioRootDirectory); + if (relativePath.is_absolute()) + { + throw std::runtime_error(SS("Can't write to path " << path << ".")); + }; + return audioRootDirectory / "shadow_indexes" / relativePath; + } + + // recover the original path fromthe shadow index path. + std::filesystem::path ShadowIndexPathToIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) + { + auto shadowIndexesPath = audioRootDirectory / "shadow_indexes"; + std::filesystem::path relativePath = MakeRelativePath(path, shadowIndexesPath); + if (relativePath.is_absolute()) + { + throw std::runtime_error(SS("Path must start with " << shadowIndexesPath)); + } + return audioRootDirectory / relativePath; // recover the original path. + } + + std::filesystem::path GetShadowIndexDirectory(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path) + { + if (HasWritePermissions(path)) + { + return ""; // use default index path. + } + return IndexPathToShadowIndexPath(audioRootDirectory, path); + } +} + diff --git a/src/AudioFiles.hpp b/src/AudioFiles.hpp new file mode 100644 index 0000000..132eb56 --- /dev/null +++ b/src/AudioFiles.hpp @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 +#include +#include +#include +#include +#include "AudioFileMetadata.hpp" +#include "TemporaryFile.hpp" + +namespace pipedal +{ + + class ThumbnailTemporaryFile : public TemporaryFile + { + private: + // Private constructor to enforce the use of CreateTemporaryFile or the other constructor. + explicit ThumbnailTemporaryFile(const std::filesystem::path &temporaryDirectory); + + public: + using super = TemporaryFile; + ThumbnailTemporaryFile() = default; + ThumbnailTemporaryFile(const ThumbnailTemporaryFile &) = delete; + ThumbnailTemporaryFile(ThumbnailTemporaryFile &&) = default; + ThumbnailTemporaryFile &operator=(const ThumbnailTemporaryFile &) = delete; + ThumbnailTemporaryFile &operator=(ThumbnailTemporaryFile &&) = default; + + public: + static ThumbnailTemporaryFile CreateTemporaryFile(const std::filesystem::path &tempDirectory, const std::string &mimeType); + static ThumbnailTemporaryFile FromFile(const std::filesystem::path &filePath, const std::string &mimeType); + + public: + void SetNonDeletedPath(const std::filesystem::path &path, const std::string &mimeType = "audio/mpeg") + { + TemporaryFile::SetNonDeletedPath(path); + // Set the MIME type for audio files. + this->mimeType = mimeType; // Default MIME type, can be set later. + } + void Attach(const std::filesystem::path &path, const std::string &mimeType); + + std::string GetMimeType() + { + return mimeType; + } + void SetMimeType(const std::string &mimeType) + { + this->mimeType = mimeType; + } + + private: + std::string mimeType = "image/jpeg"; // Default MIME type, can be set later. + }; + + class AudioDirectoryInfo + { + protected: + AudioDirectoryInfo() {} + virtual ~AudioDirectoryInfo() {} + + public: + using self = AudioDirectoryInfo; + using Ptr = std::shared_ptr; + + static Ptr Create( + const std::filesystem::path &path, + const std::filesystem::path &indexPath = ""); + + virtual std::vector GetFiles() = 0; + + virtual ThumbnailTemporaryFile GetThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) = 0; + + virtual std::string GetNextAudioFile(const std::string &fileNameOnly) = 0; + virtual std::string GetPreviousAudioFile(const std::string &fileNameOnly) = 0; + + virtual void MoveAudioFile( + const std::string &directory, + int32_t fromPosition, + int32_t toPosition) = 0; + static ThumbnailTemporaryFile DefaultThumbnailTemporaryFile(); + + static void SetTemporaryDirectory(const std::filesystem::path &path); + static void SetResourceDirectory(const std::filesystem::path &path); + + virtual size_t TestGetNumberOfThumbnails() = 0; // test use only. + virtual void TestSetIndexPath(const std::filesystem::path &path) = 0; + + public: + static std::filesystem::path GetTemporaryDirectory(); + static std::filesystem::path GetResourceDirectory(); + + private: + static std::filesystem::path temporaryDirectory; + static std::filesystem::path resourceDirectory; + ; + }; + + + + std::filesystem::path IndexPathToShadowIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path); + // recover the original path fromthe shadow index path. + std::filesystem::path ShadowIndexPathToIndexPath(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path); + std::filesystem::path GetShadowIndexDirectory(const std::filesystem::path &audioRootDirectory, const std::filesystem::path &path); + + bool isArtworkFileName(const std::string &fileName); +} \ No newline at end of file diff --git a/src/AudioFilesDb.cpp b/src/AudioFilesDb.cpp new file mode 100644 index 0000000..c4c5b7c --- /dev/null +++ b/src/AudioFilesDb.cpp @@ -0,0 +1,505 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 "AudioFilesDb.hpp" +#include +#include +#include +#include + +using namespace pipedal; +using namespace pipedal::impl; +namespace fs = std::filesystem; + +namespace pipedal::impl +{ + struct DatabaseLockEntry + { + virtual ~DatabaseLockEntry() = default; + + using ptr = std::shared_ptr; + + std::mutex mutex; + }; + static std::map> databaseLocks; + + std::mutex databaseLockMutex; + + class DatabaseLock + { + private: + fs::path indexFile; + std::shared_ptr lockEntry; + + // Disable copy and move constructors + DatabaseLock(const DatabaseLock &) = delete; + DatabaseLock &operator=(const DatabaseLock &) = delete; + DatabaseLock(DatabaseLock &&) = delete; + DatabaseLock &operator=(DatabaseLock &&) = delete; + + public: + explicit DatabaseLock(const fs::path &lockFilePath) + : indexFile(lockFilePath) + { + { + std::lock_guard lock(databaseLockMutex); + + auto it = databaseLocks.find(indexFile); + if (it == databaseLocks.end()) + { + // Create a new lock entry + DatabaseLockEntry::ptr entry = std::make_shared(); + databaseLocks[indexFile] = entry; + lockEntry = entry; + } + else + { + lockEntry = it->second; + } + } + lockEntry->mutex.lock(); // we now have exclusive access to the database. + } + + ~DatabaseLock() + { + lockEntry->mutex.unlock(); // release the lock + { + std::lock_guard lock(databaseLockMutex); + auto use_count = lockEntry.use_count(); + + if (use_count == 2) // one for us, one for the Lockentry + { + // Remove the entry from the map if no other locks are held. + databaseLocks.erase(indexFile); + } + } + } + }; +} + +static std::unique_ptr getDatabaseLock(const std::filesystem::path &path) +{ + // Create a lock file in the same directory as the database. + return std::make_unique(path); +} + +AudioFilesDb::AudioFilesDb( + const std::filesystem::path &path, + const std::filesystem::path &indexPath) + : path(path) +{ + fs::path dbPathName = this->path / ".pipedal.index"; + if (!indexPath.empty()) + { + dbPathName = indexPath; + } + + dbLock = getDatabaseLock(dbPathName); + + if (!fs::exists(dbPathName)) + { + CreateDb(dbPathName); + } + else + { + this->db = std::make_unique(dbPathName, SQLite::OPEN_READWRITE); + UpgradeDb(); + } +} + +void AudioFilesDb::UpgradeDb() +{ + int version = QueryVersion(); +} + +int AudioFilesDb::QueryVersion() +{ + SQLite::Statement query(*db, "SELECT version FROM am_dbInfo LIMIT 1"); + + // Fetch the result + if (query.executeStep()) + { + int version = query.getColumn(0).getInt(); + return version; + } + else + { + throw std::runtime_error("QueryVersion failed."); + } +} + +void AudioFilesDb::CreateDb(const std::filesystem::path &dbPathName) +{ + try + { + + this->db = std::make_unique(dbPathName, SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); + + try + { + db->exec("CREATE TABLE am_dbInfo (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "version INTEGER NOT NULL)"); + + { + SQLite::Statement query(*db, "INSERT INTO am_dbInfo (version) VALUES (?)"); + query.bind(1, DB_VERSION); + query.exec(); + } + + db->exec( + "CREATE TABLE IF NOT EXISTS files (" + "idFile INTEGER PRIMARY KEY AUTOINCREMENT, " + "fileName TEXT NOT NULL, " + "lastModified INT64 NOT NULL," + "title TEXT NOT NULL," + "track NUMBER NOT NULL," + "album TEXT NOT NULL, " + "artist TEXT NOT NULL, " + "albumArtist TEXT NOT NULL, " + "duration REAL NOT NULL DEFAULT 0.0," + + "thumbnailType INTEGER NOT NULL DEFAULT 0," + "position INTEGER NOT NULL DEFAULT -1," + "thumbnailFile TEXT NOT NULL DEFAULT \"\"," + "thumbnailLastModified INT64 NOT NULL DEFAULT 0" + ")"); + + db->exec("CREATE TABLE IF NOT EXISTS thumbnails (" + "idThumbnail INTEGER PRIMARY KEY AUTOINCREMENT, " + "idFile INT64, " + "thumbnail BLOB, " + "width INTEGER, " + "height INTEGER)"); + + } + catch (const SQLite::Exception &e) + { + throw std::runtime_error("Failed to create database tables: " + std::string(e.what())); + } + } + catch (const SQLite::Exception &e) + { + throw std::runtime_error("Failed to create database: " + std::string(e.what())); + } +} + +void AudioFilesDb::DeleteFile(DbFileInfo *dbFile) +{ + DeleteThumbnails(dbFile->idFile()); + if (!deleteFileQuery) + { + deleteFileQuery = std::make_unique( + *db, + "DELETE FROM files WHERE idFile = ?"); + } + deleteFileQuery->tryReset(); + deleteFileQuery->bind(1, dbFile->idFile()); + deleteFileQuery->exec(); +} + +void AudioFilesDb::DeleteThumbnails(id_t idFile) +{ + if (!updateThumbnailInfoQueryByName) + { + updateThumbnailInfoQueryByName = std::make_unique( + *db, + "DELETE FROM thumbnails WHERE idFile = ?"); + } + updateThumbnailInfoQueryByName->tryReset(); + updateThumbnailInfoQueryByName->bind(1, idFile); + updateThumbnailInfoQueryByName->exec(); +} + +size_t AudioFilesDb::GetNumberOfThumbnails() +{ + SQLite::Statement query(*db, "SELECT COUNT(*) FROM thumbnails"); + if (query.executeStep()) + { + return query.getColumn(0).getInt64(); + } + return 0; +} + +void AudioFilesDb::UpdateThumbnailInfo( + const std::string &fileName, + ThumbnailType thumbnailType, + const std::string &thumbnailFile, + int64_t thumbnailLastModified) +{ + if (!updateThumbnailInfoQueryByName) + { + updateThumbnailInfoQueryByName = std::make_unique( + *db, + "UPDATE files SET thumbnailType = ?,thumbnailFile = ?, thumbnailLastModified = ? WHERE fileName = ?"); + } + updateThumbnailInfoQueryByName->tryReset(); + updateThumbnailInfoQueryByName->bind(1, (int32_t)thumbnailType); + updateThumbnailInfoQueryByName->bind(2, thumbnailFile); + updateThumbnailInfoQueryByName->bind(3, thumbnailLastModified); + updateThumbnailInfoQueryByName->bind(4, fileName); + updateThumbnailInfoQueryByName->exec(); +} + +void AudioFilesDb::UpdateThumbnailInfo( + int64_t idFile, + ThumbnailType thumbnailType, + const std::string &thumbnailFile, + int64_t thumbnailLastModified) +{ + if (!updateThumbnailInfoQueryById) + { + updateThumbnailInfoQueryById = std::make_unique( + *db, + "UPDATE files SET thumbnailType = ?,thumbnailFile = ?, thumbnailLastModified = ? WHERE idFile = ?"); + } + updateThumbnailInfoQueryById->bind(1, (int32_t)thumbnailType); + updateThumbnailInfoQueryById->bind(2, thumbnailFile); + updateThumbnailInfoQueryById->bind(3, thumbnailLastModified); + updateThumbnailInfoQueryById->bind(4, idFile); + updateThumbnailInfoQueryById->exec(); +} + +std::unique_ptr AudioFilesDb::transaction() +{ + return std::make_unique(*db); +} + +std::vector AudioFilesDb::QueryTracks() +{ + std::vector result; + // get tracks fro the databse. + SQLite::Statement query( + *db, + "SELECT idFile, fileName, " + "lastModified, title, track, album, artist,albumArtist,duration, " + "thumbnailType, position, " + "thumbnailFile, thumbnailLastModified " + "FROM files "); + + while (query.executeStep()) + { + DbFileInfo row; + row.idFile(query.getColumn(0).getInt64()); + row.fileName(query.getColumn(1).getText()); + row.lastModified(query.getColumn(2).getInt64()); + row.title(query.getColumn(3).getText()); + row.track(query.getColumn(4).getInt()); + row.album(query.getColumn(5).getText()); + row.artist(query.getColumn(6).getText()); + row.albumArtist(query.getColumn(7).getText()); + row.duration(query.getColumn(8).getDouble()); + row.thumbnailType((ThumbnailType)query.getColumn(9).getInt()); + row.position(query.getColumn(10).getInt()); + row.thumbnailFile(query.getColumn(11).getText()); + row.thumbnailLastModified(query.getColumn(12).getInt64()); + result.push_back(std::move(row)); + } + return result; +} + +void AudioFilesDb::WriteFile(DbFileInfo *dbFile) +{ + if (dbFile->idFile() == -1) + { + if (!insertFileQuery) + { + insertFileQuery = std::make_unique( + *db, + "INSERT INTO files (" + "fileName, lastModified," + "title,track,album,artist, albumArtist, " + "duration, thumbnailType,thumbnailFile, thumbnailLastModified, position " + ") VALUES (?, ?, ?, ?, ?,?,?,?,?,?,?,?)"); + } + insertFileQuery->tryReset(); + insertFileQuery->bind(1, dbFile->fileName()); + insertFileQuery->bind(2, dbFile->lastModified()); + insertFileQuery->bind(3, dbFile->title()); + insertFileQuery->bind(4, dbFile->track()); + insertFileQuery->bind(5, dbFile->album()); + insertFileQuery->bind(6, dbFile->artist()); + insertFileQuery->bind(7, dbFile->albumArtist()); + insertFileQuery->bind(8, dbFile->duration()); + insertFileQuery->bind(9, (int32_t)dbFile->thumbnailType()); + insertFileQuery->bind(10, dbFile->thumbnailFile()); + insertFileQuery->bind(11, dbFile->thumbnailLastModified()); + insertFileQuery->bind(12, dbFile->position()); + insertFileQuery->exec(); + dbFile->idFile(db->getLastInsertRowid()); + } + else + { + if (!updateFileQuery) + { + updateFileQuery = std::make_unique( + *db, + "UPDATE files SET " + "fileName = ?, lastModified = ?, " + "title = ?, track = ?, album = ?, artist = ? , albumArtist = ?, " + "duration = ?, thumbnailType = ?, " + "thumbnailFile = ?, thumbnailLastModified = ?, position = ? " + " WHERE idFile = ?"); + } + updateFileQuery->tryReset(); + updateFileQuery->bind(1, dbFile->fileName()); + updateFileQuery->bind(2, dbFile->lastModified()); + updateFileQuery->bind(3, dbFile->title()); + updateFileQuery->bind(4, dbFile->track()); + updateFileQuery->bind(5, dbFile->album()); + updateFileQuery->bind(6, dbFile->artist()); + updateFileQuery->bind(7, dbFile->albumArtist()); + updateFileQuery->bind(8, dbFile->duration()); + updateFileQuery->bind(9, (int32_t)dbFile->thumbnailType()); + updateFileQuery->bind(10, dbFile->thumbnailFile()); + updateFileQuery->bind(11, dbFile->thumbnailLastModified()); + updateFileQuery->bind(12, dbFile->position()); + + updateFileQuery->bind(13, dbFile->idFile()); + + updateFileQuery->exec(); + } +} + +ThumbnailInfo AudioFilesDb::GetThumbnailInfo(const std::string &fileNameOnly) +{ + ThumbnailInfo result; + SQLite::Statement query( + *db, + "SELECT thumbnailType, thumbnailFile, thumbnailLastModified FROM files WHERE fileName = ?"); + query.bind(1, fileNameOnly); + if (query.executeStep()) + { + result.thumbnailType((ThumbnailType)query.getColumn(0).getInt()); + result.thumbnailFile(query.getColumn(1).getText()); + result.thumbnailLastModified(query.getColumn(2).getInt64()); + return result; + } + else + { + throw std::runtime_error("No thumbnail info found for file."); + } + return result; +} + +std::vector AudioFilesDb::GetEmbeddedThumbnail(int64_t idFile, int32_t width, int32_t height) +{ + SQLite::Statement query(*db, + "SELECT thumbnail FROM thumbnails " + "WHERE idFile = ? AND width = ? AND height = ?"); + query.bind(1, idFile); + query.bind(2, width); + query.bind(3, height); + + if (query.executeStep()) + { + const uint8_t *data = (uint8_t *)query.getColumn(0).getBlob(); + int size = query.getColumn(0).getBytes(); + return std::vector(data, data + size); + } + else + { + return {}; + } +} + +std::vector AudioFilesDb::GetEmbeddedThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height) +{ + int64_t idFile; + SQLite::Statement query(*db, "SELECT idFile FROM files WHERE fileName = ?"); + query.bind(1, fileNameOnly); + if (query.executeStep()) + { + idFile = query.getColumn(0).getInt64(); + } + else + { + throw std::runtime_error("File not found in database: " + fileNameOnly); + } + return GetEmbeddedThumbnail(idFile, width, height); +} + +void AudioFilesDb::AddThumbnail( + const std::string &fileName, + int64_t width, + int64_t height, + const std::vector &thumbnailData) +{ + int64_t idFile; + SQLite::Statement query(*db, "SELECT idFile FROM files WHERE fileName = ?"); + query.bind(1, fileName); + if (query.executeStep()) + { + idFile = query.getColumn(0).getInt64(); + } + else + { + throw std::runtime_error("File not found in database: " + fileName); + } + AddThumbnail(idFile, width, height, thumbnailData); +} +void AudioFilesDb::AddThumbnail( + int64_t idFile, + int64_t width, + int64_t height, + const std::vector &thumbnailData) +{ + if (thumbnailData.empty()) + { + throw std::runtime_error("Thumbnail data is empty."); + } + + SQLite::Statement insertQuery( + *db, + "INSERT INTO thumbnails (idFile, thumbnail, width, height) VALUES (?, ?, ?, ?)"); + insertQuery.bind(1, idFile); + insertQuery.bind(2, thumbnailData.data(), thumbnailData.size()); + insertQuery.bind(3, width); + insertQuery.bind(4, height); + + try + { + insertQuery.exec(); + } + catch (const SQLite::Exception &e) + { + throw std::runtime_error("Failed to add thumbnail: " + std::string(e.what())); + } +} + +void AudioFilesDb::UpdateFilePosition( + int64_t idFile, + int32_t position) +{ + if (!updateFileQuery) + { + updateFileQuery = std::make_unique( + *db, + "UPDATE files SET position = ? WHERE idFile = ?"); + } + updateFileQuery->tryReset(); + updateFileQuery->bind(1, position); + updateFileQuery->bind(2, idFile); + updateFileQuery->exec(); +} + diff --git a/src/AudioFilesDb.hpp b/src/AudioFilesDb.hpp new file mode 100644 index 0000000..1f44ecb --- /dev/null +++ b/src/AudioFilesDb.hpp @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 +#include +#include "SQLiteCpp/SQLiteCpp.h" +#include "AudioFileMetadata.hpp" +#include +#include + + +namespace pipedal::impl { + + class DatabaseLock; + + class DbFileInfo : public AudioFileMetadata + { + private: + int64_t idFile_ = -1; + bool present_ = false; + bool dirty_ = false; + public: + int64_t idFile() const { return idFile_;} + void idFile(int64_t value) { idFile_ = value;} + bool present() const { return present_; } + void present(bool value) { present_ = value;} + bool dirty() const { return dirty_;} + void dirty(bool value) { dirty_ = value;} + + }; + + + class ThumbnailInfo { + public: + ThumbnailInfo() = default; + private: + ThumbnailType thumbnailType_ = ThumbnailType::Unknown; + std::string thumbnailFile_; + int64_t thumbnailLastModified_ = 0; + public: + ThumbnailType thumbnailType() const { return thumbnailType_; } + void thumbnailType(ThumbnailType value) { thumbnailType_ = value; } + const std::string &thumbnailFile() const { return thumbnailFile_; } + void thumbnailFile(const std::string &value) { thumbnailFile_ = value; } + int64_t thumbnailLastModified() const { return thumbnailLastModified_; } + void thumbnailLastModified(int64_t value) { thumbnailLastModified_ = value; } + }; + class AudioFilesDb { + public: + static constexpr int32_t DB_VERSION = 1; + AudioFilesDb( + const std::filesystem::path &path, + const std::filesystem::path &indexPath = "" // if non-empty, forces the location of the ".index.pipedal" file. + ); + public: + void DeleteFile(DbFileInfo *dbFile); + void WriteFile(DbFileInfo *dbFile); + + // test only. + size_t GetNumberOfThumbnails(); + void UpdateThumbnailInfo( + int64_t idFile, + ThumbnailType thumbnailType, + const std::string &thumbnailFile = "", + int64_t thumbnailLastModified = 0); + void UpdateThumbnailInfo( + const std::string&fileName, + ThumbnailType thumbnailType, + const std::string &thumbnailFile = "", + int64_t thumbnailLastModified = 0); + std::unique_ptr transaction(); + std::vector QueryTracks(); + void DeleteThumbnails(id_t idFile); + ThumbnailInfo GetThumbnailInfo(const std::string &fileNameOnly); + std::vector GetEmbeddedThumbnail(const std::string &fileNameOnly, int32_t width, int32_t height); + std::vector GetEmbeddedThumbnail(int64_t idFile, int32_t width, int32_t height); + void AddThumbnail( + const std::string &fileName, + int64_t width, + int64_t height, + const std::vector &thumbnailData); + void AddThumbnail( + int64_t idFile, + int64_t width, + int64_t height, + const std::vector &thumbnailData); + void UpdateFilePosition( + int64_t idFile, + int32_t position); + + private: + + void CreateDb(const std::filesystem::path &dbPathName); + void UpgradeDb(); + int QueryVersion(); + + private: + std::filesystem::path indexPath; + + // warning: destructor order matters here! + std::shared_ptr dbLock; // mutex to protect the index file. + std::unique_ptr db; + std::unique_ptr insertFileQuery; + std::unique_ptr updateFileQuery; + std::unique_ptr deleteFileQuery; + std::unique_ptr updateThumbnailInfoQueryByName; + std::unique_ptr updateThumbnailInfoQueryById; + std::unique_ptr updatePositionQuery; + std::filesystem::path path; + }; +} + diff --git a/src/AudioFilesTest.cpp b/src/AudioFilesTest.cpp new file mode 100644 index 0000000..3073b1b --- /dev/null +++ b/src/AudioFilesTest.cpp @@ -0,0 +1,536 @@ +// Copyright (c) 2025 Robin 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 "catch.hpp" +#include +#include +#include "AudioFiles.hpp" +#include +#include "SysExec.hpp" +#include "json.hpp" +#include "json_variant.hpp" +#include "MimeTypes.hpp" + +using namespace std; +using namespace pipedal; +namespace fs = std::filesystem; + +fs::path testDir = fs::temp_directory_path() / "AudioFilesTest" / "files"; +fs::path sourceDirectory = fs::current_path() / "artifacts" / "TestTracks"; +fs::path tempDir = fs::temp_directory_path() / "AudioFilesTest" / "tmp"; +fs::path resourceDir = fs::current_path() / "vite" / "public" / "img"; +fs::path textIndexPath = fs::temp_directory_path() / "AudioFilesTest" / "TextIndex.pipedal"; + +static void CreateTestDirectory() +{ + if (fs::exists(testDir)) + { + fs::remove_all(testDir); + } + fs::remove_all(tempDir); + fs::create_directories(testDir); + if (fs::exists(tempDir)) + { + fs::remove_all(tempDir); + } + fs::create_directories(tempDir); + AudioDirectoryInfo::SetTemporaryDirectory(tempDir); + AudioDirectoryInfo::SetResourceDirectory(resourceDir); +} + +static void CheckForLeakedTemporaryFiles() +{ + bool leakedFiles = false; + // Check if the temporary directory exists and is not empty + if (fs::exists(tempDir) && fs::is_directory(tempDir)) + { + for (const auto &entry : fs::directory_iterator(tempDir)) + { + if (entry.is_regular_file()) + { + cout << "Leaked temporary file: " << entry.path() << endl; + leakedFiles = true; + } + } + } + if (fs::exists(tempDir) && fs::is_directory(tempDir / "thumbnails")) + { + for (const auto &entry : fs::directory_iterator(tempDir / "thumbnails")) + { + if (entry.is_regular_file()) + { + cout << "Leaked temporary file: " << entry.path() << endl; + leakedFiles = true; + } + } + } + if (leakedFiles) + { + throw std::runtime_error("Leaked temporary files found in: " + tempDir.string()); + } +} + +static bool ContainsTrack(const std::vector &files, const std::string &trackName) +{ + for (const auto &file : files) + { + if (file.title() == trackName) + { + return true; + } + } + return false; +} +static bool ContainsFile(const std::vector &files, const std::string &fileName) +{ + for (const auto &file : files) + { + if (file.fileName() == fileName) + { + return true; + } + } + return false; +} + +static const AudioFileMetadata *GetFile(const std::vector &files, const std::string &fileName) +{ + for (const auto &file : files) + { + if (file.fileName() == fileName) + { + return &file; + } + } + throw std::logic_error("File not found: " + fileName); +} + +static void PrintFiles(const std::vector &files) +{ + for (const auto &file : files) + { + cout << "File: " << file.fileName() << ", Title: " << file.track() << ". " << file.title() << " Album: " << file.album() << endl; + } + cout << endl; +} + +void IndexTest() +{ + CreateTestDirectory(); + + try + { + { + AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string()); + + } + AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string()); + REQUIRE(audioDir != nullptr); + if (fs::exists(testDir)) + { + fs::remove_all(testDir); // Remove any existing database file + } + fs::create_directories(testDir); + // Copy test files to the test directory + for (const auto &entry : fs::directory_iterator(sourceDirectory)) + { + if (entry.is_regular_file()) + { + fs::copy(entry.path(), testDir / entry.path().filename()); + } + } + + auto files = audioDir->GetFiles(); + PrintFiles(files); + + REQUIRE(files.size() == 3); + + REQUIRE(ContainsTrack(files, "Track 1")); + REQUIRE(ContainsTrack(files, "Track 2")); + REQUIRE(ContainsTrack(files, "Track 3")); + + for (const auto &file : files) + { + REQUIRE(!file.fileName().empty()); + } + + // check indexed results. + files = audioDir->GetFiles(); + REQUIRE(files.size() == 3); + for (const auto &file : files) + { + REQUIRE(!file.fileName().empty()); + } + + fs::remove(testDir / "Track2.mp3"); + + files = audioDir->GetFiles(); + REQUIRE(files.size() == 2); + REQUIRE(!ContainsTrack(files, "Track 2")); + REQUIRE(ContainsTrack(files, "Track 1")); + REQUIRE(ContainsTrack(files, "Track 3")); + + PrintFiles(files); + files = audioDir->GetFiles(); + REQUIRE(files.size() == 2); + REQUIRE(!ContainsTrack(files, "Track 2")); + REQUIRE(ContainsTrack(files, "Track 1")); + REQUIRE(ContainsTrack(files, "Track 3")); + + // check dbinex results. + + fs::copy_file(sourceDirectory / "Track2.mp3", testDir / "Track3.mp3", fs::copy_options::overwrite_existing); + files = audioDir->GetFiles(); + REQUIRE(files.size() == 2); + REQUIRE(ContainsTrack(files, "Track 2")); + REQUIRE(ContainsFile(files, "Track3.mp3")); + REQUIRE(!ContainsFile(files, "Track2.mp3")); + + fs::copy_file(sourceDirectory / "Track3.mp3", testDir / "Track4.mp3", fs::copy_options::overwrite_existing); + files = audioDir->GetFiles(); + REQUIRE(files.size() == 3); + REQUIRE(ContainsTrack(files, "Track 2")); + REQUIRE(ContainsFile(files, "Track3.mp3")); + REQUIRE(GetFile(files, "Track4.mp3")->title() == "Track 3"); + + { + auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200); + REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1); + } + { + auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200); + REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1); + } + { + auto thumbnail = audioDir->GetThumbnail("Track1.mp3", 0, 0); + REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2); + } + CheckForLeakedTemporaryFiles(); + } + catch (const std::exception &e) + { + cout << "Exception: " << e.what() << endl; + REQUIRE(false); + } +} + +static void ThumbnailTest() +{ + cout << "ThumbnailTest" << endl; + CreateTestDirectory(); + + try + { + AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string()); + REQUIRE(audioDir != nullptr); + if (fs::exists(testDir)) + { + fs::remove_all(testDir); // Remove any existing database file + } + fs::create_directories(testDir); + // Copy test files to the test directory + for (const auto &entry : fs::directory_iterator(sourceDirectory)) + { + if (entry.is_regular_file()) + { + fs::copy(entry.path(), testDir / entry.path().filename()); + } + } + + auto files = audioDir->GetFiles(); + + REQUIRE(files.size() == 3); + + for (const auto &file : files) + { + ThumbnailTemporaryFile thumbnail = audioDir->GetThumbnail(file.fileName(), 200, 200); + REQUIRE(fs::exists(thumbnail.Path())); + cout << "Thumbnail for " << file.fileName() << ": " << thumbnail.Path() << endl; + + cout << " " << file.fileName() << "(200,200) size: " << fs::file_size(thumbnail.Path()) << endl; + + thumbnail = audioDir->GetThumbnail(file.fileName(), 0, 0); + REQUIRE(fs::exists(thumbnail.Path())); + cout << "Thumbnail for " << file.fileName() << ": " << thumbnail.Path() << endl; + + cout << " " << file.fileName() << "(0,0) size: " << fs::file_size(thumbnail.Path()) << endl; + } + + REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2 * 2); // 3 files, one file is missing a thumbnail, 2 per file. + + { + ThumbnailTemporaryFile thumbnail = audioDir->GetThumbnail("Track1.mp3", 200, 200); + REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2 * 2); // 3 files, one file is missing a thumbnail, 2 per file. + } + fs::remove(testDir / "Track2.mp3"); + files = audioDir->GetFiles(); + + REQUIRE(audioDir->TestGetNumberOfThumbnails() == 2); + + // update the last write time, should trigger a thumbnail update. + REQUIRE(GetFile(files, "Track1.mp3")->thumbnailType() == ThumbnailType::Embedded); + + fs::copy(sourceDirectory / "Track1.mp3", testDir / "Track1.mp3", fs::copy_options::overwrite_existing); + files = audioDir->GetFiles(); + REQUIRE(GetFile(files, "Track1.mp3")->thumbnailType() == ThumbnailType::Unknown); + REQUIRE(audioDir->TestGetNumberOfThumbnails() == 0); + + audioDir->GetThumbnail("Track1.mp3", 200, 200); + REQUIRE(audioDir->TestGetNumberOfThumbnails() == 1); + + CheckForLeakedTemporaryFiles(); + cout << endl; + } + catch (const std::exception &e) + { + cout << "Exception: " << e.what() << endl; + REQUIRE(false); + } +} + +void ExecutionTimeTest() +{ + cout << "Timing test" << endl; + CreateTestDirectory(); + + // deploy sample files. + fs::create_directories(testDir); + // Copy test files to the test directory + for (const auto &entry : fs::directory_iterator(sourceDirectory)) + { + if (entry.is_regular_file()) + { + fs::copy(entry.path(), testDir / entry.path().filename()); + } + } + + AudioDirectoryInfo::Ptr audioDir = AudioDirectoryInfo::Create(testDir.string()); + audioDir->GetFiles(); + + { + using clock_t = std::chrono::high_resolution_clock; + { + auto start = clock_t::now(); + audioDir->GetThumbnail("Track1.mp3",200,200); + auto duration = clock_t::now() - start; + cout << "Track1.mp3(200,200): " << std::chrono::duration_cast(duration).count() <<"ms" << endl; + } + { + auto start = clock_t::now(); + audioDir->GetThumbnail("Track1.mp3",200,200); + auto duration = clock_t::now() - start; + cout << "Track1.mp3(200,200): " << std::chrono::duration_cast(duration).count() <<"ms" << endl; + } + } +} +TEST_CASE("AudioFiles test", "[AudioFiles]") +{ + + IndexTest(); + ThumbnailTest(); + ExecutionTimeTest(); +} + +// from AudioFileMetadata.cpp +std::string shell_escape_filename(const std::string &filename); + +static void ScanThumbnails() +{ + const std::string homeDir = getenv("HOME"); + std::filesystem::path musicDir = std::filesystem::path(homeDir) / "Music"; + + for (const auto &entry : std::filesystem::recursive_directory_iterator(musicDir)) + { + if (entry.is_directory()) + { + continue; // Skip directories + } + + if (entry.path().extension() == ".mp3" || entry.path().extension() == ".flac") + { + try + { + // ffprobe -i ~/tmp/test.flac -show_streams -show_entries stream=index,codec_name,disposition + + std::stringstream args; + args << "-log_level error -i " << shell_escape_filename(entry.path()) + << " -show_streams -of json -show_entries stream=index,codec_name,disposition 2>/dev/null"; + + std::string sargs = args.str(); + auto output = sysExecForOutput("/usr/bin/ffprobe", sargs, true); + if (output.exitCode != 0) + { + cout << "ffprobe failed for file: " << entry.path() << endl; + continue; + } + std::stringstream ss(output.output); + json_reader reader(ss); + + json_variant vt; + reader.read(&vt); + auto streams = vt.as_object()->at("streams").as_array(); + struct StreamInfo + { + int index; + std::string codec_name; + std::string comment; + }; + std::vector thumbnailStreams; + for (const auto &stream : *streams) + { + auto streamObj = stream.as_object(); + int index = streamObj->at("index").as_int64(); + std::string codecName = streamObj->at("codec_name").as_string(); + std::string comment; + if (streamObj->contains("tags")) + { + auto &tagsObj = streamObj->at("tags"); + auto &tags = tagsObj.as_object(); + if (tags->contains("comment")) + { + comment = tags->at("comment").as_string(); + } + } + thumbnailStreams.push_back({index, codecName, comment}); + } + if (thumbnailStreams.size() > 2) + { + cout << "File: " << entry.path() << endl; + for (const auto &stream : thumbnailStreams) + { + cout << " Stream Index: " << stream.index + << ", Codec: " << stream.codec_name + << ", Comment: " << stream.comment << endl; + } + } + } + catch (const std::exception &e) + { + cout << "Error processing file: " << entry.path() << " - " << e.what() << endl; + } + } + } +} + +class TestTimer { +public: + using clock_t = std::chrono::high_resolution_clock; + void Start() + { + startTime = clock_t::now(); + } + uint64_t Stop() + { + auto elapsed = clock_t::now()-startTime; + elapsedMs = std::chrono::duration_cast(elapsed).count(); + return elapsedMs; + + } + double ElapsedMs() + { + return elapsedMs; + } +private: + clock_t::time_point startTime; + uint64_t elapsedMs = 0; +}; + +static bool isAudioExtension(const std::string &extension) +{ + return MimeTypes::instance().AudioExtensions().contains(extension); +} + + +static bool ContainsMusic(const std::filesystem::path&path) +{ + for (auto dirEntry: fs::directory_iterator(path)) { + if (fs::is_regular_file(dirEntry.path())) { + if (isAudioExtension(dirEntry.path().extension())) { + return true; + } + } + } + return false; +} + +void ProfileMusicDirectory() { + const std::string homeDir = getenv("HOME"); + std::filesystem::path musicDir = std::filesystem::path(homeDir) / "Music"; + + for (const auto &entry : std::filesystem::recursive_directory_iterator(musicDir)) + { + if (!entry.is_directory()) + { + continue; // Skip directories + } + if (!ContainsMusic(entry.path())) { + continue; + } + + fs::remove(textIndexPath); + AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path()); + fs::create_directories(textIndexPath.parent_path()); + directoryInfo->TestSetIndexPath(textIndexPath); + + uint64_t firstTime; + uint64_t dbIndexSize; + uint64_t entries; + { + TestTimer t; + t.Start(); + AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path()); + directoryInfo->TestSetIndexPath(textIndexPath); + entries = directoryInfo->GetFiles().size(); + firstTime = t.Stop(); + dbIndexSize = fs::file_size(textIndexPath); + } + uint64_t secondTime; + { + TestTimer t; + t.Start(); + AudioDirectoryInfo::Ptr directoryInfo = AudioDirectoryInfo::Create(entry.path()); + directoryInfo->TestSetIndexPath(textIndexPath); + directoryInfo->GetFiles(); + secondTime = t.Stop(); + } + cout << entry.path().string() << endl; + cout << " " << "first time: " << firstTime << " second time: " << secondTime << endl; + cout << " " << "size: " << dbIndexSize << " entries: " << entries << endl; + } + +} +TEST_CASE("Search for audio files with multple thumbnails", "[ScanThumbnails]") +{ + AudioDirectoryInfo::SetTemporaryDirectory(tempDir); + AudioDirectoryInfo::SetResourceDirectory(resourceDir); + ScanThumbnails(); // scan all files in music directory. + ProfileMusicDirectory(); // Scans all folders in music directory. + + REQUIRE(true); // Just to ensure the test runs without failure +} + +TEST_CASE("Thumbnail performance test", "[ProfileThumbnails]" ) +{ + AudioDirectoryInfo::SetTemporaryDirectory(tempDir); + AudioDirectoryInfo::SetResourceDirectory(resourceDir); + ProfileMusicDirectory(); // Scans all folders in music directory. + + REQUIRE(true); // Just to ensure the test runs without failure +} diff --git a/src/AudioHost.cpp b/src/AudioHost.cpp index 2e6a76e..a255b9f 100644 --- a/src/AudioHost.cpp +++ b/src/AudioHost.cpp @@ -21,6 +21,7 @@ #include "util.hpp" #include #include "SchedulerPriority.hpp" +#include "AlsaSequencer.hpp" #include "Lv2Log.hpp" @@ -294,12 +295,14 @@ class SystemMidiBinding private: MidiBinding currentBinding; bool controlState = false; + uint8_t lastControlValue = 0; public: void SetBinding(const MidiBinding &binding) { currentBinding = binding; controlState = false; + lastControlValue = 0; } bool IsMatch(const MidiEvent &event); @@ -335,13 +338,18 @@ bool SystemMidiBinding::IsTriggered(const MidiEvent &event) return false; if (event.buffer[1] != currentBinding.control()) return false; - bool state = event.buffer[2] >= 0x64; - if (state != this->controlState) + + uint8_t value = event.buffer[2]; + bool result = false; + if (currentBinding.switchControlType() == SwitchControlTypeT::TRIGGER_ON_RISING_EDGE) { - this->controlState = state; - return state; + result = value >= 0x64 && lastControlValue < 0x64; + } else if (currentBinding.switchControlType() == SwitchControlTypeT::TRIGGER_ON_ANY) + { + result = true; } - return false; + lastControlValue = value; + return result; } break; default: @@ -381,6 +389,9 @@ bool SystemMidiBinding::IsMatch(const MidiEvent &event) class AudioHostImpl : public AudioHost, private AudioDriverHost, private IPatchWriterCallback { private: + AlsaSequencer::ptr alsaSequencer; + AlsaSequencerDeviceMonitor::ptr alsaDeviceMonitor; + void OnWritePatchPropertyBuffer( PatchPropertyWriter::Buffer *); @@ -522,7 +533,7 @@ private: std::string GetAtomObjectType(uint8_t *pData) { - LV2_Atom_Object *pAtom = (LV2_Atom_Object *)pData; + LV2_Atom_Object *pAtom = (LV2_Atom_Object *)pData; if (pAtom->atom.type != uris.atom_Object) { throw std::invalid_argument("Not an Lv2 Object"); @@ -918,11 +929,12 @@ private: if (event.size >= 3) { uint8_t cmd = (uint8_t)(event.buffer[0] & 0xF0); - bool isNote = cmd == 0x90; + bool isNote = cmd == 0x90 && event.buffer[2] != 0; // note on with velocity > 0. bool isControl = cmd == 0xB0; if (isNote || isControl) { - realtimeWriter.OnMidiListen(isNote, event.buffer[1]); + MidiNotifyBody notifyBody (event.buffer[0],event.buffer[1], event.buffer[2]); + realtimeWriter.OnMidiListen(notifyBody); } } } @@ -1173,7 +1185,7 @@ private: if (buffersValid) { - pedalboard->ProcessParameterRequests(pParameterRequests); + pedalboard->ProcessParameterRequests(pParameterRequests,nframes); processed = pedalboard->Run(inputBuffers, outputBuffers, (uint32_t)nframes, &realtimeWriter); if (processed) @@ -1240,12 +1252,26 @@ public: lv2_atom_forge_init(&inputWriterForge, pHost->GetMapFeature().GetMap()); cpuTemperatureMonitor = CpuTemperatureMonitor::Get(); + this->alsaSequencer = AlsaSequencer::Create(); + this->alsaDeviceMonitor = AlsaSequencerDeviceMonitor::Create(); + + this->alsaDeviceMonitor->StartMonitoring( + [this]( + AlsaSequencerDeviceMonitor::MonitorAction action, + int client, + const std::string &clientName) + { + HandleAlsaSequencerDevicesChanged(action, client, clientName); + } + ); } virtual ~AudioHostImpl() { + alsaDeviceMonitor->StopMonitoring(); Close(); CleanRestartThreads(true); audioDriver = nullptr; + this->alsaSequencer = nullptr; } virtual JackConfiguration GetServerConfiguration() @@ -1260,7 +1286,21 @@ public: { return this->sampleRate; } - + void HandleAlsaSequencerDevicesChanged( + AlsaSequencerDeviceMonitor::MonitorAction action, int client, const std::string &clientName) + { + if (pNotifyCallbacks) + { + if (action == AlsaSequencerDeviceMonitor::MonitorAction::DeviceRemoved) + { + pNotifyCallbacks->OnAlsaSequencerDeviceRemoved(client); + } + else if (action == AlsaSequencerDeviceMonitor::MonitorAction::DeviceAdded) + { + pNotifyCallbacks->OnAlsaSequencerDeviceAdded(client,clientName); + } + } + } void HandleAudioTerminatedAbnormally() { Lv2Log::error("Audio processing terminated unexpectedly."); @@ -1333,11 +1373,11 @@ public: { if (command == RingBufferCommand::OnMidiListen) { - uint16_t msg; - hostReader.read(&msg); + MidiNotifyBody body; + hostReader.read(&body); if (this->pNotifyCallbacks) { - pNotifyCallbacks->OnNotifyMidiListen((msg & 0xFF00) != 0, (uint8_t)msg); + pNotifyCallbacks->OnNotifyMidiListen(body.cc0_, body.cc1_, body.cc2_); } } else if (command == RingBufferCommand::MidiValueChanged) @@ -1658,7 +1698,7 @@ public: try { audioDriver->Open(jackServerSettings, this->channelSelection); - + audioDriver->SetAlsaSequencer(this->alsaSequencer); this->sampleRate = audioDriver->GetSampleRate(); this->overrunGracePeriodSamples = (uint64_t)(((uint64_t)this->sampleRate) * OVERRUN_GRACE_PERIOD_S); @@ -1806,6 +1846,9 @@ public: } } + virtual void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) override; + + void OnNotifyPathPatchPropertyReceived( int64_t instanceId, const std::string &pathPatchPropertyUri, @@ -2080,8 +2123,8 @@ public: return result; } - volatile bool listenForMidiEvent = false; - volatile bool listenForAtomOutput = false; + std::atomic listenForMidiEvent = false; + std::atomic listenForAtomOutput = false; virtual void SetListenForMidiEvent(bool listen) { @@ -2253,6 +2296,13 @@ void AudioHostImpl::OnWritePatchPropertyBuffer( this->realtimeWriter.SendPathPropertyBuffer(buffer); } +void AudioHostImpl::SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) +{ + this->alsaSequencer->SetConfiguration(alsaSequencerConfiguration); +} + + + JSON_MAP_BEGIN(JackHostStatus) JSON_MAP_REFERENCE(JackHostStatus, active) JSON_MAP_REFERENCE(JackHostStatus, errorMessage) diff --git a/src/AudioHost.hpp b/src/AudioHost.hpp index 9942042..16a9fb3 100644 --- a/src/AudioHost.hpp +++ b/src/AudioHost.hpp @@ -39,6 +39,7 @@ namespace pipedal struct RealtimeNextMidiProgramRequest; class PluginHost; class Pedalboard; + class AlsaSequencerConfiguration; using PortMonitorCallback = std::function; @@ -69,6 +70,7 @@ namespace pipedal const char *errorMessage = nullptr; std::string jsonResponse; + int64_t sampleTimeout = 0; RealtimePatchPropertyRequest *pNext = nullptr; @@ -102,13 +104,16 @@ namespace pipedal int64_t instanceId_, LV2_URID uridUri_, std::function onSuccess_, - std::function onError_) + std::function onError_, + size_t sampleTimeout) : onPatchRequestComplete(onPatchRequestcomplete_), clientId(clientId_), instanceId(instanceId_), uridUri(uridUri_), onSuccess(onSuccess_), - onError(onError_) + onError(onError_), + sampleTimeout((int64_t)sampleTimeout) + { requestType = RequestType::PatchGet; } @@ -119,13 +124,15 @@ namespace pipedal LV2_URID uridUri_, LV2_Atom *atomValue, std::function onSuccess_, - std::function onError_) + std::function onError_, + size_t sampleTimeout) : onPatchRequestComplete(onPatchRequestcomplete_), clientId(clientId_), instanceId(instanceId_), uridUri(uridUri_), onSuccess(onSuccess_), - onError(onError_) + onError(onError_), + sampleTimeout(sampleTimeout) { requestType = RequestType::PatchSet; size_t size = atomValue->size + sizeof(LV2_Atom); @@ -152,7 +159,7 @@ namespace pipedal virtual void OnNotifyVusSubscription(const std::vector &updates) = 0; virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) = 0; virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) = 0; - virtual void OnNotifyMidiListen(bool isNote, uint8_t noteOrControl) = 0; + virtual void OnNotifyMidiListen(uint8_t cc0, uint8_t cc1, uint8_t cc2) = 0; virtual void OnNotifyPathPatchPropertyReceived( int64_t instanceId, @@ -172,6 +179,8 @@ namespace pipedal virtual void OnNotifyMidiRealtimeSnapshotRequest(int32_t snapshotIndex,int64_t snapshotRequestId) = 0; virtual void OnAlsaDriverTerminatedAbnormally() = 0; + virtual void OnAlsaSequencerDeviceAdded(int client, const std::string &clientName) = 0; + virtual void OnAlsaSequencerDeviceRemoved(int client) = 0; }; class JackHostStatus @@ -219,6 +228,7 @@ namespace pipedal virtual void Open(const JackServerSettings &jackServerSettings, const JackChannelSelection &channelSelection) = 0; virtual void Close() = 0; + virtual void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) = 0; virtual uint32_t GetSampleRate() = 0; virtual JackConfiguration GetServerConfiguration() = 0; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 30878ff..7055495 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,8 +9,19 @@ set (ENABLE_BACKTRACE 0) set (USE_SANITIZE OFF) # seems to be broken on Ubuntu 24.10 + set(CXX_STANDARD 20) +find_package(PkgConfig REQUIRED) + +pkg_check_modules(PipeWire REQUIRED libpipewire-0.3) + + +message(STATUS "PipeWire_INCLUDE_DIRS: ${PipeWire_INCLUDE_DIRS}" + "PipeWire_LIBRARIES: ${PipeWire_LIBRARIES}" + "PipeWire_CFLAGS: ${PipeWire_CFLAGS}" + "PipeWire_DEFINES: ${PipeWire_DEFINES}" + "PipeWire_VERSION: ${PipeWire_VERSION}") include(FetchContent) @@ -46,6 +57,8 @@ include(FindPkgConfig) find_package(sdbus-c++ REQUIRED) + + find_package(ICU REQUIRED COMPONENTS uc i18n ) message(STATUS "ICU_LIBRARIES: ${ICU_LIBRARIES}") @@ -149,7 +162,8 @@ add_compile_definitions(DEBIAN_ARCHITECTURE="${DEBIAN_ARCHITECTURE}") if (CMAKE_BUILD_TYPE MATCHES Debug) message(STATUS "Debug build") - set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -g -DDEBUG -D_GLIBCXX_DEBUG" ) + # Must not -D_GLIBCXX_DEBUG, since it conflict with SQLiteCpp.lol + set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O0 -g -DDEBUG " ) if (USE_SANITIZE) set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -static-libasan " ) endif() @@ -192,6 +206,12 @@ else() endif() set (PIPEDAL_SOURCES + PipewireInputStream.cpp PipewireInputStream.hpp + AudioFiles.cpp AudioFiles.hpp + AudioFileMetadataReader.cpp AudioFileMetadataReader.hpp + AudioFileMetadata.hpp AudioFileMetadata.cpp + AudioFilesDb.hpp AudioFilesDb.cpp + LRUCache.hpp CpuTemperatureMonitor.cpp CpuTemperatureMonitor.hpp SchedulerPriority.hpp SchedulerPriority.cpp ModFileTypes.cpp ModFileTypes.hpp @@ -208,7 +228,6 @@ set (PIPEDAL_SOURCES Lv2PluginChangeMonitor.cpp Lv2PluginChangeMonitor.hpp WebServerConfig.cpp WebServerConfig.hpp Locale.hpp Locale.cpp - Finally.hpp ZipFile.cpp ZipFile.hpp TemporaryFile.cpp TemporaryFile.hpp FilePropertyDirectoryTree.cpp FilePropertyDirectoryTree.hpp @@ -266,6 +285,7 @@ set (PIPEDAL_SOURCES LogFeature.hpp LogFeature.cpp Worker.hpp Worker.cpp OptionsFeature.hpp OptionsFeature.cpp + FileMetadataFeature.hpp FileMetadataFeature.cpp VuUpdate.hpp VuUpdate.cpp Units.hpp Units.cpp RingBuffer.hpp @@ -304,6 +324,7 @@ set (PIPEDAL_INCLUDES ${JACK_INCLUDE_DIRS} ${LILV_0_INCLUDE_DIRS} ${VST3_INCLUDES} ${WEBSOCKETPP_INCLUDE_DIRS} + ${PipeWire_INCLUDE_DIRS} . ) @@ -313,8 +334,8 @@ set(PIPEDAL_LIBS libpipedald zip pthread atomic stdc++fs asound avahi-common avahi-client systemd ${VST3_LIBRARIES} ${LILV_0_LIBRARIES} + asound # ${JACK_LIBRARIES} - pending delete for JACK support. - ) @@ -323,10 +344,17 @@ set(PIPEDAL_LIBS libpipedald zip add_library(libpipedald STATIC ${PIPEDAL_SOURCES}) -target_include_directories(libpipedald PRIVATE ${PIPEDAL_INCLUDES} +target_compile_definitions(libpipedald PUBLIC "_REENTRANT") + +target_include_directories(libpipedald PUBLIC ${PIPEDAL_INCLUDES} ) -target_link_libraries(libpipedald PUBLIC PiPedalCommon) +target_link_libraries(libpipedald + PUBLIC + PiPedalCommon + SQLiteCpp + ${PipeWire_LIBRARIES} + ) if(${USE_PCH}) target_precompile_headers(libpipedald PRIVATE pch.h) @@ -348,8 +376,9 @@ target_link_libraries(pipedal_kconfig PRIVATE ftxui::dom PRIVATE ftxui::component # Not needed for this example. PRIVATE PiPedalCommon + asound systemd -) +) ################################# add_executable(pipedald @@ -366,6 +395,7 @@ target_link_libraries(pipedald PRIVATE PiPedalCommon ${PIPEDAL_LIBS} ) + ################################# add_executable(hotspotManagerTest hotspotManagerTestMain.cpp @@ -373,7 +403,7 @@ add_executable(hotspotManagerTest target_link_libraries(hotspotManagerTest PRIVATE ${PIPEDAL_LIBS}) -set_target_properties(hotspotManagerTest PROPERTIES EXCLUDE_FROM_ALL true) +set_target_properties(hotspotManagerTest PROPERTIES EXCLUDE_FROM_ALL ${PIPEDAL_EXCLUDE_TESTS}) add_executable(AuxInTest @@ -383,8 +413,11 @@ add_executable(AuxInTest ) add_executable(pipedaltest -testMain.cpp + testMain.cpp + PipewireInputStreamTest.cpp + AudioFilesTest.cpp + LRUCacheTest.cpp ModFileTypesTest.cpp jsonTest.cpp UpdaterTest.cpp @@ -412,7 +445,7 @@ target_link_libraries(pipedaltest PRIVATE ${PIPEDAL_LIBS} ${ICU_LIBRARIES}) target_include_directories(pipedaltest PRIVATE ${PIPEDAL_INCLUDES} ) -set_target_properties(pipedaltest PROPERTIES EXCLUDE_FROM_ALL true) +set_target_properties(pipedaltest PROPERTIES EXCLUDE_FROM_ALL ${PIPEDAL_EXCLUDE_TESTS}) if (NOT GITHUB_ACTIONS) # Google perftools are not available on Github action servers. @@ -448,7 +481,7 @@ target_link_libraries(jsonTest PRIVATE PiPedalCommon) target_include_directories(jsonTest PRIVATE ${PIPEDAL_INCLUDES} ) -set_target_properties(jsonTest PROPERTIES EXCLUDE_FROM_ALL true) +set_target_properties(jsonTest PROPERTIES EXCLUDE_FROM_ALL ${PIPEDAL_EXCLUDE_TESTS}) target_link_libraries(jsonTest PRIVATE pthread) @@ -822,7 +855,7 @@ add_custom_command(OUTPUT ${REACT_NOTICES_FILE} --output ${REACT_NOTICES_FILE} --projectCopyright ${DEBIAN_COPYRIGHT_FILE} liblilv-0-0 ${BOOST_COPYRIGHT_DIR} lv2-dev libsdbus-c++-dev librsvg2-2 libpango-1.0-0 libx11-6 libxrandr2 - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} +WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} DEPENDS ${DEBIAN_COPYRIGHT_FILE} "$" COMMENT "Updating copyright notices." ) diff --git a/src/DummyAudioDriver.cpp b/src/DummyAudioDriver.cpp index a4b58c5..37b25bb 100644 --- a/src/DummyAudioDriver.cpp +++ b/src/DummyAudioDriver.cpp @@ -104,6 +104,11 @@ namespace pipedal captureChannels = channels; playbackChannels = channels; + midiEventMemoryIndex = 0; + midiEventMemory.resize(MIDI_MEMORY_BUFFER_SIZE); + midiEvents.resize(MAX_MIDI_EVENT); + + } virtual ~DummyDriverImpl() { @@ -122,6 +127,16 @@ namespace pipedal } JackServerSettings jackServerSettings; + AlsaSequencer::ptr alsaSequencer; + + static constexpr size_t MIDI_MEMORY_BUFFER_SIZE = 32 * 1024; + static constexpr size_t MAX_MIDI_EVENT = 4 * 1024; + + size_t midiEventCount = 0; + std::vector midiEvents; + size_t midiEventMemoryIndex = 0; + std::vector midiEventMemory; + unsigned int periods = 0; @@ -158,11 +173,13 @@ namespace pipedal } } - virtual size_t GetMidiInputEventCount() override { - return 0; + virtual size_t GetMidiInputEventCount() override + { + return midiEventCount; } - virtual MidiEvent*GetMidiEvents() { - return nullptr; + virtual MidiEvent *GetMidiEvents() override + { + return this->midiEvents.data(); } @@ -190,7 +207,11 @@ namespace pipedal throw; } } - virtual std::string GetConfigurationDescription() + virtual void SetAlsaSequencer(AlsaSequencer::ptr alsaSequencer) override + { + this->alsaSequencer = alsaSequencer; + } + virtual std::string GetConfigurationDescription() { std::string result = SS( "DUMMY, " @@ -219,6 +240,43 @@ namespace pipedal bool block = false; + void ReadMidiData(uint32_t audioFrame) + { + AlsaMidiMessage message; + + midiEventCount = 0; + while(alsaSequencer->ReadMessage(message,0)) + { + size_t messageSize = message.size; + if (messageSize == 0) + { + continue; + } + if (midiEventMemoryIndex + messageSize >= this->midiEventMemory.size()) { + continue; + } + if (midiEventCount >= this->midiEvents.size()) { + midiEvents.resize(midiEventCount*2); + } + // for now, prevent META event messages from propagating. + if (message.data[0] == 0xFF && message.size > 1) { + continue; + } + MidiEvent *pEvent = midiEvents.data() + midiEventCount++; + pEvent->time = audioFrame; + pEvent->size = messageSize; + pEvent->buffer = midiEventMemory.data() + midiEventMemoryIndex; + + memcpy( + midiEventMemory.data() + midiEventMemoryIndex, + message.data, + message.size); + midiEventMemoryIndex += messageSize; + + } + } + + void AudioThread() { SetThreadName("dummyAudioDriver"); @@ -231,11 +289,14 @@ namespace pipedal while (true) { + if (terminateAudio()) { break; } + ReadMidiData((uint32_t)0); + ssize_t framesRead = this->bufferSize; this->driverHost->OnProcess(framesRead); @@ -335,8 +396,6 @@ namespace pipedal Lv2Log::debug("Audio thread joined."); } - static constexpr size_t MIDI_BUFFER_SIZE = 16 * 1024; - static constexpr size_t MAX_MIDI_EVENT = 4 * 1024; public: diff --git a/src/FileEntry.cpp b/src/FileEntry.cpp index 3c77073..8cdde41 100644 --- a/src/FileEntry.cpp +++ b/src/FileEntry.cpp @@ -26,6 +26,7 @@ JSON_MAP_BEGIN(FileEntry) JSON_MAP_REFERENCE(FileEntry,displayName) JSON_MAP_REFERENCE(FileEntry,isProtected) JSON_MAP_REFERENCE(FileEntry,isDirectory) + JSON_MAP_REFERENCE(FileEntry,metadata) JSON_MAP_END() JSON_MAP_BEGIN(BreadcrumbEntry) diff --git a/src/FileEntry.hpp b/src/FileEntry.hpp index c040955..f1a7241 100644 --- a/src/FileEntry.hpp +++ b/src/FileEntry.hpp @@ -20,6 +20,7 @@ #pragma once #include "json.hpp" +#include "AudioFileMetadata.hpp" namespace pipedal { @@ -30,11 +31,19 @@ namespace pipedal { :pathname_(pathname), displayName_(displayName), isDirectory_(isDirectory),isProtected_(isProtected) { + } + FileEntry(const std::string&pathname,const std::string &displayName, bool isProtected, + std::shared_ptr metadata + ) + :pathname_(pathname), displayName_(displayName), isDirectory_(false),isProtected_(isProtected),metadata_(metadata) + { + } std::string pathname_; std::string displayName_; bool isDirectory_ = false; bool isProtected_ = false; + std::shared_ptr metadata_; DECLARE_JSON_MAP(FileEntry); diff --git a/src/FileMetadataFeature.cpp b/src/FileMetadataFeature.cpp new file mode 100644 index 0000000..10c0019 --- /dev/null +++ b/src/FileMetadataFeature.cpp @@ -0,0 +1,263 @@ +// Copyright (c) 2022 Robin 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 "FileMetadataFeature.hpp" +#include "AudioFiles.hpp" +#include "Lv2Log.hpp" +#include "ss.hpp" +#include "util.hpp" +#include "json.hpp" +#include "json_variant.hpp" + +#include +#include + +#include "lv2/lv2plug.in/ns/ext/buf-size/buf-size.h" +using namespace pipedal; +namespace fs = std::filesystem; + +FileMetadataFeature::FileMetadataFeature() +{ + feature.URI = PIPEDAL__FILE_METADATA_FEATURE; + feature.data = &interface; + interface.handle = (void *)this; + interface.setFileMetadata = &FileMetadataFeature::S_setFileMetadata; + interface.getFileMetadata = &FileMetadataFeature::S_getFileMetadata; + interface.deleteFileMetadata = &FileMetadataFeature::S_deleteFileMetadata; +} + +FileMetadataFeature::~FileMetadataFeature() +{ +} + +void FileMetadataFeature::SetPluginStoragePath(const std::filesystem::path &storagePath) +{ + this->tracksPath = storagePath / "shared" / "audio" / "Tracks"; +} + +// Check if the path is within the tracks directory +bool FileMetadataFeature::IsValidPath(const std::filesystem::path &path) const +{ + if (tracksPath.empty()) + { + return false; + } + if (HasDotDot(path)) + { + return false; + } + // is a subdirectory of track path + // check for directory seperator + if (!IsSubdirectory(path, tracksPath)) + { + return false; // Valid subdirectory + } + return true; +} +void FileMetadataFeature::Prepare(MapFeature &map) +{ + this->mapFeature = ↦ +} + +PIPEDAL_FileMetadata_Status FileMetadataFeature::setFileMetadata( + const char *absolute_path, + const char *key, + const char *value) +{ + if (!IsValidPath(absolute_path)) + { + return PIPEDAL_FILE_METADATA_INVALID_PATH; // Cannot set metadata for files in the tracks directory + } + + std::filesystem::path path{SS(absolute_path << ".mdata")}; + + json_variant jsonData; + if (fs::exists(path)) + { + try + { + std::ifstream f(path); + json_reader reader(f); + reader.read(&jsonData); + } + catch (const std::exception &e) + { + jsonData = json_variant::make_object(); + } + } + else + { + jsonData = json_variant::make_object(); + } + + (*jsonData.as_object())[key] = json_variant(value); + + { + std::ofstream f(path); + if (!f.is_open()) + { + Lv2Log::error(SS("Failed to write metadata file " << path)); + return PIPEDAL_FILE_METADATA_PERMISSION_DENIED; // Failed to open existing metadata + } + json_writer writer(f); + writer.write(jsonData); + } + Lv2Log::debug(SS("Set file metadata for " << absolute_path << " key: " << key << " value: " << value)); + return PIPEDAL_FILE_METADATA_SUCCESS; +} + +uint32_t FileMetadataFeature::getFileMetadata( + const char *absolute_path, + const char *key, + char *fileMetadata, + uint32_t fileMetadataSize) +{ + if (fileMetadata) + fileMetadata[0] = '\0'; // Ensure the buffer is null-terminated + + if (!IsValidPath(absolute_path)) + { + return 0; // Cannot set metadata for files in the tracks directory + } + + std::filesystem::path path{SS(absolute_path << ".mdata")}; + + json_variant jsonData; + std::string result; + + try + { + if (fs::exists(path)) + { + std::ifstream f(path); + json_reader reader(f); + reader.read(&jsonData); + result = jsonData.as_object()->at(std::string(key)).as_string(); + } + else + { + Lv2Log::debug(SS("No metadata file found for " << absolute_path << " key: " << key)); + return 0; + } + } + catch (const std::exception &e) + { + Lv2Log::debug(SS("Permission denied for " << absolute_path << " key: " << key)); + return 0; + } + + size_t len = result.length() + 1; // +1 for null terminator + if (len > fileMetadataSize || fileMetadata == nullptr) + { + // Not enough space in the buffer, return the size needed. + return len; + } + memcpy(fileMetadata, result.c_str(), len); + Lv2Log::debug(SS("Get file metadata for " << absolute_path << " key: " << key << " value: " << result)); + return len; +} +PIPEDAL_FileMetadata_Status FileMetadataFeature::deleteFileMetadata( + const char *absolute_path, + const char *key) +{ + if (!IsValidPath(absolute_path)) + { + return PIPEDAL_FILE_METADATA_INVALID_PATH; // Cannot set metadata for files in the tracks directory + } + Lv2Log::debug(SS("Delete file metadata for " << absolute_path << " key: " << key)); + + try { + std::filesystem::path path{SS(absolute_path << ".mdata")}; + + if (!fs::exists(path)) + { + return PIPEDAL_FILE_METADATA_NOT_FOUND; // Metadata file does not exist + } + + json_variant jsonData; + try + { + std::ifstream f(path); + json_reader reader(f); + reader.read(&jsonData); + } + catch (const std::exception &e) + { + return PIPEDAL_FILE_METADATA_NOT_FOUND; // Failed to read existing metadata + } + + auto obj = jsonData.as_object(); + auto it = obj->find(std::string(key)); + if (it == obj->end()) + { + return PIPEDAL_FILE_METADATA_NOT_FOUND; // Key not found + } + + obj->erase(it); + + if (obj->begin() == obj->end()) + { + // If the object is empty, delete the metadata file + fs::remove(path); + return PIPEDAL_FILE_METADATA_SUCCESS; // Metadata deleted successfully + } + + std::ofstream f(path); + if (!f.is_open()) + { + Lv2Log::error(SS("Failed to write metadata file " << path)); + return PIPEDAL_FILE_METADATA_PERMISSION_DENIED; // Failed to open existing metadata + } + + json_writer writer(f); + writer.write(jsonData); + + return PIPEDAL_FILE_METADATA_SUCCESS; + } catch (const std::exception &e) { + Lv2Log::error(SS("Exception while deleting metadata: " << e.what())); + return PIPEDAL_FILE_METADATA_PERMISSION_DENIED; // Failed to open existing metadata + } +} + +PIPEDAL_FileMetadata_Status FileMetadataFeature::S_setFileMetadata( + PIPEDAL_FILE_METADATA_Handle handle, + const char *absolute_path, + const char *key, + const char *fileMetadata) +{ + return ((FileMetadataFeature *)handle)->setFileMetadata(absolute_path, key, fileMetadata); +} +uint32_t FileMetadataFeature::S_getFileMetadata( + PIPEDAL_FILE_METADATA_Handle handle, + const char *absolute_path, + const char *key, + char *buffer, + uint32_t bufferSize) +{ + return ((FileMetadataFeature *)handle)->getFileMetadata(absolute_path, key, buffer, bufferSize); +} + +PIPEDAL_FileMetadata_Status FileMetadataFeature::S_deleteFileMetadata( + PIPEDAL_FILE_METADATA_Handle handle, + const char *absolute_path, + const char *key) +{ + return ((FileMetadataFeature *)handle)->deleteFileMetadata(absolute_path, key); +} diff --git a/src/FileMetadataFeature.hpp b/src/FileMetadataFeature.hpp new file mode 100644 index 0000000..17092f0 --- /dev/null +++ b/src/FileMetadataFeature.hpp @@ -0,0 +1,85 @@ +// Copyright (c) 2022 Robin 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 "MapFeature.hpp" +#include "PiPedalUI.hpp" +#include "lv2ext/pipedal.lv2/ext/FileMetadataFeature.h" + +namespace pipedal +{ + + class FileMetadataFeature + { + + private: + LV2_Feature feature; + PIPEDAL_FileMetadata_Interface interface; + static PIPEDAL_FileMetadata_Status S_setFileMetadata( + PIPEDAL_FILE_METADATA_Handle handle, + const char *absolute_path, + const char*key, + const char *fileMetadata); + + PIPEDAL_FileMetadata_Status setFileMetadata( + const char *absolute_path, + const char*key, + const char *fileMetadata); + + static uint32_t S_getFileMetadata( + PIPEDAL_FILE_METADATA_Handle handle, + const char *filePath, + const char*key, + char *buffer, + uint32_t bufferSize); + + uint32_t getFileMetadata( + const char *absolute_path, + const char*key, + char *fileMetadata, + uint32_t fileMetadataSize); + + static PIPEDAL_FileMetadata_Status S_deleteFileMetadata( + PIPEDAL_FILE_METADATA_Handle handle, + const char *absolute_path, + const char*key); + + PIPEDAL_FileMetadata_Status deleteFileMetadata( + const char *absolute_path, + const char*key); + + + bool IsValidPath(const std::filesystem::path &path) const; + public: + FileMetadataFeature(); + void Prepare(MapFeature &map); + void SetPluginStoragePath(const std::filesystem::path &storagePath); + ~FileMetadataFeature(); + + public: + std::filesystem::path tracksPath; + MapFeature *mapFeature = nullptr; + const LV2_Feature *GetFeature() + { + return &feature; + } + }; + +} \ No newline at end of file diff --git a/src/HotspotManager.cpp b/src/HotspotManager.cpp index 0e055c6..5a936d2 100644 --- a/src/HotspotManager.cpp +++ b/src/HotspotManager.cpp @@ -80,7 +80,7 @@ namespace pipedal::impl bool hasWifi = false; HasWifiListener hasWifiListener; void SetHasWifi(bool hasWifi); - virtual bool GetHasWifi() override ; + virtual bool GetHasWifi() override; void onClose(); void onError(const std::string &message); @@ -347,7 +347,7 @@ void HotspotManagerImpl::onStartMonitoring() this->networkManager = NetworkManager::Create(dbusDispatcher); - if (!networkManager->WirelessEnabled()) + if (!networkManager->WirelessEnabled() && wifiConfigSettings.NeedsWifi()) { networkManager->WirelessEnabled(true); } @@ -483,6 +483,7 @@ void HotspotManagerImpl::onReload() // force a reload. StopHotspot(); SetState(State::Monitoring); + StartScanTimer(); // or stop it as the case may be. MaybeStartHotspot(); return; } @@ -758,16 +759,26 @@ void HotspotManagerImpl::MaybeStartHotspot() // devices are transitioning. Do nothing. return; } - std::vector allAccessPoints = GetAllAccessPoints(); - std::vector> allAccessPointSsids = GetAccessPointSsids(allAccessPoints); - std::vector> connectableSsids = GetKnownVisibleAccessPoints(allAccessPointSsids); // all the ssids currently visible for which we have credentials. - this->UpdateKnownNetworks(connectableSsids, allAccessPoints); + bool wantsHotspot; - bool wantsHotspot = this->wifiConfigSettings.WantsHotspot( + if (wifiConfigSettings.NeedsScan()) { - this->ethernetConnected, connectableSsids, allAccessPointSsids); + std::vector allAccessPoints = GetAllAccessPoints(); + std::vector> allAccessPointSsids = GetAccessPointSsids(allAccessPoints); + std::vector> connectableSsids = GetKnownVisibleAccessPoints(allAccessPointSsids); // all the ssids currently visible for which we have credentials. + this->UpdateKnownNetworks(connectableSsids, allAccessPoints); + + wantsHotspot = this->wifiConfigSettings.WantsHotspot( + this->ethernetConnected, connectableSsids, allAccessPointSsids); + + + } else { + wantsHotspot = this->wifiConfigSettings.WantsHotspot( + this->ethernetConnected); + } + if (this->state == State::Monitoring && wantsHotspot) { StartHotspot(); @@ -996,8 +1007,14 @@ void HotspotManagerImpl::ScanNow() try { - Lv2Log::debug("Scanning"); - wlanWirelessDevice->RequestScan(options); + if (this->wifiConfigSettings.NeedsScan()) + { + + Lv2Log::debug("Scanning"); + wlanWirelessDevice->RequestScan(options); + } else { + return; + } } catch (const std::exception &e) { @@ -1036,7 +1053,8 @@ void HotspotManagerImpl::SetHasWifiListener(HasWifiListener &&listener) this->hasWifiListener(this->hasWifi); } } -bool HotspotManagerImpl::GetHasWifi() { +bool HotspotManagerImpl::GetHasWifi() +{ std::lock_guard lock{this->networkChangingListenerMutex}; return hasWifi; } @@ -1112,17 +1130,20 @@ static bool IsNetworkManagerRunning() return gUsingNetworkManager; } bool bResult = false; - auto result = sysExecForOutput("systemctl","is-active NetworkManager"); + auto result = sysExecForOutput("systemctl", "is-active NetworkManager"); if (result.exitCode == EXIT_SUCCESS) { - std::string text = result.output.erase(result.output.find_last_not_of(" \n\r\t")+1); + std::string text = result.output.erase(result.output.find_last_not_of(" \n\r\t") + 1); if (text == "active") { bResult = true; - } else if (text == "inactive") + } + else if (text == "inactive") { bResult = false; - } else { + } + else + { throw std::runtime_error(SS("UsingNetworkManager: unexpected result (" << text << ")")); } gNetworkManagerTestExecuted = true; @@ -1134,19 +1155,17 @@ static bool IsNetworkManagerRunning() return false; } - bool HotspotManager::HasWifiDevice() { // use procfs to decide this, as NetworkManager may not be available yet. - if ( !(get_wireless_interfaces_sysfs().empty()) ) + if (!(get_wireless_interfaces_sysfs().empty())) { return false; } - if (!IsNetworkManagerRunning()) + if (!IsNetworkManagerRunning()) { return false; } return true; - } diff --git a/src/JackConfiguration.cpp b/src/JackConfiguration.cpp index 6ea3949..da7e256 100644 --- a/src/JackConfiguration.cpp +++ b/src/JackConfiguration.cpp @@ -23,6 +23,7 @@ #include "AlsaDriver.hpp" #include "Lv2Log.hpp" #include "PiPedalException.hpp" +#include "AlsaSequencer.hpp" #if JACK_HOST @@ -106,6 +107,23 @@ namespace pipedal { #endif } +static std::vector GetAlsaSequencers() { + std::vector result; + try { + auto sequencer = AlsaSequencer::Create(); + if (sequencer) + { + std::vector ports = sequencer->EnumeratePorts(); + for (const auto &port : ports) + { + result.push_back(AlsaMidiDeviceInfo(port.id.c_str(), port.name.c_str())); + } + } + } catch (const std::exception& e) { + Lv2Log::error("Failed to enumerate ALSA sequencers: %s", e.what()); + } + return result; +} void JackConfiguration::AlsaInitialize( const JackServerSettings &jackServerSettings) { @@ -116,7 +134,7 @@ void JackConfiguration::AlsaInitialize( { this->inputMidiDevices_.clear(); } else { - this->inputMidiDevices_ = GetAlsaMidiInputDevices(); + this->inputMidiDevices_ = GetAlsaSequencers(); // NB: Sequencers, not rawmidi devices, anymore. } if (jackServerSettings.IsValid()) { diff --git a/src/JackConfiguration.hpp b/src/JackConfiguration.hpp index 59a2ec8..b76abb0 100644 --- a/src/JackConfiguration.hpp +++ b/src/JackConfiguration.hpp @@ -107,7 +107,13 @@ namespace pipedal return outputAudioPorts_; } - const std::vector& GetInputMidiDevices() const + // replaced with AlsaSequencerConfiguration + const std::vector& LegacyGetInputMidiDevices() const + { + return inputMidiDevices_; + } + // replaced with AlsaSequencerConfiguration + std::vector& LegacyGetInputMidiDevices() { return inputMidiDevices_; } diff --git a/src/LRUCache.hpp b/src/LRUCache.hpp new file mode 100644 index 0000000..c5a3c67 --- /dev/null +++ b/src/LRUCache.hpp @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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 +#include +#include + +template +class LRUCache { +public: + struct CacheNode { + KEY key; + VALUE value; + CacheNode(KEY k, VALUE v) : key(k), value(v) {} + }; +private: + size_t capacity; + std::list cache_list; // Doubly-linked list for LRU order + std::unordered_map::iterator> cache_map; // Map key to list iterator + +public: + explicit LRUCache(size_t cap) : capacity(cap) { + if (cap == 0) { + throw std::invalid_argument("Cache capacity must be greater than 0"); + } + } + + // Get value by key, return false if not found + bool get(const KEY& key, VALUE& value) { + auto it = cache_map.find(key); + if (it == cache_map.end()) { + return false; + } + // Move to front (most recently used) + cache_list.splice(cache_list.begin(), cache_list, it->second); + value = it->second->value; + return true; + } + + // Put key-value pair into cache + void put(const KEY& key, const VALUE& value) { + auto it = cache_map.find(key); + if (it != cache_map.end()) { + // Update existing key + cache_list.splice(cache_list.begin(), cache_list, it->second); + it->second->value = value; + return; + } + + // Add new key-value pair + cache_list.emplace_front(key, value); + cache_map[key] = cache_list.begin(); + + // Evict least recently used if over capacity + if (cache_map.size() > capacity) { + auto lru = cache_list.back(); + cache_map.erase(lru.key); + cache_list.pop_back(); + } + } + + // Check if key exists + bool contains(const KEY& key) const { + return cache_map.find(key) != cache_map.end(); + } + + // Get current size + size_t size() const { + return cache_map.size(); + } + + // Get capacity + size_t get_capacity() const { + return capacity; + } + const std::list &cache() const { + return cache_list; + } +}; + diff --git a/src/LRUCacheTest.cpp b/src/LRUCacheTest.cpp new file mode 100644 index 0000000..1d58126 --- /dev/null +++ b/src/LRUCacheTest.cpp @@ -0,0 +1,67 @@ +// Copyright (c) 2025 Robin 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 "catch.hpp" +#include "LRUCache.hpp" +#include +#include + +using namespace std; + +TEST_CASE( "LRUCache test", "[lrucache]" ) { + + LRUCache lruCache(5); + + for (int i = 0; i < 10; ++i) + { + lruCache.put(i,i); + } + REQUIRE(lruCache.cache().size() == 5); + + REQUIRE(lruCache.cache().begin()->value == 9); + + int value; + REQUIRE(lruCache.get(5,value) == true); + REQUIRE(value == 5); + REQUIRE(lruCache.cache().begin()->value == 5); + + cout << '['; + for (auto it = lruCache.cache().begin();it != lruCache.cache().end(); ++it) + { + cout << it->value << ','; + } + cout << ']' << endl; + for (auto it = ++lruCache.cache().begin();it != lruCache.cache().end(); ++it) + { + REQUIRE(it->value != 5); + } + lruCache.put(1,1); + cout << '['; + for (auto it = lruCache.cache().begin();it != lruCache.cache().end(); ++it) + { + cout << it->value << ','; + } + cout << ']' << endl; + + + + + +} \ No newline at end of file diff --git a/src/Lv2Effect.cpp b/src/Lv2Effect.cpp index 915164c..6c0b47c 100644 --- a/src/Lv2Effect.cpp +++ b/src/Lv2Effect.cpp @@ -1116,3 +1116,4 @@ void Lv2Effect::SetPathPatchProperty(const std::string &propertyUri, const std:: { mainThreadPathProperties[propertyUri] = jsonAtom; } + diff --git a/src/Lv2Effect.hpp b/src/Lv2Effect.hpp index 27afc7b..6fe1117 100644 --- a/src/Lv2Effect.hpp +++ b/src/Lv2Effect.hpp @@ -56,6 +56,7 @@ namespace pipedal virtual void OnLogDebug(const char*message); private: + std::unordered_map controlIndex; FileBrowserFilesFeature fileBrowserFilesFeature; diff --git a/src/Lv2Pedalboard.cpp b/src/Lv2Pedalboard.cpp index fbb1950..5fa82e0 100644 --- a/src/Lv2Pedalboard.cpp +++ b/src/Lv2Pedalboard.cpp @@ -562,11 +562,13 @@ void Lv2Pedalboard::GatherPathPatchProperties(IPatchWriterCallback *cbPatchWrite } } -void Lv2Pedalboard::ProcessParameterRequests(RealtimePatchPropertyRequest *pParameterRequests) +void Lv2Pedalboard::ProcessParameterRequests(RealtimePatchPropertyRequest *pParameterRequests, size_t samplesThisTime) { while (pParameterRequests != nullptr) { + pParameterRequests->sampleTimeout -= samplesThisTime; IEffect *pEffect = this->GetEffect(pParameterRequests->instanceId); + if (pEffect == nullptr) { pParameterRequests->errorMessage = "No such effect."; @@ -574,8 +576,12 @@ void Lv2Pedalboard::ProcessParameterRequests(RealtimePatchPropertyRequest *pPara else if (pEffect->IsVst3()) { pParameterRequests->errorMessage = "Not supported for VST3 plugins"; + } else if (pParameterRequests->sampleTimeout < 0) + { + pParameterRequests->sampleTimeout = 0; + pParameterRequests->errorMessage = "Timed out."; } - else + else { if (pEffect->IsLv2Effect()) { @@ -818,7 +824,7 @@ void Lv2Pedalboard::OnMidiMessage(size_t size, uint8_t *message, case MidiControlType::Dial: { IEffect *pEffect = this->realtimeEffects[mapping.effectIndex]; - range = mapping.midiBinding.adjustRange(range); + float range = mapping.midiBinding.calculateRange(value); float currentValue = mapping.pPortInfo->rangeToValue(range); if (pEffect->GetControlValue(mapping.controlIndex) != currentValue) { diff --git a/src/Lv2Pedalboard.hpp b/src/Lv2Pedalboard.hpp index 4ae4420..5cb3fe9 100644 --- a/src/Lv2Pedalboard.hpp +++ b/src/Lv2Pedalboard.hpp @@ -153,7 +153,7 @@ namespace pipedal void ResetAtomBuffers(); - void ProcessParameterRequests(RealtimePatchPropertyRequest *pParameterRequests); + void ProcessParameterRequests(RealtimePatchPropertyRequest *pParameterRequests, size_t samplesThisTime); void GatherPatchProperties(RealtimePatchPropertyRequest *pParameterRequests); void GatherPathPatchProperties(IPatchWriterCallback *cbPatchWriter); diff --git a/src/MidiBinding.cpp b/src/MidiBinding.cpp index cf29f1b..25cd338 100644 --- a/src/MidiBinding.cpp +++ b/src/MidiBinding.cpp @@ -58,6 +58,8 @@ JSON_MAP_BEGIN(MidiBinding) JSON_MAP_REFERENCE(MidiBinding,bindingType) JSON_MAP_REFERENCE(MidiBinding,note) JSON_MAP_REFERENCE(MidiBinding,control) + JSON_MAP_REFERENCE(MidiBinding,minControlValue) + JSON_MAP_REFERENCE(MidiBinding,maxControlValue) JSON_MAP_REFERENCE(MidiBinding,minValue) JSON_MAP_REFERENCE(MidiBinding,maxValue) JSON_MAP_REFERENCE(MidiBinding,rotaryScale) diff --git a/src/MidiBinding.hpp b/src/MidiBinding.hpp index f66c14e..295da45 100644 --- a/src/MidiBinding.hpp +++ b/src/MidiBinding.hpp @@ -80,6 +80,8 @@ private: int bindingType_ = BINDING_TYPE_NONE; int note_ = 12*4+24; int control_ = 1; + int minControlValue_ = 0; + int maxControlValue_ = 127; float minValue_ = 0; float maxValue_ = 1; float rotaryScale_ = 1; @@ -99,6 +101,8 @@ public: && this->bindingType_ == other.bindingType_ && this->note_ == other.note_ && this->control_ == other.control_ + && this->minControlValue_ == other.minControlValue_ + && this->maxControlValue_ == other.maxControlValue_ && this->minValue_ == other.minValue_ && this->maxValue_ == other.maxValue_ && this->rotaryScale_ == other.rotaryScale_ @@ -111,6 +115,8 @@ public: GETTER_SETTER(bindingType); GETTER_SETTER(note); GETTER_SETTER(control); + GETTER_SETTER(minControlValue); + GETTER_SETTER(maxControlValue); GETTER_SETTER(minValue); GETTER_SETTER(maxValue); GETTER_SETTER(rotaryScale); @@ -120,9 +126,20 @@ public: void switchControlType(SwitchControlTypeT value) { switchControlType_ = (int)value; } - float adjustRange(float range) + float calculateRange(uint8_t value) { - return maxValue_*range+minValue_*(1.0f-range); + float controlRange; + if (maxControlValue_ == minControlValue_) + { + controlRange = minControlValue_; + } else { + controlRange = + (float)((int8_t)value - (int8_t)minControlValue_) / + (float)((int8_t)maxControlValue_ - (int8_t)minControlValue_); + } + if (controlRange < 0) controlRange = 0; + else if (controlRange > 1) controlRange = 1; + return maxValue_*controlRange+minValue_*(1.0f-controlRange); } DECLARE_JSON_MAP(MidiBinding); diff --git a/src/MimeTypes.cpp b/src/MimeTypes.cpp index 86c3c99..e98b804 100644 --- a/src/MimeTypes.cpp +++ b/src/MimeTypes.cpp @@ -58,19 +58,38 @@ static std::string toLower(const std::string&value) return result; } +const std::set playListExtensions = { + ".m3u", + ".m3u8", + ".pls", + ".wpl" +}; + +const std::set midiMimeTypes = { + "audio/midi", + "audio/sp-midi", + "audio/imelody", + "audio/xmf", + "audio/x-midi", + "audio/x-musicxml+xml" +}; + void MimeTypes::AddMimeType(const std::string&extension_, const std::string&mimeType) { std::string extension = SS("." << toLower(extension_)); mimeTypeToExtensions[mimeType].insert(extension); mimeTypeToExtension[mimeType] = extension; extensionToMimeType[extension] = mimeType; - if (mimeType.starts_with("audio/midi")) + if (midiMimeTypes.contains(mimeType)) { midiExtensions.insert(extension); } else if (mimeType.starts_with("audio/")) { - mimeTypeToExtensions["audio/*"].insert(extension); - audioExtensions.insert(extension); + if (!playListExtensions.contains(extension)) + { + mimeTypeToExtensions["audio/*"].insert(extension); + audioExtensions.insert(extension); + } } if (mimeType.starts_with("video/")) { diff --git a/src/Pedalboard.cpp b/src/Pedalboard.cpp index 89a16ba..ce694ab 100644 --- a/src/Pedalboard.cpp +++ b/src/Pedalboard.cpp @@ -136,6 +136,15 @@ bool Pedalboard::SetControlValue(int64_t pedalItemId, const std::string &symbol, return item->SetControlValue(symbol,value); } +bool Pedalboard::SetItemTitle(int64_t pedalItemId, const std::string &title) +{ + PedalboardItem*item = GetItem(pedalItemId); + if (!item) return false; + if (item->title() == title) return false; // no change. + item->title(title); + return true; +} + PedalboardItem Pedalboard::MakeEmptyItem() { @@ -503,6 +512,7 @@ JSON_MAP_BEGIN(PedalboardItem) JSON_MAP_REFERENCE(PedalboardItem,lv2State) JSON_MAP_REFERENCE(PedalboardItem,lilvPresetUri) JSON_MAP_REFERENCE(PedalboardItem,pathProperties) + JSON_MAP_REFERENCE(PedalboardItem,title) JSON_MAP_END() diff --git a/src/Pedalboard.hpp b/src/Pedalboard.hpp index 867ddc3..1dcf00b 100644 --- a/src/Pedalboard.hpp +++ b/src/Pedalboard.hpp @@ -98,6 +98,7 @@ public: Lv2PluginState lv2State_; std::string lilvPresetUri_; std::map pathProperties_; + std::string title_; // non persistent state. PropertyMap patchProperties; @@ -126,6 +127,8 @@ public: GETTER_SETTER_REF(midiChannelBinding) GETTER_SETTER(stateUpdateCount) GETTER_SETTER_REF(lv2State) + GETTER_SETTER_REF(title) + Lv2PluginState&lv2State() { return lv2State_; } // non-const version. GETTER_SETTER_REF(lilvPresetUri) @@ -198,6 +201,7 @@ public: static constexpr int64_t INPUT_VOLUME_ID = -2; // synthetic PedalboardItem for input volume. static constexpr int64_t OUTPUT_VOLUME_ID = -3; // synthetic PedalboardItem for output volume. bool SetControlValue(int64_t pedalItemId, const std::string &symbol, float value); + bool SetItemTitle(int64_t pedalItemId, const std::string &title); bool SetItemEnabled(int64_t pedalItemId, bool enabled); void SetCurrentSnapshotModified(bool modified); diff --git a/src/PiLatencyMain.cpp b/src/PiLatencyMain.cpp index 97c8a73..c7e7147 100644 --- a/src/PiLatencyMain.cpp +++ b/src/PiLatencyMain.cpp @@ -32,6 +32,7 @@ #include "AlsaDriver.hpp" #include #include +#include using namespace pipedal; @@ -213,11 +214,11 @@ public: audioDriver->Activate(); - sleep(3); // let audio stabilize. + std::this_thread::sleep_for(std::chrono::milliseconds(3000)); this->SetXruns(0); - sleep(7); // run for a bit. + std::this_thread::sleep_for(std::chrono::milliseconds(7000)); audioDriver->Deactivate(); audioDriver->Close(); diff --git a/src/PiPedalAlsa.cpp b/src/PiPedalAlsa.cpp index 63e200a..af1be7e 100644 --- a/src/PiPedalAlsa.cpp +++ b/src/PiPedalAlsa.cpp @@ -47,6 +47,7 @@ void PiPedalAlsaDevices::cacheDevice(const std::string &name, const AlsaDeviceIn std::vector PiPedalAlsaDevices::GetAlsaDevices() { + std::lock_guard guard{alsaMutex}; std::vector result; @@ -118,7 +119,7 @@ std::vector PiPedalAlsaDevices::GetAlsaDevices() if (err == 0) { unsigned int minRate = 0, maxRate = 0; - snd_pcm_uframes_t minBufferSize = 0, maxBufferSize = 0; + snd_pcm_uframes_t minBufferSize = 0, maxBufferSize = 0; int dir; err = snd_pcm_hw_params_get_rate_min(params, &minRate, &dir); if (err == 0) @@ -143,7 +144,8 @@ std::vector PiPedalAlsaDevices::GetAlsaDevices() info.sampleRates_.push_back(rate); } } - if (minBufferSize < 16) { + if (minBufferSize < 16) + { minBufferSize = 16; } @@ -180,7 +182,207 @@ std::vector PiPedalAlsaDevices::GetAlsaDevices() return result; } -static std::vector GetAlsaDevices(const char *devname, const char *direction) +static void AddMidiCardDevicesToList(snd_ctl_t *ctl, int card, int device, AlsaMidiDeviceInfo::Direction direction, std::vector *result) +{ + snd_rawmidi_info_t *info = nullptr; + const char *name = nullptr; + const char *sub_name = nullptr; + int subs = 0, subs_in = 0, subs_out = 0; + int sub = 0; + int err = 0; + ; + + snd_rawmidi_info_alloca(&info); + snd_rawmidi_info_set_device(info, device); + + snd_rawmidi_info_set_stream(info, SND_RAWMIDI_STREAM_INPUT); + err = snd_ctl_rawmidi_info(ctl, info); + if (err >= 0) + subs_in = snd_rawmidi_info_get_subdevices_count(info); + else + subs_in = 0; + + snd_rawmidi_info_set_stream(info, SND_RAWMIDI_STREAM_OUTPUT); + err = snd_ctl_rawmidi_info(ctl, info); + if (err >= 0) + subs_out = snd_rawmidi_info_get_subdevices_count(info); + else + subs_out = 0; + + subs = subs_in > subs_out ? subs_in : subs_out; + if (!subs) + return; + + switch (direction) + { + case AlsaMidiDeviceInfo::In: + if (subs_out == 0) // out for the device, in for us. + { + return; // no input devices to add + } + break; + case AlsaMidiDeviceInfo::Out: // in for the device, out for us. + if (subs_in == 0) + { + return; // no output devices to add + } + break; + case AlsaMidiDeviceInfo::InOut: + if (subs_in == 0 || subs_out == 0) + { + return; // no input or output devices to add + } + break; + case AlsaMidiDeviceInfo::None: + return; // no devices to add + } + + for (sub = 0; sub < subs; ++sub) + { + snd_rawmidi_info_set_stream(info, direction == AlsaMidiDeviceInfo::Direction::Out ? SND_RAWMIDI_STREAM_INPUT : SND_RAWMIDI_STREAM_OUTPUT); + snd_rawmidi_info_set_subdevice(info, sub); + err = snd_ctl_rawmidi_info(ctl, info); + if (err < 0) + { + throw std::runtime_error( + SS("snd_ctl_rawmidi_info failed for card " << card << ", device " << device << ", subdevice " << sub << ": " << snd_strerror(err))); + } + name = snd_rawmidi_info_get_name(info); + sub_name = snd_rawmidi_info_get_subdevice_name(info); + // get card name. + + std::string cardName; + snd_ctl_card_info_t *card_info = nullptr; + snd_ctl_card_info_malloc(&card_info); + if (snd_ctl_card_info(ctl, card_info) == 0) { + cardName = snd_ctl_card_info_get_name(card_info); + } + snd_ctl_card_info_free(card_info); + if (cardName.length() == 0) + { + return; + } + + if (sub == 0 && sub_name[0] == '\0') + { + AlsaMidiDeviceInfo info; + info.name_ = SS("hw:CARD=" << cardName << ",DEV=" << device); + info.description_ = SS("Virtual MIDI " << card << "-" << device); + info.card_ = card; + info.device_ = device; + info.subdevice_ = 0; + info.isVirtual_ = true; + info.subDevices_ = subs; + if (subs_in > 0 && subs_out > 0) + { + info.direction_ = AlsaMidiDeviceInfo::InOut; + } + else if (subs_in > 0) + { + info.direction_ = AlsaMidiDeviceInfo::In; + } + else if (subs_out > 0) + { + info.direction_ = AlsaMidiDeviceInfo::Out; + } + else + { + info.direction_ = AlsaMidiDeviceInfo::None; + } + result->push_back(info); + break; + } + else + { + AlsaMidiDeviceInfo info; + info.name_ = SS("hw:CARD=" << cardName << ",DEV=" << device); + if (sub != 0) { + info.name_ = SS(info.name_ << "," << sub); + } + info.description_ = sub_name; + info.card_ = card; + info.device_ = device; + info.subdevice_ = sub; + info.isVirtual_ = false; + info.subDevices_ = 1; + result->push_back(info); + } + } +} +static void AddMidiCardToList(int card, std::vector *result, AlsaMidiDeviceInfo::Direction direction) +{ + snd_ctl_t *ctl = nullptr; + char name[32]; + int device; + int err; + + sprintf(name, "hw:%d", card); + if ((err = snd_ctl_open(&ctl, name, 0)) < 0) + { + throw std::runtime_error(SS("cannot open control for card " << card << ": " << snd_strerror(err))); + } + device = -1; + for (;;) + { + if ((err = snd_ctl_rawmidi_next_device(ctl, &device)) < 0) + { + snd_ctl_close(ctl); + throw std::runtime_error(SS("cannot get next rawmidi device for card " << card << ": " << snd_strerror(err))); + } + if (device < 0) + break; + AddMidiCardDevicesToList(ctl, card, device, direction, result); + } + snd_ctl_close(ctl); +} + +static std::vector GetAlsaDevices(const char *devname, const char *direction_) +{ + std::vector result; + + int card, err; + + AlsaMidiDeviceInfo::Direction direction; + if (strcmp(direction_, "Input") == 0) + { + direction = AlsaMidiDeviceInfo::In; + } + else if (strcmp(direction_, "Output") == 0) + { + direction = AlsaMidiDeviceInfo::Out; + } + else if (strcmp(direction_, "InOut") == 0) + { + direction = AlsaMidiDeviceInfo::InOut; + } + else + { + direction = AlsaMidiDeviceInfo::None; + return result; + } + + card = -1; + if ((err = snd_card_next(&card)) < 0) + { + throw std::runtime_error(SS("snd_card_next failed.: " << snd_strerror(err))); + } + if (card < 0) + { + // no devices! + return result; + } + do + { + AddMidiCardToList(card, &result, direction); + if ((err = snd_card_next(&card)) < 0) + { + throw std::runtime_error(SS("snd_card_next failed: " << snd_strerror(err))); + } + } while (card >= 0); + + return result; +} +static std::vector OldGetAlsaDevices(const char *devname, const char *direction) { std::vector result; { @@ -214,6 +416,8 @@ static std::vector GetAlsaDevices(const char *devname, const result.push_back(AlsaMidiDeviceInfo(name, desc)); } } + // translate rawmidi device name to device number. + if (name && strcmp("null", name) != 0) free(name); if (desc && strcmp("null", desc) != 0) @@ -229,11 +433,11 @@ static std::vector GetAlsaDevices(const char *devname, const return result; } -std::vector pipedal::GetAlsaMidiInputDevices() +std::vector pipedal::LegacyGetAlsaMidiInputDevices() { return GetAlsaDevices("rawmidi", "Input"); } -std::vector pipedal::GetAlsaMidiOutputDevices() +std::vector pipedal::LegacyGetAlsaMidiOutputDevices() { return GetAlsaDevices("rawmidi", "Output"); } @@ -260,6 +464,14 @@ AlsaMidiDeviceInfo::AlsaMidiDeviceInfo(const char *name, const char *description } } +AlsaMidiDeviceInfo::AlsaMidiDeviceInfo(const char *name, const char *description, int card, int device, int subdevice) + : AlsaMidiDeviceInfo(name, description) +{ + this->card_ = card; + this->device_ = device; + this->subdevice_ = subdevice; +} + JSON_MAP_BEGIN(AlsaDeviceInfo) JSON_MAP_REFERENCE(AlsaDeviceInfo, cardId) JSON_MAP_REFERENCE(AlsaDeviceInfo, id) diff --git a/src/PiPedalAlsa.hpp b/src/PiPedalAlsa.hpp index 3bbf8ed..cf602b2 100644 --- a/src/PiPedalAlsa.hpp +++ b/src/PiPedalAlsa.hpp @@ -41,11 +41,26 @@ namespace pipedal { }; class AlsaMidiDeviceInfo { public: + enum Direction { + None = 0, + In = 1, + Out = 2, + InOut = 3 + }; AlsaMidiDeviceInfo() { } AlsaMidiDeviceInfo(const char*name, const char*description); + AlsaMidiDeviceInfo(const char*name, const char*description, int card, int device, int subdevice); std::string name_; std::string description_; + // non-serialized. + int card_ = -1; + int device_ = -1; + int subdevice_ = -1; + bool isVirtual_ = false; + int subDevices_ = -1; + Direction direction_ = Direction::None; + DECLARE_JSON_MAP(AlsaMidiDeviceInfo); }; @@ -60,6 +75,9 @@ namespace pipedal { std::vector GetAlsaDevices(); }; - std::vector GetAlsaMidiInputDevices(); - std::vector GetAlsaMidiOutputDevices(); + // we use ALSA sequencers now instead of ALSA rawmidi devices. + // Used by test suite to verify migration behaviour. + std::vector LegacyGetAlsaMidiInputDevices(); + std::vector LegacyGetAlsaMidiOutputDevices(); + } \ No newline at end of file diff --git a/src/PiPedalAlsaTest.cpp b/src/PiPedalAlsaTest.cpp index 4b6e3c0..aa36d68 100644 --- a/src/PiPedalAlsaTest.cpp +++ b/src/PiPedalAlsaTest.cpp @@ -23,6 +23,8 @@ #include #include #include +#include "AlsaSequencer.hpp" +#include #include "PiPedalAlsa.hpp" @@ -36,20 +38,110 @@ static void DiscoveryTest() auto result = devices.GetAlsaDevices(); std::cout << result.size() << " ALSA devices found." << std::endl; - auto midiInputDevices = GetAlsaMidiInputDevices(); - std::cout << midiInputDevices.size() << " ALSA MIDI input devices found." << std::endl; - auto midiOutputDevices = GetAlsaMidiOutputDevices(); - std::cout << midiOutputDevices.size() << " ALSA MIDI output devices found." << std::endl; + auto midiInputDevices = AlsaSequencer::EnumeratePorts(); + std::cout << midiInputDevices.size() << " ALSA MIDI input sequencers found." << std::endl; +} + +void EnumerateSequencers() +{ + cout << "--- Enumerating ALSA Sequencers" << endl; + auto sequencers = AlsaSequencer::EnumeratePorts(); + for (const auto &sequencer : sequencers) + { + cout << sequencer.client << ": "<< sequencer.port << " - " << sequencer.clientName << "/" << sequencer.name; + + cout << " [" << (sequencer.isKernelDevice ? "Kernel" : "User"); + if (!sequencer.rawMidiDevice.empty()) + { + cout << " " << sequencer.rawMidiDevice; + } + cout << "]" << endl; + + cout << " Read: " << (sequencer.canRead ? "Yes" : "No") + << ", Write: " << (sequencer.canWrite ? "Yes" : "No") + << ", Read Subscribe: " << (sequencer.canReadSubscribe ? "Yes" : "No") + << ", Write Subscribe: " << (sequencer.canWriteSubscribe ? "Yes" : "No") << std::endl; + + cout + << " System Announce : " << (sequencer.isSystemAnnounce ? " Yes " : " No ") + << "UMP: " + << (sequencer.isUmp ? "Yes" : "No") + << "MIDI: " + << (sequencer.isMidi ? "Yes" : "No") + << "Application: " + << (sequencer.isApplication ? "Yes" : "No") + << " MIDI Synth: " + << (sequencer.isMidiSynth ? "Yes" : "No") + << "Synth: " + << (sequencer.isSynth ? "Yes" : "No") + << std::endl; + cout << " Specific: " << (sequencer.isSpecific ? "Yes" : "No") + << "Hardware: " << (sequencer.isHardware ? "Yes" : "No") + << "Software: " << (sequencer.isSoftware ? "Yes" : "No") << std::endl; + } +} + +void ReadFromsequencerTest() +{ + cout << "--- Reading from ALSA Sequencer" << endl; + AlsaSequencer::ptr sequencer = AlsaSequencer::Create(); + + sequencer->ConnectPort("V25 V25 In"); + + AlsaMidiMessage message; + while (true) + { + if (sequencer->ReadMessage(message,true)) + { + cout << "Received MIDI message: " + << " " << hex << setfill('0') << setw(2) << static_cast(message.cc0()) + << " " << hex << setfill('0') << setw(2) << static_cast(message.cc1()) + << " " << hex << setfill('0') << setw(2) << static_cast(message.cc2()) + << " Timestamp: " << setw(8) << dec << message.timestamp + << std::endl; + } + } +} + +void TestConfigMigration() +{ + cout << "--- Testing ALSA Config Migration" << endl; + // older versions of PiPedal uses ALSA rawmidi. + // this code test coversion of rawmidi device IDs to ALSA Sequencer IDs. + auto rawDevices = LegacyGetAlsaMidiInputDevices(); + auto seqDevices = AlsaSequencer::EnumeratePorts(); + + for (const auto&seqDevice: seqDevices) { + cout << seqDevice.id << " -> " << seqDevice.rawMidiDevice << endl; + } + for (const auto&rawDevice: rawDevices) { + std::string sequencerId = RawMidiIdToSequencerId(seqDevices,rawDevice.name_); + REQUIRE(!sequencerId.empty()); + cout << rawDevice.name_ << "->" << sequencerId << std::endl; + } + cout << endl; } +TEST_CASE("ALSA Seq Test", "[pipedal_alsa_seq_test][Build][Dev]") +{ + + // hw:CARD=VirMIDI,DEV=0 VirMIDI + // hw:CARD=VirMIDI,DEV=1 VirMIDI + // hw:CARD=VirMIDI,DEV=2 VirMIDI + // hw:CARD=VirMIDI,DEV=3 VirMIDI + // hw:CARD=M2,DEV=0 M2 + // hw:CARD=V25,DEV=0 V25 + EnumerateSequencers(); + TestConfigMigration(); + + + ReadFromsequencerTest(); +} + +TEST_CASE("ALSA Test", "[pipedal_alsa_test][Build][Dev]") +{ -TEST_CASE( "ALSA Test", "[pipedal_alsa_test][Build][Dev]" ) { DiscoveryTest(); - - } - - - diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index dcefcaa..91fe6b8 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -44,6 +44,7 @@ #include "DBusLog.hpp" #include "AvahiService.hpp" #include "DummyAudioDriver.hpp" +#include "AudioFiles.hpp" #ifndef NO_MLOCK #include @@ -295,6 +296,8 @@ void PiPedalModel::Load() this->audioHost->SetSystemMidiBindings(this->systemMidiBindings); + audioHost->SetAlsaSequencerConfiguration(storage.GetAlsaSequencerConfiguration()); + if (configuration.GetMLock()) { #ifndef NO_MLOCK @@ -508,7 +511,6 @@ void PiPedalModel::FireBanksChanged(int64_t clientId) } } - void PiPedalModel::FirePedalboardChanged(int64_t clientId, bool loadAudioThread) { if (loadAudioThread) @@ -594,14 +596,11 @@ void PiPedalModel::UpdateCurrentPedalboard(int64_t clientId, Pedalboard &pedalbo UpdateVst3Settings(pedalboard); - - Lv2PedalboardErrorList errorMessages; std::shared_ptr lv2Pedalboard{ - this->pluginHost.UpdateLv2PedalboardStructure(pedalboard, this->lv2Pedalboard.get(), errorMessages) - }; + this->pluginHost.UpdateLv2PedalboardStructure(pedalboard, this->lv2Pedalboard.get(), errorMessages)}; this->lv2Pedalboard = lv2Pedalboard; - + // apply the error messages to the lv2Pedalboard. // return true if the error messages have changed audioHost->SetPedalboard(lv2Pedalboard); @@ -613,9 +612,7 @@ void PiPedalModel::UpdateCurrentPedalboard(int64_t clientId, Pedalboard &pedalbo UpdateRealtimeVuSubscriptions(); UpdateRealtimeMonitorPortSubscriptions(); - - - this->FirePedalboardChanged(clientId,false); + this->FirePedalboardChanged(clientId, false); this->SetPresetChanged(clientId, true); } } @@ -1399,6 +1396,77 @@ void PiPedalModel::RestartAudio(bool useDummyAudioDriver) } } +void PiPedalModel::OnAlsaSequencerDeviceAdded(int client, const std::string &clientName) +{ + std::lock_guard lock(mutex); + auto alsaSequencerConfiguration = this->storage.GetAlsaSequencerConfiguration(); + std::string key = "seq:" + clientName; + bool interested = false; + for (const auto &port : alsaSequencerConfiguration.connections()) + { + if (port.id().starts_with(key)) + { + interested = true; + break; + } + } + if (interested) + { + Post( + [this] + { + // reconfigure connections. + std::lock_guard lock(this->mutex); + if (this->audioHost) { + this->audioHost->SetAlsaSequencerConfiguration(this->storage.GetAlsaSequencerConfiguration()); + } + }); + } +} +void PiPedalModel::OnAlsaSequencerDeviceRemoved(int client) +{ + // no action required. +} + +void PiPedalModel::SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) +{ + std::lock_guard lock(mutex); + + // reset midi connections even if the configuration hasn't changed. + this->audioHost->SetAlsaSequencerConfiguration(alsaSequencerConfiguration); + + auto current = storage.GetAlsaSequencerConfiguration(); + if (alsaSequencerConfiguration != current) + { + this->storage.SetAlsaSequencerConfiguration(alsaSequencerConfiguration); + // notify subscribers. + std::vector t{subscribers.begin(), subscribers.end()}; + for (auto &subscriber : t) + { + subscriber->OnAlsaSequencerConfigurationChanged(alsaSequencerConfiguration); + } + } +} +AlsaSequencerConfiguration PiPedalModel::GetAlsaSequencerConfiguration() +{ + std::lock_guard lock(mutex); + return this->storage.GetAlsaSequencerConfiguration(); +} + +std::vector PiPedalModel::GetAlsaSequencerPorts() +{ + auto ports = AlsaSequencer::EnumeratePorts(); + std::vector result; + for (auto &port : ports) + { + result.push_back(AlsaSequencerPortSelection{ + port.id, + port.name, + port.displaySortOrder}); + } + return result; +} + void PiPedalModel::SetJackChannelSelection(int64_t clientId, const JackChannelSelection &channelSelection) { { @@ -1615,19 +1683,31 @@ void PiPedalModel::SendSetPatchProperty( { std::lock_guard lock(mutex); + if (!audioHost) + { + onError("Audio not running."); + return; + } // save the property to the preset (currently used to reconstruct snapshots only) PedalboardItem *pedalboardItem = this->pedalboard.GetItem(instanceId); - if (pedalboardItem) + if (pedalboardItem && value.is_string()) { - json_variant abstractPath = pluginHost.AbstractPath(value); - std::ostringstream ss; - json_writer writer(ss); - writer.write(abstractPath); - std::string atomString = ss.str(); - pedalboardItem->pathProperties_[propertyUri] = atomString; + std::shared_ptr pluginInfo = GetPluginInfo(pedalboardItem->uri_); + auto pipedalUi = pluginInfo->piPedalUI(); + auto fileProperty = pipedalUi->GetFileProperty(propertyUri); + if (fileProperty && value.is_string()) + { + + json_variant abstractPath = pluginHost.AbstractPath(value); + std::ostringstream ss; + json_writer writer(ss); + writer.write(abstractPath); + std::string atomString = ss.str(); + pedalboardItem->pathProperties_[propertyUri] = atomString; + } + this->SetPresetChanged(clientId, true); } - this->SetPresetChanged(clientId, true); LV2_Atom *atomValue = atomConverter.ToAtom(value); std::function onRequestComplete{ @@ -1657,7 +1737,7 @@ void PiPedalModel::SendSetPatchProperty( } else { - if (pParameter->onSuccess) + if (onSuccess) { onSuccess(); } @@ -1668,10 +1748,11 @@ void PiPedalModel::SendSetPatchProperty( }}; LV2_URID urid = this->pluginHost.GetLv2Urid(propertyUri.c_str()); - + size_t sampleTimeout = 0.5 * audioHost->GetSampleRate(); RealtimePatchPropertyRequest *request = new RealtimePatchPropertyRequest( onRequestComplete, - clientId, instanceId, urid, atomValue, nullptr, onError); + clientId, instanceId, urid, atomValue, nullptr, onError, + sampleTimeout); outstandingParameterRequests.push_back(request); if (this->audioHost) @@ -1684,7 +1765,7 @@ void PiPedalModel::SendGetPatchProperty( int64_t clientId, int64_t instanceId, const std::string uri, - std::function onSuccess, + std::function onSuccess, std::function onError) { std::function onRequestComplete{ @@ -1732,16 +1813,20 @@ void PiPedalModel::SendGetPatchProperty( }}; LV2_URID urid = this->pluginHost.GetLv2Urid(uri.c_str()); - RealtimePatchPropertyRequest *request = new RealtimePatchPropertyRequest( - onRequestComplete, - clientId, instanceId, urid, onSuccess, onError); std::lock_guard lock(mutex); - outstandingParameterRequests.push_back(request); - if (this->audioHost) + + if (!this->audioHost) { - this->audioHost->sendRealtimeParameterRequest(request); + onError("Audio stopped."); } + size_t sampleTimeout = 0.3 * audioHost->GetSampleRate(); + RealtimePatchPropertyRequest *request = new RealtimePatchPropertyRequest( + onRequestComplete, + clientId, instanceId, urid, onSuccess, onError, sampleTimeout); + + outstandingParameterRequests.push_back(request); + this->audioHost->sendRealtimeParameterRequest(request); } BankIndex PiPedalModel::GetBankIndex() const @@ -1902,7 +1987,9 @@ void PiPedalModel::UpdateDefaults(PedalboardItem *pedalboardItem, std::unordered { pedalboardItem->midiChannelBinding(MidiChannelBinding::DefaultForMissingValue()); } - } else { + } + else + { if (pedalboardItem->midiChannelBinding()) { pedalboardItem->midiChannelBinding(std::optional()); // clear it. @@ -2174,34 +2261,41 @@ void PiPedalModel::OnNotifyPathPatchPropertyReceived( } } -void PiPedalModel::OnNotifyMidiListen(bool isNote, uint8_t noteOrControl) +void PiPedalModel::OnNotifyMidiListen(uint8_t cc0, uint8_t cc1, uint8_t cc2) { std::lock_guard lock(mutex); + bool isNote = (cc0 & 0xF0) == 0x90; // Note On + if (isNote && cc2 == 0) + { + return; // Note off. Oopsie. + } + bool isControl = (cc0 & 0xF0) == 0xB0; // Control Change + if (!isNote && !isControl) + { + return; // Not a note on or control change. + } - for (int i = 0; i < midiEventListeners.size(); ++i) + for (int i = 0; i < midiEventListeners.size(); ++i) { auto &listener = midiEventListeners[i]; - if ((!isNote) == (listener.listenForControls)) + auto subscriber = this->GetNotificationSubscriber(listener.clientId); + if (subscriber) { - auto subscriber = this->GetNotificationSubscriber(listener.clientId); - if (subscriber) - { - subscriber->OnNotifyMidiListener(listener.clientHandle, isNote, noteOrControl); - } - else - { - midiEventListeners.erase(midiEventListeners.begin() + i); - --i; - } + subscriber->OnNotifyMidiListener(listener.clientHandle, cc0, cc1, cc2); + } + else + { + midiEventListeners.erase(midiEventListeners.begin() + i); + --i; } } audioHost->SetListenForMidiEvent(midiEventListeners.size() != 0); } -void PiPedalModel::ListenForMidiEvent(int64_t clientId, int64_t clientHandle, bool listenForControls) +void PiPedalModel::ListenForMidiEvent(int64_t clientId, int64_t clientHandle) { std::lock_guard lock(mutex); - MidiListener listener{clientId, clientHandle, listenForControls}; + MidiListener listener{clientId, clientHandle}; midiEventListeners.push_back(listener); audioHost->SetListenForMidiEvent(true); } @@ -2272,6 +2366,7 @@ void PiPedalModel::CancelMonitorPatchProperty(int64_t clientId, int64_t clientHa audioHost->SetListenForMidiEvent(false); } } + std::vector PiPedalModel::GetAlsaDevices() { std::vector result = this->alsaDevices.GetAlsaDevices(); @@ -2329,9 +2424,9 @@ void PiPedalModel::SetSystemMidiBindings(std::vector &bindings) } } -PedalboardItem*PiPedalModel::GetPedalboardItemForFileProperty(const UiFileProperty& fileProperty) +PedalboardItem *PiPedalModel::GetPedalboardItemForFileProperty(const UiFileProperty &fileProperty) { - for (PedalboardItem*pedalboardItem: this->pedalboard.GetAllPlugins()) + for (PedalboardItem *pedalboardItem : this->pedalboard.GetAllPlugins()) { if (pedalboardItem->pathProperties_.contains(fileProperty.patchProperty())) { @@ -2345,23 +2440,24 @@ FileRequestResult PiPedalModel::GetFileList2(const std::string &relativePath_, c std::string relativePath = relativePath_; try { - if (!storage.IsInUploadsDirectory(relativePath)) + if (!storage.IsInUploadsDirectory(relativePath)) { + // if relativePath is in a resource directory of the plugin, then we have loaded a factory preset or are using a default property. - // map the resource path to the corresponding file in the uploads directory. + // map the resource path to the corresponding file in the uploads directory. // :-( PedalboardItem *pedalboardItem = GetPedalboardItemForFileProperty(fileProperty); if (pedalboardItem) { auto pluginInfo = GetPluginInfo(pedalboardItem->uri()); - if (pluginInfo) { - std::filesystem::path resourcePath = fs::path(pluginInfo->bundle_path()) - / fileProperty.resourceDirectory(); - if (IsSubdirectory(relativePath,resourcePath)) + if (pluginInfo) + { + std::filesystem::path resourcePath = fs::path(pluginInfo->bundle_path()) / fileProperty.resourceDirectory(); + if (IsSubdirectory(relativePath, resourcePath)) { - fs::path t = MakeRelativePath(relativePath,resourcePath); - t = fileProperty.directory() / t; - if (fs::exists(t)) + fs::path t = MakeRelativePath(relativePath, resourcePath); + t = fileProperty.directory() / t; + if (fs::exists(t)) { relativePath = t; } @@ -2387,6 +2483,16 @@ std::string PiPedalModel::RenameFilePropertyFile( return storage.RenameFilePropertyFile(oldRelativePath, newRelativePath, uiFileProperty); } +std::string PiPedalModel::CopyFilePropertyFile( + const std::string &oldRelativePath, + const std::string &newRelativePath, + const UiFileProperty &uiFileProperty, + bool overwrite) +{ + std::lock_guard lock(mutex); + return storage.CopyFilePropertyFile(oldRelativePath, newRelativePath, uiFileProperty, overwrite); +} + void PiPedalModel::DeleteSampleFile(const std::filesystem::path &fileName) { std::lock_guard lock(mutex); @@ -2398,28 +2504,29 @@ std::string PiPedalModel::CreateNewSampleDirectory(const std::string &relativePa std::lock_guard lock(mutex); return storage.CreateNewSampleDirectory(relativePath, uiFileProperty); } -FilePropertyDirectoryTree::ptr PiPedalModel::GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty,const std::string&selectedPath) +FilePropertyDirectoryTree::ptr PiPedalModel::GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty, const std::string &selectedPath) { std::lock_guard lock(mutex); - return storage.GetFilePropertydirectoryTree(uiFileProperty,selectedPath); + return storage.GetFilePropertydirectoryTree(uiFileProperty, selectedPath); } -UiFileProperty::ptr PiPedalModel::FindLoadedPatchProperty(int64_t instanceId,const std::string&patchPropertyUri) +UiFileProperty::ptr PiPedalModel::FindLoadedPatchProperty(int64_t instanceId, const std::string &patchPropertyUri) { auto pedalboardItems = pedalboard.GetAllPlugins(); - for (const auto&pedalboardItem: pedalboardItems) { + for (const auto &pedalboardItem : pedalboardItems) + { if (pedalboardItem->instanceId() == instanceId) { Lv2PluginInfo::ptr pluginInfo = GetPluginInfo(pedalboardItem->uri()); if (pluginInfo && pluginInfo->piPedalUI()) { - for (const auto&fileProperty: pluginInfo->piPedalUI()->fileProperties()) - if (fileProperty->patchProperty() == patchPropertyUri) - { - return fileProperty; - } + for (const auto &fileProperty : pluginInfo->piPedalUI()->fileProperties()) + if (fileProperty->patchProperty() == patchPropertyUri) + { + return fileProperty; + } } } } @@ -2427,9 +2534,9 @@ UiFileProperty::ptr PiPedalModel::FindLoadedPatchProperty(int64_t instanceId,con throw std::runtime_error("Permission denied. Plugin not currently loaded."); } -std::string PiPedalModel::UploadUserFile(const std::string &directory, int64_t instanceId,const std::string &patchProperty, const std::string &filename, std::istream &stream, size_t contentLength) +std::string PiPedalModel::UploadUserFile(const std::string &directory, int64_t instanceId, const std::string &patchProperty, const std::string &filename, std::istream &stream, size_t contentLength) { - UiFileProperty::ptr fileProperty = FindLoadedPatchProperty(instanceId,patchProperty); + UiFileProperty::ptr fileProperty = FindLoadedPatchProperty(instanceId, patchProperty); if (!fileProperty) { Lv2Log::error(SS("Upload fle: Permission denied. No currently-loaded plugin provides that patch property: " << patchProperty)); @@ -2819,35 +2926,40 @@ bool PiPedalModel::GetHasWifi() return hasWifi; } -std::map PiPedalModel::GetWifiRegulatoryDomains() +std::map PiPedalModel::GetWifiRegulatoryDomains() { - std::map result; - try { - auto& regDb = RegDb::GetInstance(); + std::map result; + try + { + auto ®Db = RegDb::GetInstance(); result = regDb.getRegulatoryDomains(storage.GetConfigRoot() / "iso_codes.json"); - } catch (const std::exception&e) + } + catch (const std::exception &e) { Lv2Log::warning(SS("Unable to query Wifi Regulatory domains. " << e.what())); } return result; } -void PiPedalModel::CancelAudioRetry() { +void PiPedalModel::CancelAudioRetry() +{ std::lock_guard lock(mutex); - if (audioRetryPostHandle) + if (audioRetryPostHandle) { // don't think this can ever happen, but if it did, it would be bad. this->CancelPost(audioRetryPostHandle); audioRetryPostHandle = 0; } - } -void PiPedalModel::OnAlsaDriverTerminatedAbnormally() { - // notification from the realtime thread, via the audiohost that the + +void PiPedalModel::OnAlsaDriverTerminatedAbnormally() +{ + // notification from the realtime thread, via the audiohost that the // ALSA stream has broken. We want to restart. // get off the service thread as promptly as possible - this->Post([&]() { + this->Post([&]() + { std::lock_guard lock(mutex); if (closed) return; @@ -2895,11 +3007,53 @@ void PiPedalModel::OnAlsaDriverTerminatedAbnormally() { } else { Lv2Log::error(SS("Unable to reastart audio.")); - } - }); + } }); } bool PiPedalModel::IsInUploadsDirectory(const std::string &path) { return storage.IsInUploadsDirectory(path); -} \ No newline at end of file +} + +void PiPedalModel::MoveAudioFile( + const std::string &directory, + int32_t fromPosition, + int32_t toPosition) +{ + if (directory.empty()) + { + throw std::runtime_error("Directory is empty."); + } + AudioDirectoryInfo::Ptr dir = AudioDirectoryInfo::Create(directory); + dir->MoveAudioFile(directory, fromPosition, toPosition); +} +void PiPedalModel::SetPedalboardItemTitle(int64_t instanceId, const std::string &title) +{ + std::lock_guard lock(mutex); + if (!this->pedalboard.SetItemTitle(instanceId, title)) + { + return; + } + // no need to reload the pedalboard, but we do need to notify subscribers. + this->SetPresetChanged(-1, true); + this->FirePedalboardChanged(-1, false); +} + +void PiPedalModel::SetTone3000Auth(const std::string &apiKey) +{ + std::lock_guard lock(mutex); + storage.SetTone3000Auth(apiKey); + + std::vector t{subscribers.begin(), subscribers.end()}; + bool hasAuth = apiKey != ""; + for (auto &subscriber : t) + { + subscriber->OnTone3000AuthChanged(hasAuth); + } + +} +bool PiPedalModel::HasTone3000Auth() const +{ + return storage.GetTone3000Auth() != ""; +} + diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index aa551cd..d665545 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -78,7 +78,7 @@ namespace pipedal virtual void OnJackConfigurationChanged(const JackConfiguration &jackServerConfiguration) = 0; virtual void OnLoadPluginPreset(int64_t instanceId, const std::vector &controlValues) = 0; virtual void OnMidiValueChanged(int64_t instanceId, const std::string &symbol, float value) = 0; - virtual void OnNotifyMidiListener(int64_t clientHandle, bool isNote, uint8_t noteOrControl) = 0; + virtual void OnNotifyMidiListener(int64_t clientHandle, uint8_t cc0, uint8_t cc1, uint8_t cc2) = 0; virtual void OnNotifyPatchProperty(int64_t clientModel, uint64_t instanceId, const std::string &propertyUri, const std::string &atomJson) = 0; virtual void OnWifiConfigSettingsChanged(const WifiConfigSettings &wifiConfigSettings) = 0; virtual void OnWifiDirectConfigSettingsChanged(const WifiDirectConfigSettings &wifiDirectConfigSettings) = 0; @@ -94,7 +94,10 @@ namespace pipedal virtual void OnNetworkChanging(bool hotspotConnected) = 0; virtual void OnHasWifiChanged(bool hasWifi) = 0; + virtual void OnAlsaSequencerConfigurationChanged(const AlsaSequencerConfiguration &alsaSequencerConfiguration) = 0; virtual void Close() = 0; + virtual void OnTone3000AuthChanged(bool value) = 0; + }; class HotspotManager; @@ -145,7 +148,6 @@ namespace pipedal public: int64_t clientId; int64_t clientHandle; - bool listenForControls; }; class AtomOutputListener { @@ -168,6 +170,7 @@ namespace pipedal std::vector atomOutputListeners; JackServerSettings jackServerSettings; + PluginHost pluginHost; AtomConverter atomConverter; // must be AFTER pluginHost! @@ -233,11 +236,13 @@ namespace pipedal virtual void OnNotifyVusSubscription(const std::vector &updates) override; virtual void OnNotifyMonitorPort(const MonitorPortUpdate &update) override; virtual void OnNotifyMidiValueChanged(int64_t instanceId, int portIndex, float value) override; - virtual void OnNotifyMidiListen(bool isNote, uint8_t noteOrControl) override; + virtual void OnNotifyMidiListen(uint8_t cc0, uint8_t cc1, uint8_t cc2) override; virtual void OnPatchSetReply(uint64_t instanceId, LV2_URID patchSetProperty, const LV2_Atom *atomValue) override; virtual void OnNotifyMidiRealtimeEvent(RealtimeMidiEventType eventType) override; virtual void OnNotifyMidiRealtimeSnapshotRequest(int32_t snapshotIndex,int64_t snapshotRequestId) override; virtual void OnAlsaDriverTerminatedAbnormally() override; + virtual void OnAlsaSequencerDeviceAdded(int client, const std::string &clientName) override; + virtual void OnAlsaSequencerDeviceRemoved(int client) override; void OnNotifyPathPatchPropertyReceived( int64_t instanceId, @@ -262,6 +267,7 @@ namespace pipedal void CheckForResourceInitialization(Pedalboard &pedalboard); UiFileProperty::ptr FindLoadedPatchProperty(int64_t instanceId,const std::string&patchPropertyUri); + public: PiPedalModel(); virtual ~PiPedalModel(); @@ -387,6 +393,11 @@ namespace pipedal void SetJackChannelSelection(int64_t clientId, const JackChannelSelection &channelSelection); JackChannelSelection GetJackChannelSelection(); + void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration); + AlsaSequencerConfiguration GetAlsaSequencerConfiguration(); + + std::vector GetAlsaSequencerPorts(); + void SetShowStatusMonitor(bool show); bool GetShowStatusMonitor(); @@ -436,7 +447,7 @@ namespace pipedal JackServerSettings GetJackServerSettings(); void SetJackServerSettings(const JackServerSettings &jackServerSettings); - void ListenForMidiEvent(int64_t clientId, int64_t clientHandle, bool listenForControls); + void ListenForMidiEvent(int64_t clientId, int64_t clientHandle); void CancelListenForMidiEvent(int64_t clientId, int64_t clientHandle); void MonitorPatchProperty(int64_t clientId, int64_t clientHandle, uint64_t instanceId, const std::string &propertyUri); @@ -454,6 +465,7 @@ namespace pipedal void DeleteSampleFile(const std::filesystem::path &fileName); std::string CreateNewSampleDirectory(const std::string &relativePath, const UiFileProperty &uiFileProperty); std::string RenameFilePropertyFile(const std::string &oldRelativePath, const std::string &newRelativePath, const UiFileProperty &uiFileProperty); + std::string CopyFilePropertyFile(const std::string &oldRelativePath, const std::string &newRelativePath, const UiFileProperty &uiFileProperty,bool overwrite); FilePropertyDirectoryTree::ptr GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty,const std::string&selectedPath); bool IsInUploadsDirectory(const std::string &path); @@ -462,6 +474,17 @@ namespace pipedal uint64_t CreateNewPreset(); bool LoadCurrentPedalboard(); + + void MoveAudioFile( + const std::string & path, + int32_t from, + int32_t to); + + void SetPedalboardItemTitle(int64_t instanceId, const std::string &title); + + void SetTone3000Auth(const std::string &apiKey); + bool HasTone3000Auth() const; + }; } // namespace pipedal. \ No newline at end of file diff --git a/src/PiPedalSocket.cpp b/src/PiPedalSocket.cpp index a5efbe4..4740747 100644 --- a/src/PiPedalSocket.cpp +++ b/src/PiPedalSocket.cpp @@ -60,7 +60,6 @@ JSON_MAP_REFERENCE(PathPatchPropertyChangedBody, propertyUri) JSON_MAP_REFERENCE(PathPatchPropertyChangedBody, atomJson) JSON_MAP_END() - class GetPatchPropertyBody { public: @@ -74,6 +73,20 @@ JSON_MAP_REFERENCE(GetPatchPropertyBody, instanceId) JSON_MAP_REFERENCE(GetPatchPropertyBody, propertyUri) JSON_MAP_END() +class MoveAudioFileArgs +{ +public: + std::string path_; + int32_t from_; + int32_t to_; + DECLARE_JSON_MAP(MoveAudioFileArgs); +}; +JSON_MAP_BEGIN(MoveAudioFileArgs) +JSON_MAP_REFERENCE(MoveAudioFileArgs, path) +JSON_MAP_REFERENCE(MoveAudioFileArgs, from) +JSON_MAP_REFERENCE(MoveAudioFileArgs, to) +JSON_MAP_END() + class CreateNewSampleDirectoryArgs { public: @@ -102,6 +115,23 @@ JSON_MAP_REFERENCE(RenameSampleFileArgs, newRelativePath) JSON_MAP_REFERENCE(RenameSampleFileArgs, uiFileProperty) JSON_MAP_END() +class CopySampleFileArgs +{ +public: + std::string oldRelativePath_; + std::string newRelativePath_; + UiFileProperty uiFileProperty_; + bool overwrite_ = false; + DECLARE_JSON_MAP(CopySampleFileArgs); +}; + +JSON_MAP_BEGIN(CopySampleFileArgs) +JSON_MAP_REFERENCE(CopySampleFileArgs, oldRelativePath) +JSON_MAP_REFERENCE(CopySampleFileArgs, newRelativePath) +JSON_MAP_REFERENCE(CopySampleFileArgs, uiFileProperty) +JSON_MAP_REFERENCE(CopySampleFileArgs, overwrite) +JSON_MAP_END() + class GetFilePropertyDirectoryTreeArgs { public: @@ -111,14 +141,11 @@ public: DECLARE_JSON_MAP(GetFilePropertyDirectoryTreeArgs); }; - JSON_MAP_BEGIN(GetFilePropertyDirectoryTreeArgs) JSON_MAP_REFERENCE(GetFilePropertyDirectoryTreeArgs, fileProperty) JSON_MAP_REFERENCE(GetFilePropertyDirectoryTreeArgs, selectedPath) JSON_MAP_END() - - class Lv2StateChangedBody { public: @@ -147,18 +174,36 @@ JSON_MAP_REFERENCE(SetPatchPropertyBody, propertyUri) JSON_MAP_REFERENCE(SetPatchPropertyBody, value) JSON_MAP_END() +class SetPedalboardItemTitleBody +{ +public: + uint64_t instanceId_; + std::string title_; + DECLARE_JSON_MAP(SetPedalboardItemTitleBody); +}; + +JSON_MAP_BEGIN(SetPedalboardItemTitleBody) +JSON_MAP_REFERENCE(SetPedalboardItemTitleBody, instanceId) +JSON_MAP_REFERENCE(SetPedalboardItemTitleBody, title) +JSON_MAP_END() + + + + class NotifyMidiListenerBody { public: int64_t clientHandle_; - bool isNote_; - int32_t noteOrControl_; + uint16_t cc0_; + uint16_t cc1_; + uint16_t cc2_; DECLARE_JSON_MAP(NotifyMidiListenerBody); }; JSON_MAP_BEGIN(NotifyMidiListenerBody) JSON_MAP_REFERENCE(NotifyMidiListenerBody, clientHandle) -JSON_MAP_REFERENCE(NotifyMidiListenerBody, isNote) -JSON_MAP_REFERENCE(NotifyMidiListenerBody, noteOrControl) +JSON_MAP_REFERENCE(NotifyMidiListenerBody, cc0) +JSON_MAP_REFERENCE(NotifyMidiListenerBody, cc1) +JSON_MAP_REFERENCE(NotifyMidiListenerBody, cc2) JSON_MAP_END() class NotifyAtomOutputBody @@ -181,13 +226,11 @@ JSON_MAP_END() class ListenForMidiEventBody { public: - bool listenForControls_; int64_t handle_; DECLARE_JSON_MAP(ListenForMidiEventBody); }; JSON_MAP_BEGIN(ListenForMidiEventBody) -JSON_MAP_REFERENCE(ListenForMidiEventBody, listenForControls) JSON_MAP_REFERENCE(ListenForMidiEventBody, handle) JSON_MAP_END() @@ -413,7 +456,8 @@ JSON_MAP_REFERENCE(SetSnapshotsBody, snapshots) JSON_MAP_REFERENCE(SetSnapshotsBody, selectedSnapshot) JSON_MAP_END() -class SnapshotModifiedBody { +class SnapshotModifiedBody +{ public: int64_t snapshotIndex_; bool modified_; @@ -582,7 +626,6 @@ public: { FinalCleanup(); } - } bool finalCleanup = false; @@ -1072,7 +1115,7 @@ public: { ListenForMidiEventBody body; pReader->read(&body); - this->model.ListenForMidiEvent(this->clientId, body.handle_, body.listenForControls_); + this->model.ListenForMidiEvent(this->clientId, body.handle_); } else if (message == "cancelListenForMidiEvent") { @@ -1100,7 +1143,7 @@ public: else if (message == "getHasWifi") { bool result = model.GetHasWifi(); - this->Reply(replyTo, "getHasWifi",result); + this->Reply(replyTo, "getHasWifi", result); } else if (message == "updateNow") { @@ -1373,6 +1416,10 @@ public: pReader->read(&instanceId); uint64_t result = model.DeleteBank(this->clientId, instanceId); this->Reply(replyTo, "deleteBankItem", result); + } else if (message == "getHasTone3000Auth") + { + bool result = model.HasTone3000Auth(); + this->Reply(replyTo, "getHasTone3000Auth", result); } else if (message == "renameBank") { @@ -1473,15 +1520,15 @@ public: SetPatchPropertyBody body; pReader->read(&body); model.SendSetPatchProperty(clientId, body.instanceId_, body.propertyUri_, body.value_, [this, replyTo]() - { - this->JsonReply(replyTo, "setPatchProperty", "true"); - }, - [this, replyTo](const std::string &error) - { - this->SendError(replyTo, error.c_str()); - }); + { this->JsonReply(replyTo, "setPatchProperty", "true"); }, [this, replyTo](const std::string &error) + { this->SendError(replyTo, error.c_str()); }); + } + else if (message == "setPedalboardItemTitle") + { + SetPedalboardItemTitleBody body; + pReader->read(&body); + model.SetPedalboardItemTitle(body.instanceId_, body.title_); } - else if (message == "getPatchProperty") { GetPatchPropertyBody body; @@ -1640,17 +1687,43 @@ public: std::string newFileName = this->model.RenameFilePropertyFile(args.oldRelativePath_, args.newRelativePath_, args.uiFileProperty_); this->Reply(replyTo, "renameFilePropertyFile", newFileName); } + else if (message == "copyFilePropertyFile") + { + CopySampleFileArgs args; + pReader->read(&args); + + std::string newFileName = this->model.CopyFilePropertyFile(args.oldRelativePath_, args.newRelativePath_, args.uiFileProperty_, args.overwrite_); + this->Reply(replyTo, "copyFilePropertyFile", newFileName); + } else if (message == "getFilePropertyDirectoryTree") { GetFilePropertyDirectoryTreeArgs args; pReader->read(&args); - FilePropertyDirectoryTree::ptr result = + FilePropertyDirectoryTree::ptr result = + model.GetFilePropertydirectoryTree( + args.fileProperty_, + args.selectedPath_); + this->Reply(replyTo, "GetFilePropertydirectoryTree", result); + } + else if (message == "getFilePropertyDirectoryTree") + { + GetFilePropertyDirectoryTreeArgs args; + pReader->read(&args); + FilePropertyDirectoryTree::ptr result = model.GetFilePropertydirectoryTree( args.fileProperty_, args.selectedPath_); this->Reply(replyTo, "GetFilePropertydirectoryTree", result); } + else if (message == "moveAudioFile") + { + MoveAudioFileArgs args; + pReader->read(&args); + this->model.MoveAudioFile(args.path_, args.from_, args.to_); + bool result = true; + this->Reply(replyTo,"moveAudioFile", result); + } else if (message == "setOnboarding") { bool value; @@ -1659,8 +1732,24 @@ public: } else if (message == "getWifiRegulatoryDomains") { - auto regulatoryDomains = this->model.GetWifiRegulatoryDomains(); + auto regulatoryDomains = this->model.GetWifiRegulatoryDomains(); this->Reply(replyTo, "getWifiRegulatoryDomains", regulatoryDomains); + } else if (message == "setAlsaSequencerConfiguration") + { + AlsaSequencerConfiguration config; + pReader->read(&config); + this->model.SetAlsaSequencerConfiguration(config); + this->Reply(replyTo, "setAlsaSequencerConfiguration"); + } + else if (message == "getAlsaSequencerConfiguration") + { + AlsaSequencerConfiguration config = this->model.GetAlsaSequencerConfiguration(); + this->Reply(replyTo, "getAlsaSequencerConfiguration", config); + } + else if (message == "getAlsaSequencerPorts") + { + std::vector result = model.GetAlsaSequencerPorts(); + this->Reply(replyTo,"getAlsaSequencerPorts", result); } else { @@ -1670,7 +1759,9 @@ public: } protected: - virtual void onSocketClosed() override { + virtual void + onSocketClosed() override + { SocketHandler::OnSocketClosed(); this->Close(); } @@ -1754,10 +1845,15 @@ private: Send("onLv2PluginsChanging", true); Flush(); } - virtual void OnHasWifiChanged(bool hasWifi){ + virtual void OnHasWifiChanged(bool hasWifi) + { Send("onHasWifiChanged", hasWifi); Flush(); + } + virtual void OnAlsaSequencerConfigurationChanged(const AlsaSequencerConfiguration &alsaSequencerConfiguration) override + { + Send("onAlsaSequencerConfigurationChanged", alsaSequencerConfiguration); } virtual void OnNetworkChanging(bool hotspotConnected) override @@ -1771,6 +1867,10 @@ private: { } } + virtual void OnTone3000AuthChanged(bool value) + { + Send("onTone3000AuthChanged", value); + } virtual void OnErrorMessage(const std::string &message) { @@ -1809,7 +1909,7 @@ private: Send("onChannelSelectionChanged", body); } - virtual void OnSnapshotModified(int64_t snapshotIndex, bool modified) + virtual void OnSnapshotModified(int64_t snapshotIndex, bool modified) { SnapshotModifiedBody body; body.snapshotIndex_ = snapshotIndex; @@ -2071,12 +2171,13 @@ private: Send("onNotifyPathPatchPropertyChanged", body); } - virtual void OnNotifyMidiListener(int64_t clientHandle, bool isNote, uint8_t noteOrControl) + virtual void OnNotifyMidiListener(int64_t clientHandle, uint8_t cc0, uint8_t cc1, uint8_t cc2) override { NotifyMidiListenerBody body; body.clientHandle_ = clientHandle; - body.isNote_ = isNote; - body.noteOrControl_ = noteOrControl; + body.cc0_ = cc0; + body.cc1_ = cc1; + body.cc2_ = cc2; Send("onNotifyMidiListener", body); } @@ -2124,7 +2225,6 @@ private: std::atomic PiPedalSocketHandler::nextClientId = 0; - class PiPedalSocketFactory : public ISocketFactory { private: @@ -2152,7 +2252,6 @@ public: } }; - std::shared_ptr pipedal::MakePiPedalSocketFactory(PiPedalModel &model) { return std::make_shared(model); diff --git a/src/PiPedalUI.hpp b/src/PiPedalUI.hpp index a39d688..9a2ef3f 100644 --- a/src/PiPedalUI.hpp +++ b/src/PiPedalUI.hpp @@ -1,18 +1,18 @@ /* * MIT License - * + * * Copyright (c) 2023 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 @@ -31,7 +31,9 @@ #include #include #include "ModFileTypes.hpp" +#include "stdint.h" +#define PIPEDAL_HOST_FEATURE "http://github.com/rerdavies/pipedal#host" // Plugin can only be hosted by PiPedal #define PIPEDAL_PATCH "http://github.com/rerdavies/pipedal/patch" #define PIPEDAL_PATCH_PREFIX PIPEDAL_PATCH "#" @@ -45,17 +47,17 @@ #define PIPEDAL_UI__fileProperties PIPEDAL_UI_PREFIX "fileProperties" #define PIPEDAL_UI__fileProperty PIPEDAL_UI_PREFIX "fileProperty" -#define PIPEDAL_UI__patchProperty PIPEDAL_UI_PREFIX "patchProperty" -#define PIPEDAL_UI__directory PIPEDAL_UI_PREFIX "directory" -#define PIPEDAL_UI__fileTypes PIPEDAL_UI_PREFIX "fileTypes" +#define PIPEDAL_UI__patchProperty PIPEDAL_UI_PREFIX "patchProperty" +#define PIPEDAL_UI__directory PIPEDAL_UI_PREFIX "directory" +#define PIPEDAL_UI__fileTypes PIPEDAL_UI_PREFIX "fileTypes" #define PIPEDAL_UI__resourceDirectory PIPEDAL_UI_PREFIX "resourceDirectory" -#define PIPEDAL_UI__fileType PIPEDAL_UI_PREFIX "fileType" -#define PIPEDAL_UI__fileExtension PIPEDAL_UI_PREFIX "fileExtension" -#define PIPEDAL_UI__mimeType PIPEDAL_UI_PREFIX "mimeType" +#define PIPEDAL_UI__fileType PIPEDAL_UI_PREFIX "fileType" +#define PIPEDAL_UI__fileExtension PIPEDAL_UI_PREFIX "fileExtension" +#define PIPEDAL_UI__mimeType PIPEDAL_UI_PREFIX "mimeType" -#define PIPEDAL_UI__outputPorts PIPEDAL_UI_PREFIX "outputPorts" -#define PIPEDAL_UI__text PIPEDAL_UI_PREFIX "text" +#define PIPEDAL_UI__outputPorts PIPEDAL_UI_PREFIX "outputPorts" +#define PIPEDAL_UI__text PIPEDAL_UI_PREFIX "text" #define PIPEDAL_UI__frequencyPlot PIPEDAL_UI_PREFIX "frequencyPlot" #define PIPEDAL_UI__xLeft PIPEDAL_UI_PREFIX "xLeft" @@ -65,57 +67,57 @@ #define PIPEDAL_UI__yBottom PIPEDAL_UI_PREFIX "yBottom" #define PIPEDAL_UI__width PIPEDAL_UI_PREFIX "width" -#define PIPEDAL_UI__ledColor PIPEDAL_UI_PREFIX "ledColor" +#define PIPEDAL_UI__ledColor PIPEDAL_UI_PREFIX "ledColor" #define PIPEDAL_UI__graphicEq PIPEDAL_UI_PREFIX "graphicEq" - -namespace pipedal { +namespace pipedal +{ class PluginHost; - - class UiFileType { + class UiFileType + { private: std::string label_; std::string mimeType_; std::string fileExtension_; + public: - UiFileType() { } - UiFileType(PluginHost*pHost, const LilvNode*node); - UiFileType(const std::string&label, const std::string &fileType); + UiFileType() {} + UiFileType(PluginHost *pHost, const LilvNode *node); + UiFileType(const std::string &label, const std::string &fileType); + static std::vector GetArray(PluginHost *pHost, const LilvNode *node, const LilvNode *uri); - static std::vector GetArray(PluginHost*pHost, const LilvNode*node,const LilvNode*uri); - - const std::string& label() const { return label_;} + const std::string &label() const { return label_; } const std::string &fileExtension() const { return fileExtension_; } const std::string &mimeType() const { return mimeType_; } - bool IsValidExtension(const std::string&extension) const; + bool IsValidExtension(const std::string &extension) const; + public: DECLARE_JSON_MAP(UiFileType); - }; - - - class UiPortNotification { + class UiPortNotification + { private: int32_t portIndex_; std::string symbol_; std::string plugin_; std::string protocol_; + public: using ptr = std::shared_ptr; - UiPortNotification() { } - UiPortNotification(PluginHost*pHost, const LilvNode*node); - + UiPortNotification() {} + UiPortNotification(PluginHost *pHost, const LilvNode *node); + public: DECLARE_JSON_MAP(UiPortNotification); - }; - class UiFileProperty { + class UiFileProperty + { private: std::string label_; std::int32_t index_ = -1; @@ -125,57 +127,59 @@ namespace pipedal { std::string portGroup_; std::string resourceDirectory_; std::vector modDirectories_; - bool useLegacyModDirectory_= false; - std::map> fileExtensionsByModDirectory; // non-serialized. + bool useLegacyModDirectory_ = false; + std::map> fileExtensionsByModDirectory; // non-serialized. void PrecalculateFileExtensions(); + public: using ptr = std::shared_ptr; - UiFileProperty() { } - UiFileProperty(PluginHost*pHost, const LilvNode*node, const std::filesystem::path&resourcePath); - UiFileProperty(const std::string&label, const std::string&patchProperty,const std::string &directory); + UiFileProperty() {} + UiFileProperty(PluginHost *pHost, const LilvNode *node, const std::filesystem::path &resourcePath); + UiFileProperty(const std::string &label, const std::string &patchProperty, const std::string &directory); UiFileProperty( - const std::string&label, - const std::string&patchProperty, - const ModFileTypes&modFileType); + const std::string &label, + const std::string &patchProperty, + const ModFileTypes &modFileType); - void setModFileTypes(const ModFileTypes&modFileType); + void setModFileTypes(const ModFileTypes &modFileType); + + std::vector &modDirectories() { return modDirectories_; } + const std::vector &modDirectories() const { return modDirectories_; } - std::vector& modDirectories() { return modDirectories_; } - const std::vector& modDirectories() const { return modDirectories_; } - bool useLegacyModDirectory() const { return useLegacyModDirectory_; } void useLegacyModDirectory(bool value) { useLegacyModDirectory_ = value; } const std::string &label() const { return label_; } int32_t index() const { return index_; } void index(int32_t value) { index_ = value; } - + const std::string &directory() const { return directory_; } void directory(const std::string &path) { directory_ = path; } - const std::string&portGroup() const { return portGroup_; } + const std::string &portGroup() const { return portGroup_; } const std::vector &fileTypes() const { return fileTypes_; } std::vector &fileTypes() { return fileTypes_; } const std::string &patchProperty() const { return patchProperty_; } - bool IsValidExtension(const std::filesystem::path&relativePath) const; + bool IsValidExtension(const std::filesystem::path &relativePath) const; - static bool IsDirectoryNameValid(const std::string&value); + static bool IsDirectoryNameValid(const std::string &value); - static std::string GetFileExtension(const std::filesystem::path&path); - - const std::string&resourceDirectory() const { return resourceDirectory_; } + static std::string GetFileExtension(const std::filesystem::path &path); + const std::string &resourceDirectory() const { return resourceDirectory_; } - const std::set& GetPermittedFileExtensions(const std::string &modDirectory) const; + const std::set &GetPermittedFileExtensions(const std::string &modDirectory) const; + + std::string getParentModDirectory(const std::filesystem::path &path) const; - std::string getParentModDirectory(const std::filesystem::path&path) const; public: DECLARE_JSON_MAP(UiFileProperty); }; - class UiFrequencyPlot { + class UiFrequencyPlot + { private: std::string patchProperty_; std::int32_t index_ = -1; @@ -186,15 +190,16 @@ namespace pipedal { float yBottom_ = -30; bool xLog_ = true; float width_ = 60; + public: using ptr = std::shared_ptr; - UiFrequencyPlot() { } - UiFrequencyPlot(PluginHost*pHost, const LilvNode*node, - const std::filesystem::path&resourcePath); + UiFrequencyPlot() {} + UiFrequencyPlot(PluginHost *pHost, const LilvNode *node, + const std::filesystem::path &resourcePath); const std::string &patchProperty() const { return patchProperty_; } int32_t index() const { return index_; } - const std::string&portGroup() const { return portGroup_; } + const std::string &portGroup() const { return portGroup_; } float xLeft() const { return xLeft_; } float xRight() const { return xRight_; } bool xLog() const { return xLog_; } @@ -206,32 +211,31 @@ namespace pipedal { DECLARE_JSON_MAP(UiFrequencyPlot); }; - class PiPedalUI { + class PiPedalUI + { public: using ptr = std::shared_ptr; - PiPedalUI(PluginHost*pHost, const LilvNode*uiNode, const std::filesystem::path&resourcePath); + PiPedalUI(PluginHost *pHost, const LilvNode *uiNode, const std::filesystem::path &resourcePath); PiPedalUI( std::vector &&fileProperties, std::vector &&frequencyPlots); PiPedalUI( std::vector &&fileProperties); - const std::vector& fileProperties() const + const std::vector &fileProperties() const { return fileProperties_; } - const std::vector& frequencyPlots() const + const std::vector &frequencyPlots() const { return frequencyPlots_; } - - const std::vector &portNotifications() const { return portNotifications_; } - const UiFileProperty*GetFileProperty(const std::string &propertyUri) const + const UiFileProperty *GetFileProperty(const std::string &propertyUri) const { - for (const auto&fileProperty : fileProperties()) + for (const auto &fileProperty : fileProperties()) { if (fileProperty->patchProperty() == propertyUri) { @@ -241,7 +245,8 @@ namespace pipedal { return nullptr; } bool unsupportedPatchProperty() const { return unsupportedPatchProperty_; } - void unsupportedPatchProperty(bool value) { unsupportedPatchProperty_ = value; } + void unsupportedPatchProperty(bool value) { unsupportedPatchProperty_ = value; } + private: bool unsupportedPatchProperty_ = false; // not serialized. std::vector fileProperties_; @@ -250,7 +255,6 @@ namespace pipedal { }; // utilities for validating file paths received via PiPedalFileProperty-related APIs. - bool IsAlphaNumeric(const std::string&value); - + bool IsAlphaNumeric(const std::string &value); }; \ No newline at end of file diff --git a/src/PipewireInputStream.cpp b/src/PipewireInputStream.cpp new file mode 100644 index 0000000..b6ff96a --- /dev/null +++ b/src/PipewireInputStream.cpp @@ -0,0 +1,244 @@ +/* + * MIT License + * + * Copyright (c) 2025 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 "PipewireInputStream.hpp" +#include +#include +extern "C" +{ +#include +#include +#include +} +#include +#include + +using namespace pipedal; + +namespace pipedal::impl +{ + + class PipeWireInputStreamImpl : public PipeWireInputStream + { + private: + pw_main_loop *loop = nullptr; + pw_stream *stream = nullptr; + Callback callback; + + std::atomic is_running_{false}; + + static void on_process(void *userData) + { + PipeWireInputStreamImpl *this_ = (PipeWireInputStreamImpl *)userData; + this_->on_process(); + } + + void on_process() + { + pw_buffer *buf; + + if ((buf = pw_stream_dequeue_buffer(stream)) == NULL) + return; + + // Process buffer data + spa_buffer *sbuf = buf->buffer; + for (uint32_t i = 0; i < sbuf->n_datas; i++) + { + // Access audio data through sbuf->datas[i].data + // Size is sbuf->datas[i].chunk->size + } + + pw_stream_queue_buffer(stream, buf); + } + + static void on_stream_state_changed(void *userData, enum pw_stream_state old, + enum pw_stream_state state, const char *error) + { + PipeWireInputStreamImpl *this_ = (PipeWireInputStreamImpl *)userData; + this_->on_stream_state_changed( + old, state, error); + } + + void on_stream_state_changed(enum pw_stream_state old, + enum pw_stream_state state, const char *error) + { + if (state == PW_STREAM_STATE_ERROR) + { + pw_main_loop_quit(loop); + is_running_ = false; + } + } + + static const struct pw_stream_events stream_events_; + + public: + PipeWireInputStreamImpl(const std::string &stream_name, + uint32_t channels, + uint32_t rate ) + : is_running_(false) + { + + // Initialize PipeWire + pw_init(nullptr, nullptr); + + // Create main loop + loop = pw_main_loop_new(nullptr); + if (!loop) + { + throw std::runtime_error("Failed to create main loop"); + } + + // Create stream + stream = pw_stream_new_simple( + pw_main_loop_get_loop(loop), + stream_name.c_str(), + pw_properties_new( + PW_KEY_MEDIA_TYPE, "Audio", + PW_KEY_MEDIA_CATEGORY, "Playback", + PW_KEY_MEDIA_CLASS, "Audio/Sink", + PW_KEY_NODE_NAME, stream_name.c_str(), + PW_KEY_NODE_DESCRIPTION, stream_name.c_str(), + PW_KEY_APP_NAME, "PiPedal Test", + PW_KEY_APP_PROCESS_BINARY, "pipedaltest", + PW_KEY_MEDIA_ROLE, "DSP", + nullptr), + &stream_events_, + this); + + if (!stream) + { + pw_main_loop_destroy(loop); + loop = nullptr; + throw std::runtime_error("Failed to create stream"); + } + + // Setup audio format + uint8_t buffer[1024]; + spa_pod_builder b = {0}; + spa_pod_builder_init(&b, buffer, sizeof(buffer)); + + const spa_pod *params[1]; + spa_audio_info_raw format = { + .format = SPA_AUDIO_FORMAT_S16, + .flags = SPA_AUDIO_FLAG_NONE, + .rate = rate, + .channels = channels}; + if (channels == 1) + { + format.position[0] = SPA_AUDIO_CHANNEL_MONO; // Mono channel + } + else if (channels == 2) + { + format.position[0] = SPA_AUDIO_CHANNEL_FL; // Front Left + format.position[1] = SPA_AUDIO_CHANNEL_FR; // Front Right + } + else if (channels == 4) + { + // 3.1 + 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; + } + else + { + throw std::runtime_error("Unsupported number of channels: " + std::to_string(channels)); + } + params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &format); + + // Connect stream + int res = pw_stream_connect( + stream, + PW_DIRECTION_INPUT, + PW_ID_ANY, + pw_stream_flags(PW_STREAM_FLAG_AUTOCONNECT | + PW_STREAM_FLAG_RT_PROCESS), + params, 1); + + if (res < 0) + { + pw_stream_destroy(stream); + stream = nullptr; + pw_main_loop_destroy(loop); + loop = nullptr; + throw std::runtime_error("Failed to connect stream: " + std::string(strerror(-res))); + } + + // xxx: delete me + pw_main_loop_run(loop); + } + + ~PipeWireInputStreamImpl() + { + if (stream) + { + pw_stream_destroy(stream); + } + if (loop) + { + pw_main_loop_destroy(loop); + } + pw_deinit(); + } + + std::unique_ptr serviceThread; + + + virtual void Activate(Callback &&callback) override + { + serviceThread = std::make_unique([this]() { + if (!is_running_) + { + is_running_ = true; + pw_main_loop_run(loop); + is_running_ = false; + } + }); + } + + virtual void Deactivate() override + { + if (is_running_) + { + pw_main_loop_quit(loop); + } + serviceThread = nullptr; // and join. + } + + bool IsActive() const { return is_running_; } + }; + + const struct pw_stream_events PipeWireInputStreamImpl::stream_events_ = { + .version = PW_VERSION_STREAM_EVENTS, + .state_changed = on_stream_state_changed, + .process = on_process, + }; +} + +using namespace pipedal::impl; + +PipeWireInputStream::ptr PipeWireInputStream::Create(const std::string &streamName, int sampleRate, int channels) +{ + return std::make_shared(streamName, channels, sampleRate); +} \ No newline at end of file diff --git a/src/PipewireInputStream.hpp b/src/PipewireInputStream.hpp new file mode 100644 index 0000000..fe67370 --- /dev/null +++ b/src/PipewireInputStream.hpp @@ -0,0 +1,50 @@ +/* + * MIT License + * + * Copyright (c) 2025 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 +#include +#include + +namespace pipedal +{ + class PipeWireInputStream + { + protected: + PipeWireInputStream() {} + + public: + virtual ~PipeWireInputStream() {} + + using self = PipeWireInputStream; + using ptr = std::shared_ptr; + + using Callback = std::function; + static ptr Create(const std::string &streamName, int sampleRate, int channels); + + virtual void Activate(Callback &&callback) = 0; + virtual void Deactivate() = 0; + virtual bool IsActive() const = 0; + }; +} \ No newline at end of file diff --git a/src/PipewireInputStreamTest.cpp b/src/PipewireInputStreamTest.cpp new file mode 100644 index 0000000..09cb03c --- /dev/null +++ b/src/PipewireInputStreamTest.cpp @@ -0,0 +1,61 @@ +// Copyright (c) 2022 Robin 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 "catch.hpp" +#include +#include +#include +#include +#include "PipewireInputStream.hpp" + +#include "PiPedalAlsa.hpp" +#include +#include + +using namespace pipedal; +using namespace std; + + +TEST_CASE("PipeWire Input Stream Test", "[pipewire_input_stream]") +{ + + auto stream = PipeWireInputStream::Create("PiPedalTest Stream", 48000, 2); + REQUIRE(stream != nullptr); + REQUIRE(!stream->IsActive()); + std::atomic frameCount = 0; + std::atomic *pFrameCount = &frameCount; + stream->Activate([pFrameCount] ( + const float*buffer, + size_t size) + { + *pFrameCount += size; + }); + + size_t lastCount = frameCount; + while (true) { + size_t t = frameCount; + if (lastCount != t) { + lastCount = t; + cout << "Frame count: " << lastCount << endl; + } + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } +} + diff --git a/src/PluginHost.cpp b/src/PluginHost.cpp index 08077f3..e6355cf 100644 --- a/src/PluginHost.cpp +++ b/src/PluginHost.cpp @@ -249,6 +249,7 @@ static float nodeAsFloat(const LilvNode *node, float default_ = 0) void PluginHost::SetPluginStoragePath(const std::filesystem::path &path) { pluginStoragePath = path; + fileMetadataFeature.SetPluginStoragePath(path); } std::string PluginHost::GetPluginStoragePath() const @@ -266,6 +267,9 @@ PluginHost::PluginHost() optionsFeature.Prepare(mapFeature, 44100, this->GetMaxAudioBufferSize(), this->GetAtomBufferSize()); lv2Features.push_back(optionsFeature.GetFeature()); + fileMetadataFeature.Prepare(mapFeature); + lv2Features.push_back(fileMetadataFeature.GetFeature()); + lv2Features.push_back(nullptr); @@ -615,7 +619,7 @@ Lv2PluginInfo::FindWritablePathProperties(PluginHost *lv2Host, const LilvPlugin // example: // - // a lv2:Parameter; +// a lv2:Parameter; // rdfs:label "Model"; // rdfs:range atom:Path. // ... @@ -901,6 +905,8 @@ std::vector supportedFeatures = { LV2_STATE__mapPath, LV2_STATE__freePath, LV2_CORE__inPlaceBroken, + PIPEDAL_HOST_FEATURE, + PIPEDAL__FILE_METADATA_FEATURE, // UI features that we can ignore, since we won't load their ui. "http://lv2plug.in/ns/extensions/ui#makeResident", diff --git a/src/PluginHost.hpp b/src/PluginHost.hpp index ddc5d84..cafa183 100644 --- a/src/PluginHost.hpp +++ b/src/PluginHost.hpp @@ -25,6 +25,7 @@ #include "PluginType.hpp" #include #include "MapFeature.hpp" +#include "FileMetadataFeature.hpp" #include "OptionsFeature.hpp" #include #include @@ -794,6 +795,7 @@ namespace pipedal std::vector lv2Features; MapFeature mapFeature; + FileMetadataFeature fileMetadataFeature; OptionsFeature optionsFeature; std::string pluginStoragePath; @@ -858,9 +860,11 @@ namespace pipedal virtual std::shared_ptr GetHostWorkerThread(); public: - virtual MapFeature &GetMapFeature() { return this->mapFeature; } + virtual MapFeature &GetMapFeature() override { return this->mapFeature; } void CheckForResourceInitialization(const std::string& pluginUri,const std::filesystem::path& pluginUploadDirectory); + + std::string MapResourcePath(const std::string&uri, const std::string&relativePath); void ReloadPlugins(); diff --git a/src/PresetBundle.cpp b/src/PresetBundle.cpp index 27ae4da..7340ad5 100644 --- a/src/PresetBundle.cpp +++ b/src/PresetBundle.cpp @@ -42,6 +42,45 @@ static std::string ToString(const std::vector &value) return std::string(start, end); } +static std::string ToJsonAtomPath(const std::string &path) { + json_variant v = json_variant::make_object();; + auto obj = v.as_object(); + (*obj)["otype_"] = json_variant("Path"); + (*obj)["value"] = json_variant(path); + std::ostringstream ss; + json_writer writer(ss); + writer.write(v); + return ss.str(); +} +static bool TryGetAtomPath(const std::string &atomJson, std::string *outPath) +{ + if (atomJson.empty()) + return false; + std::stringstream ss(atomJson); + json_reader reader(ss); + json_variant vProperty; + reader.read(&vProperty); + if (vProperty.is_object()) + { + auto obj = vProperty.as_object(); + if (obj->contains("otype_") && + (*obj)["otype_"].as_string() == "Path") + { + if (obj->contains("value")) + { + std::string value = (*obj)["value"].as_string(); + if (!value.empty()) + { + *outPath = value; + return true; + } + } + } + } + return false; +} + + static std::vector ToBinary(const std::string &value) { std::vector result(value.length() + 1); @@ -65,6 +104,9 @@ public: void LoadPresets(PiPedalModel &model, const std::string &presetJson) { + pluginUploadDirectory = model.GetPluginUploadDirectory(); + pluginUploadDirectoryString = pluginUploadDirectory.string(); + BankFile bankFile; std::istringstream s(presetJson); @@ -73,12 +115,13 @@ public: reader.read(&bankFile); GatherMediaPaths(model, bankFile); configFiles["bankFile.json"] = presetJson; - - pluginUploadDirectory = model.GetPluginUploadDirectory(); } void LoadPluginPresets(PiPedalModel &model, const std::string pluginPresetJson) { + pluginUploadDirectory = model.GetPluginUploadDirectory(); + pluginUploadDirectoryString = pluginUploadDirectory.string(); + PluginPresets pluginPresets; std::istringstream s(pluginPresetJson); @@ -88,8 +131,6 @@ public: GatherMediaPaths(model, pluginPresets); configFiles["pluginPresets.json"] = pluginPresetJson; - - pluginUploadDirectory = model.GetPluginUploadDirectory(); } virtual ~PresetBundleWriterImpl() noexcept; @@ -98,6 +139,10 @@ public: private: void AddUsedPlugin(PiPedalModel &model, const std::string &pluginUri, const std::string &name); + std::string UnmapPath(const std::string &path); + std::string MapPath(const std::string &path); + bool IsValidMediaPath(const std::string &path); + void GatherMediaPaths(PiPedalModel &model, BankFile &bankFile); void GatherMediaPaths(PiPedalModel &model, PluginPresets &pluginPreset); @@ -107,6 +152,7 @@ private: std::vector> pluginMetadata; std::filesystem::path pluginUploadDirectory; + std::string pluginUploadDirectoryString; }; void PresetBundleWriterImpl::WriteToFile(const std::filesystem::path &filePath) @@ -134,12 +180,24 @@ void PresetBundleWriterImpl::WriteToFile(const std::filesystem::path &filePath) std::filesystem::path sourcePath = this->pluginUploadDirectory / std::filesystem::path(mediaPath); if (std::filesystem::exists(sourcePath)) { - zipFile->WriteFile(zipName, sourcePath); + if (IsValidMediaPath(sourcePath)) // paranoid guard against exfiltration of non-media files. + { + zipFile->WriteFile(zipName, sourcePath); + } + else + { + Lv2Log::warning(SS("Media file is not valid: " << sourcePath)); + } } else { Lv2Log::warning(SS("Media file not found: " << sourcePath)); } + std::filesystem::path metadataPath = SS(sourcePath.string() << ".mdata"); + if (std::filesystem::exists(metadataPath)) + { + zipFile->WriteFile(SS(zipName << ".mdata"), metadataPath); + } } zipFile->Close(); } @@ -165,6 +223,54 @@ void PresetBundleWriterImpl::GatherMediaPaths(PiPedalModel &model, BankFile &ban } } } + for (const auto &property : plugin->pathProperties_) + { + std::string path; + if (TryGetAtomPath(property.second, &path)) + { + path = UnmapPath(path); + if (!mediaPaths.contains(path)) + { + mediaPaths.insert(std::move(path)); + } + } + } + for (const auto &snapshot : pedalboard.snapshots()) + { + if (snapshot) + { + for (const auto &value : snapshot->values_) + { + if (value.isEnabled_) + { + const Lv2PluginState &state = value.lv2State_; + for (const auto &v : state.values_) + { + if (v.second.atomType_ == LV2_ATOM__Path) + { + std::string path = ToString(v.second.value_); + if (!mediaPaths.contains(path)) + { + mediaPaths.insert(std::move(path)); + } + } + } + for (const auto &property : value.pathProperties_) + { + std::string path; + if (TryGetAtomPath(property.second, &path)) + { + path = UnmapPath(path); + if (!mediaPaths.contains(path)) + { + mediaPaths.insert(std::move(path)); + } + } + } + } + } + } + } } } } @@ -236,10 +342,39 @@ private: void ExtractMediaFile(const std::string &zipFileName); bool IsSameFile(const std::string &zipFileName, const std::filesystem::path &filePath) { - return zipFile->CompareFiles(zipFileName, filePath); + if (!zipFile->CompareFiles(zipFileName, filePath)) + { + return false; + } + std::string metadataZipFilename = SS(zipFileName << ".mdata"); + std::filesystem::path metadataFilename = SS(filePath.string() << ".mdata"); + + if (!std::filesystem::exists(metadataFilename)) + { + if (!zipFile->FileExists(metadataZipFilename)) + { + return true; + } + return false; + } + else + { + if (!zipFile->FileExists(metadataZipFilename)) + { + return false; + } + return zipFile->CompareFiles(metadataZipFilename, metadataFilename); + } + return true; } void RenameMediaFileProperty(const std::string oldName, const std::string &newName); + void RenameState(Lv2PluginState &state, const std::string oldName, const std::string &newName); + void RenamePedalboardItem(PedalboardItem *item, const std::string oldName, const std::string &newName); + void RenamePedalboard(Pedalboard &pedalboard, const std::string oldName, const std::string &newName); + void RenamePreset(PluginPreset &pedalboard, const std::string oldName, const std::string &newName); + void RenameSnapshot(Snapshot *snapshot, const std::string oldName, const std::string &newName); + std::filesystem::path pluginUploadDirectory; // BankFile bankFile; ZipFileReader::ptr zipFile; @@ -247,45 +382,137 @@ private: BankFile bankFile; PluginPresets pluginPresets; }; - -void PresetBundleReaderImpl::RenameMediaFileProperty(const std::string oldName, const std::string &newName) +void PresetBundleReaderImpl::RenameState(Lv2PluginState &state, const std::string oldName, const std::string &newName) { - for (auto &preset : bankFile.presets()) + for (auto &value : state.values_) { - Pedalboard &pedalboard = preset->preset(); - auto items = pedalboard.GetAllPlugins(); - for (auto plugin : items) + if (value.second.atomType_ == LV2_ATOM__Path) { - Lv2PluginState &state = plugin->lv2State(); - for (auto &value : state.values_) + std::string path = ToString(value.second.value_); + if (path == oldName) { - if (value.second.atomType_ == LV2_ATOM__Path) + state.values_.at(value.first).value_ = ToBinary(newName); + } + } + } +} + +void PresetBundleReaderImpl::RenamePedalboardItem(PedalboardItem *plugin, const std::string oldName, const std::string &newName) +{ + Lv2PluginState &state = plugin->lv2State(); + RenameState(state, oldName, newName); + + std::vector> propertiesToUpdate; + std::string fullOldPath = (pluginUploadDirectory / oldName).string(); + for (auto &property : plugin->pathProperties_) + { + std::stringstream ss(property.second); + json_reader reader(ss); + json_variant vProperty; + reader.read(&vProperty); + if (vProperty.is_object()) + { + auto obj = vProperty.as_object(); + if (obj->contains("otype_") && + (*obj)["otype_"].as_string() == "Path") + { + if (obj->contains("value")) { - std::string path = ToString(value.second.value_); - if (path == oldName) + std::string value = (*obj)["value"].as_string(); + if (value == fullOldPath) { - state.values_.at(value.first).value_ = ToBinary(newName); + (*obj)["value"] = (pluginUploadDirectory / newName).string(); + std::ostringstream ss; + json_writer writer(ss); + writer.write(vProperty); + propertiesToUpdate.push_back(std::make_pair(property.first, ss.str())); } } } } } - for (auto preset : pluginPresets.presets_) + for (auto &property : propertiesToUpdate) { - Lv2PluginState state = preset.state_; - for (auto &value : state.values_) + plugin->pathProperties_[property.first] = property.second; + } +} + +void PresetBundleReaderImpl::RenameSnapshot(Snapshot *snapshot, const std::string oldName, const std::string &newName) +{ + std::string fullOldPath = (pluginUploadDirectory / oldName).string(); + for (auto &value : snapshot->values_) + { + if (value.isEnabled_) { - if (value.second.atomType_ == LV2_ATOM__Path) + Lv2PluginState &state = value.lv2State_; + RenameState(state, oldName, newName); + + std::vector> propertiesToUpdate; + // Check the path properties in the snapshot value. + for (auto &property : value.pathProperties_) { - std::string path = ToString(value.second.value_); - if (path == oldName) - { - state.values_.at(value.first).value_ = ToBinary(newName); + std::string path; + if (TryGetAtomPath(property.second,&path)) { + if (path == fullOldPath) { + propertiesToUpdate.push_back(std::make_pair(property.first, + ToJsonAtomPath((pluginUploadDirectory / newName).string()))); + } } } + for (auto &property : propertiesToUpdate) + { + value.pathProperties_[property.first] = property.second; + } } } } +void PresetBundleReaderImpl::RenamePedalboard(Pedalboard &pedalboard, const std::string oldName, const std::string &newName) +{ + auto items = pedalboard.GetAllPlugins(); + for (auto pedalboardItem : items) + { + RenamePedalboardItem(pedalboardItem, oldName, newName); + } + for (std::shared_ptr &snapshot : pedalboard.snapshots()) + { + if (snapshot) + { + RenameSnapshot(snapshot.get(), oldName, newName); + } + } +} + +void PresetBundleReaderImpl::RenamePreset(PluginPreset &preset, const std::string oldName, const std::string &newName) +{ + Lv2PluginState state = preset.state_; + for (auto &value : state.values_) + { + if (value.second.atomType_ == LV2_ATOM__Path) + { + std::string path = ToString(value.second.value_); + if (path == oldName) + { + state.values_.at(value.first).value_ = ToBinary(newName); + } + } + } +} + +void PresetBundleReaderImpl::RenameMediaFileProperty(const std::string oldName, const std::string &newName) +{ + // snapshots. + // there should be a dictionary for remapped media files. + // there should be a set for saved media files. + for (auto &preset : bankFile.presets()) + { + Pedalboard &pedalboard = preset->preset(); + RenamePedalboard(pedalboard, oldName, newName); + } + for (auto &preset : pluginPresets.presets_) + { + RenamePreset(preset, oldName, newName); + } +} PresetBundleReader::ptr PresetBundleReader::LoadPresetsFile(PiPedalModel &model, const std::filesystem::path &filePath) { @@ -363,6 +590,10 @@ void PresetBundleReaderImpl::ExtractMediaFile(const std::string &zipFileName) namespace fs = std::filesystem; if (zipFileName.starts_with("media/")) { + if (zipFileName.ends_with(".mdata")) + { + return; + } std::string baseName = zipFileName.substr(6); fs::path targetFileName = this->pluginUploadDirectory / std::filesystem::path(baseName); bool renamed = false; @@ -383,6 +614,10 @@ void PresetBundleReaderImpl::ExtractMediaFile(const std::string &zipFileName) else { zipFile->ExtractTo(zipFileName, targetFileName); + if (zipFile->FileExists(SS(zipFileName << ".mdata"))) + { + zipFile->ExtractTo(SS(zipFileName << ".mdata"), SS(targetFileName.string() << ".mdata")); + } break; } } @@ -465,3 +700,33 @@ void PresetBundleWriterImpl::AddUsedPlugin(PiPedalModel &model, const std::strin } } } +std::string PresetBundleWriterImpl::UnmapPath(const std::string &path) +{ + if (path.starts_with(pluginUploadDirectoryString)) + { + return path.substr(pluginUploadDirectory.string().length() + 1); + } + return path; +} +bool PresetBundleWriterImpl::IsValidMediaPath(const std::string &path) +{ + if (path.starts_with(pluginUploadDirectoryString)) + { + if (path.length() >= pluginUploadDirectoryString.length() + 1 && + path[pluginUploadDirectoryString.length()] == '/') + { + return true; + ; + } + } + return false; +} +std::string PresetBundleWriterImpl::MapPath(const std::string &path) +{ + if (!path.starts_with("/")) + { + return pluginUploadDirectory / path; + } + return path; +} + diff --git a/src/RingBufferReader.hpp b/src/RingBufferReader.hpp index 634d354..c3260fb 100644 --- a/src/RingBufferReader.hpp +++ b/src/RingBufferReader.hpp @@ -31,6 +31,20 @@ namespace pipedal { class IndexedSnapshot; + class MidiNotifyBody + { + public: + MidiNotifyBody() = default; + MidiNotifyBody(uint8_t cc0, uint8_t cc1, uint8_t cc2) + : cc0_(cc0), cc1_(cc1), cc2_(cc2) + { + } + + uint8_t cc0_; + uint8_t cc1_; + uint8_t cc2_; + }; + enum class RingBufferCommand : int64_t { Invalid = 0, @@ -38,7 +52,7 @@ namespace pipedal EffectReplaced, SetValue, SetBypass, - //AudioStopped, + // AudioStopped, AudioTerminatedAbnormally, // specifically for an ALSA loss of connection. SetVuSubscriptions, FreeVuSubscriptions, @@ -327,7 +341,7 @@ namespace pipedal return; } } - + template void write(RingBufferCommand command, const T &value, size_t dataLength, uint8_t *variableData) { @@ -374,12 +388,9 @@ namespace pipedal write(RingBufferCommand::MidiValueChanged, body); } - void OnMidiListen(bool isNote, uint8_t noteOrControl) + void OnMidiListen(const MidiNotifyBody &body) { - uint16_t msg = noteOrControl; - if (isNote) - msg |= 0x100; - write(RingBufferCommand::OnMidiListen, msg); + write(RingBufferCommand::OnMidiListen, body); } /** @@ -485,7 +496,8 @@ namespace pipedal { write(RingBufferCommand::AckMidiProgramChange, requestId); } - void AckMidiSnapshotRequest(uint64_t snapshotRequestId) { + void AckMidiSnapshotRequest(uint64_t snapshotRequestId) + { write(RingBufferCommand::AckMidiSnapshotRequest, snapshotRequestId); } diff --git a/src/Storage.cpp b/src/Storage.cpp index 8fa628f..29872c5 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -22,6 +22,7 @@ #include "Storage.hpp" #include "AudioConfig.hpp" #include "PiPedalException.hpp" +#include "AlsaSequencer.hpp" #include #include #include "json.hpp" @@ -37,6 +38,8 @@ #include #include #include "util.hpp" +#include "AudioFiles.hpp" +#include "Utf8Utils.hpp" using namespace pipedal; namespace fs = std::filesystem; @@ -46,7 +49,6 @@ const char *BANKS_FILENAME = "index.banks"; #define USER_SETTINGS_FILENAME "userSettings.json"; - static bool hasSyntheticModRoot(const UiFileProperty &fileProperty) { return (fileProperty.modDirectories().size() > 1 || (fileProperty.modDirectories().size() == 1 && fileProperty.useLegacyModDirectory())); @@ -202,6 +204,12 @@ static void CopyDirectory(const std::filesystem::path &source, const std::filesy { for (auto &directoryEntry : std::filesystem::directory_iterator(source)) { + if (!IsValidUtf8(directoryEntry.path().string())) + { + // skip invalid UTF-8 paths. + Lv2Log::warning("Skipping invalid UTF-8 path: %s", directoryEntry.path().string().c_str()); + continue; + } if (directoryEntry.is_regular_file()) { std::filesystem::path sourceFile = directoryEntry.path(); @@ -246,6 +254,7 @@ void Storage::Initialize() LoadPluginPresetIndex(); LoadBankIndex(); LoadCurrentBank(); + LoadTone3000Auth(); try { LoadChannelSelection(); @@ -253,6 +262,8 @@ void Storage::Initialize() catch (const std::exception &) { } + LoadAlsaSequencerConfiguration(); + LoadWifiConfigSettings(); LoadWifiDirectConfigSettings(); LoadUserSettings(); @@ -301,10 +312,19 @@ std::filesystem::path Storage::GetCurrentPresetPath() const return this->dataRoot / "currentPreset.json"; } +std::filesystem::path Storage::GetTone3000AuthPath() const +{ + return this->dataRoot / "tone3000.json"; +} + std::filesystem::path Storage::GetChannelSelectionFileName() { return this->dataRoot / "JackChannelSelection.json"; } +std::filesystem::path Storage::GetAlsaSequencerConfigurationFileName() +{ + return this->dataRoot / "MidiDevices.json"; +} std::filesystem::path Storage::GetIndexFileName() const { return this->GetPresetsDirectory() / BANKS_FILENAME; @@ -394,6 +414,12 @@ void Storage::ReIndex() { for (const auto &dirEntry : std::filesystem::directory_iterator(GetPresetsDirectory())) { + if (!IsValidUtf8(dirEntry.path().string())) + { + // skip invalid UTF-8 paths. + Lv2Log::warning("Skipping invalid UTF-8 path: %s", dirEntry.path().string().c_str()); + continue; + } if (!dirEntry.is_directory()) { auto path = dirEntry.path(); @@ -721,6 +747,103 @@ JackChannelSelection Storage::GetJackChannelSelection(const JackConfiguration &j return jackChannelSelection.RemoveInvalidChannels(jackConfiguration); } +static AlsaSequencerConfiguration MigrateRawMidiToAlsaSequencer(std::vector &selectedDevices) +{ + AlsaSequencerConfiguration result; + try + { + auto sequencerPorts = AlsaSequencer::EnumeratePorts(); + + // Prepare Migrate raw MIDI devices to ALSA sequencer ports. + + for (auto i = selectedDevices.begin(); i != selectedDevices.end(); ++i) + { + if (i->name_.starts_with("hw:")) + { + for (const auto &port : sequencerPorts) + { + if (i->name_ == port.rawMidiDevice) + { + result.connections().push_back( + AlsaSequencerPortSelection(port.id, port.name, port.displaySortOrder)); + break; + } + } + } + } + } + catch (const std::exception &e) + { + Lv2Log::error(SS("Failed to migrate MIDI settings. " << e.what())); + // ick. + } + return result; +} +void Storage::LoadAlsaSequencerConfiguration() +{ + auto fileName = this->GetAlsaSequencerConfigurationFileName(); + + if (std::filesystem::exists(fileName)) + { + try + { + std::ifstream s(fileName); + json_reader reader(s); + AlsaSequencerConfiguration result; + reader.read(&result); + this->alsaSequencerConfiguration = result; + } + catch (const std::exception &e) + { + Lv2Log::error("I/O error reading %s: %s", fileName.c_str(), e.what()); + } + } + else + { + + // migrate legacy settings from JackConfiguration? + if (this->isJackChannelSelectionValid) + { + this->alsaSequencerConfiguration = + MigrateRawMidiToAlsaSequencer( + this->jackChannelSelection.LegacyGetInputMidiDevices()); + this->jackChannelSelection.LegacyGetInputMidiDevices().clear(); // let them be clear, the next it gets updated. + } + else + { + // no legacy settings, so just create a default configuration. + this->alsaSequencerConfiguration = AlsaSequencerConfiguration(); + } + SaveAlsaSequencerConfiguration(); + } +} +void Storage::SaveAlsaSequencerConfiguration() +{ + auto fileName = this->GetAlsaSequencerConfigurationFileName(); + + try + { + pipedal::ofstream_synced s(fileName); + json_writer writer(s); + writer.write(this->alsaSequencerConfiguration); + } + catch (const std::exception &e) + { + Lv2Log::error("I/O error writing %s: %s", fileName.c_str(), e.what()); + } +} + +void Storage::SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration) +{ + this->alsaSequencerConfiguration = alsaSequencerConfiguration; + SaveAlsaSequencerConfiguration(); +} + +AlsaSequencerConfiguration Storage::GetAlsaSequencerConfiguration() const +{ + return this->alsaSequencerConfiguration; +} + void Storage::LoadChannelSelection() { auto fileName = this->GetChannelSelectionFileName(); @@ -736,7 +859,6 @@ void Storage::LoadChannelSelection() catch (const std::exception &e) { Lv2Log::error("I/O error reading %s: %s", fileName.c_str(), e.what()); - throw PiPedalStateException("Unexpected error reading Jack settings file."); } } } @@ -745,6 +867,8 @@ void Storage::SaveChannelSelection() auto fileName = this->GetChannelSelectionFileName(); try { + // replaced with AlsaSequencerConfiguration. Delete legacy data. + this->jackChannelSelection.LegacyGetInputMidiDevices().clear(); pipedal::ofstream_synced s(fileName); json_writer writer(s, false); writer.write(this->jackChannelSelection); @@ -1600,7 +1724,6 @@ static void ThrowPermissionDeniedError() throw std::logic_error("Permission denied."); } - static bool ensureNoDotDot(const std::filesystem::path &path) { for (auto segment_ : path) @@ -1627,13 +1750,13 @@ static bool ensureNoDotDot(const std::filesystem::path &path) return true; } - static void AddFilesToResult( FileRequestResult &result, - const ModFileTypes::ModDirectory *modDirectoryInfo, //yyx + const ModFileTypes::ModDirectory *modDirectoryInfo, // yyx const UiFileProperty &fileProperty, const fs::path &rootPath) { + if (!fs::exists(rootPath)) { return; // silently without error. @@ -1641,13 +1764,17 @@ static void AddFilesToResult( auto &resultFiles = result.files_; std::set validExtensions = fileProperty.GetPermittedFileExtensions( - modDirectoryInfo? modDirectoryInfo->modType: ""); + modDirectoryInfo ? modDirectoryInfo->modType : ""); - - try + try { for (auto const &dir_entry : std::filesystem::directory_iterator(rootPath)) { + if (!IsValidUtf8(dir_entry.path().string())) + { + Lv2Log::warning("Invalid UTF-8 name in directory: " + dir_entry.path().string()); + continue; // skip invalid UTF-8 names. + } const auto &path = dir_entry.path(); auto name = path.filename().string(); if (dir_entry.is_regular_file()) @@ -1657,10 +1784,9 @@ static void AddFilesToResult( bool match = false; if (name.length() > 0 && name[0] != '.') // don't show hidden files. { - bool match = validExtensions.size() == 0 || validExtensions.contains(extension) - || validExtensions.contains(".*"); + bool match = validExtensions.size() == 0 || validExtensions.contains(extension) || validExtensions.contains(".*"); - if (match) + if (match && !name.starts_with(".")) { resultFiles.push_back( FileEntry(path, name, false, false)); @@ -1675,7 +1801,8 @@ static void AddFilesToResult( } catch (const std::exception &error) { - throw std::logic_error("GetFileList failed. Directory not found: " + rootPath.string()); + throw std::logic_error( + SS("GetFileList failed. " << rootPath.string() << " - " << error.what())); } // sort lexicographically @@ -1690,6 +1817,108 @@ static void AddFilesToResult( } return collator->Compare(l.displayName_,r.displayName_) < 0; }); } + +static void AddTracksToResult( + const fs::path &audioRootDirectory, + FileRequestResult &result, + const ModFileTypes::ModDirectory *modDirectoryInfo, // yyx + const UiFileProperty &fileProperty, + const fs::path &rootPath) +{ + + if (!fs::exists(rootPath)) + { + return; // silently without error. + } + auto &resultFiles = result.files_; + + std::set validExtensions = fileProperty.GetPermittedFileExtensions( + modDirectoryInfo ? modDirectoryInfo->modType : ""); + + if (validExtensions.size() == 0) + { + const auto &audioExtensions = MimeTypes::instance().AudioExtensions(); + validExtensions.insert(audioExtensions.begin(), audioExtensions.end()); + } + validExtensions.insert(".jpg"); + validExtensions.insert(".png"); + try + { + // Add directories first. + try + { + for (auto const &dir_entry : std::filesystem::directory_iterator(rootPath)) + { + if (!IsValidUtf8(dir_entry.path().string())) + { + Lv2Log::warning("Invalid UTF-8 name in directory: " + dir_entry.path().string()); + continue; // skip invalid UTF-8 names. + } + const auto &path = dir_entry.path(); + auto name = path.filename().string(); + try + { + if (dir_entry.is_directory()) + { + resultFiles.push_back(FileEntry{path, name, true, dir_entry.is_symlink()}); + } + } + catch (const std::exception &e) + { + Lv2Log::warning(SS("Failed to add directory entry: " << path.string() << " - " << e.what())); + } + } + } + catch (const std::exception &error) + { + throw std::logic_error( + SS("AddTracksToResult failed to enumerate directories. " << rootPath.string() << " - " << error.what())); + } + auto collator = Locale::GetInstance()->GetCollator(); + std::sort( + resultFiles.begin(), + resultFiles.end(), + [collator](const FileEntry &l, const FileEntry &r) + { + if (l.isDirectory_ != r.isDirectory_) + { + return l.isDirectory_ > r.isDirectory_; + } + return collator->Compare(l.displayName_, r.displayName_) < 0; + }); + // Add audio files. + auto audioFiles = AudioDirectoryInfo::Create(rootPath, + GetShadowIndexDirectory(audioRootDirectory, rootPath)); + + try + { + for (const auto &audioFile : audioFiles->GetFiles()) + { + fs::path audioFilePath = rootPath / audioFile.fileName(); + std::string extension = UiFileProperty::GetFileExtension(audioFilePath); + if (validExtensions.size() == 0 || validExtensions.contains(extension) || validExtensions.contains(".*")) + { + resultFiles.push_back( + FileEntry( + audioFilePath, + audioFile.title(), + false, + std::make_shared(audioFile))); + } + } + } + catch (const std::exception &error) + { + throw std::logic_error( + SS("AddTracksToResult failed to enumerate audio files. " << rootPath.string() << " - " << error.what())); + } + } + catch (const std::exception &error) + { + throw std::logic_error(SS("GetFileList failed. " << error.what() << "(" << rootPath.string() << ")")); + } +} + FileRequestResult Storage::GetModFileList2(const std::string &relativePath, const UiFileProperty &fileProperty) { FileRequestResult result; @@ -1764,20 +1993,29 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons ++iRp; } } + fs::path cumulativePath = modDirectoryPath; while (iRp != rp.end()) { - result.breadcrumbs_.push_back({*iRp, *iRp}); + cumulativePath /= (*iRp); + result.breadcrumbs_.push_back({cumulativePath, *iRp}); ++iRp; } } - - AddFilesToResult(result,rootModDirectory, fileProperty, relativePath); + if (IsInAudioTracksDirectory(relativePath)) + { + AddTracksToResult(this->GetPluginUploadDirectory(), result, rootModDirectory, fileProperty, relativePath); + } + else + { + AddFilesToResult(result, rootModDirectory, fileProperty, relativePath); + } result.currentDirectory_ = relativePath; return result; } -static bool IsChildDirectory(const fs::path& child, const fs::path&parent) { +static bool IsChildDirectory(const fs::path &child, const fs::path &parent) +{ auto iChild = child.begin(); for (auto i = parent.begin(); i != parent.end(); ++i) { @@ -1824,17 +2062,15 @@ FileRequestResult Storage::GetFileList2(const std::string &relativePath_, const } } - if (!IsChildDirectory(absolutePath,pluginRootDirectory)) + if (!IsChildDirectory(absolutePath, pluginRootDirectory)) { absolutePath = pluginRootDirectory; } - result.currentDirectory_ = absolutePath; - { - // watch out for resource files!!! + // watch out for resource files!!! result.breadcrumbs_.push_back({"", "Home"}); fs::path fsAbsolutePath{absolutePath}; auto iAbsolutePath = fsAbsolutePath.begin(); @@ -1860,13 +2096,20 @@ FileRequestResult Storage::GetFileList2(const std::string &relativePath_, const throw std::runtime_error(SS("Improper location. " << absolutePath)); } - const ModFileTypes::ModDirectory*pModDirectory = nullptr; + const ModFileTypes::ModDirectory *pModDirectory = nullptr; if (fileProperty.modDirectories().size() > 0) { pModDirectory = ModFileTypes::GetModDirectory(fileProperty.modDirectories()[0]); } - AddFilesToResult(result,pModDirectory, fileProperty, absolutePath); + if (IsInAudioTracksDirectory(absolutePath)) + { + AddTracksToResult(GetPluginUploadDirectory(), result, pModDirectory, fileProperty, absolutePath); + } + else + { + AddFilesToResult(result, pModDirectory, fileProperty, absolutePath); + } return result; } @@ -1938,6 +2181,11 @@ void Storage::DeleteSampleFile(const std::filesystem::path &fileName) else { std::filesystem::remove(fileName); + if (IsInAudioTracksDirectory(fileName)) + { + // remove the metadata file as well. + std::filesystem::remove(fileName.string() + ".mdata"); + } } } catch (const std::exception &) @@ -1960,10 +2208,20 @@ std::filesystem::path Storage::MakeUserFilePath(const std::string &directory, co } return result; } -std::string Storage::UploadUserFile(const std::string &directory, - UiFileProperty::ptr uiFileProperty, - const std::string &filename, - std::istream &stream, size_t contentLength) + +bool Storage::IsValidArtworkFile(const std::filesystem::path &fullPath) +{ + if (IsInAudioTracksDirectory(fullPath) && isArtworkFileName(fullPath.filename().string())) + { + // allow artwork files. + return true; + } + return false; +} +std::string Storage::UploadUserFile(const std::string &directory, + UiFileProperty::ptr uiFileProperty, + const std::string &filename, + std::istream &stream, size_t contentLength) { std::filesystem::path path; if (directory.length() != 0) @@ -1975,18 +2233,19 @@ std::string Storage::UploadUserFile(const std::string &directory, throw std::logic_error("Directory argument not supplied."); } fs::path relativePath; - try { - relativePath = MakeRelativePath(path,this->GetPluginUploadDirectory()); - } catch (const std::exception& e) + try + { + relativePath = MakeRelativePath(path, this->GetPluginUploadDirectory()); + } + catch (const std::exception &e) { throw std::logic_error("Permission denied. Path is outside the upload storage directory."); } - if (!uiFileProperty->IsValidExtension(relativePath)) + if (!(uiFileProperty->IsValidExtension(relativePath) || IsValidArtworkFile(path))) { throw std::logic_error("Permission denied. Invalid file extension for this directory."); } - { try @@ -2083,6 +2342,113 @@ std::string Storage::RenameFilePropertyFile( } std::filesystem::rename(oldPath, newPath); + if (fs::exists(oldPath.string() + ".mdata")) + { + // rename the metadata file as well. + std::filesystem::rename(oldPath.string() + ".mdata", newPath.string() + ".mdata"); + } + return newPath; +} + +fs::path MakeVersionedPath(const fs::path &path) +{ + if (!fs::exists(path)) + { + return path; // no need to version a non-existing file. + } + fs::path newPath = path; + auto stem = newPath.stem().string(); + ; + if (stem.ends_with(")")) + { + // remove the trailing (n) from the file name. + size_t pos = stem.find_last_of('('); + std::string stemVersion = stem.substr(pos + 1, stem.length() - 1 - (pos + 1)); + // check if it is a number. + if (stemVersion.find_first_not_of("0123456789") == std::string::npos) + { + // it is a number, remove the trailing (n). + stem = stem.substr(0, pos); + while (stem.ends_with(" ")) + { + // remove trailing space. + stem = stem.substr(0, stem.length() - 1); + } + } + } + + int version = 0; + while (fs::exists(newPath)) + { + // append (n) to the file name. + std::string newFileName; + if (version == 0) + { + newFileName = SS(stem << newPath.extension().string()); + } + else + { + // append (n) to the file name. + newFileName = SS(stem << " (" << std::to_string(version) << ")" << newPath.extension().string()); + } + newPath = newPath.parent_path() / newFileName; + ++version; + } + return newPath; +} + +std::string Storage::CopyFilePropertyFile( + const std::string &oldRelativePath, + const std::string &newRelativePath, + const UiFileProperty &uiFileProperty, + bool overwrite) +{ + if (uiFileProperty.directory().empty()) + { + throw std::runtime_error("Invalid UI File Property."); + } + std::filesystem::path oldPath = this->GetPluginUploadDirectory() / uiFileProperty.directory() / oldRelativePath; + if (!this->IsValidSampleFileName(oldPath)) + { + throw std::runtime_error("Invalid file name."); + } + if (!std::filesystem::exists(oldPath)) + { + throw std::runtime_error("Original path does not exist."); + } + + std::filesystem::path newPath = this->GetPluginUploadDirectory() / uiFileProperty.directory() / newRelativePath; + + if (!this->IsValidSampleFileName(newPath)) + { + throw std::runtime_error("Invalid file name."); + } + if (std::filesystem::exists(newPath)) + { + if (std::filesystem::is_directory(newPath)) + { + throw std::runtime_error("A directory with that name already exists."); + } + else + { + newPath = MakeVersionedPath(newPath); + } + } + + std::filesystem::create_hard_link(oldPath, newPath); + + if (IsInAudioTracksDirectory(oldPath) && IsInAudioTracksDirectory(newPath)) + { + std::filesystem::path metadataPath = SS(oldPath.string() << ".mdata"); + if (fs::exists(metadataPath)) + { + // copy the metadata file as well. + std::filesystem::copy_file( + metadataPath, + SS(newPath.string() << ".mdata"), + fs::copy_options::overwrite_existing); + } + } return newPath; } @@ -2090,6 +2456,12 @@ void Storage::FillSampleDirectoryTree(FilePropertyDirectoryTree *node, const std { for (auto child : std::filesystem::directory_iterator(directory)) { + if (!IsValidUtf8(child.path().string())) + { + Lv2Log::warning("Invalid UTF-8 name in directory: " + child.path().string()); + // skip invalid UTF-8 paths. + continue; + } if (child.is_directory()) { const auto &childPath = child.path(); @@ -2106,26 +2478,29 @@ void Storage::FillSampleDirectoryTree(FilePropertyDirectoryTree *node, const std }); } - - -static void GetAllExtensions(std::set&result, const std::filesystem::path &path) +static void GetAllExtensions(std::set &result, const std::filesystem::path &path) { assert(fs::is_directory(path)); - for (const auto&dirEnt : fs::directory_iterator(path)) + for (const auto &dirEnt : fs::directory_iterator(path)) { + if (!IsValidUtf8(dirEnt.path().string())) + { + // skip invalid UTF-8 paths. + Lv2Log::warning("Invalid UTF-8 name in directory: " + dirEnt.path().string()); + continue; + } if (dirEnt.is_directory()) { - GetAllExtensions(result,dirEnt.path()); - } else { + GetAllExtensions(result, dirEnt.path()); + } + else + { std::string filename = dirEnt.path().filename(); - if (!filename.starts_with('.')) + if (dirEnt.path().has_extension()) { - if (dirEnt.path().has_extension()) - { - std::string extension = dirEnt.path().extension(); - result.insert(extension); - } + std::string extension = dirEnt.path().extension(); + result.insert(extension); } } } @@ -2134,28 +2509,33 @@ static void GetAllExtensions(std::set&result, const std::filesystem static std::set GetAllExtensions(const std::filesystem::path &path) { std::set result; - if (fs::is_regular_file(path)) { + if (fs::is_regular_file(path)) + { result.insert(UiFileProperty::GetFileExtension(path)); - } else { - GetAllExtensions(result,path); + } + else + { + GetAllExtensions(result, path); } return result; } -FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty, const std::filesystem::path&selectedPath) +FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty, const std::filesystem::path &selectedPath) { fs::path uploadDirectory = this->GetPluginUploadDirectory(); fs::path relativePath; - try { - relativePath = MakeRelativePath(selectedPath,uploadDirectory); - } catch (const std::exception&e) { + try + { + relativePath = MakeRelativePath(selectedPath, uploadDirectory); + } + catch (const std::exception &e) + { // not an upload directory. throw std::runtime_error(SS("Permission denied: " << selectedPath)); } std::set fileExtensions = GetAllExtensions(selectedPath); - if (hasSyntheticModRoot(uiFileProperty)) { @@ -2172,10 +2552,10 @@ FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFil auto modDirectoryInfo = ModFileTypes::GetModDirectory(modDirectory); if (modDirectoryInfo) { - if (IsSubset(fileExtensions,modDirectoryInfo->fileExtensions) || + if (IsSubset(fileExtensions, modDirectoryInfo->fileExtensions) || modDirectoryInfo->fileExtensions.contains(".*") || modDirectoryInfo->fileExtensions.empty() // Private directory that accepts files of any type. - ) + ) { auto childPath = uploadDirectory / modDirectoryInfo->pipedalPath; FilePropertyDirectoryTree::ptr child = std::make_unique( @@ -2213,9 +2593,16 @@ FilePropertyDirectoryTree::ptr Storage::GetFilePropertydirectoryTree(const UiFil } } -bool Storage::IsInUploadsDirectory(const std::filesystem::path&path) const +bool Storage::IsInAudioTracksDirectory(const std::filesystem::path &path) const { - return IsSubdirectory(path,this->GetPluginUploadDirectory()); + std::filesystem::path audioTracksDirectory = + this->GetPluginUploadDirectory() / "shared/audio/Tracks"; + return IsSubdirectory(path, audioTracksDirectory); +} + +bool Storage::IsInUploadsDirectory(const std::filesystem::path &path) const +{ + return IsSubdirectory(path, this->GetPluginUploadDirectory()); } const PluginPresetIndex &Storage::GetPluginPresetIndex() @@ -2223,6 +2610,47 @@ const PluginPresetIndex &Storage::GetPluginPresetIndex() return pluginPresetIndex; } +void Storage::LoadTone3000Auth() +{ + fs::path path = GetTone3000AuthPath(); + try + { + if (!fs::exists(path)) + { + this->tone3000Auth = ""; + return; + } + std::ifstream s(path); + json_reader reader(s); + reader.read(&(this->tone3000Auth)); + } + catch (const std::exception &e) + { + Lv2Log::error("Failed to load tone3000Auth: %s", e.what()); + } +} + +void Storage::SetTone3000Auth(const std::string &apiKey) +{ + if (tone3000Auth != apiKey) + { + tone3000Auth = apiKey; + + pipedal::ofstream_synced os(this->GetTone3000AuthPath()); + if (!os.is_open()) + { + Lv2Log::error("Failed to open Tone3000 auth file for writing."); + return; + } + json_writer writer(os); + writer.write(apiKey); + } +} +std::string Storage::GetTone3000Auth() const +{ + return tone3000Auth; +} + JSON_MAP_BEGIN(UserSettings) JSON_MAP_REFERENCE(UserSettings, governor) JSON_MAP_REFERENCE(UserSettings, showStatusMonitor) diff --git a/src/Storage.hpp b/src/Storage.hpp index 0e50aa8..6d45d63 100644 --- a/src/Storage.hpp +++ b/src/Storage.hpp @@ -31,6 +31,7 @@ #include "FileEntry.hpp" #include #include "FilePropertyDirectoryTree.hpp" +#include "AlsaSequencer.hpp" namespace pipedal { @@ -72,6 +73,7 @@ private: BankIndex bankIndex; BankFile currentBank; PluginPresetIndex pluginPresetIndex; + std::string tone3000Auth; private: void FillSampleDirectoryTree(FilePropertyDirectoryTree*node, const std::filesystem::path&directory) const; @@ -84,7 +86,9 @@ private: std::filesystem::path GetIndexFileName() const; std::filesystem::path GetBankFileName(const std::string & name) const; std::filesystem::path GetChannelSelectionFileName(); + std::filesystem::path GetAlsaSequencerConfigurationFileName(); std::filesystem::path GetCurrentPresetPath() const; + std::filesystem::path GetTone3000AuthPath() const; void LoadBankIndex(); void SaveBankIndex(); @@ -94,11 +98,19 @@ private: void LoadChannelSelection(); void SaveChannelSelection(); + + void LoadAlsaSequencerConfiguration(); + void SaveAlsaSequencerConfiguration(); + + void SaveBankFile(const std::string& name,const BankFile&bankFile); void LoadBankFile(const std::string &name,BankFile *pBank); std::string GetPresetCopyName(const std::string &name); bool isJackChannelSelectionValid = false; JackChannelSelection jackChannelSelection; + + AlsaSequencerConfiguration alsaSequencerConfiguration;; + WifiConfigSettings wifiConfigSettings; WifiDirectConfigSettings wifiDirectConfigSettings; @@ -126,6 +138,7 @@ public: void LoadWifiDirectConfigSettings(); void LoadUserSettings(); void SaveUserSettings(); + void LoadBank(int64_t instanceId); int64_t GetBankByMidiBankNumber(uint8_t bankNumber); const Pedalboard& GetCurrentPreset(); @@ -155,6 +168,8 @@ public: int64_t DeleteBank(int64_t bankId); bool IsInUploadsDirectory(const std::filesystem::path&path) const; + bool IsInAudioTracksDirectory(const std::filesystem::path&path) const; + bool IsValidArtworkFile(const std::filesystem::path& fullPath); FileRequestResult GetFileList2(const std::string&relativePath,const UiFileProperty&fileProperty); @@ -179,6 +194,10 @@ public: void SaveCurrentPreset(const CurrentPreset ¤tPreset); bool RestoreCurrentPreset(CurrentPreset*pResult); + void SetAlsaSequencerConfiguration(const AlsaSequencerConfiguration &alsaSequencerConfiguration); + AlsaSequencerConfiguration GetAlsaSequencerConfiguration() const; + + //std::string MapPropertyFileName(Lv2PluginInfo*pluginInfo, const std::string&path); private: @@ -231,7 +250,16 @@ public: const std::string&oldRelativePath, const std::string&newRelativePath, const UiFileProperty&uiFileProperty); + std::string CopyFilePropertyFile( + const std::string&oldRelativePath, + const std::string&newRelativePath, + const UiFileProperty&uiFileProperty, + bool overwrite = false); FilePropertyDirectoryTree::ptr GetFilePropertydirectoryTree(const UiFileProperty&uiFileProperty,const std::filesystem::path&selectedPath); + + void LoadTone3000Auth(); + void SetTone3000Auth(const std::string&apiKey); + std::string GetTone3000Auth() const; }; diff --git a/src/TemporaryFile.cpp b/src/TemporaryFile.cpp index 39b91d9..4e9488c 100644 --- a/src/TemporaryFile.cpp +++ b/src/TemporaryFile.cpp @@ -52,14 +52,36 @@ TemporaryFile::TemporaryFile(const std::filesystem::path&directory) this->path = filename; } -void TemporaryFile::Detach() { - this->path.clear(); +std::filesystem::path TemporaryFile::Detach() { + std::filesystem::path result = std::move(this->path); + return result; } TemporaryFile::~TemporaryFile() { - if (!path.empty()) + if (!path.empty() && deleteFile) { std::filesystem::remove(path); } } +void TemporaryFile::SetNonDeletedPath(const std::filesystem::path&path) +{ + this->path = path; + this->deleteFile = false; // Do not delete the file on destruction +} + + + +TemporaryFile::TemporaryFile(TemporaryFile&&other) { + this->path = std::move(other.path); + this->deleteFile = other.deleteFile; +} +TemporaryFile&TemporaryFile::operator=(TemporaryFile&&other) { + if (!this->path.empty() && this->deleteFile) { + std::filesystem::remove(this->path); + this->path.clear(); + } + this->path = std::move(other.path); + this->deleteFile = other.deleteFile; + return *this; +} diff --git a/src/TemporaryFile.hpp b/src/TemporaryFile.hpp index e1095b2..1ff6df0 100644 --- a/src/TemporaryFile.hpp +++ b/src/TemporaryFile.hpp @@ -25,18 +25,25 @@ namespace pipedal { TemporaryFile() {} TemporaryFile(const TemporaryFile&) = delete; TemporaryFile&operator=(const TemporaryFile&) = delete; + TemporaryFile(TemporaryFile&&other); + TemporaryFile&operator=(TemporaryFile&&other); + public: + explicit TemporaryFile(const std::filesystem::path&parentDirectory); - TemporaryFile(TemporaryFile&&other) { - this->path = std::move(other.path); + void Attach(const std::filesystem::path&path) { + this->path = path; + deleteFile = true; // default to deleting the file on destruction. } - TemporaryFile(const std::filesystem::path&parentDirectory); + void SetNonDeletedPath(const std::filesystem::path&path); - void Detach(); + bool DeleteFile() const { return deleteFile; } + std::filesystem::path Detach(); ~TemporaryFile(); const std::filesystem::path&Path()const { return path;} std::string str() const { return path.c_str(); } const char*c_str() const { return path.c_str(); } private: + bool deleteFile = true; std::filesystem::path path; }; } \ No newline at end of file diff --git a/src/Uri.hpp b/src/Uri.hpp index b07d9cf..52951f8 100644 --- a/src/Uri.hpp +++ b/src/Uri.hpp @@ -63,7 +63,7 @@ public: { set(""); } - const std::string& str() { + const std::string& str() const { return text; } uri(const char*text) diff --git a/src/WebServer.cpp b/src/WebServer.cpp index 0ad2718..946088f 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -738,7 +738,7 @@ namespace pipedal virtual void setBodyFile(std::shared_ptr&bodyFile) override { // cast away const to do what ther request would do if it had that method. - request.set_body_file(bodyFile->Path(),true); + request.set_body_file(bodyFile->Path(),bodyFile->DeleteFile()); bodyFile->Detach(); } virtual void setBodyFile(std::filesystem::path&path, bool deleteWhenDone) override { diff --git a/src/WebServerConfig.cpp b/src/WebServerConfig.cpp index a46ca0e..ae23dc9 100644 --- a/src/WebServerConfig.cpp +++ b/src/WebServerConfig.cpp @@ -35,6 +35,10 @@ #include "json.hpp" #include "HotspotManager.hpp" #include "MimeTypes.hpp" +#include "AudioFileMetadataReader.hpp" +#include "AudioFiles.hpp" +#include "util.hpp" +#include "HtmlHelper.hpp" #define OLD_PRESET_EXTENSION ".piPreset" #define PRESET_EXTENSION ".piPreset" @@ -48,26 +52,13 @@ static const std::string PLUGIN_PRESETS_MIME_TYPE = "application/vnd.pipedal.plu static const std::string PRESET_MIME_TYPE = "application/vnd.pipedal.preset"; static const std::string BANK_MIME_TYPE = "application/vnd.pipedal.bank"; +static const std::string CACHE_CONTROL_INDEFINITELY = "max-age=31536000,public,immutable"; // 1 year +static const std::string CACHE_CONTROL_SHORT = "max-age=300,public"; // 5 minutes + using namespace pipedal; using namespace boost::system; namespace fs = std::filesystem; - - -static bool HasDotDot(const std::filesystem::path &path) { - for (auto &part : path) - { - if (part == "..") { - return true; - } - if (part == ".") - { - return true; - } - } - return false; -} - class UserUploadResponse { public: @@ -80,13 +71,23 @@ JSON_MAP_REFERENCE(UserUploadResponse, errorMessage) JSON_MAP_REFERENCE(UserUploadResponse, path) JSON_MAP_END() -static std::string GetMimeType(const std::filesystem::path&path) { +static std::string GetMimeType(const std::filesystem::path &path) +{ std::string extension = path.extension(); - const MimeTypes&mimeTypes = MimeTypes::instance(); + const MimeTypes &mimeTypes = MimeTypes::instance(); auto result = mimeTypes.MimeTypeFromExtension(extension); return result; - } + +int32_t ConvertThumbnailSize(const std::string ¶m) +{ + if (param.empty()) + { + return 0; + } + return static_cast(std::stoi(param)); +} + static bool IsZipFile(const std::filesystem::path &path) { std::ifstream f(path); @@ -179,6 +180,59 @@ public: { return true; } + else if (segment == "AudioMetadata") + { + return true; + } + else if (segment == "Thumbnail") + { + return true; + } + else if (segment == "Tone3000Auth") + { + return true; + } + else if (segment == "PluginPresets") + { + return true; + } + else if (segment == "PluginPreset") + { + return true; + } + else if (segment == "PluginBanks") + { + return true; + } + else if (segment == "PluginBank") + { + return true; + } + else if (segment == "GetPluginInfo") + { + return true; + } + else if (segment == "GetPluginPresets") + { + return true; + } + else if (segment == "GetPreset") + { + return true; + } + else if (segment == "GetBank") + { + return true; + } + else if (segment == "NextAudioFile") + { + return true; + } + else if (segment == "PreviousAudioFile") + { + return true; + } + return false; } @@ -246,15 +300,17 @@ public: try { std::string segment = request_uri.segment(1); - if (segment == "downloadMediaFile") { + if (segment == "downloadMediaFile") + { fs::path path = request_uri.query("path"); - + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) { throw PiPedalException("File not found."); } auto mimeType = GetMimeType(path); - if (mimeType.empty()) { + if (mimeType.empty()) + { throw PiPedalException("Can't download files of this type."); } res.set(HttpField::content_type, mimeType); @@ -333,6 +389,19 @@ public: } } + static void setLastModifiedFromFile(HttpResponse &res, const std::filesystem::path &path) + { + auto lastModified = std::filesystem::last_write_time(path); + res.set(HttpField::LastModified, HtmlHelper::timeToHttpDate(lastModified)); + } + AudioDirectoryInfo::Ptr CreateDirectoryInfo(const fs::path &path) + { + return AudioDirectoryInfo::Create(path, + GetShadowIndexDirectory( + this->model->GetPluginUploadDirectory(), + path)); + } + virtual void get_response( const uri &request_uri, HttpRequest &req, @@ -343,18 +412,20 @@ public: { std::string segment = request_uri.segment(1); - if (segment == "downloadMediaFile") { + if (segment == "downloadMediaFile") + { fs::path path = request_uri.query("path"); - + bool t = this->model->IsInUploadsDirectory(path); - std::cout << (t? "true": "false") << std::endl; + std::cout << (t ? "true" : "false") << std::endl; (void)t; if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) { throw PiPedalException("File not found."); } auto mimeType = GetMimeType(path); - if (mimeType.empty()) { + if (mimeType.empty()) + { throw PiPedalException("Can't download files of this type."); } res.set(HttpField::content_type, mimeType); @@ -363,9 +434,10 @@ public: res.set(HttpField::content_disposition, disposition); size_t contentLength = std::filesystem::file_size(path); res.setContentLength(contentLength); - res.setBodyFile(path,false); + res.setBodyFile(path, false); return; - } else if (segment == "downloadPluginPresets") + } + else if (segment == "downloadPluginPresets") { std::string name; std::string content; @@ -416,6 +488,178 @@ public: res.set(HttpField::content_disposition, GetContentDispositionHeader(name, BANK_EXTENSION)); res.setBodyFile(tmpFile); } + else if (segment == "AudioMetadata") + { + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + fs::path path = request_uri.query("path"); + + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) + { + throw PiPedalException("File not found."); + } + AudioDirectoryInfo::Ptr audioDirectory = CreateDirectoryInfo(path.parent_path()); + try + { + auto files = audioDirectory->GetFiles(); + for (const auto &file : files) + { + std::string fileNameOnly = path.filename(); + if (file.fileName() == fileNameOnly) + { + std::stringstream ss; + json_writer writer(ss); + writer.write(file); + res.setBody(ss.str()); + return; + } + } + } + catch (const std::exception &e) + { + Lv2Log::error("Error getting audio directory info: %s - (%s)", path.c_str(), e.what()); + throw e; + } + // If we get here, the file was not found in the directory. + throw PiPedalException("File not found in directory."); + } + else if (segment == "Thumbnail") + { + ThumbnailTemporaryFile thumbnailTemporaryFile; + try + { + std::shared_ptr thumbnail; + try + { + fs::path path = request_uri.query("path"); + if (path.empty() || + !fs::exists(path) || + !this->model->IsInUploadsDirectory(path) || + HasDotDot(path)) + { + std::shared_ptr thumbnail; + // path for folder thumbnails. + path = request_uri.query("ffile"); + + if (!path.empty() && fs::exists(path) && + this->model->IsInUploadsDirectory(path) && + !HasDotDot(path)) + { + // path is a folder. + thumbnail = std::make_shared(); + thumbnail->SetNonDeletedPath(path); + } + else + { + auto t = AudioDirectoryInfo::DefaultThumbnailTemporaryFile(); + thumbnail = std::make_shared(); + thumbnail->SetNonDeletedPath(t.Path()); + } + + res.set(HttpField::content_type, MimeTypes::instance().MimeTypeFromExtension(path.extension())); + res.set(HttpField::cache_control, CACHE_CONTROL_INDEFINITELY); // URL is cache-busted, and will change if the file ismodified. + setLastModifiedFromFile(res, thumbnail->Path()); + res.set(HttpField::content_length, std::to_string(fs::file_size(thumbnail->Path()))); + res.setBodyFile(thumbnail); + return; + } + + int32_t width = ConvertThumbnailSize(request_uri.query("w")); + int32_t height = ConvertThumbnailSize(request_uri.query("h")); + + AudioDirectoryInfo::Ptr audioDirectory = CreateDirectoryInfo( + path.parent_path()); + audioDirectory->GetFiles(); // ensure that the .index file is up to date. + + thumbnailTemporaryFile = audioDirectory->GetThumbnail(path.filename(), width, height); + } + catch (const std::exception &e) + { + fs::path defaultThumbnail = model->GetWebRoot() / "img/missing_thumbnail.jpg"; + thumbnailTemporaryFile.SetNonDeletedPath(defaultThumbnail, "image/jpeg"); + } + res.set(HttpField::content_type, thumbnailTemporaryFile.GetMimeType()); + + res.set(HttpField::cache_control, CACHE_CONTROL_INDEFINITELY); // URL is cache-busted with time-stamp. + setLastModifiedFromFile(res, thumbnailTemporaryFile.Path()); + res.set(HttpField::content_length, std::to_string(fs::file_size(thumbnailTemporaryFile.Path()))); + + std::filesystem::path t = thumbnailTemporaryFile.Path(); + res.setBodyFile(t, thumbnailTemporaryFile.DeleteFile()); + thumbnailTemporaryFile.Detach(); + } + catch (const std::exception &e) + { + Lv2Log::error("Error getting thumbnail: %s - (%s)", request_uri.str().c_str(), e.what()); + throw e; + } + } + else if (segment == "NextAudioFile") + { + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + fs::path path = request_uri.query("path"); + + try + { + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) + { + throw PiPedalException("File not found."); + } + auto directoryPath = path.parent_path(); + auto directoryInfo = CreateDirectoryInfo(directoryPath); + std::string result = directoryInfo->GetNextAudioFile(path.filename()); + if (!result.empty()) + { + result = (directoryPath / result).string(); + } + + // json-encode the result. + std::stringstream ss; + json_writer writer(ss); + writer.write(result); + + res.setBody(ss.str()); + } + catch (const std::exception &e) + { + res.setBody("\"\""); + } + } + else if (segment == "PreviousAudioFile") + { + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + fs::path path = request_uri.query("path"); + + try + { + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) + { + throw PiPedalException("File not found."); + } + auto directoryPath = path.parent_path(); + auto directoryInfo = CreateDirectoryInfo(directoryPath); + std::string result = directoryInfo->GetPreviousAudioFile(path.filename()); + if (!result.empty()) + { + result = (directoryPath / result).string(); + } + + // json-encode the result. + std::stringstream ss; + json_writer writer(ss); + writer.write(result); + + res.setBody(ss.str()); + } + catch (const std::exception &e) + { + res.setBody("\"\""); + } + } + else { throw PiPedalException("Not found"); @@ -494,7 +738,17 @@ public: { std::string segment = request_uri.segment(1); - if (segment == "uploadPluginPresets") + if (segment == "Tone3000Auth") { + // https://www.tone3000.com/api/v1/auth?redirect_url=http://10.0.0.151:8080/var/Tone3000Auth&otp_only=true + std::string apiKey = request_uri.query("api_key"); + + model->SetTone3000Auth(apiKey); + + res.set(HttpField::content_type, "application/json"); + res.set(HttpField::cache_control, "no-cache"); + + res.setBody("\"OK\""); + } else if (segment == "uploadPluginPresets") { PluginPresets presets; fs::path filePath = req.get_body_temporary_file(); @@ -628,7 +882,7 @@ public: int64_t instanceId; { - std::istringstream ss { instanceIdString}; + std::istringstream ss{instanceIdString}; ss >> instanceId; if (!ss) { @@ -669,7 +923,7 @@ public: if (extensionChecker.IsValidExtension(extension)) { auto si = zipFile->GetFileInputStream(inputFile); - std::string path = this->model->UploadUserFile(directory, instanceId,patchProperty, inputFile, si, zipFile->GetFileSize(inputFile)); + std::string path = this->model->UploadUserFile(directory, instanceId, patchProperty, inputFile, si, zipFile->GetFileSize(inputFile)); } } } @@ -687,7 +941,7 @@ public: } else { - outputFileName = this->model->UploadUserFile(directory, instanceId,patchProperty, filename, req.get_body_input_stream(), req.content_length()); + outputFileName = this->model->UploadUserFile(directory, instanceId, patchProperty, filename, req.get_body_input_stream(), req.content_length()); } if (outputFileName.is_relative()) @@ -783,7 +1037,7 @@ public: << ", \"socket_server_address\": \"" << webSocketAddress << "\", \"ui_plugins\": [ ], \"max_upload_size\": " << maxUploadSize << ", \"enable_auto_update\": " << (ENABLE_AUTO_UPDATE ? " true" : "false") - << ", \"has_wifi_device\": " << (HotspotManager::HasWifiDevice() ? " true": "false") + << ", \"has_wifi_device\": " << (HotspotManager::HasWifiDevice() ? " true" : "false") << " }"; return s.str(); diff --git a/src/ZipFile.cpp b/src/ZipFile.cpp index 4c76c31..188ed52 100644 --- a/src/ZipFile.cpp +++ b/src/ZipFile.cpp @@ -52,15 +52,27 @@ public: { throw std::runtime_error("Can't open zip file."); } + zip_int64_t nEntries = zip_get_num_entries(zipFile, 0); + for (zip_int64_t i = 0; i < nEntries; ++i) + { + const char *name = zip_get_name(zipFile, i, ZIP_FL_ENC_STRICT); + if (name) + { + files.push_back(name); + nameMap[name] = i; + } + } } virtual ~ZipFileImpl(); - virtual std::vector GetFiles() override; + virtual const std::vector& GetFiles() override; virtual bool CompareFiles(const std::string &zipName, const std::filesystem::path& path) override; virtual void ExtractTo(const std::string &zipName, const std::filesystem::path &path) override; virtual zip_file_input_stream GetFileInputStream(const std::string& filename,size_t bufferSize = 16*1024) override; virtual size_t GetFileSize(const std::string&filename) override; + virtual bool FileExists(const std::string &zipName) const override; private: + std::vector files; std::map nameMap; // avoid o(2) extraction operations. const std::filesystem::path path; zip_t *zipFile = nullptr; @@ -79,20 +91,9 @@ ZipFileImpl::~ZipFileImpl() } } -std::vector ZipFileImpl::GetFiles() +const std::vector& ZipFileImpl::GetFiles() { - std::vector result; - zip_int64_t nEntries = zip_get_num_entries(zipFile, 0); - for (zip_int64_t i = 0; i < nEntries; ++i) - { - const char *name = zip_get_name(zipFile, i, ZIP_FL_ENC_STRICT); - if (name) - { - result.push_back(name); - nameMap[name] = i; - } - } - return result; + return files; } @@ -293,6 +294,11 @@ zip_file_input_stream ZipFileImpl::GetFileInputStream(const std::string& filenam } return zip_file_input_stream(f,bufferSize); } + +bool ZipFileImpl::FileExists(const std::string&fileName) const +{ + return nameMap.find(fileName) != nameMap.end(); +} size_t ZipFileImpl::GetFileSize(const std::string&filename) { zip_stat_t stat; diff --git a/src/ZipFile.hpp b/src/ZipFile.hpp index 4122d98..8dbc3c5 100644 --- a/src/ZipFile.hpp +++ b/src/ZipFile.hpp @@ -68,11 +68,12 @@ namespace pipedal { ZipFileReader&operator=(const ZipFileReader&) = delete; virtual ~ZipFileReader(); - virtual std::vector GetFiles() = 0; + virtual const std::vector& GetFiles() = 0; virtual void ExtractTo(const std::string &zipName, const std::filesystem::path& path) = 0; virtual bool CompareFiles(const std::string &zipName, const std::filesystem::path& path) = 0; virtual zip_file_input_stream GetFileInputStream(const std::string& filename,size_t bufferSize = 16*1024) = 0; virtual size_t GetFileSize(const std::string&filename) = 0; + virtual bool FileExists(const std::string&fileName) const = 0; }; diff --git a/src/jsonTest.cpp b/src/jsonTest.cpp index cdf88ff..b8f8355 100644 --- a/src/jsonTest.cpp +++ b/src/jsonTest.cpp @@ -267,11 +267,37 @@ void TestVariantSFINAE() int i; REQUIRE(x.write(i) == false); } + +void TestIlleglUtf8Sequences() +{ + { + std::string json = "\"\x80\x80\x80\""; + std::stringstream s(json); + json_reader reader(s); + std::string v; + reader.read(&v); + // just sufficient that it doesn't throw. + } + + + { + std::string illegalJsonString = "123\x80\x80\x80xyz"; + std::stringstream ss; + json_writer writer(ss); + writer.write(illegalJsonString); + std::string output = ss.str(); + std::string result = ss.str(); + REQUIRE(result == "\"123\\uFFFDyz\""); // U+FFFD is the replacement character for illegal UTF-8 sequences. + } +} TEST_CASE("json variants", "[json_variants][Build][Dev]") { { + TestIlleglUtf8Sequences(); + TestVariantSFINAE(); + TestVariantRoundTrip(0.0); TestVariantRoundTrip(std::string("abc")); TestVariantRoundTrip(json_null()); diff --git a/src/lv2ext/pipedal.lv2/ext/FileMetadataFeature.h b/src/lv2ext/pipedal.lv2/ext/FileMetadataFeature.h new file mode 100644 index 0000000..38f0a09 --- /dev/null +++ b/src/lv2ext/pipedal.lv2/ext/FileMetadataFeature.h @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +#ifndef PIPEDAL_FILE_METADAtA_FEATURE_H +#define PIPEDAL_FILE_METADAtA_FEATURE_H +#include "lv2/core/lv2.h" +#include "lv2/urid/urid.h" + +#define PIPEDAL__FILE_METADATA_FEATURE "http://github.com/rerdavies/pipedal/ext/#fileMetadata" + +#ifdef __cplusplus +extern "C" +{ +#endif + typedef void *PIPEDAL_FILE_METADATA_Handle; + + typedef enum + { + PIPEDAL_FILE_METADATA_SUCCESS = 0, /**< Completed successfully. */ + PIPEDAL_FILE_METADATA_INVALID_PATH = 1, /**< Path is not in Tracks directory, or does not exist. */ + PIPEDAL_FILE_METADATA_PERMISSION_DENIED = 2, /**< Permission denied. */ + PIPEDAL_FILE_METADATA_ERR_UNKNOWNM = 3, /**< Unknown error. */ + PIPEDAL_FILE_METADATA_INVALID_KEY = 4, /**< Key is not a valid URID. */ + PIPEDAL_FILE_METADATA_NOT_FOUND = 5, /**< Metadata not found. */ + } PIPEDAL_FileMetadata_Status; + + typedef struct + { + /** + Opaque host data. + */ + PIPEDAL_FILE_METADATA_Handle handle; + + /** + Save a piece of plugin-defined metadata for a file. + @param handle MUST be the `handle` member of this struct. + @param absolute_path The absolute path of a file. + @param key A plugin-defined key (LV2_URID) used to identify the metdadata. + @param metdata A string containing the metadata to be saved for the file. + @return A status code indicating success or failure. + + The path must be within the host-defined "Tracks" directory. The file must exist. The plugin does not + need write access to the directory containing the file in order to save metadata. + + Plugins MUST NOT make any assumptions about abstract paths except that + they can be mapped back to the absolute path of the "same" file (though + not necessarily the same original path) using absolute_path(). + + Metatadata will be automatically deleted if the file is deleted, of the file is modified, or moved + to another directory. + + This function should not be called from the plugin's realtime thread, as it may block for a long time. + */ + + PIPEDAL_FileMetadata_Status (*setFileMetadata)( + PIPEDAL_FILE_METADATA_Handle handle, + const char *absolute_path, + const char *key, + const char *fileMetadata); + /** + Restore previously saved metdata for the file. + @param handle MUST be the `handle` member of this struct. + @param absolute_path The absolute path of a file. + @param key A plugin-defined key used to identify the metdadata for a particular appliction. + @param buffer A buffer in which to store the metadata. + @param buffersize The size of the buffer in bytes, including space for the null terminator. + @return A value indicating the number of bytes written to the buffer, or 0 if the metdata was not found. + + If buffersize is insufficient to holed the metatdata, the function will return the number of bytes that would have been written, + including the space for the null terminator, but will not write any data to the buffer. The best way to find out + how much space is needed is to call this function with a buffer size of 0, which will return the size needed to contain the + metadata result. + + This function should not be called from the plugin's realtime thread, as it may block for a long time. + + */ + uint32_t (*getFileMetadata)( + PIPEDAL_FILE_METADATA_Handle handle, + const char *filePath, + const char *key, + char *buffer, + uint32_t bufferSize); + /** + Restore previously saved metdata for the file. + @param handle MUST be the `handle` member of this struct. + @param absolute_path The absolute path of a file. + @param key A plugin-defined key used to identify the metdadata for a particular appliction. + @return A status code indicating success or failure. + This function will delete the metadata for the file, if it exists. If the file does not exist, + or is not in the Tracks directory, it will return PIPEDAL_FILE_METADATA_INVALID_PATH. + */ + + PIPEDAL_FileMetadata_Status (*deleteFileMetadata)( + PIPEDAL_FILE_METADATA_Handle handle, + const char *filePath, + const char *key); + } PIPEDAL_FileMetadata_Interface; + + typedef struct + { + const char *URI; + PIPEDAL_FileMetadata_Interface *interface; + } PIPEDAL_FileMetadata_Feature; + +#ifdef __cplusplus +} +#endif + +#endif // PIPEDAL_FILE_METADAtA_FEATURE_H \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index cb2dfc7..12ead14 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -45,10 +45,12 @@ #include #include #include "SchedulerPriority.hpp" +#include "AudioFiles.hpp" #include +#include "AlsaSequencer.hpp" -using namespace pipedal; +using namespace pipedal; #ifdef __ARM_ARCH_ISA_A64 #define AARCH64 @@ -80,9 +82,9 @@ static bool isJackServiceRunning() return std::filesystem::exists(path); } - #if ENABLE_BACKTRACE -void segvHandler(int sig) { +void segvHandler(int sig) +{ void *array[10]; // Get void*'s for all entries on the stack @@ -91,26 +93,40 @@ void segvHandler(int sig) { // Print out all the frames to stderr const char *message = "Error: SEGV signal received.\n"; - auto _ = write(STDERR_FILENO,message,strlen(message)); + auto _ = write(STDERR_FILENO, message, strlen(message)); - backtrace_symbols_fd(array+2, size-2, STDERR_FILENO); + backtrace_symbols_fd(array + 2, size - 2, STDERR_FILENO); _exit(EXIT_FAILURE); } - static void EnableBacktrace() { - signal(SIGSEGV, segvHandler); - + signal(SIGSEGV, segvHandler); } #endif -static bool TryGetLogLevel(const std::string&strLogLevel,LogLevel*result) +static bool TryGetLogLevel(const std::string &strLogLevel, LogLevel *result) { - if (strLogLevel == "debug") {*result= LogLevel::Debug; return true;} - if (strLogLevel == "info") {*result= LogLevel::Info; return true;} - if (strLogLevel == "warning") {*result= LogLevel::Warning; return true;} - if (strLogLevel == "error") { *result= LogLevel::Error; return true;} + if (strLogLevel == "debug") + { + *result = LogLevel::Debug; + return true; + } + if (strLogLevel == "info") + { + *result = LogLevel::Info; + return true; + } + if (strLogLevel == "warning") + { + *result = LogLevel::Warning; + return true; + } + if (strLogLevel == "error") + { + *result = LogLevel::Error; + return true; + } *result = LogLevel::Info; return false; } @@ -120,7 +136,7 @@ int main(int argc, char *argv[]) { #ifndef WIN32 - umask(002); // newly created files in /var/pipedal get 7cd75-ish permissions, which improves debugging/live-service interaction. + umask(002); // newly created files in /var/pipedal get 775-ish permissions, which improves debugging/live-service interaction. #endif #if ENABLE_BACKTRACE @@ -139,7 +155,7 @@ int main(int argc, char *argv[]) parser.AddOption("-h", &help); parser.AddOption("--help", &help); parser.AddOption("-systemd", &systemd); - parser.AddOption("-log-level",&logLevel); + parser.AddOption("-log-level", &logLevel); parser.AddOption("-port", &portOption); parser.AddOption("-test-extra-device", &testExtraDevice); // advertise two different devices (for testing multi-device connect) @@ -219,11 +235,12 @@ int main(int argc, char *argv[]) if (logLevel.length() != 0) { LogLevel lv2LogLevel; - if (TryGetLogLevel(logLevel,&lv2LogLevel)) + if (TryGetLogLevel(logLevel, &lv2LogLevel)) { Lv2Log::log_level(lv2LogLevel); - - } else { + } + else + { Lv2Log::error(SS("Invalid log level: " << logLevel)); return EXIT_SUCCESS; // indicate to systemd that we don't want a restart. } @@ -234,6 +251,18 @@ int main(int argc, char *argv[]) configuration.SetSocketServerEndpoint(portOption); } + // clean up orphaned temporary files. + const std::filesystem::path webTempDirectory = "/var/pipedal/web_temp"; + if (!webTempDirectory.empty()) + { + std::filesystem::remove_all(webTempDirectory); //// user must belong to the pipedald grop when debugging. + std::filesystem::create_directories(webTempDirectory); + } + + // configure AudiDirectoryInfo to use the correct directories. + AudioDirectoryInfo::SetResourceDirectory(configuration.GetWebRoot()); + AudioDirectoryInfo::SetTemporaryDirectory(webTempDirectory / "audiofiles"); + uint16_t port; std::shared_ptr server; try @@ -261,6 +290,7 @@ int main(int argc, char *argv[]) return EXIT_SUCCESS; // indiate to systemd that we don't want a restart. } + try { { diff --git a/todo.txt b/todo.txt index 1bf8abf..940fc5d 100644 --- a/todo.txt +++ b/todo.txt @@ -1,3 +1,18 @@ +Exctly one underrun per seek in Toob Player + +Test autohotspot detection. + + + +check filetime conversion in gcc 12.2! (missing c++ 20 time conversion functions) + AudioFiles.cpp fileTimeToInt64 + + + +- pipewire aux in? + + + pcm.pipedal_aux_in { type file file "/tmp/aux_input_fifo" @@ -11,4 +26,3 @@ pcm.pipedal_aux_in { } } -Move to /usr/local because...?q diff --git a/vite/CMakeLists.txt b/vite/CMakeLists.txt index 32b6825..f5f87e3 100644 --- a/vite/CMakeLists.txt +++ b/vite/CMakeLists.txt @@ -45,7 +45,6 @@ add_custom_command( public/img/ic_logo.svg public/img/ic_navigate_next.svg public/img/fx_simulator.svg - public/img/VST_Logo_Steinberg.png public/img/fx_lr.svg public/img/fx_pitch.svg public/img/vst.svg @@ -93,7 +92,6 @@ add_custom_command( public/img/fx_gate.svg public/img/vst.png public/img/fx_function.svg - public/img/VST_Logo_Steinberg.ico public/img/fx_terminal.svg public/img/fx_plugin.svg public/img/fx_distortion.svg @@ -107,7 +105,6 @@ add_custom_command( public/var/current_pedalboard.json public/var/config.json public/favicon.ico - public/index.html public/iso_codes.json public/logo512.png public/sample_lv2_plugins.json diff --git a/vite/index.html b/vite/index.html index b44998e..462145c 100644 --- a/vite/index.html +++ b/vite/index.html @@ -3,7 +3,7 @@ - + @@ -15,6 +15,7 @@ BODY { background: #D0D0D0; } + - - - - -
- - - - \ No newline at end of file diff --git a/vite/src/pipedal/AboutDialog.tsx b/vite/src/pipedal/AboutDialog.tsx index 6417ce2..1780c78 100644 --- a/vite/src/pipedal/AboutDialog.tsx +++ b/vite/src/pipedal/AboutDialog.tsx @@ -1,6 +1,6 @@ import React, { SyntheticEvent, Component } from 'react'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import Typography from '@mui/material/Typography'; import { PiPedalModel, PiPedalModelFactory, State } from './PiPedalModel'; import AppBar from '@mui/material/AppBar'; @@ -147,10 +147,10 @@ const AboutDialog = class extends Component top: 0, left: 0 }} > - - + diff --git a/vite/src/pipedal/AlsaSequencer.tsx b/vite/src/pipedal/AlsaSequencer.tsx new file mode 100644 index 0000000..a49aff0 --- /dev/null +++ b/vite/src/pipedal/AlsaSequencer.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2025 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. + +export class AlsaSequencerPortSelection { + deserialize(json: any) { + this.id = json.id; + this.name = json.name; + this.sortOrder = json.sortOrder; + return this; + }; + static deserialize_array(input: any): AlsaSequencerPortSelection[] { + let result: AlsaSequencerPortSelection[] = []; + for (let i = 0; i < input.length; ++i) { + result[i] = new AlsaSequencerPortSelection().deserialize(input[i]); + } + return result; + } + id: string = ""; + name: string = ""; + sortOrder: number = 0; +}; + +export class AlsaSequencerConfiguration { + deserialize(input: any) { + this.connections = AlsaSequencerPortSelection.deserialize_array(input.connections); + return this; + } + deserialize_array(input: any): AlsaSequencerConfiguration[] { + let result: AlsaSequencerConfiguration[] = []; + for (let i = 0; i < input.length; ++i) { + result[i] = new AlsaSequencerConfiguration().deserialize(input[i]); + } + return result; + } + + connections: AlsaSequencerPortSelection[] = []; +}; diff --git a/vite/src/pipedal/App.tsx b/vite/src/pipedal/App.tsx index cb08a14..e2dca51 100644 --- a/vite/src/pipedal/App.tsx +++ b/vite/src/pipedal/App.tsx @@ -19,11 +19,13 @@ import React from 'react'; import { ThemeProvider, createTheme, StyledEngineProvider } from '@mui/material/styles'; -import { CssBaseline } from '@mui/material'; +import CssBaseline from '@mui/material/CssBaseline'; import VirtualKeyboardHandler from './VirtualKeyboardHandler'; import AppThemed from "./AppThemed"; import { isDarkMode } from './DarkMode'; +import Tone3000AuthComplete from './Tone3000AuthComplete'; + declare module '@mui/material/styles' { interface Theme { @@ -58,8 +60,50 @@ const theme = createTheme( { cssVariables: true, components: { + // MuiTouchRipple: { + // styleOverrides: { + // root: { + // borderRadius: 'inherit', + // overflow: 'hidden', + // }, + // ripple: { + // color: '#F88 !important', + // borderRadius: 'inherit', + + // '&.MuiTouchRipple-ripplePulsate': { + // //animation: 'none !important', + + // // Make focus ripple fill the entire button + // '&.MuiTouchRipple-child': { + // width: '100%', + // height: '100%', + // borderRadius: 'inherit', + // transform: 'scale(1.4)', // Override the default scaling + // } + // }, + // '&.MuiTouchRipple-ripple': { + // '&:focus': { + // // Make focus ripple fill the entire button + // transform: 'scale(1.4)', + // width: '100%', + // height: '100%', + // color: '#F88', + // borderRadius: 'inherit', + // }, + // }, + // }, + // child: { + // borderRadius: 'inherit', + // } + // } + // }, MuiButton: { styleOverrides: { + root: { + '& .MuiTouchRipple-ripple': { + transform: 'scale(1.9)', + } + }, containedPrimary: { borderRadius: '9999px', paddingLeft: "16px", paddingRight: "16px", @@ -80,7 +124,7 @@ const theme = createTheme( } }, { - props: { variant: 'dialogSecondary', }, + props: { variant: 'dialogSecondary', }, style: { color: "rgb(255,255,255,0.7)" }, @@ -108,18 +152,26 @@ const theme = createTheme( /* make the selection state for MuiListItemButtons a smidgen darker (light theme only) */ MuiListItemButton: { styleOverrides: { - root: ({ theme }) => ({ - '&.Mui-selected': { - backgroundColor: 'rgba(0, 0, 0, 0.2)', // Adjust for desired darkness - '&:hover': { - backgroundColor: 'rgba(0, 0, 0, 0.25)', // Slightly darker on hover - }, - }, - }), + root: ({ theme }) => ({ + '&.Mui-selected': { + backgroundColor: 'rgba(0, 0, 0, 0.2)', // Adjust for desired darkness + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.25)', // Slightly darker on hover + }, + }, + }), }, }, MuiButton: { styleOverrides: { + root: { + '& .MuiTouchRipple-root': { + borderRadius: 'inherit', + }, + '& .MuiTouchRipple-ripple': { + transform: 'scale(1.9)!important', + } + }, containedPrimary: { borderRadius: '9999px', paddingLeft: "16px", paddingRight: "16px", @@ -140,7 +192,7 @@ const theme = createTheme( } }, { - props: { variant: 'dialogSecondary', }, + props: { variant: 'dialogSecondary', }, style: { color: "rgb(0,0,0,0.6)" }, @@ -171,6 +223,12 @@ type AppThemeProps = { }; +function isTone3000Auth() { + let url = new URL(window.location.href); + let param = url.searchParams.get("api_key"); + return (param !== null && param !== "") +} + const App = (class extends React.Component { // Before the component mounts, we initialise our state @@ -178,8 +236,7 @@ const App = (class extends React.Component { super(props); this.state = { }; - if (!App.virtualKeyboardHandler) - { + if (!App.virtualKeyboardHandler) { App.virtualKeyboardHandler = new VirtualKeyboardHandler(); } } @@ -191,8 +248,11 @@ const App = (class extends React.Component { - + {isTone3000Auth() ? ( + + ) : ( + )} ); diff --git a/vite/src/pipedal/AppThemed.css b/vite/src/pipedal/AppThemed.css index 4ea4273..3c0694d 100644 --- a/vite/src/pipedal/AppThemed.css +++ b/vite/src/pipedal/AppThemed.css @@ -4,16 +4,49 @@ margin-top: 0px; margin-bottom: 0px; } + .fossCopyrights { padding-top: 16px; padding-bottom: 16px; } + .fossLicense { padding-left: 36px; border-bottom: 1px #CCC solid } + div { min-width: 0; - } +} +@media not all /* seems to be ok in current chrome (prefers-color-scheme: light) */{ + input[type="number"].scrollMod::-webkit-outer-spin-button, + input[type="number"].scrollMod::-webkit-inner-spin-button { + -webkit-appearance: none; + color: #FFF; + background: #0001 url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAKUlEQVQYlWNgwAT/sYhhKPiPT+F/LJgEsHv37v+EMGkmkuImoh2NoQAANlcun/q4OoYAAAAASUVORK5CYII=) no-repeat center center; + filter: invert(100%); + width: 1em; + padding: 2px; + opacity: .6; + position: absolute; + top: 0; + right: 0; + bottom: 0; + } + + input[type="number"].scrollMod::-webkit-inner-spin-button:hover, + input[type="number"].scrollMod::-webkit-inner-spin-button:active { + background: #0003 url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAKUlEQVQYlWNgwAT/sYhhKPiPT+F/LJgEsHv37v+EMGkmkuImoh2NoQAANlcun/q4OoYAAAAASUVORK5CYII=) no-repeat center center; + opacity: .8; + } +} +input[type=number] { + -moz-appearance: textfield; +} + + +img { + user-select: none; +} \ No newline at end of file diff --git a/vite/src/pipedal/AppThemed.tsx b/vite/src/pipedal/AppThemed.tsx index 3a32d70..f4f483f 100644 --- a/vite/src/pipedal/AppThemed.tsx +++ b/vite/src/pipedal/AppThemed.tsx @@ -26,10 +26,10 @@ import Modal from '@mui/material/Modal'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { withStyles } from "tss-react/mui"; -import WithStyles from './WithStyles'; +import WithStyles from './WithStyles'; import { css } from '@emotion/react'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism'; import MenuButton from '@mui/icons-material/Menu'; import { TemporaryDrawer } from './TemporaryDrawer'; @@ -196,14 +196,14 @@ const appStyles = ((theme: Theme) => ( toolBarContent: - css({ - position: "absolute", top: 0, width: "100%" - }), + css({ + position: "absolute", top: 0, width: "100%" + }), toolBarSpacer: - css({ - position: "relative", flex: "0 0 auto", - }), + css({ + position: "relative", flex: "0 0 auto", + }), mainFrame: css({ @@ -256,12 +256,17 @@ function setFullScreen(value: boolean) { let doc: any = window.document; let docEl: any = doc.documentElement; - if (docEl.requestFullscren) // the latest offical api. + if (docEl.requestFullscreen) // the latest offical api. { if (value) { - window.document.documentElement.requestFullscreen({ navigationUI: "show" }); - } else { - window.document.exitFullscreen(); + window.document.documentElement.requestFullscreen({ navigationUI: "hide" }) + .then(() => { }) + .catch((err) => { + alert( + `Unable to switch into fullscreen mode: ${err.message} (${err.name})`, + ); + }); + return; } } @@ -330,828 +335,838 @@ class MenuStackHandler implements IDialogStackable { this.app.menuStackHandler = null; this.app.hideDrawer(); } - private app: AppThemedBase; + private app: AppThemedBase; }; -export +export class AppThemedBase extends ResizeResponsiveComponent { - // Before the component mounts, we initialise our state + // Before the component mounts, we initialise our state - model_: PiPedalModel; - errorChangeHandler_: OnChangedHandler; - stateChangeHandler_: OnChangedHandler; + model_: PiPedalModel; + errorChangeHandler_: OnChangedHandler; + stateChangeHandler_: OnChangedHandler; - constructor(props: AppProps) { - super(props); - this.model_ = PiPedalModelFactory.getInstance(); + constructor(props: AppProps) { + super(props); + this.model_ = PiPedalModelFactory.getInstance(); - this.state = { - performanceView: false, - zoomedControlInfo: this.model_.zoomedUiControl.get(), - isDrawerOpen: false, - errorMessage: this.model_.errorMessage.get(), - displayState: this.model_.state.get(), - canFullScreen: supportsFullScreen() && !this.model_.isAndroidHosted(), - isFullScreen: !!document.fullscreenElement, - tinyToolBar: false, - alertDialogOpen: false, - alertDialogMessage: "", - presetName: this.model_.presets.get().getSelectedText(), - isSettingsDialogOpen: false, - updateDialogOpen: false, - onboarding: false, - isDebug: true, - presetChanged: this.model_.presets.get().presetChanged, - banks: this.model_.banks.get(), - renameBankDialogOpen: false, - saveBankAsDialogOpen: false, - aboutDialogOpen: false, - bankDialogOpen: false, - editBankDialogOpen: false, - zoomedControlOpen: false, - bankDisplayItems: 5, - showStatusMonitor: this.model_.showStatusMonitor.get() + this.state = { + performanceView: false, + zoomedControlInfo: this.model_.zoomedUiControl.get(), + isDrawerOpen: false, + errorMessage: this.model_.errorMessage.get(), + displayState: this.model_.state.get(), + canFullScreen: supportsFullScreen() && !this.model_.isAndroidHosted(), + isFullScreen: !!document.fullscreenElement, + tinyToolBar: false, + alertDialogOpen: false, + alertDialogMessage: "", + presetName: this.model_.presets.get().getSelectedText(), + isSettingsDialogOpen: false, + updateDialogOpen: false, + onboarding: false, + isDebug: true, + presetChanged: this.model_.presets.get().presetChanged, + banks: this.model_.banks.get(), + renameBankDialogOpen: false, + saveBankAsDialogOpen: false, + aboutDialogOpen: false, + bankDialogOpen: false, + editBankDialogOpen: false, + zoomedControlOpen: false, + bankDisplayItems: 5, + showStatusMonitor: this.model_.showStatusMonitor.get() - }; + }; - this.promptForUpdateHandler = this.promptForUpdateHandler.bind(this); - this.errorChangeHandler_ = this.setErrorMessage.bind(this); - this.beforeUnloadListener = this.beforeUnloadListener.bind(this); - this.unloadListener = this.unloadListener.bind(this); - this.stateChangeHandler_ = this.setDisplayState.bind(this); - this.presetChangedHandler = this.presetChangedHandler.bind(this); - this.alertMessageChangedHandler = this.alertMessageChangedHandler.bind(this); - this.handleCloseAlert = this.handleCloseAlert.bind(this); - this.banksChangedHandler = this.banksChangedHandler.bind(this); - this.showStatusMonitorHandler = this.showStatusMonitorHandler.bind(this); - this.handleZoomedUiControlChanged = this.handleZoomedUiControlChanged.bind(this); + this.promptForUpdateHandler = this.promptForUpdateHandler.bind(this); + this.errorChangeHandler_ = this.setErrorMessage.bind(this); + this.beforeUnloadListener = this.beforeUnloadListener.bind(this); + this.unloadListener = this.unloadListener.bind(this); + this.stateChangeHandler_ = this.setDisplayState.bind(this); + this.presetChangedHandler = this.presetChangedHandler.bind(this); + this.alertMessageChangedHandler = this.alertMessageChangedHandler.bind(this); + this.handleCloseAlert = this.handleCloseAlert.bind(this); + this.banksChangedHandler = this.banksChangedHandler.bind(this); + this.showStatusMonitorHandler = this.showStatusMonitorHandler.bind(this); + this.handleZoomedUiControlChanged = this.handleZoomedUiControlChanged.bind(this); + } + + showDrawer() { + if (!this.state.isDrawerOpen) { + this.setState({ isDrawerOpen: true }) + this.pushOpenMenuState(); + } + } + hideDrawer(loadingDialog: boolean = false) { + window.setTimeout(() => { + this.popOpenMenuState(); + this.setState({ isDrawerOpen: false }) + }); + } + + + menuStackHandler: MenuStackHandler | null = null; + + popOpenMenuState() { + if (this.menuStackHandler) { + let t = this.menuStackHandler; + this.menuStackHandler = null; + popDialogStack(t); } - showDrawer() { - if (!this.state.isDrawerOpen) { - this.setState({ isDrawerOpen: true }) - this.pushOpenMenuState(); - } - } - hideDrawer(loadingDialog: boolean = false) { - window.setTimeout(() => { - this.popOpenMenuState(); - this.setState({ isDrawerOpen: false }) - }); - } + } + pushOpenMenuState() { + this.menuStackHandler = new MenuStackHandler(this); + pushDialogStack(this.menuStackHandler); + } + handleSpecificBank(bankId: number) { - menuStackHandler: MenuStackHandler | null = null; + this.model_.openBank(bankId) + .catch((error) => this.model_.showAlert(error.toString())); - popOpenMenuState() { - if (this.menuStackHandler) { - let t = this.menuStackHandler; - this.menuStackHandler = null; - popDialogStack(t); - } + } - } - pushOpenMenuState() { - this.menuStackHandler = new MenuStackHandler(this); - pushDialogStack(this.menuStackHandler); - } - - handleSpecificBank(bankId: number) { - - this.model_.openBank(bankId) - .catch((error) => this.model_.showAlert(error.toString())); - - } - - handleSaveBankAsOk(newName: string) { - let currentName = this.model_.banks.get().getSelectedEntryName(); - if (currentName === newName) { - this.setState({ - renameBankDialogOpen: false, - saveBankAsDialogOpen: false - }); - return; - - } - - if (this.model_.banks.get().nameExists(newName)) { - this.model_.showAlert("A bank by that name already exists."); - return; - } + handleSaveBankAsOk(newName: string) { + let currentName = this.model_.banks.get().getSelectedEntryName(); + if (currentName === newName) { this.setState({ renameBankDialogOpen: false, saveBankAsDialogOpen: false }); - this.model_.saveBankAs(this.model_.banks.get().selectedBank, newName) - .catch((error) => { - this.model_.showAlert(error.toString()); - }); + return; } - handleBankRenameOk(newName: string) { - let currentName = this.model_.banks.get().getSelectedEntryName(); - if (currentName === newName) { - this.setState({ - renameBankDialogOpen: false, - saveBankAsDialogOpen: false - }); - return; - - } - - if (this.model_.banks.get().nameExists(newName)) { - this.model_.showAlert("A bank by that name already exists."); - return; - } - this.setState({ - renameBankDialogOpen: false - }); - this.model_.renameBank(this.model_.banks.get().selectedBank, newName) - .catch((error) => { - this.model_.showAlert(error.toString()); - }); + if (this.model_.banks.get().nameExists(newName)) { + this.model_.showAlert("A bank by that name already exists."); + return; } - handleSettingsDialogClose() { - this.setState({ - isSettingsDialogOpen: false, - onboarding: false - }); - } - handleDrawerSettingsClick() { - this.setState({ - isDrawerOpen: false, - isSettingsDialogOpen: true, - onboarding: false + this.setState({ + renameBankDialogOpen: false, + saveBankAsDialogOpen: false + }); + this.model_.saveBankAs(this.model_.banks.get().selectedBank, newName) + .catch((error) => { + this.model_.showAlert(error.toString()); }); - } - - handleDisplayOnboarding() { - this.setState({ - isDrawerOpen: false, - isSettingsDialogOpen: true, - onboarding: true - }); - - } - - handleDrawerManageBanks() { - this.setState({ - isDrawerOpen: false, - bankDialogOpen: true, - editBankDialogOpen: true - }); - - } - handleDrawerSelectBank() { - - this.setState({ - bankDialogOpen: true, - editBankDialogOpen: false - }); - - } - handleDrawerDonationClick() { - this.hideDrawer(false); - if (this.model_.isAndroidHosted()) { - this.model_.showAndroidDonationActivity(); - } else { - if (window) { - window.open("https://github.com/sponsors/rerdavies", '_blank'); - } - } - - } - - handleDrawerAboutClick() { - this.setState({ - aboutDialogOpen: true - }); - - } - handleDrawerRenameBank() { - this.setState({ - renameBankDialogOpen: true, - saveBankAsDialogOpen: false - }); - - } - handleDrawerSaveBankAs() { + } + handleBankRenameOk(newName: string) { + let currentName = this.model_.banks.get().getSelectedEntryName(); + if (currentName === newName) { this.setState({ renameBankDialogOpen: false, - saveBankAsDialogOpen: true + saveBankAsDialogOpen: false }); + return; + } - - handleCloseAlert(e?: any, reason?: any) { - this.model_.alertMessage.set(""); + if (this.model_.banks.get().nameExists(newName)) { + this.model_.showAlert("A bank by that name already exists."); + return; } - showStatusMonitorHandler() { + this.setState({ + renameBankDialogOpen: false + }); + this.model_.renameBank(this.model_.banks.get().selectedBank, newName) + .catch((error) => { + this.model_.showAlert(error.toString()); + }); + + } + handleSettingsDialogClose() { + this.setState({ + isSettingsDialogOpen: false, + onboarding: false + }); + } + handleDrawerSettingsClick() { + this.setState({ + isDrawerOpen: false, + isSettingsDialogOpen: true, + onboarding: false + }); + + } + + handleDisplayOnboarding() { + this.setState({ + isDrawerOpen: false, + isSettingsDialogOpen: true, + onboarding: true + }); + + } + + handleDrawerManageBanks() { + this.setState({ + isDrawerOpen: false, + bankDialogOpen: true, + editBankDialogOpen: true + }); + + } + handleDrawerSelectBank() { + + this.setState({ + bankDialogOpen: true, + editBankDialogOpen: false + }); + + } + handleDrawerDonationClick() { + this.hideDrawer(false); + if (this.model_.isAndroidHosted()) { + this.model_.showAndroidDonationActivity(); + } else { + if (window) { + window.open("https://github.com/sponsors/rerdavies", '_blank'); + } + } + + } + + handleDrawerAboutClick() { + this.setState({ + aboutDialogOpen: true + }); + + } + handleDrawerRenameBank() { + this.setState({ + renameBankDialogOpen: true, + saveBankAsDialogOpen: false + }); + + } + handleDrawerSaveBankAs() { + this.setState({ + renameBankDialogOpen: false, + saveBankAsDialogOpen: true + }); + } + + + handleCloseAlert(e?: any, reason?: any) { + this.model_.alertMessage.set(""); + } + showStatusMonitorHandler() { + this.setState({ + showStatusMonitor: this.model_.showStatusMonitor.get() + }); + } + banksChangedHandler() { + this.setState({ + banks: this.model_.banks.get() + }); + } + presetChangedHandler() { + let presets = this.model_.presets.get(); + + this.setState({ + presetName: presets.getSelectedText(), + presetChanged: presets.presetChanged + }); + } + toggleFullScreen(): void { + setFullScreen(!this.state.isFullScreen); + this.setState({ isFullScreen: !this.state.isFullScreen }); + } + + private unloadListener(e: Event) { + this.model_.close(); + return undefined; + } + + private beforeUnloadListener(e: Event) { + this.model_.close(); + return undefined; + } + promptForUpdateHandler(newValue: boolean) { + if (this.model_.enableAutoUpdate) { + if (this.state.updateDialogOpen !== newValue) { + this.setState({ updateDialogOpen: newValue }); + } + } + } + componentDidMount() { + + super.componentDidMount(); + window.addEventListener("beforeunload", this.beforeUnloadListener); + window.addEventListener("unload", this.unloadListener); + + this.model_.errorMessage.addOnChangedHandler(this.errorChangeHandler_); + this.model_.state.addOnChangedHandler(this.stateChangeHandler_); + this.model_.pedalboard.addOnChangedHandler(this.presetChangedHandler); + this.model_.alertMessage.addOnChangedHandler(this.alertMessageChangedHandler); + this.model_.banks.addOnChangedHandler(this.banksChangedHandler); + this.model_.showStatusMonitor.addOnChangedHandler(this.showStatusMonitorHandler); + this.model_.promptForUpdate.addOnChangedHandler(this.promptForUpdateHandler); + this.alertMessageChangedHandler(); + + this.model_.zoomedUiControl.addOnChangedHandler( + this.handleZoomedUiControlChanged + ); + } + handleZoomedUiControlChanged() { + this.setState({ + zoomedControlOpen: this.model_.zoomedUiControl.get() !== undefined, + zoomedControlInfo: this.model_.zoomedUiControl.get() + }); + + } + + updateOverscroll(): void { + if (this.model_.serverVersion) { + // no pull-down refresh on android devices once we're ready (unless we're debug) + let preventOverscroll = + this.model_.state.get() === State.Ready + && !this.model_.debug; + + let overscrollBehavior = preventOverscroll ? "none" : "auto"; + document.body.style.overscrollBehavior = overscrollBehavior; + } + } + + componentDidUpdate() { + } + + componentWillUnmount() { + super.componentWillUnmount(); + + this.model_.zoomedUiControl.removeOnChangedHandler( + this.handleZoomedUiControlChanged + ); + + window.removeEventListener("beforeunload", this.beforeUnloadListener); + + this.model_.promptForUpdate.removeOnChangedHandler(this.promptForUpdateHandler); + this.model_.errorMessage.removeOnChangedHandler(this.errorChangeHandler_); + this.model_.state.removeOnChangedHandler(this.stateChangeHandler_); + this.model_.pedalboard.removeOnChangedHandler(this.presetChangedHandler); + this.model_.banks.removeOnChangedHandler(this.banksChangedHandler); + this.model_.banks.removeOnChangedHandler(this.showStatusMonitorHandler); + + this.model_.close(); + + } + + alertMessageChangedHandler() { + let message = this.model_.alertMessage.get(); + if (message === "") { + this.setState({ alertDialogOpen: false }); + // leave the message intact so the dialog can fade. + } else { this.setState({ - showStatusMonitor: this.model_.showStatusMonitor.get() + alertDialogOpen: true, + alertDialogMessage: message }); } - banksChangedHandler() { - this.setState({ - banks: this.model_.banks.get() - }); - } - presetChangedHandler() { - let presets = this.model_.presets.get(); - this.setState({ - presetName: presets.getSelectedText(), - presetChanged: presets.presetChanged - }); - } - toggleFullScreen(): void { - setFullScreen(!this.state.isFullScreen); - this.setState({ isFullScreen: !this.state.isFullScreen }); - } + } + updateResponsive() { + // functional, but disabled. + // let tinyToolBar_ = this.windowSize.height < 600; + // this.setState({ tinyToolBar: tinyToolBar_ }); - private unloadListener(e: Event) { - this.model_.close(); - return undefined; - } + let height = this.windowSize.height; - private beforeUnloadListener(e: Event) { - this.model_.close(); - return undefined; + const ENTRY_HEIGHT = 48; + // ENTRY_HEIGHT*6 +K = 727 from observation. + const K = 450; + + let bankEntries = Math.floor((height - K) / ENTRY_HEIGHT); + if (bankEntries < 1) bankEntries = 1; + if (bankEntries > 7) bankEntries = 7; + if (bankEntries === 2) bankEntries = 1; + + this.setState({ bankDisplayItems: bankEntries }); + + } + onWindowSizeChanged(width: number, height: number): void { + super.onWindowSizeChanged(width, height); + this.updateResponsive(); + } + + + setErrorMessage(message: string): void { + this.setState({ errorMessage: message }); + } + + + setDisplayState(newState: State): void { + this.updateOverscroll(); + + this.setState({ + displayState: newState, + canFullScreen: supportsFullScreen() && !this.model_.isAndroidHosted() + }); + if (newState === State.Ready) { + if (this.model_.isOnboarding()) { + this.handleDisplayOnboarding(); + } } - promptForUpdateHandler(newValue: boolean) { - if (this.model_.enableAutoUpdate) { - if (this.state.updateDialogOpen !== newValue) { - this.setState({ updateDialogOpen: newValue }); + } + + shortBankList(banks: BankIndex): BankIndexEntry[] { + let nDisplayEntries = this.state.bankDisplayItems; + let entries = banks.entries; + let nListEntries = entries.length; + + let result: BankIndexEntry[] = []; + + if (nListEntries <= nDisplayEntries) { + for (let i = 0; i < nListEntries; ++i) { + result.push(entries[i]); + } + } else { + // a subset of the list CENTERED on the currently selected entry. + let selectedIndex = 0; + for (let i = 0; i < nListEntries; ++i) { + if (entries[i].instanceId === banks.selectedBank) { + selectedIndex = i; + break; } } - } - componentDidMount() { - super.componentDidMount(); - window.addEventListener("beforeunload", this.beforeUnloadListener); - window.addEventListener("unload", this.unloadListener); - - this.model_.errorMessage.addOnChangedHandler(this.errorChangeHandler_); - this.model_.state.addOnChangedHandler(this.stateChangeHandler_); - this.model_.pedalboard.addOnChangedHandler(this.presetChangedHandler); - this.model_.alertMessage.addOnChangedHandler(this.alertMessageChangedHandler); - this.model_.banks.addOnChangedHandler(this.banksChangedHandler); - this.model_.showStatusMonitor.addOnChangedHandler(this.showStatusMonitorHandler); - this.model_.promptForUpdate.addOnChangedHandler(this.promptForUpdateHandler); - this.alertMessageChangedHandler(); - - this.model_.zoomedUiControl.addOnChangedHandler( - this.handleZoomedUiControlChanged - ); - } - handleZoomedUiControlChanged() { - this.setState({ - zoomedControlOpen: this.model_.zoomedUiControl.get() !== undefined, - zoomedControlInfo: this.model_.zoomedUiControl.get() - }); - - } - - updateOverscroll(): void { - if (this.model_.serverVersion) { - // no pull-down refresh on android devices once we're ready (unless we're debug) - let preventOverscroll = - this.model_.state.get() === State.Ready - && !this.model_.debug; - - let overscrollBehavior = preventOverscroll ? "none" : "auto"; - document.body.style.overscrollBehavior = overscrollBehavior; + let minN = selectedIndex - Math.floor(nDisplayEntries / 2); + if (minN < 0) minN = 0; + let maxN = minN + nDisplayEntries; + if (maxN > entries.length) { + maxN = entries.length; + } + for (let i = minN; i < maxN; ++i) { + result.push(entries[i]); } } - - componentDidUpdate() { + return result; + } + handleReload() { + if (this.model_.isAndroidHosted()) { + this.model_.chooseNewDevice(); + } else { + window.location.reload(); } - - componentWillUnmount() { - super.componentWillUnmount(); - - this.model_.zoomedUiControl.removeOnChangedHandler( - this.handleZoomedUiControlChanged - ); - - window.removeEventListener("beforeunload", this.beforeUnloadListener); - - this.model_.promptForUpdate.removeOnChangedHandler(this.promptForUpdateHandler); - this.model_.errorMessage.removeOnChangedHandler(this.errorChangeHandler_); - this.model_.state.removeOnChangedHandler(this.stateChangeHandler_); - this.model_.pedalboard.removeOnChangedHandler(this.presetChangedHandler); - this.model_.banks.removeOnChangedHandler(this.banksChangedHandler); - this.model_.banks.removeOnChangedHandler(this.showStatusMonitorHandler); - - this.model_.close(); - + } + getReloadingMessage(): string { + switch (this.state.displayState) { + case State.ApplyingChanges: + return "Applying\u00A0changes..."; + case State.ReloadingPlugins: + return "Reloading\u00A0plugins..."; + case State.DownloadingUpdate: + return "Downloading update..."; + case State.InstallingUpdate: + return "Installing update...."; + case State.HotspotChanging: + return "Network connection changing..." + default: + return "Reconnecting..."; } + } + render() { - alertMessageChangedHandler() { - let message = this.model_.alertMessage.get(); - if (message === "") { - this.setState({ alertDialogOpen: false }); - // leave the message intact so the dialog can fade. - } else { - this.setState({ - alertDialogOpen: true, - alertDialogMessage: message - }); - } + const classes = withStyles.getClasses(this.props); - } - updateResponsive() { - // functional, but disabled. - // let tinyToolBar_ = this.windowSize.height < 600; - // this.setState({ tinyToolBar: tinyToolBar_ }); - - let height = this.windowSize.height; - - const ENTRY_HEIGHT = 48; - // ENTRY_HEIGHT*6 +K = 727 from observation. - const K = 450; - - let bankEntries = Math.floor((height - K) / ENTRY_HEIGHT); - if (bankEntries < 1) bankEntries = 1; - if (bankEntries > 7) bankEntries = 7; - if (bankEntries === 2) bankEntries = 1; - - this.setState({ bankDisplayItems: bankEntries }); - - } - onWindowSizeChanged(width: number, height: number): void { - super.onWindowSizeChanged(width, height); - this.updateResponsive(); - } + let shortBankList = this.shortBankList(this.state.banks); + let showBankSelectDialog = shortBankList.length !== this.state.banks.entries.length; - setErrorMessage(message: string): void { - this.setState({ errorMessage: message }); - } - - - setDisplayState(newState: State): void { - this.updateOverscroll(); - - this.setState({ - displayState: newState, - canFullScreen: supportsFullScreen() && !this.model_.isAndroidHosted() - }); - if (newState === State.Ready) { - if (this.model_.isOnboarding()) { - this.handleDisplayOnboarding(); - } - } - } - - shortBankList(banks: BankIndex): BankIndexEntry[] { - let nDisplayEntries = this.state.bankDisplayItems; - let entries = banks.entries; - let nListEntries = entries.length; - - let result: BankIndexEntry[] = []; - - if (nListEntries <= nDisplayEntries) { - for (let i = 0; i < nListEntries; ++i) { - result.push(entries[i]); - } - } else { - // a subset of the list CENTERED on the currently selected entry. - let selectedIndex = 0; - for (let i = 0; i < nListEntries; ++i) { - if (entries[i].instanceId === banks.selectedBank) { - selectedIndex = i; - break; - } - } - - let minN = selectedIndex - Math.floor(nDisplayEntries / 2); - if (minN < 0) minN = 0; - let maxN = minN + nDisplayEntries; - if (maxN > entries.length) { - maxN = entries.length; - } - for (let i = minN; i < maxN; ++i) { - result.push(entries[i]); - } - } - return result; - } - handleReload() { - if (this.model_.isAndroidHosted()) { - this.model_.chooseNewDevice(); - } else { - window.location.reload(); - } - } - getReloadingMessage(): string { - switch (this.state.displayState) { - case State.ApplyingChanges: - return "Applying\u00A0changes..."; - case State.ReloadingPlugins: - return "Reloading\u00A0plugins..."; - case State.DownloadingUpdate: - return "Downloading update..."; - case State.InstallingUpdate: - return "Installing update...."; - case State.HotspotChanging: - return "Network connection changing..." - default: - return "Reconnecting..."; - } - } - render() { - - const classes = withStyles.getClasses(this.props); - - let shortBankList = this.shortBankList(this.state.banks); - let showBankSelectDialog = shortBankList.length !== this.state.banks.entries.length; - - - return ( -
{ - if (!this.model_.debug) { - e.preventDefault(); e.stopPropagation(); + return ( +
{ + if (!this.model_.debug) { + e.preventDefault(); e.stopPropagation(); + } else { + if ((e.target as any).tagName === "IMG") { + e.preventDefault(); + e.stopPropagation(); } + } + }} + > + {this.state.performanceView ? ( + { this.setState({ performanceView: false }); }} + /> + ) : ( +
- {this.state.performanceView ? ( - { this.setState({ performanceView: false }); }} - /> - ) : ( -
+ > - {(!this.state.tinyToolBar) && !this.state.performanceView ? - ( - - - { this.showDrawer() }} - size="large"> - - -
- -
-
- {this.state.canFullScreen && - { this.toggleFullScreen(); }} - color="inherit" - size="large"> - {this.state.isFullScreen ? ( - - ) : ( - - - )} - - - } - - - ) : ( -
- + + { this.showDrawer() }} color="inherit" + onClick={() => { this.showDrawer() }} size="large"> - - - {this.state.canFullScreen && ( - + +
+ +
+
+ {this.state.canFullScreen && + { this.toggleFullScreen(); }} + color="inherit" size="large"> {this.state.isFullScreen ? ( - + ) : ( - + )} - - )} -
- )} - { this.hideDrawer(false); }} > + + } + + + ) : ( +
+ { this.showDrawer() }} + color="inherit" + size="large"> + + + {this.state.canFullScreen && ( + { this.toggleFullScreen(); }} + size="large"> + {this.state.isFullScreen ? ( + + ) : ( + - - { - ev.stopPropagation(); - this.hideDrawer(true); - this.setState({ performanceView: true }); - }}> - - - - - - - - - Banks + )} - - { - shortBankList.map((bank) => { - return ( - { - ev.stopPropagation(); - this.hideDrawer(false); - this.handleSpecificBank(bank.instanceId); - }} - > + + )} +
+ )} + { this.hideDrawer(false); }} > - - + + { + ev.stopPropagation(); + this.hideDrawer(true); + this.setState({ performanceView: true }); + }}> + + + + + + + + + Banks - ); - }) - } - { - showBankSelectDialog && ( - + { + shortBankList.map((bank) => { + return ( + { ev.stopPropagation(); - this.hideDrawer(true); - this.handleDrawerSelectBank(); + this.hideDrawer(false); + this.handleSpecificBank(bank.instanceId); }} > - + + ); + }) + } + { + showBankSelectDialog && ( + { + ev.stopPropagation(); + this.hideDrawer(true); + this.handleDrawerSelectBank(); + }} + > + + + + + ) + } + + + + { + ev.stopPropagation(); + this.hideDrawer(true); + this.handleDrawerRenameBank() + }}> + + + + + + { + ev.stopPropagation(); + this.hideDrawer(true); + this.handleDrawerSaveBankAs(); + }} > + + + + + + { + ev.stopPropagation(); + this.hideDrawer(true); + this.handleDrawerManageBanks(); + }}> + + + + + + + + + { + ev.stopPropagation(); + this.hideDrawer(true); + this.handleDrawerSettingsClick() + }}> + + + + + + { + ev.stopPropagation(); + this.hideDrawer(true); + this.handleDrawerAboutClick(); + }}> + + + + + + { + ev.stopPropagation(); + this.handleDrawerDonationClick(); + }}> + + + + + + + + + {!this.state.tinyToolBar && ( + + )} +
+
+
+ {(this.state.displayState !== State.Loading) && + ( + ) } - - - - { - ev.stopPropagation(); - this.hideDrawer(true); - this.handleDrawerRenameBank() - }}> - - - - - - { - ev.stopPropagation(); - this.hideDrawer(true); - this.handleDrawerSaveBankAs(); - }} > - - - - - - { - ev.stopPropagation(); - this.hideDrawer(true); - this.handleDrawerManageBanks(); - }}> - - - - - - - - - { - ev.stopPropagation(); - this.hideDrawer(true); - this.handleDrawerSettingsClick() - }}> - - - - - - { - ev.stopPropagation(); - this.hideDrawer(true); - this.handleDrawerAboutClick(); - }}> - - - - - - { - ev.stopPropagation(); - this.handleDrawerDonationClick(); - }}> - - - - - - - - - {!this.state.tinyToolBar && ( - - )} -
-
-
- {(this.state.displayState !== State.Loading) && - ( - - ) - } -
-
-
- this.setState({ bankDialogOpen: false })} /> - {(this.state.aboutDialogOpen) && - ( - this.setState({ aboutDialogOpen: false })} /> - )} - this.handleSettingsDialogClose()} /> - { - this.setState({ - renameBankDialogOpen: false, - saveBankAsDialogOpen: false - }) - }} - onOk={(text: string) => { - if (this.state.renameBankDialogOpen) { - this.handleBankRenameOk(text); - } else if (this.state.saveBankAsDialogOpen) { - this.handleSaveBankAsOk(text); - } + +
+
+ this.setState({ bankDialogOpen: false })} /> + {(this.state.aboutDialogOpen) && + ( + this.setState({ aboutDialogOpen: false })} /> + )} + this.handleSettingsDialogClose()} /> + { + this.setState({ + renameBankDialogOpen: false, + saveBankAsDialogOpen: false + }) + }} + onOk={(text: string) => { + if (this.state.renameBankDialogOpen) { + this.handleBankRenameOk(text); + } else if (this.state.saveBankAsDialogOpen) { + this.handleSaveBankAsOk(text); } + } + } + /> + + { this.setState({ zoomedControlOpen: false }); }} + onDialogClosed={() => { this.model_.zoomedUiControl.set(undefined); } + } + /> + + {this.state.showStatusMonitor && ()} +
+ ) + } + + + + + + + + { + this.state.alertDialogMessage } - /> - - { this.setState({ zoomedControlOpen: false }); }} - onDialogClosed={() => { this.model_.zoomedUiControl.set(undefined); } - } - /> - - {this.state.showStatusMonitor && ()} -
- ) - } + + + + + + + - - - - - - { - this.state.alertDialogMessage - } - - - - - - - - - - +
-
-
 
-
-
- -
-
-

- Error: {this.state.errorMessage} -

-
-
- - -
+ position: "absolute", + minHeight: "10em", + left: "0px", + top: "0px", + right: "0px", + bottom: "0px", + opacity: 0.8, + background: + isDarkMode() ? "#222" : "#EEE", + }} /> +
 
+
+
+
-
 
-
+
+

+ Error: {this.state.errorMessage} +

+
+
+ - - {/* Reloading mask */} - < Modal - open={wantsReloadingScreen(this.state.displayState) || this.state.displayState === State.Loading} - aria-label="loading" - aria-describedby="reloading-modal-description" - > -
-
-
-
- -
- - { - this.state.displayState === State.Loading ? - "Loading..." - : this.getReloadingMessage() - } -
- -
+
 
+
- ); - } - }; + + {/* Reloading mask */} + < Modal + open={wantsReloadingScreen(this.state.displayState) || this.state.displayState === State.Loading} + aria-label="loading" + aria-describedby="reloading-modal-description" + > +
+
+
+
+ +
+ + { + this.state.displayState === State.Loading ? + "Loading..." + : this.getReloadingMessage() + } + +
+
+ +
+ + ); + } +}; const AppThemed = withStyles(AppThemedBase, appStyles); diff --git a/vite/src/pipedal/AudioFileMetadata.tsx b/vite/src/pipedal/AudioFileMetadata.tsx new file mode 100644 index 0000000..d38d435 --- /dev/null +++ b/vite/src/pipedal/AudioFileMetadata.tsx @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +import { pathConcat, pathParentDirectory, pathFileName } from "./FileUtils"; +import { PiPedalModel } from "./PiPedalModel"; + +export class ThumbnailType { + static Unknown = 0; + static Embedded = 1; // embedded in the file + static Folder = 2; // from a albumArt.jpg or similar + static None = 3; // Use default thumbnail. +}; + + +const fileVersionRegex = /\((\d+)\)\.[0-9a-zA-Z]*$/; +function getVersionSuffix(filePath: string): string | null { + const match = filePath.match(fileVersionRegex); + return match ? match[1] : null; +} + + +export function getTrackTitle(pathname: string, metadata: AudioFileMetadata | null | undefined): string { + if (!metadata) { + return pathFileName(pathname); + } + if (metadata.title === "") { + return pathFileName(pathname); + } + let trackDisplay = ""; + if (metadata.track > 0) { + if (metadata.track >= 1000) { + trackDisplay = (metadata.track % 1000).toString() + "/" + Math.floor(metadata.track / 1000) + ". "; + } else { + trackDisplay = metadata.track.toString() + ". "; + } + } + let result = trackDisplay + metadata.title; + if (result === "") { + result = pathFileName(pathname); + } + let versionSuffix = getVersionSuffix(pathname); + if (versionSuffix) { + result += " (" + versionSuffix + ")"; + } + return result; +} + + +export default class AudioFileMetadata { + //AudioFileMetadata&operator=(const AudioFileMetadata&) = default; + deserialize(o: any) { + this.fileName = o.fileName; + this.lastModified = o.lastModified; + this.title = o.title; + this.track = o.track; + this.album = o.album; + this.artist = o.artist; + this.albumArtist = o.albumArtist; + this.duration = o.duration; + this.thumbnailType = o.thumbnailType; + this.thumbnailFile = o.thumbnailFile; + this.thumbnailLastModified = o.thumbnailLastModified; + + return this; + } + fileName: string = ""; + lastModified: number = 0; + title: string = ""; + track: number = 0; + album: string = ""; + artist: string = ""; + albumArtist: string = ""; + duration: number = 0; + thumbnailType: ThumbnailType = new ThumbnailType(); + thumbnailFile = ""; + thumbnailLastModified = 0; +} + + + +export function getAlbumArtUri(model: PiPedalModel, metadata: AudioFileMetadata | undefined, path: string): string { + let coverArtUri: string; + if (!metadata) { + return "/img/missing_thumbnail.jpg"; + } + if (metadata.thumbnailType === ThumbnailType.None) { + return "/img/missing_thumbnail.jpg"; + + } else if (metadata.thumbnailType === ThumbnailType.Folder) { + // Use the thumbnailFile to get the cover art. + let thumbnailPath = pathConcat(pathParentDirectory(path) ,metadata.thumbnailFile); + + coverArtUri = model.varServerUrl + "Thumbnail" + + "?ffile=" + encodeURIComponent(thumbnailPath) + + "&t=" + metadata.thumbnailLastModified + + "&w=240&h=240" + ; + return coverArtUri; + } else { + // for embeed and unknown + coverArtUri = model.varServerUrl + "Thumbnail" + + "?path=" + encodeURIComponent(path) + + "&t=" + metadata.lastModified + + "&w=240&h=240" + ; + return coverArtUri; + } + +} \ No newline at end of file diff --git a/vite/src/pipedal/BankDialog.tsx b/vite/src/pipedal/BankDialog.tsx index ea12606..4080a42 100644 --- a/vite/src/pipedal/BankDialog.tsx +++ b/vite/src/pipedal/BankDialog.tsx @@ -18,7 +18,7 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React, { Component } from 'react'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import Typography from '@mui/material/Typography'; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import { BankIndexEntry, BankIndex } from './Banks'; @@ -422,17 +422,17 @@ const BankDialog = withStyles(
- - + Banks - this.showActionBar(true)} > + this.showActionBar(true)} > - + {(!this.props.isEditDialog) ? ( - this.showActionBar(false)} aria-label="close"> + this.showActionBar(false)} aria-label="close"> - + ) : ( - - + )} @@ -456,9 +456,9 @@ const BankDialog = withStyles( {(this.state.banks.getEntry(this.state.selectedItem) != null) && ( -
+
-
); } @@ -579,22 +930,145 @@ export default withStyles( } return result; } - private getIcon(fileEntry: FileEntry) { + + + private getIcon(fileEntry: FileEntry, largeIcon: boolean) { + let style = largeIcon + ? { + flex: "0 0 auto", opacity: 0.7, width: 32, height: 32, + marginLeft: 16, marginRight: 24, marginTop: 16, marginBottom: 16, + } + : { flex: "0 0 auto", opacity: 0.7, marginRight: 8, marginLeft: 8 }; + + + + if (fileEntry.pathname === "") { - return (); + return (); } if (fileEntry.isDirectory) { return ( - + ); } if (isAudioFile(fileEntry.pathname)) { - return (); + return (); } - return (); + return (); + } + + private listContainerElementRef: HTMLDivElement | null = null; + private scrollContainerElementRef: HTMLDivElement | null = null; + private autoScrollTimer: number | null = null; + + handleAutoScrollTick() { + let dragState = this.state.dragState; + if (!dragState) return; + + let scrollContainer = this.scrollContainerElementRef; + if (!scrollContainer) { + return; + } + let scrollBounds_ = scrollContainer.getBoundingClientRect(); + let scrollClientTop = scrollBounds_.top; + let scrollClientBottom = scrollClientTop + scrollContainer.clientHeight; + let dragBounds = dragState.dragElement?.getBoundingClientRect(); + + let dy = 0; + let y = scrollContainer.scrollTop; + if (dragBounds.top < scrollClientTop + AUTOSCROLL_THRESHOLD) { + dy = -AUTOSCROLL_SCROLL_RATE; + + y = scrollContainer.scrollTop + dy; + if (y < 0) y = 0; + dy = y - scrollContainer.scrollTop; + } else if (dragBounds.bottom > scrollClientBottom - AUTOSCROLL_THRESHOLD) { + dy = AUTOSCROLL_SCROLL_RATE; + + y = scrollContainer.scrollTop + dy; + let maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight; + if (y > maxScrollTop) { + y = maxScrollTop; + } + dy = y - scrollContainer.scrollTop; + } else { + return; + } + if (this.longPressStartPoint === null) { + return; + } + if (dy === 0) { + return; + } + this.longPressStartPoint = { + x: this.longPressStartPoint.x, + y: this.longPressStartPoint.y - dy + }; + let dragElementDy = dragState.lastMousePoint.y - this.longPressStartPoint.y; + let originalY = dragState.from * dragState.height; + let newY = originalY + dragElementDy; + if (newY < 0) { + newY = 0; + dragElementDy = -originalY; + } + let maxDy = (this.maxPosition - 1) * dragState.height; + if (newY > maxDy) { + newY = maxDy; + dragElementDy = maxDy - originalY; + } + let newTo = Math.floor((newY + dragState.height / 2) / dragState.height); + + + let newDragState = { ...dragState }; + + newDragState.dragElementDy = dragElementDy; + newDragState.to = newTo; + + // console.log("autoscroll from " + dragState.from + " to " + newTo + " dy: " + dragElementDy); + // avoid jitter. + newDragState.dragElement.style.top = (newDragState.dragElementDy + 5) + "px"; + + this.setState({ dragState: newDragState }); + scrollContainer.scrollTo({ left: 0, top: y, behavior: "instant" }); + this.startAutoScroll(); + + } + + + stopAutoScroll() { + if (this.autoScrollTimer !== null) { + clearTimeout(this.autoScrollTimer); + this.autoScrollTimer = null; + } + } + startAutoScroll() { + this.stopAutoScroll(); + + this.autoScrollTimer = setTimeout( + () => { this.handleAutoScrollTick() }, AUTOSCROLL_TICK_DELAY); + } + + // handleTone3000Dialog(e: React.MouseEvent) { + // e.stopPropagation(); + // e.preventDefault(); + + // this.setState({ openTone3000Dialog: true }); + + // } + + + handleTone3000Help(e: React.MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + + this.setState({ openTone3000Help: true }); + } render() { + const isTracksDirectory = this.isTracksDirectory(); + const isToobNamModelFile = this.props.fileProperty.patchProperty === ToobNamModelFileUrl; + const classes = withStyles.getClasses(this.props); let columnWidth = this.state.columnWidth; let okButtonText = "Select"; @@ -606,17 +1080,34 @@ export default withStyles( if (this.state.hasSelection) { protectedItem = this.state.selectedFileProtected; } - let canMoveOrRename = this.hasSelectedFileOrFolder() && !protectedItem; + let canMove = this.hasSelectedFileOrFolder() && !protectedItem; + let canRename = this.hasSelectedFileOrFolder() && !protectedItem && !isTracksDirectory; + let canReorder = isTracksDirectory; + let needsDivider = canMove || canRename || canReorder; + let trackPosition = 0; + let compactVertical = this.state.windowHeight < 700; + let canSelectFile = this.state.hasSelection && !this.isFolderArtwork(this.state.selectedFile); + return this.props.open && ( { - this.props.onCancel(); + if (this.state.reordering) { + this.setState({ reordering: false, dragState: null }); + } else { + this.props.onCancel(); + } }} onEnterKey={() => { - this.openSelectedFile(); + if (this.state.reordering) { + this.setState({ reordering: false, dragState: null }); + } else if (this.state.multiSelect) { + this.setState({ multiSelect: false, selectedFiles: [] }); + } else { + this.openSelectedFile(); + } }} open={this.props.open} tag="fileProperty" fullWidth maxWidth="md" @@ -631,86 +1122,227 @@ export default withStyles( } }} > - - - { this.props.onCancel(); }} - > - - - - {this.props.fileProperty.label} - + + {this.state.reordering && ( + + { this.setState({ reordering: false, dragState: null }); } + } + > + + + + Reorder files + + - { this.onNewFolder(); }} - disabled={protectedDirectory} - > - - + )} + {this.state.multiSelect && ( + + { + this.handleMultisSelectClose(); + }} + > + + + + {this.state.selectedFiles.length.toString() + " files selected."} + + + { this.handleMenuMultiSelectOpen(ev); }} + disabled={protectedDirectory} + + > + + + { this.handleMenuMultiSelectClose(); }} + > + {canMove && ( { this.handleMenuMultiSelectClose(); this.handleMove(); }}>Move)} + {canMove && ( { this.handleMenuMultiSelectClose(); this.handleCopy(); }}>Copy)} + + + + )} + {!(this.state.reordering || this.state.multiSelect) && ( + <> + + { this.props.onCancel(); }} + > + + + + {this.props.fileProperty.label} + + + { this.onNewFolder(); }} + disabled={protectedDirectory} + > + + - { this.handleMenuOpen(ev); }} - disabled={protectedDirectory} + { this.handleMenuOpen(ev); }} + disabled={protectedDirectory} - > - - - { this.handleMenuClose(); }} - > - { this.handleMenuClose(); this.onNewFolder(); }}>New folder - {canMoveOrRename && ()} - {canMoveOrRename && ( { this.handleMenuClose(); this.onMove(); }}>Move)} - {canMoveOrRename && this.hasSelectedFileOrFolder() && !protectedItem && ( { this.handleMenuClose(); this.onRename(); }}>Rename)} - - + > + + + { this.handleMenuClose(); }} + > + { this.handleMenuClose(); this.onNewFolder(); }}>New folder + {(needsDivider) && ()} + {canMove && ( { this.handleMenuClose(); this.handleMove(); }}>Move)} + {canMove && ( { this.handleMenuClose(); this.handleCopy(); }}>Copy)} + {canRename && this.hasSelectedFileOrFolder() && !protectedItem && ( { + this.handleMenuClose(); this.onRename(); + }}>Rename)} + {canReorder && ( + { this.handleMenuClose(); this.onReorder(); }}>Reorder)} + + + + + )}
{this.renderBreadcrumbs()}
+
- + { this.scrollContainerElementRef = element; }} + > + +
+ {this.state.showProgress && ( + <> +
+ +
+ + )} +
this.onMeasureRef(element)} + ref={(element) => { this.listContainerElementRef = element; this.onMeasureRef(element); }} style={{ flex: "1 1 100%", display: "flex", flexFlow: "row wrap", - justifyContent: "flex-start", alignContent: "flex-start", paddingLeft: 16, paddingBottom: 16 + position: "relative", justifyContent: "flex-start", alignContent: "flex-start", + paddingLeft: 16, paddingBottom: 16, paddingTop: 16, }}> { (this.state.columns !== 0) && // don't render until we have number of columns derived from layout. this.state.fileResult.files.map( (value: FileEntry, index: number) => { + + if (this.state.reordering && !value.metadata) { + return null; // don't render non-track files when reordering. + } + if (this.state.reordering && this.isFolderArtwork(value.pathname)) { + return null; + } let displayValue = value.displayName; if (displayValue === "") { displayValue = ""; } - let selected = value.pathname === this.state.selectedFile; + let dataPosition = ""; + let myTrackPosition = trackPosition; + + if (value.metadata && value.metadata.track) { + dataPosition = trackPosition.toString(); + ++trackPosition; + this.maxPosition = trackPosition; + } + let selected = false; + if (this.state.reordering) { + selected = false; + } else if (this.state.multiSelect) { + selected = this.state.selectedFiles.indexOf(value.pathname) !== -1; + } else { + selected = value.pathname === this.state.selectedFile; + } let selectBg = selected ? "rgba(0,0,0,0.15)" : "rgba(0,0,0,0.0)"; if (isDarkMode()) { selectBg = selected ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.0)"; @@ -719,105 +1351,245 @@ export default withStyles( if (selected) { scrollRef = (element) => { this.onScrollRef(element) }; } - + let dragOffset = 0; + let dragLeft = 0; + let dragState = this.state.dragState; + let zIndex: number | undefined = undefined; + let background: string | undefined = undefined; + if (dragState && value.metadata) { + if (myTrackPosition !== dragState.from) { + if (myTrackPosition >= dragState.to && myTrackPosition < dragState.from) { + dragOffset = dragState.height; + } else if (myTrackPosition > dragState.from && myTrackPosition <= dragState.to) { + dragOffset = -dragState.height; + } + } else { + dragOffset = dragState.dragElementDy + 5; + dragLeft = 5; + zIndex = 1000; + background = isDarkMode() ? "#555" : "#EEF"; + } + } + let dragButtonStyle: React.CSSProperties = { + width: columnWidth, flex: "0 0 auto", height: (value.metadata && !compactVertical) ? 64 : 48, + position: "relative", + top: dragOffset, + left: dragLeft, + zIndex: zIndex, + background: background, + }; return ( - this.onSelectValue(value)} + style={dragButtonStyle} + onClick={(e) => this.handleFileClick(e, value)} onDoubleClick={() => { this.onDoubleClickValue(value.pathname); }} + + onLongPressEnd={(e) => { + this.handleLongPressEnd(e, value); + } + } + onLongPressStart={(currentTarget, e) => { + return this.handleLongPressStart(currentTarget, e, value); + } + } + onLongPressMove={(e) => { + this.handleLongPressMove(e); + } + } + >
-
- {this.getIcon(value)} - {displayValue} -
- + {value.metadata ? + (!compactVertical ? + ( +
+ { + this.isFolderArtwork(value.pathname) ? ( + { e.preventDefault(); }} + src={this.getTrackThumbnail(value)} + style={{ width: 24, height: 24, margin: 20, borderRadius: 4 }} /> + ) : ( + { e.preventDefault(); }} + src={this.getTrackThumbnail(value)} + style={{ width: 48, height: 48, margin: 8, borderRadius: 4 }} /> + + ) + } +
+ + {getTrackTitle(value.pathname, value.metadata)} + + + {this.getAlbumTitle(value)} + + +
+
+ ) : ( +
+ { e.preventDefault(); }} + src={this.getTrackThumbnail(value)} + style={{ width: 24, height: 24, margin: 8, borderRadius: 4 }} /> +
+ + {this.getCompactTrackTitle(value)} + +
+
+ ) + + ) : ( +
+ {this.getIcon(value, this.isTracksDirectory() && !compactVertical)} + {displayValue} +
+ )} + + ); } ) }
- - - {this.state.windowWidth > 500 ? ( -
- this.handleDelete()} > - - + {(!this.state.reordering && !this.state.multiSelect) && ( + <> + + {this.state.windowWidth > 500 ? ( +
+ {isToobNamModelFile && ( +
+ + Download model files from TONE3000 + + { this.handleTone3000Help(e); }} aria-label="help" edge="end" color="inherit" style={{ opacity: 0.6, marginLeft: 8 }} + > + + +
- + )} - +
+ this.handleDelete()} > + + -
 
+ } + onClick={() => { this.setState({ openUploadFileDialog: true }) }} disabled={protectedDirectory} + > +
Upload
+
- - -
- ) : ( -
-
- this.handleDelete()} > - - +
 
- + + + disabled={(!canSelectFile) + } aria-label="select" + > + {okButtonText} + +
+
+ ) : ( +
+
+ this.handleDelete()} > + + -
 
-
-
-
 
+ - - -
-
- )} - + > +
Download
+ + +
 
+
+
+
 
+ + + +
+
+ )} +
+ + )} this.handleConfirmDelete()} onClose={() => { @@ -850,11 +1628,23 @@ export default withStyles( }} /> + { this.handleConfirmCopy(); }} + onClose={() => { + this.setState({ confirmCopyDialogState: null }); + }} + + /> + { this.state.newFolderDialogOpen && ( { this.setState({ newFolderDialogOpen: false }); this.onExecuteNewFolder(newName) }} onClose={() => { this.setState({ newFolderDialogOpen: false }); }} + title="New folder" acceptActionName="OK" /> ) @@ -862,6 +1652,7 @@ export default withStyles( { this.state.renameDialogOpen && ( { this.setState({ renameDialogOpen: false }); this.onExecuteRename(newName) }} onClose={() => { this.setState({ renameDialogOpen: false }); }} acceptActionName="OK" @@ -870,23 +1661,28 @@ export default withStyles( ) } { - this.state.moveDialogOpen && ( + (this.state.moveDialogOpen || this.state.copyDialogOpen) + && ( ( { this.setState({ moveDialogOpen: false }); }} + onClose={() => { this.setState({ moveDialogOpen: false, copyDialogOpen: false }); }} onOk={ (path) => { - this.setState({ moveDialogOpen: false }); + this.setState({ moveDialogOpen: false, copyDialogOpen: false }); this.setDefaultPath(path); - this.onExecuteMove(path); + if (this.state.copyDialogOpen) { + this.onExecuteCopy(path); + } else { + this.onExecuteMove(path); + } } } @@ -894,10 +1690,28 @@ export default withStyles( ) ) } + {/* {this.state.openTone3000Dialog && ( + this.setState({ openTone3000Dialog: false })} + /> + )} */} + {this.state.openTone3000Help && ( + this.setState({ openTone3000Help: false })} + /> + )} ); } openSelectedFile(): void { + if (this.state.multiSelect || this.state.reordering) { + return; + } + if (this.isFolderArtwork(this.state.selectedFile)) { + return; + } if (this.isDirectory(this.state.selectedFile)) { this.requestFiles(this.state.selectedFile); this.setState({ navDirectory: this.state.selectedFile }); @@ -915,10 +1729,53 @@ export default withStyles( return pathFileNameOnly(name); } } - private onMove(): void { + private handleMove(): void { this.setState({ moveDialogOpen: true }); } + private handleCopy(): void { + this.setState({ copyDialogOpen: true }); + } + private async onExecuteMoveMulti(newDirectory: string) { + let files = this.state.selectedFiles.slice(); + let newFileList = this.state.selectedFiles.slice(); + try { + for (let file of files) { + let fileName = pathFileName(file); + let oldFilePath = pathConcat(this.state.navDirectory, fileName); + let newFilePath = pathConcat(newDirectory, fileName); + + await this.model.renameFilePropertyFile(oldFilePath, newFilePath, this.props.fileProperty); + let index = newFileList.indexOf(file); + if (index !== -1) { + newFileList.splice(index, 1); + } + + } + } catch (e) { + this.model.showAlert(e); + } + // update state to refelect what we did. + if (!this.mounted) { + return; + } + if (newFileList.length === 0) { + this.setState({ + multiSelect: false, + selectedFiles: [] + }); + } else { + this.setState({ + multiSelect: true, + selectedFiles: newFileList, + }); + } + this.requestFiles(this.state.navDirectory); + } private onExecuteMove(newDirectory: string) { + if (this.state.multiSelect) { + this.onExecuteMoveMulti(newDirectory); + return; + } let fileName = pathFileName(this.state.selectedFile); let oldFilePath = pathConcat(this.state.navDirectory, fileName); let newFilePath = pathConcat(newDirectory, fileName); @@ -931,11 +1788,86 @@ export default withStyles( this.model.showAlert(e.toString()); }); + } + private handleConfirmCopy() { + if (this.state.confirmCopyDialogState) { + this.model.copyFilePropertyFile( + this.state.confirmCopyDialogState.oldFilePath, + this.state.confirmCopyDialogState.newFilePath, + this.props.fileProperty, true) + .then((filename) => { + this.requestFiles(this.state.navDirectory); + }) + .catch((e) => { + this.model.showAlert(e.toString()); + }); + this.setState({ confirmCopyDialogState: null }); + } + } + private async onExecuteCopyMulti(newDirectory: string) { + + let files = this.state.selectedFiles.slice(); + let fileProperty = this.props.fileProperty; + try { + for (let fileName of files) { + let oldFilePath = pathConcat(this.state.navDirectory, fileName); + let newFilePath = pathConcat(newDirectory, fileName); + + await this.model.copyFilePropertyFile(oldFilePath, newFilePath, fileProperty, true); + } + } catch (e) { + this.model.showAlert(e); + } + // update state to reflect what we did. + if (!this.mounted) { + return; + } + this.requestFiles(this.state.navDirectory); + } + private onExecuteCopy(newDirectory: string) { + this.setState({ copyDialogOpen: false }); + if (this.state.multiSelect) { + this.onExecuteCopyMulti(newDirectory); + return; + } + let fileName = pathFileName(this.state.selectedFile); + let oldFilePath = pathConcat(this.state.navDirectory, fileName); + let newFilePath = pathConcat(newDirectory, fileName); + + this.model.copyFilePropertyFile(oldFilePath, newFilePath, this.props.fileProperty, false) + .then((filename) => { + if (filename === "") { + // the file already exists. prompt for overrwrite. + this.setState({ + confirmCopyDialogState: { + fileName: fileName, + oldFilePath: oldFilePath, + newFilePath + } + }); + + } else { + this.requestFiles(this.state.navDirectory); + this.setState({ + selectedFile: filename, + selectedFileIsDirectory: this.isDirectory(filename), + selectedFileProtected: false + }); + this.requestScroll = true; + } + }) + .catch((e) => { + this.model.showAlert(e.toString()); + }); + } private onRename(): void { this.setState({ renameDialogOpen: true }); } + private onReorder(): void { + this.setState({ reordering: true }); + } private onExecuteRename(newName: string) { let newPath: string = ""; let oldPath: string = ""; diff --git a/vite/src/pipedal/FilePropertyDirectorySelectDialog.tsx b/vite/src/pipedal/FilePropertyDirectorySelectDialog.tsx index e923223..ad631d7 100644 --- a/vite/src/pipedal/FilePropertyDirectorySelectDialog.tsx +++ b/vite/src/pipedal/FilePropertyDirectorySelectDialog.tsx @@ -37,6 +37,7 @@ import { isDarkMode } from './DarkMode'; import IconButton from '@mui/material/IconButton'; + class DirectoryTree { name: string = ""; path: string = ""; @@ -279,7 +280,8 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC ) } - { this.onTreeClick(directoryTree); }}> + { this.onTreeClick(directoryTree); }} + >
{ @@ -329,8 +331,11 @@ export default class FilePropertyDirectorySelectDialog extends ResizeResponsiveC {this.props.dialogTitle} - -
+ +
{ this.renderTree() } diff --git a/vite/src/pipedal/FileUtils.tsx b/vite/src/pipedal/FileUtils.tsx new file mode 100644 index 0000000..a1bf1c1 --- /dev/null +++ b/vite/src/pipedal/FileUtils.tsx @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + + +export function pathExtension(path: string) { + let dotPos = path.lastIndexOf('.'); + if (dotPos === -1) return ""; + + let slashPos = path.lastIndexOf('/'); + if (slashPos !== -1) { + if (dotPos <= slashPos + 1) return ""; + } + return path.substring(dotPos); // include the '.'. + +} +export function pathParentDirectory(path: string) { + let npos = path.lastIndexOf('/'); + if (npos === -1) return ""; + return path.substring(0, npos); +} +export function pathConcat(left: string, right: string) { + if (left === "") return right; + if (right === "") return left; + if (left.endsWith('/')) { + left = left.substring(0, left.length - 1); + } + if (right.startsWith("/")) { + right = right.substring(1); + } + return left + "/" + right; +} +export function pathFileNameOnly(path: string): string { + if (path === "..") return path; + let slashPos = path.lastIndexOf('/'); + if (slashPos < 0) { + slashPos = 0; + } else { + ++slashPos; + } + let extPos = path.lastIndexOf('.'); + if (extPos < 0 || extPos < slashPos) { + extPos = path.length; + } + + return path.substring(slashPos, extPos); +} + +export function pathFileName(path: string): string { + if (path === "..") return path; + let slashPos = path.lastIndexOf('/'); + if (slashPos < 0) { + slashPos = 0; + } else { + ++slashPos; + } + return path.substring(slashPos); +} + + + diff --git a/vite/src/pipedal/GxTunerView.tsx b/vite/src/pipedal/GxTunerView.tsx index c4c9b7e..302860a 100644 --- a/vite/src/pipedal/GxTunerView.tsx +++ b/vite/src/pipedal/GxTunerView.tsx @@ -77,7 +77,11 @@ const GxTunerView = } throw new Error("GxTuner: Control '" + key + "' not found."); } - ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] + fullScreen() { + return false; + } + + modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] { let refFreqIndex = this.getControlIndex("REFFREQ"); let thresholdIndex = this.getControlIndex("THRESHOLD"); diff --git a/vite/src/pipedal/IconButtonEx.tsx b/vite/src/pipedal/IconButtonEx.tsx new file mode 100644 index 0000000..6248d4e --- /dev/null +++ b/vite/src/pipedal/IconButtonEx.tsx @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +import IconButton, {IconButtonProps} from '@mui/material/IconButton'; +import ToolTipEx from './ToolTipEx'; +import Typography from "@mui/material/Typography"; + +interface IconButtonExProps extends IconButtonProps { + tooltip: string; + style?: React.CSSProperties; +}; + +function IconButtonEx(props: IconButtonExProps) { + const { tooltip,style, ...extra } = props; + + return ( + {tooltip || extra['aria-label'] } + ) + } + > + + + ); +} + +export default IconButtonEx; \ No newline at end of file diff --git a/vite/src/pipedal/LoadPluginDialog.tsx b/vite/src/pipedal/LoadPluginDialog.tsx index e985b85..6d2076c 100644 --- a/vite/src/pipedal/LoadPluginDialog.tsx +++ b/vite/src/pipedal/LoadPluginDialog.tsx @@ -34,7 +34,7 @@ import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import SelectHoverBackground from './SelectHoverBackground'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import PluginClass from './PluginClass' import ResizeResponsiveComponent from './ResizeResponsiveComponent'; import SearchControl from './SearchControl'; @@ -722,9 +722,9 @@ export const LoadPluginDialog =
- { this.cancel(); }} style={{ flex: "0 0 auto" }} > + { this.cancel(); }} style={{ flex: "0 0 auto" }} > - +
- { this.onClearFilter(); }}> + { this.onClearFilter(); }}> {this.state.filterType === PluginType.Plugin ? ( ) : ( )} - +
@@ -861,11 +861,11 @@ export const LoadPluginDialog = color: isFavorite ? "#F80" : "#CCC", display: (this.state.selected_uri !== "" ? "block" : "none"), position: "relative", top: -2 }}> - { this.setFavorite(this.state.selected_uri, !isFavorite); }} > - +
@@ -886,11 +886,11 @@ export const LoadPluginDialog = }}> { this.state.selected_uri !== "uri://two-play/pipedal/pedalboard#Empty" && ( - { this.setFavorite(this.state.selected_uri, !isFavorite); }} > - + ) }
diff --git a/vite/src/pipedal/LoopDialog.tsx b/vite/src/pipedal/LoopDialog.tsx new file mode 100644 index 0000000..c32e5ba --- /dev/null +++ b/vite/src/pipedal/LoopDialog.tsx @@ -0,0 +1,649 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +import React, { SyntheticEvent, useEffect } from "react"; +import FormGroup from "@mui/material/FormGroup"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import DialogContent from "@mui/material/DialogContent"; +import TextField, { StandardTextFieldProps } from "@mui/material/TextField"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogEx from "./DialogEx"; +import Toolbar from "@mui/material/Toolbar"; +import IconButtonEx from "./IconButtonEx"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import Typography from "@mui/material/Typography"; +import Slider, { SliderProps } from "@mui/material/Slider"; +import Checkbox from "@mui/material/Checkbox"; +import TimebaseSelectorDialog from "./TimebaseselectorDialog"; +import Timebase, { TimebaseUnits } from "./Timebase"; +import useWindowSize from "./UseWindowSize"; +import Button from "@mui/material/Button"; +import PlayArrow from "@mui/icons-material/PlayArrow"; +import StopIcon from '@mui/icons-material/Stop'; +import { LoopParameters } from './Timebase'; + + +export interface LoopDialogProps { + isOpen: boolean; + onClose: () => void; + onSetLoop: (value: LoopParameters) => void; + value: LoopParameters; + playing: boolean; + onPreview: () => void; + onCancelPlaying: () => void; + duration: number; + timebase: Timebase; + onTimebaseChange: (timebase: Timebase) => void; // Optional callback for timebase changes + sampleRate: number; + +}; + + +interface TimeEditProps extends StandardTextFieldProps { + timebase: Timebase; + sampleRate: number; + value: number; + onValueChange?: (e: React.ChangeEvent, value: number) => void; + onBlur: () => void; + max: number; +}; + + + +function parseTime(timebase: Timebase, sampleRate: number, duration: number, timeString: string): number { + + switch (timebase.units) { + case TimebaseUnits.Samples: + { + const samples = parseInt(timeString, 10); + if (isNaN(samples)) { + return samples; + } + let result = samples / sampleRate; // Convert samples to seconds + if (result < 0) { + result = 0; + } + if (result > duration) { + result = duration; + } + return result; + + } + case TimebaseUnits.Seconds: + { + const parts = timeString.split(':'); + let hours = 0; + let minutes = 0; + let seconds = 0; + + if (parts.length === 1) { + hours = 0; + minutes = 0; + seconds = parseFloat(parts[0]); + if (isNaN(seconds)) { + throw new Error("Invalid seconds format. Use 'mm:ss' or 'mm:ss.mmmm'."); + } + } else if (parts.length === 2) { + hours = 0; + minutes = parseInt(parts[0], 10); + seconds = parseFloat(parts[1]); + if (isNaN(minutes) || isNaN(seconds)) { + throw new Error("Invalid minutes or seconds format. Use 'mm:ss' or 'mm:ss.mmmm'."); + } + } else if (parts.length === 3) { + hours = parseInt(parts[0]); + minutes = parseInt(parts[1], 10); + seconds = parseFloat(parts[2]); + if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) { + throw new Error("Invalid hours, minutes or seconds format. Use 'hh:mm:ss' or 'mm:ss.mmmm'."); + } + } else { + throw new Error("Invalid time format. Use 'hh:mm:ss' or 'mm:ss.mmmm'."); + } + + let result = hours * 60 * 60 + minutes * 60 + seconds; // Convert total time to seconds + if (result < 0) { + result = 0; + } + if (result > duration) { + result = duration; + } + return result; + + } + case TimebaseUnits.Beats: + { + const parts = timeString.split(':'); + let bar = 0; + let beat = 0; + + + + + if (parts.length === 1) { + bar = 1; + beat = parseFloat(parts[0]); + if (isNaN(beat)) { + throw new Error("Invalid seconds format. Use 'bar:beat' or 'bar:beat.fraction'."); + } + if (beat < 1 || beat >= timebase.timeSignature.numerator + 1) { + throw new Error(`Beat number must be between 1 and ${timebase.timeSignature.numerator + 1}.`); + } + } else if (parts.length === 2) { + bar = parseInt(parts[0], 10); + if (bar < 1) { + throw new Error("Bar number must be 1 or greater."); + } + beat = parseFloat(parts[1]); + if (isNaN(bar) || isNaN(beat)) { + throw new Error("Invalid bar or beat format. Use 'bar:beat' or 'bar:beat.fraction'."); + } + if (beat < 1 || beat >= timebase.timeSignature.numerator + 1) { + throw new Error(`Beat number must be between 1 and ${timebase.timeSignature.numerator + 1}.`); + } + } else { + throw new Error("Invalid time format. Use 'hh:mm:ss' or 'mm:ss.mmmm'."); + } + const actualBeat = (bar - 1) * timebase.timeSignature.numerator + (beat - 1); // Convert bar and beat to actual beat number + let result = actualBeat * 60 / timebase.tempo; // Convert beats to seconds based on tempo + if (result < 0) { + result = 0; + } + if (result > duration) { + result = duration; + } + return result; + } + } +} + +export function formatTime(timebase: Timebase, sampleRate: number, seconds: number): string { + switch (timebase.units) { + case TimebaseUnits.Samples: + { + return Math.round(seconds * sampleRate).toString(); + } + break; + case TimebaseUnits.Seconds: + { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const mmillis = Math.floor((seconds % 1) * 10000); + if (hours == 0) { + return `${minutes}:${secs.toString().padStart(2, '0')}.${mmillis.toString().padStart(4, '0')}`; + } else { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${mmillis.toString().padStart(4, '0')}`; + } + break; + } + case TimebaseUnits.Beats: + { + let beats = timebase.tempo / 60.0 * seconds; + const bars = Math.floor(beats / timebase.timeSignature.numerator); + const beat = (beats - bars * timebase.timeSignature.numerator); + return `${bars + 1}:${(beat + 1).toFixed(4)}`; + } + break; + default: + throw new Error("Unsupported timebase units"); + } +} + + + + +interface SliderWithPreviewProps extends SliderProps { + style?: React.CSSProperties; + value: number | number[]; + onChange: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void; + onPreview?: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void; + onCommitPreviewValue?: (event: Event | SyntheticEvent, value: number | number[], thumb?: number) => void; +} +interface PreviewArguments { + newValue: number | number[]; + thumb?: number; +}; + +function SliderWithPreview(props: SliderWithPreviewProps) { + const { value, onChange, onPreview, onCommitPreviewValue, style, ...extra } = props; + const [previewArguments, setPreviewArguments] = + React.useState(null); + + const [pointerDown, setPointerDown] = React.useState(false); + + + // code to handle a bug in crhome relating to mouseup events, that causes zero values to not be set. + + const handleMouseUp = (e: MouseEvent) => { + if (pointerDown && e.button === 0) { // Left mouse button + setPointerDown(false); + if (previewArguments) { + if (onCommitPreviewValue) { + onCommitPreviewValue(e as Event, previewArguments.newValue, previewArguments.thumb); + } + setPreviewArguments(null); + } + } + }; + + useEffect(() => { + document.addEventListener('mouseup', handleMouseUp); + + // Cleanup: Remove the event listener when the component unmounts + return () => { + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); // Re-run effect when tempValue changes to ensure latest value is committed + + + return ( + { + if (pointerDown) { + if (props.onPreview) { + props.onPreview(event, newValue, thumb); + } + setPreviewArguments({ newValue, thumb }); + } else { + if (props.onChange) { + props.onChange(event, newValue, thumb); + } + return; + } + }} + + onChangeCommitted={(event, newValue) => { + if (pointerDown) { + if (props.onPreview) { + props.onPreview(event as SyntheticEvent, newValue, 0); + } + setPreviewArguments({ newValue: newValue, thumb: 0 }); + } else { + if (props.onChange) { + props.onChange(event, newValue, 0); + } + return; + } + }} + onMouseDown={(e) => { + if (e.button == 0) { + setPointerDown(true); + } + }} + onMouseUp={(e) => { + if (e.button == 0) { // Left mouse button + setPointerDown(false); + if (previewArguments) { + if (onCommitPreviewValue) { + onCommitPreviewValue(e, previewArguments.newValue, previewArguments.thumb); + } + setPreviewArguments(null); + } + } + + }} + onMouseMove={(e) => { + if (pointerDown && previewArguments) { + if (props.onPreview) { + if (e.clientX < 0) // bug in crhrome + { + props.onPreview(e, previewArguments.newValue, previewArguments.thumb); + + } + } + } + } + } + /> + ); +} + + +function TimeEdit(props: TimeEditProps) { + let { value, onValueChange, onBlur, timebase, sampleRate, max, ...extra } = props; + const [text, setText] = React.useState(formatTime(timebase, props.sampleRate, props.value)); + const [error, setError] = React.useState(false); + const [focus, setFocus] = React.useState(false); + // slice props. + + React.useEffect(() => { + if (!focus) { + setText(formatTime(timebase, sampleRate, props.value)); + } + }, + [props.value]); + + React.useEffect(() => { + if (!focus) { + setText(formatTime(props.timebase, sampleRate, props.value)); + } + }, [props.timebase, sampleRate]); + + + + return ( + { + setText(formatTime(props.timebase, sampleRate, props.value)); + setFocus(false); + onBlur(); + }} + onChange={(e) => { + try { + setText(e.target.value); + let val = parseTime(props.timebase, sampleRate, max, e.target.value); + if (!isNaN(val)) { + if (val > max) { + val = max; + } + if (props.onValueChange) { + props.onValueChange(e, val); + } + setError(false); + } else { + setError(true); + } + } catch (err) { + setError(true); + } + }} + onFocus={(e) => { + setFocus(true); + setText(formatTime(props.timebase, sampleRate, props.value)); + setError(false); + e.target.select(); + } + } + inputProps={{ + style: { textAlign: "center" }, + autoComplete: "off", + spellCheck: "false", + autoCorrect: "off", + autoCapitalize: "off", + }} + /> + + ); +} + + + +export default function LoopDialog(props: LoopDialogProps) { + const [start, setStart] = React.useState(props.value.start); + const [loopEnable, setLoopEnable] = React.useState(props.value.loopEnable); + const [loopStart, setLoopStart] = React.useState(props.value.loopStart); + const [loopEnd, setLoopEnd] = React.useState(props.value.loopEnd); + + + const [windowSize] = useWindowSize(); + const fullScreen = windowSize.height < 500; + + function cancelPlaying() { + props.onCancelPlaying(); + } + function commitResults() { + let tLoopStart = loopStart; + let tLoopEnd = loopEnd; + if (tLoopEnd < tLoopStart) { + let tmp = tLoopStart; + tLoopStart = tLoopEnd; + tLoopEnd = tmp; + setLoopStart(tLoopStart); + setLoopEnd(tLoopEnd); + } + if (props.onSetLoop) { + props.onSetLoop({ start: start, loopEnable: loopEnable, loopStart: tLoopStart, loopEnd: tLoopEnd }); + } + } + React.useEffect(() => { + setStart(props.value.start); + setLoopEnable(props.value.loopEnable); + setLoopStart(props.value.loopStart); + setLoopEnd(props.value.loopEnd); + }, [props.value]); + + return ( + { + commitResults(); + props.onClose(); + }} + + maxWidth="xl" + open={props.isOpen} + onEnterKey={() => { + }} + fullScreen={fullScreen} + sx={{ + "& .MuiDialog-container": { + "& .MuiPaper-root": { + width: "100%", + maxWidth: fullScreen ? undefined : "500px", // Set your width here + }, + }, + }} + > + + + { + commitResults(); + props.onClose(); + }} + > + + + + Loop + +
+ + { + props.onTimebaseChange(timebase); // Handle timebase change if needed + }} + timebase={props.timebase} // Pass the current timebase if needed + /> + + + +
+ + Start + + { + let val = Array.isArray(v) ? v[0] : v + setStart(val); + }} + onPreview={(e, v, thumb) => { + let val = Array.isArray(v) ? v[0] : v + + setStart(val); + }} + onCommitPreviewValue={(e, v, thumb) => { + let val = Array.isArray(v) ? v[0] : v + + setStart(val); + cancelPlaying(); + + }} + valueLabelFormat={(v) => formatTime(props.timebase, props.sampleRate, v)} + /> + + { + if (start > props.duration) { + setStart(props.duration); + } + }} + onValueChange={(e, val) => { + setStart(val); + cancelPlaying(); + } + } + style={{ width: 120, alignSelf: "center", textAlign: "center" }} /> + + { + setLoopEnable(checked); + cancelPlaying(); + }} + + />} label="Enable loop" /> + + + { + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end"); + + setLoopStart(v[0]); + setLoopEnd(v[1]); + }} + onPreview={(e, v, thumb) => { + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end"); + + setLoopStart(v[0]); + setLoopEnd(v[1]); + }} + onCommitPreviewValue={(e, v, thumb) => { + if (!Array.isArray(v)) throw new Error("Expected array for loop start and end"); + + setLoopStart(v[0]); + setLoopEnd(v[1]); + cancelPlaying(); + + }} + + /> +
+ { + }} + onValueChange={(e, val) => { + setLoopStart(val); + cancelPlaying(); + + + }} + /> + { + }} + onValueChange={(e, val) => { + setLoopEnd(val); + cancelPlaying(); + + }} + /> +
+ +
+ +
+ + ) +} + diff --git a/vite/src/pipedal/MainPage.tsx b/vite/src/pipedal/MainPage.tsx index 6a41c96..85ca9e8 100644 --- a/vite/src/pipedal/MainPage.tsx +++ b/vite/src/pipedal/MainPage.tsx @@ -19,14 +19,18 @@ import { SyntheticEvent } from 'react'; import { Theme } from '@mui/material/styles'; -import WithStyles, {withTheme} from './WithStyles'; +import WithStyles, { withTheme } from './WithStyles'; import { withStyles } from "tss-react/mui"; +import IconButtonEx from './IconButtonEx'; +import ButtonEx from './ButtonEx'; +import ButtonBase from '@mui/material/ButtonBase'; +import RenameDialog from './RenameDialog'; +import ToolTipEx from './ToolTipEx'; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import { Pedalboard, PedalboardItem, PedalboardSplitItem, SplitType } from './Pedalboard'; -import Button from '@mui/material/Button'; import InputIcon from '@mui/icons-material/Input'; import LoadPluginDialog from './LoadPluginDialog'; import Switch from '@mui/material/Switch'; @@ -34,7 +38,6 @@ import Typography from '@mui/material/Typography'; import PedalboardView from './PedalboardView'; import { PiPedalStateError } from './PiPedalError'; -import IconButton from '@mui/material/IconButton'; import AddIcon from '@mui/icons-material/Add'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; @@ -45,16 +48,16 @@ import PluginInfoDialog from './PluginInfoDialog'; import { GetControlView } from './ControlViewFactory'; import MidiBindingsDialog from './MidiBindingsDialog'; import PluginPresetSelector from './PluginPresetSelector'; -import OldDeleteIcon from "./svg/old_delete_outline_24dp.svg?react"; -import MidiIcon from "./svg/ic_midi.svg?react"; +import OldDeleteIcon from "./svg/old_delete_outline_24dp.svg?react"; +import MidiIcon from "./svg/ic_midi.svg?react"; import { isDarkMode } from './DarkMode'; -import Snapshot0Icon from "./svg/snapshot_0.svg?react"; -import Snapshot1Icon from "./svg/snapshot_1.svg?react"; -import Snapshot2Icon from "./svg/snapshot_2.svg?react"; -import Snapshot3Icon from "./svg/snapshot_3.svg?react"; -import Snapshot4Icon from "./svg/snapshot_4.svg?react"; -import Snapshot5Icon from "./svg/snapshot_5.svg?react"; -import Snapshot6Icon from "./svg/snapshot_6.svg?react"; +import Snapshot0Icon from "./svg/snapshot_0.svg?react"; +import Snapshot1Icon from "./svg/snapshot_1.svg?react"; +import Snapshot2Icon from "./svg/snapshot_2.svg?react"; +import Snapshot3Icon from "./svg/snapshot_3.svg?react"; +import Snapshot4Icon from "./svg/snapshot_4.svg?react"; +import Snapshot5Icon from "./svg/snapshot_5.svg?react"; +import Snapshot6Icon from "./svg/snapshot_6.svg?react"; import SnapshotDialog from './SnapshotDialog'; import { css } from '@emotion/react'; @@ -68,39 +71,40 @@ const HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK = 500; // eslint-disable-next-line @typescript-eslint/no-unused-vars // const HORIZONTAL_LAYOUT_MQ = "@media (max-height: " + HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK + "px)"; -const styles = ({ palette }: Theme) => { return { - frame: css({ - position: "absolute", display: "flex", flexDirection: "column", flexWrap: "nowrap", - justifyContent: "flex-start", left: "0px", top: "0px", bottom: "0px", right: "0px", overflow: "hidden" - }), - pedalboardScroll: css({ - position: "relative", width: "100%", - flex: "0 0 auto", overflow: "auto", maxHeight: 220 - }), - pedalboardScrollSmall: css({ - position: "relative", width: "100%", - flex: "1 1 1px", overflow: "auto" - }), - separator: css({ - width: "100%", height: "1px", background: "#888", opacity: "0.5", - flex: "0 0 1px" - }), +const styles = ({ palette }: Theme) => { + return { + frame: css({ + position: "absolute", display: "flex", flexDirection: "column", flexWrap: "nowrap", + justifyContent: "flex-start", left: "0px", top: "0px", bottom: "0px", right: "0px", overflow: "hidden" + }), + pedalboardScroll: css({ + position: "relative", width: "100%", + flex: "0 0 auto", overflow: "auto", maxHeight: 220 + }), + pedalboardScrollSmall: css({ + position: "relative", width: "100%", + flex: "1 1 1px", overflow: "auto" + }), + separator: css({ + width: "100%", height: "1px", background: "#888", opacity: "0.5", + flex: "0 0 1px" + }), - controlToolBar: css({ - flex: "0 0 auto", width: "100%", height: 48 - }), - splitControlBar: css({ - flex: "0 0 64px", width: "100%", paddingLeft: 24, paddingRight: 16, paddingBottom: 16 - }), - controlContent: css({ - flex: "1 1 auto", width: "100%", overflowY: "auto", minHeight: 240 - }), - controlContentSmall: css({ - flex: "0 0 162px", width: "100%", height: 162, overflowY: "hidden", - }), - title: css({ fontSize: "1.1em", fontWeight: 700, marginRight: 8, textOverflow: "ellipsis", whiteSpace: "nowrap", opacity: 0.75 }), - author: css({ fontWeight: 500, marginRight: 8, textOverflow: "ellipsis", whiteSpace: "nowrap", opacity: 0.75 }) -} + controlToolBar: css({ + flex: "0 0 auto", width: "100%", height: 48 + }), + splitControlBar: css({ + flex: "0 0 64px", width: "100%", paddingLeft: 24, paddingRight: 16, paddingBottom: 16 + }), + controlContent: css({ + flex: "1 1 auto", width: "100%", overflowY: "hidden", minHeight: 300 + }), + controlContentSmall: css({ + flex: "0 0 162px", width: "100%", height: 162, overflowY: "hidden", + }), + title: css({ fontSize: "1.1rem", fontWeight: 700, marginRight: 8, textOverflow: "ellipsis", whiteSpace: "nowrap", opacity: 0.75 }), + author: css({ fontWeight: 500, fontSize: "0.8rem", marginRight: 8, textOverflow: "ellipsis", whiteSpace: "nowrap", opacity: 0.75 }) + } }; @@ -123,525 +127,574 @@ interface MainState { horizontalScrollLayout: boolean; showMidiBindingsDialog: boolean; screenHeight: number; + displayNameDialogOpen: boolean; + } export const MainPage = withTheme( - withStyles( - class extends ResizeResponsiveComponent { - model: PiPedalModel; + withStyles( + class extends ResizeResponsiveComponent { + model: PiPedalModel; - getSplitToolbar() { - return this.windowSize.width < SPLIT_CONTROLBAR_THRESHHOLD; - } - getDisplayAuthor() - { - if (this.getSplitToolbar()) - { - return this.windowSize.width >= DISPLAY_AUTHOR_SPLIT_THRESHOLD; - } else { - return this.windowSize.width >= DISPLAY_AUTHOR_THRESHHOLD; + getSplitToolbar() { + return this.windowSize.width < SPLIT_CONTROLBAR_THRESHHOLD; } - } - constructor(props: MainProps) { - super(props); - this.model = PiPedalModelFactory.getInstance(); - let pedalboard = this.model.pedalboard.get(); - let selectedPedal = pedalboard.getFirstSelectableItem(); - - this.state = { - selectedPedal: selectedPedal, - selectedSnapshot: -1, - loadDialogOpen: false, - snapshotDialogOpen: false, - pedalboard: pedalboard, - addMenuAnchorEl: null, - splitControlBar: this.getSplitToolbar(), - displayAuthor: this.getDisplayAuthor(), - horizontalScrollLayout: this.windowSize.height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK, - showMidiBindingsDialog: false, - screenHeight: this.windowSize.height - - - }; - this.onSelectionChanged = this.onSelectionChanged.bind(this); - this.onPedalDoubleClick = this.onPedalDoubleClick.bind(this); - this.onLoadClick = this.onLoadClick.bind(this); - this.onLoadOk = this.onLoadOk.bind(this); - this.onLoadCancel = this.onLoadCancel.bind(this); - this.onPedalboardChanged = this.onPedalboardChanged.bind(this); - this.onSelectedSnapshotChanged = this.onSelectedSnapshotChanged.bind(this); - this.handleEnableCurrentItemChanged = this.handleEnableCurrentItemChanged.bind(this); - } - - onInsertPedal(instanceId: number) { - this.setAddMenuAnchorEl(null); - let newId = this.model.addPedalboardItem(instanceId, false); - this.setSelection(newId); - } - onAppendPedal(instanceId: number) { - this.setAddMenuAnchorEl(null); - let newId = this.model.addPedalboardItem(instanceId, true); - - this.setSelection(newId); - - } - onInsertSplit(instanceId: number) { - this.setAddMenuAnchorEl(null); - let newId = this.model.addPedalboardSplitItem(instanceId, false); - this.setSelection(newId); - - } - onAppendSplit(instanceId: number) { - this.setAddMenuAnchorEl(null); - let newId = this.model.addPedalboardSplitItem(instanceId, true); - this.setSelection(newId); - - } - setAddMenuAnchorEl(value: HTMLElement | null) { - this.setState({ addMenuAnchorEl: value }); - } - onAddClick(e: SyntheticEvent) { - this.setAddMenuAnchorEl(e.currentTarget as HTMLElement); - } - - handleMidiBindingsDialogClose() { - this.setState({ showMidiBindingsDialog: false }) - } - handleAddClose(): void { - this.setAddMenuAnchorEl(null); - } - - handleMidiConfiguration(instanceId: number): void { - this.setState({ showMidiBindingsDialog: true }); - } - handleEnableCurrentItemChanged(event: any): void { - let newValue = event.target.checked; - let item = this.getSelectedPedalboardItem(); - if (item != null) { - this.model.setPedalboardItemEnabled(item.getInstanceId(), newValue); - - } - } - handleSelectPluginPreset(instanceId: number, presetInstanceId: number) { - this.model.loadPluginPreset(instanceId, presetInstanceId); - } - onPedalboardChanged(value: Pedalboard) { - let selectedItem = -1; - if (this.state.selectedPedal === Pedalboard.START_CONTROL || this.state.selectedPedal === Pedalboard.END_CONTROL) { - selectedItem = this.state.selectedPedal; - } else if (value.hasItem(this.state.selectedPedal)) { - selectedItem = this.state.selectedPedal; - } else { - selectedItem = value.getFirstSelectableItem(); - } - this.setState({ - pedalboard: value, - selectedPedal: selectedItem, - selectedSnapshot: value.selectedSnapshot - }); - } - onSelectedSnapshotChanged(selectedSnapshot: number) { - this.setState({ - selectedSnapshot: selectedSnapshot - }); - - } - onDeletePedal(instanceId: number): void { - let result = this.model.deletePedalboardPedal(instanceId); - if (result != null) { - this.setState({ selectedPedal: result }); - } - } - - componentDidMount() { - super.componentDidMount(); - this.model.pedalboard.addOnChangedHandler(this.onPedalboardChanged); - this.model.selectedSnapshot.addOnChangedHandler(this.onSelectedSnapshotChanged); - } - componentWillUnmount() { - this.model.selectedSnapshot.removeOnChangedHandler(this.onSelectedSnapshotChanged); - this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged); - super.componentWillUnmount(); - } - updateResponsive() { - this.setState({ - splitControlBar: this.getSplitToolbar(), - displayAuthor: this.getDisplayAuthor(), - horizontalScrollLayout: this.windowSize.height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK, - screenHeight: this.windowSize.height - }); - } - onWindowSizeChanged(width: number, height: number): void { - super.onWindowSizeChanged(width, height); - this.updateResponsive(); - } - - - setSelection(selectedId_: number): void { - this.setState({ selectedPedal: selectedId_ }); - } - - onSelectionChanged(selectedId: number): void { - this.setSelection(selectedId); - } - onPedalDoubleClick(selectedId: number): void { - this.setSelection(selectedId); - let item = this.getPedalboardItem(selectedId); - if (item != null) { - if (item.isStart() || item.isEnd()) - { - // do nothing. - } else if (item.isSplit()) { - let split = item as PedalboardSplitItem; - if (split.getSplitType() === SplitType.Ab) { - let cv = split.getToggleAbControlValue(); - if (split.instanceId === undefined) throw new PiPedalStateError("Split without valid id."); - this.model.setPedalboardControl(split.instanceId, cv.key, cv.value); - } + getDisplayAuthor() { + if (this.getSplitToolbar()) { + return this.windowSize.width >= DISPLAY_AUTHOR_SPLIT_THRESHOLD; } else { - this.setState({ loadDialogOpen: true }); + return this.windowSize.width >= DISPLAY_AUTHOR_THRESHHOLD; } } - } - onLoadCancel(): void { - this.setState({ loadDialogOpen: false }); - } - onLoadOk(selectedUri: string): void { - this.setState({ loadDialogOpen: false }); - let itemId = this.state.selectedPedal; - let newSelectedItem = this.model.loadPedalboardPlugin(itemId, selectedUri); - this.setState({ selectedPedal: newSelectedItem }); - } - onSnapshotDialogOk(): void { - this.setState({ snapshotDialogOpen: false }); - } + constructor(props: MainProps) { + super(props); + this.model = PiPedalModelFactory.getInstance(); + let pedalboard = this.model.pedalboard.get(); + let selectedPedal = pedalboard.getFirstSelectableItem(); - onLoadClick(e: SyntheticEvent) { - this.setState({ loadDialogOpen: true }); - e.preventDefault(); - e.stopPropagation(); - } - onSnapshotClick() { - this.setState({ snapshotDialogOpen: true }); - } + this.state = { + selectedPedal: selectedPedal, + selectedSnapshot: -1, + loadDialogOpen: false, + snapshotDialogOpen: false, + displayNameDialogOpen: false, + pedalboard: pedalboard, + addMenuAnchorEl: null, + splitControlBar: this.getSplitToolbar(), + displayAuthor: this.getDisplayAuthor(), + horizontalScrollLayout: this.windowSize.height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK, + showMidiBindingsDialog: false, + screenHeight: this.windowSize.height - getPedalboardItem(selectedId?: number): PedalboardItem | null { - if (selectedId === undefined) return null; - let pedalboard = this.model.pedalboard.get(); - if (!pedalboard) return null; - - if (selectedId === Pedalboard.START_CONTROL) // synthetic input volume item. - { - return pedalboard.makeStartItem(); - } else if (selectedId === Pedalboard.END_CONTROL) // synthetic output volume. - { - return pedalboard.makeEndItem(); + }; + this.onSelectionChanged = this.onSelectionChanged.bind(this); + this.onPedalDoubleClick = this.onPedalDoubleClick.bind(this); + this.onLoadClick = this.onLoadClick.bind(this); + this.onLoadOk = this.onLoadOk.bind(this); + this.onLoadCancel = this.onLoadCancel.bind(this); + this.onPedalboardChanged = this.onPedalboardChanged.bind(this); + this.onSelectedSnapshotChanged = this.onSelectedSnapshotChanged.bind(this); + this.handleEnableCurrentItemChanged = this.handleEnableCurrentItemChanged.bind(this); } - let it = pedalboard.itemsGenerator(); - if (!selectedId) return null; - while (true) { - let v = it.next(); - if (v.done) break; - let item = v.value; - if (item.instanceId === selectedId) { - return item; - } + handleEditPluginDisplayName() { + this.setState({ displayNameDialogOpen: true }); + } + onInsertPedal(instanceId: number) { + this.setAddMenuAnchorEl(null); + let newId = this.model.addPedalboardItem(instanceId, false); + this.setSelection(newId); + } + onAppendPedal(instanceId: number) { + this.setAddMenuAnchorEl(null); + let newId = this.model.addPedalboardItem(instanceId, true); + + this.setSelection(newId); } - return null; + onInsertSplit(instanceId: number) { + this.setAddMenuAnchorEl(null); + let newId = this.model.addPedalboardSplitItem(instanceId, false); + this.setSelection(newId); - } + } + onAppendSplit(instanceId: number) { + this.setAddMenuAnchorEl(null); + let newId = this.model.addPedalboardSplitItem(instanceId, true); + this.setSelection(newId); + + } + setAddMenuAnchorEl(value: HTMLElement | null) { + this.setState({ addMenuAnchorEl: value }); + } + onAddClick(e: SyntheticEvent) { + this.setAddMenuAnchorEl(e.currentTarget as HTMLElement); + } + + handleMidiBindingsDialogClose() { + this.setState({ showMidiBindingsDialog: false }) + } + handleAddClose(): void { + this.setAddMenuAnchorEl(null); + } + + handleMidiConfiguration(instanceId: number): void { + this.setState({ showMidiBindingsDialog: true }); + } + handleEnableCurrentItemChanged(event: any): void { + let newValue = event.target.checked; + let item = this.getSelectedPedalboardItem(); + if (item != null) { + this.model.setPedalboardItemEnabled(item.getInstanceId(), newValue); - onPedalboardPropertyChanged(instanceId: number, key: string, value: number) { - this.model.setPedalboardControl(instanceId, key, value); - } - getSelectedPedalboardItem(): PedalboardItem | null { - return this.getPedalboardItem(this.state.selectedPedal); - } - getSelectedUri(): string { - let pedalboardItem = this.getSelectedPedalboardItem(); - if (pedalboardItem === null) return ""; - return pedalboardItem.uri; - } - titleBar(pedalboardItem: PedalboardItem | null): React.ReactNode { - let title = ""; - let author = ""; - let infoPluginUri = ""; - let presetsUri = ""; - let missing = false; - if (pedalboardItem) { - if (pedalboardItem.isEmpty()) { - title = ""; - } else if (pedalboardItem.isSplit()) { - title = "Split"; - } else if (pedalboardItem.isSyntheticItem()) { - title = pedalboardItem.pluginName ?? "#error"; - author = ""; - presetsUri = ""; - infoPluginUri = ""; - } - else { - let uiPlugin = this.model.getUiPlugin(pedalboardItem.uri); - if (!uiPlugin) { - missing = true; - title = pedalboardItem?.pluginName ?? "Missing plugin"; - } else { - title = uiPlugin.name; - author = uiPlugin.author_name; - presetsUri = uiPlugin.uri; - // if (uiPlugin.description.length > 20) { - // } - infoPluginUri = uiPlugin.uri; - } } } - const classes = withStyles.getClasses(this.props); - if (missing) { - return ( -
-
- - {title} - -
-
- ); - - } else { - return ( -
-
- {title} - {this.state.displayAuthor&&( - {author} - )} -
-
- -
-
- -
-
- ); + handleSelectPluginPreset(instanceId: number, presetInstanceId: number) { + this.model.loadPluginPreset(instanceId, presetInstanceId); } - } - - snapshotIcon(theme: Theme, snapshotNumber: number) { - switch (snapshotNumber + 1) { - case 0: - default: - return (); - case 1: - return (); - case 2: - return (); - case 3: - return (); - case 4: - return (); - case 5: - return (); - case 6: - return (); - } - } - - render() { - const classes = withStyles.getClasses(this.props); - let pedalboard = this.model.pedalboard.get(); - let pedalboardItem = this.getSelectedPedalboardItem(); - let uiPlugin = null; - let bypassVisible = false; - let bypassChecked = false; - let canDelete = false; - let canInsert = false; - let canAppend = false; - let canLoad = true; - let instanceId = -1; - let missing = false; - let pluginUri = "#error"; - - if (pedalboardItem) { - canDelete = pedalboard.canDeleteItem(pedalboardItem.instanceId); - instanceId = pedalboardItem.instanceId; - if (pedalboardItem.isEmpty()) { - canInsert = true; - canAppend = true; - - } else if (pedalboardItem.isStart()) { - canAppend = true; - canDelete = false; - canLoad = false; - } else if (pedalboardItem.isEnd()) { - canInsert = true; - canDelete = false; - canLoad = false; - } else if (pedalboardItem.isSplit()) { - canInsert = true; - canAppend = true; - canLoad = false; + onPedalboardChanged(value: Pedalboard) { + let selectedItem = -1; + if (this.state.selectedPedal === Pedalboard.START_CONTROL || this.state.selectedPedal === Pedalboard.END_CONTROL) { + selectedItem = this.state.selectedPedal; + } else if (value.hasItem(this.state.selectedPedal)) { + selectedItem = this.state.selectedPedal; } else { - pluginUri = pedalboardItem.uri; - uiPlugin = this.model.getUiPlugin(pluginUri); - canInsert = true; - canAppend = true; - if (uiPlugin) { - bypassVisible = true; - bypassChecked = pedalboardItem.isEnabled; - } else { - missing = true; - } + selectedItem = value.getFirstSelectableItem(); + } + this.setState({ + pedalboard: value, + selectedPedal: selectedItem, + selectedSnapshot: value.selectedSnapshot + }); + } + onSelectedSnapshotChanged(selectedSnapshot: number) { + this.setState({ + selectedSnapshot: selectedSnapshot + }); + + } + onDeletePedal(instanceId: number): void { + let result = this.model.deletePedalboardPedal(instanceId); + if (result != null) { + this.setState({ selectedPedal: result }); } } - let horizontalScrollLayout = this.state.horizontalScrollLayout; - return ( -
-
- -
-
-
-
-
-
- -
-
- { - (!this.state.splitControlBar || !this.props.enableStructureEditing) && this.titleBar(pedalboardItem) - } -
+ componentDidMount() { + super.componentDidMount(); + this.model.pedalboard.addOnChangedHandler(this.onPedalboardChanged); + this.model.selectedSnapshot.addOnChangedHandler(this.onSelectedSnapshotChanged); + } + componentWillUnmount() { + this.model.selectedSnapshot.removeOnChangedHandler(this.onSelectedSnapshotChanged); + this.model.pedalboard.removeOnChangedHandler(this.onPedalboardChanged); + super.componentWillUnmount(); + } + updateResponsive() { + this.setState({ + splitControlBar: this.getSplitToolbar(), + displayAuthor: this.getDisplayAuthor(), + horizontalScrollLayout: this.windowSize.height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK, + screenHeight: this.windowSize.height + }); + } + onWindowSizeChanged(width: number, height: number): void { + super.onWindowSizeChanged(width, height); + this.updateResponsive(); + } -
- {this.props.enableStructureEditing && ( -
-
- { this.onAddClick(e) }} size="large"> - - - this.handleAddClose()} - TransitionComponent={Fade} - > - {canInsert && ( this.onInsertPedal(instanceId)}>Insert pedal)} - {canAppend && ( this.onAppendPedal(instanceId)}>Append pedal)} - - {canInsert && ( this.onInsertSplit(instanceId)}>Insert split)} - {canAppend && ( this.onAppendSplit(instanceId)}>Append split)} - -
-
- { this.onDeletePedal(pedalboardItem?.instanceId ?? -1) }} - size="large"> - - -
-
- -
-
- { this.handleMidiConfiguration(instanceId); }} - size="large"> - - -
-
- { this.setState({ snapshotDialogOpen: true }); }} - size="large"> - {this.snapshotIcon(this.props.theme, this.state.selectedSnapshot)} - -
-
- )} -
-
- { - this.state.splitControlBar && this.props.enableStructureEditing && ( -
- { - this.titleBar(pedalboardItem) - } -
- ) - } -
- { - missing ? ( -
- Error: Plugin is not installed. - {pluginUri} -
- ) : - ( - GetControlView(pedalboardItem) - ) + setSelection(selectedId_: number): void { + this.setState({ selectedPedal: selectedId_ }); + } + + onSelectionChanged(selectedId: number): void { + this.setSelection(selectedId); + } + onPedalDoubleClick(selectedId: number): void { + this.setSelection(selectedId); + let item = this.getPedalboardItem(selectedId); + if (item != null) { + if (item.isStart() || item.isEnd()) { + // do nothing. + } else if (item.isSplit()) { + let split = item as PedalboardSplitItem; + if (split.getSplitType() === SplitType.Ab) { + let cv = split.getToggleAbControlValue(); + if (split.instanceId === undefined) throw new PiPedalStateError("Split without valid id."); + this.model.setPedalboardControl(split.instanceId, cv.key, cv.value); } -
- this.setState({ showMidiBindingsDialog: false })} - /> - { - (this.state.loadDialogOpen) && ( - - - ) + } else { + this.setState({ loadDialogOpen: true }); } - {(this.state.snapshotDialogOpen) && ( - this.onSnapshotDialogOk()} /> - )} -
- ); - } - }, - styles - )); + } + } + onLoadCancel(): void { + this.setState({ loadDialogOpen: false }); + } + onLoadOk(selectedUri: string): void { + this.setState({ loadDialogOpen: false }); + let itemId = this.state.selectedPedal; + let newSelectedItem = this.model.loadPedalboardPlugin(itemId, selectedUri); + this.setState({ selectedPedal: newSelectedItem }); + } + onSnapshotDialogOk(): void { + this.setState({ snapshotDialogOpen: false }); + } + + onLoadClick(e: SyntheticEvent) { + this.setState({ loadDialogOpen: true }); + e.preventDefault(); + e.stopPropagation(); + } + onSnapshotClick() { + this.setState({ snapshotDialogOpen: true }); + } + + getPedalboardItem(selectedId?: number): PedalboardItem | null { + if (selectedId === undefined) return null; + + let pedalboard = this.model.pedalboard.get(); + if (!pedalboard) return null; + + if (selectedId === Pedalboard.START_CONTROL) // synthetic input volume item. + { + return pedalboard.makeStartItem(); + } else if (selectedId === Pedalboard.END_CONTROL) // synthetic output volume. + { + return pedalboard.makeEndItem(); + } + + let it = pedalboard.itemsGenerator(); + if (!selectedId) return null; + while (true) { + let v = it.next(); + if (v.done) break; + let item = v.value; + if (item.instanceId === selectedId) { + return item; + } + + } + return null; + + } + + onPedalboardPropertyChanged(instanceId: number, key: string, value: number) { + this.model.setPedalboardControl(instanceId, key, value); + } + getSelectedPedalboardItem(): PedalboardItem | null { + return this.getPedalboardItem(this.state.selectedPedal); + } + getSelectedUri(): string { + let pedalboardItem = this.getSelectedPedalboardItem(); + if (pedalboardItem === null) return ""; + return pedalboardItem.uri; + } + titleBar(pedalboardItem: PedalboardItem | null): React.ReactNode { + let title = ""; + let author = ""; + let infoPluginUri = ""; + let presetsUri = ""; + let missing = false; + let canEditTitle = false; + if (pedalboardItem) { + if (pedalboardItem.isEmpty()) { + title = ""; + } else if (pedalboardItem.isSplit()) { + title = "Split"; + } else if (pedalboardItem.isSyntheticItem()) { + title = pedalboardItem.pluginName ?? "#error"; + author = ""; + presetsUri = ""; + infoPluginUri = ""; + } + else { + let uiPlugin = this.model.getUiPlugin(pedalboardItem.uri); + if (!uiPlugin) { + missing = true; + title = pedalboardItem?.pluginName ?? "Missing plugin"; + } else { + canEditTitle = this.props.enableStructureEditing; + if (pedalboardItem.title !== "") { + title = pedalboardItem.title; + author = ""; + } else { + title = uiPlugin.name; + author = uiPlugin.author_name; + } + presetsUri = uiPlugin.uri; + // if (uiPlugin.description.length > 20) { + // } + infoPluginUri = uiPlugin.uri; + } + } + } + const classes = withStyles.getClasses(this.props); + if (missing) { + return ( +
+
+ + {title} + +
+
+ ); + + } else { + return ( +
+
+ {canEditTitle ? ( + { + this.handleEditPluginDisplayName(); + }} + > + + {title} + {this.state.displayAuthor && ( + {author} + )} + + ) + : ( +
+ {title} + {this.state.displayAuthor && ( + {author} + )} +
+ + )} +
+
+ +
+
+ +
+
+ ); + } + } + + snapshotIcon(theme: Theme, snapshotNumber: number) { + switch (snapshotNumber + 1) { + case 0: + default: + return (); + case 1: + return (); + case 2: + return (); + case 3: + return (); + case 4: + return (); + case 5: + return (); + case 6: + return (); + } + } + + render() { + const classes = withStyles.getClasses(this.props); + let pedalboard = this.model.pedalboard.get(); + let pedalboardItem = this.getSelectedPedalboardItem(); + let uiPlugin = null; + let bypassVisible = false; + let bypassChecked = false; + let canDelete = false; + let canInsert = false; + let canAppend = false; + let canLoad = true; + let instanceId = -1; + let missing = false; + let pluginUri = "#error"; + + if (pedalboardItem) { + canDelete = pedalboard.canDeleteItem(pedalboardItem.instanceId); + instanceId = pedalboardItem.instanceId; + if (pedalboardItem.isEmpty()) { + canInsert = true; + canAppend = true; + + } else if (pedalboardItem.isStart()) { + canAppend = true; + canDelete = false; + canLoad = false; + } else if (pedalboardItem.isEnd()) { + canInsert = true; + canDelete = false; + canLoad = false; + } else if (pedalboardItem.isSplit()) { + canInsert = true; + canAppend = true; + canLoad = false; + } else { + pluginUri = pedalboardItem.uri; + uiPlugin = this.model.getUiPlugin(pluginUri); + canInsert = true; + canAppend = true; + if (uiPlugin) { + bypassVisible = true; + bypassChecked = pedalboardItem.isEnabled; + } else { + missing = true; + } + } + } + let horizontalScrollLayout = this.state.horizontalScrollLayout; + + return ( +
+
+ +
+
+
+
+
+
+ + + +
+
+ { + (!this.state.splitControlBar || !this.props.enableStructureEditing) + && this.titleBar(pedalboardItem) + } +
+ +
+ {this.props.enableStructureEditing && ( +
+
+ { this.onAddClick(e) }} size="large"> + + + this.handleAddClose()} + TransitionComponent={Fade} + > + {canInsert && ( this.onInsertPedal(instanceId)}>Insert pedal)} + {canAppend && ( this.onAppendPedal(instanceId)}>Append pedal)} + + {canInsert && ( this.onInsertSplit(instanceId)}>Insert split)} + {canAppend && ( this.onAppendSplit(instanceId)}>Append split)} + +
+
+ { this.onDeletePedal(pedalboardItem?.instanceId ?? -1) }} + size="large"> + + +
+
+ } + style={{ + textTransform: "none", + background: (isDarkMode() ? "#6750A4" : undefined) + }} + > + Load + +
+
+ { this.handleMidiConfiguration(instanceId); }} + size="large"> + + +
+
+ { this.setState({ snapshotDialogOpen: true }); }} + size="large"> + {this.snapshotIcon(this.props.theme, this.state.selectedSnapshot)} + +
+
+ )} +
+
+ { + this.state.splitControlBar && this.props.enableStructureEditing && ( +
+ { + this.titleBar(pedalboardItem) + } +
+ ) + } +
+ { + missing ? ( +
+ Error: Plugin is not installed. + {pluginUri} +
+ + ) : + ( + GetControlView(pedalboardItem) + ) + } +
+ this.setState({ showMidiBindingsDialog: false })} + /> + { + (this.state.loadDialogOpen) && ( + + + ) + } + {(this.state.snapshotDialogOpen) && ( + this.onSnapshotDialogOk()} /> + )} + {(this.state.displayNameDialogOpen) && ( + this.setState({ displayNameDialogOpen: false })} + onOk={(newName: string) => { + if (pedalboardItem) { + this.model.setPedalboardItemTitle(pedalboardItem.instanceId, newName); + } + this.setState({ displayNameDialogOpen: false }); + }} + /> + )} +
+ ); + } + }, + styles + )); export default MainPage \ No newline at end of file diff --git a/vite/src/pipedal/MidiBinding.tsx b/vite/src/pipedal/MidiBinding.tsx index 53e7c18..3a2add0 100644 --- a/vite/src/pipedal/MidiBinding.tsx +++ b/vite/src/pipedal/MidiBinding.tsx @@ -25,6 +25,9 @@ export default class MidiBinding { this.bindingType = input.bindingType; this.note = input.note; this.control = input.control; + this.minControlValue = input.minControlValue?? 0; + this.maxControlValue = input.maxControlValue?? 127; + this.rotaryScale = input.rotaryScale?? 1; this.minValue = input.minValue; this.maxValue = input.maxValue; this.linearControlType = input.linearControlType; @@ -53,6 +56,9 @@ export default class MidiBinding { && (this.note === other.note) && (this.control === other.control) && (this.minValue === other.minValue) + && (this.minControlValue === other.minControlValue) + && (this.maxControlValue === other.maxControlValue) + && (this.rotaryScale === other.rotaryScale) && (this.maxValue === other.maxValue) && (this.linearControlType === other.linearControlType) && (this.switchControlType === other.switchControlType) @@ -73,6 +79,8 @@ export default class MidiBinding { bindingType: number = MidiBinding.BINDING_TYPE_NONE; note: number = 12*4+24; // C4. control: number = 1; + minControlValue: number = 0; + maxControlValue: number = 127; minValue: number = 0; maxValue: number = 1; rotaryScale: number = 1; diff --git a/vite/src/pipedal/MidiBindingView.tsx b/vite/src/pipedal/MidiBindingView.tsx index 1eeadb6..ad4e554 100644 --- a/vite/src/pipedal/MidiBindingView.tsx +++ b/vite/src/pipedal/MidiBindingView.tsx @@ -18,11 +18,12 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import { Component } from 'react'; -import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; +import { ListenHandle, MidiMessage, PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; import { withStyles } from "tss-react/mui"; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; +import Snackbar from '@mui/material/Snackbar'; import { UiPlugin } from './Lv2Plugin'; @@ -33,7 +34,8 @@ import Utility from './Utility'; import Typography from '@mui/material/Typography'; import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; import MicOutlinedIcon from '@mui/icons-material/MicOutlined'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; + import NumericInput from './NumericInput'; @@ -46,7 +48,7 @@ const styles = (theme: Theme) => createStyles({ } }); -enum MidiControlType { +export enum MidiControlType { None, Select, Dial, @@ -55,18 +57,57 @@ enum MidiControlType { MomentarySwitch } +export function getMidiControlType(uiPlugin: UiPlugin | undefined, symbol: string): MidiControlType { + if (!uiPlugin) return MidiControlType.None; + let port = uiPlugin.getControl(symbol); + if (!port) return MidiControlType.None; + + if (symbol === "__bypass") { + return MidiControlType.Toggle; + } + + + if (!port) return MidiControlType.None; + + if (port.mod_momentaryOffByDefault || port.mod_momentaryOnByDefault) { + return MidiControlType.MomentarySwitch; + } + if (port.trigger_property) { + return MidiControlType.Trigger; + } + if (port.trigger_property) { + return MidiControlType.Trigger; + } + if (port.isAbToggle() || port.isOnOffSwitch()) { + return MidiControlType.Toggle; + } + if (port.isSelect()) { + return MidiControlType.Select; + } + return MidiControlType.Dial; +} + +export function canBindToNote(controlType: MidiControlType): boolean { + return controlType === MidiControlType.Toggle + || controlType === MidiControlType.Trigger + || controlType === MidiControlType.MomentarySwitch +} + interface MidiBindingViewProps extends WithStyles { instanceId: number; - listen: boolean; midiBinding: MidiBinding; - uiPlugin: UiPlugin; + midiControlType: MidiControlType; onChange: (instanceId: number, newBinding: MidiBinding) => void; - onListen: (instanceId: number, key: string, listenForControl: boolean) => void; } interface MidiBindingViewState { - midiControlType: MidiControlType; + listenSymbol: string; + listenForRangeSymbol: string; + listenForRangeMax: number; + listenForRangeMin: number; + listenSnackbarOpen: boolean; + } @@ -82,7 +123,11 @@ const MidiBindingView = super(props); this.model = PiPedalModelFactory.getInstance(); this.state = { - midiControlType: this.getControlType() + listenSymbol: "", + listenForRangeSymbol: "", + listenForRangeMax: 127, + listenForRangeMin: 0, + listenSnackbarOpen: false }; } @@ -138,6 +183,16 @@ const MidiBindingView = newBinding.maxValue = value; this.props.onChange(this.props.instanceId, newBinding); } + handleCtlMinChange(value: number): void { + let newBinding = this.props.midiBinding.clone(); + newBinding.minControlValue = Math.round(value); + this.props.onChange(this.props.instanceId, newBinding); + } + handleCtlMaxChange(value: number): void { + let newBinding = this.props.midiBinding.clone(); + newBinding.maxControlValue = Math.round(value); + this.props.onChange(this.props.instanceId, newBinding); + } handleScaleChange(value: number): void { let newBinding = this.props.midiBinding.clone(); newBinding.rotaryScale = value; @@ -157,10 +212,23 @@ const MidiBindingView = return result; } + mounted: boolean = false; + + componentDidMount() { + super.componentDidMount?.(); + this.mounted = true; + } + + + componentWillUnmount() { + this.mounted = false; + this.cancelListenForControl(); + super.componentWillUnmount?.(); + } validateSwitchControlType(midiBinding: MidiBinding) { // :-( - let controlType = this.state.midiControlType; + let controlType = this.props.midiControlType; if (controlType === MidiControlType.Toggle && midiBinding.bindingType === MidiBinding.BINDING_TYPE_NOTE) { if (midiBinding.switchControlType !== MidiBinding.TRIGGER_ON_RISING_EDGE // Toggle on Note On. @@ -180,41 +248,154 @@ const MidiBindingView = } getControlType(): MidiControlType { - - if (this.props.midiBinding.symbol === "__bypass") { - return MidiControlType.Toggle; - } - if (!this.props.uiPlugin) return MidiControlType.None; - - let port = this.props.uiPlugin.getControl(this.props.midiBinding.symbol); - - if (!port) return MidiControlType.None; - if (port.mod_momentaryOffByDefault || port.mod_momentaryOnByDefault) { - return MidiControlType.MomentarySwitch; - } - if (port.trigger_property) { - return MidiControlType.Trigger; - } - if (port.trigger_property) { - return MidiControlType.Trigger; - } - if (port.isAbToggle() || port.isOnOffSwitch()) { - return MidiControlType.Toggle; - } - if (port.isSelect()) { - return MidiControlType.Select; - } - return MidiControlType.Dial; + return this.props.midiControlType; } + /////////////////////////////////////// + listenTimeoutHandle?: number; + + listenHandle?: ListenHandle; + + cancelListenForControl() { + this.stopListenTimeout(); + if (this.listenHandle) { + this.model.cancelListenForMidiEvent(this.listenHandle) + this.listenHandle = undefined; + } + + this.setState({ listenSymbol: "", listenForRangeSymbol: "" }); + + } + + handleListenForRangeSucceeded(instanceId: number, symbol: string, midiMessage: MidiMessage) { + if (!midiMessage.isControl()) return; + + let binding = this.props.midiBinding; + if (binding.control != midiMessage.cc1) { + return; + } + + + let value = midiMessage.cc2; + let min = this.state.listenForRangeMin; + let max = this.state.listenForRangeMax; + + let update = false; + if (value < min) { + min = value; + update = true; + } + if (value > max) { + max = value; + update = true; + } + if (!update) { + return; // No change, so ignore. + } + + let newBinding = binding.clone(); + newBinding.minControlValue = min; + newBinding.maxControlValue = max; + + this.props.onChange(this.props.instanceId, newBinding); + this.setState({ listenForRangeMin: min, listenForRangeMax: max }); + this.startListenTimeout(20000); + + } + + handleListenSucceeded(instanceId: number, symbol: string, midiMessage: MidiMessage) { + if (instanceId !== this.props.instanceId) { + return; + } + if (symbol !== this.props.midiBinding.symbol) { + return; + } + + + let binding = this.props.midiBinding; + let newBinding = binding.clone(); + if (midiMessage.isNote()) { + if (!canBindToNote(this.props.midiControlType)) { + return; + } + newBinding.bindingType = MidiBinding.BINDING_TYPE_NOTE + newBinding.note = midiMessage.cc1; + } else if (midiMessage.isControl()) { + newBinding.bindingType = MidiBinding.BINDING_TYPE_CONTROL + newBinding.control = midiMessage.cc1; + } else { + return; + } + this.props.onChange(this.props.instanceId, newBinding); + this.cancelListenForControl(); + } + handleListenForControlRange(instanceId: number, symbol: string): void { + if (this.state.listenForRangeSymbol !== "") { + this.cancelListenForControl(); + return; + } + this.cancelListenForControl(); + + this.setState({ + listenSymbol: "", + listenForRangeSymbol: symbol, + listenForRangeMin: 127, + listenForRangeMax: 0, + listenSnackbarOpen: true + }); + this.startListenTimeout(20000); + + this.listenHandle = this.model.listenForMidiEvent( + (midiMessage) => { + this.handleListenForRangeSucceeded(instanceId, symbol, midiMessage); + }); + } + + stopListenTimeout(): void { + if (this.listenTimeoutHandle) { + clearTimeout(this.listenTimeoutHandle); + } + } + + startListenTimeout(timeout: number): void { + this.stopListenTimeout(); + this.listenTimeoutHandle = setTimeout(() => { + this.cancelListenForControl(); + this.listenTimeoutHandle = undefined; + }, timeout); + } + + handleListen(): void { + if (this.state.listenSymbol !== "") { + this.cancelListenForControl(); + return; + } + this.cancelListenForControl(); + + this.setState({ + listenSymbol: this.props.midiBinding.symbol, + listenForRangeSymbol: "", + listenSnackbarOpen: true + }); + let instanceId = this.props.instanceId; + let symbol = this.props.midiBinding.symbol; + + this.startListenTimeout(8000); + + this.listenHandle = this.model.listenForMidiEvent( + (midiMessage) => { + this.handleListenSucceeded(instanceId, symbol, midiMessage); + }); + } + + /////////////////////////////////////// render() { const classes = withStyles.getClasses(this.props); let midiBinding = this.props.midiBinding; - let uiPlugin = this.props.uiPlugin; - if (!uiPlugin) { + if (this.props.midiControlType === MidiControlType.None) { return (
); } - let controlType = this.state.midiControlType; + let controlType = this.props.midiControlType; let showLinearRange = controlType === MidiControlType.Dial && @@ -237,9 +418,7 @@ const MidiBindingView = value={midiBinding.bindingType} > None - {(controlType === MidiControlType.Toggle - || controlType === MidiControlType.Trigger - || controlType === MidiControlType.MomentarySwitch) && ( + {(canBindToNote(this.props.midiControlType)) && ( Note )} Control @@ -259,21 +438,22 @@ const MidiBindingView = this.generateMidiNoteSelects() } - { + this.cancelListenForControl(); + }} + onClick={() => { - if (this.props.listen) { - this.props.onListen(-2, "", false) - } else { - this.props.onListen(this.props.instanceId, this.props.midiBinding.symbol, false) - } + this.handleListen() }} size="large"> - {this.props.listen ? ( + {this.state.listenSymbol !== "" ? ( ) : ( )} - +
) } @@ -292,25 +472,47 @@ const MidiBindingView = this.generateControlSelects() } - { + this.cancelListenForControl(); + }} + onClick={() => { - if (this.props.listen) { - this.props.onListen(-2, "", false) - } else { - this.props.onListen(this.props.instanceId, this.props.midiBinding.symbol, true) - } + this.handleListen() }} size="large"> - {this.props.listen ? ( + {this.state.listenSymbol !== "" ? ( ) : ( )} - +
) } + {midiBinding.bindingType === MidiBinding.BINDING_TYPE_NONE && + ( + { + this.cancelListenForControl(); + }} + + onClick={() => { + this.handleListen() + }} + size="large"> + {this.state.listenSymbol !== "" ? ( + + ) : ( + + )} + + + ) + } { ((controlType === MidiControlType.Toggle && midiBinding.bindingType === MidiBinding.BINDING_TYPE_NOTE) && ( @@ -375,25 +577,58 @@ const MidiBindingView = Rotary
- - ) - } - { - showLinearRange && ( + )} + {showLinearRange && ( +
- Min:  - { this.handleMinChange(value); }} + Min Ctl:  + { this.handleCtlMinChange(value); }} />
- ) +
+
+ Max Ctl:  + { this.handleCtlMaxChange(value); }} + /> +
+ + { + this.cancelListenForControl(); + }} + onClick={() => { + this.handleListenForControlRange(this.props.instanceId, this.props.midiBinding.symbol); + }} + size="large"> + {this.state.listenForRangeSymbol !== "" ? ( + + ) : ( + + )} + +
+ +
+ ) } { showLinearRange && ( -
- Max:  - { this.handleMaxChange(value); }} /> +
+
+ Min Val:  + { this.handleMinChange(value); }} + /> +
+
+ Max Val:  + { this.handleMaxChange(value); }} /> +
) } @@ -401,11 +636,24 @@ const MidiBindingView = canRotaryScale && (
Scale:  - { this.handleScaleChange(value); }} />
) } + + this.setState({ listenSnackbarOpen: false })} + message="Listening for MIDI input" + /> + +
); diff --git a/vite/src/pipedal/MidiBindingsDialog.tsx b/vite/src/pipedal/MidiBindingsDialog.tsx index 1b846e4..7ab6f9a 100644 --- a/vite/src/pipedal/MidiBindingsDialog.tsx +++ b/vite/src/pipedal/MidiBindingsDialog.tsx @@ -20,7 +20,7 @@ import React, { SyntheticEvent } from 'react'; import DialogEx from './DialogEx'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; -import { PiPedalModel, PiPedalModelFactory, ListenHandle } from './PiPedalModel'; +import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import Typography from '@mui/material/Typography'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; @@ -30,10 +30,9 @@ import { withStyles } from "tss-react/mui"; import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import MidiBinding from './MidiBinding'; -import MidiBindingView from './MidiBindingView'; -import Snackbar from '@mui/material/Snackbar'; +import MidiBindingView, { getMidiControlType } from './MidiBindingView'; import { PortGroup, UiPlugin, makeSplitUiPlugin } from './Lv2Plugin'; import { css } from '@emotion/react'; @@ -81,9 +80,6 @@ export interface MidiBindingDialogProps extends WithStyles { } export interface MidiBindingDialogState { - listenInstanceId: number; - listenSymbol: string; - listenSnackbarOpen: boolean; } @@ -98,7 +94,10 @@ export const MidiBindingDialog = this.state = { listenInstanceId: -2, listenSymbol: "", - listenSnackbarOpen: false + listenForRangeSymbol: "", + listenSnackbarOpen: false, + listenForRangeMin: 127, + listenForRangeMax: 0 }; this.model = PiPedalModelFactory.getInstance(); this.handleClose = this.handleClose.bind(this); @@ -108,64 +107,9 @@ export const MidiBindingDialog = hasHooks: boolean = false; handleClose() { - this.cancelListenForControl(); this.props.onClose(); } - listenTimeoutHandle?: number; - - listenHandle?: ListenHandle; - - cancelListenForControl() { - if (this.listenTimeoutHandle) { - clearTimeout(this.listenTimeoutHandle); - this.listenTimeoutHandle = undefined; - } - if (this.listenHandle) { - this.model.cancelListenForMidiEvent(this.listenHandle) - this.listenHandle = undefined; - } - - this.setState({ listenInstanceId: -2, listenSymbol: "" }); - - } - - handleListenSucceeded(instanceId: number, symbol: string, isNote: boolean, noteOrControl: number) { - this.cancelListenForControl(); - - let pedalboard = this.model.pedalboard.get(); - let item = pedalboard.getItem(instanceId); - if (!item) return; - - let binding = item.getMidiBinding(symbol); - let newBinding = binding.clone(); - if (isNote) { - newBinding.bindingType = MidiBinding.BINDING_TYPE_NOTE; - newBinding.note = noteOrControl; - } else { - newBinding.bindingType = MidiBinding.BINDING_TYPE_CONTROL; - newBinding.control = noteOrControl; - } - - this.model.setMidiBinding(instanceId, newBinding); - } - - - handleListenForControl(instanceId: number, symbol: string, listenForControl: boolean): void { - this.cancelListenForControl(); - this.setState({ listenInstanceId: instanceId, listenSymbol: symbol, listenSnackbarOpen: true }); - this.listenTimeoutHandle = setTimeout(() => { - this.cancelListenForControl(); - }, 8000); - - this.listenHandle = this.model.listenForMidiEvent(listenForControl, - (isNote: boolean, noteOrControl: number) => { - this.handleListenSucceeded(instanceId, symbol, isNote, noteOrControl); - }); - - - - } onWindowSizeChanged(width: number, height: number): void { } @@ -176,10 +120,11 @@ export const MidiBindingDialog = this.mounted = true; } componentWillUnmount() { - super.componentWillUnmount(); this.mounted = false; + super.componentWillUnmount(); + } componentDidUpdate() { @@ -255,15 +200,7 @@ export const MidiBindingDialog = { - if (instanceId === -2) { - this.cancelListenForControl(); - } else { - this.handleListenForControl(instanceId, symbol, listenForControl); - } - }} - listen={item.instanceId === this.state.listenInstanceId && this.state.listenSymbol === "__bypass"} + midiControlType={ getMidiControlType(plugin, "__bypass") } onChange={(instanceId: number, newItem: MidiBinding) => this.handleItemChanged(instanceId, newItem)} /> @@ -305,13 +242,8 @@ export const MidiBindingDialog = { - this.handleListenForControl(instanceId, symbol, listenForControl); - }} - onChange={(instanceId: number, newItem: MidiBinding) => this.handleItemChanged(instanceId, newItem)} /> @@ -350,14 +282,15 @@ export const MidiBindingDialog =
- - + Preset MIDI Bindings @@ -378,16 +311,6 @@ export const MidiBindingDialog =
- this.setState({ listenSnackbarOpen: false })} - message="Listening for MIDI input" - />
); } diff --git a/vite/src/pipedal/MidiChannelBindingControl.tsx b/vite/src/pipedal/MidiChannelBindingControl.tsx index db7b086..dbcef51 100644 --- a/vite/src/pipedal/MidiChannelBindingControl.tsx +++ b/vite/src/pipedal/MidiChannelBindingControl.tsx @@ -26,7 +26,7 @@ import { withStyles } from "tss-react/mui"; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import { pluginControlStyles } from './PluginControl'; import MidiChannelBinding from './MidiChannelBinding'; -import Tooltip from '@mui/material/Tooltip'; +import ToolTipEx from './ToolTipEx'; import Typography from '@mui/material/Typography'; import Divider from '@mui/material/Divider'; import ButtonBase from '@mui/material/ButtonBase'; @@ -78,7 +78,7 @@ const MidiChannelBindingControl = alignSelf: "stretch" }}> - title @@ -86,13 +86,13 @@ const MidiChannelBindingControl = )} - placement="top-start" arrow enterDelay={1500} enterNextDelay={1500} + > MIDI - +
{/* CONTROL SECTION */} diff --git a/vite/src/pipedal/MidiChannelBindingDialog.tsx b/vite/src/pipedal/MidiChannelBindingDialog.tsx index e3ba3d5..027a81a 100644 --- a/vite/src/pipedal/MidiChannelBindingDialog.tsx +++ b/vite/src/pipedal/MidiChannelBindingDialog.tsx @@ -27,8 +27,7 @@ import DialogActions from '@mui/material/DialogActions'; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import FormControlLabel from '@mui/material/FormControlLabel'; import ChannelBindingHelpDialog from './ChannelBindingsHelpDialog'; - -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import Checkbox from '@mui/material/Checkbox'; @@ -167,7 +166,9 @@ function MidiChannelBindingDialog(props: MidiChannelBindingDialogProps) { } label="Allow Program Changes" /> {false&&( // wait until the implementation stabilizes before exposing the help dialog. - { setHelpDialog(true); }} size="large"> - + )}
diff --git a/vite/src/pipedal/NumericInput.tsx b/vite/src/pipedal/NumericInput.tsx index 73e50d6..a2c8dd9 100644 --- a/vite/src/pipedal/NumericInput.tsx +++ b/vite/src/pipedal/NumericInput.tsx @@ -35,14 +35,18 @@ const styles = ({ palette }: Theme) => createStyles({ interface NumericInputProps extends WithStyles { ariaLabel: string; - defaultValue: number; + value: number; min: number; max: number; + step?: number; + integer?: boolean; onChange: (value: number) => void; } interface NumericInputState { + stateValue: string; error: boolean; + focused: boolean; } export const NumericInput = @@ -54,22 +58,28 @@ export const NumericInput = constructor(props: NumericInputProps) { super(props); this.state = { - error: false + stateValue: this.toDisplayValue(props.value), + error: false, + focused: false }; } changed: boolean = false; - handleChange(event: any): void { + handleChange(event: React.ChangeEvent): void { this.changed = true; let strValue = event.target.value; + this.setState({ stateValue: strValue }); try { let value = Number(strValue); if (isNaN(value)) { this.setState({ error: true }); + } else if (this.props.integer === true && !Number.isInteger(value)) { + this.setState({ error: true }); } else if (value >= this.props.min && value <= this.props.max) { this.setState({ error: false }); + this.props.onChange(value); } else { this.setState({ error: true }); } @@ -80,6 +90,10 @@ export const NumericInput = } toDisplayValue(value: number): string { + if (this.props.integer === true) + { + return Math.round(value).toFixed(0); + } if (value <= -1000 || value >= 1000) { return value.toFixed(0); @@ -96,7 +110,7 @@ export const NumericInput = } } - apply(input: HTMLInputElement) { + apply(input: HTMLInputElement| HTMLTextAreaElement) { if (this.changed) { let strValue = input.value; let value = Number(strValue); @@ -104,28 +118,34 @@ export const NumericInput = { if (value < this.props.min) value = this.props.min; if (value > this.props.max) value = this.props.max; + if (this.props.integer === true) value = Math.round(value); input.value = this.toDisplayValue(value); this.props.onChange(value); } else { - input.value = this.toDisplayValue(this.props.defaultValue); + input.value = this.toDisplayValue(this.props.value); + this.props.onChange(this.props.value); } this.changed = false; this.setState({ error: false }); } } - handleLostFocus(e: any) { - this.apply(e.currentTarget as HTMLInputElement); + handleFocus(e: React.FocusEvent) { + this.changed = false; + this.setState({ focused: true }); + e.currentTarget.select(); + this.setState({ stateValue: this.toDisplayValue(this.props.value) }); } - handleKeyDown(e: React.KeyboardEvent) { + handleBlur(e: any) { + this.apply(e.currentTarget as HTMLInputElement); + this.setState({ focused: false }); + } + handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter") { - this.apply(e.currentTarget as HTMLInputElement); - } else if (e.key === "Escape") - { - e.currentTarget.value = this.toDisplayValue(this.props.defaultValue); - this.changed = false; - } + this.setState({ stateValue: this.toDisplayValue(this.props.value) }); + this.changed = false; + } } @@ -135,11 +155,15 @@ export const NumericInput = inputProps={{ 'aria-label': this.props.ariaLabel, style: { textAlign: 'right' }, - "onBlur": (e: any) => this.handleLostFocus(e), - "onKeyDown": (e: any) => this.handleKeyDown(e) + "min": this.props.min, + "max": this.props.max, + "step:": this.props.step }} - onChange={(event) => this.handleChange(event)} - defaultValue={this.toDisplayValue(this.props.defaultValue)} + onFocus={(e) => {this.handleFocus(e);}} + onBlur= {(e) => { this.handleBlur(e); }} + onKeyDown={(e) => {this.handleKeyDown(e);}} + onChange={(e) => this.handleChange(e)} + value={this.state.focused ? this.state.stateValue: this.toDisplayValue(this.props.value)} error={this.state.error} /> diff --git a/vite/src/pipedal/Pedalboard.tsx b/vite/src/pipedal/Pedalboard.tsx index ce7ad12..c18a2cc 100644 --- a/vite/src/pipedal/Pedalboard.tsx +++ b/vite/src/pipedal/Pedalboard.tsx @@ -61,6 +61,7 @@ export class ControlValue implements Deserializable { export class PedalboardItem implements Deserializable { deserializePedalboardItem(input: any): PedalboardItem { this.instanceId = input.instanceId ?? -1; + this.title = input.title ?? ""; this.uri = input.uri; this.pluginName = input.pluginName; this.isEnabled = input.isEnabled; @@ -199,6 +200,7 @@ export class PedalboardItem implements Deserializable { static EmptyArray: PedalboardItem[] = []; instanceId: number = -1; + title: string = ""; isEnabled: boolean = false; uri: string = ""; pluginName?: string; diff --git a/vite/src/pipedal/PedalboardView.tsx b/vite/src/pipedal/PedalboardView.tsx index c1fa5fe..ffd73f1 100644 --- a/vite/src/pipedal/PedalboardView.tsx +++ b/vite/src/pipedal/PedalboardView.tsx @@ -329,6 +329,9 @@ class PedalLayout { this.pluginType = uiPlugin.plugin_type; this.iconUrl = SelectIconUri(uiPlugin.plugin_type); this.name = uiPlugin.label; + if (pedalItem.title !== "") { + this.name = pedalItem.title; + } this.numberOfInputs = Math.min(uiPlugin.audio_inputs, 2); this.numberOfOutputs = Math.min(uiPlugin.audio_outputs, 2); } else { diff --git a/vite/src/pipedal/PerformanceView.tsx b/vite/src/pipedal/PerformanceView.tsx index e521535..db346c1 100644 --- a/vite/src/pipedal/PerformanceView.tsx +++ b/vite/src/pipedal/PerformanceView.tsx @@ -27,7 +27,7 @@ import { withStyles } from "tss-react/mui"; import { PiPedalModel, PiPedalModelFactory, PresetIndex } from './PiPedalModel'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import AppBar from '@mui/material/AppBar'; import SnapshotPanel from './SnapshotPanel'; import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; @@ -246,13 +246,13 @@ export const PerformanceView = paddingTop: 3, paddingBottom: 3, paddingRight: 8, paddingLeft: 8, alignItems: "start" }}> - { this.props.onClose(); }} style={{ position: "relative",top: 3, flex: "0 0 auto" }} > - +
{/********* BANKS *******************/}
- { this.handlePreviousBank(); }} size="medium" @@ -275,7 +275,7 @@ export const PerformanceView = }} > - + - { this.handleNextPreset(); }} color="inherit" style={{ borderTopLeftRadius: "3px", borderBottomLeftRadius: "3px", marginLeft: 4 }} > - + - { this.model.saveCurrentPreset(); }} size="medium" color="inherit" style={{flexShrink: 0,visibility: this.state.presetModified? "visible": "hidden"}} > - - + +
diff --git a/vite/src/pipedal/PiPedalModel.tsx b/vite/src/pipedal/PiPedalModel.tsx index 5c1acd1..c9c2e5a 100644 --- a/vite/src/pipedal/PiPedalModel.tsx +++ b/vite/src/pipedal/PiPedalModel.tsx @@ -41,6 +41,9 @@ import AlsaDeviceInfo from './AlsaDeviceInfo'; import { AndroidHostInterface, FakeAndroidHost } from './AndroidHost'; import { ColorTheme, getColorScheme, setColorScheme } from './DarkMode'; import FilePropertyDirectoryTree from './FilePropertyDirectoryTree'; +import AudioFileMetadata from './AudioFileMetadata'; +import { pathFileName } from './FileUtils'; +import { AlsaSequencerConfiguration, AlsaSequencerPortSelection } from './AlsaSequencer'; export enum State { @@ -56,14 +59,11 @@ export enum State { HotspotChanging, }; -function getErrorMessage(error: any) -{ - if (error instanceof Error) - { +function getErrorMessage(error: any) { + if (error instanceof Error) { return (error as Error).message; } - if (!error) - { + if (!error) { return ""; } return error.toString(); @@ -89,6 +89,8 @@ export interface FileEntry { displayName: string; isDirectory: boolean; isProtected: boolean; + // optional metadata for audio + metadata?: AudioFileMetadata; }; export interface BreadcrumbEntry { @@ -157,14 +159,31 @@ interface ControlValueChangeItem { }; +export class MidiMessage { + constructor(cc0: number, cc1: number, cc2: number) { + this.cc0 = cc0; + this.cc1 = cc1; + this.cc2 = cc2; + } + cc0: number; + cc1: number; + cc2: number; + + isNote() { + return (this.cc0 & 0xF0) == 0x90 && this.cc2 !== 0; + } + isControl() { + return (this.cc0 & 0xF0) == 0xB0; + } +}; class MidiEventListener { - constructor(handle: number, callback: (isNote: boolean, noteOrControl: number) => void) { + constructor(handle: number, callback: (message: MidiMessage) => void) { this.handle = handle; this.callback = callback; } handle: number; - callback: (isNote: boolean, noteOrControl: number) => void; + callback: (midiMessage: MidiMessage) => void; }; export type PatchPropertyListener = (instanceId: number, propertyUri: string, atomObject: any) => void; @@ -401,6 +420,8 @@ export class PiPedalModel //implements PiPedalModel webSocket?: PiPedalSocket; + hasTone3000Auth: ObservableProperty = new ObservableProperty(false); + hasWifiDevice: ObservableProperty = new ObservableProperty(false); onSnapshotModified: ObservableEvent = new ObservableEvent(); @@ -434,6 +455,7 @@ export class PiPedalModel //implements PiPedalModel favorites: ObservableProperty = new ObservableProperty({}); + alsaSequencerConfiguration : ObservableProperty = new ObservableProperty(new AlsaSequencerConfiguration()); presets: ObservableProperty = new ObservableProperty ( @@ -479,14 +501,12 @@ export class PiPedalModel //implements PiPedalModel private expectDisconnectTimer?: number = undefined; - cancelExpectDisconnectTimer() - { + cancelExpectDisconnectTimer() { if (this.expectDisconnectTimer) { clearTimeout(this.expectDisconnectTimer); } } - startExpectDisconnectTimer() - { + startExpectDisconnectTimer() { this.cancelExpectDisconnectTimer(); // poll for access to a running pipedal server this.expectDisconnectTimer = setTimeout( @@ -500,8 +520,7 @@ export class PiPedalModel //implements PiPedalModel expectDisconnect(reason: ReconnectReason) { this.cancelExpectDisconnectTimer(); this.reconnectReason = reason; - if (this.reconnectReason !== ReconnectReason.Disconnected) - { + if (this.reconnectReason !== ReconnectReason.Disconnected) { this.startExpectDisconnectTimer(); } } @@ -619,17 +638,14 @@ export class PiPedalModel //implements PiPedalModel let channelSelection = new JackChannelSelection().deserialize(channelSelectionBody.jackChannelSelection); this.jackSettings.set(channelSelection); } else if (message === "onSnapshotModified") { - let {snapshotIndex,modified} = (body as {snapshotIndex: number, modified: boolean}); + let { snapshotIndex, modified } = (body as { snapshotIndex: number, modified: boolean }); let snapshots = this.pedalboard.get().snapshots; - if (snapshotIndex >= 0 && snapshotIndex < snapshots.length) - { + if (snapshotIndex >= 0 && snapshotIndex < snapshots.length) { let snapshot = snapshots[snapshotIndex] - if (snapshot) - { - if (snapshot.isModified !== modified) - { + if (snapshot) { + if (snapshot.isModified !== modified) { snapshot.isModified = modified; - this.onSnapshotModified.fire({snapshotIndex: snapshotIndex,modified: modified}); + this.onSnapshotModified.fire({ snapshotIndex: snapshotIndex, modified: modified }); } } } @@ -656,6 +672,9 @@ export class PiPedalModel //implements PiPedalModel this.handlePluginPresetsChanged(pluginUri); } else if (message === "onJackConfigurationChanged") { this.jackConfiguration.set(new JackConfiguration().deserialize(body)); + } else if (message === "onAlsaSequencerConfigurationChanged") { + this.alsaSequencerConfiguration.set(new AlsaSequencerConfiguration().deserialize(body)); + } else if (message === "onLoadPluginPreset") { let instanceId = body.instanceId as number; let controlValues = ControlValue.deserializeArray(body.controlValues); @@ -684,15 +703,18 @@ export class PiPedalModel //implements PiPedalModel } } else if (message === "onNotifyMidiListener") { - let clientHandle = body.clientHandle as number; - let isNote = body.isNote as boolean; - let noteOrControl = body.noteOrControl as number; - this.handleNotifyMidiListener(clientHandle, isNote, noteOrControl); + let notifyBody = body as { clientHandle: number, cc0: number, cc1: number, cc2: number }; + let clientHandle = notifyBody.clientHandle as number; + this.handleNotifyMidiListener( + clientHandle, + notifyBody.cc0, + notifyBody.cc1, + notifyBody.cc2); } else if (message === "onNotifyPathPatchPropertyChanged") { let instanceId = body.instanceId as number; let propertyUri = body.propertyUri as string; - let atomJson = body.atomJson as any; - this.handleNotifyPathPatchPropertyChanged(instanceId, propertyUri, atomJson); + let atomJsonString = body.atomJson as string; + this.handleNotifyPathPatchPropertyChanged(instanceId, propertyUri, atomJsonString); if (header.replyTo) { this.webSocket?.reply(header.replyTo, "onNotifyPathPatchPropertyChanged", true); } @@ -740,6 +762,8 @@ export class PiPedalModel //implements PiPedalModel } else if (message === "onErrorMessage") { this.showAlert(body as string); + } else if (message == "onTone3000AuthChanged") { + this.hasTone3000Auth.set(body as boolean); } else if (message === "onLv2PluginsChanging") { this.onLv2PluginsChanging(); @@ -752,7 +776,7 @@ export class PiPedalModel //implements PiPedalModel else if (message === "onHasWifiChanged") { let hasWifi = body as boolean; this.hasWifiDevice.set(hasWifi); - } + } } @@ -923,6 +947,9 @@ export class PiPedalModel //implements PiPedalModel } } } + + + async onSocketReconnected(): Promise { this.cancelOnNetworkChanging(); this.cancelAndroidReconnectTimer(); @@ -932,13 +959,12 @@ export class PiPedalModel //implements PiPedalModel } if (this.visibilityState.get() === VisibilityState.Hidden) return; - + // reload state, but not configuration. this.clientId = await this.getWebSocket().request("hello"); - let newServerVersion = this.serverVersion = await this.getWebSocket().request("version"); - if (newServerVersion.serverVersion !== this.serverVersion.serverVersion) - { + let newServerVersion = this.serverVersion = await this.getWebSocket().request("version"); + if (newServerVersion.serverVersion !== this.serverVersion.serverVersion) { this.reloadPage(); return; } @@ -955,6 +981,49 @@ export class PiPedalModel //implements PiPedalModel } + async getNextAudioFile(filePath: string): Promise { + try { + let url = + this.varServerUrl + + "NextAudioFile?path=" + encodeURIComponent(filePath); + + let response = await fetch(url); + let json = await response.json(); + return json as string; + } catch (e) { + return ""; + } + } + async getPreviousAudioFile(filePath: string): Promise { + try { + let url = + this.varServerUrl + + "PreviousAudioFile?path=" + encodeURIComponent(filePath); + + let response = await fetch(url); + let json = await response.json(); + return json as string; + } catch (e) { + return ""; + } + } + + + async getAudioFileMetadata(filePath: string): Promise { + try { + let url = + this.varServerUrl + + "AudioMetadata?path=" + encodeURIComponent(filePath); + + let response = await fetch(url); + let json = await response.json(); + return new AudioFileMetadata().deserialize(json); + } catch (e) { + return new AudioFileMetadata(); + } + } + + maxFileUploadSize: number = 512 * 1024 * 1024; maxPresetUploadSize: number = 1024 * 1024; debug: boolean = false; @@ -964,7 +1033,7 @@ export class PiPedalModel //implements PiPedalModel async requestConfig(): Promise { try { - const myRequest = new Request(this.varRequest('config.json')); + const myRequest = new Request(this.varRequest('config.json')); let response: Response = await fetch(myRequest); let data = await response.json(); @@ -984,12 +1053,11 @@ export class PiPedalModel //implements PiPedalModel if (!socket_server_port) socket_server_port = 8080; let socket_server = this.makeSocketServerUrl(socket_server_address, socket_server_port); let var_server_url = this.makeVarServerUrl("http", socket_server_address, socket_server_port); - + this.socketServerUrl = socket_server; this.varServerUrl = var_server_url; this.maxFileUploadSize = parseInt(max_upload_size); - } catch (error: any) - { + } catch (error: any) { this.setError("Can't connect to server. " + getErrorMessage(error)); return false; } @@ -1005,10 +1073,10 @@ export class PiPedalModel //implements PiPedalModel } ); - try { + try { await this.webSocket.connect(); } catch (error) { - this.setError("Failed to connect to server. " + getErrorMessage(error) ); + this.setError("Failed to connect to server. " + getErrorMessage(error)); return false; } @@ -1017,23 +1085,21 @@ export class PiPedalModel //implements PiPedalModel this.clientId = (await this.getWebSocket().request("hello")) as number; - this.preloadImages( (await this.getWebSocket().request("imageList"))); + this.preloadImages((await this.getWebSocket().request("imageList"))); } catch (error) { this.setError("Failed to establish connection. " + getErrorMessage(error)); return false; } return await this.loadServerState(); } - async loadServerState() : Promise - { - try - { + async loadServerState(): Promise { + try { this.serverVersion = await this.getWebSocket().request("version"); this.updateStatus.set(new UpdateStatus().deserialize(await this.getUpdateStatus())); this.hasWifiDevice.set(await this.getWebSocket().request("getHasWifi")); - + this.ui_plugins.set( UiPlugin.deserialize_array(await this.getWebSocket().request("plugins")) ); @@ -1059,7 +1125,7 @@ export class PiPedalModel //implements PiPedalModel this.wifiDirectConfigSettings.set( new WifiDirectConfigSettings().deserialize( await this.getWebSocket().request("getWifiDirectConfigSettings") - )); + )); this.governorSettings.set(new GovernorSettings().deserialize( await this.getWebSocket().request("getGovernorSettings") )); @@ -1077,7 +1143,13 @@ export class PiPedalModel //implements PiPedalModel this.jackSettings.set(new JackChannelSelection().deserialize( await this.getWebSocket().request("getJackSettings") )); - this.banks.set(new BankIndex().deserialize(await this.getWebSocket().request("getBankIndex"))); + this.alsaSequencerConfiguration.set(new AlsaSequencerConfiguration().deserialize( + await this.getWebSocket().request("getAlsaSequencerConfiguration") + )); + this.hasTone3000Auth.set( + await this.getWebSocket().request("getHasTone3000Auth") + ); + this.banks.set(new BankIndex().deserialize(await this.getWebSocket().request("getBankIndex"))); this.favorites.set(await this.getWebSocket().request("getFavorites")); @@ -1089,7 +1161,7 @@ export class PiPedalModel //implements PiPedalModel this.setState(State.Ready); return true; } - catch(error) { + catch (error) { this.setError("Failed to fetch server state.\n\n" + getErrorMessage(error)); return false; } @@ -1473,7 +1545,7 @@ export class PiPedalModel //implements PiPedalModel } - sendPedalboardControlTrigger(instanceId: number, key: string, value: number) : void { + sendPedalboardControlTrigger(instanceId: number, key: string, value: number): void { // no state change, no saving the value, just send it to the realtime thread/ this._setServerControl("previewControl", instanceId, key, value); } @@ -1788,6 +1860,27 @@ export class PiPedalModel //implements PiPedalModel .request("setOnboarding", value); } + moveAudioFile( + path: string, + from: number, + to: number + ): Promise { + return new Promise( + (accept, reject) => { + this.webSocket?.request("moveAudioFile", { + path: path, + from: from, + to: to + }).then(() => { + accept(true); + }).catch((e) => { + this.setError(getErrorMessage(e)); + accept(false); + }); + } + ); + } + saveCurrentPresetAs(newName: string, saveAfterInstanceId = -1): Promise { // default behaviour is to save after the currently selected preset. @@ -1961,8 +2054,8 @@ export class PiPedalModel //implements PiPedalModel return this.copyPreset(instanceId, -1); } - showAlert(message: string | Error): void { - let m = message; + showAlert(message: any): void { + let m: string; if (message instanceof Error) { let e = message as Error; if (e.message) { @@ -2297,12 +2390,12 @@ export class PiPedalModel //implements PiPedalModel private monitorPatchPropertyListeners: PatchPropertyListenerItem[] = []; nextListenHandle = 1; - listenForMidiEvent(listenForControl: boolean, onComplete: (isNote: boolean, noteOrControl: number) => void): ListenHandle { + listenForMidiEvent(onComplete: (midiMessage: MidiMessage) => void): ListenHandle { let handle = this.nextListenHandle++; this.midiListeners.push(new MidiEventListener(handle, onComplete)); - this.webSocket?.send("listenForMidiEvent", { listenForControls: listenForControl, handle: handle }); + this.webSocket?.send("listenForMidiEvent", { handle: handle }); return { _handle: handle }; @@ -2334,19 +2427,15 @@ export class PiPedalModel //implements PiPedalModel } private handleNotifyPathPatchPropertyChanged( - instanceId: number, propertyUri: string, jsonObject: any + instanceId: number, propertyUri: string, jsonObjectString: string ) { let pedalboard = this.pedalboard.get(); let pedalboardItem = pedalboard.getItem(instanceId); if (pedalboardItem) { - pedalboardItem.pathProperties[propertyUri] = jsonObject; - } - for (let i = 0; i < this.monitorPatchPropertyListeners.length; ++i) { - let listener = this.monitorPatchPropertyListeners[i]; - if (listener.instanceId === instanceId) { - listener.callback(instanceId, propertyUri, jsonObject); - } + pedalboardItem.pathProperties[propertyUri] = jsonObjectString; } + // No NOT notify monitorPatchProperty listeners, because they extpect objects not strings, + // AND they will a NotifyPatchPropertychanged message anyway. } private handleNotifyPatchProperty(clientHandle: number, instanceId: number, propertyUri: string, jsonObject: any) { @@ -2364,12 +2453,16 @@ export class PiPedalModel //implements PiPedalModel } - handleNotifyMidiListener(clientHandle: number, isNote: boolean, noteOrControl: number) { + handleNotifyMidiListener(clientHandle: number, cc0: number, cc1: number, cc2: number): void { + let midiMessage = new MidiMessage(cc0, cc1, cc2); + + if (!midiMessage.isNote() && !midiMessage.isControl()) { + return; + } for (let i = 0; i < this.midiListeners.length; ++i) { let listener = this.midiListeners[i]; if (listener.handle === clientHandle) { - listener.callback(isNote, noteOrControl); - + listener.callback(midiMessage); } } } @@ -2392,10 +2485,13 @@ export class PiPedalModel //implements PiPedalModel let downloadUrl = this.varServerUrl + "downloadMediaFile?path=" + encodeURIComponent(filePath); // download with no flashing temporary tab. - let link = window.document.createElement("A") as HTMLLinkElement; + let link = window.document.createElement("A") as HTMLAnchorElement; link.href = downloadUrl; - link.setAttribute("download", ""); + link.target = "_blank"; + link.setAttribute("download", pathFileName(filePath)); + document.body.appendChild(link); link.click(); + document.body.removeChild(link); } download(targetType: string, instanceId: number | string): void { @@ -2404,10 +2500,13 @@ export class PiPedalModel //implements PiPedalModel // window.open(url, "_blank"); // download with no flashing temporary tab. - let link = window.document.createElement("A") as HTMLLinkElement; + let link = window.document.createElement("A") as HTMLAnchorElement; link.href = url; - link.setAttribute("download", ""); + link.target = "_blank"; + link.download = "download.piPreset"; + document.body.appendChild(link); link.click(); + document.body.removeChild(link); } uploadUserFile(uploadPage: string, file: File, contentType: string = "application/octet-stream", abortController?: AbortController): Promise { @@ -2451,16 +2550,14 @@ export class PiPedalModel //implements PiPedalModel } }) .then((json) => { - let response = json as {errorMessage: string, path: string}; - if (response.errorMessage !== "") - { + let response = json as { errorMessage: string, path: string }; + if (response.errorMessage !== "") { throw new Error(response.errorMessage); } resolve(response.path); }) .catch((error) => { - if (error instanceof Error) - { + if (error instanceof Error) { reject("Upload failed. " + (error as Error).message); } else { reject("Upload failed. " + error); @@ -2605,7 +2702,7 @@ export class PiPedalModel //implements PiPedalModel }); } - getFilePropertyDirectoryTree(uiFileProperty: UiFileProperty,selectedPath: string): Promise { + getFilePropertyDirectoryTree(uiFileProperty: UiFileProperty, selectedPath: string): Promise { return new Promise((resolve, reject) => { let ws = this.webSocket; if (!ws) { @@ -2614,7 +2711,7 @@ export class PiPedalModel //implements PiPedalModel } ws.request( "getFilePropertyDirectoryTree", - {fileProperty: uiFileProperty, selectedPath: selectedPath} + { fileProperty: uiFileProperty, selectedPath: selectedPath } ).then((result) => { resolve(new FilePropertyDirectoryTree().deserialize(result)); }).catch((e) => { @@ -2646,6 +2743,36 @@ export class PiPedalModel //implements PiPedalModel }); }); } + copyFilePropertyFile( + oldRelativePath: string, + newRelativePath: string, + uiFileProperty: UiFileProperty, + overwrite: boolean + ): Promise { + return new Promise((resolve, reject) => { + + let ws = this.webSocket; + if (!ws) { + resolve(""); + return; + } + ws.request( + "copyFilePropertyFile", + { + oldRelativePath: oldRelativePath, + newRelativePath: newRelativePath, + uiFileProperty: uiFileProperty, + overwrite: overwrite + } + ) + .then((newPath) => { + resolve(newPath); + }) + .catch((err) => { + reject(err); + }); + }); + } setWifiConfigSettings(wifiConfigSettings: WifiConfigSettings): Promise { let result = new Promise((resolve, reject) => { @@ -3065,6 +3192,41 @@ export class PiPedalModel //implements PiPedalModel 30 * 1000); } + setPedalboardItemTitle(instanceId: number, title: string): void { + let pedalboard = this.pedalboard.get(); + if (!pedalboard) { + throw new PiPedalStateError("Pedalboard not loaded."); + } + let newPedalboard = pedalboard.clone(); + this.updateVst3State(newPedalboard); + let item = newPedalboard.getItem(instanceId); + if (item.title === title) { + return; + } + item.title = title; + this.pedalboard.set(newPedalboard); + // notify the server. + this.webSocket?.send("setPedalboardItemTitle", { instanceId: instanceId, title: title }); + } + setAlsaSequencerConfiguration(alsaSequencerConfiguration: AlsaSequencerConfiguration): void { + this.webSocket?.send("setAlsaSequencerConfiguration", alsaSequencerConfiguration); + } + async getAlsaSequencerConfiguration(): Promise { + if (this.webSocket) + { + let result = await this.webSocket.request("getAlsaSequencerConfiguration"); + return new AlsaSequencerConfiguration().deserialize(result); + } + throw new Error("No connection."); + } + async getAlsaSequencerPorts(): Promise { + if (this.webSocket) { + let result = await this.webSocket.request("getAlsaSequencerPorts"); + return AlsaSequencerPortSelection.deserialize_array(result); + } + throw new Error("No connection."); + } + }; let instance: PiPedalModel | undefined = undefined; diff --git a/vite/src/pipedal/PluginControl.tsx b/vite/src/pipedal/PluginControl.tsx index aee58fc..3aa0c33 100644 --- a/vite/src/pipedal/PluginControl.tsx +++ b/vite/src/pipedal/PluginControl.tsx @@ -23,6 +23,7 @@ import Button from '@mui/material/Button'; import { Theme } from '@mui/material/styles'; import WithStyles, { withTheme } from './WithStyles'; import { createStyles } from './WithStyles'; +import ControlTooltip from './ControlTooltip'; import { withStyles } from "tss-react/mui"; import { UiControl, ScalePoint } from './Lv2Plugin'; @@ -30,12 +31,12 @@ import Typography from '@mui/material/Typography'; import Input from '@mui/material/Input'; import Select from '@mui/material/Select'; import Switch from '@mui/material/Switch'; -import Utility, { nullCast } from './Utility'; +import Utility from './Utility'; import MenuItem from '@mui/material/MenuItem'; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import DialIcon from './svg/fx_dial.svg?react'; import { isDarkMode } from './DarkMode'; -import ControlTooltip from './ControlTooltip'; + import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import StopIcon from '@mui/icons-material/Stop'; @@ -65,14 +66,14 @@ function preventNextClickAfterDrag() { // on ANY element under the mouse. Prevent this click event // (and any other click event) from happening for 100ms // after the drag stops. - let clickHandler = (e: MouseEvent) => { + let clickHandler = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }; - document.addEventListener('click',clickHandler,true); + document.addEventListener('click', clickHandler, true); window.setTimeout(() => { - document.removeEventListener('click',clickHandler,true); - },100); + document.removeEventListener('click', clickHandler, true); + }, 100); } function androidEmoji(text: string) { @@ -119,7 +120,7 @@ export const pluginControlStyles = (theme: Theme) => createStyles({ right: 0, bottom: 4, textAlign: "center", - background: theme.mainBackground, + background: "transparent", color: theme.palette.text.secondary, // zIndex: -1, }), @@ -145,6 +146,7 @@ export interface PluginControlProps extends WithStyles) { if (this.isCapturedPointer(e)) { - if (this.pointersDown !== 0) - { + if (this.pointersDown !== 0) { --this.pointersDown; } - + if (this.pointersDown === 0) { + this.setState({ previewValue: undefined }); + } e.preventDefault(); e.stopPropagation(); @@ -515,8 +519,7 @@ const PluginControl = preventNextClickAfterDrag(); } else { - if (this.pointersDown !== 0) - { + if (this.pointersDown !== 0) { --this.pointersDown; } @@ -546,7 +549,7 @@ const PluginControl = } clickSlop() { - return 3.5; // maybe larger on touch devices. + return 5; // maybe larger on touch devices. } onPointerMove(e: PointerEvent): void { if (this.isCapturedPointer(e)) { @@ -607,6 +610,7 @@ const PluginControl = break; } } + this.setState({ previewValue: undefined}); } previewInputValue(value: number, commitValue: boolean) { let range = this.valueToRange(value); @@ -620,7 +624,7 @@ const PluginControl = UpdateGraphicEqPath(imgElement, range); } } else { - + let transform = this.rangeToRotationTransform(range); if (this.mouseDown && !commitValue) { transform += " scale(1.5, 1.5)"; @@ -670,6 +674,12 @@ const PluginControl = if (displayValue) { let v = this.formatDisplayValue(this.props.uiControl, value); displayValue.childNodes[0].textContent = v; + + if (commitValue) { + this.setState({ previewValue: undefined }); + } else { + this.setState({ previewValue: v }); + } } let selectElement = this.selectRef.current; if (selectElement) { @@ -703,45 +713,51 @@ const PluginControl = if (control.isOnOffSwitch()) { // normal gray unchecked state. return ( - { - this.onCheckChanged(event.target.checked); - }} - /> + + { + this.onCheckChanged(event.target.checked); + }} + /> + ); } if (control.isAbToggle()) { let classes = withStyles.getClasses(this.props); // unchecked color is not gray. return ( - { - this.onCheckChanged(event.target.checked); - }} - classes={{ - track: classes.switchTrack - }} - style={{ color: this.props.theme.palette.primary.main }} - /> + + { + this.onCheckChanged(event.target.checked); + }} + classes={{ + track: classes.switchTrack + }} + style={{ color: this.props.theme.palette.primary.main }} + /> + ); } else { return ( - + {control.scale_points.map((scale_point: ScalePoint) => ( + {scale_point.label} - ))} - + ))} + + ); } } @@ -923,12 +939,10 @@ const PluginControl = alignSelf: "stretch", marginBottom: 8, marginLeft: isSelect ? 8 : 0, marginRight: 0 }}> - - {isButton ? "\u00A0" : control.name} - + {isButton ? "\u00A0" : control.name}
{/* CONTROL SECTION */} @@ -937,81 +951,86 @@ const PluginControl = {isButton ? ( control.name.length !== 1 ? ( - + > + {control.name} + + ) : ( - + > + {androidEmoji(control.name)} + + ) ) @@ -1020,34 +1039,40 @@ const PluginControl = ) : (isGraphicEq) ? (
- + + /> +
) : (
- + + /> +
) } @@ -1068,21 +1093,30 @@ const PluginControl = defaultValue={control.formatShortValue(value)} error={this.state.error} inputProps={{ + className: "scrollMod", min: this.props.uiControl?.min_value, max: this.props.uiControl?.max_value, - 'aria-label': - control.symbol + " value", - style: { textAlign: "center", fontSize: FONT_SIZE }, }} + + sx={{ + // Style the input element + '& input[type=number]': { + width: 60, + opacity: this.state.editFocused ? 1 : 0, + textAlign: "center", fontSize: FONT_SIZE, + borderBottom: "0px", + }, + }} + inputRef={this.inputRef} onChange={this.onInputChange} onBlur={this.onInputLostFocus} onFocus={this.onInputFocus} onKeyPress={this.onInputKeyPress} /> -
{ this.inputRef.current!.focus(); }} - style={{display: this.state.editFocused? "none": "block"}} - > +
{ this.inputRef.current!.focus(); }} + style={{ display: this.state.editFocused ? "none" : "block" }} + > diff --git a/vite/src/pipedal/PluginControlView.tsx b/vite/src/pipedal/PluginControlView.tsx index 8e475c7..0a73410 100644 --- a/vite/src/pipedal/PluginControlView.tsx +++ b/vite/src/pipedal/PluginControlView.tsx @@ -19,8 +19,8 @@ import { ReactNode } from 'react'; import { Theme } from '@mui/material/styles'; -import WithStyles, {withTheme} from './WithStyles'; -import {createStyles} from './WithStyles'; +import WithStyles, { withTheme } from './WithStyles'; +import { createStyles } from './WithStyles'; import { css } from '@emotion/react'; import { withStyles } from "tss-react/mui"; @@ -42,7 +42,7 @@ import JsonAtom from './JsonAtom'; import PluginOutputControl from './PluginOutputControl'; import Units from './Units'; import ToobFrequencyResponseView from './ToobFrequencyResponseView'; -import Tooltip from '@mui/material/Tooltip'; +import ToolTipEx from './ToolTipEx'; import MidiChannelBindingControl from './MidiChannelBindingControl'; import MidiChannelBinding from './MidiChannelBinding'; @@ -97,10 +97,37 @@ const styles = (theme: Theme) => createStyles({ overflowX: "hidden", overflowY: "hidden" }), + noScrollFrame: css({ + display: "block", + position: "relative", + width: "100%", + height: "100%", + + left: 0, top: 0, + flexDirection: "row", + flexWrap: "nowrap", + paddingTop: "0px", + paddingBottom: "0px", + overflowX: "hidden", + overflowY: "hidden" + + }), + frameScrollNone: css({ + display: "block", + position: "relative", + left: 0, top: 0, + width: "100%", height: "100%", + flexDirection: "row", + flexWrap: "nowrap", + paddingTop: "0px", + paddingBottom: "0px", + overflowX: "auto", + overflowY: "hidden" + }), frameScrollLandscape: css({ display: "block", position: "absolute", - left: 0, top: 0, right: 0, bottom:0, + left: 0, top: 0, right: 0, bottom: 0, flexDirection: "row", flexWrap: "nowrap", paddingTop: "0px", @@ -111,7 +138,7 @@ const styles = (theme: Theme) => createStyles({ frameScrollPortrait: css({ display: "block", position: "absolute", - left: 0, top: 0, right: 0, bottom:0, + left: 0, top: 0, right: 0, bottom: 0, flexDirection: "row", flexWrap: "nowrap", paddingTop: "0px", @@ -150,12 +177,26 @@ const styles = (theme: Theme) => createStyles({ }), + noScrollGrid: css({ + position: "relative", + left: 0, + top: 0, + bottom: 0, + width: "100%", + height: "100%", + paddingLeft: 30, + paddingRight: 45, + paddingTop: 0, + + flex: "1 1 auto", + }), + normalGrid: css({ position: "relative", paddingLeft: 30, - paddingRight: 30, + paddingRight: 45, paddingTop: 8, - + flex: "1 1 auto", display: "flex", flexDirection: "row", flexWrap: "wrap", justifyContent: "flex-start", alignItems: "flex_start", @@ -171,7 +212,8 @@ const styles = (theme: Theme) => createStyles({ // See the spacer div added after all controls in render() with provides the same effect. display: "flex", flexDirection: "row", flexWrap: "nowrap", justifyContent: "flex-start", alignItems: "flex-start", - + position: "relative", + overflowX: "hidden", overflowY: "hidden", flex: "0 0 auto", @@ -218,7 +260,7 @@ const styles = (theme: Theme) => createStyles({ elevation: 12, display: "flex", flexDirection: "row", flexWrap: "wrap", - + flex: "0 1 auto", }), portGroupLandscape: css({ @@ -229,7 +271,7 @@ const styles = (theme: Theme) => createStyles({ position: "relative", paddingLeft: 0, paddingRight: 0, - + paddingTop: 0, paddingBottom: 0, border: "2pt #AAA solid", @@ -238,7 +280,7 @@ const styles = (theme: Theme) => createStyles({ display: "inline-flex", textOverflow: "ellipsis", flexDirection: "row", flexWrap: "nowrap", - flex: "0 0 auto", + flex: "0 0 auto", width: "fit-content", minWidth: "max-content" }), @@ -287,7 +329,8 @@ export type ControlNodes = (ReactNode | ControlGroup)[]; export interface ControlViewCustomization { - ModifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[]; + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[]; + fullScreen(): boolean; } @@ -407,11 +450,13 @@ const PluginControlView = /> )); } + private ixKey: number = 1; + makeStandardControl(uiControl: UiControl, controlValues: ControlValue[]): ReactNode { let symbol = uiControl.symbol; if (!uiControl.is_input) { return ( - + ); } @@ -427,38 +472,43 @@ const PluginControlView = throw new PiPedalStateError("Missing control value."); } return (( - - { this.onControlValueChanged(controlValue!.key, value) }} onPreviewChange={(value: number) => { this.onPreviewChange(controlValue!.key, value) }} requestIMEEdit={(uiControl: any, value: any) => this.requestImeEdit(uiControl, value)} /> )); + // return (( + // { this.onControlValueChanged(controlValue!.key, value) }} + // onPreviewChange={(value: number) => { this.onPreviewChange(controlValue!.key, value) }} + // requestIMEEdit={(uiControl: any, value: any) => this.requestImeEdit(uiControl, value)} + + // /> + // )); } - push_control(controls:ControlNodes, pluginControl: UiControl,controlValues: ControlValue[]) - { + push_control(controls: ControlNodes, pluginControl: UiControl, controlValues: ControlValue[]) { // combine lamps with their previous control - if ((pluginControl.isLamp() || pluginControl.isDbVu()) && controls.length !== 0) - { + if ((pluginControl.isLamp() || pluginControl.isDbVu()) && controls.length !== 0) { const classes = withStyles.getClasses(this.props); - let newControl = this.makeStandardControl(pluginControl,controlValues); - let previousControl = controls[controls.length-1]; + let newControl = this.makeStandardControl(pluginControl, controlValues); + let previousControl = controls[controls.length - 1]; if (!(previousControl instanceof ControlGroup)) { let pair = ( -
+
{previousControl as ReactNode} {newControl}
); - controls[controls.length-1] = pair; - // push a spacer control in order to make placing of extended controls predictable. + controls[controls.length - 1] = pair; + // push a spacer control in order to make placing of extended controls predictable. // (e.g.. inserting at position 4 still places the extended control after four previous controls controls.push(( -
+
)); } else { controls.push(newControl); @@ -492,7 +542,7 @@ const PluginControlView = pluginControl = plugin.controls[i]; if (!pluginControl.isHidden()) { - this.push_control(groupControls,pluginControl,controlValues); + this.push_control(groupControls, pluginControl, controlValues); indexes.push(pluginControl.index); } } @@ -502,7 +552,7 @@ const PluginControlView = ) portGroupMap[pluginControl.port_group] = controlGroup; } else { - this.push_control(result,pluginControl,controlValues); + this.push_control(result, pluginControl, controlValues); } } } @@ -636,7 +686,7 @@ const PluginControlView = let item = controlGroup.controls[j]; controls.push( ( -
+
{item}
@@ -645,13 +695,12 @@ const PluginControlView = } result.push(( -
+
- {controlGroup.name} - +
@@ -663,11 +712,20 @@ const PluginControlView = )); } else { - result.push(( -
- {node as ReactNode} -
- )); + if (this.fullScreen()) { + result.push( +
+ {node as ReactNode} +
+ ); + } else { + result.push(( +
+ {node as ReactNode} +
+ )); + } } } @@ -686,7 +744,7 @@ const PluginControlView = } return ( { + onChange={(result) => { }} /> @@ -694,6 +752,12 @@ const PluginControlView = } + fullScreen() { + if (this.props.customization) { + return this.props.customization.fullScreen(); + } + return false; + } render(): ReactNode { this.controlKeyIndex = 0; @@ -734,20 +798,26 @@ const PluginControlView = let scrollClass = this.state.landscapeGrid ? classes.frameScrollLandscape : classes.frameScrollPortrait; let vuMeterRClass = this.state.landscapeGrid ? classes.vuMeterRLandscape : classes.vuMeterR; let controlNodes: ControlNodes; + let frameClass = classes.frame; + + if (this.fullScreen()) { + gridClass = classes.noScrollGrid; + scrollClass = classes.frameScrollNone; + frameClass = classes.noScrollFrame; + } controlNodes = this.getStandardControlNodes(plugin, controlValues); if (this.props.customization) { // allow wrapper class to insert/remove/rebuild controls. - controlNodes = this.props.customization.ModifyControls(controlNodes); + controlNodes = this.props.customization.modifyControls(controlNodes); } let nodes = this.controlNodesToNodes(controlNodes); - if (plugin.has_midi_input && !pedalboardItem.midiChannelBinding) - { + if (plugin.has_midi_input && !pedalboardItem.midiChannelBinding) { pedalboardItem.midiChannelBinding = MidiChannelBinding.CreateMissingValue(); - + } if (pedalboardItem.midiChannelBinding) { nodes.push(this.midiBindingControl(pedalboardItem)); @@ -755,7 +825,7 @@ const PluginControlView = return ( -
+
@@ -768,9 +838,11 @@ const PluginControlView = nodes } {/* Extra space to allow scrolling right to the end in lascape especially */} -
+ {!this.fullScreen() && ( +
+ )} { - (!this.state.landscapeGrid) && ( + (!this.state.landscapeGrid) && (!this.fullScreen()) && (
) } @@ -785,7 +857,7 @@ const PluginControlView = onCancel={() => { this.setState({ showFileDialog: false }); }} - onApply={(fileProperty,selectedFile) => { + onApply={(fileProperty, selectedFile) => { this.model.setPatchProperty( this.props.instanceId, fileProperty.patchProperty, diff --git a/vite/src/pipedal/PluginInfoDialog.tsx b/vite/src/pipedal/PluginInfoDialog.tsx index 6a37c86..0054eb0 100644 --- a/vite/src/pipedal/PluginInfoDialog.tsx +++ b/vite/src/pipedal/PluginInfoDialog.tsx @@ -18,6 +18,7 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React from 'react'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; import { createStyles } from './WithStyles'; @@ -27,8 +28,7 @@ import DialogEx from './DialogEx'; import MuiDialogTitle from '@mui/material/DialogTitle'; import MuiDialogContent from '@mui/material/DialogContent'; import MuiDialogActions from '@mui/material/DialogActions'; -import IconButton from '@mui/material/IconButton'; -import CloseIcon from '@mui/icons-material/Close'; +import IconButtonEx from './IconButtonEx'; import Typography from '@mui/material/Typography'; import Grid from '@mui/material/Grid'; import { PiPedalModelFactory } from "./PiPedalModel"; @@ -67,23 +67,6 @@ export interface PluginInfoDialogTitleProps extends WithStyles { onClose: () => void; } -// const PluginInfoDialogTitle = withStyles(styles)((props: PluginInfoDialogTitleProps) => { -// const { children, classes, onClose, ...other } = props; -// return ( -// -//
-// -//
-//
-// {children} -//
-// handleClose()} -// style={{ flex: "0 0 auto" }}> -// -// -//
-// ); -// }); const PluginInfoDialogContent = withStyles( MuiDialogContent, @@ -255,32 +238,36 @@ const PluginInfoDialog = withStyles((props: PluginInfoProps) => { return (
- - + {open && ( -
-
- +
+ + { handleClose()}} + > + + + +
+
{plugin.name}
- handleClose()} - style={{ flex: "0 0 auto" }} - size="large"> - -
diff --git a/vite/src/pipedal/PluginPresetSelector.tsx b/vite/src/pipedal/PluginPresetSelector.tsx index 8aa2f2a..d3036a3 100644 --- a/vite/src/pipedal/PluginPresetSelector.tsx +++ b/vite/src/pipedal/PluginPresetSelector.tsx @@ -18,7 +18,7 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import { SyntheticEvent, Component } from 'react'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import { PiPedalModel, PiPedalModelFactory, PluginPresetsChangedHandle } from './PiPedalModel'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; @@ -293,9 +293,11 @@ const PluginPresetSelector = } return (
- this.handlePresetMenuClick(e)} size="large"> + this.handlePresetMenuClick(e)} size="large"> - + this.handleDialogClose()} /> this.handleRenameDialogClose()} diff --git a/vite/src/pipedal/PluginPresetsDialog.tsx b/vite/src/pipedal/PluginPresetsDialog.tsx index 2573c78..27d848c 100644 --- a/vite/src/pipedal/PluginPresetsDialog.tsx +++ b/vite/src/pipedal/PluginPresetsDialog.tsx @@ -18,7 +18,7 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React, { SyntheticEvent,Component } from 'react'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import Typography from '@mui/material/Typography'; import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import Button from "@mui/material/Button"; @@ -371,17 +371,17 @@ const PluginPresetsDialog = withStyles(
- - + {title} - this.showActionBar(true)} > + this.showActionBar(true)} > - + {(!this.props.isEditDialog) ? ( - this.showActionBar(false)} aria-label="close"> + this.showActionBar(false)} aria-label="close"> - + ) : ( - - + )} @@ -413,6 +413,7 @@ const PluginPresetsDialog = withStyles( Rename - this.handleDeleteClick()} > + this.handleDeleteClick()} > Delete - - { this.onMoreClick(e) }} > + + { this.onMoreClick(e) }} > - + - - + Presets - this.showActionBar(true)} > + this.showActionBar(true)} > - + {(!this.props.isEditDialog) ? ( - this.showActionBar(false)} aria-label="close"> + this.showActionBar(false)} aria-label="close"> - + ) : ( - - + )} @@ -385,7 +385,9 @@ const PresetDialog = withStyles( {(this.state.presets.getItem(this.state.selectedItem) != null) && ( -
+
@@ -393,6 +395,7 @@ const PresetDialog = withStyles( Rename - this.handleDeleteClick()} > + this.handleDeleteClick()} > Delete - - { this.onMoreClick(e) }} > + + { this.onMoreClick(e) }} > - + void; @@ -95,6 +96,7 @@ const PresetSelector = showEditPresetsDialog: false, presetsMenuAnchorRef: null, renameDialogOpen: false, + renameDialogTitle: "", renameDialogDefaultName: "", renameDialogActionName: "", renameDialogOnOk: undefined, @@ -144,7 +146,7 @@ const PresetSelector = if (item == null) return; let name = item.name; - this.renameDialogOpen(name, "Save As") + this.renameDialogOpen(name, "Save Preset As", "OK") .then((newName) => { return this.model.saveCurrentPresetAs(newName); }) @@ -167,7 +169,7 @@ const PresetSelector = if (item == null) return; let name = item.name; - this.renameDialogOpen(name, "Rename") + this.renameDialogOpen(name, "Rename Preset", "OK") .then((newName) => { if (newName === name) return; return this.model.renamePresetItem(this.model.presets.get().selectedInstanceId, newName); @@ -196,12 +198,13 @@ const PresetSelector = showError(error: string) { this.model.showAlert(error); } - renameDialogOpen(defaultText: string, acceptButtonText: string): Promise { + renameDialogOpen(defaultText: string, title: string,acceptButtonText: string): Promise { let result = new Promise( (resolve, reject) => { this.setState( { renameDialogOpen: true, + renameDialogTitle: title, renameDialogDefaultName: defaultText, renameDialogActionName: acceptButtonText, renameDialogOnOk: (name) => { @@ -300,12 +303,12 @@ const PresetSelector = justifyContent: "left", flexWrap: "nowrap", alignItems: "center", height: "100%", position: "relative" }}>
- { this.handleSave(); }} size="large"> - +
@@ -337,13 +340,14 @@ const PresetSelector =
- this.handlePresetMenuClick(e)} size="large" > - + this.handleDialogClose()} /> this.handleRenameDialogClose()} diff --git a/vite/src/pipedal/RenameDialog.tsx b/vite/src/pipedal/RenameDialog.tsx index c795c60..e802165 100644 --- a/vite/src/pipedal/RenameDialog.tsx +++ b/vite/src/pipedal/RenameDialog.tsx @@ -20,6 +20,7 @@ import React from 'react'; import Button from '@mui/material/Button'; import DialogEx from './DialogEx'; +import DialogTitle from '@mui/material/DialogTitle'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import { nullCast } from './Utility'; @@ -33,6 +34,9 @@ export interface RenameDialogProps { open: boolean, defaultName: string, acceptActionName: string, + title: string, + allowEmpty?: boolean, + label?: string, onOk: (text: string) => void, onClose: () => void }; @@ -102,7 +106,7 @@ export default class RenameDialog extends ResizeResponsiveComponent): void => { @@ -119,20 +123,23 @@ export default class RenameDialog extends ResizeResponsiveComponent{}} > - + + {props.title ?? "Rename"} + + + {(this.props.showSearchIcon ?? true) && ( - { this.props.onClick(); }} size="large"> + { this.props.onClick(); }} size="large"> - + ) } @@ -118,7 +118,7 @@ const SearchControl = withTheme(withStyles( placeholder="Search" endAdornment={( - { this.props.onClearFilterClick(); @@ -130,7 +130,7 @@ const SearchControl = withTheme(withStyles( edge="end" size="large"> - + )} diff --git a/vite/src/pipedal/SelectMidiChannelsDialog.tsx b/vite/src/pipedal/SelectMidiChannelsDialog.tsx index 22a57e6..4ca546e 100644 --- a/vite/src/pipedal/SelectMidiChannelsDialog.tsx +++ b/vite/src/pipedal/SelectMidiChannelsDialog.tsx @@ -17,100 +17,196 @@ // 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. +import React from 'react'; import { useState } from 'react'; import Button from '@mui/material/Button'; import List from '@mui/material/List'; import ListItemButton from '@mui/material/ListItemButton'; import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import FormControlLabel from '@mui/material/FormControlLabel'; +import Typography from '@mui/material/Typography'; import Checkbox from '@mui/material/Checkbox'; -import {AlsaMidiDeviceInfo} from './AlsaMidiDeviceInfo'; +import { AlsaSequencerConfiguration, AlsaSequencerPortSelection } from './AlsaSequencer'; import DialogEx from './DialogEx'; - +import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; export interface SelectMidiChannelsDialogProps { open: boolean; - selectedChannels: AlsaMidiDeviceInfo[]; - availableChannels: AlsaMidiDeviceInfo[]; - onClose: (selectedChannels: AlsaMidiDeviceInfo[] | null) => void; + onClose: () => void; } -function isChecked(selectedChannels: AlsaMidiDeviceInfo[], channel: AlsaMidiDeviceInfo): boolean { - for (let i = 0; i < selectedChannels.length; ++i) { - if (selectedChannels[i].equals(channel)) return true; - } - return false; -} -function addPort(availableChannels: AlsaMidiDeviceInfo[], selectedChannels: AlsaMidiDeviceInfo[], newChannel: AlsaMidiDeviceInfo) -:AlsaMidiDeviceInfo[] - { - let result: AlsaMidiDeviceInfo[] = []; - for (let i = 0; i < availableChannels.length; ++i) { - let channel = availableChannels[i]; - if (isChecked(selectedChannels, channel) || channel.equals(newChannel)) { - result.push(channel); - } - } - return result; -} - -function removePort(selectedChannels: AlsaMidiDeviceInfo[], channel: AlsaMidiDeviceInfo) -:AlsaMidiDeviceInfo[] -{ - let result: AlsaMidiDeviceInfo[] = []; - for (let i = 0; i < selectedChannels.length; ++i) { - if (!selectedChannels[i].equals(channel)) { - result.push(selectedChannels[i]); - } - } - return result; -} +interface DialogItem { + id: string; + name: string; + sortOrder: number + offline: boolean; +}; function SelectMidiChannelsDialog(props: SelectMidiChannelsDialogProps) { //const classes = useStyles(); - const { onClose, selectedChannels, availableChannels, open } = props; - const [currentSelection, setCurrentSelection] = useState(selectedChannels); + const { open, onClose } = props; + const [availablePorts, setAvailablePorts] = useState(null); + const [configuration, setConfiguration] = useState(null); + const [allPorts, setAllPorts] = useState(null); + const [model] = useState(PiPedalModelFactory.getInstance()); + const [changed, setChanged] = useState(false); + const [ readyToDisplay, setReadyToDisplay ] = useState(false); - - - let toggleSelect = (value: AlsaMidiDeviceInfo) => { - if (!isChecked(currentSelection, value)) { - setCurrentSelection(addPort(availableChannels, currentSelection, value)); + React.useEffect(() => { + if (open) { + setReadyToDisplay(false); + model.getAlsaSequencerPorts().then((ports) => { + setAvailablePorts(ports); + }).catch((error) => { + model.showAlert(error); + setReadyToDisplay(true); + setAvailablePorts(null); + }); + model.getAlsaSequencerConfiguration().then((config) => { + setConfiguration(config); + }).catch((error) => { + model.showAlert(error); + setReadyToDisplay(true); + setConfiguration(null); + }); + return () => { + } } else { - setCurrentSelection(removePort(currentSelection, value)); + return () => { }; } + }, [open]); + React.useEffect(() => { + if (availablePorts !== null && configuration !== null) { + let result: DialogItem[] = []; + setReadyToDisplay(true); + for (let port of availablePorts) { + result.push({ + id: port.id, + name: port.name, + sortOrder: port.sortOrder, + offline: false + }); + } + + // include ports that have been previously selected but are not in the current list of available ports + for (let port of configuration.connections) { + if (!availablePorts.some((p) => p.id === port.id)) { + result.push( + { + id: port.id, + name: port.name, + sortOrder: port.sortOrder, + offline: true + } + ); + } + } + result.sort((a, b) => { + return a.sortOrder - b.sortOrder; + }); + setAllPorts(result); + } else { + setAllPorts(null); + } + + }, [availablePorts, configuration]); + + const isChecked = (value: DialogItem) => { + if (availablePorts === null || configuration === null) { + return false; + } + return configuration.connections.some((port) => port.id === value.id); + }; + const setChecked = (value_: DialogItem, checked: boolean) => { + if (availablePorts === null || configuration === null) { + return; + } + let value = new AlsaSequencerPortSelection(); + value.id = value_.id; + value.name = value_.name; + value.sortOrder = value_.sortOrder; + let newConnections = configuration.connections.slice(); + if (checked) { + newConnections.push(value); + } else { + newConnections = newConnections.filter((port) => port.id !== value.id); + } + let newConfiguration = new AlsaSequencerConfiguration(); + newConfiguration.connections = newConnections; + setConfiguration(newConfiguration); + }; + let toggleSelect = (value: DialogItem) => { + if (availablePorts === null || configuration === null) { + return; + } + if (!isChecked(value)) { + setChecked(value, true); + } else { + setChecked(value, false); + } + setChanged(true); }; const handleClose = (): void => { - onClose(null); + onClose(); }; const handleOk = (): void => { - onClose(currentSelection); + if (changed && configuration !== null) { + model.setAlsaSequencerConfiguration(configuration); + } + onClose(); }; return ( - - Select MIDI Device - - {availableChannels.map((channel) => ( - - toggleSelect(channel)} key={channel.name} /> - } - label={channel.description} - /> - - ) + Select MIDI Inputs + + + {allPorts !== null && allPorts.length === 0 && ( + + No MIDI devices found. + )} + {allPorts != null && allPorts.map((port) => ( + + toggleSelect(port)} /> + } + label={ + ( +
+ + {port.name} + + {port.offline && ( + + (offline) + + )} +
+ )} + /> +
+ ) - )} -
+ )} +
+
)}
diff --git a/vite/src/pipedal/SnapshotDialog.tsx b/vite/src/pipedal/SnapshotDialog.tsx index f1a725b..765de56 100644 --- a/vite/src/pipedal/SnapshotDialog.tsx +++ b/vite/src/pipedal/SnapshotDialog.tsx @@ -29,7 +29,7 @@ import DialogEx from './DialogEx'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; import { TransitionProps } from '@mui/material/transitions'; @@ -114,10 +114,10 @@ export default class SnapshotDialog extends ResizeResponsiveComponent
- { this.props.onOk(); }} aria-label="back" + { this.props.onOk(); }} aria-label="back" > - + Snapshots
diff --git a/vite/src/pipedal/SnapshotEditor.tsx b/vite/src/pipedal/SnapshotEditor.tsx index 63fd2e1..5708b44 100644 --- a/vite/src/pipedal/SnapshotEditor.tsx +++ b/vite/src/pipedal/SnapshotEditor.tsx @@ -27,7 +27,7 @@ import CssBaseline from '@mui/material/CssBaseline'; import {createStyles} from './WithStyles'; import { withStyles } from "tss-react/mui"; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import FullscreenIcon from '@mui/icons-material/Fullscreen'; import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; @@ -271,14 +271,15 @@ const SnapshotEditor = withStyles( - { this.handleOk(); }} size="large"> - + {!this.state.collapseLabel && ( {this.state.canFullScreen && - { this.toggleFullScreen(); }} color="inherit" @@ -322,15 +324,16 @@ const SnapshotEditor = withStyles( )} - + } - { this.props.onClose(); }} color="inherit" size="medium"> - + diff --git a/vite/src/pipedal/SnapshotPropertiesDialog.tsx b/vite/src/pipedal/SnapshotPropertiesDialog.tsx index a70f205..9296218 100644 --- a/vite/src/pipedal/SnapshotPropertiesDialog.tsx +++ b/vite/src/pipedal/SnapshotPropertiesDialog.tsx @@ -29,7 +29,7 @@ import Typography from '@mui/material/Typography'; import DialogEx from './DialogEx'; import DialogTitle from '@mui/material/DialogTitle'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import { colorKeys } from "./MaterialColors"; @@ -117,10 +117,10 @@ export default class SnapshotPropertiesDialog extends ResizeResponsiveComponent< >
- { this.props.onClose(); }} aria-label="back" + { this.props.onClose(); }} aria-label="back" > - + {this.props.editing ? "Edit snapshot" : "Save snapshot"} diff --git a/vite/src/pipedal/SystemMidiBindingView.tsx b/vite/src/pipedal/SystemMidiBindingView.tsx index 0d3ab11..8b87e46 100644 --- a/vite/src/pipedal/SystemMidiBindingView.tsx +++ b/vite/src/pipedal/SystemMidiBindingView.tsx @@ -23,199 +23,21 @@ */ -import { Component } from 'react'; -import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; -import { Theme } from '@mui/material/styles'; -import WithStyles from './WithStyles'; -import { withStyles } from "tss-react/mui"; -import {createStyles} from './WithStyles'; - -import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; import MidiBinding from './MidiBinding'; -import Utility from './Utility'; -import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; -import MicOutlinedIcon from '@mui/icons-material/MicOutlined'; -import IconButton from '@mui/material/IconButton'; - - - -const styles = (theme: Theme) => createStyles({ - controlDiv: { flex: "0 0 auto", marginRight: 12, verticalAlign: "center", height: 48, paddingTop: 8, paddingBottom: 8 }, - controlDiv2: { - flex: "0 0 auto", marginRight: 12, verticalAlign: "center", - height: 48, paddingTop: 0, paddingBottom: 0, whiteSpace: "nowrap" - } -}); - -interface SystemMidiBindingViewProps extends WithStyles { +import MidiBindingView, {MidiControlType} from './MidiBindingView'; +interface SystemMidiBindingViewProps { instanceId: number; - listen: boolean; midiBinding: MidiBinding; onChange: (instanceId: number, newBinding: MidiBinding) => void; - onListen: (instanceId: number, key: string, listenForControl: boolean) => void; } - - -interface SystemMidiBindingViewState { +function SystemMidiBindingView(props: SystemMidiBindingViewProps) { + return ( + ) } - - - -const SystemMidiBindingView = - withStyles( - class extends Component { - - model: PiPedalModel; - - constructor(props: SystemMidiBindingViewProps) { - super(props); - this.model = PiPedalModelFactory.getInstance(); - this.state = { - }; - } - - handleTypeChange(e: any, extra: any) { - let newValue = parseInt(e.target.value); - let newBinding = this.props.midiBinding.clone(); - newBinding.bindingType = newValue; - this.props.onChange(this.props.instanceId, newBinding); - } - handleNoteChange(e: any, extra: any) { - let newValue = parseInt(e.target.value); - let newBinding = this.props.midiBinding.clone(); - newBinding.note = newValue; - this.props.onChange(this.props.instanceId, newBinding); - } - handleControlChange(e: any, extra: any) { - let newValue = parseInt(e.target.value); - let newBinding = this.props.midiBinding.clone(); - newBinding.control = newValue; - this.props.onChange(this.props.instanceId, newBinding); - } - handleLatchControlTypeChange(e: any, extra: any) { - let newValue = parseInt(e.target.value); - let newBinding = this.props.midiBinding.clone(); - newBinding.switchControlType = newValue; - this.props.onChange(this.props.instanceId, newBinding); - } - handleLinearControlTypeChange(e: any, extra: any) { - let newValue = parseInt(e.target.value); - let newBinding = this.props.midiBinding.clone(); - newBinding.linearControlType = newValue; - this.props.onChange(this.props.instanceId, newBinding); - } - handleMinChange(value: number): void { - let newBinding = this.props.midiBinding.clone(); - newBinding.minValue = value; - this.props.onChange(this.props.instanceId, newBinding); - } - handleMaxChange(value: number): void { - let newBinding = this.props.midiBinding.clone(); - newBinding.maxValue = value; - this.props.onChange(this.props.instanceId, newBinding); - } - handleScaleChange(value: number): void { - let newBinding = this.props.midiBinding.clone(); - newBinding.rotaryScale = value; - this.props.onChange(this.props.instanceId, newBinding); - } - - - generateMidiSelects(): React.ReactNode[] { - let result: React.ReactNode[] = []; - - for (let i = 0; i < 127; ++i) { - result.push( - {Utility.midiNoteName(i)} - ) - } - - return result; - } - generateControlSelects(): React.ReactNode[] { - - return Utility.validMidiControllers.map((control) => ( - {control.displayName} - ) - ); - } - - - render() { - const classes = withStyles.getClasses(this.props); - let midiBinding = this.props.midiBinding; - - return ( -
-
- -
- { - (midiBinding.bindingType === MidiBinding.BINDING_TYPE_NOTE) && - ( -
- -
- ) - } - { - (midiBinding.bindingType === MidiBinding.BINDING_TYPE_CONTROL) && - ( -
- - -
- ) - } - { - if (this.props.listen) { - this.props.onListen(-2, "", false) - } else { - this.props.onListen(this.props.instanceId, this.props.midiBinding.symbol, false) - } - }} - size="large"> - {this.props.listen ? ( - - ) : ( - - )} - -
- ); - - } - }, - styles - ); - export default SystemMidiBindingView; \ No newline at end of file diff --git a/vite/src/pipedal/SystemMidiBindingsDialog.tsx b/vite/src/pipedal/SystemMidiBindingsDialog.tsx index 736f4d5..648b3f8 100644 --- a/vite/src/pipedal/SystemMidiBindingsDialog.tsx +++ b/vite/src/pipedal/SystemMidiBindingsDialog.tsx @@ -27,17 +27,17 @@ import React, { SyntheticEvent } from 'react'; import { css } from '@emotion/react'; import DialogEx from './DialogEx'; import ResizeResponsiveComponent from './ResizeResponsiveComponent'; -import { PiPedalModel, PiPedalModelFactory, ListenHandle } from './PiPedalModel'; +import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; import Typography from '@mui/material/Typography'; import { Theme } from '@mui/material/styles'; import WithStyles from './WithStyles'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { withStyles } from "tss-react/mui"; import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import MidiBinding from './MidiBinding'; import SystemMidiBindingView from './SystemMidiBindingView'; import Snackbar from '@mui/material/Snackbar'; @@ -69,11 +69,11 @@ const styles = (theme: Theme) => createStyles({ paddingTop: 12 }), plainRow: css({ - borderWidth: "1px 0px 0px 0px", borderStyle: "solid", borderColor: "transparent" + borderWidth: "1px 0px 0px 0px", borderStyle: "solid", borderColor: "transparent" }), dividerRow: css({ - borderWidth: "1px 0px 0px 0px", borderStyle: "solid", borderColor: theme.palette.divider - }) + borderWidth: "1px 0px 0px 0px", borderStyle: "solid", borderColor: theme.palette.divider + }) }); @@ -139,7 +139,7 @@ export const SystemMidiBindingDialog = } else if (item.symbol === "nextProgram") { displayName = "Next Preset"; - }else if (item.symbol === "prevProgram") { + } else if (item.symbol === "prevProgram") { displayName = "Previous Preset"; } else if (item.symbol === "snapshot1") { @@ -170,8 +170,7 @@ export const SystemMidiBindingDialog = } else { found = false; } - if (found) - { + if (found) { result.push(new BindingEntry(displayName, listenInstanceId, item)); ++listenInstanceId; } @@ -183,66 +182,9 @@ export const SystemMidiBindingDialog = hasHooks: boolean = false; handleClose() { - this.cancelListenForControl(); this.props.onClose(); } - listenTimeoutHandle?: number; - - listenHandle?: ListenHandle; - - cancelListenForControl() { - if (this.listenTimeoutHandle) { - clearTimeout(this.listenTimeoutHandle); - this.listenTimeoutHandle = undefined; - } - if (this.listenHandle) { - this.model.cancelListenForMidiEvent(this.listenHandle) - this.listenHandle = undefined; - } - - this.setState({ listenInstanceId: -2, listenSymbol: "" }); - - } - - handleListenSucceeded(instanceId: number, symbol: string, isNote: boolean, noteOrControl: number) { - this.cancelListenForControl(); - - for (var binding of this.state.systemMidiBindings) { - if (binding.instanceId === instanceId) { - let newBinding = binding.midiBinding.clone(); - - if (isNote) { - newBinding.bindingType = MidiBinding.BINDING_TYPE_NOTE; - newBinding.note = noteOrControl; - } else { - newBinding.bindingType = MidiBinding.BINDING_TYPE_CONTROL; - newBinding.control = noteOrControl; - } - - this.model.setSystemMidiBinding(instanceId, newBinding); - return; - } - } - } - - - handleListenForControl(instanceId: number, symbol: string, listenForControl: boolean): void { - this.cancelListenForControl(); - this.setState({ listenInstanceId: instanceId, listenSymbol: symbol, listenSnackbarOpen: true }); - this.listenTimeoutHandle = setTimeout(() => { - this.cancelListenForControl(); - }, 8000); - - this.listenHandle = this.model.listenForMidiEvent(listenForControl, - (isNote: boolean, noteOrControl: number) => { - this.handleListenSucceeded(instanceId, symbol, isNote, noteOrControl); - }); - - - - } - onWindowSizeChanged(width: number, height: number): void { } @@ -277,12 +219,11 @@ export const SystemMidiBindingDialog = for (var item of items) { let symbol = item.midiBinding.symbol; - let hasDivider = symbol === "snapshot1" || symbol === "stopHotspot" || symbol === "shotdown"; - if (hasDivider) - { + let hasDivider = symbol === "snapshot1" || symbol === "stopHotspot" || symbol === "shotdown"; + if (hasDivider) { result.push( -
+
); } @@ -295,14 +236,6 @@ export const SystemMidiBindingDialog = { - if (instanceId === -2) { - this.cancelListenForControl(); - } else { - this.handleListenForControl(instanceId, symbol, listenForControl); - } - }} - listen={item.instanceId === this.state.listenInstanceId && this.state.listenSymbol === item.midiBinding.symbol} onChange={(instanceId: number, newItem: MidiBinding) => this.handleItemChanged(instanceId, newItem)} /> @@ -321,7 +254,7 @@ export const SystemMidiBindingDialog = render() { let props = this.props; - let { open} = props; + let { open } = props; const classes = withStyles.getClasses(this.props); if (!open) { @@ -332,20 +265,21 @@ export const SystemMidiBindingDialog = {}} + onEnterKey={() => { }} >
- - + System MIDI Bindings diff --git a/vite/src/pipedal/TemporaryDrawer.tsx b/vite/src/pipedal/TemporaryDrawer.tsx index 6c87b0f..ee1afaf 100644 --- a/vite/src/pipedal/TemporaryDrawer.tsx +++ b/vite/src/pipedal/TemporaryDrawer.tsx @@ -23,7 +23,7 @@ import { withStyles } from "tss-react/mui"; import {createStyles} from './WithStyles'; -import IconButton from '@mui/material/Toolbar'; +import IconButtonEx from './IconButtonEx'; import Drawer from '@mui/material/Drawer'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Theme } from '@mui/material/styles'; @@ -100,9 +100,10 @@ export const TemporaryDrawer = withStyles( >
- + - +
{this.props.children} diff --git a/vite/src/pipedal/Timebase.tsx b/vite/src/pipedal/Timebase.tsx new file mode 100644 index 0000000..a957af7 --- /dev/null +++ b/vite/src/pipedal/Timebase.tsx @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +export enum TimebaseUnits { + Seconds = 0, + Samples = 1, + Beats = 2, +} + +export interface TimeSignature { + numerator: number; + denominator: number; +} + +export default interface Timebase { + units: TimebaseUnits; + tempo: number; + timeSignature: TimeSignature; +} + +export interface LoopParameters +{ + start: number; + loopEnable: boolean; + loopStart: number; + loopEnd: number; +}; + +export interface ToobPlayerSettings { + timebase: Timebase; + loopParameters: LoopParameters; +}; diff --git a/vite/src/pipedal/TimebaseselectorDialog.tsx b/vite/src/pipedal/TimebaseselectorDialog.tsx new file mode 100644 index 0000000..bdd4bd9 --- /dev/null +++ b/vite/src/pipedal/TimebaseselectorDialog.tsx @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +import React, { useState } from 'react'; +import DialogEx from './DialogEx'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import Button from '@mui/material/Button'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import TextField, { StandardTextFieldProps } from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import Toolbar from '@mui/material/Toolbar'; +import IconButtonEx from './IconButtonEx'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import Timebase, { TimebaseUnits } from './Timebase'; + +interface TimebaseSelectorDialogProps { + timebase: Timebase; + onTimebaseChange: (timebase: Timebase) => void; +} + +interface NumericEditProps extends StandardTextFieldProps { + value: number; + onValueChange: (value: number) => void; + parse: (value: string) => number; + min: number; + max: number; + step?: number; + style?: React.CSSProperties; +} + +function NumericEdit(props: NumericEditProps) { + let { value, min, max, parse, step, onValueChange,style, ...extras } = props; + let [text, setText] = React.useState(value.toString()); + let [error, setError] = React.useState(false); + let [focus, setFocus] = React.useState(false); + + React.useEffect(() => { + if (!focus) { + setText(value.toString()); + setError(false); + } + }, [value]); + + const handleChange = (event: React.ChangeEvent) => { + let newValue = parse(event.target.value); + if (!isNaN(newValue) && newValue >= min && newValue <= max) { + setText(event.target.value); + onValueChange(newValue); + setError(false); + } else { + setText(event.target.value); + setError(true); + } + }; + return ( + { + setFocus(true); + e.target.select(); + }} + onBlur={() => { + setFocus(false); + let newValue = parse(text); + if (isNaN(newValue)) { + setText(value.toString()); + } else if (newValue < min) { + newValue = min; + setText(value.toString()); + onValueChange(newValue); + } else if (newValue > max) { + newValue = max; + setText(newValue.toString()); + onValueChange(value) + + } else { + setText(newValue.toString()); + onValueChange(newValue); + } + setError(false); + }} + /> + ); +} + + +export default function TimebaseSelectorDialog(props: TimebaseSelectorDialogProps) { + const { timebase, onTimebaseChange } = props; + const [open, setOpen] = useState(false); + const [editingTimebase, setEditingTimebase] = useState({ ...timebase }); + + const handleOpen = () => { + setEditingTimebase({ ...timebase }); + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + const enableBeatControls = editingTimebase.units === TimebaseUnits.Beats; + + const handleChange = (timebase: Timebase) => { + onTimebaseChange(timebase); + }; + + const handleTimebaseTypeChange = (event: any) => { + let value = { + ...editingTimebase, + units: event.target.value as TimebaseUnits + }; + setEditingTimebase(value); + handleChange(value); + + }; + + const handleTempoChange = (tempo: number) => { + let value = { + ...editingTimebase, + tempo: tempo + }; + setEditingTimebase(value); + handleChange(value); + }; + + const handleTimeSignatureChange = (field: 'numerator' | 'denominator', numVal: number) => { + let value = { + ...editingTimebase, + timeSignature: { + ...editingTimebase.timeSignature, + [field]: numVal + } + }; + setEditingTimebase(value); + handleChange(value); + }; + + const getTimebaseTypeLabel = (timebase: Timebase): string => { + switch (timebase.units) { + case TimebaseUnits.Seconds: return 'Seconds'; + case TimebaseUnits.Samples: return 'Samples'; + case TimebaseUnits.Beats: + { + + return timebase.tempo.toString() + + " bpm (" + + timebase.timeSignature.numerator.toString() + + "/" + timebase.timeSignature.denominator.toString() + ")"; + } + default: return 'Unknown'; + } + }; + return ( + <> + + + { }} + > + + + { handleClose(); }} + > + + + + Timebase Settings + +
+ + + + + +
+ + Units + + + +
+ + Tempo (BPM) + + parseFloat(value)} + min={10} + max={400} + step={1} + onValueChange={handleTempoChange} + value={editingTimebase.tempo} + disabled={!enableBeatControls} + + variant="standard" + type="number" + style={{ width: 120, }} + /> + + Time Signature + +
+ + parseInt(value)} + min={1} + max={32} + step={1} + onValueChange={(value) => handleTimeSignatureChange('numerator', value)} + value={editingTimebase.timeSignature.numerator} + style={{ width: 80 }} + variant="standard" + type="number" + disabled={!enableBeatControls} + /> +  /  + + +
+
+
+
+
+ + + ); +} + + diff --git a/vite/src/pipedal/Tone3000AuthComplete.tsx b/vite/src/pipedal/Tone3000AuthComplete.tsx new file mode 100644 index 0000000..a74d6be --- /dev/null +++ b/vite/src/pipedal/Tone3000AuthComplete.tsx @@ -0,0 +1,85 @@ + +import React from 'react'; +import Typography from '@mui/material/Typography'; + +import { styled } from '@mui/material/styles'; + + +const Frame = styled('div', { + name: 'MuiStat', // The component name + slot: 'root', // The slot name +})(({ theme }) => ({ + flex: "1 1 auto", + gap: theme.spacing(0.5), + padding: theme.spacing(3, 4), + backgroundColor: theme.palette.background.paper, + width: "100%", + height: "100%" +})); + + +interface Tone3000AuthCompleteProps { + +} + +function Tone3000AuthComplete(props: Tone3000AuthCompleteProps) { + + function postApiKey() { + let url = new URL(window.location.href); + let apiKey = url.searchParams.get("api_key"); + if (apiKey) { + let myUrl = new URL(window.location.href); + let port = myUrl.port; + if (port === "5173") { // when running debug server. + port = "8080"; + } + let postUrl = "http://" + myUrl.hostname + ":" + port + "/var/Tone3000Auth?api_key=" + encodeURIComponent(apiKey); + fetch(postUrl, { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ api_key: apiKey }) + }).then((response) => { + if (!response.ok) { + alert("Failed to post API key to Pipedal: " + response.statusText); + } + return response.json(); + }) + .catch((error) => { + alert("Error posting API key to Pipedal: " + error.message); + }) + } else { + alert("No API key found in URL."); + } + } + React.useEffect(() => { + postApiKey(); + return () => { }; + }) + + return ( + +
+
+
+ TONE3000 Authorization Complete + + You have successfully obtained an access token from TONE3000, and can now download Toob Neural Amp models from TONE3000 + directly. + + Close this page and return to Pipedal in order to continue. +
+
+
+ + ); + +} + +export default Tone3000AuthComplete; \ No newline at end of file diff --git a/vite/src/pipedal/Tone3000Dialog.tsx b/vite/src/pipedal/Tone3000Dialog.tsx new file mode 100644 index 0000000..8b889fc --- /dev/null +++ b/vite/src/pipedal/Tone3000Dialog.tsx @@ -0,0 +1,163 @@ + + +import React from 'react'; +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 Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { PiPedalModel, PiPedalModelFactory } from './PiPedalModel'; +import DialogEx from './DialogEx'; +import Link from '@mui/material/Link'; +import Toolbar from '@mui/material/Toolbar'; +import IconButtonEx from './IconButtonEx'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; + + + + +export interface Tone3000DialogProps { + open: boolean; + onClose: () => void; +} + +function Tone3000Dialog(props: Tone3000DialogProps) { + const { open, onClose } = props; + + const model: PiPedalModel = PiPedalModelFactory.getInstance(); + + let [openAuthDialog, setOpenAuthDialog] = React.useState(!model.hasTone3000Auth.get()); + + + React.useEffect(()=> { + let authListener = (value: boolean) => { + setOpenAuthDialog(!value); + } + model.hasTone3000Auth.addOnChangedHandler(authListener); + return () => { + model.hasTone3000Auth.removeOnChangedHandler(authListener); + } + }); + + function RedirectUrl() { + let baseUrl = new URL(window.location.href); + let result = "http://" + baseUrl.hostname + ":" + baseUrl.port + "/"; + return result; + } + function AuthUrl() { + //return "https://www.tone3000.com/api/v1/auth?redirect_url=" + encodeURIComponent(RedirectUrl()) + "&opt_only=true"; + return "https://www.tone3000.com/api/v1/auth?redirect_url=" + encodeURIComponent(RedirectUrl()) + "&otp_only=true"; + } + return ( + { onClose(); }} + onClose={() => { onClose(); }} + aria-labelledby="tone3000-dialog-title" + aria-describedby="tone3000-dialog-description" + > + + + { onClose(); }} + > + + + + + TONE3000 Models + + + + + + + This is a placeholder for the Tone 3000 dialog. + + + + + + { + openAuthDialog && ( + + + + + { onClose(); }} + > + + + + + TONE3000 Authorization + + + + + +
+
+
+ + The TONE3000 website provides an + online library of models for use with TooB Neural Amp Modeller. You can download + models from the TONE3000 website onto your local machine and then upload them to + PiPedal; or, more conveniently, you can browse the TONE3000 model database directly, and download models directly + to the PiPedal server from the TONE3000 model database in a single step. + + + In order to access the TONE3000 database, you must first obtain an access token from the + TONE3000 website. Clicking on the button will take you to an external website in + order to complete the authorization process. + + + + + Privacy statement: PiPedal will only have access to your authorization token + which does not contain personally identifying information, and which is stored locally on + your PiPedal server. Please refer to + the TONE3000 privacy policy for + information on how your data is used by TONE3000. + +
+
+
+ + + +
+ )} +
+ + ); +} + +export default Tone3000Dialog; \ No newline at end of file diff --git a/vite/src/pipedal/Tone3000HelpDialog.tsx b/vite/src/pipedal/Tone3000HelpDialog.tsx new file mode 100644 index 0000000..6d6c477 --- /dev/null +++ b/vite/src/pipedal/Tone3000HelpDialog.tsx @@ -0,0 +1,92 @@ +import DialogEx from "./DialogEx"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import Toolbar from "@mui/material/Toolbar"; +import IconButtonEx from "./IconButtonEx"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import Typography from "@mui/material/Typography"; +import Link from "@mui/material/Link"; + + + +function Tone3000HelpDialog(props: { + open: boolean; + onClose: () => void; +}) { + const { open, onClose } = props; + return ( + { onClose(); }} + onClose={() => { onClose(); }} + open={open}> + + + + { onClose(); }} + > + + + + + TONE3000 Help + + + + + +
+
+
+ + The TONE3000 website provides a + massive collection of neural amp models that can be used by TooB Neural Amp Modeler. + + + Using TONE3000 model files in PiPedal is a two step process. First, download model files from + the website using your browser; and then upload + the files in your browser's Downloads directory to the PiPedal server using + the Upload button. PiPedal + automatically extracts bundles of model files from zip archives, so manual extraction is unnecessary. + + + TONE3000 is a community-driven platform where community members share Neural Amp Modeler profiles. + The site showcases the collaborative spirit of the guitar modeling community. And all of this is made + possible by Steven Atkins' Neural Amp Modeler library, which forms the foundation and core of TooB Neural Amp Modeler and other NAM-based plugins. + + + If you would like to profile your own amplifiers, and effects, the TONE3000 website allows you to + generate your own NAM profiles for free. Refer to + the TONE3000 website for more details. + + + 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. + + + + Privacy statement: PiPedal has no access to personal data used by the TONE3000 website. Please refer to + the TONE3000 privacy policy for + information on how your data is used by TONE3000. + +
+
+
+ + + + + ); +} +export default Tone3000HelpDialog; \ No newline at end of file diff --git a/vite/src/pipedal/ToobCabSimView.tsx b/vite/src/pipedal/ToobCabSimView.tsx index 97e936e..caa0e3e 100644 --- a/vite/src/pipedal/ToobCabSimView.tsx +++ b/vite/src/pipedal/ToobCabSimView.tsx @@ -60,8 +60,11 @@ const ToobCabSimView = this.state = { } } + fullScreen() { + return false; + } - ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] + modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] { controls.splice(0,0, ( ) diff --git a/vite/src/pipedal/ToobInputStageView.tsx b/vite/src/pipedal/ToobInputStageView.tsx index 8f4f601..dd37f4c 100644 --- a/vite/src/pipedal/ToobInputStageView.tsx +++ b/vite/src/pipedal/ToobInputStageView.tsx @@ -60,8 +60,11 @@ const ToobInputStageView = this.state = { } } + fullScreen() { + return false; + } - ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] + modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] { return controls; // let group = controls[1] as ControlGroup; diff --git a/vite/src/pipedal/ToobMLView.tsx b/vite/src/pipedal/ToobMLView.tsx index b33f18f..d4dd702 100644 --- a/vite/src/pipedal/ToobMLView.tsx +++ b/vite/src/pipedal/ToobMLView.tsx @@ -103,8 +103,11 @@ const ToobMLView = componentWillUnmount() { this.removeGainEnabledSubscription(); } + fullScreen() { + return false; + } - ModifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { // Find EQ group // let group = controls.find((control) => typeof control !== 'string' && (control as ControlGroup).name === "EQ") as ControlGroup; // if (group) { diff --git a/vite/src/pipedal/ToobPlayerControl.tsx b/vite/src/pipedal/ToobPlayerControl.tsx new file mode 100644 index 0000000..ac80320 --- /dev/null +++ b/vite/src/pipedal/ToobPlayerControl.tsx @@ -0,0 +1,907 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +import React, { useEffect } from 'react'; +import { styled } from '@mui/material/styles'; +import LoopDialog from './LoopDialog'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import ButtonEx from './ButtonEx'; +import IconButtonEx from './IconButtonEx'; +import Pause from '@mui/icons-material/Pause'; +import PlayArrow from '@mui/icons-material/PlayArrow'; +import FastForward from '@mui/icons-material/FastForward'; +import FastRewind from '@mui/icons-material/FastRewind'; +import { PiPedalModelFactory, State } from './PiPedalModel'; +import { pathFileNameOnly } from './FileUtils'; +import ButtonBase from '@mui/material/ButtonBase'; +import FilePropertyDialog from './FilePropertyDialog'; +import JsonAtom from './JsonAtom'; +import { UiFileProperty } from './Lv2Plugin'; +import Divider from '@mui/material/Divider'; +import useWindowSize from './UseWindowSize'; +import { getAlbumArtUri } from './AudioFileMetadata'; +import RepeatIcon from '@mui/icons-material/Repeat'; +import Timebase, { LoopParameters, TimebaseUnits } from './Timebase'; +import ControlSlider from './ControlSlider'; +import { getTrackTitle } from './AudioFileMetadata'; + + +let Player__seek = "http://two-play.com/plugins/toob-player#seek" +const AUDIO_FILE_PROPERTY_URI = "http://two-play.com/plugins/toob-player#audioFile"; +const LOOP_PROPERTY_URI = "http://two-play.com/plugins/toob-player#loop"; +class PluginState { + // Must match ProcessorState values in Lv2AudioFileProcessor.hpp + // Not an enum because it has to be json serializable. + static Idle = 0; + static Recording = 1; + static StoppingRecording = 2; + static CuePlayingThenPlay = 3; + static CuePlayingThenPause = 4; + static Paused = 5; + static Playing = 6; + static Error = 7; +}; + +const useWallpaper = false; +const WallPaper = styled('div')({ + position: 'absolute', + width: '100%', + height: '100%', + top: 0, + left: 0, + overflow: 'hidden', + background: 'linear-gradient(rgb(255, 38, 142) 0%, rgb(255, 105, 79) 100%)', + transition: 'all 500ms cubic-bezier(0.175, 0.885, 0.32, 1.275) 0s', + '&::before': { + content: '""', + width: '140%', + height: '140%', + position: 'absolute', + top: '-40%', + right: '-50%', + background: + 'radial-gradient(at center center, rgb(62, 79, 249) 0%, rgba(62, 79, 249, 0) 64%)', + }, + '&::after': { + content: '""', + width: '140%', + height: '140%', + position: 'absolute', + bottom: '-50%', + left: '-30%', + background: + 'radial-gradient(at center center, rgb(247, 237, 225) 0%, rgba(247, 237, 225, 0) 70%)', + transform: 'rotate(30deg)', + }, +}); + +function tidyHundredths(value: number): string { + if (value === 0) { + return ""; + } else if (value % 10 == 0) { + return `.${(value / 10).toString()}`; + } else { + return `.${value.toString()}`; + } + +} +export function formatTimeCompact(timebase: Timebase, sampleRate: number, seconds: number): string { + switch (timebase.units) { + case TimebaseUnits.Samples: + { + return Math.round(seconds * sampleRate).toString(); + } + break; + case TimebaseUnits.Seconds: + { + const t = Math.round(seconds * 100); + let hundredths = t % 100; + let secs = Math.floor(t / 100); + let minutes = Math.floor(secs / 60); + let hours = Math.floor(minutes / 60); + minutes = minutes % 60; + secs = secs % 60; + + if (hours == 0) { + return `${minutes}:${secs.toString().padStart(2, '0')}${tidyHundredths(hundredths)}`; + } else { + return `${hours}:${minutes}:${secs.toString().padStart(2, '0')}${tidyHundredths(hundredths)}`; + } + break; + } + case TimebaseUnits.Beats: + { + let t = Math.round(seconds * timebase.tempo / 60.0 * 100.0); + let hundredths = t % 100; + let beats = Math.floor(t / 100.0); + let bars = Math.floor(beats / timebase.timeSignature.numerator); + let beat = (beats - bars * timebase.timeSignature.numerator); + + return `${bars + 1}:${(beat + 1)}${tidyHundredths(hundredths)}`; + } + break; + default: + throw new Error("Unsupported timebase units"); + } +} + +function timebaseEqual(a: Timebase, b: Timebase): boolean { + if (a.units !== b.units) { + return false; + } + if (a.tempo !== b.tempo) { + return false; + } + if (a.timeSignature.numerator !== b.timeSignature.numerator) { + return false; + } + if (a.timeSignature.denominator !== b.timeSignature.denominator) { + return false; + } + return true; +} +function getAlbumLine(album: string, artist: string, albumArtist: string): string { + if (artist === "") { + artist = albumArtist; + } + let joiner = (artist !== "" && album !== "") ? " - " : ""; + return album + joiner + artist; + +} + +interface WidgetProps { + noBorders: boolean; + children: React.ReactNode; +} + +const WidgetBorders = styled('div')(({ theme }) => ({ + + padding: 16, + borderRadius: 16, + minWidth: 300, + maxWidth: 800, + width: "70%", + margin: 'auto', + position: 'relative', + zIndex: 1, + backgroundColor: 'rgba(255,255,255,0.4)', + boxShadow: "1px 4px 12px rgba(0,0,0,0.2)", + ...theme.applyStyles('dark', { + backgroundColor: useWallpaper ? 'rgba(0,0,0,0.6)' : '#282828', + }), +})); + +const WidgetNoBorders = styled('div')(({ theme }) => ({ + + padding: 16, + borderRadius: 0, + width: "100%", + height: "100%", + margin: 0, + position: 'relative', + zIndex: 1, + backgroundColor: 'rgba(255,255,255,0.4)', + ...theme.applyStyles('dark', { + backgroundColor: useWallpaper ? 'rgba(0,0,0,0.6)' : theme.mainBackground, + }), +})); +// const WidgetNoWallpaper = styled('div')(({ theme }) => ({ + +// padding: 16, +// borderRadius: 0, +// width: "100%", +// height: "100%", +// margin: 0, +// position: 'relative', +// zIndex: 1, + +// })); + + +function Widget(props: WidgetProps) { + // if (!useWallpaper) { + // return({props.children} ); + // } + if (props.noBorders) { + return ({props.children} ); + } else { + return ({props.children} ); + } +} + +const CoverImage = styled('div')({ + width: 60, + height: 60, + objectFit: 'cover', + overflow: 'hidden', + flexShrink: 0, + borderRadius: 8, + backgroundColor: 'rgba(0,0,0,0.08)', + '& > img': { + width: '100%', + }, +}); + + + +export interface ToobPlayerControlProps { + instanceId: number; + extraControls: React.ReactElement[]; +} + +export default function ToobPlayerControl( + props: ToobPlayerControlProps +) { + + const model = PiPedalModelFactory.getInstance(); + const sampleRate = model.jackConfiguration.get().sampleRate == 0 ? + 48000 : + model.jackConfiguration.get().sampleRate; + + + const defaultCoverArt = "/img/default_album.jpg"; + const [serverConnected, setServerConnected] = React.useState(model.state.get() == State.Ready); + const [duration, setDuration] = React.useState(0.0); + const [start, setStart] = React.useState(0.0); + const [loopStart, setLoopStart] = React.useState(0.0); + const [loopEnable, setLoopEnable] = React.useState(false); + const [loopEnd, setLoopEnd] = React.useState(0.0); + const [pluginState, setPluginState] = React.useState(0.0); + const [coverArt, setCoverArt] = React.useState(defaultCoverArt); + const [audioFile, setAudioFile] = React.useState(""); + const [position, setPosition] = React.useState(0.0); + //let position = 0; + const [title, setTitle] = React.useState(""); + const [album, setAlbum] = React.useState(""); + const [artist, setArtist] = React.useState(""); + const [albumArtist, setAlbumArtist] = React.useState(""); + const [showFileDialog, setShowFileDialog] = React.useState(false); + const [showLoopDialog, setShowLoopDialog] = React.useState(false); + const [timebase, setTimebase] = React.useState( + { + units: TimebaseUnits.Seconds, + tempo: 120.0, + timeSignature: { numerator: 4, denominator: 4 } + }); + const [loopParameters, setLoopParameters] = React.useState({ + start: 0.0, + loopEnable: false, + loopStart: 0.0, + loopEnd: 0.0 + }); + + + const [size] = useWindowSize(); + const width = size.width; + const height = size.height; + + const HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK = 500; + + const useHorizontalLayout = height < HORIZONTAL_CONTROL_SCROLL_HEIGHT_BREAK && width > 573 && width > height; + //const useVerticalScroll = width < 573; + const noBorders = width < 420 || height < 720; + let useQuadMixPanel = width < 720; + if (noBorders) { + useQuadMixPanel = width < 370; + } + + function SelectFile() { + setShowFileDialog(true); + } + function onAudioFileChanged(path: string) { + setAudioFile(path); + if (path === "") { + setTitle(""); + setAlbum(""); + setArtist(""); + setAlbumArtist(""); + setCoverArt(defaultCoverArt); + return; + } + model.getAudioFileMetadata(path) + .then((metadata) => { + let coverArtUri = getAlbumArtUri(model, metadata, path); + setCoverArt(coverArtUri); + setTitle(getTrackTitle(path, metadata)); + setAlbum(metadata.album); + setArtist(metadata.artist); + setAlbumArtist(metadata.albumArtist); + }) + .catch((e) => { + setTitle("#error" + e.message); + setAlbum(""); + }); + + } + function ControlCluster() { + return ( + ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + mt: -1, + '& svg': { + color: '#000', + ...theme.applyStyles('dark', { + color: '#fff', + }), + }, + })} + > + { + if (position > start + 3.0) { + model.sendPedalboardControlTrigger( + props.instanceId, + "stop", + 1 + ); + } else { + onPreviousTrack(); + } + }} + > + + + { + if (pluginState != PluginState.Idle) { + if (paused) { + model.sendPedalboardControlTrigger( + props.instanceId, + "play", + 1 + ); + } else { + model.sendPedalboardControlTrigger( + props.instanceId, + "pause", + 1 + ); + } + } + }} + > + {paused ? ( + + ) : ( + + )} + + { onNextTrack(); }} + > + + + + + ); + } + function FilePanel() { + let textColor = pluginState == PluginState.Error ? "error" : "textPrimary"; + + return ( + { SelectFile() }} + > + + + { + e.preventDefault(); + }} + alt="Cover Art" + src={ + coverArt + } + /> + + + {pluginState === PluginState.Idle ? ( + + Tap to select + + ) : ( +
+ + {titleLine} + + + {albumLine} + +
+ + )} +
+
+
+ ); + + } + function loopButtonText() { + if (start === 0 && !loopEnable) { + return "Set loop"; + + } else if (loopEnable) { + return `${formatTimeCompact(timebase, sampleRate, start)} [${formatTimeCompact(timebase, sampleRate, loopStart)} - ${formatTimeCompact(timebase, sampleRate, loopEnd)}]`; + } else { + return `Start: ${formatTimeCompact(timebase, sampleRate, start)}`; + } + } + function getUiFileProperty(uri: string): UiFileProperty { + let pedalboardItem = model.pedalboard.get().getItem(props.instanceId); + let uiPlugin = model.getUiPlugin(pedalboardItem.uri); + if (!uiPlugin) { + throw "uiPlugin not found."; + } + for (let property of uiPlugin.fileProperties) { + if (property.patchProperty === uri) { + return property; + } + } + throw "FileProperty not found."; + } + + function onLoopPropertyChanged(loopSettingsJson: string) { + try { + if (loopSettingsJson === "") { + setTimebase( + { + units: TimebaseUnits.Seconds, + tempo: 120.0, + timeSignature: { numerator: 4, denominator: 4 } + }) + let loopParameters = { + start: 0.0, + loopEnable: false, + loopStart: 0.0, + loopEnd: 0.0 + }; + setLoopParameters(loopParameters); + + setStart(0.0); + setLoopEnable(false); + setLoopStart(0.0); + setLoopEnd(0.0); + return; + } + let atomObject = JSON.parse(loopSettingsJson); + let loopParameters: LoopParameters = atomObject.loopParameters as LoopParameters; + let newTimebase: Timebase | undefined = atomObject.timebase as (Timebase | undefined);; + if (newTimebase !== undefined) { + if (!timebaseEqual(timebase, newTimebase)) { + setTimebase(newTimebase); + } + setLoopParameters(loopParameters); + setLoopEnable(loopParameters.loopEnable); + setStart(loopParameters.start); + setLoopStart(loopParameters.loopStart); + setLoopEnd(loopParameters.loopEnd); + } else { + throw new Error("Invalid loop settings."); + } + } catch (e) { + console.warn("Unable to parse loop settings."); + setTimebase( + { + units: TimebaseUnits.Seconds, + tempo: 120.0, + timeSignature: { numerator: 4, denominator: 4 } + }) + setLoopEnable(false); + setStart(0.0); + setLoopStart(0.0); + setLoopEnd(0.0); + } + + } + function onNextTrack() { + model.getNextAudioFile(audioFile) + .then((file) => { + let json = JsonAtom.Path(file); + model.setPatchProperty( + props.instanceId, + AUDIO_FILE_PROPERTY_URI, + json); + }) + .catch(() => { + }); + + } + function onPreviousTrack() { + model.getPreviousAudioFile(audioFile) + .then((file) => { + let json = JsonAtom.Path(file); + model.setPatchProperty( + props.instanceId, + AUDIO_FILE_PROPERTY_URI, + json); + }) + .catch(() => { + }); + } + function OnSeek(value: number) { + model.setPatchProperty(props.instanceId, Player__seek, value) + .then(() => { + }).catch((e) => { + console.warn("Seek error. " + e.toString()); + }); + } + function onStateChanged(value: State) { + setServerConnected(value === State.Ready); + } + useEffect(() => { + model.state.addOnChangedHandler(onStateChanged); + if (model.state.get() !== State.Ready) { + // wait for it. + return () => { + model.state.removeOnChangedHandler(onStateChanged); + } + } + let durationHandle = model.monitorPort(props.instanceId, "duration", 1.0 / 15, + (value) => { + setDuration(value); + } + ); + let positionHandle = model.monitorPort(props.instanceId, "position", 1.0, + (value) => { + setPosition(value); + } + ); + + let pluginStateHandle = model.monitorPort(props.instanceId, "state", 1.0 / 1000.0, + (value) => { + setPluginState(value); + } + ); + let filePropertyHandle = model.monitorPatchProperty( + props.instanceId, + AUDIO_FILE_PROPERTY_URI, + (instanceId: number, propertyUri: string, atomObject: any) => { + if (typeof (atomObject) === "object") { + let path = atomObject.value; + onAudioFileChanged(path); + } else if (typeof (atomObject) === "string") { + onAudioFileChanged(atomObject as string); + } + } + + ); + let loopPropertyHandle = model.monitorPatchProperty( + props.instanceId, + LOOP_PROPERTY_URI, + (instanceId: number, propertyUri: string, atomObject: any) => { + if (typeof (atomObject) === "string") { + onLoopPropertyChanged(atomObject as string); + } + } + + ); + + model.getPatchProperty(props.instanceId, AUDIO_FILE_PROPERTY_URI) + .then((o) => { + let path = o.value; + onAudioFileChanged(path); + }); + model.getPatchProperty(props.instanceId, LOOP_PROPERTY_URI) + .then((o) => { + onLoopPropertyChanged(o as string); + }) + + + return () => { + model.state.removeOnChangedHandler(onStateChanged); + model.unmonitorPort(durationHandle); + model.unmonitorPort(pluginStateHandle); + model.unmonitorPort(positionHandle); + // model.unmonitorPort(loopEnableHandle); + // model.unmonitorPort(startHandle); + // model.unmonitorPort(loopStartHandle); + // model.unmonitorPort(loopEndHandle); + model.cancelMonitorPatchProperty(filePropertyHandle); + model.cancelMonitorPatchProperty(loopPropertyHandle); + }; + }, + [serverConnected] + ); + const titleLine = title !== "" ? title : pathFileNameOnly(audioFile); + const albumLine = getAlbumLine(album, artist, albumArtist); + + const paused = (pluginState === PluginState.Idle + || pluginState === PluginState.CuePlayingThenPause + || pluginState === PluginState.Paused + || pluginState === PluginState.Error); + + function handlePreview() { + if (paused) { + model.sendPedalboardControlTrigger( + props.instanceId, + "play", + 1 + ); + } else { + model.sendPedalboardControlTrigger( + props.instanceId, + "stop", + 1 + ); + } + } + function handleCancelPlaying() { + if (!paused) { + model.sendPedalboardControlTrigger( + props.instanceId, + "stop", + 1 + ); + } + } + function LinearMixPanel() { + return ( + +
+ {props.extraControls} +
+
+ ); + + } + function QuadMixPanel() { + return ( +
+
+ {props.extraControls[0]} + {props.extraControls[1]} +
+
+ {props.extraControls[2]} + {props.extraControls[3]} +
+ +
+ ); + } + function SliderCluster() { + return ( + +
+
+ { + }} + onValueChanged={(value) => { + OnSeek(value); + }} + /> +
+
+ )} + onClick={() => { + if (audioFile !== "") { + setShowLoopDialog(true); + } + }} + > + + {loopButtonText() + } + + +
+
+ ); + } + + + function VerticalWidget() { + return ( +
+
+ + + { + FilePanel() + } + {SliderCluster()} + { + ControlCluster() + } + + {useQuadMixPanel ? QuadMixPanel() + : + LinearMixPanel() + } + +
+
+ ); + } + + function HorizontalWidget() { + return ( +
+ + +
+
+
+
+ { + FilePanel() + } +
+
+
+ { + SliderCluster() + } +
+
+
+ { + ControlCluster() + } +
+
+ {LinearMixPanel()} +
+
+ + +
+
+
+ ); + } + return ( +
+ {useWallpaper && ()} + { + useHorizontalLayout ? + HorizontalWidget() : VerticalWidget() + } + {showLoopDialog && ( + { handlePreview(); }} + value={loopParameters} + onCancelPlaying={() => { handleCancelPlaying(); }} + timebase={timebase} + onTimebaseChange={(newTimebase) => { + setTimebase(newTimebase); + // model.setPatchProperty( + // props.instanceId, + // "timebase", + // newTimebase + // ); + }} + + onClose={() => { + setShowLoopDialog(false); + }} + onSetLoop={(loop: LoopParameters) => { + let loopSettings = { + timebase: timebase, + loopParameters: loop + }; + model.setPatchProperty( + props.instanceId, + LOOP_PROPERTY_URI, + JSON.stringify(loopSettings)); + + setStart(loop.start); + setLoopEnable(loop.loopEnable); + setLoopStart(loop.loopStart); + setLoopEnd(loop.loopEnd); + }} + duration={duration} + /> + )} + {showFileDialog && ( + { + setShowFileDialog(false); + }} + onApply={(fileProperty, selectedFile) => { + model.setPatchProperty( + props.instanceId, + AUDIO_FILE_PROPERTY_URI, + JsonAtom.Path(selectedFile) + ) + .then(() => { + + }) + .catch((error) => { + model.showAlert("Unable to complete the operation. " + error); + }); + + }} + onOk={(fileProperty, selectedFile) => { + + model.setPatchProperty( + props.instanceId, + fileProperty.patchProperty, + JsonAtom.Path(selectedFile) + ) + .then(() => { + + }) + .catch((error) => { + model.showAlert("Unable to complete the operation. " + error); + }); + setShowFileDialog(false); + } + } + /> + + )} +
+ ); +} \ No newline at end of file diff --git a/vite/src/pipedal/ToobPlayerView.tsx b/vite/src/pipedal/ToobPlayerView.tsx new file mode 100644 index 0000000..1f40908 --- /dev/null +++ b/vite/src/pipedal/ToobPlayerView.tsx @@ -0,0 +1,154 @@ +// Copyright (c) 2022 Robin 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. + +import React from 'react'; +import { Theme } from '@mui/material/styles'; + +import WithStyles from './WithStyles'; +import { createStyles } from './WithStyles'; + +import { withStyles } from "tss-react/mui"; + +import IControlViewFactory from './IControlViewFactory'; +import { PiPedalModelFactory, PiPedalModel, MonitorPortHandle } from "./PiPedalModel"; +import { PedalboardItem } from './Pedalboard'; +import PluginControlView, { ControlGroup, ControlViewCustomization } from './PluginControlView'; +// import ToobFrequencyResponseView from './ToobFrequencyResponseView'; +import ToobPlayerControl from './ToobPlayerControl'; + +const styles = (theme: Theme) => createStyles({ +}); + +interface ToobPlayerProps extends WithStyles { + instanceId: number; + item: PedalboardItem; + +} +interface ToobPlayerState { + playPosition: number; + duration: number; +} + +const ToobPlayerView = + withStyles( + class extends React.Component + implements ControlViewCustomization { + model: PiPedalModel; + gainRef: React.RefObject; + + customizationId: number = 1; + + constructor(props: ToobPlayerProps) { + super(props); + this.model = PiPedalModelFactory.getInstance(); + this.gainRef = React.createRef(); + this.state = { + playPosition: 0.0, + duration: 0.0 + } + } + + subscribedId?: number = undefined; + monitorPlayPositionHandle?: MonitorPortHandle = undefined; + monitorDurationHandle?: MonitorPortHandle = undefined; + removePortSubscriptions() { + + if (this.monitorPlayPositionHandle) { + this.model.unmonitorPort(this.monitorPlayPositionHandle); + this.monitorPlayPositionHandle = undefined; + } + } + addPortSubscriptions(instanceId: number) { + this.removePortSubscriptions(); + this.subscribedId = instanceId; + this.monitorPlayPositionHandle = this.model.monitorPort(instanceId, "position", 1 / 15.0, + (value: number) => { + this.setState({ playPosition: value }); + }); + this.monitorDurationHandle = this.model.monitorPort(instanceId, "duration", 1 / 15.0, + (value: number) => { + this.setState({ duration: value }); + }); + } + + // componentDidUpdate() { + // if (this.props.instanceId !== this.subscribedId) { + // this.removeGainEnabledSubscription(); + // this.addGainEnabledSubscription(this.props.instanceId); + + // } + // } + componentDidMount() { + this.addPortSubscriptions(this.props.instanceId); + } + componentWillUnmount() { + this.removePortSubscriptions(); + } + fullScreen() { + return true; + } + modifyControls(controls: (React.ReactNode | ControlGroup)[]): (React.ReactNode | ControlGroup)[] { + let extraControls: React.ReactElement[] = []; + let mixPanel = controls[3] as ControlGroup; + let iKey = 0; + for (let mixControl of mixPanel.controls) { + extraControls.push( + ( +
+ {mixControl} +
+ )); + } + + let panel = ( + + ); + + let result: (React.ReactNode | ControlGroup)[] = []; + result.push(panel); + return result; + } + + render() { + return ( + + ); + } + }, + styles + ); + + + +class ToobPlayerViewFactory implements IControlViewFactory { + uri: string = "http://two-play.com/plugins/toob-player"; + + Create(model: PiPedalModel, pedalboardItem: PedalboardItem): React.ReactNode { + return (); + } + + +} +export default ToobPlayerViewFactory; \ No newline at end of file diff --git a/vite/src/pipedal/ToobPowerStage2View.tsx b/vite/src/pipedal/ToobPowerStage2View.tsx index 868f694..3d1e8b2 100644 --- a/vite/src/pipedal/ToobPowerStage2View.tsx +++ b/vite/src/pipedal/ToobPowerStage2View.tsx @@ -126,8 +126,11 @@ const ToobPowerstage2View = this.isControlMounted = false; this.maybeListenForAtomOutput(); } + fullScreen() { + return false; + } - ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] + modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] { let time = Date.now()*0.001; diff --git a/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx b/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx index 0e600ba..103d530 100644 --- a/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx +++ b/vite/src/pipedal/ToobSpectrumAnalyzerView.tsx @@ -61,8 +61,11 @@ const ToobSpectrumAnalyzerView = this.state = { } } + fullScreen() { + return false; + } - ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] + modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] { controls.splice(0,0, ( ) diff --git a/vite/src/pipedal/ToobToneStackView.tsx b/vite/src/pipedal/ToobToneStackView.tsx index 99d4ee0..055177a 100644 --- a/vite/src/pipedal/ToobToneStackView.tsx +++ b/vite/src/pipedal/ToobToneStackView.tsx @@ -86,8 +86,11 @@ const ToobToneStackView = this.controlValueChangedHandle = undefined; } } + fullScreen() { + return false; + } - ModifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] + modifyControls(controls: (React.ReactNode| ControlGroup)[]): (React.ReactNode| ControlGroup)[] { if (this.state.isBaxandall) { diff --git a/vite/src/pipedal/ToolTipEx.tsx b/vite/src/pipedal/ToolTipEx.tsx new file mode 100644 index 0000000..95590d1 --- /dev/null +++ b/vite/src/pipedal/ToolTipEx.tsx @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +import React from 'react'; +import Tooltip from '@mui/material/Tooltip'; + +export interface ToolTipExProps extends React.ComponentProps { + valueTooltip?: React.ReactNode; // For use with value prop, to show a tooltip on the value. +} + +/* ToolTipEx: a reimplementation of the MUI Tooltip that works with pointer events and long presses. */ + +function ToolTipEx(props: ToolTipExProps) { + let {title, valueTooltip, ...extras} = props; + let [open,setOpen]= React.useState(false); + let [isLongPress, setIsLongPress] = React.useState(false); + let [longPressLeaving, setLongPressLeaving] = React.useState(false); + let [timeout, setTimeout] = React.useState(0); + let [timeoutInstance, setTimeoutInstance] = React.useState(0); + let [pointerDownPoint, setPointerdownPoint] = React.useState<{x: number, y: number} | null>(null); + + const hoverTimeout = 1250; + const longpressTimeout = 500; + const longpressLeaveTimeout = 1500; + + function startTimeout(timeout: number) { + setTimeout(timeout); + setTimeoutInstance(timeoutInstance + 1); // make useeffect run. + } + function stopTimeout() { + setTimeout(0); + setTimeoutInstance(0); + } + function handleMouseEnter(event: React.MouseEvent) { + setOpen(false); + startTimeout(hoverTimeout); + } + + function handleMouseLeave(event: React.MouseEvent) { + setOpen(false); + stopTimeout(); + setLongPressLeaving(false); + } + + function handlePointerDownCapture(event: React.PointerEvent) { + let pointerType = (event as any).pointerType || "n/a"; + setIsLongPress(false); + if (pointerType === "mouse") { + setOpen(false); + stopTimeout(); // Reset hover timeout for mouse + } else { // pen, or touch. + setTimeout(longpressTimeout); + setOpen(false); + setPointerdownPoint({x: event.clientX, y: event.clientY}); + } + } + + React.useEffect(()=> { + let t = timeout; + let handle: number | null = null; + if (valueTooltip === undefined) // no timeout if there's a value tooltip + { + if (t > 0) { + // console.log("ToolTipEx: starting timeout for ", t); + handle = window.setTimeout(() => { + setOpen(true); + },t); + } + } + return () => { + if (handle !== null) { + // console.log("ToolTipEx: clearing timeout for ", t); + window.clearTimeout(handle); + } + }; + },[timeoutInstance]); + + React.useEffect(()=> { + if (longPressLeaving) { + let handle: number | null = null; + handle = window.setTimeout(() => { + setIsLongPress(false); + setLongPressLeaving(false); + }, longpressLeaveTimeout); + return () => { + if (handle !== null) { + window.clearTimeout(handle); + } + }; + } else { + return () => { }; + } + },[longPressLeaving]); + + function handlePointerCancel(event: React.PointerEvent) { + setOpen(false); + setIsLongPress(false); + stopTimeout(); + setPointerdownPoint(null); // Reset the mouse down point + } + function handlePointerMoveCapture(event: React.PointerEvent) { + let pointerType = (event as any).pointerType || "n/a"; + if (pointerType === "mouse") { + setOpen(false); + startTimeout(hoverTimeout); // Reset hover timeout for mouse + } else { // pen, or touch. + if (pointerDownPoint) { + const dx = event.clientX - pointerDownPoint.x; + const dy = event.clientY - pointerDownPoint.y; + if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { + // If the mouse has moved more than 5 pixels, re-enable the tooltip + setOpen(false); + setIsLongPress(false); + stopTimeout(); + setPointerdownPoint(null); // Reset the mouse down point + } + } + } + } + function handleConextMenu(event: React.MouseEvent) { + event.preventDefault(); // Prevent the default context menu from appearing + event.stopPropagation(); // Prevent the default context menu from appearing + if (pointerDownPoint) // touch sequence. This is a long press. + { + setIsLongPress(true); + setOpen(true); + stopTimeout(); + } + } // Reset hover timeout for context menu + function handlePointerUpCapture(event: React.PointerEvent) { + let pointerType = (event as any).pointerType || "n/a"; + if (pointerType === "mouse") { + setOpen(false); + stopTimeout(); // Reset hover timeout for mouse + } else { // pen, or touch. + stopTimeout(); + setOpen(false); + if (isLongPress) { + // If this is a long press, we want to leave the tooltip open for a while. + setLongPressLeaving(true); + } + } + } + + let effectiveTitle: React.ReactNode | null = null; + let placement: "top-start" | "right" = "top-start"; + if (valueTooltip !== undefined && !isLongPress) { + effectiveTitle = valueTooltip; // Show value tooltip if available + placement = "right"; + } + else { + if (open || isLongPress) { + effectiveTitle = title; // Show regular title if no value tooltip + } + } + function handleClickCapture(event: React.MouseEvent) { + setLongPressLeaving(false); + setIsLongPress(false); + setOpen(false); + stopTimeout(); // Reset hover timeout on click + } + return ( +
{ + // console.log("ToolTipEx: onClickCapture"); + handleClickCapture(e); + }} + + onMouseEnter={(e)=> { + // console.log("ToolTipEx: onMouseEnter"); + if (!longPressLeaving) {// Don't handle mouse enter if we're in a long press leaving state + handleMouseEnter(e); + } + }} + onMouseLeave={(e)=> { + // console.log("ToolTipEx: onMouseLeave"); + handleMouseLeave(e); + }} + + onPointerCancelCapture={(e) => { + // console.log("ToolTipEx: onPointerCancelCapture"); + handlePointerCancel(e); + }} + onContextMenuCapture={(e) => { + // console.log("ToolTipEx: onContextMenuCapture"); + handleConextMenu(e); + return false; + }} + onPointerDownCapture={(e) => { + // console.log("ToolTipEx: onPointerDownCapture"); + handlePointerDownCapture(e); + }} + onPointerMoveCapture={(e) => { + // console.log("ToolTipEx: onPointerMoveCapture"); + handlePointerMoveCapture(e); + }} + onPointerUpCapture={(e) => { + // console.log("ToolTipEx: onPointerUpCapture"); + handlePointerUpCapture(e); + }} + > + +
+ ) +} + +export default ToolTipEx; \ No newline at end of file diff --git a/vite/src/pipedal/UploadFileDialog.tsx b/vite/src/pipedal/UploadFileDialog.tsx index c8b966d..3f11ede 100644 --- a/vite/src/pipedal/UploadFileDialog.tsx +++ b/vite/src/pipedal/UploadFileDialog.tsx @@ -32,7 +32,7 @@ import SvgIcon from '@mui/material/SvgIcon'; //import ErrorIcon from '@mui/icons-material/Error'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import CircularProgress from '@mui/material/CircularProgress'; @@ -41,12 +41,38 @@ import CircularProgress from '@mui/material/CircularProgress'; // const BANK_EXTENSION = ".piBank"; // const PLUGIN_PRESETS_EXTENSION = ".piPluginPresets"; +let COVER_ART_FILES = [ + "Folder.jpg", // window media player/explorer. + "Cover.jpg", // itunes. + "folder.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. + "cover.jpg", // Linux audio players plex, kodi, Rythmbox, Amorok, Clementine. + "Artwork.jpg", // itunes. + "Front.jpg", + "front.jpg", // linux. + "AlbumArt.jpg", + "albumArt.jpg", + "Frontcover.jpg", + "AlbumArtSmall.jpg" // windows media player. +]; + +function isCoverArtFile(fileName: string) : boolean { + // does COVER_ART_FILES contain the file name? + for (let coverArtFile of COVER_ART_FILES) { + if (fileName === coverArtFile) { + return true; + } + } + return false; +} + + export interface UploadFileDialogProps { open: boolean, onClose: () => void, onUploaded: (fileName: string) => void, uploadPage: string, - fileProperty: UiFileProperty + fileProperty: UiFileProperty, + isTracksDirectory: boolean }; @@ -251,7 +277,15 @@ export default class UploadFileDialog extends ResizeResponsiveComponent
- { this.handleCancel(); }} aria-label="back" + { this.handleCancel(); }} aria-label="back" > - + Upload
diff --git a/vite/src/pipedal/UseWindowSize.tsx b/vite/src/pipedal/UseWindowSize.tsx new file mode 100644 index 0000000..e14c206 --- /dev/null +++ b/vite/src/pipedal/UseWindowSize.tsx @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Robin E. R. Davies + * All rights reserved. + + * 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. + */ + +import { useState, useEffect } from 'react'; + +const getSize = () => { + return { + width: window.innerWidth, + height: window.innerHeight, + }; +}; + +export interface WindowSize { + width: number; + height: number; +} +export default function useWindowSize() { + + const [size, setSize] = useState(getSize()); + + useEffect(() => { + let mounted = true; + let handleResizeT = () => { + let pendingCallback = false; + if (!pendingCallback) { + window.requestAnimationFrame(() => { + if (mounted) { + setSize(getSize()); + pendingCallback = false; + } + }); + pendingCallback = true; + } + } + window.addEventListener('resize', handleResizeT); + return () => { + mounted = false; + window.removeEventListener('resize', handleResizeT); + }; + }, []); + + return [size]; +} \ No newline at end of file diff --git a/vite/src/pipedal/WifiConfigDialog.tsx b/vite/src/pipedal/WifiConfigDialog.tsx index e90aafe..8da050f 100644 --- a/vite/src/pipedal/WifiConfigDialog.tsx +++ b/vite/src/pipedal/WifiConfigDialog.tsx @@ -18,7 +18,7 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import React from 'react'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import FormHelperText from '@mui/material/FormHelperText'; @@ -432,11 +432,11 @@ const WifiConfigDialog = withStyles( {NBSP} {(this.state.compactWidth || this.state.autoStartMode !== 2) && ( - { this.setState({ showHelpDialog: true }); }} > - + )}
} /> - { this.setState({ showHelpDialog: true }); }} > - +
@@ -532,7 +532,9 @@ const WifiConfigDialog = withStyles( : "" , endAdornment: ( - { this.handleTogglePasswordVisibility(); }} > @@ -546,7 +548,7 @@ const WifiConfigDialog = withStyles( ) } - + ) }} /> diff --git a/vite/src/pipedal/WifiDirectConfigDialog.tsx b/vite/src/pipedal/WifiDirectConfigDialog.tsx index 90909e6..d53c122 100644 --- a/vite/src/pipedal/WifiDirectConfigDialog.tsx +++ b/vite/src/pipedal/WifiDirectConfigDialog.tsx @@ -23,7 +23,7 @@ */ import React from 'react'; -import IconButton from '@mui/material/IconButton'; +import IconButtonEx from './IconButtonEx'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import Button from '@mui/material/Button'; @@ -305,14 +305,15 @@ const WifiDirectConfigDialog = {this.state.landscapeLayout && (
- { this.props.onClose(); }} aria-label="back" size="large"> - + - { this.model.onNextZoomedControl(); } } > - +
{this.props.controlInfo.name} - {uiControl.isDial() ? ( + {(uiControl.isDial() || uiControl.isGraphicEq()) ? ( {this.onDoubleTap();}} @@ -281,14 +281,14 @@ const ZoomedUiControl = withTheme(withStyles( {displayValue}
- { this.model.onPreviousZoomedControl(); } } > - +