summaryrefslogtreecommitdiff
path: root/src/audio/audio_fsm.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/audio/audio_fsm.cpp')
-rw-r--r--src/audio/audio_fsm.cpp392
1 files changed, 273 insertions, 119 deletions
diff --git a/src/audio/audio_fsm.cpp b/src/audio/audio_fsm.cpp
index 7800802e..a8f1260f 100644
--- a/src/audio/audio_fsm.cpp
+++ b/src/audio/audio_fsm.cpp
@@ -13,6 +13,8 @@
#include "audio_sink.hpp"
#include "bluetooth_types.hpp"
+#include "cppbor.h"
+#include "cppbor_parse.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
@@ -29,11 +31,11 @@
#include "future_fetcher.hpp"
#include "i2s_audio_output.hpp"
#include "i2s_dac.hpp"
-#include "idf_additions.h"
#include "nvs.hpp"
#include "sample.hpp"
#include "service_locator.hpp"
#include "system_events.hpp"
+#include "tinyfsm.hpp"
#include "track.hpp"
#include "track_queue.hpp"
#include "wm8523.hpp"
@@ -51,8 +53,184 @@ std::shared_ptr<I2SAudioOutput> AudioState::sI2SOutput;
std::shared_ptr<BluetoothAudioOutput> AudioState::sBtOutput;
std::shared_ptr<IAudioOutput> AudioState::sOutput;
-std::optional<database::TrackId> AudioState::sCurrentTrack;
-bool AudioState::sIsPlaybackAllowed;
+// Two seconds of samples for two channels, at a representative sample rate.
+constexpr size_t kDrainLatencySamples = 48000 * 2 * 2;
+constexpr size_t kDrainBufferSize =
+ sizeof(sample::Sample) * kDrainLatencySamples;
+
+StreamBufferHandle_t AudioState::sDrainBuffer;
+std::optional<IAudioOutput::Format> AudioState::sDrainFormat;
+
+std::shared_ptr<TrackInfo> AudioState::sCurrentTrack;
+uint64_t AudioState::sCurrentSamples;
+bool AudioState::sCurrentTrackIsFromQueue;
+
+std::shared_ptr<TrackInfo> AudioState::sNextTrack;
+uint64_t AudioState::sNextTrackCueSamples;
+bool AudioState::sNextTrackIsFromQueue;
+
+bool AudioState::sIsResampling;
+bool AudioState::sIsPaused = true;
+
+auto AudioState::currentPositionSeconds() -> std::optional<uint32_t> {
+ if (!sCurrentTrack || !sDrainFormat) {
+ return {};
+ }
+ return sCurrentSamples /
+ (sDrainFormat->num_channels * sDrainFormat->sample_rate);
+}
+
+void AudioState::react(const QueueUpdate& ev) {
+ SetTrack cmd{
+ .new_track = std::monostate{},
+ .seek_to_second = {},
+ .transition = SetTrack::Transition::kHardCut,
+ };
+
+ auto current = sServices->track_queue().current();
+ if (current) {
+ cmd.new_track = *current;
+ }
+
+ switch (ev.reason) {
+ case QueueUpdate::kExplicitUpdate:
+ if (!ev.current_changed) {
+ return;
+ }
+ sNextTrackIsFromQueue = true;
+ cmd.transition = SetTrack::Transition::kHardCut;
+ break;
+ case QueueUpdate::kRepeatingLastTrack:
+ sNextTrackIsFromQueue = true;
+ cmd.transition = SetTrack::Transition::kGapless;
+ break;
+ case QueueUpdate::kTrackFinished:
+ if (!ev.current_changed) {
+ cmd.new_track = std::monostate{};
+ } else {
+ sNextTrackIsFromQueue = true;
+ }
+ cmd.transition = SetTrack::Transition::kGapless;
+ break;
+ case QueueUpdate::kDeserialised:
+ default:
+ // The current track is deserialised separately in order to retain seek
+ // position.
+ return;
+ }
+
+ tinyfsm::FsmList<AudioState>::dispatch(cmd);
+}
+
+void AudioState::react(const SetTrack& ev) {
+ if (ev.transition == SetTrack::Transition::kHardCut) {
+ sCurrentTrack.reset();
+ sCurrentSamples = 0;
+ sCurrentTrackIsFromQueue = false;
+ clearDrainBuffer();
+ }
+
+ if (std::holds_alternative<std::monostate>(ev.new_track)) {
+ ESP_LOGI(kTag, "playback finished, awaiting drain");
+ sFileSource->SetPath();
+ awaitEmptyDrainBuffer();
+ sCurrentTrack.reset();
+ sDrainFormat.reset();
+ sCurrentSamples = 0;
+ sCurrentTrackIsFromQueue = false;
+ transit<states::Standby>();
+ return;
+ }
+
+ // Move the rest of the work to a background worker, since it may require db
+ // lookups to resolve a track id into a path.
+ auto new_track = ev.new_track;
+ uint32_t seek_to = ev.seek_to_second.value_or(0);
+ sServices->bg_worker().Dispatch<void>([=]() {
+ std::optional<std::string> path;
+ if (std::holds_alternative<database::TrackId>(new_track)) {
+ auto db = sServices->database().lock();
+ if (db) {
+ path = db->getTrackPath(std::get<database::TrackId>(new_track));
+ }
+ } else if (std::holds_alternative<std::string>(new_track)) {
+ path = std::get<std::string>(new_track);
+ }
+
+ if (path) {
+ sFileSource->SetPath(*path, seek_to);
+ } else {
+ sFileSource->SetPath();
+ }
+ });
+}
+
+void AudioState::react(const TogglePlayPause& ev) {
+ sIsPaused = !ev.set_to.value_or(sIsPaused);
+ if (!sIsPaused && is_in_state<states::Standby>() && sCurrentTrack) {
+ transit<states::Playback>();
+ } else if (sIsPaused && is_in_state<states::Playback>()) {
+ transit<states::Standby>();
+ }
+}
+
+void AudioState::react(const internal::StreamStarted& ev) {
+ sDrainFormat = ev.dst_format;
+ sIsResampling = ev.src_format != ev.dst_format;
+
+ sNextTrack = ev.track;
+ sNextTrackCueSamples = sCurrentSamples + (kDrainLatencySamples / 2);
+
+ ESP_LOGI(kTag, "new stream %s %u ch @ %lu hz (resample=%i)",
+ ev.track->uri.c_str(), sDrainFormat->num_channels,
+ sDrainFormat->sample_rate, sIsResampling);
+}
+
+void AudioState::react(const internal::StreamEnded&) {
+ ESP_LOGI(kTag, "stream ended");
+
+ if (sCurrentTrackIsFromQueue) {
+ sServices->track_queue().finish();
+ } else {
+ tinyfsm::FsmList<AudioState>::dispatch(SetTrack{
+ .new_track = std::monostate{},
+ .seek_to_second = {},
+ .transition = SetTrack::Transition::kGapless,
+ });
+ }
+}
+
+void AudioState::react(const internal::StreamUpdate& ev) {
+ sCurrentSamples += ev.samples_sunk;
+
+ if (sNextTrack && sCurrentSamples >= sNextTrackCueSamples) {
+ ESP_LOGI(kTag, "next track is now sinking");
+ sCurrentTrack = sNextTrack;
+ sCurrentSamples -= sNextTrackCueSamples;
+ sCurrentSamples += sNextTrack->start_offset.value_or(0) *
+ (sDrainFormat->num_channels * sDrainFormat->sample_rate);
+ sCurrentTrackIsFromQueue = sNextTrackIsFromQueue;
+
+ sNextTrack.reset();
+ sNextTrackCueSamples = 0;
+ sNextTrackIsFromQueue = false;
+ }
+
+ if (sCurrentTrack) {
+ PlaybackUpdate event{
+ .current_track = sCurrentTrack,
+ .track_position = currentPositionSeconds(),
+ .paused = !is_in_state<states::Playback>(),
+ };
+ events::System().Dispatch(event);
+ events::Ui().Dispatch(event);
+ }
+
+ if (sCurrentTrack && !sIsPaused && !is_in_state<states::Playback>()) {
+ ESP_LOGI(kTag, "ready to play!");
+ transit<states::Playback>();
+ }
+}
void AudioState::react(const system_fsm::BluetoothEvent& ev) {
if (ev.event != drivers::bluetooth::Event::kConnectionStateChanged) {
@@ -146,7 +324,7 @@ void AudioState::react(const SetVolumeBalance& ev) {
void AudioState::react(const OutputModeChanged& ev) {
ESP_LOGI(kTag, "output mode changed");
auto new_mode = sServices->nvs().OutputMode();
- sOutput->SetMode(IAudioOutput::Modes::kOff);
+ sOutput->mode(IAudioOutput::Modes::kOff);
switch (new_mode) {
case drivers::NvsStorage::Output::kBluetooth:
sOutput = sBtOutput;
@@ -155,7 +333,7 @@ void AudioState::react(const OutputModeChanged& ev) {
sOutput = sI2SOutput;
break;
}
- sOutput->SetMode(IAudioOutput::Modes::kOnPaused);
+ sOutput->mode(IAudioOutput::Modes::kOnPaused);
sSampleConverter->SetOutput(sOutput);
// Bluetooth volume isn't 'changed' until we've connected to a device.
@@ -167,15 +345,41 @@ void AudioState::react(const OutputModeChanged& ev) {
}
}
-auto AudioState::playTrack(database::TrackId id) -> void {
- sCurrentTrack = id;
- sServices->bg_worker().Dispatch<void>([=]() {
- auto db = sServices->database().lock();
- if (!db) {
- return;
+auto AudioState::clearDrainBuffer() -> void {
+ // Tell the decoder to stop adding new samples. This might not take effect
+ // immediately, since the decoder might currently be stuck waiting for space
+ // to become available in the drain buffer.
+ sFileSource->SetPath();
+
+ auto mode = sOutput->mode();
+ if (mode == IAudioOutput::Modes::kOnPlaying) {
+ // If we're currently playing, then the drain buffer will be actively
+ // draining on its own. Just keep trying to reset until it works.
+ while (xStreamBufferReset(sDrainBuffer) != pdPASS) {
}
- sFileSource->SetPath(db->getTrackPath(id));
- });
+ } else {
+ // If we're not currently playing, then we need to actively pull samples
+ // out of the drain buffer to unblock the decoder.
+ while (!xStreamBufferIsEmpty(sDrainBuffer)) {
+ // Read a little to unblock the decoder.
+ uint8_t drain[2048];
+ xStreamBufferReceive(sDrainBuffer, drain, sizeof(drain), 0);
+
+ // Try to quickly discard the rest.
+ xStreamBufferReset(sDrainBuffer);
+ }
+ }
+}
+
+auto AudioState::awaitEmptyDrainBuffer() -> void {
+ if (is_in_state<states::Playback>()) {
+ for (int i = 0; i < 10 && !xStreamBufferIsEmpty(sDrainBuffer); i++) {
+ vTaskDelay(pdMS_TO_TICKS(250));
+ }
+ }
+ if (!xStreamBufferIsEmpty(sDrainBuffer)) {
+ clearDrainBuffer();
+ }
}
auto AudioState::commitVolume() -> void {
@@ -192,29 +396,8 @@ auto AudioState::commitVolume() -> void {
}
}
-auto AudioState::readyToPlay() -> bool {
- return sCurrentTrack.has_value() && sIsPlaybackAllowed;
-}
-
-void AudioState::react(const TogglePlayPause& ev) {
- sIsPlaybackAllowed = !sIsPlaybackAllowed;
- if (readyToPlay()) {
- if (!is_in_state<states::Playback>()) {
- transit<states::Playback>();
- }
- } else {
- if (!is_in_state<states::Standby>()) {
- transit<states::Standby>();
- }
- }
-}
-
namespace states {
-// Two seconds of samples for two channels, at a representative sample rate.
-constexpr size_t kDrainBufferSize = sizeof(sample::Sample) * 48000 * 4;
-static StreamBufferHandle_t sDrainBuffer;
-
void Uninitialised::react(const system_fsm::BootComplete& ev) {
sServices = ev.services;
@@ -246,7 +429,7 @@ void Uninitialised::react(const system_fsm::BootComplete& ev) {
} else {
sOutput = sBtOutput;
}
- sOutput->SetMode(IAudioOutput::Modes::kOnPaused);
+ sOutput->mode(IAudioOutput::Modes::kOnPaused);
events::Ui().Dispatch(VolumeLimitChanged{
.new_limit_db =
@@ -270,35 +453,14 @@ void Uninitialised::react(const system_fsm::BootComplete& ev) {
transit<Standby>();
}
-void Standby::react(const PlayFile& ev) {
- sFileSource->SetPath(ev.filename);
-}
-
-void Playback::react(const PlayFile& ev) {
- sFileSource->SetPath(ev.filename);
-}
-
-void Standby::react(const internal::InputFileOpened& ev) {
- if (readyToPlay()) {
- transit<Playback>();
- }
-}
-
-void Standby::react(const QueueUpdate& ev) {
- auto current_track = sServices->track_queue().current();
- if (!current_track || (sCurrentTrack && (*sCurrentTrack == *current_track))) {
- return;
- }
- playTrack(*current_track);
-}
-
static const char kQueueKey[] = "audio:queue";
+static const char kCurrentFileKey[] = "audio:current";
void Standby::react(const system_fsm::KeyLockChanged& ev) {
if (!ev.locking) {
return;
}
- sServices->bg_worker().Dispatch<void>([]() {
+ sServices->bg_worker().Dispatch<void>([this]() {
auto db = sServices->database().lock();
if (!db) {
return;
@@ -310,6 +472,14 @@ void Standby::react(const system_fsm::KeyLockChanged& ev) {
return;
}
db->put(kQueueKey, queue.serialise());
+
+ if (sCurrentTrack) {
+ cppbor::Array current_track{
+ cppbor::Tstr{sCurrentTrack->uri},
+ cppbor::Uint{currentPositionSeconds().value_or(0)},
+ };
+ db->put(kCurrentFileKey, current_track.toString());
+ }
});
}
@@ -319,82 +489,66 @@ void Standby::react(const system_fsm::StorageMounted& ev) {
if (!db) {
return;
}
- auto res = db->get(kQueueKey);
- if (res) {
+
+ // Restore the currently playing file before restoring the queue. This way,
+ // we can fall back to restarting the queue's current track if there's any
+ // issue restoring the current file.
+ auto current = db->get(kCurrentFileKey);
+ if (current) {
+ // Again, ensure we don't boot-loop by trying to play a track that causes
+ // a crash over and over again.
+ db->put(kCurrentFileKey, "");
+ auto [parsed, unused, err] = cppbor::parse(
+ reinterpret_cast<uint8_t*>(current->data()), current->size());
+ if (parsed->type() == cppbor::ARRAY) {
+ std::string filename = parsed->asArray()->get(0)->asTstr()->value();
+ uint32_t pos = parsed->asArray()->get(1)->asUint()->value();
+
+ events::Audio().Dispatch(SetTrack{
+ .new_track = filename,
+ .seek_to_second = pos,
+ .transition = SetTrack::Transition::kHardCut,
+ });
+ }
+ }
+
+ auto queue = db->get(kQueueKey);
+ if (queue) {
// Don't restore the same queue again. This ideally should do nothing,
// but guards against bad edge cases where restoring the queue ends up
// causing a crash.
db->put(kQueueKey, "");
- sServices->track_queue().deserialise(*res);
+ sServices->track_queue().deserialise(*queue);
}
});
}
void Playback::entry() {
- ESP_LOGI(kTag, "beginning playback");
- sOutput->SetMode(IAudioOutput::Modes::kOnPlaying);
-
- events::System().Dispatch(PlaybackStarted{});
- events::Ui().Dispatch(PlaybackStarted{});
-}
-
-void Playback::exit() {
- ESP_LOGI(kTag, "finishing playback");
- sOutput->SetMode(IAudioOutput::Modes::kOnPaused);
-
- // Stash the current volume now, in case it changed during playback, since we
- // might be powering off soon.
- commitVolume();
+ ESP_LOGI(kTag, "audio output resumed");
+ sOutput->mode(IAudioOutput::Modes::kOnPlaying);
- events::System().Dispatch(PlaybackStopped{});
- events::Ui().Dispatch(PlaybackStopped{});
-}
+ PlaybackUpdate event{
+ .current_track = sCurrentTrack,
+ .track_position = currentPositionSeconds(),
+ .paused = false,
+ };
-void Playback::react(const system_fsm::HasPhonesChanged& ev) {
- if (!ev.has_headphones) {
- transit<Standby>();
- }
+ events::System().Dispatch(event);
+ events::Ui().Dispatch(event);
}
-void Playback::react(const QueueUpdate& ev) {
- if (!ev.current_changed) {
- return;
- }
- auto current_track = sServices->track_queue().current();
- if (!current_track) {
- sFileSource->SetPath();
- sCurrentTrack.reset();
- transit<Standby>();
- return;
- }
- playTrack(*current_track);
-}
-
-void Playback::react(const PlaybackUpdate& ev) {
- ESP_LOGI(kTag, "elapsed: %lu, total: %lu", ev.seconds_elapsed,
- ev.track->duration);
-}
-
-void Playback::react(const internal::InputFileOpened& ev) {}
+void Playback::exit() {
+ ESP_LOGI(kTag, "audio output paused");
+ sOutput->mode(IAudioOutput::Modes::kOnPaused);
-void Playback::react(const internal::InputFileClosed& ev) {}
+ PlaybackUpdate event{
+ .current_track = sCurrentTrack,
+ .track_position = currentPositionSeconds(),
+ .paused = true,
+ };
-void Playback::react(const internal::InputFileFinished& ev) {
- ESP_LOGI(kTag, "finished playing file");
- sServices->track_queue().finish();
- if (!sServices->track_queue().current()) {
- for (int i = 0; i < 20; i++) {
- if (xStreamBufferIsEmpty(sDrainBuffer)) {
- break;
- }
- vTaskDelay(pdMS_TO_TICKS(200));
- }
- transit<Standby>();
- }
-}
-
-void Playback::react(const internal::AudioPipelineIdle& ev) {
- transit<Standby>();
+ events::System().Dispatch(event);
+ events::Ui().Dispatch(event);
}
} // namespace states