blob: 4510fb3d69ed29be372341f4768b415abf3696c8 [file] [edit]
/*
*
* Copyright (c) 2026 Project CHIP Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include <PosixChimeDevice.h>
#include <cmath>
#include <lib/support/CodeUtils.h>
#include <lib/support/logging/CHIPLogging.h>
#include <limits>
#include <sstream>
// This file implements the audio playback for the Chime cluster on POSIX systems using the
// miniaudio library. To avoid shipping large audio files or consuming significant memory with
// pre-rendered PCM buffers, this implementation uses an "incremental synthesis" approach.
//
// A custom miniaudio data source (`CustomDataSource`) is defined. When miniaudio needs more
// audio data, it calls `custom_data_source_read`, which generates the audio samples on-the-fly.
// The sounds are synthesized using additive synthesis (combining a fundamental frequency with
// harmonics) and an exponential decay to simulate a natural bell or chime fade-out.
//
// The `SoundResource` class manages the lifecycle of these miniaudio structures using RAII,
// ensuring proper cleanup when the device is destroyed.
namespace chip {
namespace app {
namespace {
constexpr double kPi = 3.14159265358979323846;
constexpr uint32_t kSampleRateHz = 44100;
// Custom data source read callback. This is called by miniaudio to fetch more audio frames.
// It generates the audio on-the-fly instead of reading from a buffer.
ma_result custom_data_source_read(ma_data_source * pDataSource, void * pFramesOut, ma_uint64 frameCount, ma_uint64 * pFramesRead)
{
auto * pCustomDS = reinterpret_cast<PosixChimeDevice::CustomDataSource *>(pDataSource);
VerifyOrReturnError(pCustomDS != nullptr, MA_INVALID_ARGS);
// Calculate total samples for the full duration of the sound
const ma_uint64 totalSamples = static_cast<ma_uint64>(pCustomDS->duration_sec * kSampleRateHz);
ma_uint64 framesToRead = frameCount;
// Ensure we don't read past the end of the sound
if (pCustomDS->cursor + framesToRead > totalSamples)
{
framesToRead = totalSamples - pCustomDS->cursor;
}
if (framesToRead == 0)
{
if (pFramesRead)
{
*pFramesRead = 0;
}
return MA_AT_END;
}
int16_t * pOut = reinterpret_cast<int16_t *>(pFramesOut);
// Generate samples on the fly
for (ma_uint64 i = 0; i < framesToRead; ++i)
{
ma_uint64 currentSample = pCustomDS->cursor + i;
// Calculate current time in seconds
double t = static_cast<double>(currentSample) / kSampleRateHz;
double freq;
double t_note;
if (pCustomDS->pulse)
{
// Pulsing sound: keep same frequency
freq = pCustomDS->freq1_hz;
t_note = t;
}
else
{
// Two-tone sound (e.g., Ding-Dong): switch from freq1_hz to freq2_hz halfway through the total duration.
// This creates a classic two-note chime effect. We also reset the relative time (t_note) for the second
// tone so that the exponential decay applies to each note individually.
if (t < pCustomDS->duration_sec / 2.0)
{
freq = pCustomDS->freq1_hz;
t_note = t;
}
else
{
freq = pCustomDS->freq2_hz;
t_note = t - (pCustomDS->duration_sec / 2.0); // Reset relative time for second tone
}
}
// Apply exponential decay for a natural chime fade-out effect
double volume = exp(-t_note * 4.0);
if (pCustomDS->pulse)
{
// Apply a square wave modulation for the pulse effect
bool on = (static_cast<int>(t * 20) % 2) == 0;
if (!on)
{
volume = 0;
}
}
// Additive synthesis: combine fundamental frequency and harmonics
double sample = 0;
sample += 0.6 * sin(2.0 * kPi * freq * t_note); // Fundamental
sample += 0.3 * sin(2.0 * kPi * freq * 2.0 * t_note); // 2nd harmonic
sample += 0.1 * sin(2.0 * kPi * freq * 3.0 * t_note); // 3rd harmonic
sample *= volume;
// Scale to 16-bit signed PCM and store
pOut[i] = static_cast<int16_t>(sample * static_cast<double>(std::numeric_limits<int16_t>::max()));
}
pCustomDS->cursor += framesToRead;
if (pFramesRead)
{
*pFramesRead = framesToRead;
}
return (framesToRead < frameCount) ? MA_AT_END : MA_SUCCESS;
}
// Custom data source seek callback. Allows miniaudio to jump to a specific frame.
ma_result custom_data_source_seek(ma_data_source * pDataSource, ma_uint64 frameIndex)
{
auto * pCustomDS = reinterpret_cast<PosixChimeDevice::CustomDataSource *>(pDataSource);
VerifyOrReturnError(pCustomDS != nullptr, MA_INVALID_ARGS);
const ma_uint64 totalSamples = static_cast<ma_uint64>(pCustomDS->duration_sec * kSampleRateHz);
// Cap the cursor at the end of the sound
if (frameIndex > totalSamples)
{
pCustomDS->cursor = totalSamples;
}
else
{
pCustomDS->cursor = frameIndex;
}
return MA_SUCCESS;
}
// Custom data source get data format callback. Tells miniaudio what format we are generating.
ma_result custom_data_source_get_data_format(ma_data_source * pDataSource, ma_format * pFormat, ma_uint32 * pChannels,
ma_uint32 * pSampleRate, ma_channel * pChannelMap, size_t channelMapCap)
{
if (pFormat)
{
*pFormat = ma_format_s16; // 16-bit signed integer PCM
}
if (pChannels)
{
*pChannels = 1; // Mono
}
if (pSampleRate)
{
*pSampleRate = kSampleRateHz;
}
if (pChannelMap && channelMapCap > 0)
{
*pChannelMap = MA_CHANNEL_MONO;
}
return MA_SUCCESS;
}
// Custom data source get cursor callback. Returns current playback position.
ma_result custom_data_source_get_cursor(ma_data_source * pDataSource, ma_uint64 * pCursor)
{
auto * pCustomDS = reinterpret_cast<PosixChimeDevice::CustomDataSource *>(pDataSource);
VerifyOrReturnError(pCustomDS != nullptr, MA_INVALID_ARGS);
if (pCursor)
{
*pCursor = pCustomDS->cursor;
}
return MA_SUCCESS;
}
// Custom data source get length callback. Returns total duration in frames.
ma_result custom_data_source_get_length(ma_data_source * pDataSource, ma_uint64 * pLength)
{
auto * pCustomDS = reinterpret_cast<PosixChimeDevice::CustomDataSource *>(pDataSource);
VerifyOrReturnError(pCustomDS != nullptr, MA_INVALID_ARGS);
if (pLength)
{
*pLength = static_cast<ma_uint64>(pCustomDS->duration_sec * kSampleRateHz);
}
return MA_SUCCESS;
}
// Custom data source set looping callback.
ma_result custom_data_source_set_looping(ma_data_source * pDataSource, ma_bool32 isLooping)
{
// Not supported for now as chime sounds are usually one-shot.
return MA_NOT_IMPLEMENTED;
}
// Define the vtable for the custom data source, mapping callbacks to miniaudio operations.
static ma_data_source_vtable g_custom_data_source_vtable = {
custom_data_source_read,
custom_data_source_seek,
custom_data_source_get_data_format,
custom_data_source_get_cursor,
custom_data_source_get_length,
custom_data_source_set_looping,
0 // flags
};
} // namespace
// SoundResource Factory: Initializes a specific sound based on its ID.
std::unique_ptr<PosixChimeDevice::SoundResource> PosixChimeDevice::SoundResource::Create(ma_engine * engine,
const ChimeDevice::Sound & soundInfo)
{
auto resource = std::make_unique<SoundResource>();
resource->id = soundInfo.id;
// Configure the custom data source parameters based on ID.
// These hardcoded values define the characteristics of each chime.
resource->dataSource.cursor = 0;
if (soundInfo.id == 0)
{
// Chime 0: Two-tone "Ding-Dong" (880Hz then 660Hz)
resource->dataSource.freq1_hz = 880;
resource->dataSource.freq2_hz = 660;
resource->dataSource.duration_sec = 1.0;
resource->dataSource.pulse = false;
}
else if (soundInfo.id == 1)
{
// Chime 1: Pulsing warning tone (1000Hz pulsing)
resource->dataSource.freq1_hz = 1000;
resource->dataSource.freq2_hz = 1000;
resource->dataSource.duration_sec = 1.0;
resource->dataSource.pulse = true;
}
else
{
// Chime 2 (Default): Single short tone (440Hz)
resource->dataSource.freq1_hz = 440;
resource->dataSource.freq2_hz = 440;
resource->dataSource.duration_sec = 0.5;
resource->dataSource.pulse = false;
}
// Initialize the base data source with our vtable mapping to the callbacks above
ma_data_source_config config = ma_data_source_config_init();
config.vtable = &g_custom_data_source_vtable;
ma_result res = ma_data_source_init(&config, &resource->dataSource.base);
if (res != MA_SUCCESS)
{
ChipLogError(DeviceLayer, "Failed to initialize base data source for sound %d: %d", soundInfo.id, res);
return nullptr;
}
// Initialize the sound object from the data source. Miniaudio will pull data from it during playback.
res = ma_sound_init_from_data_source(engine, &resource->dataSource.base, 0, NULL, &resource->sound);
if (res != MA_SUCCESS)
{
ChipLogError(DeviceLayer, "Failed to initialize sound %d from data source: %d", soundInfo.id, res);
ma_data_source_uninit(&resource->dataSource.base);
return nullptr;
}
resource->mInitialized = true;
return resource;
}
// SoundResource Destructor: Ensures safe cleanup of miniaudio structures.
PosixChimeDevice::SoundResource::~SoundResource()
{
if (mInitialized)
{
ma_sound_uninit(&sound);
ma_data_source_uninit(&dataSource.base);
}
}
// PosixChimeDevice Constructor: Initializes the audio engine and pre-loads sound resources.
PosixChimeDevice::PosixChimeDevice(TimerDelegate & timerDelegate, Span<const Sound> sounds) : ChimeDevice(timerDelegate, sounds)
{
// Initialize the miniaudio engine with default configuration
ma_result result = ma_engine_init(NULL, &mEngine);
if (result != MA_SUCCESS)
{
ChipLogError(DeviceLayer, "Failed to initialize miniaudio engine: %d", result);
return;
}
mEngineInitialized = true;
ChipLogProgress(DeviceLayer, "Miniaudio engine initialized successfully");
// Pre-initialize all requested sound resources
bool allSoundsInitialized = true;
for (size_t i = 0; i < sounds.size(); ++i)
{
auto resource = SoundResource::Create(&mEngine, sounds[i]);
if (resource)
{
mSoundResources.push_back(std::move(resource));
}
else
{
allSoundsInitialized = false;
}
}
mSoundsInitialized = allSoundsInitialized;
if (mSoundsInitialized)
{
ChipLogProgress(DeviceLayer, "In-memory sounds initialized successfully");
}
}
// PosixChimeDevice Destructor: Cleans up resources and shuts down the engine.
PosixChimeDevice::~PosixChimeDevice()
{
if (mEngineInitialized)
{
// Clear the vector first to trigger SoundResource destructors before engine shutdown
mSoundResources.clear();
ma_engine_uninit(&mEngine);
ChipLogProgress(DeviceLayer, "Miniaudio engine uninitialized");
}
}
// PlayChimeSound: Triggers the playback of a sound by its ID.
Protocols::InteractionModel::Status PosixChimeDevice::PlayChimeSound(uint8_t chimeID)
{
// Call base class to log the default message
auto status = ChimeDevice::PlayChimeSound(chimeID);
if (!mSoundsInitialized)
{
ChipLogError(DeviceLayer, "PosixChimeDevice: Sounds not initialized, cannot play sound");
return status;
}
// Find the requested sound resource by ID
ma_sound * pSound = nullptr;
for (auto & resource : mSoundResources)
{
if (resource && resource->id == chimeID && resource->mInitialized)
{
pSound = &resource->sound;
break;
}
}
if (pSound)
{
ChipLogProgress(DeviceLayer, "PosixChimeDevice: Attempting to play sound %d from memory", chimeID);
// Rewind sound to the beginning before playing
ma_sound_seek_to_pcm_frame(pSound, 0);
// Start playback. This will trigger callbacks to custom_data_source_read.
ma_result result = ma_sound_start(pSound);
if (result != MA_SUCCESS)
{
ChipLogError(DeviceLayer, "Failed to start sound %d: %d", chimeID, result);
}
}
else
{
ChipLogError(DeviceLayer, "PosixChimeDevice: Sound %d not found in memory", chimeID);
}
return status;
}
} // namespace app
} // namespace chip