diff --git a/firmware/keira/platformio.ini b/firmware/keira/platformio.ini index 27d5efb8..411d95c1 100644 --- a/firmware/keira/platformio.ini +++ b/firmware/keira/platformio.ini @@ -28,5 +28,6 @@ lib_deps = https://github.com/moononournation/arduino-nofrendo.git bitbank2/PNGenc @ ^1.1.1 bblanchon/ArduinoJson @ ^7.0.4 + https://github.com/earlephilhower/ESP8266Audio.git lib_extra_dirs = ./lib extra_scripts = targets.py diff --git a/firmware/keira/sdcard/mods/beyond_music.mod b/firmware/keira/sdcard/mods/beyond_music.mod new file mode 100644 index 00000000..1b6967fd Binary files /dev/null and b/firmware/keira/sdcard/mods/beyond_music.mod differ diff --git a/firmware/keira/sdcard/mods/credits.md b/firmware/keira/sdcard/mods/credits.md new file mode 100644 index 00000000..69f45ff2 --- /dev/null +++ b/firmware/keira/sdcard/mods/credits.md @@ -0,0 +1,19 @@ +# space_debris.mod +Artist: Markus Captain Kaarlonen - Space Debris +https://markuskaarlonen.com/space-debris + +# elysium.mod +Jester - Elysium +https://modarchive.org/index.php?request=view_by_moduleid&query=40475 + +# stardust_memories.mod +Jester - Stardust Memories +https://modarchive.org/index.php?request=view_by_moduleid&query=59344 + +# enigma.mod +firefox - Enigma +https://modarchive.org/index.php?request=view_by_moduleid&query=42146 + +# beyond_music.mod +Markus Captain Kaarlonen - Beyond Music +https://modarchive.org/index.php?request=view_by_moduleid&query=83779 diff --git a/firmware/keira/sdcard/mods/elysium.mod b/firmware/keira/sdcard/mods/elysium.mod new file mode 100644 index 00000000..1c889ee0 Binary files /dev/null and b/firmware/keira/sdcard/mods/elysium.mod differ diff --git a/firmware/keira/sdcard/mods/enigma.mod b/firmware/keira/sdcard/mods/enigma.mod new file mode 100644 index 00000000..cd93aeb7 Binary files /dev/null and b/firmware/keira/sdcard/mods/enigma.mod differ diff --git a/firmware/keira/sdcard/mods/space_debris.mod b/firmware/keira/sdcard/mods/space_debris.mod new file mode 100644 index 00000000..141cdd68 Binary files /dev/null and b/firmware/keira/sdcard/mods/space_debris.mod differ diff --git a/firmware/keira/sdcard/mods/stardust_memories.mod b/firmware/keira/sdcard/mods/stardust_memories.mod new file mode 100644 index 00000000..1d2a39f4 Binary files /dev/null and b/firmware/keira/sdcard/mods/stardust_memories.mod differ diff --git a/firmware/keira/src/apps/icons/music.h b/firmware/keira/src/apps/icons/music.h new file mode 100644 index 00000000..7074f0cd --- /dev/null +++ b/firmware/keira/src/apps/icons/music.h @@ -0,0 +1,584 @@ +// This is a generated file, do not edit. +// clang-format off +#include +const uint16_t music_width = 24; +const uint16_t music_height = 24; +const uint16_t music[] = { + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x0841, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x531f, + 0x529f, + 0x5a9f, + 0x0841, + 0x0841, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x545f, + 0x535f, + 0x52bf, + 0x529f, + 0x629f, + 0x8a9f, + 0x0841, + 0x0841, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x563f, + 0x54bf, + 0x53bf, + 0x52bf, + 0x529f, + 0x5a9f, + 0x829f, + 0xa29f, + 0xd29f, + 0x0841, + 0x0841, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x57df, + 0x0841, + 0x0841, + 0x53ff, + 0x52ff, + 0x529f, + 0x5a9f, + 0x7a9f, + 0xa29f, + 0xca9f, + 0xea9f, + 0xfa9b, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x57fa, + 0x0841, + 0x0000, + 0x0841, + 0x0841, + 0x0841, + 0x529f, + 0x5a9f, + 0x6a9f, + 0x9a9f, + 0xba9f, + 0xf29f, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x57f4, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x0841, + 0x0841, + 0x6a9f, + 0x8a9f, + 0xb29f, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x57ee, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x0841, + 0x829f, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x57ea, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x5a9f, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x5fea, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x529f, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x7fea, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x531f, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0xa7ea, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x545f, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x0841, + 0x0841, + 0x0841, + 0xd7ea, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x563f, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0xfaca, + 0xfb8a, + 0xfcea, + 0xfe0a, + 0xffaa, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x57df, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0xfa8a, + 0xfa8a, + 0xfaaa, + 0xfb8a, + 0xfc8a, + 0xfdaa, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x57fa, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0xfa8a, + 0xfa8a, + 0xfa8a, + 0xfa8a, + 0xfb4a, + 0xfc2a, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x0841, + 0x0841, + 0x0841, + 0x57f3, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0xfa8a, + 0xfa8a, + 0xfa8a, + 0xfa8a, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x87ea, + 0x67ea, + 0x57ea, + 0x57ea, + 0x57ee, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x0841, + 0x0841, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0xe7ea, + 0xb7ea, + 0x8fea, + 0x67ea, + 0x57ea, + 0x57ea, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0xff2a, + 0xe7ea, + 0xbfea, + 0x9fea, + 0x6fea, + 0x5fea, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0xfeca, + 0xefea, + 0xcfea, + 0x9fea, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0841, + 0x0841, + 0x0841, + 0x0841, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, + 0x0000, +}; +// clang-format on diff --git a/firmware/keira/src/apps/icons/music.png b/firmware/keira/src/apps/icons/music.png new file mode 100644 index 00000000..74370ad7 Binary files /dev/null and b/firmware/keira/src/apps/icons/music.png differ diff --git a/firmware/keira/src/apps/icons/music.xcf b/firmware/keira/src/apps/icons/music.xcf new file mode 100644 index 00000000..ad7415f5 Binary files /dev/null and b/firmware/keira/src/apps/icons/music.xcf differ diff --git a/firmware/keira/src/apps/launcher.cpp b/firmware/keira/src/apps/launcher.cpp index 065d7628..6d42cfd2 100644 --- a/firmware/keira/src/apps/launcher.cpp +++ b/firmware/keira/src/apps/launcher.cpp @@ -27,6 +27,7 @@ #include "nes/nesapp.h" #include "ftp/ftp_server.h" #include "weather/weather.h" +#include "modplayer/modplayer.h" #include "liltracker/liltracker.h" #include "icons/demos.h" @@ -44,6 +45,7 @@ #include "icons/bin.h" #include "icons/lua.h" #include "icons/js.h" +#include "icons/music.h" LauncherApp::LauncherApp() : App("Menu") { } @@ -148,6 +150,8 @@ const menu_icon_t* get_file_icon(const String& filename) { return &lua_img; } else if (lowerCasedFileName.endsWith(".js")) { return &js_img; + } else if (lowerCasedFileName.endsWith(".mod")) { + return &music; } else { return &normalfile_img; } @@ -164,6 +168,8 @@ const uint16_t get_file_color(const String& filename) { return lilka::colors::Maya_blue; } else if (lowerCasedFileName.endsWith(".js")) { return lilka::colors::Butterscotch; + } else if (lowerCasedFileName.endsWith(".mod")) { + return lilka::colors::Pink_lace; } else { return lilka::colors::Light_gray; } @@ -272,6 +278,8 @@ void LauncherApp::selectFile(String path) { AppManager::getInstance()->runApp(new LuaFileRunnerApp(path)); } else if (lowerCasedPath.endsWith(".js")) { AppManager::getInstance()->runApp(new MJSApp(path)); + } else if (lowerCasedPath.endsWith(".mod")) { + AppManager::getInstance()->runApp(new ModPlayerApp(path)); } else if (lowerCasedPath.endsWith(".lt")) { AppManager::getInstance()->runApp(new LilTrackerApp(path)); } else { diff --git a/firmware/keira/src/apps/modplayer/analyzer.cpp b/firmware/keira/src/apps/modplayer/analyzer.cpp new file mode 100644 index 00000000..ee54f9cc --- /dev/null +++ b/firmware/keira/src/apps/modplayer/analyzer.cpp @@ -0,0 +1,53 @@ +#include "analyzer.h" + +AudioOutputAnalyzer::AudioOutputAnalyzer(AudioOutput* sink, int16_t bufferDivisor) : + sink(sink), bufferDivisor(bufferDivisor) { + memset(buffer, 0, sizeof(buffer)); +} + +AudioOutputAnalyzer::~AudioOutputAnalyzer() { +} + +bool AudioOutputAnalyzer::SetRate(int hz) { + return sink->SetRate(hz); +} + +bool AudioOutputAnalyzer::SetBitsPerSample(int bits) { + return sink->SetBitsPerSample(bits); +} + +bool AudioOutputAnalyzer::SetChannels(int chan) { + return sink->SetChannels(chan); +} + +bool AudioOutputAnalyzer::SetGain(float f) { + return sink->SetGain(f); +} + +bool AudioOutputAnalyzer::begin() { + return sink->begin(); +} + +bool AudioOutputAnalyzer::ConsumeSample(int16_t sample[2]) { + bool res = sink->ConsumeSample(sample); + if (res) { + buffer[bufferPos++ / bufferDivisor] = (sample[0] + sample[1]) / 2; + if (bufferPos / bufferDivisor >= ANALYZER_BUFFER_SIZE) { + bufferPos = 0; + } + } + return res; +} + +int16_t AudioOutputAnalyzer::readBuffer(int16_t* dest) { + memcpy(dest, buffer, ANALYZER_BUFFER_SIZE * sizeof(int16_t)); + return ANALYZER_BUFFER_SIZE; +} + +int16_t AudioOutputAnalyzer::getBufferHead() { + return bufferPos / bufferDivisor; +} + +bool AudioOutputAnalyzer::stop() { + return sink->stop(); +} diff --git a/firmware/keira/src/apps/modplayer/analyzer.h b/firmware/keira/src/apps/modplayer/analyzer.h new file mode 100644 index 00000000..25e3ecdd --- /dev/null +++ b/firmware/keira/src/apps/modplayer/analyzer.h @@ -0,0 +1,30 @@ +#include + +#define ANALYZER_BUFFER_SIZE 256 + +class AudioOutputAnalyzer : public AudioOutput { +public: + // bufferDivisor - кількість семплів на один запис в буфер. + // Значення "4" означає, що кожен 4-й семпл буде записаний в буфер. + // Це дасть красивіший графік, але зменшить частоту оновлення + // (але цього не буде помітно, оскільки швидкість наповнення буфера в рази вища за частоту малювання графіка). + // Значення 1 відображає кожен семпл, але графік буде більш хаотичним (зате виглядає круто!) + // Значення 32 (та вище) створить графік, який "пливе" по екрану, але не відображає деталей. + explicit AudioOutputAnalyzer(AudioOutput* sink, int16_t bufferDivisor = 2); + virtual ~AudioOutputAnalyzer() override; + virtual bool SetRate(int hz) override; + virtual bool SetBitsPerSample(int bits) override; + virtual bool SetChannels(int chan) override; + virtual bool SetGain(float f) override; + virtual bool begin() override; + virtual bool ConsumeSample(int16_t sample[2]) override; + virtual bool stop() override; + int16_t readBuffer(int16_t* dest); + int16_t getBufferHead(); + +protected: + AudioOutput* sink; + int16_t bufferPos = 0; + int16_t bufferDivisor = 0; + int16_t buffer[ANALYZER_BUFFER_SIZE]; +}; diff --git a/firmware/keira/src/apps/modplayer/modplayer.cpp b/firmware/keira/src/apps/modplayer/modplayer.cpp new file mode 100644 index 00000000..78f98d68 --- /dev/null +++ b/firmware/keira/src/apps/modplayer/modplayer.cpp @@ -0,0 +1,240 @@ +// +// ModPlayer – програвач MOD-файлів на базі бібліотеки ESP8266Audio для Lilka зі звуковим модулем PCM5102. +// Автори: Олексій "Alder" Деркач (https://github.com/alder) та Андрій "and3rson" Дунай (https://github.com/and3rson) +// Детальніше про формат та його історію — https://en.wikipedia.org/wiki/MOD_(file_format) +// Найбільший архів з музикою у форматі MOD – https://modarchive.org/ +// + +#include +#include +#include +#include +#include + +#include "modplayer.h" + +ModPlayerApp::ModPlayerApp(String path) : + App("MODPlayer", 0, 0, lilka::display.width(), lilka::display.height()), + playerCommandQueue(xQueueCreate(8, sizeof(PlayerCommand))) { + setFlags(AppFlags::APP_FLAG_FULLSCREEN); + // This app will run on core 1 since it's already more busy with drawing Keira stuff. + // However, player task will run on core 0 since it's less busy. /AD + setCore(1); + // Get local file name (path minus mount point) + fileName = lilka::fileutils.getLocalPathInfo(path).path; + playerMutex = xSemaphoreCreateMutex(); + xSemaphoreGive(playerMutex); +} + +void ModPlayerApp::run() { + // Start the player task on core 0 + xTaskCreatePinnedToCore( + [](void* arg) { + ModPlayerApp* app = static_cast(arg); + app->playTask(); + }, + "MODPlayer", + 8192, + this, + 1, + nullptr, + 0 + ); + + while (1) { + mainWindow(); + xSemaphoreTake(playerMutex, portMAX_DELAY); + PlayerTaskData info = playerTaskData; + xSemaphoreGive(playerMutex); + + lilka::State state = lilka::controller.getState(); + if (state.a.justPressed) { + PlayerCommand command = {.type = CMD_SET_PAUSED, .isPaused = !info.isPaused}; + xQueueSend(playerCommandQueue, &command, portMAX_DELAY); + }; + if (state.b.justPressed) { + // Exit app + PlayerCommand command = {.type = CMD_STOP}; + xQueueSend(playerCommandQueue, &command, portMAX_DELAY); + break; + }; + if (state.up.justPressed) { + PlayerCommand command = {.type = CMD_SET_GAIN, .gain = info.gain + 0.25f}; + xQueueSend(playerCommandQueue, &command, portMAX_DELAY); + }; + if (state.down.justPressed) { + PlayerCommand command = {.type = CMD_SET_GAIN, .gain = info.gain - 0.25f}; + xQueueSend(playerCommandQueue, &command, portMAX_DELAY); + }; + }; +} + +void ModPlayerApp::mainWindow() { + canvas->fillScreen(lilka::colors::Black); + + xSemaphoreTake(playerMutex, portMAX_DELAY); + bool shouldDrawAnalyzer = !playerTaskData.isPaused && !playerTaskData.isFinished; + xSemaphoreGive(playerMutex); + if (shouldDrawAnalyzer) { + int16_t analyzerBuffer[ANALYZER_BUFFER_SIZE]; + xSemaphoreTake(playerMutex, portMAX_DELAY); + playerTaskData.analyzer->readBuffer(analyzerBuffer); + int16_t head = playerTaskData.analyzer->getBufferHead(); + float gain = playerTaskData.gain; + xSemaphoreGive(playerMutex); + + int16_t prevX, prevY; + int16_t width = canvas->width(); + int16_t height = canvas->height(); + + constexpr int16_t HUE_SPEED_DIV = 4; + constexpr int16_t HUE_SCALE = 4; + int16_t yCenter = height * 5 / 7; + + int64_t time = millis(); + + for (int i = 0; i < ANALYZER_BUFFER_SIZE; i += 4) { + int x = i * width / ANALYZER_BUFFER_SIZE; + int index = (i + head) % ANALYZER_BUFFER_SIZE; + float amplitude = static_cast(analyzerBuffer[index]) / 32768 * gain; + int y = yCenter + static_cast(amplitude * height / 2); + if (i > 0) { + int16_t hue = (time / HUE_SPEED_DIV + i / HUE_SCALE) % 360; + canvas->drawLine(prevX, prevY, x, y, lilka::display.color565hsv(hue, 100, 100)); + } + prevX = x; + prevY = y; + } + } + + xSemaphoreTake(playerMutex, portMAX_DELAY); + // Copy playerTaskData to prevent blocking the mutex for too long + PlayerTaskData info = this->playerTaskData; + xSemaphoreGive(playerMutex); + + canvas->setFont(FONT_9x15); + canvas->setTextBound(32, 32, canvas->width() - 64, canvas->height() - 32); + canvas->setTextColor(lilka::colors::White); + canvas->setCursor(32, 32 + 15); + canvas->println("Програвач MOD"); + canvas->println("------------------------"); + canvas->println("A - Відтворення / пауза"); + canvas->setFont(FONT_9x15_SYMBOLS); + canvas->print("↑ / ↓"); + canvas->setFont(FONT_9x15); + canvas->println(" - Гучність"); + canvas->println("B - Вихід"); + canvas->println("------------------------"); + canvas->println("Гучність: " + String(info.gain)); + if (info.isFinished) canvas->println("Трек закінчився"); + + lilka::Canvas titleCanvas(canvas->width(), 20); + titleCanvas.fillScreen(lilka::colors::Black); + titleCanvas.setFont(FONT_9x15); + titleCanvas.setTextColor(lilka::display.color565hsv((millis() * 30) % 360, 100, 100)); + titleCanvas.drawTextAligned( + fileName.c_str(), titleCanvas.width() / 2, titleCanvas.height() / 2, lilka::ALIGN_CENTER, lilka::ALIGN_CENTER + ); + const uint16_t* titleCanvasFB = titleCanvas.getFramebuffer(); + uint16_t yOffset = canvas->height() - titleCanvas.height() - 8; + float time = millis() / 1500.0f; + for (int16_t x = 0; x < titleCanvas.width(); x++) { + int16_t yShift = sinf(time + x / 25.0f) * 4 + yOffset; + for (int16_t y = 0; y < titleCanvas.height(); y++) { + uint16_t color = titleCanvasFB[x + y * titleCanvas.width()]; + if (color) { + canvas->drawPixel(x, y + yShift, color); + } + } + } + queueDraw(); +} + +void ModPlayerApp::playTask() { + // Source/sink order: + // modSource -> modBufferSource -> mod -> analyzer -> out + + // Create output + AudioOutputI2S* out = new AudioOutputI2S(); + std::unique_ptr outPtr(out); // Auto-delete on task return + out->SetPinout(LILKA_I2S_BCLK, LILKA_I2S_LRCK, LILKA_I2S_DOUT); + + // Create output analyzer + playerTaskData.analyzer = new AudioOutputAnalyzer(out); + std::unique_ptr analyzerPtr(playerTaskData.analyzer); + + // Create source + AudioFileSourceSD* modSource = new AudioFileSourceSD(fileName.c_str()); + std::unique_ptr modSourcePtr(modSource); + + // Previously we directly fed modSource to mod, but it caused a lot of stuttering due to SPI bus contention between SD and display. + // However, MOD files are small enough to fit in memory, so we can read the whole file into a buffer and then feed it to mod! + // Why not use AudioFileSourceBuffer? Because it doesn't seem to work properly and does weird things, and its code is barely readable. + // So we read the file manually and then use AudioFileSourcePROGMEM to feed it to mod. (In ESP32, PROGMEM is an outdated misnomer: it works with RAM too) + // TODO: Задокументувати нюанс SPI bus contention між SD та дисплеєм. /AD + uint8_t* modFileData = new uint8_t[modSource->getSize()]; + std::unique_ptr modFileDataPtr(modFileData); + // Read the whole file into buffer + modSource->read(modFileData, modSource->getSize()); + // Create source buffer + AudioFileSourcePROGMEM* modBufferSource = new AudioFileSourcePROGMEM(modFileData, modSource->getSize()); + std::unique_ptr modBufferSourcePtr(modBufferSource); + + // Create MOD player + AudioGeneratorMOD* mod = new AudioGeneratorMOD(); + std::unique_ptr modPtr(mod); + mod->begin(modBufferSource, playerTaskData.analyzer); + + xSemaphoreTake(playerMutex, portMAX_DELAY); + playerTaskData.isPaused = false; + playerTaskData.isFinished = false; + playerTaskData.gain = 1.0f; + xSemaphoreGive(playerMutex); + + while (1) { + // Check for new command + PlayerCommand command; + if (xQueueReceive(playerCommandQueue, &command, 0) == pdTRUE) { + switch (command.type) { + case CMD_SET_PAUSED: + xSemaphoreTake(playerMutex, portMAX_DELAY); + playerTaskData.isPaused = command.isPaused; + xSemaphoreGive(playerMutex); + break; + case CMD_SET_GAIN: + xSemaphoreTake(playerMutex, portMAX_DELAY); + playerTaskData.gain = command.gain; + xSemaphoreGive(playerMutex); + if (playerTaskData.gain < 0) playerTaskData.gain = 0; + if (playerTaskData.gain > 4) playerTaskData.gain = 4; + out->SetGain(playerTaskData.gain); + break; + case CMD_STOP: + xSemaphoreTake(playerMutex, portMAX_DELAY); + playerTaskData.isFinished = true; + xSemaphoreGive(playerMutex); + break; + } + } + xSemaphoreTake(playerMutex, portMAX_DELAY); + if (playerTaskData.isFinished) { + mod->stop(); + xSemaphoreGive(playerMutex); + break; + } + if (!playerTaskData.isPaused) { + if (!mod->loop()) { + mod->stop(); + playerTaskData.isFinished = true; + xSemaphoreGive(playerMutex); + break; + } + } + xSemaphoreGive(playerMutex); + taskYIELD(); // Give app a chance to acquire playerMutex + } + + // Tasks must ALWAYS delete themselves before exiting, or we're get IllegalInstruction panic + vTaskDelete(NULL); + lilka::serial_log("Player task exited"); +} diff --git a/firmware/keira/src/apps/modplayer/modplayer.h b/firmware/keira/src/apps/modplayer/modplayer.h new file mode 100644 index 00000000..d7286580 --- /dev/null +++ b/firmware/keira/src/apps/modplayer/modplayer.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +#include "app.h" +#include "analyzer.h" + +typedef enum { + CMD_SET_PAUSED, + CMD_SET_GAIN, + CMD_STOP, +} PlayerCommandType; + +typedef struct { + PlayerCommandType type; + union { + bool isPaused; + float gain; + }; +} PlayerCommand; + +typedef struct { + AudioOutputAnalyzer* analyzer; + bool isPaused; + bool isFinished; + float gain; +} PlayerTaskData; + +class ModPlayerApp : public App { +public: + explicit ModPlayerApp(String path); + void run() override; + +private: + void mainWindow(); + void playTask(); + QueueHandle_t playerCommandQueue; + String fileName; + + SemaphoreHandle_t playerMutex; + // playerTaskData is accessed by both the player task and the app task. + // It's important to always lock the mutex before accessing it. + PlayerTaskData playerTaskData = { + .analyzer = NULL, + .isPaused = false, + .isFinished = false, + .gain = 1.0f, + }; +}; diff --git a/sdk/lib/lilka/src/lilka/display.cpp b/sdk/lib/lilka/src/lilka/display.cpp index 2bace37b..bee42145 100644 --- a/sdk/lib/lilka/src/lilka/display.cpp +++ b/sdk/lib/lilka/src/lilka/display.cpp @@ -99,6 +99,62 @@ void Display::setSplash(const void* splash, uint32_t rleLength) { this->rleLength = rleLength; } +uint16_t Display::color565hsv(uint16_t h, uint8_t s, uint8_t v) { + uint8_t region, remainder, p, q, t; + uint16_t red, green, blue; + + if (s == 0) { + red = green = blue = (v * 31) / 100; + return (red << 11) | (green << 5) | blue; + } + + region = h / 60; + remainder = (h - (region * 60)) * 6; + + p = (v * (100 - s)) / 100; + q = (v * (100 - (s * remainder) / 100)) / 100; + t = (v * (100 - (s * (60 - remainder)) / 100)) / 100; + + switch (region) { + case 0: + red = v; + green = t; + blue = p; + break; + case 1: + red = q; + green = v; + blue = p; + break; + case 2: + red = p; + green = v; + blue = t; + break; + case 3: + red = p; + green = q; + blue = v; + break; + case 4: + red = t; + green = p; + blue = v; + break; + default: + red = v; + green = p; + blue = q; + break; + } + + red = (red * 31) / 100; + green = (green * 63) / 100; + blue = (blue * 31) / 100; + + return (red << 11) | (green << 5) | blue; +} + template void GFX::drawImage(Image* image, int16_t x, int16_t y) { Arduino_GFX* base = static_cast(this); diff --git a/sdk/lib/lilka/src/lilka/display.h b/sdk/lib/lilka/src/lilka/display.h index 2e6d7a6c..15189f4b 100644 --- a/sdk/lib/lilka/src/lilka/display.h +++ b/sdk/lib/lilka/src/lilka/display.h @@ -9,17 +9,18 @@ namespace lilka { // Рекомендовані шрифти для використання з дисплеєм. -#define FONT_4x6 u8g2_font_4x6_t_cyrillic -#define FONT_5x7 u8g2_font_5x7_t_cyrillic -#define FONT_5x8 u8g2_font_5x8_t_cyrillic -#define FONT_6x12 u8g2_font_6x12_t_cyrillic -#define FONT_6x13 u8g2_font_6x13_t_cyrillic -#define FONT_7x13 u8g2_font_7x13_t_cyrillic -#define FONT_8x13 u8g2_font_8x13_t_cyrillic -#define FONT_8x13_MONO u8g2_font_8x13_mf -#define FONT_9x15 u8g2_font_9x15_t_cyrillic -#define FONT_10x20 u8g2_font_10x20_t_cyrillic -#define FONT_10x20_MONO u8g2_font_10x20_mf +#define FONT_4x6 u8g2_font_4x6_t_cyrillic +#define FONT_5x7 u8g2_font_5x7_t_cyrillic +#define FONT_5x8 u8g2_font_5x8_t_cyrillic +#define FONT_6x12 u8g2_font_6x12_t_cyrillic +#define FONT_6x13 u8g2_font_6x13_t_cyrillic +#define FONT_7x13 u8g2_font_7x13_t_cyrillic +#define FONT_8x13 u8g2_font_8x13_t_cyrillic +#define FONT_8x13_MONO u8g2_font_8x13_mf +#define FONT_9x15 u8g2_font_9x15_t_cyrillic +#define FONT_9x15_SYMBOLS u8g2_font_9x15_t_symbols +#define FONT_10x20 u8g2_font_10x20_t_cyrillic +#define FONT_10x20_MONO u8g2_font_10x20_mf class Canvas; class Image; @@ -254,6 +255,13 @@ class Display : public Arduino_ST7789, public GFX { /// @param splash Масив 16-бітних кольорів (5-6-5) з розміром 280*240 (або масив байтів, закодованих алгоритмом RLE, з довжиною rleLength). /// @param rleLength Якщо використовується RLE-кодування, цей аргумент вказує довжину масиву splash. Зображення повинне бути згенероване за допомогою утиліти `sdk/tools/image2code` з прапорцем `--rle`. void setSplash(const void* splash, uint32_t rleLength = 0); + /// Перетворити HSV колір в 16-бітний формат. + /// + /// @param hue Тон (0-360). + /// @param sat Насиченість (0-100). + /// @param val Яскравість (0-100). + /// @return 16-бітний колір. + uint16_t color565hsv(uint16_t hue, uint8_t sat, uint8_t val); void draw16bitRGBBitmapWithTranColor( int16_t x, int16_t y, const uint16_t bitmap[], uint16_t transparent_color, int16_t w, int16_t h );