Add a wrapper codec source that does readahead
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "audio_decoder.cpp" "fatfs_audio_input.cpp" "i2s_audio_output.cpp"
|
SRCS "audio_decoder.cpp" "fatfs_audio_input.cpp" "i2s_audio_output.cpp"
|
||||||
"track_queue.cpp" "audio_fsm.cpp" "audio_converter.cpp" "resample.cpp"
|
"track_queue.cpp" "audio_fsm.cpp" "audio_converter.cpp" "resample.cpp"
|
||||||
"fatfs_source.cpp" "bt_audio_output.cpp"
|
"fatfs_source.cpp" "bt_audio_output.cpp" "readahead_source.cpp"
|
||||||
INCLUDE_DIRS "include"
|
INCLUDE_DIRS "include"
|
||||||
REQUIRES "codecs" "drivers" "cbor" "result" "tasks" "span" "memory" "tinyfsm"
|
REQUIRES "codecs" "drivers" "cbor" "result" "tasks" "span" "memory" "tinyfsm"
|
||||||
"database" "system_fsm" "playlist" "speexdsp")
|
"database" "system_fsm" "playlist" "speexdsp")
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ void Uninitialised::react(const system_fsm::BootComplete& ev) {
|
|||||||
StreamBufferHandle_t stream = xStreamBufferCreateWithCaps(
|
StreamBufferHandle_t stream = xStreamBufferCreateWithCaps(
|
||||||
kDrainBufferSize, sizeof(sample::Sample) * 2, MALLOC_CAP_DMA);
|
kDrainBufferSize, sizeof(sample::Sample) * 2, MALLOC_CAP_DMA);
|
||||||
|
|
||||||
sFileSource.reset(new FatfsAudioInput(sServices->tag_parser()));
|
sFileSource.reset(
|
||||||
|
new FatfsAudioInput(sServices->tag_parser(), sServices->bg_worker()));
|
||||||
sI2SOutput.reset(new I2SAudioOutput(stream, sServices->gpios()));
|
sI2SOutput.reset(new I2SAudioOutput(stream, sServices->gpios()));
|
||||||
sBtOutput.reset(new BluetoothAudioOutput(stream, sServices->bluetooth()));
|
sBtOutput.reset(new BluetoothAudioOutput(stream, sServices->bluetooth()));
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
#include "freertos/portmacro.h"
|
#include "freertos/portmacro.h"
|
||||||
#include "freertos/projdefs.h"
|
#include "freertos/projdefs.h"
|
||||||
#include "idf_additions.h"
|
#include "idf_additions.h"
|
||||||
|
#include "readahead_source.hpp"
|
||||||
#include "span.hpp"
|
#include "span.hpp"
|
||||||
|
|
||||||
#include "audio_events.hpp"
|
#include "audio_events.hpp"
|
||||||
@@ -42,9 +43,11 @@
|
|||||||
|
|
||||||
namespace audio {
|
namespace audio {
|
||||||
|
|
||||||
FatfsAudioInput::FatfsAudioInput(database::ITagParser& tag_parser)
|
FatfsAudioInput::FatfsAudioInput(database::ITagParser& tag_parser,
|
||||||
|
tasks::Worker& bg_worker)
|
||||||
: IAudioSource(),
|
: IAudioSource(),
|
||||||
tag_parser_(tag_parser),
|
tag_parser_(tag_parser),
|
||||||
|
bg_worker_(bg_worker),
|
||||||
new_stream_mutex_(),
|
new_stream_mutex_(),
|
||||||
new_stream_(),
|
new_stream_(),
|
||||||
has_new_stream_(false),
|
has_new_stream_(false),
|
||||||
@@ -142,7 +145,9 @@ auto FatfsAudioInput::OpenFile(const std::pmr::string& path) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
new_stream_.reset(new FatfsSource(stream_type.value(), std::move(file)));
|
auto source =
|
||||||
|
std::make_unique<FatfsSource>(stream_type.value(), std::move(file));
|
||||||
|
new_stream_.reset(new ReadaheadSource(bg_worker_, std::move(source)));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#include "codec.hpp"
|
#include "codec.hpp"
|
||||||
#include "future_fetcher.hpp"
|
#include "future_fetcher.hpp"
|
||||||
#include "tag_parser.hpp"
|
#include "tag_parser.hpp"
|
||||||
|
#include "tasks.hpp"
|
||||||
#include "types.hpp"
|
#include "types.hpp"
|
||||||
|
|
||||||
namespace audio {
|
namespace audio {
|
||||||
@@ -30,7 +31,7 @@ namespace audio {
|
|||||||
*/
|
*/
|
||||||
class FatfsAudioInput : public IAudioSource {
|
class FatfsAudioInput : public IAudioSource {
|
||||||
public:
|
public:
|
||||||
explicit FatfsAudioInput(database::ITagParser& tag_parser);
|
explicit FatfsAudioInput(database::ITagParser&, tasks::Worker&);
|
||||||
~FatfsAudioInput();
|
~FatfsAudioInput();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -54,6 +55,7 @@ class FatfsAudioInput : public IAudioSource {
|
|||||||
-> std::optional<codecs::StreamType>;
|
-> std::optional<codecs::StreamType>;
|
||||||
|
|
||||||
database::ITagParser& tag_parser_;
|
database::ITagParser& tag_parser_;
|
||||||
|
tasks::Worker& bg_worker_;
|
||||||
|
|
||||||
std::mutex new_stream_mutex_;
|
std::mutex new_stream_mutex_;
|
||||||
std::shared_ptr<codecs::IStream> new_stream_;
|
std::shared_ptr<codecs::IStream> new_stream_;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 jacqueline <me@jacqueline.id.au>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
|
||||||
|
#include "ff.h"
|
||||||
|
#include "freertos/stream_buffer.h"
|
||||||
|
|
||||||
|
#include "audio_source.hpp"
|
||||||
|
#include "codec.hpp"
|
||||||
|
#include "tasks.hpp"
|
||||||
|
|
||||||
|
namespace audio {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wraps another stream, proactively buffering large chunks of it into memory
|
||||||
|
* at a time.
|
||||||
|
*/
|
||||||
|
class ReadaheadSource : public codecs::IStream {
|
||||||
|
public:
|
||||||
|
ReadaheadSource(tasks::Worker&, std::unique_ptr<codecs::IStream>);
|
||||||
|
~ReadaheadSource();
|
||||||
|
|
||||||
|
auto Read(cpp::span<std::byte> dest) -> ssize_t override;
|
||||||
|
|
||||||
|
auto CanSeek() -> bool override;
|
||||||
|
|
||||||
|
auto SeekTo(int64_t destination, SeekFrom from) -> void override;
|
||||||
|
|
||||||
|
auto CurrentPosition() -> int64_t override;
|
||||||
|
|
||||||
|
ReadaheadSource(const ReadaheadSource&) = delete;
|
||||||
|
ReadaheadSource& operator=(const ReadaheadSource&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
tasks::Worker& worker_;
|
||||||
|
std::unique_ptr<codecs::IStream> wrapped_;
|
||||||
|
|
||||||
|
std::atomic<bool> is_refilling_;
|
||||||
|
StreamBufferHandle_t buffer_;
|
||||||
|
int64_t tell_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace audio
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 jacqueline <me@jacqueline.id.au>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "readahead_source.hpp"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "esp_heap_caps.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "ff.h"
|
||||||
|
|
||||||
|
#include "audio_source.hpp"
|
||||||
|
#include "codec.hpp"
|
||||||
|
#include "freertos/portmacro.h"
|
||||||
|
#include "idf_additions.h"
|
||||||
|
#include "spi.hpp"
|
||||||
|
#include "tasks.hpp"
|
||||||
|
#include "types.hpp"
|
||||||
|
|
||||||
|
namespace audio {
|
||||||
|
|
||||||
|
static constexpr char kTag[] = "readahead";
|
||||||
|
static constexpr size_t kBufferSize = 1024 * 512;
|
||||||
|
|
||||||
|
ReadaheadSource::ReadaheadSource(tasks::Worker& worker,
|
||||||
|
std::unique_ptr<codecs::IStream> wrapped)
|
||||||
|
: IStream(wrapped->type()),
|
||||||
|
worker_(worker),
|
||||||
|
wrapped_(std::move(wrapped)),
|
||||||
|
is_refilling_(false),
|
||||||
|
buffer_(xStreamBufferCreateWithCaps(kBufferSize, 1, MALLOC_CAP_SPIRAM)),
|
||||||
|
tell_(wrapped_->CurrentPosition()) {}
|
||||||
|
|
||||||
|
ReadaheadSource::~ReadaheadSource() {
|
||||||
|
vStreamBufferDeleteWithCaps(buffer_);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ReadaheadSource::Read(cpp::span<std::byte> dest) -> ssize_t {
|
||||||
|
// Optismise for the most frequent case: the buffer already contains enough
|
||||||
|
// data for this call.
|
||||||
|
size_t bytes_read =
|
||||||
|
xStreamBufferReceive(buffer_, dest.data(), dest.size_bytes(), 0);
|
||||||
|
|
||||||
|
tell_ += bytes_read;
|
||||||
|
if (bytes_read == dest.size_bytes()) {
|
||||||
|
return bytes_read;
|
||||||
|
}
|
||||||
|
|
||||||
|
dest = dest.subspan(bytes_read);
|
||||||
|
|
||||||
|
// Are we currently fetching more bytes?
|
||||||
|
ssize_t extra_bytes = 0;
|
||||||
|
if (!is_refilling_) {
|
||||||
|
// No! Pass through directly to the wrapped source for the fastest
|
||||||
|
// response.
|
||||||
|
extra_bytes = wrapped_->Read(dest);
|
||||||
|
} else {
|
||||||
|
// Yes! Wait for the refill to catch up, then try again.
|
||||||
|
is_refilling_.wait(true);
|
||||||
|
extra_bytes =
|
||||||
|
xStreamBufferReceive(buffer_, dest.data(), dest.size_bytes(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No need to check whether the dest buffer is actually filled, since at this
|
||||||
|
// point we've read as many bytes as were available.
|
||||||
|
tell_ += extra_bytes;
|
||||||
|
bytes_read += extra_bytes;
|
||||||
|
|
||||||
|
// Before returning, make sure the readahead task is kicked off again.
|
||||||
|
ESP_LOGI(kTag, "triggering readahead");
|
||||||
|
is_refilling_ = true;
|
||||||
|
std::function<void(void)> refill = [this]() {
|
||||||
|
// Try to keep larger than most reasonable FAT sector sizes for more
|
||||||
|
// efficient disk reads.
|
||||||
|
constexpr size_t kMaxSingleRead = 1024 * 64;
|
||||||
|
std::byte working_buf[kMaxSingleRead];
|
||||||
|
for (;;) {
|
||||||
|
size_t bytes_to_read = std::min<size_t>(
|
||||||
|
kMaxSingleRead, xStreamBufferSpacesAvailable(buffer_));
|
||||||
|
if (bytes_to_read == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
size_t read = wrapped_->Read({working_buf, bytes_to_read});
|
||||||
|
if (read > 0) {
|
||||||
|
xStreamBufferSend(buffer_, working_buf, read, 0);
|
||||||
|
}
|
||||||
|
if (read < bytes_to_read) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is_refilling_ = false;
|
||||||
|
is_refilling_.notify_all();
|
||||||
|
};
|
||||||
|
worker_.Dispatch(refill);
|
||||||
|
|
||||||
|
return bytes_read;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ReadaheadSource::CanSeek() -> bool {
|
||||||
|
return wrapped_->CanSeek();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ReadaheadSource::SeekTo(int64_t destination, SeekFrom from) -> void {
|
||||||
|
// Seeking blows away all of our prefetched data. To do this safely, we
|
||||||
|
// first need to wait for the refill task to finish.
|
||||||
|
is_refilling_.wait(true);
|
||||||
|
// It's now safe to clear out the buffer.
|
||||||
|
xStreamBufferReset(buffer_);
|
||||||
|
|
||||||
|
wrapped_->SeekTo(destination, from);
|
||||||
|
|
||||||
|
// Make sure our tell is up to date with the new location.
|
||||||
|
tell_ = wrapped_->CurrentPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ReadaheadSource::CurrentPosition() -> int64_t {
|
||||||
|
return tell_;
|
||||||
|
}
|
||||||
|
} // namespace audio
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
namespace codecs {
|
namespace codecs {
|
||||||
|
|
||||||
[[maybe_unused]] static constexpr char kTag[] = "dec_buf";
|
[[maybe_unused]] static constexpr char kTag[] = "dec_buf";
|
||||||
static constexpr size_t kBufferSize = 1024 * 128;
|
static constexpr size_t kBufferSize = 1024 * 16;
|
||||||
static constexpr size_t kReadThreshold = 1024 * 8;
|
static constexpr size_t kReadThreshold = 1024 * 8;
|
||||||
|
|
||||||
SourceBuffer::SourceBuffer()
|
SourceBuffer::SourceBuffer()
|
||||||
|
|||||||
@@ -117,16 +117,15 @@ static auto CheckDatabase(leveldb::DB& db, locale::ICollator& col) -> bool {
|
|||||||
|
|
||||||
auto Database::Open(IFileGatherer& gatherer,
|
auto Database::Open(IFileGatherer& gatherer,
|
||||||
ITagParser& parser,
|
ITagParser& parser,
|
||||||
locale::ICollator& collator)
|
locale::ICollator& collator,
|
||||||
|
tasks::Worker& bg_worker)
|
||||||
-> cpp::result<Database*, DatabaseError> {
|
-> cpp::result<Database*, DatabaseError> {
|
||||||
// TODO(jacqueline): Why isn't compare_and_exchange_* available?
|
|
||||||
if (sIsDbOpen.exchange(true)) {
|
if (sIsDbOpen.exchange(true)) {
|
||||||
return cpp::fail(DatabaseError::ALREADY_OPEN);
|
return cpp::fail(DatabaseError::ALREADY_OPEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!leveldb::sBackgroundThread) {
|
if (!leveldb::sBackgroundThread) {
|
||||||
leveldb::sBackgroundThread.reset(
|
leveldb::sBackgroundThread = &bg_worker;
|
||||||
tasks::Worker::Start<tasks::Type::kDatabaseBackground>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::shared_ptr<tasks::Worker> worker(
|
std::shared_ptr<tasks::Worker> worker(
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
namespace leveldb {
|
namespace leveldb {
|
||||||
|
|
||||||
std::shared_ptr<tasks::Worker> sBackgroundThread;
|
tasks::Worker *sBackgroundThread = nullptr;
|
||||||
|
|
||||||
std::string ErrToStr(FRESULT err) {
|
std::string ErrToStr(FRESULT err) {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ class Database {
|
|||||||
};
|
};
|
||||||
static auto Open(IFileGatherer& file_gatherer,
|
static auto Open(IFileGatherer& file_gatherer,
|
||||||
ITagParser& tag_parser,
|
ITagParser& tag_parser,
|
||||||
locale::ICollator& collator)
|
locale::ICollator& collator,
|
||||||
|
tasks::Worker& bg_worker)
|
||||||
-> cpp::result<Database*, DatabaseError>;
|
-> cpp::result<Database*, DatabaseError>;
|
||||||
|
|
||||||
static auto Destroy() -> void;
|
static auto Destroy() -> void;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
namespace leveldb {
|
namespace leveldb {
|
||||||
|
|
||||||
extern std::shared_ptr<tasks::Worker> sBackgroundThread;
|
extern tasks::Worker* sBackgroundThread;
|
||||||
|
|
||||||
// Tracks the files locked by EspEnv::LockFile().
|
// Tracks the files locked by EspEnv::LockFile().
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
#include "spi.hpp"
|
#include "spi.hpp"
|
||||||
#include "system_events.hpp"
|
#include "system_events.hpp"
|
||||||
#include "tag_parser.hpp"
|
#include "tag_parser.hpp"
|
||||||
|
#include "tasks.hpp"
|
||||||
#include "touchwheel.hpp"
|
#include "touchwheel.hpp"
|
||||||
#include "track_queue.hpp"
|
#include "track_queue.hpp"
|
||||||
#include "ui_fsm.hpp"
|
#include "ui_fsm.hpp"
|
||||||
@@ -63,6 +64,10 @@ auto Booting::entry() -> void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(kTag, "starting bg worker");
|
||||||
|
sServices->bg_worker(std::unique_ptr<tasks::Worker>{
|
||||||
|
tasks::Worker::Start<tasks::Type::kDatabaseBackground>()});
|
||||||
|
|
||||||
ESP_LOGI(kTag, "installing remaining drivers");
|
ESP_LOGI(kTag, "installing remaining drivers");
|
||||||
sServices->samd(std::unique_ptr<drivers::Samd>(drivers::Samd::Create()));
|
sServices->samd(std::unique_ptr<drivers::Samd>(drivers::Samd::Create()));
|
||||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "samd.hpp"
|
#include "samd.hpp"
|
||||||
#include "storage.hpp"
|
#include "storage.hpp"
|
||||||
#include "tag_parser.hpp"
|
#include "tag_parser.hpp"
|
||||||
|
#include "tasks.hpp"
|
||||||
#include "touchwheel.hpp"
|
#include "touchwheel.hpp"
|
||||||
#include "track_queue.hpp"
|
#include "track_queue.hpp"
|
||||||
|
|
||||||
@@ -111,6 +112,15 @@ class ServiceLocator {
|
|||||||
collator_ = std::move(i);
|
collator_ = std::move(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto bg_worker() -> tasks::Worker& {
|
||||||
|
assert(bg_worker_ != nullptr);
|
||||||
|
return *bg_worker_;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto bg_worker(std::unique_ptr<tasks::Worker> w) -> void {
|
||||||
|
bg_worker_ = std::move(w);
|
||||||
|
}
|
||||||
|
|
||||||
// Not copyable or movable.
|
// Not copyable or movable.
|
||||||
ServiceLocator(const ServiceLocator&) = delete;
|
ServiceLocator(const ServiceLocator&) = delete;
|
||||||
ServiceLocator& operator=(const ServiceLocator&) = delete;
|
ServiceLocator& operator=(const ServiceLocator&) = delete;
|
||||||
@@ -129,6 +139,8 @@ class ServiceLocator {
|
|||||||
std::unique_ptr<database::ITagParser> tag_parser_;
|
std::unique_ptr<database::ITagParser> tag_parser_;
|
||||||
std::unique_ptr<locale::ICollator> collator_;
|
std::unique_ptr<locale::ICollator> collator_;
|
||||||
|
|
||||||
|
std::unique_ptr<tasks::Worker> bg_worker_;
|
||||||
|
|
||||||
drivers::SdState sd_;
|
drivers::SdState sd_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ void Running::entry() {
|
|||||||
|
|
||||||
ESP_LOGI(kTag, "opening database");
|
ESP_LOGI(kTag, "opening database");
|
||||||
sFileGatherer = new database::FileGathererImpl();
|
sFileGatherer = new database::FileGathererImpl();
|
||||||
auto database_res = database::Database::Open(
|
auto database_res =
|
||||||
*sFileGatherer, sServices->tag_parser(), sServices->collator());
|
database::Database::Open(*sFileGatherer, sServices->tag_parser(),
|
||||||
|
sServices->collator(), sServices->bg_worker());
|
||||||
if (database_res.has_error()) {
|
if (database_res.has_error()) {
|
||||||
ESP_LOGW(kTag, "failed to open!");
|
ESP_LOGW(kTag, "failed to open!");
|
||||||
events::System().Dispatch(StorageError{});
|
events::System().Dispatch(StorageError{});
|
||||||
|
|||||||
+1
-2
@@ -195,8 +195,7 @@ Worker::~Worker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
template <>
|
template <>
|
||||||
auto Worker::Dispatch(const std::function<void(void)>& fn)
|
auto Worker::Dispatch(const std::function<void(void)> fn) -> std::future<void> {
|
||||||
-> std::future<void> {
|
|
||||||
std::shared_ptr<std::promise<void>> promise =
|
std::shared_ptr<std::promise<void>> promise =
|
||||||
std::make_shared<std::promise<void>>();
|
std::make_shared<std::promise<void>>();
|
||||||
WorkItem item{
|
WorkItem item{
|
||||||
|
|||||||
+2
-2
@@ -106,7 +106,7 @@ class Worker {
|
|||||||
* asynchronously returns the result as a future.
|
* asynchronously returns the result as a future.
|
||||||
*/
|
*/
|
||||||
template <typename T>
|
template <typename T>
|
||||||
auto Dispatch(const std::function<T(void)>& fn) -> std::future<T> {
|
auto Dispatch(const std::function<T(void)> fn) -> std::future<T> {
|
||||||
std::shared_ptr<std::promise<T>> promise =
|
std::shared_ptr<std::promise<T>> promise =
|
||||||
std::make_shared<std::promise<T>>();
|
std::make_shared<std::promise<T>>();
|
||||||
WorkItem item{
|
WorkItem item{
|
||||||
@@ -125,6 +125,6 @@ class Worker {
|
|||||||
|
|
||||||
/* Specialisation of Evaluate for functions that return nothing. */
|
/* Specialisation of Evaluate for functions that return nothing. */
|
||||||
template <>
|
template <>
|
||||||
auto Worker::Dispatch(const std::function<void(void)>& fn) -> std::future<void>;
|
auto Worker::Dispatch(const std::function<void(void)> fn) -> std::future<void>;
|
||||||
|
|
||||||
} // namespace tasks
|
} // namespace tasks
|
||||||
|
|||||||
Reference in New Issue
Block a user