summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorjacqueline <me@jacqueline.id.au>2024-09-09 15:15:00 +1000
committerjacqueline <me@jacqueline.id.au>2024-09-09 15:15:00 +1000
commit2b1a01705d62d08cefd6816ba108c5cae48a50ac (patch)
tree20ba16a6259ffc335dbcded84fa6bcbe327e9d84 /src
parent9475d10d1000c7e21a7ea311b0c8ee6a72ef46c4 (diff)
parentacdc9789c90ed6f083d054cd07930e020123457f (diff)
downloadtangara-fw-2b1a01705d62d08cefd6816ba108c5cae48a50ac.tar.gz
Merge branch 'main' into jqln/tts
Diffstat (limited to 'src')
-rw-r--r--src/codecs/README.md9
-rw-r--r--src/codecs/vorbis.cpp7
-rw-r--r--src/drivers/CMakeLists.txt2
-rw-r--r--src/drivers/bluetooth.cpp303
-rw-r--r--src/drivers/display.cpp9
-rw-r--r--src/drivers/i2s_dac.cpp4
-rw-r--r--src/drivers/include/drivers/bluetooth.hpp115
-rw-r--r--src/drivers/include/drivers/bluetooth_types.hpp7
-rw-r--r--src/drivers/include/drivers/nvs.hpp18
-rw-r--r--src/drivers/include/drivers/pcm_buffer.hpp8
-rw-r--r--src/drivers/include/drivers/samd.hpp13
-rw-r--r--src/drivers/include/drivers/touchwheel.hpp3
-rw-r--r--src/drivers/nvs.cpp134
-rw-r--r--src/drivers/pcm_buffer.cpp17
-rw-r--r--src/drivers/samd.cpp74
-rw-r--r--src/drivers/spi.cpp2
-rw-r--r--src/drivers/test/test_samd.cpp4
-rw-r--r--src/drivers/touchwheel.cpp8
-rw-r--r--src/locale/collation.cpp4
-rw-r--r--src/tangara/app_console/app_console.cpp59
-rw-r--r--src/tangara/audio/audio_decoder.cpp46
-rw-r--r--src/tangara/audio/audio_decoder.hpp2
-rw-r--r--src/tangara/audio/audio_events.hpp11
-rw-r--r--src/tangara/audio/audio_fsm.cpp66
-rw-r--r--src/tangara/audio/audio_fsm.hpp1
-rw-r--r--src/tangara/audio/bt_audio_output.cpp13
-rw-r--r--src/tangara/audio/playlist.cpp302
-rw-r--r--src/tangara/audio/playlist.hpp87
-rw-r--r--src/tangara/audio/processor.cpp420
-rw-r--r--src/tangara/audio/processor.hpp75
-rw-r--r--src/tangara/audio/resample.cpp18
-rw-r--r--src/tangara/audio/resample.hpp3
-rw-r--r--src/tangara/audio/track_queue.cpp282
-rw-r--r--src/tangara/audio/track_queue.hpp39
-rw-r--r--src/tangara/battery/battery.cpp39
-rw-r--r--src/tangara/battery/battery.hpp1
-rw-r--r--src/tangara/database/database.cpp413
-rw-r--r--src/tangara/database/database.hpp54
-rw-r--r--src/tangara/database/file_gatherer.cpp72
-rw-r--r--src/tangara/database/file_gatherer.hpp34
-rw-r--r--src/tangara/database/index.cpp57
-rw-r--r--src/tangara/database/index.hpp3
-rw-r--r--src/tangara/database/records.cpp18
-rw-r--r--src/tangara/database/tag_parser.cpp171
-rw-r--r--src/tangara/database/tag_parser.hpp24
-rw-r--r--src/tangara/database/track.cpp13
-rw-r--r--src/tangara/database/track.hpp2
-rw-r--r--src/tangara/database/track_finder.cpp118
-rw-r--r--src/tangara/database/track_finder.hpp77
-rw-r--r--src/tangara/dev_console/console.cpp58
-rw-r--r--src/tangara/input/input_device.hpp5
-rw-r--r--src/tangara/input/input_touch_wheel.cpp9
-rw-r--r--src/tangara/input/input_touch_wheel.hpp7
-rw-r--r--src/tangara/input/lvgl_input_driver.cpp12
-rw-r--r--src/tangara/input/lvgl_input_driver.hpp3
-rw-r--r--src/tangara/lua/file_iterator.cpp40
-rw-r--r--src/tangara/lua/file_iterator.hpp7
-rw-r--r--src/tangara/lua/lua_filesystem.cpp65
-rw-r--r--src/tangara/lua/lua_queue.cpp30
-rw-r--r--src/tangara/lua/lua_screen.cpp6
-rw-r--r--src/tangara/lua/lua_theme.cpp33
-rw-r--r--src/tangara/lua/property.cpp81
-rw-r--r--src/tangara/lua/property.hpp4
-rw-r--r--src/tangara/system_fsm/booting.cpp9
-rw-r--r--src/tangara/system_fsm/idle.cpp6
-rw-r--r--src/tangara/system_fsm/running.cpp13
-rw-r--r--src/tangara/test/CMakeLists.txt2
-rw-r--r--src/tangara/test/audio/test_playlist.cpp129
-rw-r--r--src/tangara/test/battery/test_battery.cpp3
-rw-r--r--src/tangara/ui/lvgl_task.cpp1
-rw-r--r--src/tangara/ui/screen_lua.cpp6
-rw-r--r--src/tangara/ui/screenshot.cpp48
-rw-r--r--src/tangara/ui/screenshot.hpp17
-rw-r--r--src/tangara/ui/themes.cpp5
-rw-r--r--src/tangara/ui/themes.hpp4
-rw-r--r--src/tangara/ui/ui_events.hpp4
-rw-r--r--src/tangara/ui/ui_fsm.cpp220
-rw-r--r--src/tangara/ui/ui_fsm.hpp9
-rw-r--r--src/tasks/tasks.cpp8
-rw-r--r--src/util/CMakeLists.txt3
-rw-r--r--src/util/include/debug.hpp13
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(&current_->second, next_path);
+ if (res != FR_OK) {
+ current_.reset();
+ continue;
+ }
+ }
+
+ FRESULT res = f_readdir(&current_->second, &info);
+ if (res != FR_OK || info.fname[0] == 0) {
+ // No more files in the directory.
+ f_closedir(&current_->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