Merge branch 'main' of https://git.sr.ht/~jacqueline/tangara-fw
This commit is contained in:
@@ -20,14 +20,11 @@
|
||||
#include "esp_console.h"
|
||||
#include "esp_log.h"
|
||||
#include "event_queue.hpp"
|
||||
#include "ff.h"
|
||||
|
||||
namespace console {
|
||||
|
||||
static AppConsole* sInstance = nullptr;
|
||||
|
||||
std::string toSdPath(const std::string& filepath) {
|
||||
return std::string("/") + filepath;
|
||||
}
|
||||
std::weak_ptr<database::Database> AppConsole::sDatabase;
|
||||
|
||||
int CmdListDir(int argc, char** argv) {
|
||||
static const std::string usage = "usage: ls [directory]";
|
||||
@@ -36,7 +33,7 @@ int CmdListDir(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto lock = sInstance->database_.lock();
|
||||
auto lock = AppConsole::sDatabase.lock();
|
||||
if (lock == nullptr) {
|
||||
std::cout << "storage is not available" << std::endl;
|
||||
return 1;
|
||||
@@ -44,18 +41,38 @@ int CmdListDir(int argc, char** argv) {
|
||||
|
||||
std::string path;
|
||||
if (argc == 2) {
|
||||
path = toSdPath(argv[1]);
|
||||
path = argv[1];
|
||||
} else {
|
||||
path = toSdPath("");
|
||||
path = "";
|
||||
}
|
||||
|
||||
DIR* dir;
|
||||
struct dirent* ent;
|
||||
dir = opendir(path.c_str());
|
||||
while ((ent = readdir(dir))) {
|
||||
std::cout << ent->d_name << std::endl;
|
||||
FF_DIR dir;
|
||||
FRESULT res = f_opendir(&dir, path.c_str());
|
||||
if (res != FR_OK) {
|
||||
std::cout << "failed to open directory. does it exist?" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
closedir(dir);
|
||||
|
||||
for (;;) {
|
||||
FILINFO info;
|
||||
res = f_readdir(&dir, &info);
|
||||
if (res != FR_OK || info.fname[0] == 0) {
|
||||
// No more files in the directory.
|
||||
break;
|
||||
} else {
|
||||
std::cout << path;
|
||||
if (!path.ends_with('/') && !path.empty()) {
|
||||
std::cout << '/';
|
||||
}
|
||||
std::cout << info.fname;
|
||||
if (info.fattrib & AM_DIR) {
|
||||
std::cout << '/';
|
||||
}
|
||||
std::cout << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
f_closedir(&dir);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -101,7 +118,7 @@ int CmdDbInit(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto db = sInstance->database_.lock();
|
||||
auto db = AppConsole::sDatabase.lock();
|
||||
if (!db) {
|
||||
std::cout << "no database open" << std::endl;
|
||||
return 1;
|
||||
@@ -121,21 +138,22 @@ void RegisterDbInit() {
|
||||
esp_console_cmd_register(&cmd);
|
||||
}
|
||||
|
||||
int CmdDbSongs(int argc, char** argv) {
|
||||
static const std::string usage = "usage: db_songs";
|
||||
int CmdDbTracks(int argc, char** argv) {
|
||||
static const std::string usage = "usage: db_tracks";
|
||||
if (argc != 1) {
|
||||
std::cout << usage << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto db = sInstance->database_.lock();
|
||||
auto db = AppConsole::sDatabase.lock();
|
||||
if (!db) {
|
||||
std::cout << "no database open" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
std::unique_ptr<database::Result<database::Song>> res(db->GetSongs(5).get());
|
||||
std::unique_ptr<database::Result<database::Track>> res(
|
||||
db->GetTracks(20).get());
|
||||
while (true) {
|
||||
for (database::Song s : res->values()) {
|
||||
for (database::Track s : res->values()) {
|
||||
std::cout << s.tags().title.value_or("[BLANK]") << std::endl;
|
||||
}
|
||||
if (res->next_page()) {
|
||||
@@ -149,11 +167,11 @@ int CmdDbSongs(int argc, char** argv) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterDbSongs() {
|
||||
esp_console_cmd_t cmd{.command = "db_songs",
|
||||
.help = "lists titles of ALL songs in the database",
|
||||
void RegisterDbTracks() {
|
||||
esp_console_cmd_t cmd{.command = "db_tracks",
|
||||
.help = "lists titles of ALL tracks in the database",
|
||||
.hint = NULL,
|
||||
.func = &CmdDbSongs,
|
||||
.func = &CmdDbTracks,
|
||||
.argtable = NULL};
|
||||
esp_console_cmd_register(&cmd);
|
||||
}
|
||||
@@ -165,7 +183,7 @@ int CmdDbDump(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto db = sInstance->database_.lock();
|
||||
auto db = AppConsole::sDatabase.lock();
|
||||
if (!db) {
|
||||
std::cout << "no database open" << std::endl;
|
||||
return 1;
|
||||
@@ -200,14 +218,6 @@ void RegisterDbDump() {
|
||||
esp_console_cmd_register(&cmd);
|
||||
}
|
||||
|
||||
AppConsole::AppConsole(const std::weak_ptr<database::Database>& database)
|
||||
: database_(database) {
|
||||
sInstance = this;
|
||||
}
|
||||
AppConsole::~AppConsole() {
|
||||
sInstance = nullptr;
|
||||
}
|
||||
|
||||
auto AppConsole::RegisterExtraComponents() -> void {
|
||||
RegisterListDir();
|
||||
RegisterPlayFile();
|
||||
@@ -217,7 +227,7 @@ auto AppConsole::RegisterExtraComponents() -> void {
|
||||
RegisterAudioStatus();
|
||||
*/
|
||||
RegisterDbInit();
|
||||
RegisterDbSongs();
|
||||
RegisterDbTracks();
|
||||
RegisterDbDump();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ namespace console {
|
||||
|
||||
class AppConsole : public Console {
|
||||
public:
|
||||
explicit AppConsole(const std::weak_ptr<database::Database>& database);
|
||||
virtual ~AppConsole();
|
||||
|
||||
const std::weak_ptr<database::Database>& database_;
|
||||
static std::weak_ptr<database::Database> sDatabase;
|
||||
|
||||
protected:
|
||||
virtual auto RegisterExtraComponents() -> void;
|
||||
|
||||
+76
-50
@@ -14,6 +14,7 @@
|
||||
#include <memory>
|
||||
#include <variant>
|
||||
|
||||
#include "codec.hpp"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
@@ -50,6 +51,9 @@ auto AudioDecoder::ProcessStreamInfo(const StreamInfo& info) -> bool {
|
||||
// Reuse the existing codec if we can. This will help with gapless playback,
|
||||
// since we can potentially just continue to decode as we were before,
|
||||
// without any setup overhead.
|
||||
// TODO(jacqueline): Reconsider this. It makes a lot of things harder to smash
|
||||
// streams together at this layer.
|
||||
/*
|
||||
if (current_codec_ != nullptr && current_input_format_) {
|
||||
auto cur_encoding = std::get<StreamInfo::Encoded>(*current_input_format_);
|
||||
if (cur_encoding.type == encoded.type) {
|
||||
@@ -58,6 +62,7 @@ auto AudioDecoder::ProcessStreamInfo(const StreamInfo& info) -> bool {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
*/
|
||||
current_input_format_ = info.format;
|
||||
|
||||
ESP_LOGI(kTag, "creating new decoder");
|
||||
@@ -80,68 +85,89 @@ auto AudioDecoder::Process(const std::vector<InputStream>& inputs,
|
||||
OutputStream* output) -> void {
|
||||
auto input = inputs.begin();
|
||||
const StreamInfo& info = input->info();
|
||||
if (std::holds_alternative<std::monostate>(info.format) ||
|
||||
info.bytes_in_stream == 0) {
|
||||
// TODO(jacqueline): should we clear the stream format?
|
||||
// output->prepare({});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the input stream's format has changed (or, by extension, if this is
|
||||
// the first stream).
|
||||
if (!current_input_format_ || *current_input_format_ != info.format) {
|
||||
// The input stream has changed! Immediately throw everything away and
|
||||
// start from scratch.
|
||||
has_samples_to_send_ = false;
|
||||
ProcessStreamInfo(info);
|
||||
}
|
||||
|
||||
current_codec_->SetInput(input->data());
|
||||
|
||||
while (true) {
|
||||
if (has_samples_to_send_) {
|
||||
auto format = current_codec_->GetOutputFormat();
|
||||
if (format.has_value()) {
|
||||
current_output_format_ = StreamInfo::Pcm{
|
||||
.channels = format->num_channels,
|
||||
.bits_per_sample = format->bits_per_sample,
|
||||
.sample_rate = format->sample_rate_hz,
|
||||
};
|
||||
|
||||
if (!output->prepare(*current_output_format_)) {
|
||||
break;
|
||||
}
|
||||
|
||||
auto write_res = current_codec_->WriteOutputSamples(output->data());
|
||||
output->add(write_res.first);
|
||||
has_samples_to_send_ = !write_res.second;
|
||||
|
||||
if (has_samples_to_send_) {
|
||||
// We weren't able to fit all the generated samples into the output
|
||||
// buffer. Stop trying; we'll finish up during the next pass.
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ProcessStreamInfo(info)) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto res = current_codec_->ProcessNextFrame();
|
||||
if (res.has_error()) {
|
||||
ESP_LOGI(kTag, "beginning new stream");
|
||||
auto res = current_codec_->BeginStream(input->data());
|
||||
input->consume(res.first);
|
||||
if (res.second.has_error()) {
|
||||
// TODO(jacqueline): Handle errors.
|
||||
return;
|
||||
}
|
||||
|
||||
has_input_remaining_ = !res.value();
|
||||
if (!has_input_remaining_) {
|
||||
// We're out of useable data in this buffer. Finish immediately; there's
|
||||
// nothing to send.
|
||||
input->mark_incomplete();
|
||||
break;
|
||||
// The stream started successfully. Record what format the samples are in.
|
||||
codecs::ICodec::OutputFormat format = res.second.value();
|
||||
current_output_format_ = StreamInfo::Pcm{
|
||||
.channels = format.num_channels,
|
||||
.bits_per_sample = format.bits_per_sample,
|
||||
.sample_rate = format.sample_rate_hz,
|
||||
};
|
||||
|
||||
if (info.seek_to_seconds) {
|
||||
seek_to_sample_ = *info.seek_to_seconds * format.sample_rate_hz;
|
||||
} else {
|
||||
has_samples_to_send_ = true;
|
||||
seek_to_sample_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t pos = current_codec_->GetInputPosition();
|
||||
if (pos > 0) {
|
||||
input->consume(pos - 1);
|
||||
while (seek_to_sample_) {
|
||||
ESP_LOGI(kTag, "seeking forwards...");
|
||||
auto res = current_codec_->SeekStream(input->data(), *seek_to_sample_);
|
||||
input->consume(res.first);
|
||||
if (res.second.has_error()) {
|
||||
auto err = res.second.error();
|
||||
if (err == codecs::ICodec::Error::kOutOfInput) {
|
||||
return;
|
||||
} else {
|
||||
// TODO(jacqueline): Handle errors.
|
||||
seek_to_sample_.reset();
|
||||
}
|
||||
} else {
|
||||
seek_to_sample_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
has_input_remaining_ = true;
|
||||
while (true) {
|
||||
// TODO(jacqueline): Pass through seek info here?
|
||||
if (!output->prepare(*current_output_format_)) {
|
||||
ESP_LOGI(kTag, "waiting for buffer to become free");
|
||||
break;
|
||||
}
|
||||
|
||||
auto res = current_codec_->ContinueStream(input->data(), output->data());
|
||||
input->consume(res.first);
|
||||
if (res.second.has_error()) {
|
||||
if (res.second.error() == codecs::ICodec::Error::kOutOfInput) {
|
||||
ESP_LOGW(kTag, "out of input");
|
||||
ESP_LOGW(kTag, "(%u bytes left)", input->data().size_bytes());
|
||||
has_input_remaining_ = false;
|
||||
// We can't be halfway through sending samples if the codec is asking
|
||||
// for more input.
|
||||
has_samples_to_send_ = false;
|
||||
input->mark_incomplete();
|
||||
} else {
|
||||
// TODO(jacqueline): Handle errors.
|
||||
ESP_LOGE(kTag, "codec return fatal error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
codecs::ICodec::OutputInfo out_info = res.second.value();
|
||||
output->add(out_info.bytes_written);
|
||||
has_samples_to_send_ = !out_info.is_finished_writing;
|
||||
|
||||
if (has_samples_to_send_) {
|
||||
// We weren't able to fit all the generated samples into the output
|
||||
// buffer. Stop trying; we'll finish up during the next pass.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-7
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
#include "audio_fsm.hpp"
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <variant>
|
||||
#include "audio_decoder.hpp"
|
||||
@@ -14,6 +15,7 @@
|
||||
#include "i2s_audio_output.hpp"
|
||||
#include "i2s_dac.hpp"
|
||||
#include "pipeline.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
namespace audio {
|
||||
|
||||
@@ -28,7 +30,7 @@ std::unique_ptr<FatfsAudioInput> AudioState::sFileSource;
|
||||
std::unique_ptr<I2SAudioOutput> AudioState::sI2SOutput;
|
||||
std::vector<std::unique_ptr<IAudioElement>> AudioState::sPipeline;
|
||||
|
||||
std::deque<AudioState::EnqueuedItem> AudioState::sSongQueue;
|
||||
std::deque<AudioState::EnqueuedItem> AudioState::sTrackQueue;
|
||||
|
||||
auto AudioState::Init(drivers::GpioExpander* gpio_expander,
|
||||
std::weak_ptr<database::Database> database) -> bool {
|
||||
@@ -59,16 +61,36 @@ auto AudioState::Init(drivers::GpioExpander* gpio_expander,
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioState::react(const system_fsm::StorageMounted& ev) {
|
||||
sDatabase = ev.db;
|
||||
}
|
||||
|
||||
namespace states {
|
||||
|
||||
void Uninitialised::react(const system_fsm::BootComplete&) {
|
||||
transit<Standby>();
|
||||
}
|
||||
|
||||
void Standby::react(const PlayFile& ev) {
|
||||
if (sFileSource->OpenFile(ev.filename)) {
|
||||
transit<Playback>();
|
||||
void Standby::react(const InputFileOpened& ev) {
|
||||
transit<Playback>();
|
||||
}
|
||||
|
||||
void Standby::react(const PlayTrack& ev) {
|
||||
auto db = sDatabase.lock();
|
||||
if (!db) {
|
||||
ESP_LOGW(kTag, "database not open; ignoring play request");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.data) {
|
||||
sFileSource->OpenFile(ev.data->filepath());
|
||||
} else {
|
||||
sFileSource->OpenFile(db->GetTrackPath(ev.id));
|
||||
}
|
||||
}
|
||||
|
||||
void Standby::react(const PlayFile& ev) {
|
||||
sFileSource->OpenFile(ev.filename);
|
||||
}
|
||||
|
||||
void Playback::entry() {
|
||||
@@ -81,16 +103,34 @@ void Playback::exit() {
|
||||
sI2SOutput->SetInUse(false);
|
||||
}
|
||||
|
||||
void Playback::react(const PlayTrack& ev) {
|
||||
sTrackQueue.push_back(EnqueuedItem(ev.id));
|
||||
}
|
||||
|
||||
void Playback::react(const PlayFile& ev) {
|
||||
sTrackQueue.push_back(EnqueuedItem(ev.filename));
|
||||
}
|
||||
|
||||
void Playback::react(const InputFileOpened& ev) {}
|
||||
|
||||
void Playback::react(const InputFileFinished& ev) {
|
||||
ESP_LOGI(kTag, "finished file");
|
||||
if (sSongQueue.empty()) {
|
||||
if (sTrackQueue.empty()) {
|
||||
return;
|
||||
}
|
||||
EnqueuedItem next_item = sSongQueue.front();
|
||||
sSongQueue.pop_front();
|
||||
EnqueuedItem next_item = sTrackQueue.front();
|
||||
sTrackQueue.pop_front();
|
||||
|
||||
if (std::holds_alternative<std::string>(next_item)) {
|
||||
sFileSource->OpenFile(std::get<std::string>(next_item));
|
||||
} else if (std::holds_alternative<database::TrackId>(next_item)) {
|
||||
auto db = sDatabase.lock();
|
||||
if (!db) {
|
||||
ESP_LOGW(kTag, "database not open; ignoring play request");
|
||||
return;
|
||||
}
|
||||
sFileSource->OpenFile(
|
||||
db->GetTrackPath(std::get<database::TrackId>(next_item)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace task {
|
||||
static const char* kTag = "task";
|
||||
|
||||
// The default amount of time to wait between pipeline iterations for a single
|
||||
// song.
|
||||
// track.
|
||||
static constexpr uint_fast16_t kDefaultDelayTicks = pdMS_TO_TICKS(5);
|
||||
static constexpr uint_fast16_t kMaxDelayTicks = pdMS_TO_TICKS(10);
|
||||
static constexpr uint_fast16_t kMinDelayTicks = pdMS_TO_TICKS(1);
|
||||
@@ -54,7 +54,7 @@ void AudioTaskMain(std::unique_ptr<Pipeline> pipeline, IAudioSink* sink) {
|
||||
// The stream format for bytes currently in the sink buffer.
|
||||
std::optional<StreamInfo::Format> output_format;
|
||||
|
||||
// How long to wait between pipeline iterations. This is reset for each song,
|
||||
// How long to wait between pipeline iterations. This is reset for each track,
|
||||
// and readjusted on the fly to maintain a reasonable amount playback buffer.
|
||||
// Buffering too much will mean we process samples inefficiently, wasting CPU
|
||||
// time, whilst buffering too little will affect the quality of the output.
|
||||
@@ -126,7 +126,7 @@ void AudioTaskMain(std::unique_ptr<Pipeline> pipeline, IAudioSink* sink) {
|
||||
|
||||
if (sink_stream.info().bytes_in_stream == 0) {
|
||||
// No new bytes to sink, so skip sinking completely.
|
||||
ESP_LOGI(kTag, "no bytes to sink");
|
||||
ESP_LOGW(kTag, "no bytes to sink");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
@@ -24,12 +26,12 @@
|
||||
|
||||
#include "audio_element.hpp"
|
||||
#include "chunk.hpp"
|
||||
#include "song.hpp"
|
||||
#include "stream_buffer.hpp"
|
||||
#include "stream_event.hpp"
|
||||
#include "stream_info.hpp"
|
||||
#include "stream_message.hpp"
|
||||
#include "tag_parser.hpp"
|
||||
#include "track.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
static const char* kTag = "SRC";
|
||||
@@ -38,6 +40,7 @@ namespace audio {
|
||||
|
||||
FatfsAudioInput::FatfsAudioInput()
|
||||
: IAudioElement(),
|
||||
pending_path_(),
|
||||
current_file_(),
|
||||
is_file_open_(false),
|
||||
current_container_(),
|
||||
@@ -45,22 +48,32 @@ FatfsAudioInput::FatfsAudioInput()
|
||||
|
||||
FatfsAudioInput::~FatfsAudioInput() {}
|
||||
|
||||
auto FatfsAudioInput::OpenFile(std::future<std::optional<std::string>>&& path)
|
||||
-> void {
|
||||
pending_path_ = std::move(path);
|
||||
}
|
||||
|
||||
auto FatfsAudioInput::OpenFile(const std::string& path) -> bool {
|
||||
if (is_file_open_) {
|
||||
f_close(¤t_file_);
|
||||
is_file_open_ = false;
|
||||
}
|
||||
if (pending_path_) {
|
||||
pending_path_ = {};
|
||||
}
|
||||
ESP_LOGI(kTag, "opening file %s", path.c_str());
|
||||
|
||||
database::TagParserImpl tag_parser;
|
||||
database::SongTags tags;
|
||||
database::TrackTags tags;
|
||||
if (!tag_parser.ReadAndParseTags(path, &tags)) {
|
||||
ESP_LOGE(kTag, "failed to read tags");
|
||||
return false;
|
||||
tags.encoding = database::Encoding::kFlac;
|
||||
// return false;
|
||||
}
|
||||
|
||||
auto stream_type = ContainerToStreamType(tags.encoding);
|
||||
if (!stream_type.has_value()) {
|
||||
ESP_LOGE(kTag, "couldn't match container to stream");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -87,16 +100,33 @@ auto FatfsAudioInput::OpenFile(const std::string& path) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
events::Dispatch<InputFileOpened, AudioState>({});
|
||||
is_file_open_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto FatfsAudioInput::NeedsToProcess() const -> bool {
|
||||
return is_file_open_;
|
||||
return is_file_open_ || pending_path_;
|
||||
}
|
||||
|
||||
auto FatfsAudioInput::Process(const std::vector<InputStream>& inputs,
|
||||
OutputStream* output) -> void {
|
||||
if (pending_path_) {
|
||||
ESP_LOGI(kTag, "waiting for path");
|
||||
if (!pending_path_->valid()) {
|
||||
pending_path_ = {};
|
||||
} else {
|
||||
if (pending_path_->wait_for(std::chrono::seconds(0)) ==
|
||||
std::future_status::ready) {
|
||||
ESP_LOGI(kTag, "path ready!");
|
||||
auto result = pending_path_->get();
|
||||
if (result) {
|
||||
OpenFile(*result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_file_open_) {
|
||||
return;
|
||||
}
|
||||
@@ -144,8 +174,8 @@ auto FatfsAudioInput::ContainerToStreamType(database::Encoding enc)
|
||||
return codecs::StreamType::kPcm;
|
||||
case database::Encoding::kFlac:
|
||||
return codecs::StreamType::kFlac;
|
||||
case database::Encoding::kOgg:
|
||||
return codecs::StreamType::kOgg;
|
||||
case database::Encoding::kOgg: // Misnamed; this is Ogg Vorbis.
|
||||
return codecs::StreamType::kVorbis;
|
||||
case database::Encoding::kUnsupported:
|
||||
default:
|
||||
return {};
|
||||
|
||||
@@ -42,6 +42,7 @@ class AudioDecoder : public IAudioElement {
|
||||
std::unique_ptr<codecs::ICodec> current_codec_;
|
||||
std::optional<StreamInfo::Format> current_input_format_;
|
||||
std::optional<StreamInfo::Format> current_output_format_;
|
||||
std::optional<std::size_t> seek_to_sample_;
|
||||
bool has_samples_to_send_;
|
||||
bool has_input_remaining_;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
#include "tinyfsm.hpp"
|
||||
|
||||
#include "song.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
namespace audio {
|
||||
|
||||
@@ -18,12 +18,12 @@ struct PlayFile : tinyfsm::Event {
|
||||
std::string filename;
|
||||
};
|
||||
|
||||
struct PlaySong : tinyfsm::Event {
|
||||
database::SongId id;
|
||||
std::optional<database::SongData> data;
|
||||
std::optional<database::SongTags> tags;
|
||||
struct PlayTrack : tinyfsm::Event {
|
||||
database::TrackId id;
|
||||
std::optional<database::TrackData> data;
|
||||
};
|
||||
|
||||
struct InputFileOpened : tinyfsm::Event {};
|
||||
struct InputFileFinished : tinyfsm::Event {};
|
||||
struct AudioPipelineIdle : tinyfsm::Event {};
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
#include "gpio_expander.hpp"
|
||||
#include "i2s_audio_output.hpp"
|
||||
#include "i2s_dac.hpp"
|
||||
#include "song.hpp"
|
||||
#include "storage.hpp"
|
||||
#include "tinyfsm.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
#include "system_events.hpp"
|
||||
|
||||
@@ -38,10 +38,13 @@ class AudioState : public tinyfsm::Fsm<AudioState> {
|
||||
/* Fallback event handler. Does nothing. */
|
||||
void react(const tinyfsm::Event& ev) {}
|
||||
|
||||
void react(const system_fsm::StorageMounted&);
|
||||
|
||||
virtual void react(const system_fsm::BootComplete&) {}
|
||||
virtual void react(const PlaySong&) {}
|
||||
virtual void react(const PlayTrack&) {}
|
||||
virtual void react(const PlayFile&) {}
|
||||
|
||||
virtual void react(const InputFileOpened&) {}
|
||||
virtual void react(const InputFileFinished&) {}
|
||||
virtual void react(const AudioPipelineIdle&) {}
|
||||
|
||||
@@ -55,8 +58,8 @@ class AudioState : public tinyfsm::Fsm<AudioState> {
|
||||
static std::unique_ptr<I2SAudioOutput> sI2SOutput;
|
||||
static std::vector<std::unique_ptr<IAudioElement>> sPipeline;
|
||||
|
||||
typedef std::variant<database::SongId, std::string> EnqueuedItem;
|
||||
static std::deque<EnqueuedItem> sSongQueue;
|
||||
typedef std::variant<database::TrackId, std::string> EnqueuedItem;
|
||||
static std::deque<EnqueuedItem> sTrackQueue;
|
||||
};
|
||||
|
||||
namespace states {
|
||||
@@ -69,8 +72,10 @@ class Uninitialised : public AudioState {
|
||||
|
||||
class Standby : public AudioState {
|
||||
public:
|
||||
void react(const PlaySong&) override {}
|
||||
void react(const InputFileOpened&) override;
|
||||
void react(const PlayTrack&) override;
|
||||
void react(const PlayFile&) override;
|
||||
|
||||
using AudioState::react;
|
||||
};
|
||||
|
||||
@@ -79,6 +84,10 @@ class Playback : public AudioState {
|
||||
void entry() override;
|
||||
void exit() override;
|
||||
|
||||
void react(const PlayTrack&) override;
|
||||
void react(const PlayFile&) override;
|
||||
|
||||
void react(const InputFileOpened&) override;
|
||||
void react(const InputFileFinished&) override;
|
||||
void react(const AudioPipelineIdle&) override;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -18,8 +19,8 @@
|
||||
#include "ff.h"
|
||||
#include "freertos/message_buffer.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "song.hpp"
|
||||
#include "span.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
#include "audio_element.hpp"
|
||||
#include "stream_buffer.hpp"
|
||||
@@ -33,6 +34,7 @@ class FatfsAudioInput : public IAudioElement {
|
||||
FatfsAudioInput();
|
||||
~FatfsAudioInput();
|
||||
|
||||
auto OpenFile(std::future<std::optional<std::string>>&& path) -> void;
|
||||
auto OpenFile(const std::string& path) -> bool;
|
||||
|
||||
auto NeedsToProcess() const -> bool override;
|
||||
@@ -47,6 +49,7 @@ class FatfsAudioInput : public IAudioElement {
|
||||
auto ContainerToStreamType(database::Encoding)
|
||||
-> std::optional<codecs::StreamType>;
|
||||
|
||||
std::optional<std::future<std::optional<std::string>>> pending_path_;
|
||||
FIL current_file_;
|
||||
bool is_file_open_;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -30,6 +31,9 @@ struct StreamInfo {
|
||||
// generated audio, etc.)
|
||||
std::optional<std::size_t> length_bytes{};
|
||||
|
||||
//
|
||||
std::optional<uint32_t> seek_to_seconds{};
|
||||
|
||||
struct Encoded {
|
||||
// The codec that this stream is associated with.
|
||||
codecs::StreamType type;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
idf_component_register(
|
||||
SRCS "codec.cpp" "mad.cpp"
|
||||
SRCS "codec.cpp" "mad.cpp" "foxenflac.cpp" "stbvorbis.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES "result" "span" "libmad")
|
||||
REQUIRES "result" "span" "libmad" "libfoxenflac" "stb_vorbis")
|
||||
|
||||
target_compile_options("${COMPONENT_LIB}" PRIVATE ${EXTRA_WARNINGS})
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
#include "foxenflac.hpp"
|
||||
#include "mad.hpp"
|
||||
#include "stbvorbis.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
namespace codecs {
|
||||
@@ -17,6 +20,10 @@ auto CreateCodecForType(StreamType type) -> std::optional<ICodec*> {
|
||||
switch (type) {
|
||||
case StreamType::kMp3:
|
||||
return new MadMp3Decoder();
|
||||
case StreamType::kFlac:
|
||||
return new FoxenFlacDecoder();
|
||||
case StreamType::kVorbis:
|
||||
return new StbVorbisDecoder();
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#include "foxenflac.hpp"
|
||||
#include <stdint.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "foxen/flac.h"
|
||||
|
||||
namespace codecs {
|
||||
|
||||
FoxenFlacDecoder::FoxenFlacDecoder()
|
||||
: flac_(FX_FLAC_ALLOC(FLAC_MAX_BLOCK_SIZE, 2)) {}
|
||||
|
||||
FoxenFlacDecoder::~FoxenFlacDecoder() {
|
||||
free(flac_);
|
||||
}
|
||||
|
||||
auto FoxenFlacDecoder::BeginStream(const cpp::span<const std::byte> input)
|
||||
-> Result<OutputFormat> {
|
||||
uint32_t bytes_used = input.size_bytes();
|
||||
fx_flac_state_t state =
|
||||
fx_flac_process(flac_, reinterpret_cast<const uint8_t*>(input.data()),
|
||||
&bytes_used, NULL, NULL);
|
||||
if (state != FLAC_END_OF_METADATA) {
|
||||
return {bytes_used, cpp::fail(Error::kMalformedData)};
|
||||
}
|
||||
|
||||
int64_t channels = fx_flac_get_streaminfo(flac_, FLAC_KEY_N_CHANNELS);
|
||||
int64_t fs = fx_flac_get_streaminfo(flac_, FLAC_KEY_SAMPLE_RATE);
|
||||
if (channels == FLAC_INVALID_METADATA_KEY ||
|
||||
fs == FLAC_INVALID_METADATA_KEY) {
|
||||
return {bytes_used, cpp::fail(Error::kMalformedData)};
|
||||
}
|
||||
|
||||
return {bytes_used,
|
||||
OutputFormat{
|
||||
.num_channels = static_cast<uint8_t>(channels),
|
||||
.bits_per_sample = 32, // libfoxenflac output is fixed-size.
|
||||
.sample_rate_hz = static_cast<uint32_t>(fs),
|
||||
}};
|
||||
}
|
||||
|
||||
auto FoxenFlacDecoder::ContinueStream(cpp::span<const std::byte> input,
|
||||
cpp::span<std::byte> output)
|
||||
-> Result<OutputInfo> {
|
||||
cpp::span<int32_t> output_as_samples{
|
||||
reinterpret_cast<int32_t*>(output.data()), output.size_bytes() / 4};
|
||||
uint32_t bytes_read = input.size_bytes();
|
||||
uint32_t samples_written = output_as_samples.size();
|
||||
|
||||
fx_flac_state_t state =
|
||||
fx_flac_process(flac_, reinterpret_cast<const uint8_t*>(input.data()),
|
||||
&bytes_read, output_as_samples.data(), &samples_written);
|
||||
if (state == FLAC_ERR) {
|
||||
return {bytes_read, cpp::fail(Error::kMalformedData)};
|
||||
}
|
||||
|
||||
if (samples_written > 0) {
|
||||
return {bytes_read,
|
||||
OutputInfo{.bytes_written = samples_written * 4,
|
||||
.is_finished_writing = state == FLAC_END_OF_FRAME}};
|
||||
}
|
||||
|
||||
// No error, but no samples written. We must be out of data.
|
||||
return {bytes_read, cpp::fail(Error::kOutOfInput)};
|
||||
}
|
||||
|
||||
auto FoxenFlacDecoder::SeekStream(cpp::span<const std::byte> input,
|
||||
std::size_t target_sample) -> Result<void> {
|
||||
// TODO(jacqueline): Implement me.
|
||||
return {0, {}};
|
||||
}
|
||||
|
||||
} // namespace codecs
|
||||
@@ -21,48 +21,58 @@
|
||||
|
||||
namespace codecs {
|
||||
|
||||
/*
|
||||
* Common interface to be implemented by all audio decoders.
|
||||
*/
|
||||
class ICodec {
|
||||
public:
|
||||
virtual ~ICodec() {}
|
||||
|
||||
/* Errors that may be returned by codecs. */
|
||||
enum class Error {
|
||||
// Indicates that more data is required before this codec can finish its
|
||||
// operation. E.g. the input buffer ends with a truncated frame.
|
||||
kOutOfInput,
|
||||
// Indicates that the data within the input buffer is fatally malformed.
|
||||
kMalformedData,
|
||||
|
||||
kInternalError,
|
||||
};
|
||||
|
||||
/*
|
||||
* Alias for more readable return types. All codec methods, success or
|
||||
* failure, should also return the number of bytes they consumed.
|
||||
*/
|
||||
template <typename T>
|
||||
using Result = std::pair<std::size_t, cpp::result<T, Error>>;
|
||||
|
||||
struct OutputFormat {
|
||||
uint8_t num_channels;
|
||||
uint8_t bits_per_sample;
|
||||
uint32_t sample_rate_hz;
|
||||
};
|
||||
|
||||
virtual auto GetOutputFormat() -> std::optional<OutputFormat> = 0;
|
||||
|
||||
enum ProcessingError { MALFORMED_DATA };
|
||||
|
||||
virtual auto SetInput(cpp::span<const std::byte> input) -> void = 0;
|
||||
|
||||
/*
|
||||
* Returns the codec's next read position within the input buffer. If the
|
||||
* codec is out of usable data, but there is still some data left in the
|
||||
* stream, that data should be prepended to the next input buffer.
|
||||
* Decodes metadata or headers from the given input stream, and returns the
|
||||
* format for the samples that will be decoded from it.
|
||||
*/
|
||||
virtual auto GetInputPosition() -> std::size_t = 0;
|
||||
virtual auto BeginStream(cpp::span<const std::byte> input)
|
||||
-> Result<OutputFormat> = 0;
|
||||
|
||||
/*
|
||||
* Read one frame (or equivalent discrete chunk) from the input, and
|
||||
* synthesize output samples for it.
|
||||
*
|
||||
* Returns true if we are out of usable data from the input stream, or false
|
||||
* otherwise.
|
||||
*/
|
||||
virtual auto ProcessNextFrame() -> cpp::result<bool, ProcessingError> = 0;
|
||||
struct OutputInfo {
|
||||
std::size_t bytes_written;
|
||||
bool is_finished_writing;
|
||||
};
|
||||
|
||||
/*
|
||||
* Writes PCM samples to the given output buffer.
|
||||
*
|
||||
* Returns the number of bytes that were written, and true if all of the
|
||||
* samples synthesized from the last call to `ProcessNextFrame` have been
|
||||
* written. If this returns false, then this method should be called again
|
||||
* after flushing the output buffer.
|
||||
*/
|
||||
virtual auto WriteOutputSamples(cpp::span<std::byte> output)
|
||||
-> std::pair<std::size_t, bool> = 0;
|
||||
virtual auto ContinueStream(cpp::span<const std::byte> input,
|
||||
cpp::span<std::byte> output)
|
||||
-> Result<OutputInfo> = 0;
|
||||
|
||||
virtual auto SeekStream(cpp::span<const std::byte> input,
|
||||
std::size_t target_sample) -> Result<void> = 0;
|
||||
};
|
||||
|
||||
auto CreateCodecForType(StreamType type) -> std::optional<ICodec*>;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "foxen/flac.h"
|
||||
#include "span.hpp"
|
||||
|
||||
#include "codec.hpp"
|
||||
|
||||
namespace codecs {
|
||||
|
||||
class FoxenFlacDecoder : public ICodec {
|
||||
public:
|
||||
FoxenFlacDecoder();
|
||||
~FoxenFlacDecoder();
|
||||
|
||||
auto BeginStream(cpp::span<const std::byte>) -> Result<OutputFormat> override;
|
||||
auto ContinueStream(cpp::span<const std::byte>, cpp::span<std::byte>)
|
||||
-> Result<OutputInfo> override;
|
||||
auto SeekStream(cpp::span<const std::byte> input, std::size_t target_sample)
|
||||
-> Result<void> override;
|
||||
|
||||
private:
|
||||
fx_flac_t* flac_;
|
||||
};
|
||||
|
||||
} // namespace codecs
|
||||
@@ -24,12 +24,22 @@ class MadMp3Decoder : public ICodec {
|
||||
MadMp3Decoder();
|
||||
~MadMp3Decoder();
|
||||
|
||||
auto GetOutputFormat() -> std::optional<OutputFormat> override;
|
||||
auto SetInput(cpp::span<const std::byte> input) -> void override;
|
||||
auto GetInputPosition() -> std::size_t override;
|
||||
auto ProcessNextFrame() -> cpp::result<bool, ProcessingError> override;
|
||||
auto WriteOutputSamples(cpp::span<std::byte> output)
|
||||
-> std::pair<std::size_t, bool> override;
|
||||
/*
|
||||
* Returns the output format for the next frame in the stream. MP3 streams
|
||||
* may represent multiple distinct tracks, with different bitrates, and so we
|
||||
* handle the stream only on a frame-by-frame basis.
|
||||
*/
|
||||
auto BeginStream(cpp::span<const std::byte>) -> Result<OutputFormat> override;
|
||||
|
||||
/*
|
||||
* Writes samples for the current frame.
|
||||
*/
|
||||
auto ContinueStream(cpp::span<const std::byte> input,
|
||||
cpp::span<std::byte> output)
|
||||
-> Result<OutputInfo> override;
|
||||
|
||||
auto SeekStream(cpp::span<const std::byte> input, std::size_t target_sample)
|
||||
-> Result<void> override;
|
||||
|
||||
private:
|
||||
mad_stream stream_;
|
||||
@@ -37,6 +47,8 @@ class MadMp3Decoder : public ICodec {
|
||||
mad_synth synth_;
|
||||
|
||||
int current_sample_;
|
||||
|
||||
auto GetInputPosition() -> std::size_t;
|
||||
};
|
||||
|
||||
} // namespace codecs
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "stb_vorbis.h"
|
||||
|
||||
#include "codec.hpp"
|
||||
|
||||
namespace codecs {
|
||||
|
||||
class StbVorbisDecoder : public ICodec {
|
||||
public:
|
||||
StbVorbisDecoder();
|
||||
~StbVorbisDecoder();
|
||||
|
||||
auto BeginStream(cpp::span<const std::byte>) -> Result<OutputFormat> override;
|
||||
auto ContinueStream(cpp::span<const std::byte>, cpp::span<std::byte>)
|
||||
-> Result<OutputInfo> override;
|
||||
auto SeekStream(cpp::span<const std::byte> input, std::size_t target_sample)
|
||||
-> Result<void> override;
|
||||
|
||||
private:
|
||||
stb_vorbis* vorbis_;
|
||||
|
||||
int current_sample_;
|
||||
int num_channels_;
|
||||
int num_samples_;
|
||||
float** samples_array_;
|
||||
};
|
||||
|
||||
} // namespace codecs
|
||||
@@ -13,7 +13,7 @@ namespace codecs {
|
||||
enum class StreamType {
|
||||
kMp3,
|
||||
kPcm,
|
||||
kOgg,
|
||||
kVorbis,
|
||||
kFlac,
|
||||
};
|
||||
|
||||
|
||||
+128
-53
@@ -13,11 +13,12 @@
|
||||
#include "mad.h"
|
||||
|
||||
#include "codec.hpp"
|
||||
#include "result.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
namespace codecs {
|
||||
|
||||
static uint32_t scaleToBits(mad_fixed_t sample, uint8_t bits) {
|
||||
static uint32_t mad_fixed_to_pcm(mad_fixed_t sample, uint8_t bits) {
|
||||
// Round the bottom bits.
|
||||
sample += (1L << (MAD_F_FRACBITS - bits));
|
||||
|
||||
@@ -42,93 +43,167 @@ MadMp3Decoder::~MadMp3Decoder() {
|
||||
mad_synth_finish(&synth_);
|
||||
}
|
||||
|
||||
auto MadMp3Decoder::GetOutputFormat() -> std::optional<OutputFormat> {
|
||||
if (synth_.pcm.channels == 0 || synth_.pcm.samplerate == 0) {
|
||||
return {};
|
||||
}
|
||||
return std::optional<OutputFormat>({
|
||||
.num_channels = static_cast<uint8_t>(synth_.pcm.channels),
|
||||
.bits_per_sample = 24,
|
||||
.sample_rate_hz = synth_.pcm.samplerate,
|
||||
});
|
||||
}
|
||||
|
||||
auto MadMp3Decoder::SetInput(cpp::span<const std::byte> input) -> void {
|
||||
mad_stream_buffer(&stream_,
|
||||
reinterpret_cast<const unsigned char*>(input.data()),
|
||||
input.size());
|
||||
}
|
||||
|
||||
auto MadMp3Decoder::GetInputPosition() -> std::size_t {
|
||||
return stream_.next_frame - stream_.buffer;
|
||||
}
|
||||
|
||||
auto MadMp3Decoder::ProcessNextFrame() -> cpp::result<bool, ProcessingError> {
|
||||
auto MadMp3Decoder::BeginStream(const cpp::span<const std::byte> input)
|
||||
-> Result<OutputFormat> {
|
||||
mad_stream_buffer(&stream_,
|
||||
reinterpret_cast<const unsigned char*>(input.data()),
|
||||
input.size());
|
||||
// Whatever was last synthesized is now invalid, so ensure we don't try to
|
||||
// send it.
|
||||
current_sample_ = -1;
|
||||
|
||||
// Decode the next frame. To signal errors, this returns -1 and
|
||||
// stashes an error code in the stream structure.
|
||||
if (mad_frame_decode(&frame_, &stream_) < 0) {
|
||||
// To get the output format for MP3 streams, we simply need to decode the
|
||||
// first frame header.
|
||||
mad_header header;
|
||||
mad_header_init(&header);
|
||||
while (mad_header_decode(&header, &stream_) < 0) {
|
||||
if (MAD_RECOVERABLE(stream_.error)) {
|
||||
// Recoverable errors are usually malformed parts of the stream.
|
||||
// We can recover from them by just retrying the decode.
|
||||
return false;
|
||||
continue;
|
||||
} else {
|
||||
// Don't bother checking for other errors; if the first part of the stream
|
||||
// doesn't even contain a header then something's gone wrong.
|
||||
return {GetInputPosition(), cpp::fail(Error::kMalformedData)};
|
||||
}
|
||||
|
||||
if (stream_.error == MAD_ERROR_BUFLEN) {
|
||||
// The decoder ran out of bytes before it completed a frame. We
|
||||
// need to return back to the caller to give us more data.
|
||||
return true;
|
||||
}
|
||||
|
||||
// The error is unrecoverable. Give up.
|
||||
return cpp::fail(MALFORMED_DATA);
|
||||
}
|
||||
|
||||
// We've successfully decoded a frame!
|
||||
// Now we need to synthesize PCM samples based on the frame, and send
|
||||
// them downstream.
|
||||
mad_synth_frame(&synth_, &frame_);
|
||||
current_sample_ = 0;
|
||||
return false;
|
||||
uint8_t channels = MAD_NCHANNELS(&header);
|
||||
return {GetInputPosition(),
|
||||
OutputFormat{
|
||||
.num_channels = channels,
|
||||
.bits_per_sample = 24, // We always scale to 24 bits
|
||||
.sample_rate_hz = header.samplerate,
|
||||
}};
|
||||
}
|
||||
|
||||
auto MadMp3Decoder::WriteOutputSamples(cpp::span<std::byte> output)
|
||||
-> std::pair<std::size_t, bool> {
|
||||
size_t output_byte = 0;
|
||||
// First ensure that we actually have some samples to send off.
|
||||
auto MadMp3Decoder::ContinueStream(cpp::span<const std::byte> input,
|
||||
cpp::span<std::byte> output)
|
||||
-> Result<OutputInfo> {
|
||||
if (current_sample_ < 0) {
|
||||
return std::make_pair(output_byte, true);
|
||||
mad_stream_buffer(&stream_,
|
||||
reinterpret_cast<const unsigned char*>(input.data()),
|
||||
input.size());
|
||||
|
||||
// Decode the next frame. To signal errors, this returns -1 and
|
||||
// stashes an error code in the stream structure.
|
||||
while (mad_frame_decode(&frame_, &stream_) < 0) {
|
||||
if (MAD_RECOVERABLE(stream_.error)) {
|
||||
// Recoverable errors are usually malformed parts of the stream.
|
||||
// We can recover from them by just retrying the decode.
|
||||
continue;
|
||||
}
|
||||
if (stream_.error == MAD_ERROR_BUFLEN) {
|
||||
// The decoder ran out of bytes before it completed a frame. We
|
||||
// need to return back to the caller to give us more data.
|
||||
return {GetInputPosition(), cpp::fail(Error::kOutOfInput)};
|
||||
}
|
||||
// The error is unrecoverable. Give up.
|
||||
return {GetInputPosition(), cpp::fail(Error::kMalformedData)};
|
||||
}
|
||||
|
||||
// We've successfully decoded a frame! Now synthesize samples to write out.
|
||||
mad_synth_frame(&synth_, &frame_);
|
||||
current_sample_ = 0;
|
||||
}
|
||||
|
||||
size_t output_byte = 0;
|
||||
while (current_sample_ < synth_.pcm.length) {
|
||||
if (output_byte + (2 * synth_.pcm.channels) >= output.size()) {
|
||||
return std::make_pair(output_byte, false);
|
||||
if (output_byte + (4 * synth_.pcm.channels) >= output.size()) {
|
||||
// We can't fit the next sample into the buffer. Stop now, and also avoid
|
||||
// writing the sample for only half the channels.
|
||||
return {GetInputPosition(), OutputInfo{.bytes_written = output_byte,
|
||||
.is_finished_writing = false}};
|
||||
}
|
||||
|
||||
for (int channel = 0; channel < synth_.pcm.channels; channel++) {
|
||||
uint32_t sample_24 =
|
||||
scaleToBits(synth_.pcm.samples[channel][current_sample_], 24);
|
||||
mad_fixed_to_pcm(synth_.pcm.samples[channel][current_sample_], 24);
|
||||
output[output_byte++] = static_cast<std::byte>((sample_24 >> 16) & 0xFF);
|
||||
output[output_byte++] = static_cast<std::byte>((sample_24 >> 8) & 0xFF);
|
||||
output[output_byte++] = static_cast<std::byte>((sample_24)&0xFF);
|
||||
// 24 bit samples must still be aligned to 32 bits. The LSB is ignored.
|
||||
output[output_byte++] = static_cast<std::byte>(0);
|
||||
/*
|
||||
uint16_t sample_16 =
|
||||
scaleToBits(synth_.pcm.samples[channel][current_sample_], 16);
|
||||
output[output_byte++] = static_cast<std::byte>((sample_16 >> 8) & 0xFF);
|
||||
output[output_byte++] = static_cast<std::byte>((sample_16)&0xFF);
|
||||
*/
|
||||
}
|
||||
current_sample_++;
|
||||
}
|
||||
|
||||
// We wrote everything! Reset, ready for the next frame.
|
||||
current_sample_ = -1;
|
||||
return std::make_pair(output_byte, true);
|
||||
return {GetInputPosition(), OutputInfo{.bytes_written = output_byte,
|
||||
.is_finished_writing = true}};
|
||||
}
|
||||
|
||||
auto MadMp3Decoder::SeekStream(cpp::span<const std::byte> input,
|
||||
std::size_t target_sample) -> Result<void> {
|
||||
mad_stream_buffer(&stream_,
|
||||
reinterpret_cast<const unsigned char*>(input.data()),
|
||||
input.size());
|
||||
std::size_t current_sample = 0;
|
||||
std::size_t samples_per_frame = 0;
|
||||
while (true) {
|
||||
current_sample += samples_per_frame;
|
||||
|
||||
// First, decode the header for this frame.
|
||||
mad_header header;
|
||||
mad_header_init(&header);
|
||||
while (mad_header_decode(&header, &stream_) < 0) {
|
||||
if (MAD_RECOVERABLE(stream_.error)) {
|
||||
// Recoverable errors are usually malformed parts of the stream.
|
||||
// We can recover from them by just retrying the decode.
|
||||
continue;
|
||||
} else {
|
||||
// Don't bother checking for other errors; if the first part of the
|
||||
// stream doesn't even contain a header then something's gone wrong.
|
||||
return {GetInputPosition(), cpp::fail(Error::kMalformedData)};
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate samples per frame if we haven't already.
|
||||
if (samples_per_frame == 0) {
|
||||
samples_per_frame = 32 * MAD_NSBSAMPLES(&header);
|
||||
}
|
||||
|
||||
// Work out how close we are to the target.
|
||||
std::size_t samples_to_go = target_sample - current_sample;
|
||||
std::size_t frames_to_go = samples_to_go / samples_per_frame;
|
||||
if (frames_to_go > 3) {
|
||||
// The target is far in the distance. Keep skipping through headers only.
|
||||
continue;
|
||||
}
|
||||
|
||||
// The target is within the next few frames. We should decode these, to give
|
||||
// the decoder a chance to sync with the stream.
|
||||
while (mad_frame_decode(&frame_, &stream_) < 0) {
|
||||
if (MAD_RECOVERABLE(stream_.error)) {
|
||||
continue;
|
||||
}
|
||||
if (stream_.error == MAD_ERROR_BUFLEN) {
|
||||
return {GetInputPosition(), cpp::fail(Error::kOutOfInput)};
|
||||
}
|
||||
// The error is unrecoverable. Give up.
|
||||
return {GetInputPosition(), cpp::fail(Error::kMalformedData)};
|
||||
}
|
||||
|
||||
if (frames_to_go <= 1) {
|
||||
// The target is within the next couple of frames. We should start
|
||||
// synthesizing a frame early because this guy says so:
|
||||
// https://lists.mars.org/hyperkitty/list/mad-dev@lists.mars.org/message/UZSHXZTIZEF7FZ4KFOR65DUCKAY2OCUT/
|
||||
mad_synth_frame(&synth_, &frame_);
|
||||
}
|
||||
|
||||
if (frames_to_go == 0) {
|
||||
// The target is actually within this frame! Set up for the ContinueStream
|
||||
// call.
|
||||
current_sample_ =
|
||||
(target_sample > current_sample) ? target_sample - current_sample : 0;
|
||||
return {GetInputPosition(), {}};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace codecs
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#include "stbvorbis.hpp"
|
||||
#include <stdint.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
#include "stb_vorbis.h"
|
||||
|
||||
namespace codecs {
|
||||
|
||||
StbVorbisDecoder::StbVorbisDecoder()
|
||||
: vorbis_(nullptr),
|
||||
current_sample_(-1),
|
||||
num_channels_(0),
|
||||
num_samples_(0),
|
||||
samples_array_(NULL) {}
|
||||
|
||||
StbVorbisDecoder::~StbVorbisDecoder() {
|
||||
if (vorbis_ != nullptr) {
|
||||
stb_vorbis_close(vorbis_);
|
||||
}
|
||||
}
|
||||
|
||||
static uint32_t scaleToBits(float sample, uint8_t bits) {
|
||||
// Scale to range.
|
||||
int32_t max_val = (1 << (bits - 1));
|
||||
int32_t fixed_point = sample * max_val;
|
||||
|
||||
// Clamp within bounds.
|
||||
fixed_point = std::clamp(fixed_point, -max_val, max_val);
|
||||
|
||||
// Remove sign.
|
||||
return *reinterpret_cast<uint32_t*>(&fixed_point);
|
||||
}
|
||||
|
||||
auto StbVorbisDecoder::BeginStream(const cpp::span<const std::byte> input)
|
||||
-> Result<OutputFormat> {
|
||||
if (vorbis_ != nullptr) {
|
||||
stb_vorbis_close(vorbis_);
|
||||
vorbis_ = nullptr;
|
||||
}
|
||||
current_sample_ = -1;
|
||||
int bytes_read = 0;
|
||||
int error = 0;
|
||||
vorbis_ =
|
||||
stb_vorbis_open_pushdata(reinterpret_cast<const uint8_t*>(input.data()),
|
||||
input.size_bytes(), &bytes_read, &error, NULL);
|
||||
if (error != 0) {
|
||||
return {0, cpp::fail(Error::kMalformedData)};
|
||||
}
|
||||
stb_vorbis_info info = stb_vorbis_get_info(vorbis_);
|
||||
return {bytes_read,
|
||||
OutputFormat{.num_channels = static_cast<uint8_t>(info.channels),
|
||||
.bits_per_sample = 24,
|
||||
.sample_rate_hz = info.sample_rate}};
|
||||
}
|
||||
|
||||
auto StbVorbisDecoder::ContinueStream(cpp::span<const std::byte> input,
|
||||
cpp::span<std::byte> output)
|
||||
-> Result<OutputInfo> {
|
||||
std::size_t bytes_used = 0;
|
||||
if (current_sample_ < 0) {
|
||||
num_channels_ = 0;
|
||||
num_samples_ = 0;
|
||||
samples_array_ = NULL;
|
||||
|
||||
while (true) {
|
||||
auto cropped = input.subspan(bytes_used);
|
||||
std::size_t b = stb_vorbis_decode_frame_pushdata(
|
||||
vorbis_, reinterpret_cast<const uint8_t*>(cropped.data()),
|
||||
cropped.size_bytes(), &num_channels_, &samples_array_, &num_samples_);
|
||||
if (b == 0) {
|
||||
return {bytes_used, cpp::fail(Error::kOutOfInput)};
|
||||
}
|
||||
bytes_used += b;
|
||||
|
||||
if (num_samples_ == 0) {
|
||||
// Decoder is synchronising. Decode more bytes.
|
||||
continue;
|
||||
}
|
||||
if (num_channels_ == 0 || samples_array_ == NULL) {
|
||||
// The decoder isn't satisfying its contract.
|
||||
return {bytes_used, cpp::fail(Error::kInternalError)};
|
||||
}
|
||||
current_sample_ = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// We successfully decoded a frame. Time to write out the samples.
|
||||
std::size_t output_byte = 0;
|
||||
while (current_sample_ < num_samples_) {
|
||||
if (output_byte + (2 * num_channels_) >= output.size()) {
|
||||
return {0, OutputInfo{.bytes_written = output_byte,
|
||||
.is_finished_writing = false}};
|
||||
}
|
||||
|
||||
for (int channel = 0; channel < num_channels_; channel++) {
|
||||
float raw_sample = samples_array_[channel][current_sample_];
|
||||
|
||||
uint16_t sample_24 = scaleToBits(raw_sample, 24);
|
||||
output[output_byte++] = static_cast<std::byte>((sample_24 >> 16) & 0xFF);
|
||||
output[output_byte++] = static_cast<std::byte>((sample_24 >> 8) & 0xFF);
|
||||
output[output_byte++] = static_cast<std::byte>((sample_24)&0xFF);
|
||||
// Pad to 32 bits for alignment.
|
||||
output[output_byte++] = static_cast<std::byte>(0);
|
||||
}
|
||||
current_sample_++;
|
||||
}
|
||||
|
||||
current_sample_ = -1;
|
||||
return {bytes_used, OutputInfo{.bytes_written = output_byte,
|
||||
.is_finished_writing = true}};
|
||||
}
|
||||
|
||||
auto StbVorbisDecoder::SeekStream(cpp::span<const std::byte> input,
|
||||
std::size_t target_sample) -> Result<void> {
|
||||
// TODO(jacqueline): Implement me.
|
||||
return {0, {}};
|
||||
}
|
||||
|
||||
} // namespace codecs
|
||||
@@ -3,7 +3,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
idf_component_register(
|
||||
SRCS "env_esp.cpp" "database.cpp" "song.cpp" "records.cpp" "file_gatherer.cpp" "tag_parser.cpp"
|
||||
SRCS "env_esp.cpp" "database.cpp" "track.cpp" "records.cpp" "file_gatherer.cpp" "tag_parser.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES "result" "span" "esp_psram" "fatfs" "libtags" "komihash" "cbor" "tasks")
|
||||
|
||||
|
||||
+82
-68
@@ -17,6 +17,7 @@
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "ff.h"
|
||||
#include "freertos/projdefs.h"
|
||||
#include "leveldb/cache.h"
|
||||
#include "leveldb/db.h"
|
||||
#include "leveldb/iterator.h"
|
||||
@@ -28,16 +29,16 @@
|
||||
#include "file_gatherer.hpp"
|
||||
#include "records.hpp"
|
||||
#include "result.hpp"
|
||||
#include "song.hpp"
|
||||
#include "tag_parser.hpp"
|
||||
#include "tasks.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
namespace database {
|
||||
|
||||
static SingletonEnv<leveldb::EspEnv> sEnv;
|
||||
static const char* kTag = "DB";
|
||||
|
||||
static const char kSongIdKey[] = "next_song_id";
|
||||
static const char kTrackIdKey[] = "next_track_id";
|
||||
|
||||
static std::atomic<bool> sIsDbOpen(false);
|
||||
|
||||
@@ -68,12 +69,13 @@ auto Database::Open(IFileGatherer* gatherer, ITagParser* parser)
|
||||
return cpp::fail(DatabaseError::ALREADY_OPEN);
|
||||
}
|
||||
|
||||
leveldb::sBackgroundThread.reset(
|
||||
tasks::Worker::Start<tasks::Type::kDatabaseBackground>());
|
||||
std::shared_ptr<tasks::Worker> worker(
|
||||
tasks::Worker::Start<tasks::Type::kDatabase>());
|
||||
leveldb::sBackgroundThread = std::weak_ptr<tasks::Worker>(worker);
|
||||
return worker
|
||||
->Dispatch<cpp::result<Database*, DatabaseError>>(
|
||||
[&]() -> cpp::result<Database*, DatabaseError> {
|
||||
[=]() -> cpp::result<Database*, DatabaseError> {
|
||||
leveldb::DB* db;
|
||||
leveldb::Cache* cache = leveldb::NewLRUCache(24 * 1024);
|
||||
leveldb::Options options;
|
||||
@@ -121,15 +123,15 @@ Database::~Database() {
|
||||
delete db_;
|
||||
delete cache_;
|
||||
|
||||
leveldb::sBackgroundThread = std::weak_ptr<tasks::Worker>();
|
||||
leveldb::sBackgroundThread.reset();
|
||||
|
||||
sIsDbOpen.store(false);
|
||||
}
|
||||
|
||||
auto Database::Update() -> std::future<void> {
|
||||
return worker_task_->Dispatch<void>([&]() -> void {
|
||||
// Stage 1: verify all existing songs are still valid.
|
||||
ESP_LOGI(kTag, "verifying existing songs");
|
||||
// Stage 1: verify all existing tracks are still valid.
|
||||
ESP_LOGI(kTag, "verifying existing tracks");
|
||||
const leveldb::Snapshot* snapshot = db_->GetSnapshot();
|
||||
leveldb::ReadOptions read_options;
|
||||
read_options.fill_cache = false;
|
||||
@@ -138,8 +140,8 @@ auto Database::Update() -> std::future<void> {
|
||||
OwningSlice prefix = CreateDataPrefix();
|
||||
it->Seek(prefix.slice);
|
||||
while (it->Valid() && it->key().starts_with(prefix.slice)) {
|
||||
std::optional<SongData> song = ParseDataValue(it->value());
|
||||
if (!song) {
|
||||
std::optional<TrackData> track = ParseDataValue(it->value());
|
||||
if (!track) {
|
||||
// The value was malformed. Drop this record.
|
||||
ESP_LOGW(kTag, "dropping malformed metadata");
|
||||
db_->Delete(leveldb::WriteOptions(), it->key());
|
||||
@@ -147,33 +149,33 @@ auto Database::Update() -> std::future<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (song->is_tombstoned()) {
|
||||
ESP_LOGW(kTag, "skipping tombstoned %lx", song->id());
|
||||
if (track->is_tombstoned()) {
|
||||
ESP_LOGW(kTag, "skipping tombstoned %lx", track->id());
|
||||
it->Next();
|
||||
continue;
|
||||
}
|
||||
|
||||
SongTags tags;
|
||||
if (!tag_parser_->ReadAndParseTags(song->filepath(), &tags) ||
|
||||
TrackTags tags;
|
||||
if (!tag_parser_->ReadAndParseTags(track->filepath(), &tags) ||
|
||||
tags.encoding == Encoding::kUnsupported) {
|
||||
// We couldn't read the tags for this song. Either they were
|
||||
// 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", song->id());
|
||||
dbPutSongData(song->Entomb());
|
||||
ESP_LOGW(kTag, "entombing missing #%lx", track->id());
|
||||
dbPutTrackData(track->Entomb());
|
||||
it->Next();
|
||||
continue;
|
||||
}
|
||||
|
||||
uint64_t new_hash = tags.Hash();
|
||||
if (new_hash != song->tags_hash()) {
|
||||
// This song's tags have changed. Since the filepath is exactly the
|
||||
if (new_hash != track->tags_hash()) {
|
||||
// This track's tags have changed. Since the filepath is exactly the
|
||||
// same, we assume this is a legitimate correction. Update the
|
||||
// database.
|
||||
ESP_LOGI(kTag, "updating hash (%llx -> %llx)", song->tags_hash(),
|
||||
ESP_LOGI(kTag, "updating hash (%llx -> %llx)", track->tags_hash(),
|
||||
new_hash);
|
||||
dbPutSongData(song->UpdateHash(new_hash));
|
||||
dbPutHash(new_hash, song->id());
|
||||
dbPutTrackData(track->UpdateHash(new_hash));
|
||||
dbPutHash(new_hash, track->id());
|
||||
}
|
||||
|
||||
it->Next();
|
||||
@@ -182,9 +184,9 @@ auto Database::Update() -> std::future<void> {
|
||||
db_->ReleaseSnapshot(snapshot);
|
||||
|
||||
// Stage 2: search for newly added files.
|
||||
ESP_LOGI(kTag, "scanning for new songs");
|
||||
ESP_LOGI(kTag, "scanning for new tracks");
|
||||
file_gatherer_->FindFiles("", [&](const std::string& path) {
|
||||
SongTags tags;
|
||||
TrackTags tags;
|
||||
if (!tag_parser_->ReadAndParseTags(path, &tags) ||
|
||||
tags.encoding == Encoding::kUnsupported) {
|
||||
// No parseable tags; skip this fiile.
|
||||
@@ -194,32 +196,32 @@ auto Database::Update() -> std::future<void> {
|
||||
// Check for any existing record with the same hash.
|
||||
uint64_t hash = tags.Hash();
|
||||
OwningSlice key = CreateHashKey(hash);
|
||||
std::optional<SongId> existing_hash;
|
||||
std::optional<TrackId> existing_hash;
|
||||
std::string raw_entry;
|
||||
if (db_->Get(leveldb::ReadOptions(), key.slice, &raw_entry).ok()) {
|
||||
existing_hash = ParseHashValue(raw_entry);
|
||||
}
|
||||
|
||||
if (!existing_hash) {
|
||||
// We've never met this song before! Or we have, but the entry is
|
||||
// malformed. Either way, record this as a new song.
|
||||
SongId id = dbMintNewSongId();
|
||||
// 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_LOGI(kTag, "recording new 0x%lx", id);
|
||||
dbPutSong(id, path, hash);
|
||||
dbPutTrack(id, path, hash);
|
||||
return;
|
||||
}
|
||||
|
||||
std::optional<SongData> existing_data = dbGetSongData(*existing_hash);
|
||||
std::optional<TrackData> existing_data = dbGetTrackData(*existing_hash);
|
||||
if (!existing_data) {
|
||||
// We found a hash that matches, but there's no data record? Weird.
|
||||
SongData new_data(*existing_hash, path, hash);
|
||||
dbPutSongData(new_data);
|
||||
TrackData new_data(*existing_hash, path, hash);
|
||||
dbPutTrackData(new_data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing_data->is_tombstoned()) {
|
||||
ESP_LOGI(kTag, "exhuming song %lu", existing_data->id());
|
||||
dbPutSongData(existing_data->Exhume(path));
|
||||
ESP_LOGI(kTag, "exhuming track %lu", existing_data->id());
|
||||
dbPutTrackData(existing_data->Exhume(path));
|
||||
} else if (existing_data->filepath() != path) {
|
||||
ESP_LOGW(kTag, "tag hash collision");
|
||||
}
|
||||
@@ -227,14 +229,26 @@ auto Database::Update() -> std::future<void> {
|
||||
});
|
||||
}
|
||||
|
||||
auto Database::GetSongs(std::size_t page_size) -> std::future<Result<Song>*> {
|
||||
return worker_task_->Dispatch<Result<Song>*>([=, this]() -> Result<Song>* {
|
||||
Continuation<Song> c{.iterator = nullptr,
|
||||
.prefix = CreateDataPrefix().data,
|
||||
.start_key = CreateDataPrefix().data,
|
||||
.forward = true,
|
||||
.was_prev_forward = true,
|
||||
.page_size = page_size};
|
||||
auto Database::GetTrackPath(TrackId id)
|
||||
-> std::future<std::optional<std::string>> {
|
||||
return worker_task_->Dispatch<std::optional<std::string>>(
|
||||
[=, this]() -> std::optional<std::string> {
|
||||
auto track_data = dbGetTrackData(id);
|
||||
if (track_data) {
|
||||
return track_data->filepath();
|
||||
}
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
auto Database::GetTracks(std::size_t page_size) -> std::future<Result<Track>*> {
|
||||
return worker_task_->Dispatch<Result<Track>*>([=, this]() -> Result<Track>* {
|
||||
Continuation<Track> c{.iterator = nullptr,
|
||||
.prefix = CreateDataPrefix().data,
|
||||
.start_key = CreateDataPrefix().data,
|
||||
.forward = true,
|
||||
.was_prev_forward = true,
|
||||
.page_size = page_size};
|
||||
return dbGetPage(c);
|
||||
});
|
||||
}
|
||||
@@ -260,32 +274,32 @@ auto Database::GetPage(Continuation<T>* c) -> std::future<Result<T>*> {
|
||||
[=, this]() -> Result<T>* { return dbGetPage(copy); });
|
||||
}
|
||||
|
||||
template auto Database::GetPage<Song>(Continuation<Song>* c)
|
||||
-> std::future<Result<Song>*>;
|
||||
template auto Database::GetPage<Track>(Continuation<Track>* c)
|
||||
-> std::future<Result<Track>*>;
|
||||
template auto Database::GetPage<std::string>(Continuation<std::string>* c)
|
||||
-> std::future<Result<std::string>*>;
|
||||
|
||||
auto Database::dbMintNewSongId() -> SongId {
|
||||
SongId next_id = 1;
|
||||
auto Database::dbMintNewTrackId() -> TrackId {
|
||||
TrackId next_id = 1;
|
||||
std::string val;
|
||||
auto status = db_->Get(leveldb::ReadOptions(), kSongIdKey, &val);
|
||||
auto status = db_->Get(leveldb::ReadOptions(), kTrackIdKey, &val);
|
||||
if (status.ok()) {
|
||||
next_id = BytesToSongId(val).value_or(next_id);
|
||||
next_id = BytesToTrackId(val).value_or(next_id);
|
||||
} else if (!status.IsNotFound()) {
|
||||
// TODO(jacqueline): Handle this more.
|
||||
ESP_LOGE(kTag, "failed to get next song id");
|
||||
ESP_LOGE(kTag, "failed to get next track id");
|
||||
}
|
||||
|
||||
if (!db_->Put(leveldb::WriteOptions(), kSongIdKey,
|
||||
SongIdToBytes(next_id + 1).slice)
|
||||
if (!db_->Put(leveldb::WriteOptions(), kTrackIdKey,
|
||||
TrackIdToBytes(next_id + 1).slice)
|
||||
.ok()) {
|
||||
ESP_LOGE(kTag, "failed to write next song id");
|
||||
ESP_LOGE(kTag, "failed to write next track id");
|
||||
}
|
||||
|
||||
return next_id;
|
||||
}
|
||||
|
||||
auto Database::dbEntomb(SongId id, uint64_t hash) -> void {
|
||||
auto Database::dbEntomb(TrackId id, uint64_t hash) -> void {
|
||||
OwningSlice key = CreateHashKey(hash);
|
||||
OwningSlice val = CreateHashValue(id);
|
||||
if (!db_->Put(leveldb::WriteOptions(), key.slice, val.slice).ok()) {
|
||||
@@ -293,7 +307,7 @@ auto Database::dbEntomb(SongId id, uint64_t hash) -> void {
|
||||
}
|
||||
}
|
||||
|
||||
auto Database::dbPutSongData(const SongData& s) -> void {
|
||||
auto Database::dbPutTrackData(const TrackData& s) -> void {
|
||||
OwningSlice key = CreateDataKey(s.id());
|
||||
OwningSlice val = CreateDataValue(s);
|
||||
if (!db_->Put(leveldb::WriteOptions(), key.slice, val.slice).ok()) {
|
||||
@@ -301,7 +315,7 @@ auto Database::dbPutSongData(const SongData& s) -> void {
|
||||
}
|
||||
}
|
||||
|
||||
auto Database::dbGetSongData(SongId id) -> std::optional<SongData> {
|
||||
auto Database::dbGetTrackData(TrackId id) -> std::optional<TrackData> {
|
||||
OwningSlice key = CreateDataKey(id);
|
||||
std::string raw_val;
|
||||
if (!db_->Get(leveldb::ReadOptions(), key.slice, &raw_val).ok()) {
|
||||
@@ -311,7 +325,7 @@ auto Database::dbGetSongData(SongId id) -> std::optional<SongData> {
|
||||
return ParseDataValue(raw_val);
|
||||
}
|
||||
|
||||
auto Database::dbPutHash(const uint64_t& hash, SongId i) -> void {
|
||||
auto Database::dbPutHash(const uint64_t& hash, TrackId i) -> void {
|
||||
OwningSlice key = CreateHashKey(hash);
|
||||
OwningSlice val = CreateHashValue(i);
|
||||
if (!db_->Put(leveldb::WriteOptions(), key.slice, val.slice).ok()) {
|
||||
@@ -319,7 +333,7 @@ auto Database::dbPutHash(const uint64_t& hash, SongId i) -> void {
|
||||
}
|
||||
}
|
||||
|
||||
auto Database::dbGetHash(const uint64_t& hash) -> std::optional<SongId> {
|
||||
auto Database::dbGetHash(const uint64_t& hash) -> std::optional<TrackId> {
|
||||
OwningSlice key = CreateHashKey(hash);
|
||||
std::string raw_val;
|
||||
if (!db_->Get(leveldb::ReadOptions(), key.slice, &raw_val).ok()) {
|
||||
@@ -329,10 +343,10 @@ auto Database::dbGetHash(const uint64_t& hash) -> std::optional<SongId> {
|
||||
return ParseHashValue(raw_val);
|
||||
}
|
||||
|
||||
auto Database::dbPutSong(SongId id,
|
||||
const std::string& path,
|
||||
const uint64_t& hash) -> void {
|
||||
dbPutSongData(SongData(id, path, hash));
|
||||
auto Database::dbPutTrack(TrackId id,
|
||||
const std::string& path,
|
||||
const uint64_t& hash) -> void {
|
||||
dbPutTrackData(TrackData(id, path, hash));
|
||||
dbPutHash(hash, id);
|
||||
}
|
||||
|
||||
@@ -455,24 +469,24 @@ auto Database::dbGetPage(const Continuation<T>& c) -> Result<T>* {
|
||||
return new Result<T>(std::move(records), next_page, prev_page);
|
||||
}
|
||||
|
||||
template auto Database::dbGetPage<Song>(const Continuation<Song>& c)
|
||||
-> Result<Song>*;
|
||||
template auto Database::dbGetPage<Track>(const Continuation<Track>& c)
|
||||
-> Result<Track>*;
|
||||
template auto Database::dbGetPage<std::string>(
|
||||
const Continuation<std::string>& c) -> Result<std::string>*;
|
||||
|
||||
template <>
|
||||
auto Database::ParseRecord<Song>(const leveldb::Slice& key,
|
||||
const leveldb::Slice& val)
|
||||
-> std::optional<Song> {
|
||||
std::optional<SongData> data = ParseDataValue(val);
|
||||
auto Database::ParseRecord<Track>(const leveldb::Slice& key,
|
||||
const leveldb::Slice& val)
|
||||
-> std::optional<Track> {
|
||||
std::optional<TrackData> data = ParseDataValue(val);
|
||||
if (!data || data->is_tombstoned()) {
|
||||
return {};
|
||||
}
|
||||
SongTags tags;
|
||||
TrackTags tags;
|
||||
if (!tag_parser_->ReadAndParseTags(data->filepath(), &tags)) {
|
||||
return {};
|
||||
}
|
||||
return Song(*data, tags);
|
||||
return Track(*data, tags);
|
||||
}
|
||||
|
||||
template <>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <set>
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
namespace leveldb {
|
||||
|
||||
std::weak_ptr<tasks::Worker> sBackgroundThread;
|
||||
std::shared_ptr<tasks::Worker> sBackgroundThread;
|
||||
|
||||
std::string ErrToStr(FRESULT err) {
|
||||
switch (err) {
|
||||
@@ -463,7 +464,7 @@ EspEnv::EspEnv() {}
|
||||
void EspEnv::Schedule(
|
||||
void (*background_work_function)(void* background_work_arg),
|
||||
void* background_work_arg) {
|
||||
auto worker = sBackgroundThread.lock();
|
||||
auto worker = sBackgroundThread;
|
||||
if (worker) {
|
||||
worker->Dispatch<void>(
|
||||
[=]() { std::invoke(background_work_function, background_work_arg); });
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
#include "leveldb/slice.h"
|
||||
#include "records.hpp"
|
||||
#include "result.hpp"
|
||||
#include "song.hpp"
|
||||
#include "tag_parser.hpp"
|
||||
#include "tasks.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
namespace database {
|
||||
|
||||
@@ -82,7 +82,9 @@ class Database {
|
||||
|
||||
auto Update() -> std::future<void>;
|
||||
|
||||
auto GetSongs(std::size_t page_size) -> std::future<Result<Song>*>;
|
||||
auto GetTrackPath(TrackId id) -> std::future<std::optional<std::string>>;
|
||||
|
||||
auto GetTracks(std::size_t page_size) -> std::future<Result<Track>*>;
|
||||
auto GetDump(std::size_t page_size) -> std::future<Result<std::string>*>;
|
||||
|
||||
template <typename T>
|
||||
@@ -109,14 +111,14 @@ class Database {
|
||||
ITagParser* tag_parser,
|
||||
std::shared_ptr<tasks::Worker> worker);
|
||||
|
||||
auto dbMintNewSongId() -> SongId;
|
||||
auto dbEntomb(SongId song, uint64_t hash) -> void;
|
||||
auto dbMintNewTrackId() -> TrackId;
|
||||
auto dbEntomb(TrackId track, uint64_t hash) -> void;
|
||||
|
||||
auto dbPutSongData(const SongData& s) -> void;
|
||||
auto dbGetSongData(SongId id) -> std::optional<SongData>;
|
||||
auto dbPutHash(const uint64_t& hash, SongId i) -> void;
|
||||
auto dbGetHash(const uint64_t& hash) -> std::optional<SongId>;
|
||||
auto dbPutSong(SongId id, const std::string& path, const uint64_t& hash)
|
||||
auto dbPutTrackData(const TrackData& s) -> void;
|
||||
auto dbGetTrackData(TrackId id) -> std::optional<TrackData>;
|
||||
auto dbPutHash(const uint64_t& hash, TrackId i) -> void;
|
||||
auto dbGetHash(const uint64_t& hash) -> std::optional<TrackId>;
|
||||
auto dbPutTrack(TrackId id, const std::string& path, const uint64_t& hash)
|
||||
-> void;
|
||||
|
||||
template <typename T>
|
||||
@@ -128,9 +130,9 @@ class Database {
|
||||
};
|
||||
|
||||
template <>
|
||||
auto Database::ParseRecord<Song>(const leveldb::Slice& key,
|
||||
const leveldb::Slice& val)
|
||||
-> std::optional<Song>;
|
||||
auto Database::ParseRecord<Track>(const leveldb::Slice& key,
|
||||
const leveldb::Slice& val)
|
||||
-> std::optional<Track>;
|
||||
template <>
|
||||
auto Database::ParseRecord<std::string>(const leveldb::Slice& key,
|
||||
const leveldb::Slice& val)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
namespace leveldb {
|
||||
|
||||
extern std::weak_ptr<tasks::Worker> sBackgroundThread;
|
||||
extern std::shared_ptr<tasks::Worker> sBackgroundThread;
|
||||
|
||||
// Tracks the files locked by EspEnv::LockFile().
|
||||
//
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#include "leveldb/db.h"
|
||||
#include "leveldb/slice.h"
|
||||
|
||||
#include "song.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
namespace database {
|
||||
|
||||
@@ -31,49 +31,49 @@ class OwningSlice {
|
||||
};
|
||||
|
||||
/*
|
||||
* Returns the prefix added to every SongData key. This can be used to iterate
|
||||
* Returns the prefix added to every TrackData key. This can be used to iterate
|
||||
* over every data record in the database.
|
||||
*/
|
||||
auto CreateDataPrefix() -> OwningSlice;
|
||||
|
||||
/* Creates a data key for a song with the specified id. */
|
||||
auto CreateDataKey(const SongId& id) -> OwningSlice;
|
||||
/* Creates a data key for a track with the specified id. */
|
||||
auto CreateDataKey(const TrackId& id) -> OwningSlice;
|
||||
|
||||
/*
|
||||
* Encodes a SongData instance into bytes, in preparation for storing it within
|
||||
* Encodes a TrackData instance into bytes, in preparation for storing it within
|
||||
* the database. This encoding is consistent, and will remain stable over time.
|
||||
*/
|
||||
auto CreateDataValue(const SongData& song) -> OwningSlice;
|
||||
auto CreateDataValue(const TrackData& track) -> OwningSlice;
|
||||
|
||||
/*
|
||||
* Parses bytes previously encoded via CreateDataValue back into a SongData. May
|
||||
* return nullopt if parsing fails.
|
||||
* Parses bytes previously encoded via CreateDataValue back into a TrackData.
|
||||
* May return nullopt if parsing fails.
|
||||
*/
|
||||
auto ParseDataValue(const leveldb::Slice& slice) -> std::optional<SongData>;
|
||||
auto ParseDataValue(const leveldb::Slice& slice) -> std::optional<TrackData>;
|
||||
|
||||
/* Creates a hash key for the specified hash. */
|
||||
auto CreateHashKey(const uint64_t& hash) -> OwningSlice;
|
||||
|
||||
/*
|
||||
* Encodes a hash value (at this point just a song id) into bytes, in
|
||||
* Encodes a hash value (at this point just a track id) into bytes, in
|
||||
* preparation for storing within the database. This encoding is consistent, and
|
||||
* will remain stable over time.
|
||||
*/
|
||||
auto CreateHashValue(SongId id) -> OwningSlice;
|
||||
auto CreateHashValue(TrackId id) -> OwningSlice;
|
||||
|
||||
/*
|
||||
* Parses bytes previously encoded via CreateHashValue back into a song id. May
|
||||
* Parses bytes previously encoded via CreateHashValue back into a track id. May
|
||||
* return nullopt if parsing fails.
|
||||
*/
|
||||
auto ParseHashValue(const leveldb::Slice&) -> std::optional<SongId>;
|
||||
auto ParseHashValue(const leveldb::Slice&) -> std::optional<TrackId>;
|
||||
|
||||
/* Encodes a SongId as bytes. */
|
||||
auto SongIdToBytes(SongId id) -> OwningSlice;
|
||||
/* Encodes a TrackId as bytes. */
|
||||
auto TrackIdToBytes(TrackId id) -> OwningSlice;
|
||||
|
||||
/*
|
||||
* Converts a song id encoded via SongIdToBytes back into a SongId. May return
|
||||
* nullopt if parsing fails.
|
||||
* Converts a track id encoded via TrackIdToBytes back into a TrackId. May
|
||||
* return nullopt if parsing fails.
|
||||
*/
|
||||
auto BytesToSongId(const std::string& bytes) -> std::optional<SongId>;
|
||||
auto BytesToTrackId(const std::string& bytes) -> std::optional<TrackId>;
|
||||
|
||||
} // namespace database
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "leveldb/db.h"
|
||||
#include "span.hpp"
|
||||
|
||||
namespace database {
|
||||
|
||||
/*
|
||||
* Uniquely describes a single song within the database. This value will be
|
||||
* consistent across database updates, and should ideally (but is not guaranteed
|
||||
* to) endure even across a song being removed and re-added.
|
||||
*
|
||||
* Four billion songs should be enough for anybody.
|
||||
*/
|
||||
typedef uint32_t SongId;
|
||||
|
||||
/*
|
||||
* Audio file encodings that we are aware of. Used to select an appropriate
|
||||
* decoder at play time.
|
||||
*
|
||||
* Values of this enum are persisted in this database, so it is probably never a
|
||||
* good idea to change the int representation of an existing value.
|
||||
*/
|
||||
enum class Encoding {
|
||||
kUnsupported = 0,
|
||||
kMp3 = 1,
|
||||
kWav = 2,
|
||||
kOgg = 3,
|
||||
kFlac = 4,
|
||||
};
|
||||
|
||||
/*
|
||||
* Owning container for tag-related song metadata that was extracted from a
|
||||
* file.
|
||||
*/
|
||||
struct SongTags {
|
||||
Encoding encoding;
|
||||
std::optional<std::string> title;
|
||||
|
||||
// TODO(jacqueline): It would be nice to use shared_ptr's for the artist and
|
||||
// album, since there's likely a fair number of duplicates for each
|
||||
// (especially the former).
|
||||
|
||||
std::optional<std::string> artist;
|
||||
std::optional<std::string> album;
|
||||
|
||||
std::optional<int> channels;
|
||||
std::optional<int> sample_rate;
|
||||
std::optional<int> bits_per_sample;
|
||||
|
||||
/*
|
||||
* Returns a hash of the 'identifying' tags of this song. That is, a hash that
|
||||
* can be used to determine if one song is likely the same as another, across
|
||||
* things like re-encoding, re-mastering, or moving the underlying file.
|
||||
*/
|
||||
auto Hash() const -> uint64_t;
|
||||
|
||||
bool operator==(const SongTags&) const = default;
|
||||
};
|
||||
|
||||
/*
|
||||
* Immutable owning container for all of the metadata we store for a particular
|
||||
* song. This includes two main kinds of metadata:
|
||||
* 1. static(ish) attributes, such as the id, path on disk, hash of the tags
|
||||
* 2. dynamic attributes, such as the number of times this song has been
|
||||
* played.
|
||||
*
|
||||
* Because a SongData is immutable, it is thread safe but will not reflect any
|
||||
* changes to the dynamic attributes that may happen after it was obtained.
|
||||
*
|
||||
* Songs may be 'tombstoned'; this indicates that the song is no longer present
|
||||
* at its previous location on disk, and we do not have any existing files with
|
||||
* a matching tags_hash. When this is the case, we ignore this SongData for most
|
||||
* purposes. We keep the entry in our database so that we can properly restore
|
||||
* dynamic attributes (such as play count) if the song later re-appears on disk.
|
||||
*/
|
||||
class SongData {
|
||||
private:
|
||||
const SongId id_;
|
||||
const std::string filepath_;
|
||||
const uint64_t tags_hash_;
|
||||
const uint32_t play_count_;
|
||||
const bool is_tombstoned_;
|
||||
|
||||
public:
|
||||
/* Constructor used when adding new songs to the database. */
|
||||
SongData(SongId id, const std::string& path, uint64_t hash)
|
||||
: id_(id),
|
||||
filepath_(path),
|
||||
tags_hash_(hash),
|
||||
play_count_(0),
|
||||
is_tombstoned_(false) {}
|
||||
|
||||
SongData(SongId id,
|
||||
const std::string& path,
|
||||
uint64_t hash,
|
||||
uint32_t play_count,
|
||||
bool is_tombstoned)
|
||||
: id_(id),
|
||||
filepath_(path),
|
||||
tags_hash_(hash),
|
||||
play_count_(play_count),
|
||||
is_tombstoned_(is_tombstoned) {}
|
||||
|
||||
auto id() const -> SongId { return id_; }
|
||||
auto filepath() const -> std::string { return filepath_; }
|
||||
auto play_count() const -> uint32_t { return play_count_; }
|
||||
auto tags_hash() const -> uint64_t { return tags_hash_; }
|
||||
auto is_tombstoned() const -> bool { return is_tombstoned_; }
|
||||
|
||||
auto UpdateHash(uint64_t new_hash) const -> SongData;
|
||||
|
||||
/*
|
||||
* Marks this song data as a 'tombstone'. Tombstoned songs are not playable,
|
||||
* and should not generally be shown to users.
|
||||
*/
|
||||
auto Entomb() const -> SongData;
|
||||
|
||||
/*
|
||||
* Clears the tombstone bit of this song, and updates the path to reflect its
|
||||
* new location.
|
||||
*/
|
||||
auto Exhume(const std::string& new_path) const -> SongData;
|
||||
|
||||
bool operator==(const SongData&) const = default;
|
||||
};
|
||||
|
||||
/*
|
||||
* Immutable and owning combination of a song's tags and metadata.
|
||||
*
|
||||
* Note that instances of this class may have a fairly large memory impact, due
|
||||
* to the large number of strings they own. Prefer to query the database again
|
||||
* (which has its own caching layer), rather than retaining Song instances for a
|
||||
* long time.
|
||||
*/
|
||||
class Song {
|
||||
public:
|
||||
Song(const SongData& data, const SongTags& tags) : data_(data), tags_(tags) {}
|
||||
Song(const Song& other) = default;
|
||||
|
||||
auto data() const -> const SongData& { return data_; }
|
||||
auto tags() const -> const SongTags& { return tags_; }
|
||||
|
||||
bool operator==(const Song&) const = default;
|
||||
Song operator=(const Song& other) const { return Song(other); }
|
||||
|
||||
private:
|
||||
const SongData data_;
|
||||
const SongTags tags_;
|
||||
};
|
||||
|
||||
void swap(Song& first, Song& second);
|
||||
|
||||
} // namespace database
|
||||
@@ -8,20 +8,20 @@
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "song.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
namespace database {
|
||||
|
||||
class ITagParser {
|
||||
public:
|
||||
virtual ~ITagParser() {}
|
||||
virtual auto ReadAndParseTags(const std::string& path, SongTags* out)
|
||||
virtual auto ReadAndParseTags(const std::string& path, TrackTags* out)
|
||||
-> bool = 0;
|
||||
};
|
||||
|
||||
class TagParserImpl : public ITagParser {
|
||||
public:
|
||||
virtual auto ReadAndParseTags(const std::string& path, SongTags* out)
|
||||
virtual auto ReadAndParseTags(const std::string& path, TrackTags* out)
|
||||
-> bool override;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright 2023 jacqueline <me@jacqueline.id.au>
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "leveldb/db.h"
|
||||
#include "span.hpp"
|
||||
|
||||
namespace database {
|
||||
|
||||
/*
|
||||
* Uniquely describes a single track within the database. This value will be
|
||||
* consistent across database updates, and should ideally (but is not guaranteed
|
||||
* to) endure even across a track being removed and re-added.
|
||||
*
|
||||
* Four billion tracks should be enough for anybody.
|
||||
*/
|
||||
typedef uint32_t TrackId;
|
||||
|
||||
/*
|
||||
* Audio file encodings that we are aware of. Used to select an appropriate
|
||||
* decoder at play time.
|
||||
*
|
||||
* Values of this enum are persisted in this database, so it is probably never a
|
||||
* good idea to change the int representation of an existing value.
|
||||
*/
|
||||
enum class Encoding {
|
||||
kUnsupported = 0,
|
||||
kMp3 = 1,
|
||||
kWav = 2,
|
||||
kOgg = 3,
|
||||
kFlac = 4,
|
||||
};
|
||||
|
||||
/*
|
||||
* Owning container for tag-related track metadata that was extracted from a
|
||||
* file.
|
||||
*/
|
||||
struct TrackTags {
|
||||
Encoding encoding;
|
||||
std::optional<std::string> title;
|
||||
|
||||
// TODO(jacqueline): It would be nice to use shared_ptr's for the artist and
|
||||
// album, since there's likely a fair number of duplicates for each
|
||||
// (especially the former).
|
||||
|
||||
std::optional<std::string> artist;
|
||||
std::optional<std::string> album;
|
||||
|
||||
std::optional<int> channels;
|
||||
std::optional<int> sample_rate;
|
||||
std::optional<int> bits_per_sample;
|
||||
|
||||
/*
|
||||
* Returns a hash of the 'identifying' tags of this track. That is, a hash
|
||||
* that can be used to determine if one track is likely the same as another,
|
||||
* across things like re-encoding, re-mastering, or moving the underlying
|
||||
* file.
|
||||
*/
|
||||
auto Hash() const -> uint64_t;
|
||||
|
||||
bool operator==(const TrackTags&) const = default;
|
||||
};
|
||||
|
||||
/*
|
||||
* Immutable owning container for all of the metadata we store for a particular
|
||||
* track. This includes two main kinds of metadata:
|
||||
* 1. static(ish) attributes, such as the id, path on disk, hash of the tags
|
||||
* 2. dynamic attributes, such as the number of times this track has been
|
||||
* played.
|
||||
*
|
||||
* Because a TrackData is immutable, it is thread safe but will not reflect any
|
||||
* changes to the dynamic attributes that may happen after it was obtained.
|
||||
*
|
||||
* Tracks may be 'tombstoned'; this indicates that the track is no longer
|
||||
* present at its previous location on disk, and we do not have any existing
|
||||
* files with a matching tags_hash. When this is the case, we ignore this
|
||||
* TrackData for most purposes. We keep the entry in our database so that we can
|
||||
* properly restore dynamic attributes (such as play count) if the track later
|
||||
* re-appears on disk.
|
||||
*/
|
||||
class TrackData {
|
||||
private:
|
||||
const TrackId id_;
|
||||
const std::string filepath_;
|
||||
const uint64_t tags_hash_;
|
||||
const uint32_t play_count_;
|
||||
const bool is_tombstoned_;
|
||||
|
||||
public:
|
||||
/* Constructor used when adding new tracks to the database. */
|
||||
TrackData(TrackId id, const std::string& path, uint64_t hash)
|
||||
: id_(id),
|
||||
filepath_(path),
|
||||
tags_hash_(hash),
|
||||
play_count_(0),
|
||||
is_tombstoned_(false) {}
|
||||
|
||||
TrackData(TrackId id,
|
||||
const std::string& path,
|
||||
uint64_t hash,
|
||||
uint32_t play_count,
|
||||
bool is_tombstoned)
|
||||
: id_(id),
|
||||
filepath_(path),
|
||||
tags_hash_(hash),
|
||||
play_count_(play_count),
|
||||
is_tombstoned_(is_tombstoned) {}
|
||||
|
||||
auto id() const -> TrackId { return id_; }
|
||||
auto filepath() const -> std::string { return filepath_; }
|
||||
auto play_count() const -> uint32_t { return play_count_; }
|
||||
auto tags_hash() const -> uint64_t { return tags_hash_; }
|
||||
auto is_tombstoned() const -> bool { return is_tombstoned_; }
|
||||
|
||||
auto UpdateHash(uint64_t new_hash) const -> TrackData;
|
||||
|
||||
/*
|
||||
* Marks this track data as a 'tombstone'. Tombstoned tracks are not playable,
|
||||
* and should not generally be shown to users.
|
||||
*/
|
||||
auto Entomb() const -> TrackData;
|
||||
|
||||
/*
|
||||
* Clears the tombstone bit of this track, and updates the path to reflect its
|
||||
* new location.
|
||||
*/
|
||||
auto Exhume(const std::string& new_path) const -> TrackData;
|
||||
|
||||
bool operator==(const TrackData&) const = default;
|
||||
};
|
||||
|
||||
/*
|
||||
* Immutable and owning combination of a track's tags and metadata.
|
||||
*
|
||||
* Note that instances of this class may have a fairly large memory impact, due
|
||||
* to the large number of strings they own. Prefer to query the database again
|
||||
* (which has its own caching layer), rather than retaining Track instances for
|
||||
* a long time.
|
||||
*/
|
||||
class Track {
|
||||
public:
|
||||
Track(const TrackData& data, const TrackTags& tags)
|
||||
: data_(data), tags_(tags) {}
|
||||
Track(const Track& other) = default;
|
||||
|
||||
auto data() const -> const TrackData& { return data_; }
|
||||
auto tags() const -> const TrackTags& { return tags_; }
|
||||
|
||||
bool operator==(const Track&) const = default;
|
||||
Track operator=(const Track& other) const { return Track(other); }
|
||||
|
||||
private:
|
||||
const TrackData data_;
|
||||
const TrackTags tags_;
|
||||
};
|
||||
|
||||
void swap(Track& first, Track& second);
|
||||
|
||||
} // namespace database
|
||||
+19
-19
@@ -14,7 +14,7 @@
|
||||
#include "cbor.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "song.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
namespace database {
|
||||
|
||||
@@ -60,14 +60,14 @@ auto CreateDataPrefix() -> OwningSlice {
|
||||
return OwningSlice({data, 2});
|
||||
}
|
||||
|
||||
auto CreateDataKey(const SongId& id) -> OwningSlice {
|
||||
auto CreateDataKey(const TrackId& id) -> OwningSlice {
|
||||
std::ostringstream output;
|
||||
output.put(kDataPrefix).put(kFieldSeparator);
|
||||
output << SongIdToBytes(id).data;
|
||||
output << TrackIdToBytes(id).data;
|
||||
return OwningSlice(output.str());
|
||||
}
|
||||
|
||||
auto CreateDataValue(const SongData& song) -> OwningSlice {
|
||||
auto CreateDataValue(const TrackData& track) -> OwningSlice {
|
||||
uint8_t* buf;
|
||||
std::size_t buf_len = cbor_encode(&buf, [&](CborEncoder* enc) {
|
||||
CborEncoder array_encoder;
|
||||
@@ -77,28 +77,28 @@ auto CreateDataValue(const SongData& song) -> OwningSlice {
|
||||
ESP_LOGE(kTag, "encoding err %u", err);
|
||||
return;
|
||||
}
|
||||
err = cbor_encode_int(&array_encoder, song.id());
|
||||
err = cbor_encode_int(&array_encoder, track.id());
|
||||
if (err != CborNoError && err != CborErrorOutOfMemory) {
|
||||
ESP_LOGE(kTag, "encoding err %u", err);
|
||||
return;
|
||||
}
|
||||
err = cbor_encode_text_string(&array_encoder, song.filepath().c_str(),
|
||||
song.filepath().size());
|
||||
err = cbor_encode_text_string(&array_encoder, track.filepath().c_str(),
|
||||
track.filepath().size());
|
||||
if (err != CborNoError && err != CborErrorOutOfMemory) {
|
||||
ESP_LOGE(kTag, "encoding err %u", err);
|
||||
return;
|
||||
}
|
||||
err = cbor_encode_uint(&array_encoder, song.tags_hash());
|
||||
err = cbor_encode_uint(&array_encoder, track.tags_hash());
|
||||
if (err != CborNoError && err != CborErrorOutOfMemory) {
|
||||
ESP_LOGE(kTag, "encoding err %u", err);
|
||||
return;
|
||||
}
|
||||
err = cbor_encode_int(&array_encoder, song.play_count());
|
||||
err = cbor_encode_int(&array_encoder, track.play_count());
|
||||
if (err != CborNoError && err != CborErrorOutOfMemory) {
|
||||
ESP_LOGE(kTag, "encoding err %u", err);
|
||||
return;
|
||||
}
|
||||
err = cbor_encode_boolean(&array_encoder, song.is_tombstoned());
|
||||
err = cbor_encode_boolean(&array_encoder, track.is_tombstoned());
|
||||
if (err != CborNoError && err != CborErrorOutOfMemory) {
|
||||
ESP_LOGE(kTag, "encoding err %u", err);
|
||||
return;
|
||||
@@ -114,7 +114,7 @@ auto CreateDataValue(const SongData& song) -> OwningSlice {
|
||||
return OwningSlice(as_str);
|
||||
}
|
||||
|
||||
auto ParseDataValue(const leveldb::Slice& slice) -> std::optional<SongData> {
|
||||
auto ParseDataValue(const leveldb::Slice& slice) -> std::optional<TrackData> {
|
||||
CborParser parser;
|
||||
CborValue container;
|
||||
CborError err;
|
||||
@@ -135,7 +135,7 @@ auto ParseDataValue(const leveldb::Slice& slice) -> std::optional<SongData> {
|
||||
if (err != CborNoError) {
|
||||
return {};
|
||||
}
|
||||
SongId id = raw_int;
|
||||
TrackId id = raw_int;
|
||||
err = cbor_value_advance(&val);
|
||||
if (err != CborNoError || !cbor_value_is_text_string(&val)) {
|
||||
return {};
|
||||
@@ -176,7 +176,7 @@ auto ParseDataValue(const leveldb::Slice& slice) -> std::optional<SongData> {
|
||||
return {};
|
||||
}
|
||||
|
||||
return SongData(id, path, hash, play_count, is_tombstoned);
|
||||
return TrackData(id, path, hash, play_count, is_tombstoned);
|
||||
}
|
||||
|
||||
auto CreateHashKey(const uint64_t& hash) -> OwningSlice {
|
||||
@@ -193,15 +193,15 @@ auto CreateHashKey(const uint64_t& hash) -> OwningSlice {
|
||||
return OwningSlice(output.str());
|
||||
}
|
||||
|
||||
auto ParseHashValue(const leveldb::Slice& slice) -> std::optional<SongId> {
|
||||
return BytesToSongId(slice.ToString());
|
||||
auto ParseHashValue(const leveldb::Slice& slice) -> std::optional<TrackId> {
|
||||
return BytesToTrackId(slice.ToString());
|
||||
}
|
||||
|
||||
auto CreateHashValue(SongId id) -> OwningSlice {
|
||||
return SongIdToBytes(id);
|
||||
auto CreateHashValue(TrackId id) -> OwningSlice {
|
||||
return TrackIdToBytes(id);
|
||||
}
|
||||
|
||||
auto SongIdToBytes(SongId id) -> OwningSlice {
|
||||
auto TrackIdToBytes(TrackId id) -> OwningSlice {
|
||||
uint8_t buf[8];
|
||||
CborEncoder enc;
|
||||
cbor_encoder_init(&enc, buf, sizeof(buf), 0);
|
||||
@@ -211,7 +211,7 @@ auto SongIdToBytes(SongId id) -> OwningSlice {
|
||||
return OwningSlice(as_str);
|
||||
}
|
||||
|
||||
auto BytesToSongId(const std::string& bytes) -> std::optional<SongId> {
|
||||
auto BytesToTrackId(const std::string& bytes) -> std::optional<TrackId> {
|
||||
CborParser parser;
|
||||
CborValue val;
|
||||
cbor_parser_init(reinterpret_cast<const uint8_t*>(bytes.data()), bytes.size(),
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace libtags {
|
||||
struct Aux {
|
||||
FIL file;
|
||||
FILINFO info;
|
||||
SongTags* tags;
|
||||
TrackTags* tags;
|
||||
};
|
||||
|
||||
static int read(Tagctx* ctx, void* buf, int cnt) {
|
||||
@@ -71,8 +71,14 @@ static void toc(Tagctx* ctx, int ms, int offset) {}
|
||||
static const std::size_t kBufSize = 1024;
|
||||
static const char* kTag = "TAGS";
|
||||
|
||||
auto TagParserImpl::ReadAndParseTags(const std::string& path, SongTags* out)
|
||||
auto TagParserImpl::ReadAndParseTags(const std::string& path, TrackTags* out)
|
||||
-> bool {
|
||||
if (path.ends_with(".m4a")) {
|
||||
// TODO(jacqueline): Re-enabled once libtags is fixed.
|
||||
ESP_LOGW(kTag, "skipping m4a %s", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
libtags::Aux aux;
|
||||
aux.tags = out;
|
||||
if (f_stat(path.c_str(), &aux.info) != FR_OK ||
|
||||
@@ -96,6 +102,7 @@ auto TagParserImpl::ReadAndParseTags(const std::string& path, SongTags* out)
|
||||
|
||||
if (res != 0) {
|
||||
// Parsing failed.
|
||||
ESP_LOGE(kTag, "tag parsing failed, reason %d", res);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -103,6 +110,15 @@ auto TagParserImpl::ReadAndParseTags(const std::string& path, SongTags* out)
|
||||
case Fmp3:
|
||||
out->encoding = Encoding::kMp3;
|
||||
break;
|
||||
case Fogg:
|
||||
out->encoding = Encoding::kOgg;
|
||||
break;
|
||||
case Fflac:
|
||||
out->encoding = Encoding::kFlac;
|
||||
break;
|
||||
case Fwav:
|
||||
out->encoding = Encoding::kWav;
|
||||
break;
|
||||
default:
|
||||
out->encoding = Encoding::kUnsupported;
|
||||
}
|
||||
|
||||
@@ -18,41 +18,41 @@
|
||||
#include "file_gatherer.hpp"
|
||||
#include "i2c_fixture.hpp"
|
||||
#include "leveldb/db.h"
|
||||
#include "song.hpp"
|
||||
#include "spi_fixture.hpp"
|
||||
#include "tag_parser.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
namespace database {
|
||||
|
||||
class TestBackends : public IFileGatherer, public ITagParser {
|
||||
public:
|
||||
std::map<std::string, SongTags> songs;
|
||||
std::map<std::string, TrackTags> tracks;
|
||||
|
||||
auto MakeSong(const std::string& path, const std::string& title) -> void {
|
||||
SongTags tags;
|
||||
auto MakeTrack(const std::string& path, const std::string& title) -> void {
|
||||
TrackTags tags;
|
||||
tags.encoding = Encoding::kMp3;
|
||||
tags.title = title;
|
||||
songs[path] = tags;
|
||||
tracks[path] = tags;
|
||||
}
|
||||
|
||||
auto FindFiles(const std::string& root,
|
||||
std::function<void(const std::string&)> cb) -> void override {
|
||||
for (auto keyval : songs) {
|
||||
for (auto keyval : tracks) {
|
||||
std::invoke(cb, keyval.first);
|
||||
}
|
||||
}
|
||||
|
||||
auto ReadAndParseTags(const std::string& path, SongTags* out)
|
||||
auto ReadAndParseTags(const std::string& path, TrackTags* out)
|
||||
-> bool override {
|
||||
if (songs.contains(path)) {
|
||||
*out = songs.at(path);
|
||||
if (tracks.contains(path)) {
|
||||
*out = tracks.at(path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_CASE("song database", "[integration]") {
|
||||
TEST_CASE("track database", "[integration]") {
|
||||
I2CFixture i2c;
|
||||
SpiFixture spi;
|
||||
drivers::DriverCache drivers;
|
||||
@@ -60,104 +60,104 @@ TEST_CASE("song database", "[integration]") {
|
||||
|
||||
Database::Destroy();
|
||||
|
||||
TestBackends songs;
|
||||
auto open_res = Database::Open(&songs, &songs);
|
||||
TestBackends tracks;
|
||||
auto open_res = Database::Open(&tracks, &tracks);
|
||||
REQUIRE(open_res.has_value());
|
||||
std::unique_ptr<Database> db(open_res.value());
|
||||
|
||||
SECTION("empty database") {
|
||||
std::unique_ptr<Result<Song>> res(db->GetSongs(10).get());
|
||||
std::unique_ptr<Result<Track>> res(db->GetTracks(10).get());
|
||||
REQUIRE(res->values().size() == 0);
|
||||
}
|
||||
|
||||
SECTION("add new songs") {
|
||||
songs.MakeSong("song1.mp3", "Song 1");
|
||||
songs.MakeSong("song2.wav", "Song 2");
|
||||
songs.MakeSong("song3.exe", "Song 3");
|
||||
SECTION("add new tracks") {
|
||||
tracks.MakeTrack("track1.mp3", "Track 1");
|
||||
tracks.MakeTrack("track2.wav", "Track 2");
|
||||
tracks.MakeTrack("track3.exe", "Track 3");
|
||||
|
||||
db->Update();
|
||||
|
||||
std::unique_ptr<Result<Song>> res(db->GetSongs(10).get());
|
||||
std::unique_ptr<Result<Track>> res(db->GetTracks(10).get());
|
||||
REQUIRE(res->values().size() == 3);
|
||||
CHECK(*res->values().at(0).tags().title == "Song 1");
|
||||
CHECK(*res->values().at(0).tags().title == "Track 1");
|
||||
CHECK(res->values().at(0).data().id() == 1);
|
||||
CHECK(*res->values().at(1).tags().title == "Song 2");
|
||||
CHECK(*res->values().at(1).tags().title == "Track 2");
|
||||
CHECK(res->values().at(1).data().id() == 2);
|
||||
CHECK(*res->values().at(2).tags().title == "Song 3");
|
||||
CHECK(*res->values().at(2).tags().title == "Track 3");
|
||||
CHECK(res->values().at(2).data().id() == 3);
|
||||
|
||||
SECTION("update with no filesystem changes") {
|
||||
db->Update();
|
||||
|
||||
std::unique_ptr<Result<Song>> new_res(db->GetSongs(10).get());
|
||||
std::unique_ptr<Result<Track>> new_res(db->GetTracks(10).get());
|
||||
REQUIRE(new_res->values().size() == 3);
|
||||
CHECK(res->values().at(0) == new_res->values().at(0));
|
||||
CHECK(res->values().at(1) == new_res->values().at(1));
|
||||
CHECK(res->values().at(2) == new_res->values().at(2));
|
||||
}
|
||||
|
||||
SECTION("update with all songs gone") {
|
||||
songs.songs.clear();
|
||||
SECTION("update with all tracks gone") {
|
||||
tracks.tracks.clear();
|
||||
|
||||
db->Update();
|
||||
|
||||
std::unique_ptr<Result<Song>> new_res(db->GetSongs(10).get());
|
||||
std::unique_ptr<Result<Track>> new_res(db->GetTracks(10).get());
|
||||
CHECK(new_res->values().size() == 0);
|
||||
|
||||
SECTION("update with one song returned") {
|
||||
songs.MakeSong("song2.wav", "Song 2");
|
||||
SECTION("update with one track returned") {
|
||||
tracks.MakeTrack("track2.wav", "Track 2");
|
||||
|
||||
db->Update();
|
||||
|
||||
std::unique_ptr<Result<Song>> new_res(db->GetSongs(10).get());
|
||||
std::unique_ptr<Result<Track>> new_res(db->GetTracks(10).get());
|
||||
REQUIRE(new_res->values().size() == 1);
|
||||
CHECK(res->values().at(1) == new_res->values().at(0));
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("update with one song gone") {
|
||||
songs.songs.erase("song2.wav");
|
||||
SECTION("update with one track gone") {
|
||||
tracks.tracks.erase("track2.wav");
|
||||
|
||||
db->Update();
|
||||
|
||||
std::unique_ptr<Result<Song>> new_res(db->GetSongs(10).get());
|
||||
std::unique_ptr<Result<Track>> new_res(db->GetTracks(10).get());
|
||||
REQUIRE(new_res->values().size() == 2);
|
||||
CHECK(res->values().at(0) == new_res->values().at(0));
|
||||
CHECK(res->values().at(2) == new_res->values().at(1));
|
||||
}
|
||||
|
||||
SECTION("update with tags changed") {
|
||||
songs.MakeSong("song3.exe", "The Song 3");
|
||||
tracks.MakeTrack("track3.exe", "The Track 3");
|
||||
|
||||
db->Update();
|
||||
|
||||
std::unique_ptr<Result<Song>> new_res(db->GetSongs(10).get());
|
||||
std::unique_ptr<Result<Track>> new_res(db->GetTracks(10).get());
|
||||
REQUIRE(new_res->values().size() == 3);
|
||||
CHECK(res->values().at(0) == new_res->values().at(0));
|
||||
CHECK(res->values().at(1) == new_res->values().at(1));
|
||||
CHECK(*new_res->values().at(2).tags().title == "The Song 3");
|
||||
CHECK(*new_res->values().at(2).tags().title == "The Track 3");
|
||||
// The id should not have changed, since this was just a tag update.
|
||||
CHECK(res->values().at(2).data().id() ==
|
||||
new_res->values().at(2).data().id());
|
||||
}
|
||||
|
||||
SECTION("update with one new song") {
|
||||
songs.MakeSong("my song.midi", "Song 1 (nightcore remix)");
|
||||
SECTION("update with one new track") {
|
||||
tracks.MakeTrack("my track.midi", "Track 1 (nightcore remix)");
|
||||
|
||||
db->Update();
|
||||
|
||||
std::unique_ptr<Result<Song>> new_res(db->GetSongs(10).get());
|
||||
std::unique_ptr<Result<Track>> new_res(db->GetTracks(10).get());
|
||||
REQUIRE(new_res->values().size() == 4);
|
||||
CHECK(res->values().at(0) == new_res->values().at(0));
|
||||
CHECK(res->values().at(1) == new_res->values().at(1));
|
||||
CHECK(res->values().at(2) == new_res->values().at(2));
|
||||
CHECK(*new_res->values().at(3).tags().title ==
|
||||
"Song 1 (nightcore remix)");
|
||||
"Track 1 (nightcore remix)");
|
||||
CHECK(new_res->values().at(3).data().id() == 4);
|
||||
}
|
||||
|
||||
SECTION("get songs with pagination") {
|
||||
std::unique_ptr<Result<Song>> res(db->GetSongs(1).get());
|
||||
SECTION("get tracks with pagination") {
|
||||
std::unique_ptr<Result<Track>> res(db->GetTracks(1).get());
|
||||
|
||||
REQUIRE(res->values().size() == 1);
|
||||
CHECK(res->values().at(0).data().id() == 1);
|
||||
|
||||
@@ -25,9 +25,9 @@ std::string ToHex(const std::string& s) {
|
||||
namespace database {
|
||||
|
||||
TEST_CASE("database record encoding", "[unit]") {
|
||||
SECTION("song id to bytes") {
|
||||
SongId id = 1234678;
|
||||
OwningSlice as_bytes = SongIdToBytes(id);
|
||||
SECTION("track id to bytes") {
|
||||
TrackId id = 1234678;
|
||||
OwningSlice as_bytes = TrackIdToBytes(id);
|
||||
|
||||
SECTION("encodes correctly") {
|
||||
// Purposefully a brittle test, since we need to be very careful about
|
||||
@@ -44,18 +44,18 @@ TEST_CASE("database record encoding", "[unit]") {
|
||||
}
|
||||
|
||||
SECTION("round-trips") {
|
||||
CHECK(*BytesToSongId(as_bytes.data) == id);
|
||||
CHECK(*BytesToTrackId(as_bytes.data) == id);
|
||||
}
|
||||
|
||||
SECTION("encodes compactly") {
|
||||
OwningSlice small_id = SongIdToBytes(1);
|
||||
OwningSlice large_id = SongIdToBytes(999999);
|
||||
OwningSlice small_id = TrackIdToBytes(1);
|
||||
OwningSlice large_id = TrackIdToBytes(999999);
|
||||
|
||||
CHECK(small_id.data.size() < large_id.data.size());
|
||||
}
|
||||
|
||||
SECTION("decoding rejects garbage") {
|
||||
std::optional<SongId> res = BytesToSongId("i'm gay");
|
||||
std::optional<TrackId> res = BytesToTrackId("i'm gay");
|
||||
|
||||
CHECK(res.has_value() == false);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ TEST_CASE("database record encoding", "[unit]") {
|
||||
}
|
||||
|
||||
SECTION("data values") {
|
||||
SongData data(123, "/some/path.mp3", 0xACAB, 69, true);
|
||||
TrackData data(123, "/some/path.mp3", 0xACAB, 69, true);
|
||||
|
||||
OwningSlice enc = CreateDataValue(data);
|
||||
|
||||
@@ -109,7 +109,7 @@ TEST_CASE("database record encoding", "[unit]") {
|
||||
}
|
||||
|
||||
SECTION("decoding rejects garbage") {
|
||||
std::optional<SongData> res = ParseDataValue("hi!");
|
||||
std::optional<TrackData> res = ParseDataValue("hi!");
|
||||
|
||||
CHECK(res.has_value() == false);
|
||||
}
|
||||
@@ -129,14 +129,14 @@ TEST_CASE("database record encoding", "[unit]") {
|
||||
SECTION("hash values") {
|
||||
OwningSlice val = CreateHashValue(123456);
|
||||
|
||||
CHECK(val.data == SongIdToBytes(123456).data);
|
||||
CHECK(val.data == TrackIdToBytes(123456).data);
|
||||
|
||||
SECTION("round-trips") {
|
||||
CHECK(ParseHashValue(val.slice) == 123456);
|
||||
}
|
||||
|
||||
SECTION("decoding rejects garbage") {
|
||||
std::optional<SongId> res = ParseHashValue("the first song :)");
|
||||
std::optional<TrackId> res = ParseHashValue("the first track :)");
|
||||
|
||||
CHECK(res.has_value() == false);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#include "song.hpp"
|
||||
#include "track.hpp"
|
||||
|
||||
#include <komihash.h>
|
||||
|
||||
@@ -19,8 +19,8 @@ auto HashString(komihash_stream_t* stream, std::string str) -> void {
|
||||
* Uses a komihash stream to incrementally hash tags. This lowers the function's
|
||||
* memory footprint a little so that it's safe to call from any stack.
|
||||
*/
|
||||
auto SongTags::Hash() const -> uint64_t {
|
||||
// TODO(jacqueline): this function doesn't work very well for songs with no
|
||||
auto TrackTags::Hash() const -> uint64_t {
|
||||
// TODO(jacqueline): this function doesn't work very well for tracks with no
|
||||
// tags at all.
|
||||
komihash_stream_t stream;
|
||||
komihash_stream_init(&stream, 0);
|
||||
@@ -30,20 +30,20 @@ auto SongTags::Hash() const -> uint64_t {
|
||||
return komihash_stream_final(&stream);
|
||||
}
|
||||
|
||||
auto SongData::UpdateHash(uint64_t new_hash) const -> SongData {
|
||||
return SongData(id_, filepath_, new_hash, play_count_, is_tombstoned_);
|
||||
auto TrackData::UpdateHash(uint64_t new_hash) const -> TrackData {
|
||||
return TrackData(id_, filepath_, new_hash, play_count_, is_tombstoned_);
|
||||
}
|
||||
|
||||
auto SongData::Entomb() const -> SongData {
|
||||
return SongData(id_, filepath_, tags_hash_, play_count_, true);
|
||||
auto TrackData::Entomb() const -> TrackData {
|
||||
return TrackData(id_, filepath_, tags_hash_, play_count_, true);
|
||||
}
|
||||
|
||||
auto SongData::Exhume(const std::string& new_path) const -> SongData {
|
||||
return SongData(id_, new_path, tags_hash_, play_count_, false);
|
||||
auto TrackData::Exhume(const std::string& new_path) const -> TrackData {
|
||||
return TrackData(id_, new_path, tags_hash_, play_count_, false);
|
||||
}
|
||||
|
||||
void swap(Song& first, Song& second) {
|
||||
Song temp = first;
|
||||
void swap(Track& first, Track& second) {
|
||||
Track temp = first;
|
||||
first = second;
|
||||
second = temp;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ Samd::Samd() {
|
||||
.read(&raw_res, I2C_MASTER_NACK)
|
||||
.stop();
|
||||
ESP_LOGI(kTag, "checking samd firmware rev");
|
||||
ESP_ERROR_CHECK(transaction.Execute());
|
||||
transaction.Execute();
|
||||
ESP_LOGI(kTag, "samd firmware: %u", raw_res);
|
||||
}
|
||||
Samd::~Samd() {}
|
||||
@@ -53,7 +53,7 @@ auto Samd::ReadChargeStatus() -> std::optional<ChargeStatus> {
|
||||
.read(&raw_res, I2C_MASTER_NACK)
|
||||
.stop();
|
||||
ESP_LOGI(kTag, "checking charge status");
|
||||
ESP_ERROR_CHECK(transaction.Execute());
|
||||
transaction.Execute();
|
||||
ESP_LOGI(kTag, "raw charge status: %x", raw_res);
|
||||
|
||||
uint8_t usb_state = raw_res & 0b11;
|
||||
@@ -83,7 +83,7 @@ auto Samd::WriteAllowUsbMsc(bool is_allowed) -> void {
|
||||
.write_addr(kAddress, I2C_MASTER_WRITE)
|
||||
.write_ack(kRegisterUsbMsc, is_allowed)
|
||||
.stop();
|
||||
ESP_ERROR_CHECK(transaction.Execute());
|
||||
transaction.Execute();
|
||||
}
|
||||
|
||||
auto Samd::ReadUsbMscStatus() -> UsbMscStatus {
|
||||
|
||||
@@ -65,7 +65,7 @@ void TouchWheel::WriteRegister(uint8_t reg, uint8_t val) {
|
||||
.write_addr(kTouchWheelAddress, I2C_MASTER_WRITE)
|
||||
.write_ack(reg, val)
|
||||
.stop();
|
||||
ESP_ERROR_CHECK(transaction.Execute());
|
||||
transaction.Execute();
|
||||
}
|
||||
|
||||
uint8_t TouchWheel::ReadRegister(uint8_t reg) {
|
||||
@@ -78,7 +78,7 @@ uint8_t TouchWheel::ReadRegister(uint8_t reg) {
|
||||
.write_addr(kTouchWheelAddress, I2C_MASTER_READ)
|
||||
.read(&res, I2C_MASTER_NACK)
|
||||
.stop();
|
||||
ESP_ERROR_CHECK(transaction.Execute());
|
||||
transaction.Execute();
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ namespace states {
|
||||
|
||||
static const char kTag[] = "BOOT";
|
||||
|
||||
console::AppConsole* Booting::sAppConsole;
|
||||
|
||||
auto Booting::entry() -> void {
|
||||
ESP_LOGI(kTag, "beginning tangara boot");
|
||||
ESP_LOGI(kTag, "installing early drivers");
|
||||
@@ -78,7 +76,7 @@ auto Booting::entry() -> void {
|
||||
|
||||
auto Booting::exit() -> void {
|
||||
// TODO(jacqueline): Gate this on something. Debug flag? Flashing mode?
|
||||
sAppConsole = new console::AppConsole(sDatabase);
|
||||
sAppConsole = new console::AppConsole();
|
||||
sAppConsole->Launch();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "database.hpp"
|
||||
#include "tinyfsm.hpp"
|
||||
|
||||
namespace system_fsm {
|
||||
@@ -38,7 +41,9 @@ struct StorageUnmountRequested : tinyfsm::Event {};
|
||||
/*
|
||||
* Sent by SysState when the system storage has been successfully mounted.
|
||||
*/
|
||||
struct StorageMounted : tinyfsm::Event {};
|
||||
struct StorageMounted : tinyfsm::Event {
|
||||
std::weak_ptr<database::Database> db;
|
||||
};
|
||||
|
||||
struct StorageError : tinyfsm::Event {};
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ class SystemState : public tinyfsm::Fsm<SystemState> {
|
||||
static std::shared_ptr<drivers::SdStorage> sStorage;
|
||||
static std::shared_ptr<drivers::Display> sDisplay;
|
||||
static std::shared_ptr<database::Database> sDatabase;
|
||||
|
||||
static console::AppConsole* sAppConsole;
|
||||
};
|
||||
|
||||
namespace states {
|
||||
@@ -65,9 +67,6 @@ namespace states {
|
||||
* looks good.
|
||||
*/
|
||||
class Booting : public SystemState {
|
||||
private:
|
||||
static console::AppConsole* sAppConsole;
|
||||
|
||||
public:
|
||||
void entry() override;
|
||||
void exit() override;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#include "app_console.hpp"
|
||||
#include "freertos/projdefs.h"
|
||||
#include "result.hpp"
|
||||
|
||||
@@ -28,6 +29,7 @@ void Running::entry() {
|
||||
vTaskDelay(pdMS_TO_TICKS(250));
|
||||
auto storage_res = drivers::SdStorage::Create(sGpioExpander.get());
|
||||
if (storage_res.has_error()) {
|
||||
ESP_LOGW(kTag, "failed to mount!");
|
||||
events::Dispatch<StorageError, SystemState, audio::AudioState, ui::UiState>(
|
||||
StorageError());
|
||||
return;
|
||||
@@ -38,15 +40,17 @@ void Running::entry() {
|
||||
ESP_LOGI(kTag, "opening database");
|
||||
auto database_res = database::Database::Open();
|
||||
if (database_res.has_error()) {
|
||||
ESP_LOGW(kTag, "failed to open!");
|
||||
events::Dispatch<StorageError, SystemState, audio::AudioState, ui::UiState>(
|
||||
StorageError());
|
||||
return;
|
||||
}
|
||||
sDatabase.reset(database_res.value());
|
||||
console::AppConsole::sDatabase = sDatabase;
|
||||
|
||||
ESP_LOGI(kTag, "storage loaded okay");
|
||||
events::Dispatch<StorageMounted, SystemState, audio::AudioState, ui::UiState>(
|
||||
StorageMounted());
|
||||
StorageMounted{.db = sDatabase});
|
||||
}
|
||||
|
||||
void Running::exit() {
|
||||
|
||||
@@ -20,6 +20,8 @@ std::shared_ptr<drivers::SdStorage> SystemState::sStorage;
|
||||
std::shared_ptr<drivers::Display> SystemState::sDisplay;
|
||||
std::shared_ptr<database::Database> SystemState::sDatabase;
|
||||
|
||||
console::AppConsole* SystemState::sAppConsole;
|
||||
|
||||
void SystemState::react(const FatalError& err) {
|
||||
if (!is_in_state<states::Error>()) {
|
||||
transit<states::Error>();
|
||||
|
||||
+21
-1
@@ -5,7 +5,9 @@
|
||||
*/
|
||||
|
||||
#include "tasks.hpp"
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/portmacro.h"
|
||||
@@ -31,6 +33,10 @@ template <>
|
||||
auto Name<Type::kDatabase>() -> std::string {
|
||||
return "DB";
|
||||
}
|
||||
template <>
|
||||
auto Name<Type::kDatabaseBackground>() -> std::string {
|
||||
return "DB_BG";
|
||||
}
|
||||
|
||||
template <Type t>
|
||||
auto AllocateStack() -> cpp::span<StackType_t>;
|
||||
@@ -39,7 +45,7 @@ auto AllocateStack() -> cpp::span<StackType_t>;
|
||||
// amount of stack space.
|
||||
template <>
|
||||
auto AllocateStack<Type::kAudio>() -> cpp::span<StackType_t> {
|
||||
std::size_t size = 32 * 1024;
|
||||
std::size_t size = 48 * 1024;
|
||||
return {static_cast<StackType_t*>(heap_caps_malloc(size, MALLOC_CAP_DEFAULT)),
|
||||
size};
|
||||
}
|
||||
@@ -67,6 +73,12 @@ auto AllocateStack<Type::kDatabase>() -> cpp::span<StackType_t> {
|
||||
return {static_cast<StackType_t*>(heap_caps_malloc(size, MALLOC_CAP_SPIRAM)),
|
||||
size};
|
||||
}
|
||||
template <>
|
||||
auto AllocateStack<Type::kDatabaseBackground>() -> cpp::span<StackType_t> {
|
||||
std::size_t size = 256 * 1024;
|
||||
return {static_cast<StackType_t*>(heap_caps_malloc(size, MALLOC_CAP_SPIRAM)),
|
||||
size};
|
||||
}
|
||||
|
||||
// 2048 bytes in internal ram
|
||||
// 302 KiB in external ram.
|
||||
@@ -106,6 +118,10 @@ template <>
|
||||
auto Priority<Type::kDatabase>() -> UBaseType_t {
|
||||
return 8;
|
||||
}
|
||||
template <>
|
||||
auto Priority<Type::kDatabaseBackground>() -> UBaseType_t {
|
||||
return 7;
|
||||
}
|
||||
|
||||
template <Type t>
|
||||
auto WorkerQueueSize() -> std::size_t;
|
||||
@@ -114,6 +130,10 @@ template <>
|
||||
auto WorkerQueueSize<Type::kDatabase>() -> std::size_t {
|
||||
return 8;
|
||||
}
|
||||
template <>
|
||||
auto WorkerQueueSize<Type::kDatabaseBackground>() -> std::size_t {
|
||||
return 8;
|
||||
}
|
||||
|
||||
template <>
|
||||
auto WorkerQueueSize<Type::kUiFlush>() -> std::size_t {
|
||||
|
||||
@@ -36,6 +36,8 @@ enum class Type {
|
||||
kAudio,
|
||||
// Task for running database queries.
|
||||
kDatabase,
|
||||
// Task for internal database operations
|
||||
kDatabaseBackground,
|
||||
};
|
||||
|
||||
template <Type t>
|
||||
@@ -102,6 +104,9 @@ class Worker {
|
||||
}
|
||||
|
||||
~Worker();
|
||||
|
||||
Worker(const Worker&) = delete;
|
||||
Worker& operator=(const Worker&) = delete;
|
||||
};
|
||||
|
||||
/* Specialisation of Evaluate for functions that return nothing. */
|
||||
|
||||
Reference in New Issue
Block a user