diff --git a/boards/tdeck/include/boards/tdeck/tdeck_audio_runtime.h b/boards/tdeck/include/boards/tdeck/tdeck_audio_runtime.h index 098ec090..7bf07c5f 100644 --- a/boards/tdeck/include/boards/tdeck/tdeck_audio_runtime.h +++ b/boards/tdeck/include/boards/tdeck/tdeck_audio_runtime.h @@ -16,6 +16,15 @@ class TDeckAudioRuntime public: bool begin(); void requestMessageTone(uint8_t volume_percent); + /** + * @brief Decodes one Codec2-1300 VMP object and writes it to the speaker. + * + * This runs on the VMP worker, never the LVGL task. It returns false if + * the speaker is not ready or is currently playing a notification tone. + */ + bool playCodec2Voice(const uint8_t* encoded_media, + std::size_t encoded_media_len, + uint8_t volume_percent); bool isReady() const; private: @@ -29,6 +38,7 @@ class TDeckAudioRuntime std::atomic ready_{false}; std::atomic faulted_{false}; std::atomic volume_percent_{45}; + std::atomic_flag audio_busy_ = ATOMIC_FLAG_INIT; std::array pcm_{}; }; diff --git a/boards/tdeck/include/boards/tdeck/tdeck_board.h b/boards/tdeck/include/boards/tdeck/tdeck_board.h index ff0623f3..08d22643 100644 --- a/boards/tdeck/include/boards/tdeck/tdeck_board.h +++ b/boards/tdeck/include/boards/tdeck/tdeck_board.h @@ -75,6 +75,14 @@ class TDeckBoard : public BoardBase, void setMessageToneVolume(uint8_t volume_percent) override; uint8_t getMessageToneVolume() const override; + /** @brief True after the board's I2S speaker runtime is available. */ + bool isVoicePlaybackReady() const; + + /** @brief Plays one Codec2-1300 voice-message object through the speaker. */ + bool playCodec2Voice(const uint8_t* encoded_media, + std::size_t encoded_media_len, + uint8_t volume_percent); + // LilyGo_Display void setRotation(uint8_t rotation) override; uint8_t getRotation() override; diff --git a/boards/tdeck/src/tdeck_audio_runtime.cpp b/boards/tdeck/src/tdeck_audio_runtime.cpp index a394ef90..7ad73eec 100644 --- a/boards/tdeck/src/tdeck_audio_runtime.cpp +++ b/boards/tdeck/src/tdeck_audio_runtime.cpp @@ -10,8 +10,10 @@ #include "platform/esp/common/memory_budget.h" #include "platform/ui/audio/pager_notification_tone.h" +#include #include #include +#include namespace boards::tdeck { @@ -30,6 +32,33 @@ constexpr size_t kAudioDmaFloor = 16 * 1024; // on the original 2 KiB task stack while handling MQTT chat alerts. constexpr uint32_t kAudioTaskStackBytes = 4096; constexpr UBaseType_t kAudioTaskPriority = 2; +constexpr uint32_t kCodec2VoiceSampleRateHz = 8000U; +constexpr std::size_t kCodec2BytesPerFrame = 7U; +constexpr std::size_t kCodec2FramesPerMessage = 125U; +constexpr std::size_t kCodec2MaximumEncodedBytes = + kCodec2BytesPerFrame * kCodec2FramesPerMessage; +constexpr std::size_t kCodec2SamplesPerFrame = 320U; +constexpr std::size_t kCodec2OutputChannels = 2U; +constexpr std::size_t kCodec2PcmBytes = + kCodec2SamplesPerFrame * kCodec2OutputChannels * sizeof(int16_t); +// Codec2 creates several working blocks while decoding. Reserve a generous +// PSRAM window for the complete temporary operation (PCM plus Codec2 state), +// so playback is refused before it can squeeze display/map allocations. +constexpr std::size_t kCodec2PsramReservation = 96U * 1024U; +constexpr std::size_t kCodec2InternalReservation = 0U; +constexpr std::size_t kCodec2DmaReservation = 0U; +constexpr std::size_t kCodec2InternalFloor = 48U * 1024U; +constexpr std::size_t kCodec2DmaFloor = 16U * 1024U; +constexpr std::size_t kCodec2PsramFloor = 256U * 1024U; + +void secureClear(int16_t* samples, std::size_t sample_count) +{ + volatile int16_t* cursor = samples; + while (cursor && sample_count-- != 0U) + { + *cursor++ = 0; + } +} } // namespace @@ -43,7 +72,6 @@ bool TDeckAudioRuntime::begin() { return false; } - ::platform::esp::common::memory::logSnapshot("audio", "before_i2s"); if (!::platform::esp::common::memory::admit("audio", kAudioInternalReservation, @@ -157,9 +185,139 @@ void TDeckAudioRuntime::requestMessageTone(uint8_t volume_percent) xTaskNotifyGive(task_); } +bool TDeckAudioRuntime::playCodec2Voice(const uint8_t* encoded_media, + std::size_t encoded_media_len, + uint8_t volume_percent) +{ + if (!encoded_media || encoded_media_len == 0U || + encoded_media_len > kCodec2MaximumEncodedBytes || + encoded_media_len % kCodec2BytesPerFrame != 0U || + !ready_.load(std::memory_order_acquire) || + faulted_.load(std::memory_order_acquire)) + { + return false; + } + if (!::platform::esp::common::memory::admit("vmp_play", + kCodec2InternalReservation, + kCodec2DmaReservation, + kCodec2PsramReservation, + kCodec2InternalFloor, + kCodec2DmaFloor, + kCodec2PsramFloor)) + { + std::printf("[TDeck][Audio] voice rejected reason=memory_budget\n"); + return false; + } + if (audio_busy_.test_and_set(std::memory_order_acquire)) + { + std::printf("[TDeck][Audio] voice rejected reason=audio_busy\n"); + return false; + } + + bool complete = false; + const uint8_t output_volume = std::min(volume_percent, 100U); + int16_t* const playback_pcm = static_cast( + heap_caps_malloc(kCodec2PcmBytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!playback_pcm) + { + audio_busy_.clear(std::memory_order_release); + std::printf("[TDeck][Audio] voice rejected reason=psram_unavailable\n"); + return false; + } + + esp_err_t err = i2s_set_sample_rates(kSpeakerI2sPort, kCodec2VoiceSampleRateHz); + if (err != ESP_OK) + { + std::printf("[TDeck][Audio] voice rejected reason=sample_rate err=%s\n", + esp_err_to_name(err)); + } + else + { + CODEC2* const decoder = codec2_create(CODEC2_MODE_1300); + const int sample_count = decoder ? codec2_samples_per_frame(decoder) : 0; + const int byte_count = decoder ? codec2_bytes_per_frame(decoder) : 0; + if (!decoder || sample_count != static_cast(kCodec2SamplesPerFrame) || + byte_count != static_cast(kCodec2BytesPerFrame)) + { + if (decoder) + { + codec2_destroy(decoder); + } + std::printf("[TDeck][Audio] voice rejected reason=codec_failure\n"); + } + else + { + codec2_set_lpc_post_filter(decoder, 1, 0, 0.8F, 0.2F); + complete = true; + for (std::size_t offset = 0U; offset < encoded_media_len; + offset += kCodec2BytesPerFrame) + { + codec2_decode(decoder, + playback_pcm, + const_cast(encoded_media + offset)); + // Expand backwards so mono samples are never overwritten before + // each sample reaches both output channels. + for (std::size_t sample = kCodec2SamplesPerFrame; sample != 0U; + --sample) + { + const std::size_t mono_index = sample - 1U; + int32_t output_sample = playback_pcm[mono_index]; + output_sample = output_sample * output_volume / 100; + playback_pcm[mono_index * kCodec2OutputChannels] = + static_cast(output_sample); + playback_pcm[mono_index * kCodec2OutputChannels + 1U] = + static_cast(output_sample); + } + + size_t bytes_written = 0U; + err = i2s_write(kSpeakerI2sPort, + playback_pcm, + kCodec2PcmBytes, + &bytes_written, + pdMS_TO_TICKS(100)); + if (err != ESP_OK || bytes_written != kCodec2PcmBytes) + { + std::printf("[TDeck][Audio] voice write failed err=%s requested=%u " + "written=%u\n", + esp_err_to_name(err), + static_cast(kCodec2PcmBytes), + static_cast(bytes_written)); + complete = false; + break; + } + } + codec2_destroy(decoder); + } + } + + secureClear(playback_pcm, kCodec2PcmBytes / sizeof(*playback_pcm)); + heap_caps_free(playback_pcm); + i2s_zero_dma_buffer(kSpeakerI2sPort); + const esp_err_t restore_err = i2s_set_sample_rates( + kSpeakerI2sPort, + ::platform::ui::audio::pager_notification::kPlaybackSampleRateHz); + if (restore_err != ESP_OK) + { + std::printf("[TDeck][Audio] voice restore sample_rate failed err=%s\n", + esp_err_to_name(restore_err)); + complete = false; + faulted_.store(true, std::memory_order_release); + ready_.store(false, std::memory_order_release); + } + audio_busy_.clear(std::memory_order_release); + + std::printf("[TDeck][Audio] voice %s frames=%u bytes=%u volume=%u\n", + complete ? "complete" : "failed", + static_cast(encoded_media_len / kCodec2BytesPerFrame), + static_cast(encoded_media_len), + static_cast(output_volume)); + return complete; +} + bool TDeckAudioRuntime::isReady() const { - return ready_.load(std::memory_order_acquire); + return ready_.load(std::memory_order_acquire) && + !faulted_.load(std::memory_order_acquire); } void TDeckAudioRuntime::taskEntry(void* context) @@ -181,7 +339,13 @@ void TDeckAudioRuntime::taskLoop() { continue; } + if (audio_busy_.test_and_set(std::memory_order_acquire)) + { + std::printf("[TDeck][Audio] tone dropped voice_playback_active\n"); + continue; + } playTone(volume_percent_.load(std::memory_order_acquire)); + audio_busy_.clear(std::memory_order_release); const unsigned long stack_free = static_cast(uxTaskGetStackHighWaterMark(nullptr)) * sizeof(StackType_t); diff --git a/boards/tdeck/src/tdeck_board.cpp b/boards/tdeck/src/tdeck_board.cpp index 91178b13..4a4c6619 100644 --- a/boards/tdeck/src/tdeck_board.cpp +++ b/boards/tdeck/src/tdeck_board.cpp @@ -1430,6 +1430,18 @@ uint8_t TDeckBoard::getMessageToneVolume() const return message_tone_volume_; } +bool TDeckBoard::isVoicePlaybackReady() const +{ + return audio_runtime_.isReady(); +} + +bool TDeckBoard::playCodec2Voice(const uint8_t* encoded_media, + std::size_t encoded_media_len, + uint8_t volume_percent) +{ + return audio_runtime_.playCodec2Voice(encoded_media, encoded_media_len, volume_percent); +} + namespace { TDeckBoard& getInstanceRef() diff --git a/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h b/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h index c88ca15e..1db1b502 100644 --- a/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h +++ b/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h @@ -18,7 +18,14 @@ namespace chat::voice::vmp { +// T-Deck's VMP integration is playback-only and must leave most of its PSRAM +// available for the display and map paths. Retain only the newest two local +// voice objects there; Pager keeps its eight-message history. +#if defined(ARDUINO_T_DECK) +inline constexpr std::size_t kVoiceInboxCapacity = 2U; +#else inline constexpr std::size_t kVoiceInboxCapacity = 8U; +#endif /** * Local presentation state for a voice object. diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_audio.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_audio.h index 3101323e..c0ed89f6 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_audio.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_audio.h @@ -62,6 +62,7 @@ class PagerCodec2Audio final PagerCodec2Audio& operator=(const PagerCodec2Audio&) = delete; [[nodiscard]] bool isSupported() const; + [[nodiscard]] bool canCapture() const; /** * @brief Records at most 125 Codec2 frames (exactly five seconds). diff --git a/platform/esp/arduino_common/src/app_context.cpp b/platform/esp/arduino_common/src/app_context.cpp index c7c71e1b..3664754d 100644 --- a/platform/esp/arduino_common/src/app_context.cpp +++ b/platform/esp/arduino_common/src/app_context.cpp @@ -374,11 +374,11 @@ void AppContext::initChatRuntime(bool use_mock_adapter) applyNetworkLimits(); applyPrivacyConfig(); applyChatDefaults(); -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) if (!::platform::esp::arduino_common::voice::vmp_session::initialize( getSelfNodeId(), deferred_storage_store_context_ != nullptr)) { - Serial.printf("[VMP] Pager voice service unavailable\n"); + Serial.printf("[VMP] voice service unavailable\n"); ::ui::chat_voice::setRuntime(nullptr); } else @@ -714,7 +714,7 @@ void AppContext::applyMeshConfig() chat::infra::isReticulumMeshProtocol( chat::infra::normalizeMeshProtocol(config_.mesh_protocol))); #endif -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) ::platform::esp::arduino_common::voice::vmp_session::setPresentationProtocol( static_cast(chat::infra::normalizeMeshProtocol(config_.mesh_protocol))); #endif @@ -918,7 +918,7 @@ bool AppContext::switchMeshProtocol(chat::MeshProtocol protocol, bool persist) { contact_service_->setActiveProtocol(normalized); } -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) ::platform::esp::arduino_common::voice::vmp_session::invalidateContactSecretCache(); ::platform::esp::arduino_common::voice::vmp_session::setPresentationProtocol( static_cast(normalized)); @@ -972,7 +972,7 @@ void AppContext::getEffectiveUserInfo(char* out_long, size_t long_len, void AppContext::updateCoreServices() { flushConfigPersistence(millis()); -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) // Text and VMP attachments share the deferred-storage readiness boundary. // After that boundary, this is a rate-limited retry only when an SD I/O // failure prevented the local VMP attachment snapshot from restoring. diff --git a/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp index 73f0af82..c15e9766 100644 --- a/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp +++ b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp @@ -192,6 +192,11 @@ bool PagerCodec2Audio::isSupported() const return pagerBoard() != nullptr; } +bool PagerCodec2Audio::canCapture() const +{ + return pagerBoard() != nullptr; +} + CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) { clearEncodedMedia(); @@ -451,6 +456,98 @@ void PagerCodec2Audio::duplicatePlaybackToStereo() } // namespace platform::esp::arduino_common::voice::vmp_audio +#elif defined(ARDUINO_T_DECK) + +#include + +#include "boards/tdeck/tdeck_board.h" +#include "platform/esp/boards/board_runtime.h" + +namespace platform::esp::arduino_common::voice::vmp_audio +{ +namespace +{ + +using ::boards::tdeck::TDeckBoard; + +TDeckBoard* tdeckBoard() +{ + ::platform::esp::boards::AppContextInitHandles handles; + if (!::platform::esp::boards::tryResolveAppContextInitHandles(&handles) || + !handles.board) + { + return nullptr; + } + return static_cast(handles.board); +} + +} // namespace + +bool PagerCodec2Audio::isSupported() const +{ + TDeckBoard* const board = tdeckBoard(); + return board && board->isVoicePlaybackReady(); +} + +bool PagerCodec2Audio::canCapture() const +{ + // T-Deck v1 has a bounded speaker path here; recording and outbound VMP + // remain unavailable until a separate microphone path is implemented. + return false; +} + +CaptureResult PagerCodec2Audio::capture(const volatile bool*) +{ + clearEncodedMedia(); + return CaptureResult::Unsupported; +} + +const uint8_t* PagerCodec2Audio::encodedMedia() const +{ + return nullptr; +} + +std::size_t PagerCodec2Audio::encodedMediaSize() const +{ + return 0U; +} + +bool PagerCodec2Audio::hasEncodedMedia() const +{ + return false; +} + +void PagerCodec2Audio::clearEncodedMedia() +{ + std::memset(encoded_media_, 0, sizeof(encoded_media_)); + encoded_media_size_ = 0U; +} + +PlaybackResult PagerCodec2Audio::play(const uint8_t* encoded_media, + std::size_t encoded_media_len, + chat::voice::vmp::Codec codec, + uint8_t volume_percent) +{ + if (!encoded_media || encoded_media_len == 0U || + encoded_media_len > kMaximumEncodedBytes || + encoded_media_len % kCodec2BytesPerFrame != 0U || + codec != chat::voice::vmp::Codec::Codec2_1300) + { + return PlaybackResult::InvalidMedia; + } + + TDeckBoard* const board = tdeckBoard(); + if (!board || !board->isVoicePlaybackReady()) + { + return PlaybackResult::Unsupported; + } + return board->playCodec2Voice(encoded_media, encoded_media_len, volume_percent) + ? PlaybackResult::Complete + : PlaybackResult::AudioBusy; +} + +} // namespace platform::esp::arduino_common::voice::vmp_audio + #else namespace platform::esp::arduino_common::voice::vmp_audio @@ -461,6 +558,11 @@ bool PagerCodec2Audio::isSupported() const return false; } +bool PagerCodec2Audio::canCapture() const +{ + return false; +} + CaptureResult PagerCodec2Audio::capture(const volatile bool*) { clearEncodedMedia(); diff --git a/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp index d8b07382..aeecdd1f 100644 --- a/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp +++ b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp @@ -5,7 +5,7 @@ #include "platform/esp/arduino_common/voice/vmp_pager_session.h" -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) #include "platform/esp/arduino_common/storage/storage_runtime.h" #include "platform/esp/arduino_common/voice/vmp_control_runtime.h" @@ -58,7 +58,15 @@ constexpr uint8_t kReadyProbeCount = 3U; // only for an active record/play operation and are released by vTaskDelete. constexpr uint32_t kOutboundTaskStackBytes = 8U * 1024U; constexpr UBaseType_t kOutboundTaskPriority = 4U; +#if defined(ARDUINO_T_DECK) +// T-Deck is playback-only: Codec2 state and PCM are PSRAM-backed and its I2S +// output path has no Pager codec/I2C capture chain. Keep this transient worker +// aligned with T-Deck's existing 4 KiB audio task rather than reserving the +// Pager's capture-sized 8 KiB internal-RAM stack. +constexpr uint32_t kPlaybackTaskStackBytes = 4U * 1024U; +#else constexpr uint32_t kPlaybackTaskStackBytes = 8U * 1024U; +#endif constexpr UBaseType_t kPlaybackTaskPriority = 3U; constexpr uint32_t kPersistentInboxRetryMs = 5000U; @@ -228,7 +236,7 @@ class PagerReceiveSession final bool canRecordAndSend() const { if (!initialized_ || !media_ || !inbox_ready_ || - !media_->audio.isSupported() || !lockState()) + !media_->audio.canCapture() || !lockState()) { return false; } diff --git a/third_party/codec2/src/memtools.c b/third_party/codec2/src/memtools.c index 10c7085a..e7fc2870 100644 --- a/third_party/codec2/src/memtools.c +++ b/third_party/codec2/src/memtools.c @@ -10,19 +10,19 @@ #include #include "memtools.h" -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) #include #endif /* - * Pager's Codec2 state and DSP scratch are non-DMA data. Keep them in PSRAM - * so the fragmented internal heap remains available for FreeRTOS stacks and - * I2S DMA. A missing PSRAM allocation fails Codec2 creation safely instead of - * silently consuming scarce internal SRAM. + * Pager and T-Deck Codec2 state and DSP scratch are non-DMA data. Keep them + * in PSRAM so the fragmented internal heap remains available for FreeRTOS + * stacks and I2S DMA. A missing PSRAM allocation fails Codec2 creation safely + * instead of silently consuming scarce internal SRAM. */ void* codec2_malloc(size_t size) { -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) return heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); #else return malloc(size); @@ -31,7 +31,7 @@ void* codec2_malloc(size_t size) void* codec2_calloc(size_t nmemb, size_t size) { -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) return heap_caps_calloc(nmemb, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); #else return calloc(nmemb, size); @@ -40,7 +40,7 @@ void* codec2_calloc(size_t nmemb, size_t size) void codec2_free(void* ptr) { -#if defined(ARDUINO_T_LORA_PAGER) +#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK) heap_caps_free(ptr); #else free(ptr);