feat(tdeck): play received voice messages

This commit is contained in:
liu weikai
2026-08-12 10:36:52 +08:00
parent a9df6ea297
commit 31e70d7eca
10 changed files with 329 additions and 17 deletions
@@ -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<bool> ready_{false};
std::atomic<bool> faulted_{false};
std::atomic<uint8_t> volume_percent_{45};
std::atomic_flag audio_busy_ = ATOMIC_FLAG_INIT;
std::array<int16_t, kPcmSampleCount> pcm_{};
};
@@ -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;
+166 -2
View File
@@ -10,8 +10,10 @@
#include "platform/esp/common/memory_budget.h"
#include "platform/ui/audio/pager_notification_tone.h"
#include <codec2.h>
#include <driver/i2s.h>
#include <esp_err.h>
#include <esp_heap_caps.h>
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<uint8_t>(volume_percent, 100U);
int16_t* const playback_pcm = static_cast<int16_t*>(
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<int>(kCodec2SamplesPerFrame) ||
byte_count != static_cast<int>(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<uint8_t*>(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<int16_t>(output_sample);
playback_pcm[mono_index * kCodec2OutputChannels + 1U] =
static_cast<int16_t>(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<unsigned>(kCodec2PcmBytes),
static_cast<unsigned>(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<unsigned>(encoded_media_len / kCodec2BytesPerFrame),
static_cast<unsigned>(encoded_media_len),
static_cast<unsigned>(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<unsigned long>(uxTaskGetStackHighWaterMark(nullptr)) *
sizeof(StackType_t);
+12
View File
@@ -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()
@@ -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.
@@ -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).
@@ -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<uint8_t>(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<uint8_t>(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.
@@ -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 <Arduino.h>
#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<TDeckBoard*>(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();
@@ -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;
}
+8 -8
View File
@@ -10,19 +10,19 @@
#include <math.h>
#include "memtools.h"
#if defined(ARDUINO_T_LORA_PAGER)
#if defined(ARDUINO_T_LORA_PAGER) || defined(ARDUINO_T_DECK)
#include <esp_heap_caps.h>
#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);