diff options
| author | jacqueline <me@jacqueline.id.au> | 2024-09-09 15:15:00 +1000 |
|---|---|---|
| committer | jacqueline <me@jacqueline.id.au> | 2024-09-09 15:15:00 +1000 |
| commit | 2b1a01705d62d08cefd6816ba108c5cae48a50ac (patch) | |
| tree | 20ba16a6259ffc335dbcded84fa6bcbe327e9d84 /src | |
| parent | 9475d10d1000c7e21a7ea311b0c8ee6a72ef46c4 (diff) | |
| parent | acdc9789c90ed6f083d054cd07930e020123457f (diff) | |
| download | tangara-fw-2b1a01705d62d08cefd6816ba108c5cae48a50ac.tar.gz | |
Merge branch 'main' into jqln/tts
Diffstat (limited to 'src')
81 files changed, 2975 insertions, 1146 deletions
diff --git a/src/codecs/README.md b/src/codecs/README.md deleted file mode 100644 index d8eaf405..00000000 --- a/src/codecs/README.md +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright 2023 jacqueline <me@jacqueline.id.au> - * - * SPDX-License-Identifier: GPL-3.0-only - */ - -# Software Codecs - -This component contains a collection of software decoders for various diff --git a/src/codecs/vorbis.cpp b/src/codecs/vorbis.cpp index 0b2af691..ea33a2af 100644 --- a/src/codecs/vorbis.cpp +++ b/src/codecs/vorbis.cpp @@ -137,10 +137,15 @@ auto TremorVorbisDecoder::DecodeTo(std::span<sample::Sample> output) ((output.size() - 1) * sizeof(sample::Sample)), &unused); if (bytes_written == OV_HOLE) { ESP_LOGE(kTag, "got OV_HOLE"); - return cpp::fail(Error::kMalformedData); + return OutputInfo{ + .samples_written = 0, + .is_stream_finished = false, + }; } else if (bytes_written == OV_EBADLINK) { ESP_LOGE(kTag, "got OV_EBADLINK"); return cpp::fail(Error::kMalformedData); + } else if (bytes_written == OV_EINVAL) { + return cpp::fail(Error::kMalformedData); } return OutputInfo{ diff --git a/src/drivers/CMakeLists.txt b/src/drivers/CMakeLists.txt index 33d25894..fea5780f 100644 --- a/src/drivers/CMakeLists.txt +++ b/src/drivers/CMakeLists.txt @@ -8,5 +8,5 @@ idf_component_register( "samd.cpp" "wm8523.cpp" "nvs.cpp" "haptics.cpp" "spiffs.cpp" "pcm_buffer.cpp" INCLUDE_DIRS "include" REQUIRES "esp_adc" "fatfs" "result" "lvgl" "nvs_flash" "spiffs" "bt" - "tasks" "tinyfsm" "util" "libcppbor") + "tasks" "tinyfsm" "util" "libcppbor" "driver") target_compile_options(${COMPONENT_LIB} PRIVATE ${EXTRA_WARNINGS}) diff --git a/src/drivers/bluetooth.cpp b/src/drivers/bluetooth.cpp index acb38ce4..3da5dd0c 100644 --- a/src/drivers/bluetooth.cpp +++ b/src/drivers/bluetooth.cpp @@ -4,6 +4,7 @@ #include <algorithm> #include <atomic> +#include <iterator> #include <mutex> #include <ostream> #include <sstream> @@ -116,93 +117,111 @@ IRAM_ATTR auto a2dp_data_cb(uint8_t* buf, int32_t buf_size) -> int32_t { return buf_size; } -Bluetooth::Bluetooth(NvsStorage& storage, tasks::WorkerPool& bg_worker) { +Bluetooth::Bluetooth(NvsStorage& storage, + tasks::WorkerPool& bg_worker, + EventHandler cb) + : nvs_(storage) { sBgWorker = &bg_worker; - bluetooth::BluetoothState::Init(storage); + bluetooth::BluetoothState::Init(storage, cb); } -auto Bluetooth::Enable() -> bool { - auto lock = bluetooth::BluetoothState::lock(); - tinyfsm::FsmList<bluetooth::BluetoothState>::dispatch( - bluetooth::events::Enable{}); +auto Bluetooth::enable(bool en) -> void { + if (en) { + auto lock = bluetooth::BluetoothState::lock(); + tinyfsm::FsmList<bluetooth::BluetoothState>::dispatch( + bluetooth::events::Enable{}); + } else { + // FIXME: the BT tasks unfortunately call back into us while holding an + // internal lock, which then deadlocks with our fsm lock. + // auto lock = bluetooth::BluetoothState::lock(); + tinyfsm::FsmList<bluetooth::BluetoothState>::dispatch( + bluetooth::events::Disable{}); + } +} +auto Bluetooth::enabled() -> bool { + auto lock = bluetooth::BluetoothState::lock(); return !bluetooth::BluetoothState::is_in_state<bluetooth::Disabled>(); } -auto Bluetooth::Disable() -> void { - // FIXME: the BT tasks unfortunately call back into us while holding an - // internal lock, which then deadlocks with our fsm lock. - // auto lock = bluetooth::BluetoothState::lock(); +auto Bluetooth::sources(OutputBuffers* src) -> void { + auto lock = bluetooth::BluetoothState::lock(); + if (src == sStreams) { + return; + } + sStreams = src; tinyfsm::FsmList<bluetooth::BluetoothState>::dispatch( - bluetooth::events::Disable{}); + bluetooth::events::SourcesChanged{}); } -auto Bluetooth::IsEnabled() -> bool { - auto lock = bluetooth::BluetoothState::lock(); - return !bluetooth::BluetoothState::is_in_state<bluetooth::Disabled>(); +auto Bluetooth::softVolume(float f) -> void { + sVolumeFactor = f; } -auto Bluetooth::IsConnected() -> bool { +auto Bluetooth::connectionState() -> ConnectionState { auto lock = bluetooth::BluetoothState::lock(); - return bluetooth::BluetoothState::is_in_state<bluetooth::Connected>(); + if (bluetooth::BluetoothState::is_in_state<bluetooth::Connected>()) { + return ConnectionState::kConnected; + } else if (bluetooth::BluetoothState::is_in_state<bluetooth::Connecting>()) { + return ConnectionState::kConnecting; + } + return ConnectionState::kDisconnected; } -auto Bluetooth::ConnectedDevice() -> std::optional<bluetooth::MacAndName> { +auto Bluetooth::pairedDevice() -> std::optional<bluetooth::MacAndName> { auto lock = bluetooth::BluetoothState::lock(); - if (!bluetooth::BluetoothState::is_in_state<bluetooth::Connected>()) { - return {}; - } - return bluetooth::BluetoothState::preferred_device(); + return bluetooth::BluetoothState::pairedDevice(); } -auto Bluetooth::KnownDevices() -> std::vector<bluetooth::Device> { +auto Bluetooth::pairedDevice(std::optional<bluetooth::MacAndName> dev) -> void { auto lock = bluetooth::BluetoothState::lock(); - std::vector<bluetooth::Device> out = bluetooth::BluetoothState::devices(); - std::sort(out.begin(), out.end(), [](const auto& a, const auto& b) -> bool { - return a.signal_strength < b.signal_strength; - }); - return out; + bluetooth::BluetoothState::pairedDevice(dev); } -auto Bluetooth::SetPreferredDevice(std::optional<bluetooth::MacAndName> dev) - -> void { - auto lock = bluetooth::BluetoothState::lock(); - auto cur = bluetooth::BluetoothState::preferred_device(); - if (dev && cur && dev->mac == cur->mac) { - return; - } - ESP_LOGI(kTag, "preferred is '%s' (%u%u%u%u%u%u)", dev->name.c_str(), - dev->mac[0], dev->mac[1], dev->mac[2], dev->mac[3], dev->mac[4], - dev->mac[5]); - bluetooth::BluetoothState::preferred_device(dev); - tinyfsm::FsmList<bluetooth::BluetoothState>::dispatch( - bluetooth::events::PreferredDeviceChanged{}); +auto Bluetooth::knownDevices() -> std::vector<bluetooth::MacAndName> { + return nvs_.BluetoothNames(); } -auto Bluetooth::PreferredDevice() -> std::optional<bluetooth::MacAndName> { - auto lock = bluetooth::BluetoothState::lock(); - return bluetooth::BluetoothState::preferred_device(); +auto Bluetooth::forgetKnownDevice(const bluetooth::mac_addr_t& mac) -> void { + nvs_.BluetoothName(mac, {}); } -auto Bluetooth::SetSources(OutputBuffers* src) -> void { +auto Bluetooth::discoveryEnabled(bool en) -> void { auto lock = bluetooth::BluetoothState::lock(); - OutputBuffers* cur = bluetooth::BluetoothState::sources(); - if (src == cur) { - return; - } - bluetooth::BluetoothState::sources(src); - tinyfsm::FsmList<bluetooth::BluetoothState>::dispatch( - bluetooth::events::SourcesChanged{}); + bluetooth::BluetoothState::discovery(en); } -auto Bluetooth::SetVolumeFactor(float f) -> void { - sVolumeFactor = f; +auto Bluetooth::discoveryEnabled() -> bool { + auto lock = bluetooth::BluetoothState::lock(); + return bluetooth::BluetoothState::discovery(); } -auto Bluetooth::SetEventHandler(std::function<void(bluetooth::Event)> cb) - -> void { - auto lock = bluetooth::BluetoothState::lock(); - bluetooth::BluetoothState::event_handler(cb); +auto Bluetooth::discoveredDevices() -> std::vector<bluetooth::MacAndName> { + std::vector<bluetooth::Device> discovered; + { + auto lock = bluetooth::BluetoothState::lock(); + discovered = bluetooth::BluetoothState::discoveredDevices(); + } + + // Show devices with stronger signals first, since they're more likely to be + // physically close (and therefore more likely to be what the user wants). + std::sort(discovered.begin(), discovered.end(), + [](const auto& a, const auto& b) -> bool { + return a.signal_strength < b.signal_strength; + }); + + // Convert to the right format. + std::vector<bluetooth::MacAndName> out; + out.reserve(discovered.size()); + std::transform(discovered.begin(), discovered.end(), std::back_inserter(out), + [&](const bluetooth::Device& dev) { + return bluetooth::MacAndName{ + .mac = dev.address, + .name = {dev.name.data(), dev.name.size()}, + }; + }); + + return out; } static auto DeviceName() -> std::pmr::string { @@ -255,6 +274,10 @@ auto Scanner::StopScanningNow() -> void { } } +auto Scanner::enabled() -> bool { + return enabled_; +} + auto Scanner::HandleGapEvent(const events::internal::Gap& ev) -> void { switch (ev.type) { case ESP_BT_GAP_DISC_RES_EVT: @@ -347,16 +370,18 @@ NvsStorage* BluetoothState::sStorage_; Scanner* BluetoothState::sScanner_; std::mutex BluetoothState::sFsmMutex{}; -std::map<mac_addr_t, Device> BluetoothState::sDevices_{}; -std::optional<MacAndName> BluetoothState::sPreferredDevice_{}; -std::optional<MacAndName> BluetoothState::sConnectingDevice_{}; +std::map<mac_addr_t, Device> BluetoothState::sDiscoveredDevices_{}; +std::optional<MacAndName> BluetoothState::sPairedWith_{}; +std::optional<MacAndName> BluetoothState::sConnectingTo_{}; int BluetoothState::sConnectAttemptsRemaining_{0}; std::function<void(Event)> BluetoothState::sEventHandler_; -auto BluetoothState::Init(NvsStorage& storage) -> void { +auto BluetoothState::Init(NvsStorage& storage, Bluetooth::EventHandler cb) + -> void { sStorage_ = &storage; - sPreferredDevice_ = storage.PreferredBluetoothDevice(); + sEventHandler_ = cb; + sPairedWith_ = storage.PreferredBluetoothDevice(); tinyfsm::FsmList<bluetooth::BluetoothState>::start(); } @@ -364,68 +389,85 @@ auto BluetoothState::lock() -> std::lock_guard<std::mutex> { return std::lock_guard<std::mutex>{sFsmMutex}; } -auto BluetoothState::devices() -> std::vector<Device> { - std::vector<Device> out; - for (const auto& device : sDevices_) { - out.push_back(device.second); - } - return out; +auto BluetoothState::pairedDevice() -> std::optional<MacAndName> { + return sPairedWith_; } -auto BluetoothState::preferred_device() -> std::optional<MacAndName> { - return sPreferredDevice_; -} +auto BluetoothState::pairedDevice(std::optional<MacAndName> dev) -> void { + auto cur = sPairedWith_; + if (dev && cur && dev->mac == cur->mac) { + return; + } + if (dev) { + ESP_LOGI(kTag, "pairing with '%s' (%u%u%u%u%u%u)", dev->name.c_str(), + dev->mac[0], dev->mac[1], dev->mac[2], dev->mac[3], dev->mac[4], + dev->mac[5]); + } + sPairedWith_ = dev; + std::invoke(sEventHandler_, SimpleEvent::kDeviceDiscovered); -auto BluetoothState::preferred_device(std::optional<MacAndName> addr) -> void { - sPreferredDevice_ = addr; + tinyfsm::FsmList<bluetooth::BluetoothState>::dispatch( + bluetooth::events::PairedDeviceChanged{}); } -auto BluetoothState::sources() -> OutputBuffers* { - return sStreams; +auto BluetoothState::discovery() -> bool { + return sScanner_->enabled(); } -auto BluetoothState::sources(OutputBuffers* src) -> void { - sStreams = src; +auto BluetoothState::discovery(bool en) -> void { + if (en) { + if (!sScanner_->enabled()) { + sDiscoveredDevices_.clear(); + } + sScanner_->ScanContinuously(); + } else { + sScanner_->StopScanning(); + } } -auto BluetoothState::event_handler(std::function<void(Event)> cb) -> void { - sEventHandler_ = cb; +auto BluetoothState::discoveredDevices() -> std::vector<Device> { + std::vector<Device> out; + for (const auto& device : sDiscoveredDevices_) { + out.push_back(device.second); + } + return out; } auto BluetoothState::react(const events::DeviceDiscovered& ev) -> void { - bool is_preferred = false; - bool already_known = sDevices_.contains(ev.device.address); - sDevices_[ev.device.address] = ev.device; + bool is_paired = false; + bool already_known = sDiscoveredDevices_.contains(ev.device.address); + sDiscoveredDevices_[ev.device.address] = ev.device; - if (sPreferredDevice_ && ev.device.address == sPreferredDevice_->mac) { - is_preferred = true; + if (sPairedWith_ && ev.device.address == sPairedWith_->mac) { + is_paired = true; } - if (sEventHandler_ && !already_known) { - std::invoke(sEventHandler_, SimpleEvent::kKnownDevicesChanged); + if (!already_known) { + std::invoke(sEventHandler_, SimpleEvent::kDeviceDiscovered); } - if (is_preferred && sPreferredDevice_) { - connect(*sPreferredDevice_); + if (is_paired && sPairedWith_) { + connect(*sPairedWith_); } } auto BluetoothState::connect(const MacAndName& dev) -> bool { - if (sConnectingDevice_ && sConnectingDevice_->mac == dev.mac) { + if (sConnectingTo_ && sConnectingTo_->mac == dev.mac) { sConnectAttemptsRemaining_--; } else { sConnectAttemptsRemaining_ = 3; } if (sConnectAttemptsRemaining_ == 0) { + sConnectingTo_ = {}; return false; } - sConnectingDevice_ = dev; + sConnectingTo_ = dev; ESP_LOGI(kTag, "connecting to '%s' (%u%u%u%u%u%u)", dev.name.c_str(), dev.mac[0], dev.mac[1], dev.mac[2], dev.mac[3], dev.mac[4], dev.mac[5]); - if (esp_a2d_source_connect(sConnectingDevice_->mac.data()) != ESP_OK) { + if (esp_a2d_source_connect(sConnectingTo_->mac.data()) != ESP_OK) { ESP_LOGI(kTag, "Connecting failed..."); if (sConnectAttemptsRemaining_ > 1) { ESP_LOGI(kTag, "Will retry."); @@ -489,7 +531,7 @@ void Disabled::react(const events::Enable&) { // Set a reasonable name for the device. std::pmr::string name = DeviceName(); - esp_bt_dev_set_device_name(name.c_str()); + esp_bt_gap_set_device_name(name.c_str()); // Initialise GAP. This controls advertising our device, and scanning for // other devices. @@ -553,36 +595,31 @@ void Disabled::react(const events::Enable&) { esp_bt_gap_set_scan_mode(ESP_BT_NON_CONNECTABLE, ESP_BT_NON_DISCOVERABLE); ESP_LOGI(kTag, "bt enabled"); - if (sPreferredDevice_) { - ESP_LOGI(kTag, "connecting to preferred device '%s'", - sPreferredDevice_->name.c_str()); - connect(*sPreferredDevice_); + if (sPairedWith_) { + ESP_LOGI(kTag, "connecting to paired device '%s'", + sPairedWith_->name.c_str()); + connect(*sPairedWith_); } else { - ESP_LOGI(kTag, "scanning for devices"); transit<Idle>(); } } void Idle::entry() { ESP_LOGI(kTag, "bt is idle"); - sScanner_->ScanContinuously(); + std::invoke(sEventHandler_, SimpleEvent::kConnectionStateChanged); } void Idle::exit() { - sScanner_->StopScanning(); + std::invoke(sEventHandler_, SimpleEvent::kConnectionStateChanged); } void Idle::react(const events::Disable& ev) { transit<Disabled>(); } -void Idle::react(const events::PreferredDeviceChanged& ev) { - bool is_discovered = false; - if (sPreferredDevice_ && sDevices_.contains(sPreferredDevice_->mac)) { - is_discovered = true; - } - if (is_discovered) { - connect(*sPreferredDevice_); +void Idle::react(const events::PairedDeviceChanged& ev) { + if (sPairedWith_) { + connect(*sPairedWith_); } } @@ -604,36 +641,32 @@ void Connecting::entry() { sTimeoutTimer = xTimerCreate("bt_timeout", pdMS_TO_TICKS(15000), false, NULL, timeoutCallback); xTimerStart(sTimeoutTimer, portMAX_DELAY); - - sScanner_->StopScanning(); - if (sEventHandler_) { - std::invoke(sEventHandler_, SimpleEvent::kConnectionStateChanged); - } } void Connecting::exit() { xTimerDelete(sTimeoutTimer, portMAX_DELAY); - - if (sEventHandler_) { - std::invoke(sEventHandler_, SimpleEvent::kConnectionStateChanged); - } } void Connecting::react(const events::ConnectTimedOut& ev) { ESP_LOGI(kTag, "timed out awaiting connection"); - esp_a2d_source_disconnect(sConnectingDevice_->mac.data()); - if (!connect(*sConnectingDevice_)) { + esp_a2d_source_disconnect(sConnectingTo_->mac.data()); + if (!connect(*sConnectingTo_)) { transit<Idle>(); } } void Connecting::react(const events::Disable& ev) { - // TODO: disconnect gracefully + esp_a2d_source_disconnect(sConnectingTo_->mac.data()); transit<Disabled>(); } -void Connecting::react(const events::PreferredDeviceChanged& ev) { - // TODO. Cancel out and start again. +void Connecting::react(const events::PairedDeviceChanged& ev) { + esp_a2d_source_disconnect(sConnectingTo_->mac.data()); + if (sPairedWith_) { + connect(*sPairedWith_); + } else { + transit<Idle>(); + } } void Connecting::react(events::internal::Gap ev) { @@ -704,29 +737,41 @@ void Connected::entry() { ESP_LOGI(kTag, "entering connected state"); transaction_num_ = 0; - connected_to_ = sConnectingDevice_->mac; - sPreferredDevice_ = sConnectingDevice_; - sConnectingDevice_ = {}; + connected_to_ = sConnectingTo_->mac; + sPairedWith_ = sConnectingTo_; + + sStorage_->BluetoothName(sConnectingTo_->mac, sConnectingTo_->name); + std::invoke(sEventHandler_, SimpleEvent::kKnownDevicesChanged); + + sConnectingTo_ = {}; auto stored_pref = sStorage_->PreferredBluetoothDevice(); - if (!stored_pref || (sPreferredDevice_->name != stored_pref->name || - sPreferredDevice_->mac != stored_pref->mac)) { - sStorage_->PreferredBluetoothDevice(sPreferredDevice_); + if (!stored_pref || (sPairedWith_->name != stored_pref->name || + sPairedWith_->mac != stored_pref->mac)) { + sStorage_->PreferredBluetoothDevice(sPairedWith_); } + + std::invoke(sEventHandler_, SimpleEvent::kConnectionStateChanged); + + // TODO: if we already have a source, immediately start playing } void Connected::exit() { ESP_LOGI(kTag, "exiting connected state"); esp_a2d_source_disconnect(connected_to_.data()); + + std::invoke(sEventHandler_, SimpleEvent::kConnectionStateChanged); } void Connected::react(const events::Disable& ev) { transit<Disabled>(); } -void Connected::react(const events::PreferredDeviceChanged& ev) { - sConnectingDevice_ = sPreferredDevice_; - transit<Connecting>(); +void Connected::react(const events::PairedDeviceChanged& ev) { + transit<Idle>(); + if (sPairedWith_) { + connect(*sPairedWith_); + } } void Connected::react(const events::SourcesChanged& ev) { diff --git a/src/drivers/display.cpp b/src/drivers/display.cpp index efc9df93..7321f20b 100644 --- a/src/drivers/display.cpp +++ b/src/drivers/display.cpp @@ -242,6 +242,7 @@ void Display::SendInitialisationSequence(const uint8_t* data) { spi_device_release_bus(handle_); } +IRAM_ATTR void Display::SendCommandWithData(uint8_t command, const uint8_t* data, size_t length) { @@ -249,17 +250,20 @@ void Display::SendCommandWithData(uint8_t command, SendData(data, length); } +IRAM_ATTR void Display::SendCmd(const uint8_t* data, size_t length) { SendTransaction(COMMAND, data, length); } +IRAM_ATTR void Display::SendData(const uint8_t* data, size_t length) { SendTransaction(DATA, data, length); } +IRAM_ATTR void Display::SendTransaction(TransactionType type, - const uint8_t* data, - size_t length) { + const uint8_t* data, + size_t length) { // TODO(jacqueline): What's sending this? if (length == 0) { return; @@ -290,6 +294,7 @@ void Display::SendTransaction(TransactionType type, ESP_ERROR_CHECK(spi_device_transmit(handle_, &sTransaction)); } +IRAM_ATTR void Display::OnLvglFlush(const lv_area_t* area, uint8_t* color_map) { // Swap the pixel byte order first, since we don't want to do this whilst // holding the SPI bus lock. diff --git a/src/drivers/i2s_dac.cpp b/src/drivers/i2s_dac.cpp index 4e2e171a..46bf8e80 100644 --- a/src/drivers/i2s_dac.cpp +++ b/src/drivers/i2s_dac.cpp @@ -46,12 +46,12 @@ extern "C" IRAM_ATTR auto callback(i2s_chan_handle_t handle, if (event == nullptr || user_ctx == nullptr) { return false; } - if (event->data == nullptr || event->size == 0) { + if (event->dma_buf == nullptr || event->size == 0) { return false; } assert(event->size % 4 == 0); - uint8_t* buf = *reinterpret_cast<uint8_t**>(event->data); + uint8_t* buf = reinterpret_cast<uint8_t*>(event->dma_buf); auto* src = reinterpret_cast<OutputBuffers*>(user_ctx); BaseType_t ret1 = src->first.receive( diff --git a/src/drivers/include/drivers/bluetooth.hpp b/src/drivers/include/drivers/bluetooth.hpp index eaecfb2b..99c71e52 100644 --- a/src/drivers/include/drivers/bluetooth.hpp +++ b/src/drivers/include/drivers/bluetooth.hpp @@ -3,6 +3,7 @@ #include <array> #include <atomic> +#include <functional> #include <map> #include <mutex> #include <optional> @@ -25,28 +26,68 @@ namespace drivers { /* - * A handle used to interact with the bluetooth state machine. + * A handle used to interact with the bluetooth state machine. This is the main + * API that the rest of the system should use to interact with Bluetooth. */ class Bluetooth { public: - Bluetooth(NvsStorage& storage, tasks::WorkerPool&); + /* + * Callback invoked when an event is generated by the Bluetooth stack. This + * callback is invoked synchronously from a Bluetooth task context, so + * implementations should immediately hop to a different task to process the + * event. + */ + using EventHandler = std::function<void(bluetooth::Event)>; + + Bluetooth(NvsStorage&, tasks::WorkerPool&, EventHandler); + + /* Enables or disables the entire Bluetooth stack. */ + auto enable(bool en) -> void; + auto enabled() -> bool; + + auto sources(OutputBuffers*) -> void; + auto softVolume(float) -> void; + + enum class ConnectionState { + kConnected, + kConnecting, + kDisconnected, + }; + + auto connectionState() -> ConnectionState; + + /* + * The 'paired' device is a device that will be preferred for connections. + * When Bluetooth is first enabled, we immediately try to connect to the + * paired device. If the paired device is seen during a scan, then we will + * also automatically connect to it. + */ + auto pairedDevice() -> std::optional<bluetooth::MacAndName>; + + /* + * Sets the preferred device. If a device is provided, a connection will be + * attempted immediately, even if the device has not been detected in a + * previous scan. + */ + auto pairedDevice(std::optional<bluetooth::MacAndName> dev) -> void; + + /* A list of devices that have previously been the paired device. */ + auto knownDevices() -> std::vector<bluetooth::MacAndName>; + auto forgetKnownDevice(const bluetooth::mac_addr_t&) -> void; + + /* Enables or disables scanning for nearby Bluetooth devices. */ + auto discoveryEnabled(bool) -> void; + auto discoveryEnabled() -> bool; + + /* + * A list of nearby devices that have been discovered since discovery was + * last enabled. This list may include the paired device, as well as devices + * that are also present in the known devices list. + */ + auto discoveredDevices() -> std::vector<bluetooth::MacAndName>; - auto Enable() -> bool; - auto Disable() -> void; - auto IsEnabled() -> bool; - - auto IsConnected() -> bool; - auto ConnectedDevice() -> std::optional<bluetooth::MacAndName>; - - auto KnownDevices() -> std::vector<bluetooth::Device>; - - auto SetPreferredDevice(std::optional<bluetooth::MacAndName> dev) -> void; - auto PreferredDevice() -> std::optional<bluetooth::MacAndName>; - - auto SetSources(OutputBuffers*) -> void; - auto SetVolumeFactor(float) -> void; - - auto SetEventHandler(std::function<void(bluetooth::Event)> cb) -> void; + private: + NvsStorage& nvs_; }; namespace bluetooth { @@ -56,7 +97,7 @@ struct Enable : public tinyfsm::Event {}; struct Disable : public tinyfsm::Event {}; struct ConnectTimedOut : public tinyfsm::Event {}; -struct PreferredDeviceChanged : public tinyfsm::Event {}; +struct PairedDeviceChanged : public tinyfsm::Event {}; struct SourcesChanged : public tinyfsm::Event {}; struct DeviceDiscovered : public tinyfsm::Event { const Device& device; @@ -94,6 +135,8 @@ class Scanner { auto StopScanning() -> void; auto StopScanningNow() -> void; + auto enabled() -> bool; + auto HandleGapEvent(const events::internal::Gap&) -> void; private: @@ -103,25 +146,22 @@ class Scanner { auto HandleDeviceDiscovery(const esp_bt_gap_cb_param_t& param) -> void; }; +/* + * The main state machine for managing the state of the Bluetooth stack, and + * the current (if any) Bluetooth connection. + */ class BluetoothState : public tinyfsm::Fsm<BluetoothState> { public: - static auto Init(NvsStorage& storage) -> void; + static auto Init(NvsStorage& storage, Bluetooth::EventHandler) -> void; static auto lock() -> std::lock_guard<std::mutex>; - static auto devices() -> std::vector<Device>; - - static auto preferred_device() -> std::optional<bluetooth::MacAndName>; - static auto preferred_device(std::optional<bluetooth::MacAndName>) -> void; + static auto pairedDevice() -> std::optional<bluetooth::MacAndName>; + static auto pairedDevice(std::optional<bluetooth::MacAndName>) -> void; - static auto scanning() -> bool; static auto discovery() -> bool; static auto discovery(bool) -> void; - - static auto sources() -> OutputBuffers*; - static auto sources(OutputBuffers*) -> void; - - static auto event_handler(std::function<void(Event)>) -> void; + static auto discoveredDevices() -> std::vector<Device>; virtual ~BluetoothState(){}; @@ -131,7 +171,7 @@ class BluetoothState : public tinyfsm::Fsm<BluetoothState> { virtual void react(const events::Enable& ev){}; virtual void react(const events::Disable& ev) = 0; virtual void react(const events::ConnectTimedOut& ev){}; - virtual void react(const events::PreferredDeviceChanged& ev){}; + virtual void react(const events::PairedDeviceChanged& ev){}; virtual void react(const events::SourcesChanged& ev){}; virtual void react(const events::DeviceDiscovered&); @@ -146,10 +186,9 @@ class BluetoothState : public tinyfsm::Fsm<BluetoothState> { static Scanner* sScanner_; static std::mutex sFsmMutex; - static std::map<mac_addr_t, Device> sDevices_; - static std::optional<bluetooth::MacAndName> sPreferredDevice_; - - static std::optional<bluetooth::MacAndName> sConnectingDevice_; + static std::map<mac_addr_t, Device> sDiscoveredDevices_; + static std::optional<bluetooth::MacAndName> sPairedWith_; + static std::optional<bluetooth::MacAndName> sConnectingTo_; static int sConnectAttemptsRemaining_; static std::function<void(Event)> sEventHandler_; @@ -176,7 +215,7 @@ class Idle : public BluetoothState { void exit() override; void react(const events::Disable& ev) override; - void react(const events::PreferredDeviceChanged& ev) override; + void react(const events::PairedDeviceChanged& ev) override; void react(events::internal::Gap ev) override; @@ -188,7 +227,7 @@ class Connecting : public BluetoothState { void entry() override; void exit() override; - void react(const events::PreferredDeviceChanged& ev) override; + void react(const events::PairedDeviceChanged& ev) override; void react(const events::ConnectTimedOut& ev) override; void react(const events::Disable& ev) override; @@ -203,7 +242,7 @@ class Connected : public BluetoothState { void entry() override; void exit() override; - void react(const events::PreferredDeviceChanged& ev) override; + void react(const events::PairedDeviceChanged& ev) override; void react(const events::SourcesChanged& ev) override; void react(const events::Disable& ev) override; diff --git a/src/drivers/include/drivers/bluetooth_types.hpp b/src/drivers/include/drivers/bluetooth_types.hpp index d2e55ee5..05caee47 100644 --- a/src/drivers/include/drivers/bluetooth_types.hpp +++ b/src/drivers/include/drivers/bluetooth_types.hpp @@ -27,9 +27,12 @@ struct Device { }; enum class SimpleEvent { - kKnownDevicesChanged, kConnectionStateChanged, - kPreferredDeviceChanged, + kPairedDeviceChanged, + kKnownDevicesChanged, + kDiscoveryChanged, + kDeviceDiscovered, + // Passthrough events kPlayPause, kStop, diff --git a/src/drivers/include/drivers/nvs.hpp b/src/drivers/include/drivers/nvs.hpp index 88dd5ae0..e147c8c7 100644 --- a/src/drivers/include/drivers/nvs.hpp +++ b/src/drivers/include/drivers/nvs.hpp @@ -34,7 +34,7 @@ class Setting { dirty_ = true; } } - auto get() -> std::optional<T>& { return val_; } + auto get() -> std::optional<T> { return val_; } /* Reads the stored value from NVS and parses it into the correct type. */ auto load(nvs_handle_t) -> std::optional<T>; @@ -90,12 +90,19 @@ class NvsStorage { auto LraCalibration() -> std::optional<LraData>; auto LraCalibration(const LraData&) -> void; + auto FastCharge() -> bool; + auto FastCharge(bool) -> void; + auto PreferredBluetoothDevice() -> std::optional<bluetooth::MacAndName>; auto PreferredBluetoothDevice(std::optional<bluetooth::MacAndName>) -> void; auto BluetoothVolume(const bluetooth::mac_addr_t&) -> uint8_t; auto BluetoothVolume(const bluetooth::mac_addr_t&, uint8_t) -> void; + auto BluetoothNames() -> std::vector<bluetooth::MacAndName>; + auto BluetoothName(const bluetooth::mac_addr_t&, std::optional<std::string>) + -> void; + enum class Output : uint8_t { kHeadphones = 0, kBluetooth = 1, @@ -106,6 +113,9 @@ class NvsStorage { auto ScreenBrightness() -> uint_fast8_t; auto ScreenBrightness(uint_fast8_t) -> void; + auto InterfaceTheme() -> std::optional<std::string>; + auto InterfaceTheme(std::string) -> void; + auto ScrollSensitivity() -> uint_fast8_t; auto ScrollSensitivity(uint_fast8_t) -> void; @@ -146,6 +156,7 @@ class NvsStorage { Setting<uint16_t> display_rows_; Setting<uint8_t> haptic_motor_type_; Setting<LraData> lra_calibration_; + Setting<uint8_t> fast_charge_; Setting<uint8_t> brightness_; Setting<uint8_t> sensitivity_; @@ -154,7 +165,12 @@ class NvsStorage { Setting<int8_t> amp_left_bias_; Setting<uint8_t> input_mode_; Setting<uint8_t> output_mode_; + + Setting<std::string> theme_; + Setting<bluetooth::MacAndName> bt_preferred_; + Setting<std::vector<bluetooth::MacAndName>> bt_names_; + Setting<uint8_t> db_auto_index_; util::LruCache<10, bluetooth::mac_addr_t, uint8_t> bt_volumes_; diff --git a/src/drivers/include/drivers/pcm_buffer.hpp b/src/drivers/include/drivers/pcm_buffer.hpp index 968c3398..4e5fa041 100644 --- a/src/drivers/include/drivers/pcm_buffer.hpp +++ b/src/drivers/include/drivers/pcm_buffer.hpp @@ -28,8 +28,12 @@ class PcmBuffer { PcmBuffer(size_t size_in_samples); ~PcmBuffer(); - /* Adds samples to the buffer. */ - auto send(std::span<const int16_t>) -> void; + /* + * Adds samples to the buffer. Returns the number of samples that were added, + * which may be less than the number of samples given if this PcmBuffer is + * close to full. + */ + auto send(std::span<const int16_t>) -> size_t; /* * Fills the given span with samples. If enough samples are available in diff --git a/src/drivers/include/drivers/samd.hpp b/src/drivers/include/drivers/samd.hpp index 897e78d6..ff479225 100644 --- a/src/drivers/include/drivers/samd.hpp +++ b/src/drivers/include/drivers/samd.hpp @@ -10,6 +10,7 @@ #include <optional> #include <string> +#include "drivers/nvs.hpp" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -17,9 +18,7 @@ namespace drivers { class Samd { public: - static auto Create() -> Samd* { return new Samd(); } - - Samd(); + Samd(NvsStorage& nvs); ~Samd(); auto Version() -> std::string; @@ -37,8 +36,14 @@ class Samd { kChargingFast, // The battery is full charged, and we are still plugged in. kFullCharge, + // Charging failed. + kFault, + // The battery status returned isn't a known enum value. + kUnknown, }; + static auto chargeStatusToString(ChargeStatus) -> std::string; + auto GetChargeStatus() -> std::optional<ChargeStatus>; auto UpdateChargeStatus() -> void; @@ -68,6 +73,8 @@ class Samd { Samd& operator=(const Samd&) = delete; private: + NvsStorage& nvs_; + uint8_t version_; std::optional<ChargeStatus> charge_status_; UsbStatus usb_status_; diff --git a/src/drivers/include/drivers/touchwheel.hpp b/src/drivers/include/drivers/touchwheel.hpp index 60902087..9cd925a6 100644 --- a/src/drivers/include/drivers/touchwheel.hpp +++ b/src/drivers/include/drivers/touchwheel.hpp @@ -39,7 +39,8 @@ class TouchWheel { auto Update() -> void; auto GetTouchWheelData() const -> TouchWheelData; - auto PowerDown() -> void; + auto Recalibrate() -> void; + auto LowPowerMode(bool en) -> void; private: TouchWheelData data_; diff --git a/src/drivers/nvs.cpp b/src/drivers/nvs.cpp index 5c7d2218..d004201b 100644 --- a/src/drivers/nvs.cpp +++ b/src/drivers/nvs.cpp @@ -26,8 +26,10 @@ static constexpr uint8_t kSchemaVersion = 1; static constexpr char kKeyVersion[] = "ver"; static constexpr char kKeyBluetoothPreferred[] = "bt_dev"; static constexpr char kKeyBluetoothVolumes[] = "bt_vols"; +static constexpr char kKeyBluetoothNames[] = "bt_names"; static constexpr char kKeyOutput[] = "out"; static constexpr char kKeyBrightness[] = "bright"; +static constexpr char kKeyInterfaceTheme[] = "ui_theme"; static constexpr char kKeyAmpMaxVolume[] = "hp_vol_max"; static constexpr char kKeyAmpCurrentVolume[] = "hp_vol"; static constexpr char kKeyAmpLeftBias[] = "hp_bias"; @@ -39,6 +41,7 @@ static constexpr char kKeyDisplayRows[] = "disprows"; static constexpr char kKeyHapticMotorType[] = "hapticmtype"; static constexpr char kKeyLraCalibration[] = "lra_cali"; static constexpr char kKeyDbAutoIndex[] = "dbautoindex"; +static constexpr char kKeyFastCharge[] = "fastchg"; static auto nvs_get_string(nvs_handle_t nvs, const char* key) -> std::optional<std::string> { @@ -130,6 +133,69 @@ auto Setting<bluetooth::MacAndName>::store(nvs_handle_t nvs, } template <> +auto Setting<std::vector<bluetooth::MacAndName>>::load(nvs_handle_t nvs) + -> std::optional<std::vector<bluetooth::MacAndName>> { + auto raw = nvs_get_string(nvs, name_); + if (!raw) { + return {}; + } + auto [parsed, unused, err] = cppbor::parseWithViews( + reinterpret_cast<const uint8_t*>(raw->data()), raw->size()); + if (parsed->type() != cppbor::MAP) { + return {}; + } + std::vector<bluetooth::MacAndName> res; + for (const auto& i : *parsed->asMap()) { + auto mac = i.first->asViewBstr()->view(); + auto name = i.second->asViewTstr()->view(); + bluetooth::MacAndName entry{ + .mac = {}, + .name = {name.begin(), name.end()}, + }; + std::copy(mac.begin(), mac.end(), entry.mac.begin()); + res.push_back(entry); + } + return res; +} + +template <> +auto Setting<std::vector<bluetooth::MacAndName>>::store( + nvs_handle_t nvs, + std::vector<bluetooth::MacAndName> v) -> void { + cppbor::Map cbor{}; + for (const auto& i : v) { + cbor.add(cppbor::Bstr{{i.mac.data(), i.mac.size()}}, cppbor::Tstr{i.name}); + } + auto encoded = cbor.encode(); + nvs_set_blob(nvs, name_, encoded.data(), encoded.size()); +} + +template <> +auto Setting<std::string>::store( + nvs_handle_t nvs, + std::string v) -> void { + cppbor::Tstr cbor{v}; + auto encoded = cbor.encode(); + nvs_set_blob(nvs, name_, encoded.data(), encoded.size()); +} + +template <> +auto Setting<std::string>::load(nvs_handle_t nvs) + -> std::optional<std::string> { + auto raw = nvs_get_string(nvs, name_); + if (!raw) { + return {}; + } + auto [parsed, unused, err] = cppbor::parseWithViews( + reinterpret_cast<const uint8_t*>(raw->data()), raw->size()); + if (parsed->type() != cppbor::TSTR) { + return {}; + } + auto v = parsed->asViewTstr()->view(); + return std::string{v.begin(), v.end()}; +} + +template <> auto Setting<NvsStorage::LraData>::load(nvs_handle_t nvs) -> std::optional<NvsStorage::LraData> { auto raw = nvs_get_string(nvs, name_); @@ -200,6 +266,7 @@ NvsStorage::NvsStorage(nvs_handle_t handle) display_rows_(kKeyDisplayRows), haptic_motor_type_(kKeyHapticMotorType), lra_calibration_(kKeyLraCalibration), + fast_charge_(kKeyFastCharge), brightness_(kKeyBrightness), sensitivity_(kKeyScrollSensitivity), amp_max_vol_(kKeyAmpMaxVolume), @@ -207,7 +274,9 @@ NvsStorage::NvsStorage(nvs_handle_t handle) amp_left_bias_(kKeyAmpLeftBias), input_mode_(kKeyPrimaryInput), output_mode_(kKeyOutput), + theme_{kKeyInterfaceTheme}, bt_preferred_(kKeyBluetoothPreferred), + bt_names_(kKeyBluetoothNames), db_auto_index_(kKeyDbAutoIndex), bt_volumes_(), bt_volumes_dirty_(false) {} @@ -231,7 +300,9 @@ auto NvsStorage::Read() -> void { amp_left_bias_.read(handle_); input_mode_.read(handle_); output_mode_.read(handle_); + theme_.read(handle_); bt_preferred_.read(handle_); + bt_names_.read(handle_); db_auto_index_.read(handle_); readBtVolumes(); } @@ -250,7 +321,9 @@ auto NvsStorage::Write() -> bool { amp_left_bias_.write(handle_); input_mode_.write(handle_); output_mode_.write(handle_); + theme_.write(handle_); bt_preferred_.write(handle_); + bt_names_.write(handle_); db_auto_index_.write(handle_); writeBtVolumes(); return nvs_commit(handle_) == ESP_OK; @@ -341,6 +414,47 @@ auto NvsStorage::BluetoothVolume(const bluetooth::mac_addr_t& mac, uint8_t vol) bt_volumes_.Put(mac, vol); } +auto NvsStorage::BluetoothNames() -> std::vector<bluetooth::MacAndName> { + std::lock_guard<std::mutex> lock{mutex_}; + return bt_names_.get().value_or(std::vector<bluetooth::MacAndName>{}); +} + +auto NvsStorage::BluetoothName(const bluetooth::mac_addr_t& mac, + std::optional<std::string> name) -> void { + std::lock_guard<std::mutex> lock{mutex_}; + auto val = bt_names_.get(); + if (!val) { + val.emplace(); + } + + bool mut = false; + bool found = false; + for (auto it = val->begin(); it != val->end(); it++) { + if (it->mac == mac) { + if (name) { + it->name = *name; + } else { + val->erase(it); + } + found = true; + mut = true; + break; + } + } + + if (!found && name) { + val->push_back(bluetooth::MacAndName{ + .mac = mac, + .name = *name, + }); + mut = true; + } + + if (mut) { + bt_names_.set(*val); + } +} + auto NvsStorage::OutputMode() -> Output { std::lock_guard<std::mutex> lock{mutex_}; switch (output_mode_.get().value_or(0xFF)) { @@ -361,6 +475,16 @@ auto NvsStorage::OutputMode(Output out) -> void { nvs_commit(handle_); } +auto NvsStorage::FastCharge() -> bool { + std::lock_guard<std::mutex> lock{mutex_}; + return fast_charge_.get().value_or(true); +} + +auto NvsStorage::FastCharge(bool en) -> void { + std::lock_guard<std::mutex> lock{mutex_}; + fast_charge_.set(en); +} + auto NvsStorage::ScreenBrightness() -> uint_fast8_t { std::lock_guard<std::mutex> lock{mutex_}; return std::clamp<uint8_t>(brightness_.get().value_or(50), 0, 100); @@ -371,6 +495,16 @@ auto NvsStorage::ScreenBrightness(uint_fast8_t val) -> void { brightness_.set(val); } +auto NvsStorage::InterfaceTheme() -> std::optional<std::string> { + std::lock_guard<std::mutex> lock{mutex_}; + return theme_.get(); +} + +auto NvsStorage::InterfaceTheme(std::string themeFile) -> void { + std::lock_guard<std::mutex> lock{mutex_}; + theme_.set(themeFile); +} + auto NvsStorage::ScrollSensitivity() -> uint_fast8_t { std::lock_guard<std::mutex> lock{mutex_}; return std::clamp<uint8_t>(sensitivity_.get().value_or(128), 0, 255); diff --git a/src/drivers/pcm_buffer.cpp b/src/drivers/pcm_buffer.cpp index 1d2bab1e..1e416301 100644 --- a/src/drivers/pcm_buffer.cpp +++ b/src/drivers/pcm_buffer.cpp @@ -17,6 +17,7 @@ #include "freertos/FreeRTOS.h" #include "esp_heap_caps.h" +#include "freertos/projdefs.h" #include "freertos/ringbuf.h" #include "portmacro.h" @@ -39,9 +40,13 @@ PcmBuffer::~PcmBuffer() { heap_caps_free(buf_); } -auto PcmBuffer::send(std::span<const int16_t> data) -> void { - xRingbufferSend(ringbuf_, data.data(), data.size_bytes(), portMAX_DELAY); +auto PcmBuffer::send(std::span<const int16_t> data) -> size_t { + if (!xRingbufferSend(ringbuf_, data.data(), data.size_bytes(), + pdMS_TO_TICKS(100))) { + return 0; + } sent_ += data.size(); + return data.size(); } IRAM_ATTR auto PcmBuffer::receive(std::span<int16_t> dest, bool mix, bool isr) @@ -67,10 +72,12 @@ IRAM_ATTR auto PcmBuffer::receive(std::span<int16_t> dest, bool mix, bool isr) auto PcmBuffer::clear() -> void { while (!isEmpty()) { - size_t bytes_cleared; + size_t bytes_cleared = 0; void* data = xRingbufferReceive(ringbuf_, &bytes_cleared, 0); - vRingbufferReturnItem(ringbuf_, data); - received_ += bytes_cleared / sizeof(int16_t); + if (data) { + vRingbufferReturnItem(ringbuf_, data); + received_ += bytes_cleared / sizeof(int16_t); + } } } diff --git a/src/drivers/samd.cpp b/src/drivers/samd.cpp index e4aa73ad..c2308760 100644 --- a/src/drivers/samd.cpp +++ b/src/drivers/samd.cpp @@ -5,11 +5,13 @@ */ #include "drivers/samd.hpp" +#include <stdint.h> #include <cstdint> #include <optional> #include <string> +#include "drivers/nvs.hpp" #include "esp_err.h" #include "esp_log.h" #include "hal/gpio_types.h" @@ -32,7 +34,29 @@ namespace drivers { static constexpr gpio_num_t kIntPin = GPIO_NUM_35; -Samd::Samd() { +auto Samd::chargeStatusToString(ChargeStatus status) -> std::string { + switch (status) { + case ChargeStatus::kNoBattery: + return "no_battery"; + case ChargeStatus::kBatteryCritical: + return "critical"; + case ChargeStatus::kDischarging: + return "discharging"; + case ChargeStatus::kChargingRegular: + return "charge_regular"; + case ChargeStatus::kChargingFast: + return "charge_fast"; + case ChargeStatus::kFullCharge: + return "full_charge"; + case ChargeStatus::kFault: + return "fault"; + case ChargeStatus::kUnknown: + default: + return "unknown"; + } +} + +Samd::Samd(NvsStorage& nvs) : nvs_(nvs) { gpio_set_direction(kIntPin, GPIO_MODE_INPUT); // Being able to interface with the SAMD properly is critical. To ensure we @@ -51,7 +75,7 @@ Samd::Samd() { UpdateChargeStatus(); UpdateUsbStatus(); - SetFastChargeEnabled(true); + SetFastChargeEnabled(nvs.FastCharge()); } Samd::~Samd() {} @@ -78,16 +102,38 @@ auto Samd::UpdateChargeStatus() -> void { return; } - // FIXME: Ideally we should be using the three 'charge status' bits to work - // out whether we're actually charging, or if we've got a full charge, - // critically low charge, etc. + // Lower two bits are the usb power status, next three are the BMS status. + // See 'gpio.c' in the SAMD21 firmware for how these bits get packed. + uint8_t charge_state = (raw_res & 0b11100) >> 2; uint8_t usb_state = raw_res & 0b11; - if (usb_state == 0) { - charge_status_ = ChargeStatus::kDischarging; - } else if (usb_state == 1) { - charge_status_ = ChargeStatus::kChargingRegular; - } else { - charge_status_ = ChargeStatus::kChargingFast; + switch (charge_state) { + case 0b000: + charge_status_ = ChargeStatus::kNoBattery; + break; + case 0b001: + // BMS says we're charging; work out how fast we're charging. + if (usb_state >= 0b10 && nvs_.FastCharge()) { + charge_status_ = ChargeStatus::kChargingFast; + } else { + charge_status_ = ChargeStatus::kChargingRegular; + } + break; + case 0b010: + charge_status_ = ChargeStatus::kFullCharge; + break; + case 0b011: + charge_status_ = ChargeStatus::kFault; + break; + case 0b100: + charge_status_ = ChargeStatus::kBatteryCritical; + break; + case 0b101: + charge_status_ = ChargeStatus::kDischarging; + break; + case 0b110: + case 0b111: + charge_status_ = ChargeStatus::kUnknown; + break; } } @@ -127,9 +173,15 @@ auto Samd::ResetToFlashSamd() -> void { } auto Samd::SetFastChargeEnabled(bool en) -> void { + // Always update NVS, so that the setting is right after the SAMD firmware is + // updated. + nvs_.FastCharge(en); + if (version_ < 4) { return; } + ESP_LOGI(kTag, "set fast charge %u", en); + I2CTransaction transaction; transaction.start() .write_addr(kAddress, I2C_MASTER_WRITE) diff --git a/src/drivers/spi.cpp b/src/drivers/spi.cpp index 632fe89f..40487197 100644 --- a/src/drivers/spi.cpp +++ b/src/drivers/spi.cpp @@ -41,7 +41,7 @@ esp_err_t init_spi(void) { // manages its own use of DMA-capable memory. .max_transfer_sz = 4096, .flags = SPICOMMON_BUSFLAG_MASTER | SPICOMMON_BUSFLAG_IOMUX_PINS, - .isr_cpu_id = ESP_INTR_CPU_AFFINITY_0, + .isr_cpu_id = ESP_INTR_CPU_AFFINITY_1, .intr_flags = ESP_INTR_FLAG_LOWMED | ESP_INTR_FLAG_IRAM, }; diff --git a/src/drivers/test/test_samd.cpp b/src/drivers/test/test_samd.cpp index c466d88e..96248377 100644 --- a/src/drivers/test/test_samd.cpp +++ b/src/drivers/test/test_samd.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-only */ +#include "drivers/nvs.hpp" #include "drivers/samd.hpp" #include <cstdint> @@ -16,7 +17,8 @@ namespace drivers { TEST_CASE("samd21 interface", "[integration]") { I2CFixture i2c; - auto samd = std::make_unique<Samd>(); + std::unique_ptr<drivers::NvsStorage> nvs{drivers::NvsStorage::OpenSync()}; + auto samd = std::make_unique<Samd>(*nvs); REQUIRE(samd); diff --git a/src/drivers/touchwheel.cpp b/src/drivers/touchwheel.cpp index 5d55c6f2..402b839d 100644 --- a/src/drivers/touchwheel.cpp +++ b/src/drivers/touchwheel.cpp @@ -137,8 +137,12 @@ TouchWheelData TouchWheel::GetTouchWheelData() const { return data_; } -auto TouchWheel::PowerDown() -> void { - WriteRegister(LOW_POWER, 0); +auto TouchWheel::Recalibrate() -> void { + WriteRegister(CALIBRATE, 1); +} + +auto TouchWheel::LowPowerMode(bool en) -> void { + WriteRegister(LOW_POWER, en ? 0 : 1); } } // namespace drivers diff --git a/src/locale/collation.cpp b/src/locale/collation.cpp index 8748742d..c3360483 100644 --- a/src/locale/collation.cpp +++ b/src/locale/collation.cpp @@ -89,8 +89,8 @@ GLibCollator::~GLibCollator() { auto GLibCollator::Transform(const std::string& in) -> std::string { size_t size = glib_strxfrm(NULL, in.c_str(), 0, locale_data_.get()); char* dest = new char[size + 1]{0}; - glib_strxfrm(dest, in.c_str(), size, locale_data_.get()); - std::string out{dest, strnlen(dest, size)}; + size = glib_strxfrm(dest, in.c_str(), size, locale_data_.get()); + std::string out{dest, size}; delete[] dest; return out; } diff --git a/src/tangara/app_console/app_console.cpp b/src/tangara/app_console/app_console.cpp index f3593e1b..21dec56a 100644 --- a/src/tangara/app_console/app_console.cpp +++ b/src/tangara/app_console/app_console.cpp @@ -418,28 +418,21 @@ int CmdBtList(int argc, char** argv) { return 1; } - auto devices = AppConsole::sServices->bluetooth().KnownDevices(); + auto devices = AppConsole::sServices->bluetooth().knownDevices(); if (argc == 2) { int index = std::atoi(argv[1]); if (index < 0 || index >= devices.size()) { std::cout << "index out of range" << std::endl; return -1; } - drivers::bluetooth::MacAndName dev{ - .mac = devices[index].address, - .name = {devices[index].name.data(), devices[index].name.size()}, - }; - AppConsole::sServices->bluetooth().SetPreferredDevice(dev); + AppConsole::sServices->bluetooth().pairedDevice(devices[index]); } else { - std::cout << "mac\t\trssi\tname" << std::endl; + std::cout << "mac\t\tname" << std::endl; for (const auto& device : devices) { - for (size_t i = 0; i < device.address.size(); i++) { + for (size_t i = 0; i < device.mac.size(); i++) { std::cout << std::hex << std::setfill('0') << std::setw(2) - << static_cast<int>(device.address[i]); + << static_cast<int>(device.mac[i]); } - float perc = - (static_cast<double>(device.signal_strength) + 127.0) / 256.0 * 100; - std::cout << "\t" << std::fixed << std::setprecision(0) << perc << "%"; std::cout << "\t" << device.name << std::endl; } } @@ -472,26 +465,7 @@ int CmdSamd(int argc, char** argv) { } else if (cmd == "charge") { auto res = samd.GetChargeStatus(); if (res) { - switch (res.value()) { - case drivers::Samd::ChargeStatus::kNoBattery: - std::cout << "kNoBattery" << std::endl; - break; - case drivers::Samd::ChargeStatus::kBatteryCritical: - std::cout << "kBatteryCritical" << std::endl; - break; - case drivers::Samd::ChargeStatus::kDischarging: - std::cout << "kDischarging" << std::endl; - break; - case drivers::Samd::ChargeStatus::kChargingRegular: - std::cout << "kChargingRegular" << std::endl; - break; - case drivers::Samd::ChargeStatus::kChargingFast: - std::cout << "kChargingFast" << std::endl; - break; - case drivers::Samd::ChargeStatus::kFullCharge: - std::cout << "kFullCharge" << std::endl; - break; - } + std::cout << drivers::Samd::chargeStatusToString(*res) << std::endl; } else { std::cout << "unknown" << std::endl; } @@ -690,6 +664,26 @@ void RegisterLua() { esp_console_cmd_register(&cmd_luarun); } +int CmdSnapshot(int argc, char** argv) { + if (argc != 2) { + std::cout << "snapshot expects 1 argument" << std::endl; + return 1; + } + + events::Ui().Dispatch(ui::Screenshot{.filename = argv[1]}); + return 0; +} + +void RegisterSnapshot() { + esp_console_cmd_t cmd_snapshot{ + .command = "snapshot", + .help = "Saves a screenshot of the display to a file", + .hint = "filename", + .func = &CmdSnapshot, + .argtable = NULL}; + esp_console_cmd_register(&cmd_snapshot); +} + auto AppConsole::PrerunCallback() -> void { Console::PrerunCallback(); esp_log_level_set("*", ESP_LOG_NONE); @@ -720,6 +714,7 @@ auto AppConsole::RegisterExtraComponents() -> void { RegisterHapticEffect(); RegisterLua(); + RegisterSnapshot(); } } // namespace console diff --git a/src/tangara/audio/audio_decoder.cpp b/src/tangara/audio/audio_decoder.cpp index ee06d984..8c0b264f 100644 --- a/src/tangara/audio/audio_decoder.cpp +++ b/src/tangara/audio/audio_decoder.cpp @@ -48,7 +48,7 @@ static const char* kTag = "decoder"; * increasing its size. */ static constexpr std::size_t kCodecBufferLength = - drivers::kI2SBufferLengthFrames * sizeof(sample::Sample); + drivers::kI2SBufferLengthFrames * 2; auto Decoder::Start(std::shared_ptr<SampleProcessor> sink) -> Decoder* { Decoder* task = new Decoder(sink); @@ -78,11 +78,17 @@ Decoder::Decoder(std::shared_ptr<SampleProcessor> processor) * Main decoding loop. Handles watching for new streams, or continuing to nudge * along the current stream if we have one. */ +IRAM_ATTR void Decoder::Main() { for (;;) { - // Check whether there's a new stream to begin. If we're idle, then we - // simply park and wait forever for a stream to arrive. - TickType_t wait_time = stream_ ? 0 : portMAX_DELAY; + // How long should we spend waiting for a command? By default, assume we're + // idle and wait forever. + TickType_t wait_time = portMAX_DELAY; + if (!leftover_samples_.empty() || stream_) { + // If we have work to do, then don't block waiting for a new stream. + wait_time = 0; + } + NextStream* next; if (xQueueReceive(next_stream_, &next, wait_time)) { // Copy the data out of the queue, then clean up the item. @@ -103,8 +109,15 @@ void Decoder::Main() { // Start decoding the new stream. prepareDecode(new_stream); + + // Keep handling commands until the command queue is empty. + continue; } + // We should always have a stream if we returned from xQueueReceive without + // receiving a new stream. + assert(stream_); + if (!continueDecode()) { finishDecode(false); } @@ -167,16 +180,36 @@ auto Decoder::prepareDecode(std::shared_ptr<TaggedStream> stream) -> void { } auto Decoder::continueDecode() -> bool { + // First, see if we have any samples from a previous decode that still need + // to be sent. + if (!leftover_samples_.empty()) { + leftover_samples_ = processor_->continueStream(leftover_samples_); + return true; + } + + // We might have already cleaned up the codec if the last decode pass of the + // stream resulted in leftover samples. + if (!codec_) { + return false; + } + auto res = codec_->DecodeTo(codec_buffer_); if (res.has_error()) { return false; } if (res->samples_written > 0) { - processor_->continueStream(codec_buffer_.first(res->samples_written)); + leftover_samples_ = + processor_->continueStream(codec_buffer_.first(res->samples_written)); + } + + if (res->is_stream_finished) { + // The codec has finished, so make sure we don't call it again. + codec_.reset(); } - return !res->is_stream_finished; + // We're done iff the codec has finished and we sent everything. + return codec_ || !leftover_samples_.empty(); } auto Decoder::finishDecode(bool cancel) -> void { @@ -191,6 +224,7 @@ auto Decoder::finishDecode(bool cancel) -> void { processor_->endStream(cancel); // Clean up after ourselves. + leftover_samples_ = {}; stream_.reset(); codec_.reset(); track_.reset(); diff --git a/src/tangara/audio/audio_decoder.hpp b/src/tangara/audio/audio_decoder.hpp index 64561d9d..9f20ec59 100644 --- a/src/tangara/audio/audio_decoder.hpp +++ b/src/tangara/audio/audio_decoder.hpp @@ -15,6 +15,7 @@ #include "audio/processor.hpp" #include "codec.hpp" #include "database/track.hpp" +#include "sample.hpp" #include "types.hpp" namespace audio { @@ -55,6 +56,7 @@ class Decoder { std::shared_ptr<TrackInfo> track_; std::span<sample::Sample> codec_buffer_; + std::span<sample::Sample> leftover_samples_; }; } // namespace audio diff --git a/src/tangara/audio/audio_events.hpp b/src/tangara/audio/audio_events.hpp index 503664cc..91bcf48b 100644 --- a/src/tangara/audio/audio_events.hpp +++ b/src/tangara/audio/audio_events.hpp @@ -16,6 +16,7 @@ #include "tinyfsm.hpp" #include "database/track.hpp" +#include "drivers/nvs.hpp" #include "types.hpp" namespace audio { @@ -102,6 +103,7 @@ struct QueueUpdate : tinyfsm::Event { kRepeatingLastTrack, kTrackFinished, kDeserialised, + kBulkLoadingUpdate, }; Reason reason; }; @@ -117,10 +119,11 @@ struct SetVolumeBalance : tinyfsm::Event { }; /* - Event emitted when the hardware volume for a connected Bluetooth device has changed. + Event emitted when the hardware volume for a connected Bluetooth device has + changed. */ struct RemoteVolumeChanged : tinyfsm::Event { - uint_fast8_t value; // 0..127 + uint_fast8_t value; // 0..127 }; struct VolumeChanged : tinyfsm::Event { uint_fast8_t percent; @@ -137,7 +140,9 @@ struct SetVolumeLimit : tinyfsm::Event { int limit_db; }; -struct OutputModeChanged : tinyfsm::Event {}; +struct OutputModeChanged : tinyfsm::Event { + std::optional<drivers::NvsStorage::Output> set_to; +}; namespace internal { diff --git a/src/tangara/audio/audio_fsm.cpp b/src/tangara/audio/audio_fsm.cpp index dbf1954c..ee7215cb 100644 --- a/src/tangara/audio/audio_fsm.cpp +++ b/src/tangara/audio/audio_fsm.cpp @@ -9,6 +9,7 @@ #include <cstdint> #include <future> #include <memory> +#include <sstream> #include <variant> #include "audio/audio_source.hpp" @@ -103,9 +104,7 @@ void AudioState::react(const QueueUpdate& ev) { }; auto current = sServices->track_queue().current(); - if (current) { - cmd.new_track = *current; - } + cmd.new_track = current; switch (ev.reason) { case QueueUpdate::kExplicitUpdate: @@ -120,10 +119,13 @@ void AudioState::react(const QueueUpdate& ev) { cmd.new_track = std::monostate{}; } break; + case QueueUpdate::kBulkLoadingUpdate: + // Bulk loading updates are informational only; a separate QueueUpdate + // event will be sent when loading is done. case QueueUpdate::kDeserialised: - default: // The current track is deserialised separately in order to retain seek // position. + default: return; } @@ -151,7 +153,20 @@ void AudioState::react(const SetTrack& ev) { sStreamFactory->create(std::get<std::string>(new_track), seek_to); } + // Always give the stream to the decoder, even if it turns out to be empty. + // This has the effect of stopping the current playback, which is generally + // what the user expects to happen when they say "Play this track!", even + // if the new track has an issue. sDecoder->open(stream); + + // ...but if the stream that failed is the front of the queue, then we + // should advance to the next track in order to keep the tunes flowing. + if (!stream) { + auto& queue = sServices->track_queue(); + if (new_track == queue.current()) { + queue.finish(); + } + } }); } @@ -183,18 +198,21 @@ void AudioState::react(const internal::DecodingFinished& ev) { sServices->bg_worker().Dispatch<void>([=]() { auto& queue = sServices->track_queue(); auto current = queue.current(); - if (!current) { + if (std::holds_alternative<std::monostate>(current)) { return; } auto db = sServices->database().lock(); if (!db) { return; } - auto path = db->getTrackPath(*current); - if (!path) { - return; + std::string path; + if (std::holds_alternative<std::string>(current)) { + path = std::get<std::string>(current); + } else if (std::holds_alternative<database::TrackId>(current)) { + auto tid = std::get<database::TrackId>(current); + path = db->getTrackPath(tid).value_or(""); } - if (*path == ev.track->uri) { + if (path == ev.track->uri) { queue.finish(); } }); @@ -208,6 +226,7 @@ void AudioState::react(const internal::StreamStarted& ev) { } sStreamCues.addCue(ev.track, ev.cue_at_sample); + sStreamCues.update(sDrainBuffer->totalReceived()); if (!sIsPaused && !is_in_state<states::Playback>()) { transit<states::Playback>(); @@ -223,13 +242,30 @@ void AudioState::react(const internal::StreamEnded& ev) { sStreamCues.addCue({}, ev.cue_at_sample); } +void AudioState::react(const system_fsm::HasPhonesChanged& ev) { + if (ev.has_headphones) { + events::Audio().Dispatch(audio::OutputModeChanged{ + .set_to = drivers::NvsStorage::Output::kHeadphones}); + } else { + if (sServices->bluetooth().enabled()) { + events::Audio().Dispatch(audio::OutputModeChanged{ + .set_to = drivers::NvsStorage::Output::kBluetooth}); + } + } +} + void AudioState::react(const system_fsm::BluetoothEvent& ev) { using drivers::bluetooth::SimpleEvent; if (std::holds_alternative<SimpleEvent>(ev.event)) { auto simpleEvent = std::get<SimpleEvent>(ev.event); switch (simpleEvent) { case SimpleEvent::kConnectionStateChanged: { - auto dev = sServices->bluetooth().ConnectedDevice(); + auto bt = sServices->bluetooth(); + if (bt.connectionState() != + drivers::Bluetooth::ConnectionState::kConnected) { + return; + } + auto dev = sServices->bluetooth().pairedDevice(); if (!dev) { return; } @@ -321,6 +357,9 @@ void AudioState::react(const SetVolumeBalance& ev) { void AudioState::react(const OutputModeChanged& ev) { ESP_LOGI(kTag, "output mode changed"); auto new_mode = sServices->nvs().OutputMode(); + if (ev.set_to) { + new_mode = *ev.set_to; + } sOutput->mode(IAudioOutput::Modes::kOff); switch (new_mode) { case drivers::NvsStorage::Output::kBluetooth: @@ -348,7 +387,7 @@ auto AudioState::commitVolume() -> void { if (mode == drivers::NvsStorage::Output::kHeadphones) { sServices->nvs().AmpCurrentVolume(vol); } else if (mode == drivers::NvsStorage::Output::kBluetooth) { - auto dev = sServices->bluetooth().ConnectedDevice(); + auto dev = sServices->bluetooth().pairedDevice(); if (!dev) { return; } @@ -385,7 +424,7 @@ void Uninitialised::react(const system_fsm::BootComplete& ev) { sOutput = sI2SOutput; } else { // Ensure Bluetooth gets enabled if it's the default sink. - sServices->bluetooth().Enable(); + sServices->bluetooth().enable(true); sOutput = sBtOutput; } sOutput->mode(IAudioOutput::Modes::kOnPaused); @@ -457,6 +496,9 @@ void Standby::react(const system_fsm::SdStateChanged& ev) { return; } + // Open the queue file + sServices->track_queue().open(); + // Restore the currently playing file before restoring the queue. This way, // we can fall back to restarting the queue's current track if there's any // issue restoring the current file. diff --git a/src/tangara/audio/audio_fsm.hpp b/src/tangara/audio/audio_fsm.hpp index 1e5184b5..134d9ffd 100644 --- a/src/tangara/audio/audio_fsm.hpp +++ b/src/tangara/audio/audio_fsm.hpp @@ -67,6 +67,7 @@ class AudioState : public tinyfsm::Fsm<AudioState> { virtual void react(const system_fsm::KeyLockChanged&){}; virtual void react(const system_fsm::SdStateChanged&) {} virtual void react(const system_fsm::BluetoothEvent&); + virtual void react(const system_fsm::HasPhonesChanged&); protected: auto emitPlaybackUpdate(bool paused) -> void; diff --git a/src/tangara/audio/bt_audio_output.cpp b/src/tangara/audio/bt_audio_output.cpp index 54547622..c6c64fd1 100644 --- a/src/tangara/audio/bt_audio_output.cpp +++ b/src/tangara/audio/bt_audio_output.cpp @@ -13,6 +13,7 @@ #include <memory> #include <variant> +#include "drivers/bluetooth.hpp" #include "esp_err.h" #include "esp_heap_caps.h" #include "freertos/portmacro.h" @@ -32,6 +33,8 @@ namespace audio { static constexpr uint16_t kVolumeRange = 60; +using ConnectionState = drivers::Bluetooth::ConnectionState; + BluetoothAudioOutput::BluetoothAudioOutput(drivers::Bluetooth& bt, drivers::OutputBuffers& bufs, tasks::WorkerPool& p) @@ -45,9 +48,9 @@ BluetoothAudioOutput::~BluetoothAudioOutput() {} auto BluetoothAudioOutput::changeMode(Modes mode) -> void { if (mode == Modes::kOnPlaying) { - bluetooth_.SetSources(&buffers_); + bluetooth_.sources(&buffers_); } else { - bluetooth_.SetSources(nullptr); + bluetooth_.sources(nullptr); } } @@ -60,7 +63,7 @@ auto BluetoothAudioOutput::SetVolume(uint16_t v) -> void { bg_worker_.Dispatch<void>([&]() { float factor = pow(10, static_cast<double>(kVolumeRange) * (volume_ - 100) / 100 / 20); - bluetooth_.SetVolumeFactor(factor); + bluetooth_.softVolume(factor); }); } @@ -95,7 +98,7 @@ auto BluetoothAudioOutput::SetVolumeDb(int_fast16_t val) -> bool { } auto BluetoothAudioOutput::AdjustVolumeUp() -> bool { - if (volume_ == 100 || !bluetooth_.IsConnected()) { + if (volume_ == 100) { return false; } volume_++; @@ -104,7 +107,7 @@ auto BluetoothAudioOutput::AdjustVolumeUp() -> bool { } auto BluetoothAudioOutput::AdjustVolumeDown() -> bool { - if (volume_ == 0 || !bluetooth_.IsConnected()) { + if (volume_ == 0) { return false; } volume_--; diff --git a/src/tangara/audio/playlist.cpp b/src/tangara/audio/playlist.cpp new file mode 100644 index 00000000..1436e2d2 --- /dev/null +++ b/src/tangara/audio/playlist.cpp @@ -0,0 +1,302 @@ +/* + * Copyright 2024 ailurux <ailuruxx@gmail.com> + * + * SPDX-License-Identifier: GPL-3.0-only + */ +#include "playlist.hpp" + +#include <string> + +#include "esp_log.h" +#include "ff.h" + +#include "audio/playlist.hpp" +#include "database/database.hpp" + +namespace audio { + +[[maybe_unused]] static constexpr char kTag[] = "playlist"; + +Playlist::Playlist(const std::string& playlistFilepath) + : filepath_(playlistFilepath), + mutex_(), + total_size_(0), + pos_(-1), + file_open_(false), + file_error_(false), + offset_cache_(&memory::kSpiRamResource), + sample_size_(50) {} + +auto Playlist::open() -> bool { + std::unique_lock<std::mutex> lock(mutex_); + if (file_open_) { + return true; + } + + FRESULT res = + f_open(&file_, filepath_.c_str(), FA_READ | FA_WRITE | FA_OPEN_ALWAYS); + if (res != FR_OK) { + ESP_LOGE(kTag, "failed to open file! res: %i", res); + return false; + } + file_open_ = true; + file_error_ = false; + + // Count the playlist size and build our offset cache. + countItems(); + // Advance to the first item. + skipToWithoutCache(0); + + return !file_error_; +} + +Playlist::~Playlist() { + if (file_open_) { + f_close(&file_); + } +} + +auto Playlist::filepath() const -> std::string { + return filepath_; +} + +auto Playlist::currentPosition() const -> size_t { + std::unique_lock<std::mutex> lock(mutex_); + return pos_ < 0 ? 0 : pos_; +} + +auto Playlist::size() const -> size_t { + std::unique_lock<std::mutex> lock(mutex_); + return total_size_; +} + +auto Playlist::value() const -> std::string { + std::unique_lock<std::mutex> lock(mutex_); + return current_value_; +} + +auto Playlist::atEnd() const -> bool { + std::unique_lock<std::mutex> lock(mutex_); + return pos_ + 1 >= total_size_; +} + +auto Playlist::next() -> void { + std::unique_lock<std::mutex> lock(mutex_); + if (pos_ + 1 < total_size_ && !file_error_) { + advanceBy(1); + } +} + +auto Playlist::prev() -> void { + std::unique_lock<std::mutex> lock(mutex_); + if (!file_error_) { + // Naive approach to see how that goes for now + skipToLocked(pos_ - 1); + } +} + +auto Playlist::skipTo(size_t position) -> void { + std::unique_lock<std::mutex> lock(mutex_); + skipToLocked(position); +} + +auto Playlist::skipToLocked(size_t position) -> void { + if (!file_open_ || file_error_) { + return; + } + + // Check our cache and go to nearest entry + auto remainder = position % sample_size_; + auto quotient = (position - remainder) / sample_size_; + if (offset_cache_.size() <= quotient) { + skipToWithoutCache(position); + return; + } + + // Go to byte offset + auto entry = offset_cache_.at(quotient); + auto res = f_lseek(&file_, entry); + if (res != FR_OK) { + ESP_LOGW(kTag, "error seeking %u", res); + file_error_ = true; + return; + } + + // Count ahead entries. + advanceBy(remainder + 1); +} + +auto Playlist::skipToWithoutCache(size_t position) -> void { + if (position >= pos_) { + advanceBy(position - pos_); + } else { + pos_ = -1; + FRESULT res = f_rewind(&file_); + if (res != FR_OK) { + ESP_LOGW(kTag, "error rewinding %u", res); + file_error_ = true; + return; + } + advanceBy(position + 1); + } +} + +auto Playlist::countItems() -> void { + TCHAR buff[512]; + + for (;;) { + auto offset = f_tell(&file_); + auto next_item = nextItem(buff); + if (!next_item) { + break; + } + if (total_size_ % sample_size_ == 0) { + offset_cache_.push_back(offset); + } + total_size_++; + } + + f_rewind(&file_); +} + +auto Playlist::advanceBy(ssize_t amt) -> bool { + TCHAR buff[512]; + std::optional<std::string_view> item; + + while (amt > 0) { + item = nextItem(buff); + if (!item) { + break; + } + pos_++; + amt--; + } + + if (item) { + current_value_ = *item; + } + + return amt == 0; +} + +auto Playlist::nextItem(std::span<TCHAR> buf) + -> std::optional<std::string_view> { + while (file_open_ && !file_error_ && !f_eof(&file_)) { + // FIXME: f_gets is quite slow (it does several very small reads instead of + // grabbing a whole sector at a time), and it doesn't work well for very + // long lines. We should do something smarter here. + TCHAR* str = f_gets(buf.data(), buf.size(), &file_); + if (str == NULL) { + ESP_LOGW(kTag, "Error consuming playlist file at offset %llu", + f_tell(&file_)); + file_error_ = true; + return {}; + } + + std::string_view line{str}; + if (line.starts_with("#")) { + continue; + } + if (line.ends_with('\n')) { + line = line.substr(0, line.size() - 1); + } + return line; + } + + // Got to EOF without reading a valid line. + return {}; +} + +MutablePlaylist::MutablePlaylist(const std::string& playlistFilepath) + : Playlist(playlistFilepath) {} + +auto MutablePlaylist::clear() -> bool { + std::unique_lock<std::mutex> lock(mutex_); + + // Try to recover from any IO errors. + if (file_error_ && file_open_) { + file_error_ = false; + file_open_ = false; + f_close(&file_); + } + + FRESULT res; + if (file_open_) { + res = f_rewind(&file_); + if (res != FR_OK) { + ESP_LOGE(kTag, "error rewinding %u", res); + file_error_ = true; + return false; + } + res = f_truncate(&file_); + if (res != FR_OK) { + ESP_LOGE(kTag, "error truncating %u", res); + file_error_ = true; + return false; + } + } else { + res = f_open(&file_, filepath_.c_str(), + FA_READ | FA_WRITE | FA_CREATE_ALWAYS); + if (res != FR_OK) { + ESP_LOGE(kTag, "error opening file %u", res); + file_error_ = true; + return false; + } + file_open_ = true; + } + + total_size_ = 0; + current_value_.clear(); + offset_cache_.clear(); + pos_ = -1; + return true; +} + +auto MutablePlaylist::append(Item i) -> void { + std::unique_lock<std::mutex> lock(mutex_); + if (!file_open_ || file_error_) { + return; + } + + auto offset = f_tell(&file_); + bool first_entry = current_value_.empty(); + + // Seek to end and append + auto end = f_size(&file_); + auto res = f_lseek(&file_, end); + if (res != FR_OK) { + ESP_LOGE(kTag, "Seek to end of file failed? Error %d", res); + file_error_ = true; + return; + } + + // TODO: Resolve paths for track id, etc + std::string path; + if (std::holds_alternative<std::string>(i)) { + path = std::get<std::string>(i); + f_printf(&file_, "%s\n", path.c_str()); + if (total_size_ % sample_size_ == 0) { + offset_cache_.push_back(end); + } + if (first_entry) { + current_value_ = path; + } + total_size_++; + } + + // Restore position + res = f_lseek(&file_, offset); + if (res != FR_OK) { + ESP_LOGE(kTag, "Failed to restore file position after append?"); + file_error_ = true; + return; + } + res = f_sync(&file_); + if (res != FR_OK) { + ESP_LOGE(kTag, "Failed to sync playlist file after append"); + file_error_ = true; + return; + } +} + +} // namespace audio diff --git a/src/tangara/audio/playlist.hpp b/src/tangara/audio/playlist.hpp new file mode 100644 index 00000000..ac62c82e --- /dev/null +++ b/src/tangara/audio/playlist.hpp @@ -0,0 +1,87 @@ + +/* + * Copyright 2024 ailurux <ailuruxx@gmail.com> + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#pragma once + +#include <string> +#include <variant> + +#include "ff.h" + +#include "database/database.hpp" +#include "database/track.hpp" + +namespace audio { + +/* + * Owns and manages a playlist file. + * Each line in the playlist file is the absolute filepath of the track to play. + * In order to avoid mapping to byte offsets, each line must contain only a + * filepath (ie, no comments are supported). This limitation may be removed + * later if benchmarks show that the file can be quickly scanned from 'bookmark' + * offsets. This is a subset of the m3u format and ideally will be + * import/exportable to and from this format, to better support playlists from + * beets import and other music management software. + */ +class Playlist { + public: + Playlist(const std::string& playlistFilepath); + virtual ~Playlist(); + using Item = + std::variant<database::TrackId, database::TrackIterator, std::string>; + auto open() -> bool; + + auto filepath() const -> std::string; + auto currentPosition() const -> size_t; + auto size() const -> size_t; + auto value() const -> std::string; + auto atEnd() const -> bool; + + auto next() -> void; + auto prev() -> void; + auto skipTo(size_t position) -> void; + + protected: + const std::string filepath_; + + mutable std::mutex mutex_; + size_t total_size_; + ssize_t pos_; + + FIL file_; + bool file_open_; + bool file_error_; + + std::string current_value_; + + /* List of offsets determined by sample size */ + std::pmr::vector<FSIZE_t> offset_cache_; + + /* + * How many tracks per offset saved (ie, a value of 100 means every 100 tracks + * the file offset is saved) This speeds up searches, especially in the case + * of shuffling a lot of tracks. + */ + const uint32_t sample_size_; + + private: + auto skipToLocked(size_t position) -> void; + auto countItems() -> void; + auto advanceBy(ssize_t amt) -> bool; + auto nextItem(std::span<TCHAR>) -> std::optional<std::string_view>; + auto skipToWithoutCache(size_t position) -> void; +}; + +class MutablePlaylist : public Playlist { + public: + MutablePlaylist(const std::string& playlistFilepath); + + auto clear() -> bool; + auto append(Item i) -> void; +}; + +} // namespace audio diff --git a/src/tangara/audio/processor.cpp b/src/tangara/audio/processor.cpp index 81858110..aa2604b5 100644 --- a/src/tangara/audio/processor.cpp +++ b/src/tangara/audio/processor.cpp @@ -1,69 +1,73 @@ /* - * Copyright 2023 jacqueline <me@jacqueline.id.au> + * Copyright 2024 jacqueline <me@jacqueline.id.au> * * SPDX-License-Identifier: GPL-3.0-only */ #include "audio/processor.hpp" -#include <stdint.h> #include <algorithm> #include <cmath> #include <cstdint> +#include <cstring> #include <limits> #include <span> -#include "audio/audio_events.hpp" -#include "audio/audio_sink.hpp" -#include "drivers/i2s_dac.hpp" -#include "drivers/pcm_buffer.hpp" +#include "assert.h" #include "esp_heap_caps.h" #include "esp_log.h" -#include "events/event_queue.hpp" +#include "esp_timer.h" #include "freertos/portmacro.h" #include "freertos/projdefs.h" +#include "audio/audio_events.hpp" +#include "audio/audio_sink.hpp" +#include "audio/i2s_audio_output.hpp" #include "audio/resample.hpp" +#include "drivers/i2s_dac.hpp" +#include "drivers/pcm_buffer.hpp" +#include "events/event_queue.hpp" #include "sample.hpp" #include "tasks.hpp" [[maybe_unused]] static constexpr char kTag[] = "mixer"; -static constexpr std::size_t kSampleBufferLength = - drivers::kI2SBufferLengthFrames * sizeof(sample::Sample) * 2; -static constexpr std::size_t kSourceBufferLength = kSampleBufferLength * 2; +static const size_t kSampleBufferLength = drivers::kI2SBufferLengthFrames * 2; +static const size_t kSourceBufferLength = kSampleBufferLength * 2; namespace audio { +/* + * The output format to convert all sources to. This is currently fixed because + * the Bluetooth output doesn't support runtime configuration of its input + * format. + */ +static const I2SAudioOutput::Format kTargetFormat{ + .sample_rate = 48000, + .num_channels = 2, + .bits_per_sample = 16, +}; + SampleProcessor::SampleProcessor(drivers::PcmBuffer& sink) - : commands_(xQueueCreate(1, sizeof(Args))), - resampler_(nullptr), - source_(xStreamBufferCreateWithCaps(kSourceBufferLength, - sizeof(sample::Sample) * 2, + : commands_(xQueueCreate(2, sizeof(Args))), + source_(xStreamBufferCreateWithCaps(kSourceBufferLength + 1, + sizeof(sample::Sample), MALLOC_CAP_DMA)), sink_(sink), - leftover_bytes_(0) { - input_buffer_ = { - reinterpret_cast<sample::Sample*>(heap_caps_calloc( - kSampleBufferLength, sizeof(sample::Sample), MALLOC_CAP_DMA)), - kSampleBufferLength}; - input_buffer_as_bytes_ = {reinterpret_cast<std::byte*>(input_buffer_.data()), - input_buffer_.size_bytes()}; - - resampled_buffer_ = { - reinterpret_cast<sample::Sample*>(heap_caps_calloc( - kSampleBufferLength, sizeof(sample::Sample), MALLOC_CAP_DMA)), - kSampleBufferLength}; - + unprocessed_samples_(0) { tasks::StartPersistent<tasks::Type::kAudioConverter>([&]() { Main(); }); } SampleProcessor::~SampleProcessor() { vQueueDelete(commands_); - vStreamBufferDelete(source_); + vStreamBufferDeleteWithCaps(source_); } auto SampleProcessor::SetOutput(std::shared_ptr<IAudioOutput> output) -> void { + // Make sure our fixed output format is valid. + assert(output->PrepareFormat(kTargetFormat) == kTargetFormat); + output->Configure(kTargetFormat); + // FIXME: We should add synchronisation here, but we should be careful // about not impacting performance given that the output will change only // very rarely (if ever). @@ -80,15 +84,30 @@ auto SampleProcessor::beginStream(std::shared_ptr<TrackInfo> track) -> void { xQueueSend(commands_, &args, portMAX_DELAY); } -auto SampleProcessor::continueStream(std::span<sample::Sample> input) -> void { +auto SampleProcessor::continueStream(std::span<sample::Sample> input) + -> std::span<sample::Sample> { + size_t bytes_sent = xStreamBufferSend(source_, input.data(), + input.size_bytes(), pdMS_TO_TICKS(100)); + if (!bytes_sent) { + // If nothing could be sent, then bail out early. We don't want to send a + // samples_available command with zero samples. + return input; + } + + // We should only ever be placing whole samples into the buffer. If half + // samples start being sent, then this indicates a serious bug somewhere. + size_t samples_sent = bytes_sent / sizeof(sample::Sample); + assert(samples_sent * sizeof(sample::Sample) == bytes_sent); + Args args{ .track = nullptr, - .samples_available = input.size(), + .samples_available = samples_sent, .is_end_of_stream = false, .clear_buffers = false, }; xQueueSend(commands_, &args, portMAX_DELAY); - xStreamBufferSend(source_, input.data(), input.size_bytes(), portMAX_DELAY); + + return input.subspan(samples_sent); } auto SampleProcessor::endStream(bool cancelled) -> void { @@ -101,152 +120,281 @@ auto SampleProcessor::endStream(bool cancelled) -> void { xQueueSend(commands_, &args, portMAX_DELAY); } +IRAM_ATTR auto SampleProcessor::Main() -> void { for (;;) { + // Block indefinitely if the processor is idle. Otherwise check briefly for + // new commands, then continue processing. + TickType_t wait = hasPendingWork() ? 0 : portMAX_DELAY; + Args args; - while (!xQueueReceive(commands_, &args, portMAX_DELAY)) { + if (xQueueReceive(commands_, &args, wait)) { + if (args.is_end_of_stream && args.clear_buffers) { + // The new command is telling us to clear our buffers! This includes + // discarding any commands that have backed up without being processed. + // Discard all the old commands, then immediately handle the end of + // stream. + while (!pending_commands_.empty()) { + Args discard = pending_commands_.front(); + pending_commands_.pop_front(); + discardCommand(discard); + } + handleEndStream(true); + } else { + pending_commands_.push_back(args); + } } - if (args.track) { - handleBeginStream(*args.track); - delete args.track; + // We need to finish flushing all processed samples before we can process + // more samples. + if (!output_buffer_.isEmpty() && flushOutputBuffer()) { + continue; } - if (args.samples_available) { - handleContinueStream(args.samples_available); + + // We need to finish processing all the samples we've been told about + // before we handle backed up commands. + if (unprocessed_samples_ && !processSamples(false)) { + continue; } - if (args.is_end_of_stream) { - handleEndStream(args.clear_buffers); + + while (!pending_commands_.empty()) { + args = pending_commands_.front(); + pending_commands_.pop_front(); + + if (args.track) { + handleBeginStream(*args.track); + delete args.track; + } + if (args.samples_available) { + unprocessed_samples_ += args.samples_available; + } + if (args.is_end_of_stream) { + if (processSamples(true) || args.clear_buffers) { + handleEndStream(args.clear_buffers); + } else { + // The output filled up while we were trying to flush the last + // samples of this stream, and we haven't been told to clear our + // buffers. Retry handling this command later. + pending_commands_.push_front(args); + break; + } + } } } } auto SampleProcessor::handleBeginStream(std::shared_ptr<TrackInfo> track) -> void { - if (track->format != source_format_) { - source_format_ = track->format; - // The new stream has a different format to the previous stream (or there - // was no previous stream). - // First, clean up our filters. - resampler_.reset(); - leftover_bytes_ = 0; - - // If the output is idle, then we can reconfigure it to the closest format - // to our new source. - // If the output *wasn't* idle, then we can't reconfigure without an - // audible gap in playback. So instead, we simply keep the same target - // format and begin resampling. - if (sink_.isEmpty()) { - target_format_ = output_->PrepareFormat(track->format); - output_->Configure(target_format_); + // If the new stream's sample rate doesn't match our canonical sample rate, + // then prepare to start resampling. + if (track->format.sample_rate != kTargetFormat.sample_rate) { + ESP_LOGI(kTag, "resampling %lu -> %lu", track->format.sample_rate, + kTargetFormat.sample_rate); + if (!resampler_ || resampler_->sourceRate() != track->format.sample_rate) { + // If there's already a resampler instance for this source rate, then + // reuse it to help gapless playback work smoothly. + resampler_.reset(new Resampler(track->format.sample_rate, + kTargetFormat.sample_rate, + track->format.num_channels)); } + } else { + resampler_.reset(); } + // If the new stream has only one channel, then we double it to get stereo + // audio. + // FIXME: If the Bluetooth stack allowed us to configure the number of + // channels, we could remove this. + double_samples_ = track->format.num_channels != kTargetFormat.num_channels; + events::Audio().Dispatch(internal::StreamStarted{ .track = track, - .sink_format = target_format_, + .sink_format = kTargetFormat, .cue_at_sample = sink_.totalSent(), }); } -auto SampleProcessor::handleContinueStream(size_t samples_available) -> void { - // Loop until we finish reading all the bytes indicated. There might be - // leftovers from each iteration, and from this process as a whole, - // depending on the resampling stage. - size_t bytes_read = 0; - size_t bytes_to_read = samples_available * sizeof(sample::Sample); - while (bytes_read < bytes_to_read) { - // First top up the input buffer, taking care not to overwrite anything - // remaining from a previous iteration. - size_t bytes_read_this_it = xStreamBufferReceive( - source_, input_buffer_as_bytes_.subspan(leftover_bytes_).data(), - std::min(input_buffer_as_bytes_.size() - leftover_bytes_, - bytes_to_read - bytes_read), - portMAX_DELAY); - bytes_read += bytes_read_this_it; - - // Calculate the number of whole samples that are now in the input buffer. - size_t bytes_in_buffer = bytes_read_this_it + leftover_bytes_; - size_t samples_in_buffer = bytes_in_buffer / sizeof(sample::Sample); - - size_t samples_used = handleSamples(input_buffer_.first(samples_in_buffer)); - - // Maybe the resampler didn't consume everything. Maybe the last few - // bytes we read were half a frame. Either way, we need to calculate the - // size of the remainder in bytes, then move it to the front of our - // buffer. - size_t bytes_used = samples_used * sizeof(sample::Sample); - assert(bytes_used <= bytes_in_buffer); - - leftover_bytes_ = bytes_in_buffer - bytes_used; - if (leftover_bytes_ > 0) { - std::memmove(input_buffer_as_bytes_.data(), - input_buffer_as_bytes_.data() + bytes_used, leftover_bytes_); - } - } -} +IRAM_ATTR +auto SampleProcessor::processSamples(bool finalise) -> bool { + for (;;) { + bool out_of_work = true; -auto SampleProcessor::handleSamples(std::span<sample::Sample> input) -> size_t { - if (source_format_ == target_format_) { - // The happiest possible case: the input format matches the output - // format already. - sink_.send(input); - return input.size(); - } + // First, fill up our input buffer with samples. + if (unprocessed_samples_ > 0) { + out_of_work = false; + auto input = input_buffer_.writeAcquire(); - size_t samples_used = 0; - while (samples_used < input.size()) { - std::span<sample::Sample> output_source; - if (source_format_.sample_rate != target_format_.sample_rate) { - if (resampler_ == nullptr) { - ESP_LOGI(kTag, "creating new resampler for %lu -> %lu", - source_format_.sample_rate, target_format_.sample_rate); - resampler_.reset(new Resampler(source_format_.sample_rate, - target_format_.sample_rate, - source_format_.num_channels)); - } + size_t bytes_received = xStreamBufferReceive( + source_, input.data(), + std::min(input.size_bytes(), + unprocessed_samples_ * sizeof(sample::Sample)), + 0); - size_t read, written; - std::tie(read, written) = resampler_->Process(input.subspan(samples_used), - resampled_buffer_, false); - samples_used += read; + // We should never receive a half sample. Blow up immediately if we do. + size_t samples_received = bytes_received / sizeof(sample::Sample); + assert(samples_received * sizeof(sample::Sample) == bytes_received); - if (read == 0 && written == 0) { - // Zero samples used or written. We need more input. - break; - } - output_source = resampled_buffer_.first(written); - } else { - output_source = input; - samples_used = input.size(); + unprocessed_samples_ -= samples_received; + input_buffer_.writeCommit(samples_received); } - sink_.send(output_source); - } + // Next, push input samples through the resampler. In the best case, this + // is a simple copy operation. + if (!input_buffer_.isEmpty()) { + out_of_work = false; + auto resample_input = input_buffer_.readAcquire(); + auto resample_output = resampled_buffer_.writeAcquire(); + + size_t read, wrote; + if (resampler_) { + std::tie(read, wrote) = + resampler_->Process(resample_input, resample_output, finalise); + } else { + read = wrote = std::min(resample_input.size(), resample_output.size()); + std::copy_n(resample_input.begin(), read, resample_output.begin()); + } - return samples_used; -} + input_buffer_.readCommit(read); + resampled_buffer_.writeCommit(wrote); + } -auto SampleProcessor::handleEndStream(bool clear_bufs) -> void { - if (resampler_ && !clear_bufs) { - size_t read, written; - std::tie(read, written) = resampler_->Process({}, resampled_buffer_, true); + // Next, we need to make sure the output is in stereo. This is also a simple + // copy in the best case. + if (!resampled_buffer_.isEmpty()) { + out_of_work = false; + auto channels_input = resampled_buffer_.readAcquire(); + auto channels_output = output_buffer_.writeAcquire(); + size_t read, wrote; + if (double_samples_) { + wrote = channels_output.size(); + read = wrote / 2; + if (read > channels_input.size()) { + read = channels_input.size(); + wrote = read * 2; + } + for (size_t i = 0; i < read; i++) { + channels_output[i * 2] = channels_input[i]; + channels_output[(i * 2) + 1] = channels_input[i]; + } + } else { + read = wrote = std::min(channels_input.size(), channels_output.size()); + std::copy_n(channels_input.begin(), read, channels_output.begin()); + } + resampled_buffer_.readCommit(read); + output_buffer_.writeCommit(wrote); + } - if (written > 0) { - sink_.send(resampled_buffer_.first(written)); + // Finally, flush whatever ended up in the output buffer. + if (flushOutputBuffer()) { + if (out_of_work) { + return true; + } + } else { + // The output is congested. Back off of processing for a moment. + return false; } } +} +auto SampleProcessor::handleEndStream(bool clear_bufs) -> void { if (clear_bufs) { sink_.clear(); - } - // FIXME: This discards any leftover samples, but there probably shouldn't be - // any leftover samples. Can this be an assert instead? - leftover_bytes_ = 0; + input_buffer_.clear(); + resampled_buffer_.clear(); + output_buffer_.clear(); + + size_t bytes_discarded = 0; + size_t bytes_to_discard = unprocessed_samples_ * sizeof(sample::Sample); + auto scratch_buf = output_buffer_.writeAcquire(); + while (bytes_discarded < bytes_to_discard) { + size_t bytes_read = + xStreamBufferReceive(source_, scratch_buf.data(), + std::min(scratch_buf.size_bytes(), + bytes_to_discard - bytes_discarded), + 0); + bytes_discarded += bytes_read; + } + unprocessed_samples_ = 0; + } events::Audio().Dispatch(internal::StreamEnded{ .cue_at_sample = sink_.totalSent(), }); } +auto SampleProcessor::hasPendingWork() -> bool { + return !pending_commands_.empty() || unprocessed_samples_ > 0 || + !input_buffer_.isEmpty() || !resampled_buffer_.isEmpty() || + !output_buffer_.isEmpty(); +} + +IRAM_ATTR +auto SampleProcessor::flushOutputBuffer() -> bool { + auto samples = output_buffer_.readAcquire(); + size_t sent = sink_.send(samples); + output_buffer_.readCommit(sent); + return output_buffer_.isEmpty(); +} + +auto SampleProcessor::discardCommand(Args& command) -> void { + if (command.track) { + delete command.track; + } + if (command.samples_available) { + unprocessed_samples_ += command.samples_available; + } + // End of stream commands can just be dropped without further action. +} + +SampleProcessor::Buffer::Buffer() + : buffer_(reinterpret_cast<sample::Sample*>( + heap_caps_calloc(kSampleBufferLength, + sizeof(sample::Sample), + MALLOC_CAP_DMA)), + kSampleBufferLength), + samples_in_buffer_() {} + +SampleProcessor::Buffer::~Buffer() { + heap_caps_free(buffer_.data()); +} + +auto SampleProcessor::Buffer::writeAcquire() -> std::span<sample::Sample> { + return buffer_.subspan(samples_in_buffer_.size()); +} + +auto SampleProcessor::Buffer::writeCommit(size_t samples) -> void { + if (samples == 0) { + return; + } + samples_in_buffer_ = buffer_.first(samples + samples_in_buffer_.size()); +} + +auto SampleProcessor::Buffer::readAcquire() -> std::span<sample::Sample> { + return samples_in_buffer_; +} + +auto SampleProcessor::Buffer::readCommit(size_t samples) -> void { + if (samples == 0) { + return; + } + samples_in_buffer_ = samples_in_buffer_.subspan(samples); + + // Move the leftover samples to the front of the buffer, so that we're setup + // for a new write. + if (!samples_in_buffer_.empty()) { + std::memmove(buffer_.data(), samples_in_buffer_.data(), + samples_in_buffer_.size_bytes()); + samples_in_buffer_ = buffer_.first(samples_in_buffer_.size()); + } +} + +auto SampleProcessor::Buffer::isEmpty() -> bool { + return samples_in_buffer_.empty(); +} + +auto SampleProcessor::Buffer::clear() -> void { + samples_in_buffer_ = {}; +} + } // namespace audio diff --git a/src/tangara/audio/processor.hpp b/src/tangara/audio/processor.hpp index 5c4ad0fa..45e05291 100644 --- a/src/tangara/audio/processor.hpp +++ b/src/tangara/audio/processor.hpp @@ -8,6 +8,8 @@ #include <stdint.h> #include <cstdint> +#include <functional> +#include <list> #include <memory> #include "audio/audio_events.hpp" @@ -33,18 +35,43 @@ class SampleProcessor { auto SetOutput(std::shared_ptr<IAudioOutput>) -> void; + /* + * Signals to the sample processor that a new discrete stream of audio is now + * being sent. This will typically represent a new track being played. + */ auto beginStream(std::shared_ptr<TrackInfo>) -> void; - auto continueStream(std::span<sample::Sample>) -> void; + + /* + * Sends a span of PCM samples to the processor. Returns a subspan of the + * given span containing samples that were not able to be sent during this + * call, e.g. because of congestion downstream from the processor. + */ + auto continueStream(std::span<sample::Sample>) -> std::span<sample::Sample>; + + /* + * Signals to the sample processor that the current stream is ending. This + * can either be because the stream has naturally finished, or because it is + * being interrupted. + * If `cancelled` is false, the sample processor will ensure all previous + * samples are processed and sent before communicating the end of the stream + * onwards. If `cancelled` is true, any samples from the current stream that + * have not yet been played will be discarded. + */ auto endStream(bool cancelled) -> void; + SampleProcessor(const SampleProcessor&) = delete; + SampleProcessor& operator=(const SampleProcessor&) = delete; + private: auto Main() -> void; auto handleBeginStream(std::shared_ptr<TrackInfo>) -> void; - auto handleContinueStream(size_t samples_available) -> void; auto handleEndStream(bool cancel) -> void; - auto handleSamples(std::span<sample::Sample>) -> size_t; + auto processSamples(bool finalise) -> bool; + + auto hasPendingWork() -> bool; + auto flushOutputBuffer() -> bool; struct Args { std::shared_ptr<TrackInfo>* track; @@ -53,21 +80,49 @@ class SampleProcessor { bool clear_buffers; }; QueueHandle_t commands_; + std::list<Args> pending_commands_; - std::unique_ptr<Resampler> resampler_; + auto discardCommand(Args& command) -> void; StreamBufferHandle_t source_; drivers::PcmBuffer& sink_; - std::span<sample::Sample> input_buffer_; - std::span<std::byte> input_buffer_as_bytes_; + /* Internal utility for managing buffering samples between our filters. */ + class Buffer { + public: + Buffer(); + ~Buffer(); + + /* Returns a span of the unused space within the buffer. */ + auto writeAcquire() -> std::span<sample::Sample>; + /* Signals how many samples were just added to the writeAcquire span. */ + auto writeCommit(size_t) -> void; + + /* Returns a span of the samples stored within the buffer. */ + auto readAcquire() -> std::span<sample::Sample>; + /* Signals how many samples from the readAcquire span were consumed. */ + auto readCommit(size_t) -> void; - std::span<sample::Sample> resampled_buffer_; + auto isEmpty() -> bool; + auto clear() -> void; + + Buffer(const Buffer&) = delete; + Buffer& operator=(const Buffer&) = delete; + + private: + std::span<sample::Sample> buffer_; + std::span<sample::Sample> samples_in_buffer_; + }; + + Buffer input_buffer_; + Buffer resampled_buffer_; + Buffer output_buffer_; + + std::unique_ptr<Resampler> resampler_; + bool double_samples_; std::shared_ptr<IAudioOutput> output_; - IAudioOutput::Format source_format_; - IAudioOutput::Format target_format_; - size_t leftover_bytes_; + size_t unprocessed_samples_; }; } // namespace audio diff --git a/src/tangara/audio/resample.cpp b/src/tangara/audio/resample.cpp index 143ce230..d6369022 100644 --- a/src/tangara/audio/resample.cpp +++ b/src/tangara/audio/resample.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-only */ #include "audio/resample.hpp" +#include <stdint.h> #include <algorithm> #include <cmath> @@ -31,6 +32,7 @@ Resampler::Resampler(uint32_t source_sample_rate, kQuality, &err_)), num_channels_(num_channels) { + speex_resampler_skip_zeros(resampler_); assert(err_ == 0); } @@ -38,18 +40,24 @@ Resampler::~Resampler() { speex_resampler_destroy(resampler_); } +auto Resampler::sourceRate() -> uint32_t { + uint32_t input = 0; + uint32_t output = 0; + speex_resampler_get_rate(resampler_, &input, &output); + return input; +} + auto Resampler::Process(std::span<sample::Sample> input, std::span<sample::Sample> output, bool end_of_data) -> std::pair<size_t, size_t> { - uint32_t samples_used = input.size() / num_channels_; - uint32_t samples_produced = output.size() / num_channels_; + uint32_t frames_used = input.size() / num_channels_; + uint32_t frames_produced = output.size() / num_channels_; int err = speex_resampler_process_interleaved_int( - resampler_, input.data(), &samples_used, output.data(), - &samples_produced); + resampler_, input.data(), &frames_used, output.data(), &frames_produced); assert(err == 0); - return {samples_used * num_channels_, samples_produced * num_channels_}; + return {frames_used * num_channels_, frames_produced * num_channels_}; } } // namespace audio diff --git a/src/tangara/audio/resample.hpp b/src/tangara/audio/resample.hpp index 4d48d47f..df285020 100644 --- a/src/tangara/audio/resample.hpp +++ b/src/tangara/audio/resample.hpp @@ -6,6 +6,7 @@ #pragma once +#include <stdint.h> #include <cstdint> #include <span> #include <vector> @@ -24,6 +25,8 @@ class Resampler { ~Resampler(); + auto sourceRate() -> uint32_t; + auto Process(std::span<sample::Sample> input, std::span<sample::Sample> output, bool end_of_data) -> std::pair<size_t, size_t>; diff --git a/src/tangara/audio/track_queue.cpp b/src/tangara/audio/track_queue.cpp index 603b0de1..2c1faf96 100644 --- a/src/tangara/audio/track_queue.cpp +++ b/src/tangara/audio/track_queue.cpp @@ -26,7 +26,9 @@ #include "database/track.hpp" #include "events/event_queue.hpp" #include "memory_resource.hpp" +#include "random.hpp" #include "tasks.hpp" +#include "track_queue.hpp" #include "ui/ui_fsm.hpp" namespace audio { @@ -83,93 +85,119 @@ auto notifyChanged(bool current_changed, Reason reason) -> void { events::Audio().Dispatch(ev); } -TrackQueue::TrackQueue(tasks::WorkerPool& bg_worker) +TrackQueue::TrackQueue(tasks::WorkerPool& bg_worker, database::Handle db) : mutex_(), bg_worker_(bg_worker), - pos_(0), - tracks_(&memory::kSpiRamResource), + db_(db), + playlist_(".queue.playlist"), + position_(0), shuffle_(), repeat_(false), replay_(false) {} -auto TrackQueue::current() const -> std::optional<database::TrackId> { +auto TrackQueue::current() const -> TrackItem { const std::shared_lock<std::shared_mutex> lock(mutex_); - if (pos_ >= tracks_.size()) { + std::string val; + if (opened_playlist_ && position_ < opened_playlist_->size()) { + val = opened_playlist_->value(); + } else { + val = playlist_.value(); + } + if (val.empty()) { return {}; } - return tracks_[pos_]; + return val; } -auto TrackQueue::peekNext(std::size_t limit) const - -> std::vector<database::TrackId> { +auto TrackQueue::currentPosition() const -> size_t { const std::shared_lock<std::shared_mutex> lock(mutex_); - std::vector<database::TrackId> out; - for (size_t i = pos_ + 1; i < pos_ + limit + 1 && i < tracks_.size(); i++) { - out.push_back(i); + return position_; +} + +auto TrackQueue::totalSize() const -> size_t { + size_t sum = playlist_.size(); + if (opened_playlist_) { + sum += opened_playlist_->size(); } - return out; + return sum; } -auto TrackQueue::peekPrevious(std::size_t limit) const - -> std::vector<database::TrackId> { - const std::shared_lock<std::shared_mutex> lock(mutex_); - std::vector<database::TrackId> out; - for (size_t i = pos_ - 1; i < pos_ - limit - 1 && i >= tracks_.size(); i--) { - out.push_back(i); +auto TrackQueue::updateShuffler(bool andUpdatePosition) -> void { + if (shuffle_) { + shuffle_->resize(totalSize()); + if (andUpdatePosition) { + goTo(shuffle_->current()); + } } - return out; } -auto TrackQueue::currentPosition() const -> size_t { - const std::shared_lock<std::shared_mutex> lock(mutex_); - return pos_; +auto TrackQueue::open() -> bool { + // FIX ME: If playlist opening fails, should probably fall back to a vector of + // tracks or something so that we're not necessarily always needing mounted + // storage + return playlist_.open(); } -auto TrackQueue::totalSize() const -> size_t { - const std::shared_lock<std::shared_mutex> lock(mutex_); - return tracks_.size(); +auto TrackQueue::openPlaylist(const std::string& playlist_file, bool notify) + -> bool { + opened_playlist_.emplace(playlist_file); + auto res = opened_playlist_->open(); + if (!res) { + return false; + } + updateShuffler(true); + if (notify) { + notifyChanged(true, Reason::kExplicitUpdate); + } + return true; } -auto TrackQueue::insert(Item i, size_t index) -> void { +auto TrackQueue::getFilepath(database::TrackId id) + -> std::optional<std::string> { + auto db = db_.lock(); + if (!db) { + return {}; + } + return db->getTrackPath(id); +} + +auto TrackQueue::append(Item i) -> void { bool was_queue_empty; bool current_changed; { const std::shared_lock<std::shared_mutex> lock(mutex_); - was_queue_empty = pos_ == tracks_.size(); - current_changed = was_queue_empty || index == pos_; + was_queue_empty = playlist_.currentPosition() >= playlist_.size(); + current_changed = was_queue_empty; // Dont support inserts yet } - auto update_shuffler = [=, this]() { - if (shuffle_) { - shuffle_->resize(tracks_.size()); - // If there wasn't anything already playing, then we should make sure we - // begin playback at a random point, instead of always starting with - // whatever was inserted first and *then* shuffling. - // We don't base this purely off of current_changed because we would like - // 'play this track now' (by inserting at the current pos) to work even - // when shuffling is enabled. - if (was_queue_empty) { - pos_ = shuffle_->current(); - } - } - }; - if (std::holds_alternative<database::TrackId>(i)) { { const std::unique_lock<std::shared_mutex> lock(mutex_); - if (index <= tracks_.size()) { - tracks_.insert(tracks_.begin() + index, std::get<database::TrackId>(i)); - update_shuffler(); + auto filename = getFilepath(std::get<database::TrackId>(i)).value_or(""); + if (!filename.empty()) { + playlist_.append(filename); } + updateShuffler(was_queue_empty); } notifyChanged(current_changed, Reason::kExplicitUpdate); + } else if (std::holds_alternative<std::string>(i)) { + auto& path = std::get<std::string>(i); + if (!path.empty()) { + { + const std::unique_lock<std::shared_mutex> lock(mutex_); + playlist_.append(std::get<std::string>(i)); + updateShuffler(was_queue_empty); + } + notifyChanged(current_changed, Reason::kExplicitUpdate); + } } else if (std::holds_alternative<database::TrackIterator>(i)) { // Iterators can be very large, and retrieving items from them often // requires disk i/o. Handle them asynchronously so that inserting them // doesn't block. bg_worker_.Dispatch<void>([=, this]() { database::TrackIterator it = std::get<database::TrackIterator>(i); - size_t working_pos = index; + + size_t next_update_at = 10; while (true) { auto next = *it; if (!next) { @@ -179,33 +207,61 @@ auto TrackQueue::insert(Item i, size_t index) -> void { // like current(). { const std::unique_lock<std::shared_mutex> lock(mutex_); - if (working_pos <= tracks_.size()) { - tracks_.insert(tracks_.begin() + working_pos, *next); + auto filename = getFilepath(*next).value_or(""); + if (!filename.empty()) { + playlist_.append(filename); } } - working_pos++; it++; + + // Appending very large iterators can take a while. Send out periodic + // queue updates during them so that the user has an idea what's going + // on. + if (!--next_update_at) { + next_update_at = util::sRandom->RangeInclusive(10, 20); + notifyChanged(false, Reason::kBulkLoadingUpdate); + } } + { const std::unique_lock<std::shared_mutex> lock(mutex_); - update_shuffler(); + updateShuffler(was_queue_empty); } notifyChanged(current_changed, Reason::kExplicitUpdate); }); } } -auto TrackQueue::append(Item i) -> void { - size_t end; +auto TrackQueue::next() -> void { + next(Reason::kExplicitUpdate); +} + +auto TrackQueue::currentPosition(size_t position) -> bool { { const std::shared_lock<std::shared_mutex> lock(mutex_); - end = tracks_.size(); + if (position >= totalSize()) { + return false; + } + goTo(position); } - insert(i, end); + + // If we're explicitly setting the position, we want to treat it as though + // the current track has changed, even if the position was the same + notifyChanged(true, Reason::kExplicitUpdate); + return true; } -auto TrackQueue::next() -> void { - next(Reason::kExplicitUpdate); +auto TrackQueue::goTo(size_t position) -> void { + position_ = position; + if (opened_playlist_) { + if (position_ < opened_playlist_->size()) { + opened_playlist_->skipTo(position_); + } else { + playlist_.skipTo(position_ - opened_playlist_->size()); + } + } else { + playlist_.skipTo(position_); + } } auto TrackQueue::next(Reason r) -> void { @@ -213,21 +269,19 @@ auto TrackQueue::next(Reason r) -> void { { const std::unique_lock<std::shared_mutex> lock(mutex_); + auto pos = position_; + if (shuffle_) { shuffle_->next(); - pos_ = shuffle_->current(); + position_ = shuffle_->current(); } else { - if (pos_ + 1 >= tracks_.size()) { - if (replay_) { - pos_ = 0; - } else { - pos_ = tracks_.size(); - changed = false; - } - } else { - pos_++; + if (position_ + 1 < totalSize()) { + position_++; } } + + goTo(position_); + changed = pos != position_; } notifyChanged(changed, r); @@ -240,18 +294,13 @@ auto TrackQueue::previous() -> void { const std::unique_lock<std::shared_mutex> lock(mutex_); if (shuffle_) { shuffle_->prev(); - pos_ = shuffle_->current(); + position_ = shuffle_->current(); } else { - if (pos_ == 0) { - if (repeat_) { - pos_ = tracks_.size() - 1; - } else { - changed = false; - } - } else { - pos_--; + if (position_ > 0) { + position_--; } } + goTo(position_); } notifyChanged(changed, Reason::kExplicitUpdate); @@ -265,39 +314,12 @@ auto TrackQueue::finish() -> void { } } -auto TrackQueue::skipTo(database::TrackId id) -> void { - // Defer this work to the background not because it's particularly - // long-running (although it could be), but because we want to ensure we - // only search for the given id after any previously pending iterator - // insertions have finished. - bg_worker_.Dispatch<void>([=, this]() { - bool found = false; - { - const std::unique_lock<std::shared_mutex> lock(mutex_); - for (size_t i = 0; i < tracks_.size(); i++) { - if (tracks_[i] == id) { - pos_ = i; - found = true; - break; - } - } - } - if (found) { - notifyChanged(true, Reason::kExplicitUpdate); - } - }); -} - auto TrackQueue::clear() -> void { { const std::unique_lock<std::shared_mutex> lock(mutex_); - if (tracks_.empty()) { - return; - } - - pos_ = 0; - tracks_.clear(); - + position_ = 0; + playlist_.clear(); + opened_playlist_.reset(); if (shuffle_) { shuffle_->resize(0); } @@ -309,10 +331,8 @@ auto TrackQueue::clear() -> void { auto TrackQueue::random(bool en) -> void { { const std::unique_lock<std::shared_mutex> lock(mutex_); - // Don't check for en == true already; this has the side effect that - // repeated calls with en == true will re-shuffle. if (en) { - shuffle_.emplace(tracks_.size()); + shuffle_.emplace(totalSize()); shuffle_->replay(replay_); } else { shuffle_.reset(); @@ -360,15 +380,20 @@ auto TrackQueue::replay() const -> bool { auto TrackQueue::serialise() -> std::string { cppbor::Array tracks{}; - for (database::TrackId track : tracks_) { - tracks.add(cppbor::Uint(track)); - } cppbor::Map encoded; - encoded.add(cppbor::Uint{0}, cppbor::Array{ - cppbor::Uint{pos_}, - cppbor::Bool{repeat_}, - cppbor::Bool{replay_}, - }); + + cppbor::Array metadata{ + cppbor::Bool{repeat_}, + cppbor::Bool{replay_}, + cppbor::Uint{position_}, + }; + + if (opened_playlist_) { + metadata.add(cppbor::Tstr{opened_playlist_->filepath()}); + } + + encoded.add(cppbor::Uint{0}, std::move(metadata)); + if (shuffle_) { encoded.add(cppbor::Uint{1}, cppbor::Array{ cppbor::Uint{shuffle_->size()}, @@ -376,12 +401,11 @@ auto TrackQueue::serialise() -> std::string { cppbor::Uint{shuffle_->pos()}, }); } - encoded.add(cppbor::Uint{2}, std::move(tracks)); return encoded.toString(); } TrackQueue::QueueParseClient::QueueParseClient(TrackQueue& queue) - : queue_(queue), state_(State::kInit), i_(0) {} + : queue_(queue), state_(State::kInit), i_(0), position_to_set_(0) {} cppbor::ParseClient* TrackQueue::QueueParseClient::item( std::unique_ptr<cppbor::Item>& item, @@ -401,9 +425,6 @@ cppbor::ParseClient* TrackQueue::QueueParseClient::item( case 1: state_ = State::kShuffle; break; - case 2: - state_ = State::kTracks; - break; default: state_ = State::kFinished; } @@ -412,7 +433,13 @@ cppbor::ParseClient* TrackQueue::QueueParseClient::item( if (item->type() == cppbor::ARRAY) { i_ = 0; } else if (item->type() == cppbor::UINT) { - queue_.pos_ = item->asUint()->unsignedValue(); + auto val = item->asUint()->unsignedValue(); + // Save the position so we can apply it later when we have finished + // serialising + position_to_set_ = val; + } else if (item->type() == cppbor::TSTR) { + auto val = item->asTstr(); + queue_.openPlaylist(val->value(), false); } else if (item->type() == cppbor::SIMPLE) { bool val = item->asBool()->value(); if (i_ == 0) { @@ -444,10 +471,6 @@ cppbor::ParseClient* TrackQueue::QueueParseClient::item( } i_++; } - } else if (state_ == State::kTracks) { - if (item->type() == cppbor::UINT) { - queue_.tracks_.push_back(item->asUint()->unsignedValue()); - } } else if (state_ == State::kFinished) { } return this; @@ -461,6 +484,7 @@ cppbor::ParseClient* TrackQueue::QueueParseClient::itemEnd( if (state_ == State::kInit) { state_ = State::kFinished; } else if (state_ == State::kRoot) { + queue_.goTo(position_to_set_); state_ = State::kFinished; } else if (state_ == State::kMetadata) { if (item->type() == cppbor::ARRAY) { @@ -470,10 +494,6 @@ cppbor::ParseClient* TrackQueue::QueueParseClient::itemEnd( if (item->type() == cppbor::ARRAY) { state_ = State::kRoot; } - } else if (state_ == State::kTracks) { - if (item->type() == cppbor::ARRAY) { - state_ = State::kRoot; - } } else if (state_ == State::kFinished) { } return this; diff --git a/src/tangara/audio/track_queue.hpp b/src/tangara/audio/track_queue.hpp index 427d5f75..a8d1dc3a 100644 --- a/src/tangara/audio/track_queue.hpp +++ b/src/tangara/audio/track_queue.hpp @@ -16,6 +16,7 @@ #include "cppbor_parse.h" #include "database/database.hpp" #include "database/track.hpp" +#include "playlist.hpp" #include "tasks.hpp" namespace audio { @@ -64,27 +65,26 @@ class RandomIterator { */ class TrackQueue { public: - TrackQueue(tasks::WorkerPool& bg_worker); + TrackQueue(tasks::WorkerPool& bg_worker, database::Handle db); /* Returns the currently playing track. */ - auto current() const -> std::optional<database::TrackId>; - - /* Returns, in order, tracks that have been queued to be played next. */ - auto peekNext(std::size_t limit) const -> std::vector<database::TrackId>; - - /* - * Returns the tracks in the queue that have already been played, ordered - * most recently played first. - */ - auto peekPrevious(std::size_t limit) const -> std::vector<database::TrackId>; + using TrackItem = + std::variant<std::string, database::TrackId, std::monostate>; + auto current() const -> TrackItem; auto currentPosition() const -> size_t; + auto currentPosition(size_t position) -> bool; auto totalSize() const -> size_t; + auto open() -> bool; + auto openPlaylist(const std::string& playlist_file, bool notify = true) + -> bool; - using Item = std::variant<database::TrackId, database::TrackIterator>; - auto insert(Item, size_t index = 0) -> void; + using Item = + std::variant<database::TrackId, database::TrackIterator, std::string>; auto append(Item i) -> void; + auto updateShuffler(bool andUpdatePosition) -> void; + /* * Advances to the next track in the queue, placing the current track at the * front of the 'played' queue. @@ -97,8 +97,6 @@ class TrackQueue { */ auto finish() -> void; - auto skipTo(database::TrackId) -> void; - /* * Removes all tracks from all queues, and stops any currently playing track. */ @@ -122,13 +120,18 @@ class TrackQueue { private: auto next(QueueUpdate::Reason r) -> void; + auto goTo(size_t position) -> void; + auto getFilepath(database::TrackId id) -> std::optional<std::string>; mutable std::shared_mutex mutex_; tasks::WorkerPool& bg_worker_; + database::Handle db_; - size_t pos_; - std::pmr::vector<database::TrackId> tracks_; + MutablePlaylist playlist_; + std::optional<Playlist> opened_playlist_; + + size_t position_; std::optional<RandomIterator> shuffle_; bool repeat_; @@ -159,11 +162,11 @@ class TrackQueue { kRoot, kMetadata, kShuffle, - kTracks, kFinished, }; State state_; size_t i_; + size_t position_to_set_; }; }; diff --git a/src/tangara/battery/battery.cpp b/src/tangara/battery/battery.cpp index 3cfdb20c..f68746ae 100644 --- a/src/tangara/battery/battery.cpp +++ b/src/tangara/battery/battery.cpp @@ -26,6 +26,8 @@ static const TickType_t kBatteryCheckPeriod = pdMS_TO_TICKS(60 * 1000); */ static const uint32_t kFullChargeMilliVolts = 4200; +static const uint32_t kCriticalChargeMilliVolts = 3500; + /* * Battery voltage, in millivolts, at which *we* will consider the battery to * be completely discharged. This is intentionally higher than the charger IC @@ -65,12 +67,35 @@ auto Battery::Update() -> void { // Ideally the way you're 'supposed' to measure battery charge percent is to // keep continuous track of the amps going in and out of it at any point. I'm // skeptical of this approach, and we're not set up with the hardware needed - // to do it anyway. Instead, we use a curve-fitting formula by StackOverflow - // user 'Roho' to estimate the remaining capacity based on the battery's - // voltage. This seems to work pretty good! - double v = mV / 1000.0; - uint_fast8_t percent = static_cast<uint_fast8_t>(std::clamp<double>( - 123 - (123 / std::pow(1 + std::pow(v / 3.7, 80.0), 0.165)), 0.0, 100.0)); + // to do it anyway. Instead, we use a piecewise linear formula derived from + // voltage measurements of our actual cells. + uint_fast8_t percent; + if (mV > kCriticalChargeMilliVolts) { + // Above the 'critical' point, the relationship between battery voltage and + // charge percentage is close enough to linear. + percent = ((mV - kCriticalChargeMilliVolts) * 100 / + (kFullChargeMilliVolts - kCriticalChargeMilliVolts)) + + 5; + } else { + // Below the 'critical' point, battery voltage drops very very quickly. + // Give this part of the curve the lowest 5% to work with. + percent = (mV - kEmptyChargeMilliVolts) * 5 / + (kCriticalChargeMilliVolts - kEmptyChargeMilliVolts); + } + + // A full charge is always 100%. + if (charge_state == ChargeStatus::kFullCharge) { + percent = 100; + } + // Critical charge is always <= 5% + if (charge_state == ChargeStatus::kBatteryCritical) { + percent = std::min<uint_fast8_t>(percent, 5); + } + // When very close to full, the BMS transitions to a constant-voltage charge + // algorithm. Hold off on reporting 100% charge until this stage is finished. + if (percent >= 95 && charge_state != ChargeStatus::kFullCharge) { + percent = std::min<uint_fast8_t>(percent, 95); + } bool is_charging; if (!charge_state) { @@ -93,6 +118,8 @@ auto Battery::Update() -> void { .percent = percent, .millivolts = mV, .is_charging = is_charging, + .raw_status = + charge_state.value_or(drivers::Samd::ChargeStatus::kUnknown), }; EmitEvent(); } diff --git a/src/tangara/battery/battery.hpp b/src/tangara/battery/battery.hpp index 80b0f2d2..c4f631e0 100644 --- a/src/tangara/battery/battery.hpp +++ b/src/tangara/battery/battery.hpp @@ -27,6 +27,7 @@ class Battery { uint_fast8_t percent; uint32_t millivolts; bool is_charging; + drivers::Samd::ChargeStatus raw_status; bool operator==(const BatteryState& other) const { return percent == other.percent && is_charging == other.is_charging; diff --git a/src/tangara/database/database.cpp b/src/tangara/database/database.cpp index 85700431..64451f48 100644 --- a/src/tangara/database/database.cpp +++ b/src/tangara/database/database.cpp @@ -6,9 +6,6 @@ #include "database/database.hpp" -#include <stdint.h> -#include <sys/_stdint.h> - #include <algorithm> #include <cstdint> #include <functional> @@ -20,10 +17,8 @@ #include <string> #include <variant> -#include "collation.hpp" #include "cppbor.h" #include "cppbor_parse.h" -#include "database/index.hpp" #include "esp_log.h" #include "esp_timer.h" #include "ff.h" @@ -37,13 +32,14 @@ #include "leveldb/status.h" #include "leveldb/write_batch.h" +#include "collation.hpp" #include "database/db_events.hpp" #include "database/env_esp.hpp" -#include "database/file_gatherer.hpp" +#include "database/index.hpp" #include "database/records.hpp" #include "database/tag_parser.hpp" #include "database/track.hpp" -#include "drivers/spi.hpp" +#include "database/track_finder.hpp" #include "events/event_queue.hpp" #include "memory_resource.hpp" #include "result.hpp" @@ -55,15 +51,19 @@ static SingletonEnv<leveldb::EspEnv> sEnv; [[maybe_unused]] static const char* kTag = "DB"; static const char kDbPath[] = "/.tangara-db"; +static const char kMusicPath[] = "Music"; static const char kKeyDbVersion[] = "schema_version"; - static const char kKeyCustom[] = "U\0"; static const char kKeyCollator[] = "collator"; -static const char kKeyTrackId[] = "next_track_id"; + +static constexpr size_t kMaxParallelism = 2; static std::atomic<bool> sIsDbOpen(false); +using std::placeholders::_1; +using std::placeholders::_2; + static auto CreateNewDatabase(leveldb::Options& options, locale::ICollator& col) -> leveldb::DB* { Database::Destroy(); @@ -122,8 +122,7 @@ static auto CheckDatabase(leveldb::DB& db, locale::ICollator& col) -> bool { return true; } -auto Database::Open(IFileGatherer& gatherer, - ITagParser& parser, +auto Database::Open(ITagParser& parser, locale::ICollator& collator, tasks::WorkerPool& bg_worker) -> cpp::result<Database*, DatabaseError> { @@ -144,10 +143,10 @@ auto Database::Open(IFileGatherer& gatherer, leveldb::Options options; options.env = sEnv.env(); - options.write_buffer_size = 4 * 1024; - options.max_file_size = 16 * 1024; + // Match the write buffer size to the MMU page size in order to + // make most efficient use of PSRAM mapping. + options.write_buffer_size = CONFIG_MMU_PAGE_SIZE; options.block_cache = cache.get(); - options.block_size = 2048; auto status = leveldb::DB::Open(options, kDbPath, &db); if (!status.ok()) { @@ -168,7 +167,7 @@ auto Database::Open(IFileGatherer& gatherer, } ESP_LOGI(kTag, "Database opened successfully"); - return new Database(db, cache.release(), gatherer, parser, + return new Database(db, cache.release(), bg_worker, parser, collator); }) .get(); @@ -182,15 +181,21 @@ auto Database::Destroy() -> void { Database::Database(leveldb::DB* db, leveldb::Cache* cache, - IFileGatherer& file_gatherer, + tasks::WorkerPool& pool, ITagParser& tag_parser, locale::ICollator& collator) : db_(db), cache_(cache), - file_gatherer_(file_gatherer), + track_finder_( + pool, + kMaxParallelism, + std::bind(&Database::processCandidateCallback, this, _1, _2), + std::bind(&Database::indexingCompleteCallback, this)), tag_parser_(tag_parser), collator_(collator), - is_updating_(false) {} + is_updating_(false) { + dbCalculateNextTrackId(); +} Database::~Database() { // Delete db_ first so that any outstanding background work finishes before @@ -244,7 +249,7 @@ auto Database::get(const std::string& key) -> std::optional<std::string> { } auto Database::getTrackPath(TrackId id) -> std::optional<std::string> { - auto track_data = dbGetTrackData(id); + auto track_data = dbGetTrackData(leveldb::ReadOptions(), id); if (!track_data) { return {}; } @@ -252,7 +257,7 @@ auto Database::getTrackPath(TrackId id) -> std::optional<std::string> { } auto Database::getTrack(TrackId id) -> std::shared_ptr<Track> { - std::shared_ptr<TrackData> data = dbGetTrackData(id); + std::shared_ptr<TrackData> data = dbGetTrackData(leveldb::ReadOptions(), id); if (!data || data->is_tombstoned) { return {}; } @@ -275,34 +280,61 @@ auto Database::getIndexes() -> std::vector<IndexInfo> { }; } -class UpdateNotifier { - public: - UpdateNotifier(std::atomic<bool>& is_updating) : is_updating_(is_updating) { - events::Ui().Dispatch(event::UpdateStarted{}); - events::System().Dispatch(event::UpdateStarted{}); +Database::UpdateTracker::UpdateTracker() + : num_old_tracks_(0), + num_new_tracks_(0), + start_time_(esp_timer_get_time()) { + events::Ui().Dispatch(event::UpdateStarted{}); + events::System().Dispatch(event::UpdateStarted{}); +} + +Database::UpdateTracker::~UpdateTracker() { + uint64_t end_time = esp_timer_get_time(); + + uint64_t time_per_old = 0; + if (num_old_tracks_) { + time_per_old = (verification_finish_time_ - start_time_) / num_old_tracks_; } - ~UpdateNotifier() { - is_updating_ = false; - events::Ui().Dispatch(event::UpdateFinished{}); - events::System().Dispatch(event::UpdateFinished{}); + uint64_t time_per_new = 0; + if (num_new_tracks_) { + time_per_new = (end_time - verification_finish_time_) / num_new_tracks_; } - private: - std::atomic<bool>& is_updating_; -}; + ESP_LOGI( + kTag, + "processed %lu old tracks and %lu new tracks in %llu seconds (%llums " + "per old, %llums per new)", + num_old_tracks_, num_new_tracks_, (end_time - start_time_) / 1000000, + time_per_old / 1000, time_per_new / 1000); + + events::Ui().Dispatch(event::UpdateFinished{}); + events::System().Dispatch(event::UpdateFinished{}); +} + +auto Database::UpdateTracker::onTrackVerified() -> void { + events::Ui().Dispatch(event::UpdateProgress{ + .stage = event::UpdateProgress::Stage::kVerifyingExistingTracks, + .val = ++num_old_tracks_, + }); +} + +auto Database::UpdateTracker::onVerificationFinished() -> void { + verification_finish_time_ = esp_timer_get_time(); +} + +auto Database::UpdateTracker::onTrackAdded() -> void { + num_new_tracks_++; +} auto Database::updateIndexes() -> void { if (is_updating_.exchange(true)) { return; } - UpdateNotifier notifier{is_updating_}; - - uint32_t num_old_tracks = 0; - uint32_t num_new_tracks = 0; - uint64_t start_time = esp_timer_get_time(); + update_tracker_ = std::make_unique<UpdateTracker>(); leveldb::ReadOptions read_options; - read_options.fill_cache = true; + read_options.fill_cache = false; + read_options.verify_checksums = true; // Stage 1: verify all existing tracks are still valid. ESP_LOGI(kTag, "verifying existing tracks"); @@ -311,11 +343,7 @@ auto Database::updateIndexes() -> void { std::string prefix = EncodeDataPrefix(); for (it->Seek(prefix); it->Valid() && it->key().starts_with(prefix); it->Next()) { - num_old_tracks++; - events::Ui().Dispatch(event::UpdateProgress{ - .stage = event::UpdateProgress::Stage::kVerifyingExistingTracks, - .val = num_old_tracks, - }); + update_tracker_->onTrackVerified(); std::shared_ptr<TrackData> track = ParseDataValue(it->value()); if (!track) { @@ -326,7 +354,6 @@ auto Database::updateIndexes() -> void { } if (track->is_tombstoned) { - ESP_LOGW(kTag, "skipping tombstoned %lx", track->id); continue; } @@ -349,11 +376,19 @@ auto Database::updateIndexes() -> void { // We couldn't read the tags for this track. Either they were // malformed, or perhaps the file is missing. Either way, tombstone // this record. - ESP_LOGW(kTag, "entombing missing #%lx", track->id); + ESP_LOGI(kTag, "entombing missing #%lx", track->id); + + // Remove the indexes first, so that interrupted operations don't leave + // dangling index records. dbRemoveIndexes(track); + + // Do the rest of the tombstoning as one atomic write. + leveldb::WriteBatch batch; track->is_tombstoned = true; - dbPutTrackData(*track); - db_->Delete(leveldb::WriteOptions{}, EncodePathKey(track->filepath)); + batch.Put(EncodeDataKey(track->id), EncodeDataValue(*track)); + batch.Delete(EncodePathKey(track->filepath)); + + db_->Write(leveldb::WriteOptions(), &batch); continue; } @@ -367,204 +402,181 @@ auto Database::updateIndexes() -> void { // database. ESP_LOGI(kTag, "updating hash (%llx -> %llx)", track->tags_hash, new_hash); + + // Again, we remove the old index records first so has to avoid + // dangling references. dbRemoveIndexes(track); + // Atomically correct the hash + create the new index records. + leveldb::WriteBatch batch; track->tags_hash = new_hash; - dbIngestTagHashes(*tags, track->individual_tag_hashes); - dbPutTrackData(*track); - dbPutHash(new_hash, track->id); + dbIngestTagHashes(*tags, track->individual_tag_hashes, batch); + + dbCreateIndexesForTrack(*track, *tags, batch); + batch.Put(EncodeDataKey(track->id), EncodeDataValue(*track)); + batch.Put(EncodeHashKey(new_hash), EncodeHashValue(track->id)); + db_->Write(leveldb::WriteOptions(), &batch); } } } - uint64_t verify_end_time = esp_timer_get_time(); + update_tracker_->onVerificationFinished(); // Stage 2: search for newly added files. - ESP_LOGI(kTag, "scanning for new tracks"); - uint64_t num_files = 0; - file_gatherer_.FindFiles("", [&](std::string_view path, const FILINFO& info) { - num_files++; - events::Ui().Dispatch(event::UpdateProgress{ - .stage = event::UpdateProgress::Stage::kScanningForNewTracks, - .val = num_files, - }); - - std::string unused; - if (db_->Get(read_options, EncodePathKey(path), &unused).ok()) { - // This file is already in the database; skip it. - return; - } - - std::shared_ptr<TrackTags> tags = tag_parser_.ReadAndParseTags(path); - if (!tags || tags->encoding() == Container::kUnsupported) { - // No parseable tags; skip this fiile. - return; - } + std::string root; + FF_DIR dir; + if (f_opendir(&dir, kMusicPath) == FR_OK) { + f_closedir(&dir); + root = kMusicPath; + } + ESP_LOGI(kTag, "scanning for new tracks in '%s'", root.c_str()); + track_finder_.launch(root); +}; - // Check for any existing record with the same hash. - uint64_t hash = tags->Hash(); - std::string key = EncodeHashKey(hash); - std::optional<TrackId> existing_hash; - std::string raw_entry; - if (db_->Get(leveldb::ReadOptions(), key, &raw_entry).ok()) { - existing_hash = ParseHashValue(raw_entry); - } +auto Database::processCandidateCallback(FILINFO& info, std::string_view path) + -> void { + leveldb::ReadOptions read_options; + read_options.fill_cache = true; + read_options.verify_checksums = false; - std::pair<uint16_t, uint16_t> modified{info.fdate, info.ftime}; - if (!existing_hash) { - // We've never met this track before! Or we have, but the entry is - // malformed. Either way, record this as a new track. - TrackId id = dbMintNewTrackId(); - ESP_LOGD(kTag, "recording new 0x%lx", id); - num_new_tracks++; - - auto data = std::make_shared<TrackData>(); - data->id = id; - data->filepath = path; - data->tags_hash = hash; - data->modified_at = modified; - dbIngestTagHashes(*tags, data->individual_tag_hashes); - - dbPutTrackData(*data); - dbPutHash(hash, id); - auto t = std::make_shared<Track>(data, tags); - dbCreateIndexesForTrack(*t); - db_->Put(leveldb::WriteOptions{}, EncodePathKey(path), - TrackIdToBytes(id)); - return; - } + std::string unused; + if (db_->Get(read_options, EncodePathKey(path), &unused).ok()) { + // This file is already in the database; skip it. + return; + } - std::shared_ptr<TrackData> existing_data = dbGetTrackData(*existing_hash); - if (!existing_data) { - // We found a hash that matches, but there's no data record? Weird. - auto new_data = std::make_shared<TrackData>(); - new_data->id = dbMintNewTrackId(); - new_data->filepath = path; - new_data->tags_hash = hash; - new_data->modified_at = modified; - dbIngestTagHashes(*tags, new_data->individual_tag_hashes); - dbPutTrackData(*new_data); - auto t = std::make_shared<Track>(new_data, tags); - dbCreateIndexesForTrack(*t); - db_->Put(leveldb::WriteOptions{}, EncodePathKey(path), - TrackIdToBytes(new_data->id)); - return; - } + std::shared_ptr<TrackTags> tags = tag_parser_.ReadAndParseTags(path); + if (!tags || tags->encoding() == Container::kUnsupported) { + // No parseable tags; skip this fiile. + return; + } - if (existing_data->is_tombstoned) { - ESP_LOGI(kTag, "exhuming track %lu", existing_data->id); - existing_data->is_tombstoned = false; - existing_data->modified_at = modified; - dbPutTrackData(*existing_data); - auto t = std::make_shared<Track>(existing_data, tags); - dbCreateIndexesForTrack(*t); - db_->Put(leveldb::WriteOptions{}, EncodePathKey(path), - TrackIdToBytes(existing_data->id)); - } else if (existing_data->filepath != - std::pmr::string{path.data(), path.size()}) { + // Check for any existing track with the same hash. + uint64_t hash = tags->Hash(); + std::optional<TrackId> existing_id; + std::string raw_entry; + if (db_->Get(read_options, EncodeHashKey(hash), &raw_entry).ok()) { + existing_id = ParseHashValue(raw_entry); + } + + std::shared_ptr<TrackData> data; + if (existing_id) { + // Do we have any existing data for this track? This could be the case if + // this is a tombstoned entry. In such as case, we want to reuse the + // previous TrackData so that any extra metadata is preserved. + data = dbGetTrackData(read_options, *existing_id); + if (!data) { + data = std::make_shared<TrackData>(); + data->id = *existing_id; + } else if (data->filepath != path && !data->is_tombstoned) { ESP_LOGW(kTag, "hash collision: %s, %s, %s", tags->title().value_or("no title").c_str(), tags->artist().value_or("no artist").c_str(), tags->album().value_or("no album").c_str()); + // Don't commit anything if there's a hash collision, since we're + // likely to make a big mess. + return; } - }); + } else { + update_tracker_->onTrackAdded(); + data = std::make_shared<TrackData>(); + data->id = dbMintNewTrackId(); + } - uint64_t end_time = esp_timer_get_time(); + // Make sure the file-based metadata on the TrackData is up to date. + data->filepath = path; + data->tags_hash = hash; + data->modified_at = {info.fdate, info.ftime}; + data->is_tombstoned = false; - uint64_t time_per_old = 0; - if (num_old_tracks) { - time_per_old = (verify_end_time - start_time) / num_old_tracks; - } - uint64_t time_per_new = 0; - if (num_new_tracks) { - time_per_new = (end_time - verify_end_time) / num_new_tracks; - } + // Apply all the actual database changes as one atomic batch. This makes + // the whole 'new track' operation atomic, and also reduces the amount of + // lock contention when adding many tracks at once. + leveldb::WriteBatch batch; + dbIngestTagHashes(*tags, data->individual_tag_hashes, batch); - ESP_LOGI( - kTag, - "processed %lu old tracks and %lu new tracks in %llu seconds (%llums " - "per old, %llums per new)", - num_old_tracks, num_new_tracks, (end_time - start_time) / 1000000, - time_per_old / 1000, time_per_new / 1000); + dbCreateIndexesForTrack(*data, *tags, batch); + batch.Put(EncodeDataKey(data->id), EncodeDataValue(*data)); + batch.Put(EncodeHashKey(data->tags_hash), EncodeHashValue(data->id)); + batch.Put(EncodePathKey(path), TrackIdToBytes(data->id)); + + db_->Write(leveldb::WriteOptions(), &batch); +} + +auto Database::indexingCompleteCallback() -> void { + update_tracker_.reset(); + is_updating_ = false; } auto Database::isUpdating() -> bool { return is_updating_; } -auto Database::dbMintNewTrackId() -> TrackId { - TrackId next_id = 1; - std::string val; - auto status = db_->Get(leveldb::ReadOptions(), kKeyTrackId, &val); - if (status.ok()) { - next_id = BytesToTrackId(val).value_or(next_id); - } else if (!status.IsNotFound()) { - // TODO(jacqueline): Handle this more. - ESP_LOGE(kTag, "failed to get next track id"); +auto Database::dbCalculateNextTrackId() -> void { + std::unique_ptr<leveldb::Iterator> it{ + db_->NewIterator(leveldb::ReadOptions())}; + + // Track data entries are of the format 'D/trackid', where track ids are + // encoded as big-endian cbor types. They can therefore be compared through + // byte ordering, which means we can determine what the next id should be by + // looking at the larged track data record in the database. + std::string prefix = EncodeDataPrefix(); + std::string prefixPlusOne = prefix; + prefixPlusOne[prefixPlusOne.size() - 1]++; + + // Seek to just past the track data section. + it->Seek(prefixPlusOne); + if (!it->Valid()) { + next_track_id_ = 1; + return; } - if (!db_->Put(leveldb::WriteOptions(), kKeyTrackId, - TrackIdToBytes(next_id + 1)) - .ok()) { - ESP_LOGE(kTag, "failed to write next track id"); + // Go back to the last track data record. + it->Prev(); + if (!it->Valid() || !it->key().starts_with(prefix)) { + next_track_id_ = 1; + return; } - return next_id; -} - -auto Database::dbEntomb(TrackId id, uint64_t hash) -> void { - std::string key = EncodeHashKey(hash); - std::string val = EncodeHashValue(id); - if (!db_->Put(leveldb::WriteOptions(), key, val).ok()) { - ESP_LOGE(kTag, "failed to entomb #%llx (id #%lx)", hash, id); + // Parse the track id back out of the key. + std::span<const char> key{it->key().data(), it->key().size()}; + auto id_part = key.subspan(prefix.size()); + if (id_part.empty()) { + next_track_id_ = 1; + return; } + + next_track_id_ = BytesToTrackId(id_part).value_or(0) + 1; } -auto Database::dbPutTrackData(const TrackData& s) -> void { - std::string key = EncodeDataKey(s.id); - std::string val = EncodeDataValue(s); - if (!db_->Put(leveldb::WriteOptions(), key, val).ok()) { - ESP_LOGE(kTag, "failed to write data for #%lx", s.id); - } +auto Database::dbMintNewTrackId() -> TrackId { + return next_track_id_++; } -auto Database::dbGetTrackData(TrackId id) -> std::shared_ptr<TrackData> { +auto Database::dbGetTrackData(leveldb::ReadOptions options, TrackId id) + -> std::shared_ptr<TrackData> { std::string key = EncodeDataKey(id); std::string raw_val; - if (!db_->Get(leveldb::ReadOptions(), key, &raw_val).ok()) { + if (!db_->Get(options, key, &raw_val).ok()) { ESP_LOGW(kTag, "no key found for #%lx", id); return {}; } return ParseDataValue(raw_val); } -auto Database::dbPutHash(const uint64_t& hash, TrackId i) -> void { - std::string key = EncodeHashKey(hash); - std::string val = EncodeHashValue(i); - if (!db_->Put(leveldb::WriteOptions(), key, val).ok()) { - ESP_LOGE(kTag, "failed to write hash for #%lx", i); - } +auto Database::dbCreateIndexesForTrack(const Track& track, + leveldb::WriteBatch& batch) -> void { + dbCreateIndexesForTrack(track.data(), track.tags(), batch); } -auto Database::dbGetHash(const uint64_t& hash) -> std::optional<TrackId> { - std::string key = EncodeHashKey(hash); - std::string raw_val; - if (!db_->Get(leveldb::ReadOptions(), key, &raw_val).ok()) { - ESP_LOGW(kTag, "no key found for hash #%llx", hash); - return {}; - } - return ParseHashValue(raw_val); -} - -auto Database::dbCreateIndexesForTrack(const Track& track) -> void { +auto Database::dbCreateIndexesForTrack(const TrackData& data, + const TrackTags& tags, + leveldb::WriteBatch& batch) -> void { for (const IndexInfo& index : getIndexes()) { - leveldb::WriteBatch writes; - auto entries = Index(collator_, index, track); + auto entries = Index(collator_, index, data, tags); for (const auto& it : entries) { - writes.Put(EncodeIndexKey(it.first), - {it.second.data(), it.second.size()}); + batch.Put(EncodeIndexKey(it.first), {it.second.data(), it.second.size()}); } - db_->Write(leveldb::WriteOptions(), &writes); } } @@ -573,9 +585,8 @@ auto Database::dbRemoveIndexes(std::shared_ptr<TrackData> data) -> void { if (!tags) { return; } - Track track{data, tags}; for (const IndexInfo& index : getIndexes()) { - auto entries = Index(collator_, index, track); + auto entries = Index(collator_, index, *data, *tags); for (auto it = entries.rbegin(); it != entries.rend(); it++) { auto key = EncodeIndexKey(it->first); auto status = db_->Delete(leveldb::WriteOptions{}, key); @@ -602,16 +613,14 @@ auto Database::dbRemoveIndexes(std::shared_ptr<TrackData> data) -> void { } auto Database::dbIngestTagHashes(const TrackTags& tags, - std::pmr::unordered_map<Tag, uint64_t>& out) - -> void { - leveldb::WriteBatch batch{}; + std::pmr::unordered_map<Tag, uint64_t>& out, + leveldb::WriteBatch& batch) -> void { for (const auto& tag : tags.allPresent()) { auto val = tags.get(tag); auto hash = tagHash(val); batch.Put(EncodeTagHashKey(hash), tagToString(val)); out[tag] = hash; } - db_->Write(leveldb::WriteOptions{}, &batch); } auto Database::dbRecoverTagsFromHashes( diff --git a/src/tangara/database/database.hpp b/src/tangara/database/database.hpp index c2e72568..18070353 100644 --- a/src/tangara/database/database.hpp +++ b/src/tangara/database/database.hpp @@ -19,23 +19,25 @@ #include "collation.hpp" #include "cppbor.h" -#include "database/file_gatherer.hpp" #include "database/index.hpp" #include "database/records.hpp" #include "database/tag_parser.hpp" #include "database/track.hpp" +#include "database/track_finder.hpp" +#include "ff.h" #include "leveldb/cache.h" #include "leveldb/db.h" #include "leveldb/iterator.h" #include "leveldb/options.h" #include "leveldb/slice.h" +#include "leveldb/write_batch.h" #include "memory_resource.hpp" #include "result.hpp" #include "tasks.hpp" namespace database { -const uint8_t kCurrentDbVersion = 6; +const uint8_t kCurrentDbVersion = 7; struct SearchKey; class Record; @@ -55,8 +57,7 @@ class Database { ALREADY_OPEN, FAILED_TO_OPEN, }; - static auto Open(IFileGatherer& file_gatherer, - ITagParser& tag_parser, + static auto Open(ITagParser& tag_parser, locale::ICollator& collator, tasks::WorkerPool& bg_worker) -> cpp::result<Database*, DatabaseError>; @@ -94,32 +95,59 @@ class Database { leveldb::DB* db_; leveldb::Cache* cache_; + TrackFinder track_finder_; + // Not owned. - IFileGatherer& file_gatherer_; ITagParser& tag_parser_; locale::ICollator& collator_; + /* Internal utility for tracking a currently in-progress index update. */ + class UpdateTracker { + public: + UpdateTracker(); + ~UpdateTracker(); + + auto onTrackVerified() -> void; + auto onVerificationFinished() -> void; + auto onTrackAdded() -> void; + + private: + uint32_t num_old_tracks_; + uint32_t num_new_tracks_; + uint64_t start_time_; + uint64_t verification_finish_time_; + }; + std::atomic<bool> is_updating_; + std::unique_ptr<UpdateTracker> update_tracker_; + + std::atomic<TrackId> next_track_id_; Database(leveldb::DB* db, leveldb::Cache* cache, - IFileGatherer& file_gatherer, + tasks::WorkerPool& pool, ITagParser& tag_parser, locale::ICollator& collator); + auto processCandidateCallback(FILINFO&, std::string_view) -> void; + auto indexingCompleteCallback() -> void; + + auto dbCalculateNextTrackId() -> void; auto dbMintNewTrackId() -> TrackId; - auto dbEntomb(TrackId track, uint64_t hash) -> void; - auto dbPutTrackData(const TrackData& s) -> void; - auto dbGetTrackData(TrackId id) -> std::shared_ptr<TrackData>; - auto dbPutHash(const uint64_t& hash, TrackId i) -> void; - auto dbGetHash(const uint64_t& hash) -> std::optional<TrackId>; + auto dbGetTrackData(leveldb::ReadOptions, TrackId id) + -> std::shared_ptr<TrackData>; + + auto dbCreateIndexesForTrack(const Track&, leveldb::WriteBatch&) -> void; + auto dbCreateIndexesForTrack(const TrackData&, + const TrackTags&, + leveldb::WriteBatch&) -> void; - auto dbCreateIndexesForTrack(const Track& track) -> void; auto dbRemoveIndexes(std::shared_ptr<TrackData>) -> void; auto dbIngestTagHashes(const TrackTags&, - std::pmr::unordered_map<Tag, uint64_t>&) -> void; + std::pmr::unordered_map<Tag, uint64_t>&, + leveldb::WriteBatch&) -> void; auto dbRecoverTagsFromHashes(const std::pmr::unordered_map<Tag, uint64_t>&) -> std::shared_ptr<TrackTags>; diff --git a/src/tangara/database/file_gatherer.cpp b/src/tangara/database/file_gatherer.cpp deleted file mode 100644 index dd4b1138..00000000 --- a/src/tangara/database/file_gatherer.cpp +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2023 jacqueline <me@jacqueline.id.au> - * - * SPDX-License-Identifier: GPL-3.0-only - */ - -#include "database/file_gatherer.hpp" - -#include <deque> -#include <functional> -#include <sstream> -#include <string> - -#include "ff.h" - -#include "drivers/spi.hpp" -#include "memory_resource.hpp" - -namespace database { - -static_assert(sizeof(TCHAR) == sizeof(char), "TCHAR must be CHAR"); - -auto FileGathererImpl::FindFiles( - const std::string& root, - std::function<void(std::string_view, const FILINFO&)> cb) -> void { - std::pmr::deque<std::pmr::string> to_explore{&memory::kSpiRamResource}; - to_explore.push_back({root.data(), root.size()}); - - while (!to_explore.empty()) { - auto next_path_str = to_explore.front(); - to_explore.pop_front(); - - const TCHAR* next_path = static_cast<const TCHAR*>(next_path_str.c_str()); - - FF_DIR dir; - FRESULT res = f_opendir(&dir, next_path); - if (res != FR_OK) { - // TODO: log. - continue; - } - - for (;;) { - FILINFO info; - res = f_readdir(&dir, &info); - if (res != FR_OK || info.fname[0] == 0) { - // No more files in the directory. - break; - } else if (info.fattrib & (AM_HID | AM_SYS) || info.fname[0] == '.') { - // System or hidden file. Ignore it and move on. - continue; - } else { - std::pmr::string full_path{&memory::kSpiRamResource}; - full_path += next_path_str; - full_path += "/"; - full_path += info.fname; - - if (info.fattrib & AM_DIR) { - // This is a directory. Add it to the explore queue. - to_explore.push_back(full_path); - } else { - // This is a file! Let the callback know about it. - // std::invoke(cb, full_path.str(), info); - std::invoke(cb, full_path, info); - } - } - } - - f_closedir(&dir); - } -} - -} // namespace database diff --git a/src/tangara/database/file_gatherer.hpp b/src/tangara/database/file_gatherer.hpp deleted file mode 100644 index 38558b9e..00000000 --- a/src/tangara/database/file_gatherer.hpp +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2023 jacqueline <me@jacqueline.id.au> - * - * SPDX-License-Identifier: GPL-3.0-only - */ - -#pragma once - -#include <deque> -#include <functional> -#include <sstream> -#include <string> - -#include "ff.h" - -namespace database { - -class IFileGatherer { - public: - virtual ~IFileGatherer() {}; - - virtual auto FindFiles( - const std::string& root, - std::function<void(std::string_view, const FILINFO&)> cb) -> void = 0; -}; - -class FileGathererImpl : public IFileGatherer { - public: - virtual auto FindFiles(const std::string& root, - std::function<void(std::string_view, const FILINFO&)> - cb) -> void override; -}; - -} // namespace database diff --git a/src/tangara/database/index.cpp b/src/tangara/database/index.cpp index 93a2b1c2..dec458f4 100644 --- a/src/tangara/database/index.cpp +++ b/src/tangara/database/index.cpp @@ -52,10 +52,29 @@ const IndexInfo kAllAlbums{ .components = {Tag::kAlbum, Tag::kAlbumOrder}, }; +static auto titleOrFilename(const TrackData& data, const TrackTags& tags) + -> std::pmr::string { + auto title = tags.title(); + if (title) { + return *title; + } + auto start = data.filepath.find_last_of('/'); + if (start == std::pmr::string::npos) { + return data.filepath; + } + return data.filepath.substr(start + 1); +} + class Indexer { public: - Indexer(locale::ICollator& collator, const Track& t, const IndexInfo& idx) - : collator_(collator), track_(t), index_(idx) {} + Indexer(locale::ICollator& collator, + const IndexInfo& idx, + const TrackData& data, + const TrackTags& tags) + : collator_(collator), + index_(idx), + track_data_(data), + track_tags_(tags) {} auto index() -> std::vector<std::pair<IndexKey, std::string>>; @@ -70,14 +89,13 @@ class Indexer { auto missing_value(Tag tag) -> TagValue { switch (tag) { case Tag::kTitle: - return track_.TitleOrFilename(); + return titleOrFilename(track_data_, track_tags_); case Tag::kArtist: return "Unknown Artist"; case Tag::kAlbum: return "Unknown Album"; case Tag::kAlbumArtist: - return track_.tags().artist().value_or("Unknown Artist"); - return "Unknown Album"; + return track_tags_.artist().value_or("Unknown Artist"); case Tag::kGenres: return std::pmr::vector<std::pmr::string>{}; case Tag::kDisc: @@ -91,8 +109,9 @@ class Indexer { } locale::ICollator& collator_; - const Track& track_; const IndexInfo index_; + const TrackData& track_data_; + const TrackTags& track_tags_; std::vector<std::pair<IndexKey, std::string>> out_; }; @@ -113,7 +132,7 @@ auto Indexer::index() -> std::vector<std::pair<IndexKey, std::string>> { auto Indexer::handleLevel(const IndexKey::Header& header, std::span<const Tag> components) -> void { Tag component = components.front(); - TagValue value = track_.tags().get(component); + TagValue value = track_tags_.get(component); if (std::holds_alternative<std::monostate>(value)) { value = missing_value(component); } @@ -157,21 +176,17 @@ auto Indexer::handleItem(const IndexKey::Header& header, auto xfrm = collator_.Transform(value); key.item = {xfrm.data(), xfrm.size()}; } else if constexpr (std::is_same_v<T, uint32_t>) { - value = std::to_string(arg); - // FIXME: this sucks lol. we should just write the number directly, - // LSB-first, but then we need to be able to parse it back properly. - std::ostringstream str; - str << std::setw(8) << std::setfill('0') << arg; - std::string encoded = str.str(); - key.item = {encoded.data(), encoded.size()}; + // CBOR's varint encoding actually works great for lexicographical + // sorting. + key.item = cppbor::Uint{arg}.toString(); } }, item); std::optional<IndexKey::Header> next_level; if (components.size() == 1) { - value = track_.TitleOrFilename(); - key.track = track_.data().id; + value = titleOrFilename(track_data_, track_tags_); + key.track = track_data_.id; } else { next_level = ExpandHeader(key.header, key.item); } @@ -183,10 +198,12 @@ auto Indexer::handleItem(const IndexKey::Header& header, } } -auto Index(locale::ICollator& c, - const IndexInfo& i, - const Track& t) -> std::vector<std::pair<IndexKey, std::string>> { - Indexer indexer{c, t, i}; +auto Index(locale::ICollator& collator, + const IndexInfo& index, + const TrackData& data, + const TrackTags& tags) + -> std::vector<std::pair<IndexKey, std::string>> { + Indexer indexer{collator, index, data, tags}; return indexer.index(); } diff --git a/src/tangara/database/index.hpp b/src/tangara/database/index.hpp index 8f78439b..bc01ec2f 100644 --- a/src/tangara/database/index.hpp +++ b/src/tangara/database/index.hpp @@ -63,7 +63,8 @@ struct IndexKey { auto Index(locale::ICollator&, const IndexInfo&, - const Track&) -> std::vector<std::pair<IndexKey, std::string>>; + const TrackData&, + const TrackTags&) -> std::vector<std::pair<IndexKey, std::string>>; auto ExpandHeader(const IndexKey::Header&, const std::optional<std::pmr::string>&) -> IndexKey::Header; diff --git a/src/tangara/database/records.cpp b/src/tangara/database/records.cpp index 88ddbd91..17009cd8 100644 --- a/src/tangara/database/records.cpp +++ b/src/tangara/database/records.cpp @@ -19,6 +19,7 @@ #include "cppbor.h" #include "cppbor_parse.h" +#include "debug.hpp" #include "esp_log.h" #include "database/index.hpp" @@ -226,19 +227,15 @@ auto ParseIndexKey(const leveldb::Slice& slice) -> std::optional<IndexKey> { return {}; } - std::istringstream in(key_data.substr(header_length + 1)); - std::stringbuf buffer{}; + key_data = key_data.substr(header_length + 1); + size_t last_sep = key_data.find_last_of('\0'); - in.get(buffer, kFieldSeparator); - if (buffer.str().size() > 0) { - result.item = buffer.str(); + if (last_sep > 0) { + result.item = key_data.substr(0, last_sep); } - buffer = {}; - in.get(buffer); - std::string id_str = buffer.str(); - if (id_str.size() > 1) { - result.track = BytesToTrackId(id_str.substr(1)); + if (last_sep + 1 < key_data.size()) { + result.track = BytesToTrackId(key_data.substr(last_sep + 1)); } return result; @@ -252,6 +249,7 @@ auto BytesToTrackId(std::span<const char> bytes) -> std::optional<TrackId> { auto [res, unused, err] = cppbor::parse( reinterpret_cast<const uint8_t*>(bytes.data()), bytes.size()); if (!res || res->type() != cppbor::UINT) { + ESP_LOGE(kTag, "Track ID parsing failed!!"); return {}; } return res->asUint()->unsignedValue(); diff --git a/src/tangara/database/tag_parser.cpp b/src/tangara/database/tag_parser.cpp index d377adb1..a6a25555 100644 --- a/src/tangara/database/tag_parser.cpp +++ b/src/tangara/database/tag_parser.cpp @@ -6,14 +6,20 @@ #include "database/tag_parser.hpp" +#include <algorithm> #include <cstdint> #include <cstdlib> +#include <cstring> #include <iomanip> +#include <memory> #include <mutex> +#include "database/track.hpp" +#include "debug.hpp" #include "drivers/spi.hpp" #include "esp_log.h" #include "ff.h" +#include "ogg/ogg.h" #include "tags.h" #include "memory_resource.hpp" @@ -106,10 +112,18 @@ static void toc(Tagctx* ctx, int ms, int offset) {} static const std::size_t kBufSize = 1024; [[maybe_unused]] static const char* kTag = "TAGS"; -TagParserImpl::TagParserImpl() {} +TagParserImpl::TagParserImpl() { + parsers_.emplace_back(new OggTagParser()); + parsers_.emplace_back(new GenericTagParser()); +} auto TagParserImpl::ReadAndParseTags(std::string_view path) -> std::shared_ptr<TrackTags> { + if (path.empty()) { + return {}; + } + + // Check the cache first to see if we can skip parsing this file completely. { std::lock_guard<std::mutex> lock{cache_mutex_}; std::optional<std::shared_ptr<TrackTags>> cached = @@ -119,7 +133,15 @@ auto TagParserImpl::ReadAndParseTags(std::string_view path) } } - std::shared_ptr<TrackTags> tags = parseNew(path); + // Nothing in the cache; try each of our parsers. + std::shared_ptr<TrackTags> tags; + for (auto& parser : parsers_) { + tags = parser->ReadAndParseTags(path); + if (tags) { + break; + } + } + if (!tags) { return {}; } @@ -135,6 +157,7 @@ auto TagParserImpl::ReadAndParseTags(std::string_view path) } } + // Store the result in the cache for later. { std::lock_guard<std::mutex> lock{cache_mutex_}; cache_.Put({path.data(), path.size(), &memory::kSpiRamResource}, tags); @@ -143,7 +166,148 @@ auto TagParserImpl::ReadAndParseTags(std::string_view path) return tags; } -auto TagParserImpl::parseNew(std::string_view p) -> std::shared_ptr<TrackTags> { +OggTagParser::OggTagParser() { + nameToTag_["TITLE"] = Tag::kTitle; + nameToTag_["ALBUM"] = Tag::kAlbum; + nameToTag_["ARTIST"] = Tag::kArtist; + nameToTag_["ALBUMARTIST"] = Tag::kAlbumArtist; + nameToTag_["TRACKNUMBER"] = Tag::kTrack; + nameToTag_["GENRE"] = Tag::kGenres; + nameToTag_["DISC"] = Tag::kDisc; +} + +auto OggTagParser::ReadAndParseTags(std::string_view p) + -> std::shared_ptr<TrackTags> { + if (!p.ends_with(".ogg") && !p.ends_with(".opus") && !p.ends_with(".ogx")) { + return {}; + } + ogg_sync_state sync; + ogg_sync_init(&sync); + + ogg_page page; + ogg_stream_state stream; + bool stream_init = false; + + std::string path{p}; + FIL file; + if (f_open(&file, path.c_str(), FA_READ) != FR_OK) { + ESP_LOGW(kTag, "failed to open file '%s'", path.c_str()); + return {}; + } + + std::shared_ptr<TrackTags> tags; + + // The comments packet is the second in the stream. This is *usually* the + // second page, sometimes overflowing onto the third page. There is no + // guarantee of this however, so we read the first five pages before giving + // up just in case. We don't try to read more pages than this as it could take + // quite some time, with no likely benefit. + for (int i = 0; i < 5; i++) { + // Load up the sync with data until we have a complete page. + while (ogg_sync_pageout(&sync, &page) != 1) { + char* buffer = ogg_sync_buffer(&sync, 512); + + UINT br; + FRESULT fres = f_read(&file, buffer, 512, &br); + if (fres != FR_OK || br == 0) { + goto finish; + } + + int res = ogg_sync_wrote(&sync, br); + if (res != 0) { + goto finish; + } + } + + // Ensure the stream has the correct serialno. pagein and packetout both + // give no results if the serialno is incorrect. + if (ogg_page_bos(&page)) { + ogg_stream_init(&stream, ogg_page_serialno(&page)); + stream_init = true; + } + + if (ogg_stream_pagein(&stream, &page) < 0) { + goto finish; + } + + // Try to pull out a packet. + ogg_packet packet; + if (ogg_stream_packetout(&stream, &packet) == 1) { + // We're interested in the second packet (packetno == 1) only. + if (packet.packetno < 1) { + continue; + } + if (packet.packetno > 1) { + goto finish; + } + + tags = TrackTags::create(); + if (memcmp(packet.packet, "OpusTags", 8) == 0) { + std::span<unsigned char> data{packet.packet, + static_cast<size_t>(packet.bytes)}; + tags->encoding(Container::kOpus); + parseComments(*tags, data.subspan(8)); + } else if (packet.packet[0] == 3 && + memcmp(packet.packet + 1, "vorbis", 6) == 0) { + std::span<unsigned char> data{packet.packet, + static_cast<size_t>(packet.bytes)}; + tags->encoding(Container::kOgg); + parseComments(*tags, data.subspan(7)); + } + break; + } + } + +finish: + if (stream_init) { + ogg_stream_clear(&stream); + } + ogg_sync_clear(&sync); + f_close(&file); + + return tags; +} + +auto OggTagParser::parseComments(TrackTags& res, std::span<unsigned char> data) + -> void { + uint64_t vendor_len = parseLength(data); + uint64_t num_tags = parseLength(data.subspan(4 + vendor_len)); + + data = data.subspan(4 + vendor_len + 4); + for (size_t i = 0; i < num_tags; i++) { + uint64_t size = parseLength(data); + + std::string_view tag = { + reinterpret_cast<const char*>(data.subspan(4).data()), + static_cast<size_t>(size)}; + + auto split = tag.find("="); + + if (split != std::string::npos) { + std::string_view key = tag.substr(0, split); + std::string_view val = tag.substr(split + 1); + + std::string key_upper{key}; + std::transform(key.begin(), key.end(), key_upper.begin(), ::toupper); + + if (nameToTag_.contains(key_upper) && !val.empty()) { + res.set(nameToTag_[key_upper], val); + } + } + + data = data.subspan(4 + size); + } +} + +auto OggTagParser::parseLength(std::span<unsigned char> data) -> uint64_t { + return static_cast<uint64_t>(data[3]) << 24 | + static_cast<uint64_t>(data[2]) << 16 | + static_cast<uint64_t>(data[1]) << 8 | + static_cast<uint64_t>(data[0]) << 0; +} + +auto GenericTagParser::ReadAndParseTags(std::string_view p) + -> std::shared_ptr<TrackTags> { std::string path{p}; libtags::Aux aux; auto out = TrackTags::create(); @@ -151,7 +315,6 @@ auto TagParserImpl::parseNew(std::string_view p) -> std::shared_ptr<TrackTags> { if (f_stat(path.c_str(), &aux.info) != FR_OK || f_open(&aux.file, path.c_str(), FA_READ) != FR_OK) { - ESP_LOGW(kTag, "failed to open file %s", path.c_str()); return {}; } diff --git a/src/tangara/database/tag_parser.hpp b/src/tangara/database/tag_parser.hpp index ccbc0ea9..642c4876 100644 --- a/src/tangara/database/tag_parser.hpp +++ b/src/tangara/database/tag_parser.hpp @@ -6,6 +6,7 @@ #pragma once +#include <stdint.h> #include <string> #include "database/track.hpp" @@ -27,7 +28,7 @@ class TagParserImpl : public ITagParser { -> std::shared_ptr<TrackTags> override; private: - auto parseNew(std::string_view path) -> std::shared_ptr<TrackTags>; + std::vector<std::unique_ptr<ITagParser>> parsers_; /* * Cache of tags that have already been extracted from files. Ideally this @@ -35,10 +36,25 @@ class TagParserImpl : public ITagParser { */ std::mutex cache_mutex_; util::LruCache<8, std::pmr::string, std::shared_ptr<TrackTags>> cache_; +}; + +class OggTagParser : public ITagParser { + public: + OggTagParser(); + auto ReadAndParseTags(std::string_view path) + -> std::shared_ptr<TrackTags> override; + + private: + auto parseComments(TrackTags&, std::span<unsigned char> data) -> void; + auto parseLength(std::span<unsigned char> data) -> uint64_t; + + std::unordered_map<std::string, Tag> nameToTag_; +}; - // We could also consider keeping caches of artist name -> std::string and - // similar. This hasn't been done yet, as this isn't a common workload in - // any of our UI. +class GenericTagParser : public ITagParser { + public: + auto ReadAndParseTags(std::string_view path) + -> std::shared_ptr<TrackTags> override; }; } // namespace database diff --git a/src/tangara/database/track.cpp b/src/tangara/database/track.cpp index 5bf8c3e2..cdb7543c 100644 --- a/src/tangara/database/track.cpp +++ b/src/tangara/database/track.cpp @@ -148,7 +148,7 @@ auto TrackTags::set(Tag t, std::string_view v) -> void { track(v); break; case Tag::kAlbumOrder: - // This tag is derices from disc and track, and so it can't be set. + // This tag is derived from disc and track, and so it can't be set. break; case Tag::kGenres: genres(v); @@ -293,15 +293,4 @@ auto TrackTags::Hash() const -> uint64_t { return komihash_stream_final(&stream); } -auto Track::TitleOrFilename() const -> std::pmr::string { - auto title = tags().title(); - if (title) { - return *title; - } - auto start = data().filepath.find_last_of('/'); - if (start == std::pmr::string::npos) { - return data().filepath; - } - return data().filepath.substr(start + 1); -} } // namespace database diff --git a/src/tangara/database/track.hpp b/src/tangara/database/track.hpp index b097ab52..6501e31f 100644 --- a/src/tangara/database/track.hpp +++ b/src/tangara/database/track.hpp @@ -195,8 +195,6 @@ class Track { auto data() const -> const TrackData& { return *data_; } auto tags() const -> const TrackTags& { return *tags_; } - auto TitleOrFilename() const -> std::pmr::string; - private: std::shared_ptr<const TrackData> data_; std::shared_ptr<TrackTags> tags_; diff --git a/src/tangara/database/track_finder.cpp b/src/tangara/database/track_finder.cpp new file mode 100644 index 00000000..21a44339 --- /dev/null +++ b/src/tangara/database/track_finder.cpp @@ -0,0 +1,118 @@ +/* + * Copyright 2023 jacqueline <me@jacqueline.id.au> + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#include "database/track_finder.hpp" + +#include <deque> +#include <functional> +#include <memory> +#include <mutex> +#include <sstream> +#include <string> +#include <string_view> + +#include "database/track_finder.hpp" +#include "ff.h" + +#include "drivers/spi.hpp" +#include "memory_resource.hpp" + +namespace database { + +static_assert(sizeof(TCHAR) == sizeof(char), "TCHAR must be CHAR"); + +CandidateIterator::CandidateIterator(std::string_view root) + : to_explore_(&memory::kSpiRamResource) { + to_explore_.push_back({root.data(), root.size()}); +} + +auto CandidateIterator::next(FILINFO& info) -> std::optional<std::string> { + std::scoped_lock<std::mutex> lock{mut_}; + while (!to_explore_.empty() || current_) { + if (!current_) { + current_.emplace(); + + // Get the next directory to iterate through. + current_->first = to_explore_.front(); + to_explore_.pop_front(); + const TCHAR* next_path = + static_cast<const TCHAR*>(current_->first.data()); + + // Open it for iterating. + FRESULT res = f_opendir(¤t_->second, next_path); + if (res != FR_OK) { + current_.reset(); + continue; + } + } + + FRESULT res = f_readdir(¤t_->second, &info); + if (res != FR_OK || info.fname[0] == 0) { + // No more files in the directory. + f_closedir(¤t_->second); + current_.reset(); + continue; + } else if (info.fattrib & (AM_HID | AM_SYS) || info.fname[0] == '.') { + // System or hidden file. Ignore it and move on. + continue; + } else { + // A valid file or folder. + std::pmr::string full_path{&memory::kSpiRamResource}; + full_path += current_->first; + full_path += "/"; + full_path += info.fname; + + if (info.fattrib & AM_DIR) { + // This is a directory. Add it to the explore queue. + to_explore_.push_back(full_path); + } else { + // This is a file! We can return now. + return {{full_path.data(), full_path.size()}}; + } + } + } + + // Out of paths to explore. + return {}; +} + +TrackFinder::TrackFinder( + tasks::WorkerPool& pool, + size_t parallelism, + std::function<void(FILINFO&, std::string_view)> processor, + std::function<void()> complete_cb) + : pool_{pool}, + parallelism_(parallelism), + processor_(processor), + complete_cb_(complete_cb) {} + +auto TrackFinder::launch(std::string_view root) -> void { + iterator_ = std::make_unique<CandidateIterator>(root); + num_workers_ = parallelism_; + for (size_t i = 0; i < parallelism_; i++) { + schedule(); + } +} + +auto TrackFinder::schedule() -> void { + pool_.Dispatch<void>([&]() { + FILINFO info; + auto next = iterator_->next(info); + if (next) { + std::invoke(processor_, info, *next); + schedule(); + } else { + std::scoped_lock<std::mutex> lock{workers_mutex_}; + num_workers_ -= 1; + if (num_workers_ == 0) { + iterator_.reset(); + std::invoke(complete_cb_); + } + } + }); +} + +} // namespace database diff --git a/src/tangara/database/track_finder.hpp b/src/tangara/database/track_finder.hpp new file mode 100644 index 00000000..daaaa2f2 --- /dev/null +++ b/src/tangara/database/track_finder.hpp @@ -0,0 +1,77 @@ +/* + * Copyright 2023 jacqueline <me@jacqueline.id.au> + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#pragma once + +#include <deque> +#include <functional> +#include <memory> +#include <mutex> +#include <optional> +#include <sstream> +#include <string> + +#include "ff.h" + +#include "tasks.hpp" + +namespace database { + +/* + * Iterator that recursively stats every file within the given directory root. + */ +class CandidateIterator { + public: + CandidateIterator(std::string_view root); + + /* + * Returns the next file. The stat result is placed within `out`. If the + * iterator has finished, returns absent. This method always modifies the + * contents of `out`, even if no file is returned. + */ + auto next(FILINFO& out) -> std::optional<std::string>; + + // Cannot be copied or moved. + CandidateIterator(const CandidateIterator&) = delete; + CandidateIterator& operator=(const CandidateIterator&) = delete; + + private: + std::mutex mut_; + std::pmr::deque<std::pmr::string> to_explore_; + std::optional<std::pair<std::pmr::string, FF_DIR>> current_; +}; + +/* + * Utility for iterating through each file within a directory root. Iteration + * can be sharded across several tasks. + */ +class TrackFinder { + public: + TrackFinder(tasks::WorkerPool&, + size_t parallelism, + std::function<void(FILINFO&, std::string_view)> processor, + std::function<void()> complete_cb); + + auto launch(std::string_view root) -> void; + + // Cannot be copied or moved. + TrackFinder(const TrackFinder&) = delete; + TrackFinder& operator=(const TrackFinder&) = delete; + + private: + tasks::WorkerPool& pool_; + const size_t parallelism_; + const std::function<void(FILINFO&, std::string_view)> processor_; + const std::function<void()> complete_cb_; + + std::mutex workers_mutex_; + std::unique_ptr<CandidateIterator> iterator_; + size_t num_workers_; + + auto schedule() -> void; +}; + +} // namespace database diff --git a/src/tangara/dev_console/console.cpp b/src/tangara/dev_console/console.cpp index a7f7a721..bc3a7aca 100644 --- a/src/tangara/dev_console/console.cpp +++ b/src/tangara/dev_console/console.cpp @@ -13,6 +13,7 @@ #include <string> #include "esp_console.h" +#include "esp_intr_alloc.h" #include "esp_log.h" #include "esp_system.h" @@ -22,34 +23,47 @@ namespace console { int CmdLogLevel(int argc, char** argv) { static const std::pmr::string usage = - "usage: loglevel [VERBOSE,DEBUG,INFO,WARN,ERROR,NONE]"; - if (argc != 2) { + "usage: loglevel [tag] [VERBOSE,DEBUG,INFO,WARN,ERROR,NONE]"; + if (argc < 2 || argc > 3) { std::cout << usage << std::endl; return 1; } - std::pmr::string level_str = argv[1]; - std::transform(level_str.begin(), level_str.end(), level_str.begin(), + + std::string tag; + if (argc == 2) { + tag = "*"; + } else { + tag = argv[1]; + } + + std::string raw_level; + if (argc == 2) { + raw_level = argv[1]; + } else { + raw_level = argv[2]; + } + std::transform(raw_level.begin(), raw_level.end(), raw_level.begin(), [](unsigned char c) { return std::toupper(c); }); esp_log_level_t level; - if (level_str == "VERBOSE") { + if (raw_level == "VERBOSE") { level = ESP_LOG_VERBOSE; - } else if (level_str == "DEBUG") { + } else if (raw_level == "DEBUG") { level = ESP_LOG_DEBUG; - } else if (level_str == "INFO") { + } else if (raw_level == "INFO") { level = ESP_LOG_INFO; - } else if (level_str == "WARN") { + } else if (raw_level == "WARN") { level = ESP_LOG_WARN; - } else if (level_str == "ERROR") { + } else if (raw_level == "ERROR") { level = ESP_LOG_ERROR; - } else if (level_str == "NONE") { + } else if (raw_level == "NONE") { level = ESP_LOG_NONE; } else { std::cout << usage << std::endl; return 1; } - esp_log_level_set("*", level); + esp_log_level_set(tag.c_str(), level); return 0; } @@ -66,12 +80,34 @@ void RegisterLogLevel() { esp_console_cmd_register(&cmd); } +int CmdInterrupts(int argc, char** argv) { + static const std::pmr::string usage = "usage: intr"; + if (argc != 1) { + std::cout << usage << std::endl; + return 1; + } + esp_intr_dump(NULL); + return 0; +} + +void RegisterInterrupts() { + esp_console_cmd_t cmd{.command = "intr", + .help = "Dumps a table of all allocated interrupts", + .hint = NULL, + .func = &CmdInterrupts, + .argtable = NULL}; + esp_console_cmd_register(&cmd); + cmd.command = "interrupts"; + esp_console_cmd_register(&cmd); +} + Console::Console() {} Console::~Console() {} auto Console::RegisterCommonComponents() -> void { esp_console_register_help_command(); RegisterLogLevel(); + RegisterInterrupts(); } static Console* sInstance; diff --git a/src/tangara/input/input_device.hpp b/src/tangara/input/input_device.hpp index da2b31cd..7edded3e 100644 --- a/src/tangara/input/input_device.hpp +++ b/src/tangara/input/input_device.hpp @@ -32,6 +32,11 @@ class IInputDevice { virtual auto triggers() -> std::vector<std::reference_wrapper<TriggerHooks>> { return {}; } + + /* Called by the LVGL driver when controls are being locked. */ + virtual auto onLock() -> void {} + /* Called by the LVGL driver when controls are being unlocked. */ + virtual auto onUnlock() -> void {} }; } // namespace input diff --git a/src/tangara/input/input_touch_wheel.cpp b/src/tangara/input/input_touch_wheel.cpp index b961bb02..a5069ae4 100644 --- a/src/tangara/input/input_touch_wheel.cpp +++ b/src/tangara/input/input_touch_wheel.cpp @@ -108,6 +108,15 @@ auto TouchWheel::triggers() return {centre_, up_, right_, down_, left_}; } +auto TouchWheel::onLock() -> void { + wheel_.LowPowerMode(true); +} + +auto TouchWheel::onUnlock() -> void { + wheel_.LowPowerMode(false); + wheel_.Recalibrate(); +} + auto TouchWheel::sensitivity() -> lua::Property& { return sensitivity_; } diff --git a/src/tangara/input/input_touch_wheel.hpp b/src/tangara/input/input_touch_wheel.hpp index cf86eced..d5cdbbfc 100644 --- a/src/tangara/input/input_touch_wheel.hpp +++ b/src/tangara/input/input_touch_wheel.hpp @@ -12,12 +12,12 @@ #include "indev/lv_indev.h" #include "drivers/haptics.hpp" +#include "drivers/nvs.hpp" +#include "drivers/touchwheel.hpp" #include "input/input_device.hpp" #include "input/input_hook.hpp" #include "input/input_trigger.hpp" #include "lua/property.hpp" -#include "drivers/nvs.hpp" -#include "drivers/touchwheel.hpp" namespace input { @@ -30,6 +30,9 @@ class TouchWheel : public IInputDevice { auto name() -> std::string override; auto triggers() -> std::vector<std::reference_wrapper<TriggerHooks>> override; + auto onLock() -> void override; + auto onUnlock() -> void override; + auto sensitivity() -> lua::Property&; private: diff --git a/src/tangara/input/lvgl_input_driver.cpp b/src/tangara/input/lvgl_input_driver.cpp index 86f9b279..824e49cc 100644 --- a/src/tangara/input/lvgl_input_driver.cpp +++ b/src/tangara/input/lvgl_input_driver.cpp @@ -8,6 +8,7 @@ #include <cstdint> #include <memory> +#include <sstream> #include <variant> #include "core/lv_group.h" @@ -132,6 +133,17 @@ auto LvglInputDriver::feedback(uint8_t event) -> void { } } +auto LvglInputDriver::lock(bool l) -> void { + is_locked_ = l; + for (auto&& device : inputs_) { + if (l) { + device->onLock(); + } else { + device->onUnlock(); + } + } +} + LvglInputDriver::LuaTrigger::LuaTrigger(LvglInputDriver& driver, IInputDevice& dev, TriggerHooks& trigger) diff --git a/src/tangara/input/lvgl_input_driver.hpp b/src/tangara/input/lvgl_input_driver.hpp index ddbdee55..9b62c24d 100644 --- a/src/tangara/input/lvgl_input_driver.hpp +++ b/src/tangara/input/lvgl_input_driver.hpp @@ -40,8 +40,7 @@ class LvglInputDriver { auto setGroup(lv_group_t*) -> void; auto read(lv_indev_data_t* data) -> void; auto feedback(uint8_t) -> void; - - auto lock(bool l) -> void { is_locked_ = l; } + auto lock(bool l) -> void; auto pushHooks(lua_State* L) -> int; diff --git a/src/tangara/lua/file_iterator.cpp b/src/tangara/lua/file_iterator.cpp index c3d63a16..71daf2d8 100644 --- a/src/tangara/lua/file_iterator.cpp +++ b/src/tangara/lua/file_iterator.cpp @@ -15,8 +15,8 @@ namespace lua { [[maybe_unused]] static const char* kTag = "FileIterator"; -FileIterator::FileIterator(std::string filepath) - : original_path_(filepath), current_(), offset_(-1) { +FileIterator::FileIterator(std::string filepath, bool showHidden) + : original_path_(filepath), show_hidden_(showHidden), current_(), offset_(-1) { const TCHAR* path = static_cast<const TCHAR*>(filepath.c_str()); FRESULT res = f_opendir(&dir_, path); if (res != FR_OK) { @@ -33,7 +33,16 @@ auto FileIterator::value() const -> const std::optional<FileEntry>& { } auto FileIterator::next() -> void { - iterate(false); + size_t prev_index = -1; + if (current_) { + prev_index = current_->index; + } + do { + bool res = iterate(show_hidden_); + if (!res) { + break; + } + } while (!current_ || current_->index == prev_index); } auto FileIterator::prev() -> void { @@ -45,11 +54,11 @@ auto FileIterator::prev() -> void { auto new_offset = offset_ - 1; offset_ = -1; for (int i = 0; i <= new_offset; i++) { - iterate(false); + iterate(show_hidden_); } } -auto FileIterator::iterate(bool reverse) -> bool { +auto FileIterator::iterate(bool show_hidden) -> bool { FILINFO info; auto res = f_readdir(&dir_, &info); if (res != FR_OK) { @@ -60,18 +69,21 @@ auto FileIterator::iterate(bool reverse) -> bool { // End of directory // Set value to nil current_.reset(); + return false; } else { // Update current value offset_++; - current_ = FileEntry{ - .index = offset_, - .isHidden = (info.fattrib & AM_HID) > 0, - .isDirectory = (info.fattrib & AM_DIR) > 0, - .isTrack = false, // TODO - .filepath = original_path_ + (original_path_.size() > 0 ? "/" : "") + - info.fname, - - }; + bool hidden = (info.fattrib & AM_HID) > 0 || info.fname[0] == '.'; + if (!hidden || show_hidden) { + current_ = FileEntry{ + .index = offset_, + .isHidden = hidden, + .isDirectory = (info.fattrib & AM_DIR) > 0, + .filepath = original_path_ + (original_path_.size() > 0 ? "/" : "") + + info.fname, + .name = info.fname, + }; + } } return true; } diff --git a/src/tangara/lua/file_iterator.hpp b/src/tangara/lua/file_iterator.hpp index b803062c..2d5c2d7d 100644 --- a/src/tangara/lua/file_iterator.hpp +++ b/src/tangara/lua/file_iterator.hpp @@ -19,13 +19,13 @@ struct FileEntry { int index; bool isHidden; bool isDirectory; - bool isTrack; std::string filepath; + std::string name; }; class FileIterator { public: - FileIterator(std::string filepath); + FileIterator(std::string filepath, bool showHidden); ~FileIterator(); auto value() const -> const std::optional<FileEntry>&; @@ -35,6 +35,7 @@ class FileIterator { private: FF_DIR dir_; std::string original_path_; + bool show_hidden_; std::optional<FileEntry> current_; int offset_; @@ -42,4 +43,4 @@ class FileIterator { auto iterate(bool reverse = false) -> bool; }; -} // namespace lua
\ No newline at end of file +} // namespace lua diff --git a/src/tangara/lua/lua_filesystem.cpp b/src/tangara/lua/lua_filesystem.cpp index de51f555..9c2ea880 100644 --- a/src/tangara/lua/lua_filesystem.cpp +++ b/src/tangara/lua/lua_filesystem.cpp @@ -21,29 +21,21 @@ struct LuaFileEntry { bool isHidden; bool isDirectory; bool isTrack; - size_t path_size; - char path[]; + std::string path; + std::string name; }; -static_assert(std::is_trivially_destructible<LuaFileEntry>()); -static_assert(std::is_trivially_copy_assignable<LuaFileEntry>()); - static auto push_lua_file_entry(lua_State* L, const lua::FileEntry& r) -> void { - // Create and init the userdata. - LuaFileEntry* file_entry = reinterpret_cast<LuaFileEntry*>( - lua_newuserdata(L, sizeof(LuaFileEntry) + r.filepath.size())); + lua::FileEntry** entry = reinterpret_cast<lua::FileEntry**>( + lua_newuserdata(L, sizeof(uintptr_t))); + *entry = new lua::FileEntry(r); luaL_setmetatable(L, kFileEntryMetatable); +} - // Init all the fields - *file_entry = { - .isHidden = r.isHidden, - .isDirectory = r.isDirectory, - .isTrack = r.isTrack, - .path_size = r.filepath.size(), - }; - - // Copy the string data across. - std::memcpy(file_entry->path, r.filepath.data(), r.filepath.size()); +auto check_file_entry(lua_State* L, int stack_pos) -> lua::FileEntry* { + lua::FileEntry* entry = *reinterpret_cast<lua::FileEntry**>( + luaL_checkudata(L, stack_pos, kFileEntryMetatable)); + return entry; } auto check_file_iterator(lua_State* L, int stack_pos) -> lua::FileIterator* { @@ -56,7 +48,7 @@ static auto push_iterator(lua_State* state, const lua::FileIterator& it) -> void { lua::FileIterator** data = reinterpret_cast<lua::FileIterator**>( lua_newuserdata(state, sizeof(uintptr_t))); - *data = new lua::FileIterator(it); // TODO... + *data = new lua::FileIterator(it); luaL_setmetatable(state, kFileIteratorMetatable); } @@ -108,45 +100,48 @@ static const struct luaL_Reg kFileIteratorFuncs[] = {{"next", fs_iterate}, {NULL, NULL}}; static auto file_entry_path(lua_State* state) -> int { - LuaFileEntry* data = reinterpret_cast<LuaFileEntry*>( - luaL_checkudata(state, 1, kFileEntryMetatable)); - lua_pushlstring(state, data->path, data->path_size); + lua::FileEntry* entry = check_file_entry(state, 1); + lua_pushlstring(state, entry->filepath.c_str(), entry->filepath.size()); return 1; } static auto file_entry_is_dir(lua_State* state) -> int { - LuaFileEntry* data = reinterpret_cast<LuaFileEntry*>( - luaL_checkudata(state, 1, kFileEntryMetatable)); - lua_pushboolean(state, data->isDirectory); + lua::FileEntry* entry = check_file_entry(state, 1); + lua_pushboolean(state, entry->isDirectory); return 1; } static auto file_entry_is_hidden(lua_State* state) -> int { - LuaFileEntry* data = reinterpret_cast<LuaFileEntry*>( - luaL_checkudata(state, 1, kFileEntryMetatable)); - lua_pushboolean(state, data->isHidden); + lua::FileEntry* entry = check_file_entry(state, 1); + lua_pushboolean(state, entry->isHidden); + return 1; +} + +static auto file_entry_name(lua_State* state) -> int { + lua::FileEntry* entry = check_file_entry(state, 1); + lua_pushlstring(state, entry->name.c_str(), entry->name.size()); return 1; } -static auto file_entry_is_track(lua_State* state) -> int { - LuaFileEntry* data = reinterpret_cast<LuaFileEntry*>( - luaL_checkudata(state, 1, kFileEntryMetatable)); - lua_pushboolean(state, data->isTrack); +static auto file_entry_gc(lua_State* state) -> int { + lua::FileEntry* entry = check_file_entry(state, 1); + delete entry; return 1; } static const struct luaL_Reg kFileEntryFuncs[] = {{"filepath", file_entry_path}, + {"name", file_entry_name}, {"is_directory", file_entry_is_dir}, {"is_hidden", file_entry_is_hidden}, - {"is_track", file_entry_is_track}, - {"__tostring", file_entry_path}, + {"__tostring", file_entry_name}, + {"__gc", file_entry_gc}, {NULL, NULL}}; static auto fs_new_iterator(lua_State* state) -> int { // Takes a filepath as a string and returns a new FileIterator // on that directory std::string filepath = luaL_checkstring(state, -1); - lua::FileIterator iter(filepath); + lua::FileIterator iter(filepath, false); push_iterator(state, iter); return 1; } diff --git a/src/tangara/lua/lua_queue.cpp b/src/tangara/lua/lua_queue.cpp index bc393aa5..7eb32c62 100644 --- a/src/tangara/lua/lua_queue.cpp +++ b/src/tangara/lua/lua_queue.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-only */ +#include "audio/audio_events.hpp" #include "lua/lua_database.hpp" #include <memory> @@ -39,6 +40,14 @@ static auto queue_add(lua_State* state) -> int { audio::TrackQueue& queue = instance->services().track_queue(); queue.append(id); }); + } else if (lua_isstring(state, 1)) { + size_t len; + const char* str = luaL_checklstring(state, 1, &len); + std::string path{str, len}; + instance->services().bg_worker().Dispatch<void>([=]() { + audio::TrackQueue& queue = instance->services().track_queue(); + queue.append(path); + }); } else { database::Iterator* it = db_check_iterator(state, 1); instance->services().bg_worker().Dispatch<void>([=]() { @@ -57,9 +66,24 @@ static auto queue_clear(lua_State* state) -> int { return 0; } -static const struct luaL_Reg kQueueFuncs[] = {{"add", queue_add}, - {"clear", queue_clear}, - {NULL, NULL}}; +static auto queue_open_playlist(lua_State* state) -> int { + Bridge* instance = Bridge::Get(state); + audio::TrackQueue& queue = instance->services().track_queue(); + size_t len = 0; + const char* str = luaL_checklstring(state, 1, &len); + if (!str) { + return 0; + } + queue.clear(); + queue.openPlaylist(str); + return 0; +} + +static const struct luaL_Reg kQueueFuncs[] = { + {"add", queue_add}, + {"clear", queue_clear}, + {"open_playlist", queue_open_playlist}, + {NULL, NULL}}; static auto lua_queue(lua_State* state) -> int { luaL_newlib(state, kQueueFuncs); diff --git a/src/tangara/lua/lua_screen.cpp b/src/tangara/lua/lua_screen.cpp index 8d87eebd..6bb26ec1 100644 --- a/src/tangara/lua/lua_screen.cpp +++ b/src/tangara/lua/lua_screen.cpp @@ -56,9 +56,9 @@ static auto screen_true(lua_State* state) -> int { } static const struct luaL_Reg kScreenFuncs[] = { - {"new", screen_new}, {"createUi", screen_noop}, - {"onShown", screen_noop}, {"onHidden", screen_noop}, - {"canPop", screen_true}, {NULL, NULL}}; + {"new", screen_new}, {"create_ui", screen_noop}, + {"on_show", screen_noop}, {"on_hide", screen_noop}, + {"can_pop", screen_true}, {NULL, NULL}}; static auto lua_screen(lua_State* state) -> int { luaL_newlib(state, kScreenFuncs); diff --git a/src/tangara/lua/lua_theme.cpp b/src/tangara/lua/lua_theme.cpp index 5edde104..03578778 100644 --- a/src/tangara/lua/lua_theme.cpp +++ b/src/tangara/lua/lua_theme.cpp @@ -75,8 +75,41 @@ static auto set_theme(lua_State* L) -> int { return 0; } + +static auto load_theme(lua_State* L) -> int { + std::string filename = luaL_checkstring(L, -1); + // Set the theme filename in non-volatile storage + Bridge* instance = Bridge::Get(L); + // Load the theme using lua + auto status = luaL_loadfile(L, filename.c_str()); + if (status != LUA_OK) { + lua_pushboolean(L, false); + return 1; + } + status = lua::CallProtected(L, 0, 1); + if (status == LUA_OK) { + ui::themes::Theme::instance()->Reset(); + set_theme(L); + instance->services().nvs().InterfaceTheme(filename); + lua_pushboolean(L, true); + } else { + lua_pushboolean(L, false); + } + + return 1; +} + +static auto theme_filename(lua_State* L) -> int { + Bridge* instance = Bridge::Get(L); + auto file = instance->services().nvs().InterfaceTheme().value_or("/lua/theme_light.lua"); + lua_pushstring(L, file.c_str()); + return 1; +} + static const struct luaL_Reg kThemeFuncs[] = {{"set", set_theme}, {"set_style", set_style}, + {"load_theme", load_theme}, + {"theme_filename", theme_filename}, {NULL, NULL}}; static auto lua_theme(lua_State* L) -> int { diff --git a/src/tangara/lua/property.cpp b/src/tangara/lua/property.cpp index 2b93809d..1be1fd2d 100644 --- a/src/tangara/lua/property.cpp +++ b/src/tangara/lua/property.cpp @@ -289,13 +289,14 @@ static void pushTrack(lua_State* L, const audio::TrackInfo& track) { lua_settable(L, -3); } -static void pushDevice(lua_State* L, const drivers::bluetooth::Device& dev) { +static void pushDevice(lua_State* L, + const drivers::bluetooth::MacAndName& dev) { lua_createtable(L, 0, 4); lua_pushliteral(L, "address"); auto* mac = reinterpret_cast<drivers::bluetooth::mac_addr_t*>( lua_newuserdata(L, sizeof(drivers::bluetooth::mac_addr_t))); - *mac = dev.address; + *mac = dev.mac; lua_rawset(L, -3); // What I just did there was perfectly safe. Look, I can prove it: @@ -308,14 +309,8 @@ static void pushDevice(lua_State* L, const drivers::bluetooth::Device& dev) { lua_pushlstring(L, dev.name.data(), dev.name.size()); lua_rawset(L, -3); - // FIXME: This field deserves a little more structure. - lua_pushliteral(L, "class"); - lua_pushinteger(L, dev.class_of_device); - lua_rawset(L, -3); - - lua_pushliteral(L, "signal_strength"); - lua_pushinteger(L, dev.signal_strength); - lua_rawset(L, -3); + // FIXME: Plumbing through device classes to here could be useful if we ever + // want to show cute little icons. } auto Property::pushValue(lua_State& s) -> int { @@ -332,10 +327,12 @@ auto Property::pushValue(lua_State& s) -> int { lua_pushstring(&s, arg.c_str()); } else if constexpr (std::is_same_v<T, audio::TrackInfo>) { pushTrack(&s, arg); - } else if constexpr (std::is_same_v<T, drivers::bluetooth::Device>) { + } else if constexpr (std::is_same_v<T, + drivers::bluetooth::MacAndName>) { pushDevice(&s, arg); } else if constexpr (std::is_same_v< - T, std::vector<drivers::bluetooth::Device>>) { + T, + std::vector<drivers::bluetooth::MacAndName>>) { lua_createtable(&s, arg.size(), 0); size_t i = 1; for (const auto& dev : arg) { @@ -364,48 +361,44 @@ auto popRichType(lua_State* L) -> LuaValue { lua_pushliteral(L, "name"); lua_gettable(L, -2); - std::pmr::string name = lua_tostring(L, -1); + std::string name = lua_tostring(L, -1); lua_pop(L, 1); - return drivers::bluetooth::Device{ - .address = mac, - .name = name, - .class_of_device = 0, - .signal_strength = 0, - }; + return drivers::bluetooth::MacAndName{.mac = mac, .name = name}; } return std::monostate{}; } auto Property::popValue(lua_State& s) -> bool { - LuaValue new_val; - switch (lua_type(&s, 2)) { - case LUA_TNIL: - new_val = std::monostate{}; - break; - case LUA_TNUMBER: - if (lua_isinteger(&s, 2)) { - new_val = lua_tointeger(&s, 2); - } else { - new_val = static_cast<lua_Integer>(std::round(lua_tonumber(&s, 2))); - } - break; - case LUA_TBOOLEAN: - new_val = static_cast<bool>(lua_toboolean(&s, 2)); - break; - case LUA_TSTRING: - new_val = lua_tostring(&s, 2); - break; - default: - if (lua_istable(&s, 2)) { - new_val = popRichType(&s); - if (std::holds_alternative<std::monostate>(new_val)) { + LuaValue new_val{std::monostate{}}; + if (lua_gettop(&s) >= 2) { + switch (lua_type(&s, 2)) { + case LUA_TNIL: + break; + case LUA_TNUMBER: + if (lua_isinteger(&s, 2)) { + new_val = lua_tointeger(&s, 2); + } else { + new_val = static_cast<lua_Integer>(std::round(lua_tonumber(&s, 2))); + } + break; + case LUA_TBOOLEAN: + new_val = static_cast<bool>(lua_toboolean(&s, 2)); + break; + case LUA_TSTRING: + new_val = lua_tostring(&s, 2); + break; + default: + if (lua_istable(&s, 2)) { + new_val = popRichType(&s); + if (std::holds_alternative<std::monostate>(new_val)) { + return false; + } + } else { return false; } - } else { - return false; - } + } } return set(new_val); diff --git a/src/tangara/lua/property.hpp b/src/tangara/lua/property.hpp index 9f925766..d45821bd 100644 --- a/src/tangara/lua/property.hpp +++ b/src/tangara/lua/property.hpp @@ -24,8 +24,8 @@ using LuaValue = std::variant<std::monostate, bool, std::string, audio::TrackInfo, - drivers::bluetooth::Device, - std::vector<drivers::bluetooth::Device>>; + drivers::bluetooth::MacAndName, + std::vector<drivers::bluetooth::MacAndName>>; using LuaFunction = std::function<int(lua_State*)>; diff --git a/src/tangara/system_fsm/booting.cpp b/src/tangara/system_fsm/booting.cpp index 9d505f81..1f99e3ab 100644 --- a/src/tangara/system_fsm/booting.cpp +++ b/src/tangara/system_fsm/booting.cpp @@ -87,7 +87,7 @@ auto Booting::entry() -> void { ESP_LOGI(kTag, "installing remaining drivers"); drivers::spiffs_mount(); - sServices->samd(std::unique_ptr<drivers::Samd>(drivers::Samd::Create())); + sServices->samd(std::make_unique<drivers::Samd>(sServices->nvs())); sServices->touchwheel( std::unique_ptr<drivers::TouchWheel>{drivers::TouchWheel::Create()}); sServices->haptics(std::make_unique<drivers::Haptics>(sServices->nvs())); @@ -96,16 +96,15 @@ auto Booting::entry() -> void { sServices->battery(std::make_unique<battery::Battery>( sServices->samd(), std::unique_ptr<drivers::AdcBattery>(adc))); - sServices->track_queue( - std::make_unique<audio::TrackQueue>(sServices->bg_worker())); + sServices->track_queue(std::make_unique<audio::TrackQueue>( + sServices->bg_worker(), sServices->database())); sServices->tag_parser(std::make_unique<database::TagParserImpl>()); sServices->collator(locale::CreateCollator()); sServices->tts(std::make_unique<tts::Provider>()); ESP_LOGI(kTag, "init bluetooth"); sServices->bluetooth(std::make_unique<drivers::Bluetooth>( - sServices->nvs(), sServices->bg_worker())); - sServices->bluetooth().SetEventHandler(bt_event_cb); + sServices->nvs(), sServices->bg_worker(), bt_event_cb)); BootComplete ev{.services = sServices}; events::Audio().Dispatch(ev); diff --git a/src/tangara/system_fsm/idle.cpp b/src/tangara/system_fsm/idle.cpp index e499693d..2d66b01d 100644 --- a/src/tangara/system_fsm/idle.cpp +++ b/src/tangara/system_fsm/idle.cpp @@ -4,8 +4,9 @@ * SPDX-License-Identifier: GPL-3.0-only */ +#include "ui/ui_fsm.hpp" + #include "app_console/app_console.hpp" -#include "database/file_gatherer.hpp" #include "drivers/gpios.hpp" #include "freertos/portmacro.h" #include "freertos/projdefs.h" @@ -17,7 +18,6 @@ #include "events/event_queue.hpp" #include "system_fsm/system_events.hpp" #include "system_fsm/system_fsm.hpp" -#include "ui/ui_fsm.hpp" namespace system_fsm { namespace states { @@ -76,7 +76,7 @@ void Idle::react(const internal::IdleTimeout& ev) { // other state machines, etc. auto touchwheel = sServices->touchwheel(); if (touchwheel) { - touchwheel.value()->PowerDown(); + touchwheel.value()->LowPowerMode(true); } auto& gpios = sServices->gpios(); diff --git a/src/tangara/system_fsm/running.cpp b/src/tangara/system_fsm/running.cpp index c808e9da..07166e2f 100644 --- a/src/tangara/system_fsm/running.cpp +++ b/src/tangara/system_fsm/running.cpp @@ -8,7 +8,6 @@ #include "audio/audio_events.hpp" #include "database/database.hpp" #include "database/db_events.hpp" -#include "database/file_gatherer.hpp" #include "drivers/gpios.hpp" #include "drivers/spi.hpp" #include "ff.h" @@ -36,8 +35,6 @@ static void timer_callback(TimerHandle_t timer) { events::System().Dispatch(internal::UnmountTimeout{}); } -static database::IFileGatherer* sFileGatherer; - void Running::entry() { if (!sUnmountTimer) { sUnmountTimer = xTimerCreate("unmount_timeout", kTicksBeforeUnmount, false, @@ -174,10 +171,8 @@ auto Running::mountStorage() -> void { sStorage.reset(storage_res.value()); ESP_LOGI(kTag, "opening database"); - sFileGatherer = new database::FileGathererImpl(); - auto database_res = - database::Database::Open(*sFileGatherer, sServices->tag_parser(), - sServices->collator(), sServices->bg_worker()); + auto database_res = database::Database::Open( + sServices->tag_parser(), sServices->collator(), sServices->bg_worker()); if (database_res.has_error()) { unmountStorage(); return; @@ -193,6 +188,10 @@ auto Running::mountStorage() -> void { // mounted card. if (sServices->nvs().DbAutoIndex()) { sServices->bg_worker().Dispatch<void>([&]() { + // Delay the index update for a bit, since we don't want to cause a lot + // of disk contention immediately after mounting (especially when we've + // just booted), or else we risk slowing down stuff like UI loading. + vTaskDelay(pdMS_TO_TICKS(6000)); auto db = sServices->database().lock(); if (!db) { return; diff --git a/src/tangara/test/CMakeLists.txt b/src/tangara/test/CMakeLists.txt index 728c06b0..58882f9f 100644 --- a/src/tangara/test/CMakeLists.txt +++ b/src/tangara/test/CMakeLists.txt @@ -3,5 +3,5 @@ # SPDX-License-Identifier: GPL-3.0-only idf_component_register( - SRC_DIRS "battery" + SRC_DIRS "battery" "audio" INCLUDE_DIRS "." REQUIRES catch2 cmock tangara fixtures) diff --git a/src/tangara/test/audio/test_playlist.cpp b/src/tangara/test/audio/test_playlist.cpp new file mode 100644 index 00000000..34a6bc56 --- /dev/null +++ b/src/tangara/test/audio/test_playlist.cpp @@ -0,0 +1,129 @@ +/* + * Copyright 2023 ailurux <ailuruxx@gmail.com> + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#include "audio/playlist.hpp" + +#include <dirent.h> + +#include <cstdio> + +#include "catch2/catch.hpp" + +#include "drivers/gpios.hpp" +#include "drivers/i2c.hpp" +#include "drivers/spi.hpp" +#include "drivers/storage.hpp" +#include "ff.h" +#include "i2c_fixture.hpp" +#include "spi_fixture.hpp" + +namespace audio { + +static const std::string kTestFilename = "test_playlist2.m3u"; +static const std::string kTestFilePath = kTestFilename; + +TEST_CASE("playlist file", "[integration]") { + I2CFixture i2c; + SpiFixture spi; + std::unique_ptr<drivers::IGpios> gpios{drivers::Gpios::Create(false)}; + + if (gpios->Get(drivers::IGpios::Pin::kSdCardDetect)) { + // Skip if nothing is inserted. + SKIP("no sd card detected; skipping storage tests"); + return; + } + + { + std::unique_ptr<drivers::SdStorage> result( + drivers::SdStorage::Create(*gpios).value()); + MutablePlaylist plist(kTestFilePath); + + SECTION("empty file appears empty") { + REQUIRE(plist.clear()); + + REQUIRE(plist.size() == 0); + REQUIRE(plist.currentPosition() == 0); + REQUIRE(plist.value().empty()); + } + + SECTION("write to the playlist file") { + plist.append("test1.mp3"); + plist.append("test2.mp3"); + plist.append("test3.mp3"); + plist.append("test4.wav"); + plist.append("directory/test1.mp3"); + plist.append("directory/test2.mp3"); + plist.append("a/really/long/directory/test1.mp3"); + plist.append("directory/and/another/test2.mp3"); + REQUIRE(plist.size() == 8); + + SECTION("read from the playlist file") { + Playlist plist2(kTestFilePath); + REQUIRE(plist2.open()); + REQUIRE(plist2.size() == 8); + REQUIRE(plist2.value() == "test1.mp3"); + plist2.next(); + REQUIRE(plist2.value() == "test2.mp3"); + plist2.prev(); + REQUIRE(plist2.value() == "test1.mp3"); + } + } + + REQUIRE(plist.clear()); + + size_t tracks = 0; + + BENCHMARK("appending items") { + plist.append("track " + std::to_string(plist.size())); + return tracks++; + }; + + BENCHMARK("opening large playlist file") { + Playlist plist2(kTestFilePath); + REQUIRE(plist2.open()); + REQUIRE(plist2.size() == tracks); + return plist2.size(); + }; + + BENCHMARK("seeking after appending a large file") { + REQUIRE(plist.size() == tracks); + + plist.skipTo(50); + REQUIRE(plist.value() == "track 50"); + plist.skipTo(99); + REQUIRE(plist.value() == "track 99"); + plist.skipTo(1); + REQUIRE(plist.value() == "track 1"); + + return plist.size(); + }; + + BENCHMARK("seeking after opening a large file") { + Playlist plist2(kTestFilePath); + REQUIRE(plist2.open()); + REQUIRE(plist.size() == tracks); + REQUIRE(tracks >= 100); + + plist.skipTo(50); + REQUIRE(plist.value() == "track 50"); + plist.skipTo(99); + REQUIRE(plist.value() == "track 99"); + plist.skipTo(1); + REQUIRE(plist.value() == "track 1"); + + return plist.size(); + }; + + BENCHMARK("opening a large file and appending") { + MutablePlaylist plist2(kTestFilePath); + REQUIRE(plist2.open()); + REQUIRE(plist2.size() >= 100); + plist2.append("A/Nother/New/Item.opus"); + return plist2.size(); + }; + } +} +} // namespace audio diff --git a/src/tangara/test/battery/test_battery.cpp b/src/tangara/test/battery/test_battery.cpp index 7b55bd59..cf6b19b0 100644 --- a/src/tangara/test/battery/test_battery.cpp +++ b/src/tangara/test/battery/test_battery.cpp @@ -26,9 +26,10 @@ class FakeAdc : public drivers::AdcBattery { TEST_CASE("battery charge state", "[unit]") { I2CFixture i2c; + std::unique_ptr<drivers::NvsStorage> nvs{drivers::NvsStorage::OpenSync()}; // FIXME: mock the SAMD21 as well. - std::unique_ptr<drivers::Samd> samd{drivers::Samd::Create()}; + auto samd = std::make_unique<drivers::Samd>(*nvs); FakeAdc* adc = new FakeAdc{}; // Freed by Battery. Battery battery{*samd, std::unique_ptr<drivers::AdcBattery>{adc}}; diff --git a/src/tangara/ui/lvgl_task.cpp b/src/tangara/ui/lvgl_task.cpp index e82aefc4..287f6b7e 100644 --- a/src/tangara/ui/lvgl_task.cpp +++ b/src/tangara/ui/lvgl_task.cpp @@ -38,6 +38,7 @@ UiTask::~UiTask() { assert(false); } +IRAM_ATTR auto UiTask::Main() -> void { ESP_LOGI(kTag, "start ui task"); lv_group_t* current_group = nullptr; diff --git a/src/tangara/ui/screen_lua.cpp b/src/tangara/ui/screen_lua.cpp index 301df143..b8d11ed7 100644 --- a/src/tangara/ui/screen_lua.cpp +++ b/src/tangara/ui/screen_lua.cpp @@ -29,7 +29,7 @@ Lua::~Lua() { } auto Lua::onShown() -> void { - callMethod("onShown"); + callMethod("on_show"); forEachBinding([&](lua::Binding* b) { b->active = true; lua::Binding::apply(s_, -1); @@ -37,7 +37,7 @@ auto Lua::onShown() -> void { } auto Lua::onHidden() -> void { - callMethod("onHidden"); + callMethod("on_hide"); forEachBinding([&](lua::Binding* b) { b->active = false; }); } @@ -46,7 +46,7 @@ auto Lua::canPop() -> bool { return true; } lua_rawgeti(s_, LUA_REGISTRYINDEX, *obj_ref_); - lua_pushliteral(s_, "canPop"); + lua_pushliteral(s_, "can_pop"); if (lua_gettable(s_, -2) == LUA_TFUNCTION) { // If we got a callback instead of a value, then invoke it to turn it into diff --git a/src/tangara/ui/screenshot.cpp b/src/tangara/ui/screenshot.cpp new file mode 100644 index 00000000..e4f9cc3f --- /dev/null +++ b/src/tangara/ui/screenshot.cpp @@ -0,0 +1,48 @@ +/* + * Copyright 2024 jacqueline <me@jacqueline.id.au> + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#include "screenshot.hpp" +#include <sys/_stdint.h> + +#include <string> + +#define LODEPNG_NO_COMPILE_CPP +#include "libs/lodepng/lodepng.h" + +#include "esp_log.h" +#include "lvgl.h" + +namespace ui { + +[[maybe_unused]] static constexpr char kTag[] = "screenshot"; + +auto SaveScreenshot(lv_obj_t* obj, const std::string& path) -> void { + lv_draw_buf_t* buf = lv_snapshot_take(obj, LV_COLOR_FORMAT_RGB888); + if (!buf) { + return; + } + + // LVGL appears to output BGR data instead. Not quite sure why, but swapping + // each pair is quite easy. + for (size_t i = 0; i < buf->data_size; i += 3) { + uint8_t temp = buf->data[i]; + buf->data[i] = buf->data[i + 2]; + buf->data[i + 2] = temp; + } + + // The LVGL lodepng fork uses LVGL's file API, so an extra '/' is needed. + std::string fullpath = "//sdcard/" + path; + + auto res = lodepng_encode_file(fullpath.c_str(), buf->data, buf->header.w, + buf->header.h, LCT_RGB, 8); + + lv_draw_buf_destroy(buf); + if (res != 0) { + ESP_LOGE(kTag, "lodepng error: '%s'", lodepng_error_text(res)); + } +} + +} // namespace ui diff --git a/src/tangara/ui/screenshot.hpp b/src/tangara/ui/screenshot.hpp new file mode 100644 index 00000000..d19be0f0 --- /dev/null +++ b/src/tangara/ui/screenshot.hpp @@ -0,0 +1,17 @@ +/* + * Copyright 2024 jacqueline <me@jacqueline.id.au> + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +#pragma once + +#include <string> + +#include "lvgl.h" + +namespace ui { + +auto SaveScreenshot(lv_obj_t* obj, const std::string& path) -> void; + +} diff --git a/src/tangara/ui/themes.cpp b/src/tangara/ui/themes.cpp index 726bd5f0..3d532d10 100644 --- a/src/tangara/ui/themes.cpp +++ b/src/tangara/ui/themes.cpp @@ -16,6 +16,7 @@ #include "widgets/bar/lv_bar.h" #include "widgets/button/lv_button.h" #include "widgets/slider/lv_slider.h" +#include "themes.hpp" namespace ui { namespace themes { @@ -81,6 +82,10 @@ void Theme::ApplyStyle(lv_obj_t* obj, std::string style_key) { } } +void Theme::Reset() { + style_map.clear(); +} + auto Theme::instance() -> Theme* { static Theme sTheme{}; return &sTheme; diff --git a/src/tangara/ui/themes.hpp b/src/tangara/ui/themes.hpp index fd576478..4826859e 100644 --- a/src/tangara/ui/themes.hpp +++ b/src/tangara/ui/themes.hpp @@ -1,6 +1,7 @@ #pragma once #include <map> +#include <optional> #include <string> #include <vector> #include "lvgl.h" @@ -26,12 +27,15 @@ class Theme { void AddStyle(std::string key, int selector, lv_style_t* style); + void Reset(); + static auto instance() -> Theme*; private: Theme(); std::map<std::string, std::vector<std::pair<int, lv_style_t*>>> style_map; lv_theme_t theme_; + std::optional<std::string> filename_; }; } // namespace themes } // namespace ui diff --git a/src/tangara/ui/ui_events.hpp b/src/tangara/ui/ui_events.hpp index 05fd4483..0f371769 100644 --- a/src/tangara/ui/ui_events.hpp +++ b/src/tangara/ui/ui_events.hpp @@ -30,6 +30,10 @@ struct OnLuaError : tinyfsm::Event { struct DumpLuaStack : tinyfsm::Event {}; +struct Screenshot : tinyfsm::Event { + std::string filename; +}; + namespace internal { struct InitDisplay : tinyfsm::Event { diff --git a/src/tangara/ui/ui_fsm.cpp b/src/tangara/ui/ui_fsm.cpp index 7c4147a3..2009a888 100644 --- a/src/tangara/ui/ui_fsm.cpp +++ b/src/tangara/ui/ui_fsm.cpp @@ -7,11 +7,16 @@ #include "ui/ui_fsm.hpp" #include <stdint.h> +#include <algorithm> #include <memory> #include <memory_resource> #include <variant> #include "FreeRTOSConfig.h" +#include "draw/lv_draw_buf.h" +#include "drivers/bluetooth.hpp" +#include "lauxlib.h" +#include "lua.h" #include "lvgl.h" #include "core/lv_group.h" @@ -24,6 +29,9 @@ #include "freertos/projdefs.h" #include "lua.hpp" #include "luavgl.h" +#include "misc/lv_color.h" +#include "misc/lv_utils.h" +#include "others/snapshot/lv_snapshot.h" #include "tick/lv_tick.h" #include "tinyfsm.hpp" @@ -59,6 +67,7 @@ #include "ui/screen.hpp" #include "ui/screen_lua.hpp" #include "ui/screen_splash.hpp" +#include "ui/screenshot.hpp" #include "ui/ui_events.hpp" namespace ui { @@ -94,38 +103,72 @@ static auto lvgl_delay_cb(uint32_t ms) -> void { lua::Property UiState::sBatteryPct{0}; lua::Property UiState::sBatteryMv{0}; lua::Property UiState::sBatteryCharging{false}; +lua::Property UiState::sPowerChargeState{"unknown"}; +lua::Property UiState::sPowerFastChargeEnabled{ + false, [](const lua::LuaValue& val) { + if (!std::holds_alternative<bool>(val)) { + return false; + } + sServices->samd().SetFastChargeEnabled(std::get<bool>(val)); + return true; + }}; lua::Property UiState::sBluetoothEnabled{ false, [](const lua::LuaValue& val) { if (!std::holds_alternative<bool>(val)) { return false; } + // Note we always write the OutputMode NVS change before actually + // modifying the peripheral. We do this because ESP-IDF's Bluetooth stack + // breaks in surprising ways when repeatedly initialised/uninitialised. if (std::get<bool>(val)) { sServices->nvs().OutputMode(drivers::NvsStorage::Output::kBluetooth); - sServices->bluetooth().Enable(); + sServices->bluetooth().enable(true); } else { sServices->nvs().OutputMode(drivers::NvsStorage::Output::kHeadphones); - sServices->bluetooth().Disable(); + sServices->bluetooth().enable(false); } events::Audio().Dispatch(audio::OutputModeChanged{}); return true; }}; +lua::Property UiState::sBluetoothConnecting{false}; lua::Property UiState::sBluetoothConnected{false}; + +lua::Property UiState::sBluetoothDiscovering{ + false, [](const lua::LuaValue& val) { + if (!std::holds_alternative<bool>(val)) { + return false; + } + // Note we always write the OutputMode NVS change before actually + // modifying the peripheral. We do this because ESP-IDF's Bluetooth stack + // breaks in surprising ways when repeatedly initialised/uninitialised. + if (std::get<bool>(val)) { + sServices->bluetooth().discoveryEnabled(true); + } else { + sServices->bluetooth().discoveryEnabled(false); + } + return true; + }}; + lua::Property UiState::sBluetoothPairedDevice{ std::monostate{}, [](const lua::LuaValue& val) { - if (std::holds_alternative<drivers::bluetooth::Device>(val)) { - auto dev = std::get<drivers::bluetooth::Device>(val); - sServices->bluetooth().SetPreferredDevice( - drivers::bluetooth::MacAndName{ - .mac = dev.address, - .name = {dev.name.data(), dev.name.size()}, - }); + if (std::holds_alternative<drivers::bluetooth::MacAndName>(val)) { + auto dev = std::get<drivers::bluetooth::MacAndName>(val); + sServices->bluetooth().pairedDevice(dev); + } else if (std::holds_alternative<std::monostate>(val)) { + sServices->bluetooth().pairedDevice({}); + } else { + // Don't accept any other types. + return false; } - return false; + return true; }}; -lua::Property UiState::sBluetoothDevices{ - std::vector<drivers::bluetooth::Device>{}}; + +lua::Property UiState::sBluetoothKnownDevices{ + std::vector<drivers::bluetooth::MacAndName>{}}; +lua::Property UiState::sBluetoothDiscoveredDevices{ + std::vector<drivers::bluetooth::MacAndName>{}}; lua::Property UiState::sPlaybackPlaying{ false, [](const lua::LuaValue& val) { @@ -158,7 +201,14 @@ lua::Property UiState::sPlaybackPosition{ return true; }}; -lua::Property UiState::sQueuePosition{0}; +lua::Property UiState::sQueuePosition{0, [](const lua::LuaValue& val){ + if (!std::holds_alternative<int>(val)) { + return false; + } + int new_val = std::get<int>(val); + // val-1 because Lua uses 1-based indexing + return sServices->track_queue().currentPosition(new_val-1); + }}; lua::Property UiState::sQueueSize{0}; lua::Property UiState::sQueueRepeat{false, [](const lua::LuaValue& val) { if (!std::holds_alternative<bool>(val)) { @@ -184,6 +234,7 @@ lua::Property UiState::sQueueRandom{false, [](const lua::LuaValue& val) { sServices->track_queue().random(new_val); return true; }}; +lua::Property UiState::sQueueLoading{false}; lua::Property UiState::sVolumeCurrentPct{ 0, [](const lua::LuaValue& val) { @@ -336,6 +387,13 @@ int UiState::PopScreen() { return sScreens.size(); } +void UiState::react(const Screenshot& ev) { + if (!sCurrentScreen) { + return; + } + SaveScreenshot(sCurrentScreen->root(), ev.filename); +} + void UiState::react(const system_fsm::KeyLockChanged& ev) { sDisplay->SetDisplayOn(!ev.locking); sInput->lock(ev.locking); @@ -367,20 +425,39 @@ void UiState::react(const system_fsm::BatteryStateChanged& ev) { sBatteryPct.setDirect(static_cast<int>(ev.new_state.percent)); sBatteryMv.setDirect(static_cast<int>(ev.new_state.millivolts)); sBatteryCharging.setDirect(ev.new_state.is_charging); + sPowerChargeState.setDirect( + drivers::Samd::chargeStatusToString(ev.new_state.raw_status)); + + // FIXME: Avoid calling these event handlers before boot. + if (sServices) { + sPowerFastChargeEnabled.setDirect(sServices->nvs().FastCharge()); + } } -void UiState::react(const audio::QueueUpdate&) { +void UiState::react(const audio::QueueUpdate& update) { auto& queue = sServices->track_queue(); - sQueueSize.setDirect(static_cast<int>(queue.totalSize())); + auto queue_size = queue.totalSize(); + sQueueSize.setDirect(static_cast<int>(queue_size)); int current_pos = queue.currentPosition(); - if (queue.current()) { + // If there is nothing in the queue, the position should be 0, otherwise, add + // one because lua + if (queue_size > 0) { current_pos++; } + if (current_pos > queue_size) { + current_pos = queue_size; + } sQueuePosition.setDirect(current_pos); sQueueRandom.setDirect(queue.random()); sQueueRepeat.setDirect(queue.repeat()); sQueueReplay.setDirect(queue.replay()); + + if (update.reason == audio::QueueUpdate::Reason::kBulkLoadingUpdate) { + sQueueLoading.setDirect(true); + } else { + sQueueLoading.setDirect(false); + } } void UiState::react(const audio::PlaybackUpdate& ev) { @@ -412,8 +489,13 @@ void UiState::react(const audio::VolumeLimitChanged& ev) { void UiState::react(const system_fsm::BluetoothEvent& ev) { using drivers::bluetooth::SimpleEvent; + using ConnectionState = drivers::Bluetooth::ConnectionState; + ConnectionState state; auto bt = sServices->bluetooth(); - auto dev = bt.ConnectedDevice(); + + std::optional<drivers::bluetooth::MacAndName> dev; + std::vector<drivers::bluetooth::MacAndName> devs; + if (std::holds_alternative<SimpleEvent>(ev.event)) { switch (std::get<SimpleEvent>(ev.event)) { case SimpleEvent::kPlayPause: @@ -438,30 +520,36 @@ void UiState::react(const system_fsm::BluetoothEvent& ev) { break; case SimpleEvent::kFastForward: break; - case SimpleEvent::kKnownDevicesChanged: - sBluetoothDevices.setDirect(bt.KnownDevices()); - break; case SimpleEvent::kConnectionStateChanged: - sBluetoothConnected.setDirect(bt.IsConnected()); + state = bt.connectionState(); + sBluetoothConnected.setDirect(state == ConnectionState::kConnected); + sBluetoothConnecting.setDirect(state == ConnectionState::kConnecting); + break; + case SimpleEvent::kPairedDeviceChanged: + dev = bt.pairedDevice(); if (dev) { - sBluetoothPairedDevice.setDirect(drivers::bluetooth::Device{ - .address = dev->mac, - .name = {dev->name.data(), dev->name.size()}, - .class_of_device = 0, - .signal_strength = 0, - }); + sBluetoothPairedDevice.setDirect(*dev); } else { sBluetoothPairedDevice.setDirect(std::monostate{}); } break; - case SimpleEvent::kPreferredDeviceChanged: + case SimpleEvent::kKnownDevicesChanged: + sBluetoothKnownDevices.setDirect(bt.knownDevices()); + break; + case SimpleEvent::kDiscoveryChanged: + sBluetoothDiscovering.setDirect(bt.discoveryEnabled()); + // Dump the old list of discovered devices when discovery is toggled. + sBluetoothDiscoveredDevices.setDirect(bt.discoveredDevices()); + break; + case SimpleEvent::kDeviceDiscovered: + sBluetoothDiscoveredDevices.setDirect(bt.discoveredDevices()); break; default: break; } } else if (std::holds_alternative<drivers::bluetooth::RemoteVolumeChanged>( ev.event)) { - // Todo: Do something with this (ie, bt volume alert) + // TODO: Do something with this (ie, bt volume alert) ESP_LOGI( kTag, "Recieved volume changed event with new volume: %d", std::get<drivers::bluetooth::RemoteVolumeChanged>(ev.event).new_vol); @@ -512,23 +600,53 @@ void Lua::entry() { auto& registry = lua::Registry::instance(*sServices); sLua = registry.uiThread(); - registry.AddPropertyModule("power", { - {"battery_pct", &sBatteryPct}, - {"battery_millivolts", &sBatteryMv}, - {"plugged_in", &sBatteryCharging}, - }); - registry.AddPropertyModule("bluetooth", + registry.AddPropertyModule("power", { - {"enabled", &sBluetoothEnabled}, - {"connected", &sBluetoothConnected}, - {"paired_device", &sBluetoothPairedDevice}, - {"devices", &sBluetoothDevices}, + {"battery_pct", &sBatteryPct}, + {"battery_millivolts", &sBatteryMv}, + {"plugged_in", &sBatteryCharging}, + {"charge_state", &sPowerChargeState}, + {"fast_charge", &sPowerFastChargeEnabled}, }); - registry.AddPropertyModule("playback", { - {"playing", &sPlaybackPlaying}, - {"track", &sPlaybackTrack}, - {"position", &sPlaybackPosition}, - }); + registry.AddPropertyModule( + "bluetooth", { + {"enabled", &sBluetoothEnabled}, + {"connected", &sBluetoothConnected}, + {"connecting", &sBluetoothConnecting}, + {"discovering", &sBluetoothDiscovering}, + {"paired_device", &sBluetoothPairedDevice}, + {"discovered_devices", &sBluetoothDiscoveredDevices}, + {"known_devices", &sBluetoothKnownDevices}, + {"enable", + [&](lua_State* s) { + sBluetoothEnabled.set(true); + return 0; + }}, + {"disable", + [&](lua_State* s) { + sBluetoothEnabled.set(false); + return 0; + }}, + }); + registry.AddPropertyModule( + "playback", + { + {"playing", &sPlaybackPlaying}, + {"track", &sPlaybackTrack}, + {"position", &sPlaybackPosition}, + {"is_playable", + [&](lua_State* s) { + size_t len; + const char* path = luaL_checklstring(s, 1, &len); + auto res = sServices->tag_parser().ReadAndParseTags({path, len}); + if (res) { + lua_pushboolean(s, true); + } else { + lua_pushboolean(s, false); + } + return 1; + }}, + }); registry.AddPropertyModule( "queue", { @@ -539,6 +657,7 @@ void Lua::entry() { {"replay", &sQueueReplay}, {"repeat_track", &sQueueRepeat}, {"random", &sQueueRandom}, + {"loading", &sQueueLoading}, }); registry.AddPropertyModule("volume", { @@ -601,9 +720,14 @@ void Lua::entry() { sDatabaseAutoUpdate.setDirect(sServices->nvs().DbAutoIndex()); auto bt = sServices->bluetooth(); - sBluetoothEnabled.setDirect(bt.IsEnabled()); - sBluetoothConnected.setDirect(bt.IsConnected()); - sBluetoothDevices.setDirect(bt.KnownDevices()); + sBluetoothEnabled.setDirect(bt.enabled()); + auto paired = bt.pairedDevice(); + if (paired) { + sBluetoothPairedDevice.setDirect(*paired); + } + sBluetoothKnownDevices.setDirect(bt.knownDevices()); + + sPowerFastChargeEnabled.setDirect(sServices->nvs().FastCharge()); if (sServices->sd() == drivers::SdState::kMounted) { sLua->RunScript("/sdcard/config.lua"); @@ -630,7 +754,7 @@ auto Lua::PushLuaScreen(lua_State* s, bool replace) -> int { // Call the constructor for this screen. // lua_settop(s, 1); // Make sure the screen is actually at top of stack - lua_pushliteral(s, "createUi"); + lua_pushliteral(s, "create_ui"); if (lua_gettable(s, 1) == LUA_TFUNCTION) { lua_pushvalue(s, 1); lua::CallProtected(s, 1, 0); diff --git a/src/tangara/ui/ui_fsm.hpp b/src/tangara/ui/ui_fsm.hpp index 7e34db34..32966657 100644 --- a/src/tangara/ui/ui_fsm.hpp +++ b/src/tangara/ui/ui_fsm.hpp @@ -53,6 +53,7 @@ class UiState : public tinyfsm::Fsm<UiState> { /* Fallback event handler. Does nothing. */ void react(const tinyfsm::Event& ev) {} + void react(const Screenshot&); virtual void react(const OnLuaError&) {} virtual void react(const DumpLuaStack&) {} virtual void react(const internal::BackPressed&) {} @@ -100,11 +101,16 @@ class UiState : public tinyfsm::Fsm<UiState> { static lua::Property sBatteryPct; static lua::Property sBatteryMv; static lua::Property sBatteryCharging; + static lua::Property sPowerChargeState; + static lua::Property sPowerFastChargeEnabled; static lua::Property sBluetoothEnabled; + static lua::Property sBluetoothConnecting; static lua::Property sBluetoothConnected; + static lua::Property sBluetoothDiscovering; static lua::Property sBluetoothPairedDevice; - static lua::Property sBluetoothDevices; + static lua::Property sBluetoothKnownDevices; + static lua::Property sBluetoothDiscoveredDevices; static lua::Property sPlaybackPlaying; @@ -116,6 +122,7 @@ class UiState : public tinyfsm::Fsm<UiState> { static lua::Property sQueueReplay; static lua::Property sQueueRepeat; static lua::Property sQueueRandom; + static lua::Property sQueueLoading; static lua::Property sVolumeCurrentPct; static lua::Property sVolumeCurrentDb; diff --git a/src/tasks/tasks.cpp b/src/tasks/tasks.cpp index d3937c68..f0b567f2 100644 --- a/src/tasks/tasks.cpp +++ b/src/tasks/tasks.cpp @@ -47,7 +47,7 @@ auto AllocateStack<Type::kAudioDecoder>() -> std::span<StackType_t> { // separately. template <> auto AllocateStack<Type::kUi>() -> std::span<StackType_t> { - constexpr std::size_t size = 14 * 1024; + constexpr std::size_t size = 20 * 1024; static StackType_t sStack[size]; return {sStack, size}; } @@ -64,7 +64,7 @@ auto AllocateStack<Type::kAudioConverter>() -> std::span<StackType_t> { // an eye-wateringly large amount of stack. template <> auto AllocateStack<Type::kBackgroundWorker>() -> std::span<StackType_t> { - std::size_t size = 64 * 1024; + std::size_t size = 32 * 1024; return {static_cast<StackType_t*>(heap_caps_malloc(size, MALLOC_CAP_SPIRAM)), size}; } @@ -83,11 +83,11 @@ auto Priority() -> UBaseType_t; // highest priority. template <> auto Priority<Type::kAudioDecoder>() -> UBaseType_t { - return configMAX_PRIORITIES - 1; + return 15; } template <> auto Priority<Type::kAudioConverter>() -> UBaseType_t { - return configMAX_PRIORITIES - 1; + return 15; } // After audio issues, UI jank is the most noticeable kind of scheduling-induced // slowness that the user is likely to notice or care about. Therefore we place diff --git a/src/util/CMakeLists.txt b/src/util/CMakeLists.txt index 49554be8..90778c5d 100644 --- a/src/util/CMakeLists.txt +++ b/src/util/CMakeLists.txt @@ -2,4 +2,5 @@ # # SPDX-License-Identifier: GPL-3.0-only -idf_component_register(SRCS INCLUDE_DIRS "include" REQUIRES "memory") +idf_component_register( + SRCS "random.cpp" INCLUDE_DIRS "include" REQUIRES "memory" "komihash") diff --git a/src/util/include/debug.hpp b/src/util/include/debug.hpp index 27fb2999..37c26f6a 100644 --- a/src/util/include/debug.hpp +++ b/src/util/include/debug.hpp @@ -6,6 +6,7 @@ #pragma once +#include <cstdint> #include <iomanip> #include <ostream> #include <span> @@ -31,8 +32,8 @@ inline std::string format_hex_string(std::span<const std::byte> data) { oss << " "; } int byte_val = (int)byte; - oss << "[0x" << std::uppercase << std::setfill('0') << std::setw(2) - << std::hex << byte_val << ']'; + oss << std::uppercase << std::setfill('0') << std::setw(2) << std::hex + << byte_val << ' '; if (byte_val >= 32 && byte_val < 127) { ascii_values << (char)byte; } else { @@ -43,4 +44,12 @@ inline std::string format_hex_string(std::span<const std::byte> data) { return oss.str(); } +inline std::string format_hex_string(std::span<const uint8_t> data) { + return format_hex_string(std::as_bytes(data)); +} + +inline std::string format_hex_string(std::span<const char> data) { + return format_hex_string(std::as_bytes(data)); +} + } // namespace util |
