diff --git a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp index 09a71d2f..b5ae415c 100644 --- a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp +++ b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp @@ -59,6 +59,9 @@ int main(int argc, char** argv) "voice/vmp_pager_session.h"); const std::string pager_audio = readFile( root / "platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp"); + const std::string pager_audio_header = readFile( + root / "platform/esp/arduino_common/include/platform/esp/arduino_common/" + "voice/vmp_pager_audio.h"); const std::string codec2 = readFile(root / "third_party/codec2/src/codec2.c"); const std::string codec2_internal = readFile(root / "third_party/codec2/src/codec2_internal.h"); @@ -324,6 +327,46 @@ int main(int argc, char** argv) assert(session.find("stack_budget_bytes=%u") != std::string::npos); assert(session.find("logTaskCreateFailure(\"vmp_tx\", kOutboundTaskStackBytes)") != std::string::npos); + // Voice-message playback is speech, not a notification tone. Its former + // hard-coded 70% value maps to -15 dB in the ES8311 default volume curve. + // Keep it at full output gain unless a future dedicated voice-volume + // setting supplies an explicit user value. + assert(session.find("playback_codec_,\n 100U)") != + std::string::npos); + assert(pager_header.find("volume_percent = 100U") != std::string::npos); + assert(pager_audio_header.find("volume_percent = 100U") != + std::string::npos); + const std::size_t capture_audio = + positionOf(pager_audio, "bool beginCaptureAudio("); + const std::size_t playback_audio = + positionOfAfter(pager_audio, "bool beginPlaybackAudio(", capture_audio); + const std::size_t pcm_level = + positionOfAfter(pager_audio, "struct PcmLevel", playback_audio); + const std::string capture_audio_body = + pager_audio.substr(capture_audio, playback_audio - capture_audio); + const std::string playback_audio_body = + pager_audio.substr(playback_audio, pcm_level - playback_audio); + assert(capture_audio_body.find("audioSetGain(kOwner, kCaptureGainDb)") != + std::string::npos); + assert(capture_audio_body.find("audioSetMute(kOwner, false)") != + std::string::npos); + assert(capture_audio_body.find("audioSetOutMute") == std::string::npos); + assert(playback_audio_body.find("audioSetOutMute(kOwner, false)") != + std::string::npos); + assert(playback_audio_body.find("audioSetGain") == std::string::npos); + assert(playback_audio_body.find("audioSetMute") == std::string::npos); + // Peak/RMS inspection must run on the existing PCM frame. It must not add + // a retained frame, queue, or other RAM-backed telemetry buffer. + assert(pager_audio.find("kAudioLevelLogIntervalFrames = 25U") != + std::string::npos); + assert(pager_audio.find("[VMP][AUDIO] level capture") != + std::string::npos); + assert(pager_audio.find("[VMP][AUDIO] level playback") != + std::string::npos); + assert(pager_audio.find("struct PagerCodec2Audio::FrameScratch\n{\n int16_t stereo[") != + std::string::npos); + assert(pager_audio.find("PcmLevel stereo[") == std::string::npos); + assert(pager_audio.find("PcmLevel mono[") == std::string::npos); assert(codec2_internal.find("CODEC2_1300_SCRATCH") != std::string::npos); assert(codec2_internal.find("COMP *fft_inplace_scratch") != std::string::npos); 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 30bd8fcc..3101323e 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 @@ -80,7 +80,7 @@ class PagerCodec2Audio final PlaybackResult play(const uint8_t* encoded_media, std::size_t encoded_media_len, chat::voice::vmp::Codec codec, - uint8_t volume_percent = 70U); + uint8_t volume_percent = 100U); private: struct FrameScratch; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h index 7333384f..5d87aa37 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h @@ -110,7 +110,7 @@ std::size_t listInboxMetadata(chat::voice::vmp::VoiceMessageMetadata* out_metada std::size_t capacity); /** @brief Decodes one already-local VMP inbox object to the Pager speaker. */ -bool playInboxMessage(uint64_t local_id, uint8_t volume_percent = 70U); +bool playInboxMessage(uint64_t local_id, uint8_t volume_percent = 100U); /** * @brief Starts local playback on a VMP worker so the UI task never blocks. 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 eee6a291..73f0af82 100644 --- a/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp +++ b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp @@ -5,6 +5,7 @@ #include "platform/esp/arduino_common/voice/vmp_pager_audio.h" +#include #include #include @@ -27,6 +28,7 @@ using ::boards::tlora_pager::TLoRaPagerBoard; constexpr PagerAudioOwner kOwner = PagerAudioOwner::VoiceMessage; constexpr float kCaptureGainDb = 24.0F; +constexpr std::size_t kAudioLevelLogIntervalFrames = 25U; TLoRaPagerBoard* pagerBoard() { @@ -61,14 +63,14 @@ int16_t clampToInt16(int32_t value) return static_cast(value); } -bool beginAudio(TLoRaPagerBoard* board, bool speaker_enabled) +bool beginCaptureAudio(TLoRaPagerBoard* board) { if (!board || board->openAudioSession(kOwner, kBitsPerSample, kHardwareChannels, kSampleRateHz, - speaker_enabled) != 0) + false) != 0) { return false; } @@ -78,7 +80,21 @@ bool beginAudio(TLoRaPagerBoard* board, bool speaker_enabled) board->closeAudioSession(kOwner); return false; } - if (speaker_enabled && !board->audioSetOutMute(kOwner, false)) + return true; +} + +bool beginPlaybackAudio(TLoRaPagerBoard* board) +{ + if (!board || + board->openAudioSession(kOwner, + kBitsPerSample, + kHardwareChannels, + kSampleRateHz, + true) != 0) + { + return false; + } + if (!board->audioSetOutMute(kOwner, false)) { board->closeAudioSession(kOwner); return false; @@ -86,6 +102,83 @@ bool beginAudio(TLoRaPagerBoard* board, bool speaker_enabled) return true; } +struct PcmLevel +{ + uint16_t peak = 0U; + uint16_t rms = 0U; +}; + +struct CapturePcmLevel +{ + PcmLevel left{}; + PcmLevel right{}; + PcmLevel mono{}; +}; + +PcmLevel measurePcmLevel(const int16_t* samples, + std::size_t sample_count, + std::size_t stride = 1U, + std::size_t offset = 0U) +{ + uint32_t peak = 0U; + uint64_t sum_of_squares = 0U; + for (std::size_t index = 0U; index < sample_count; ++index) + { + const int32_t sample = samples[index * stride + offset]; + const uint32_t magnitude = sample < 0 ? static_cast(-sample) + : static_cast(sample); + if (magnitude > peak) + { + peak = magnitude; + } + sum_of_squares += static_cast(sample * sample); + } + const float mean_square = + static_cast(sum_of_squares) / static_cast(sample_count); + return {static_cast(peak), + static_cast(sqrtf(mean_square) + 0.5F)}; +} + +CapturePcmLevel measureCapturePcmLevel(const int16_t* stereo, const int16_t* mono) +{ + return {measurePcmLevel(stereo, kCodec2SamplesPerFrame, kHardwareChannels, 0U), + measurePcmLevel(stereo, kCodec2SamplesPerFrame, kHardwareChannels, 1U), + measurePcmLevel(mono, kCodec2SamplesPerFrame)}; +} + +bool shouldLogPcmLevel(std::size_t frame_index, std::size_t frame_count) +{ + const std::size_t completed_frames = frame_index + 1U; + return frame_index == 0U || + (completed_frames % kAudioLevelLogIntervalFrames) == 0U || + completed_frames == frame_count; +} + +void logCapturePcmLevel(std::size_t frame_index, const CapturePcmLevel& level) +{ + Serial.printf("[VMP][AUDIO] level capture frame=%u left_peak=%u left_rms=%u " + "right_peak=%u right_rms=%u mono_peak=%u mono_rms=%u\n", + static_cast(frame_index + 1U), + static_cast(level.left.peak), + static_cast(level.left.rms), + static_cast(level.right.peak), + static_cast(level.right.rms), + static_cast(level.mono.peak), + static_cast(level.mono.rms)); +} + +void logPlaybackPcmLevel(std::size_t frame_index, + const PcmLevel& level, + uint8_t output_volume) +{ + Serial.printf("[VMP][AUDIO] level playback frame=%u mono_peak=%u mono_rms=%u " + "output_volume=%u\n", + static_cast(frame_index + 1U), + static_cast(level.peak), + static_cast(level.rms), + static_cast(output_volume)); +} + } // namespace struct PagerCodec2Audio::FrameScratch @@ -111,7 +204,7 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) Serial.printf("[VMP][AUDIO] capture rejected reason=unsupported\n"); return CaptureResult::Unsupported; } - if (!beginAudio(board, false)) + if (!beginCaptureAudio(board)) { releaseFrameScratch(); Serial.printf("[VMP][AUDIO] capture rejected reason=audio_busy\n"); @@ -135,6 +228,8 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) } CaptureResult result = CaptureResult::Complete; + CapturePcmLevel last_capture_level{}; + bool has_capture_level = false; bool stopped_by_release = false; for (std::size_t frame = 0; frame < kCodec2FramesPerMessage; ++frame) { @@ -154,6 +249,13 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) break; } mixCaptureToMono(); + last_capture_level = measureCapturePcmLevel(frame_scratch_->stereo, + frame_scratch_->mono); + has_capture_level = true; + if (shouldLogPcmLevel(frame, kCodec2FramesPerMessage)) + { + logCapturePcmLevel(frame, last_capture_level); + } codec2_encode(encoder, encoded_media_ + encoded_media_size_, frame_scratch_->mono); @@ -169,6 +271,11 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) codec2_destroy(encoder); board->closeAudioSession(kOwner); + const std::size_t captured_frame_count = encoded_media_size_ / kCodec2BytesPerFrame; + if (has_capture_level && captured_frame_count % kAudioLevelLogIntervalFrames != 0U) + { + logCapturePcmLevel(captured_frame_count - 1U, last_capture_level); + } releaseFrameScratch(); const std::size_t encoded_size = encoded_media_size_; if (result != CaptureResult::Complete) @@ -223,13 +330,13 @@ PlaybackResult PagerCodec2Audio::play(const uint8_t* encoded_media, { return PlaybackResult::Unsupported; } - if (!beginAudio(board, true)) + if (!beginPlaybackAudio(board)) { releaseFrameScratch(); return PlaybackResult::AudioBusy; } - (void)board->audioSetVolume(kOwner, volume_percent > 100U ? 100U - : volume_percent); + const uint8_t output_volume = volume_percent > 100U ? 100U : volume_percent; + (void)board->audioSetVolume(kOwner, output_volume); CODEC2* const decoder = codec2_create(CODEC2_MODE_1300); const int sample_count = decoder ? codec2_samples_per_frame(decoder) : 0; @@ -248,12 +355,19 @@ PlaybackResult PagerCodec2Audio::play(const uint8_t* encoded_media, codec2_set_lpc_post_filter(decoder, 1, 0, 0.8F, 0.2F); PlaybackResult result = PlaybackResult::Complete; - for (std::size_t offset = 0U; offset < encoded_media_len; - offset += kCodec2BytesPerFrame) + const std::size_t frame_count = encoded_media_len / kCodec2BytesPerFrame; + for (std::size_t frame_index = 0U, offset = 0U; offset < encoded_media_len; + ++frame_index, offset += kCodec2BytesPerFrame) { codec2_decode(decoder, frame_scratch_->mono, const_cast(encoded_media + offset)); + const PcmLevel level = + measurePcmLevel(frame_scratch_->mono, kCodec2SamplesPerFrame); + if (shouldLogPcmLevel(frame_index, frame_count)) + { + logPlaybackPcmLevel(frame_index, level, output_volume); + } duplicatePlaybackToStereo(); if (!writePlaybackFrame()) { 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 b7180724..d8b07382 100644 --- a/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp +++ b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp @@ -1038,7 +1038,7 @@ class PagerReceiveSession final result = media_->audio.play(media_->playback_media, playback_media_len_, playback_codec_, - 70U); + 100U); } logCurrentTaskStack("vmp_play", "after_playback"); Serial.printf("[VMP][PLAY] end local_id=%llu result=%u\n",