diff --git a/Buildscripts/release-simulator-macos-app.sh b/Buildscripts/release-simulator-macos-app.sh
index 69cb0884..e6ab94ba 100644
--- a/Buildscripts/release-simulator-macos-app.sh
+++ b/Buildscripts/release-simulator-macos-app.sh
@@ -45,6 +45,8 @@ cat > "$contents_path/Info.plist" <<'EOF'
0.8.0-dev
CFBundleVersion
1
+ NSMicrophoneUsageDescription
+ Tactility uses the microphone when a simulator app records audio.
EOF
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9f123385..8aa63310 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -110,6 +110,9 @@ endif ()
# Defined as regular project for PC and component for ESP
if (NOT DEFINED ENV{ESP_IDF_VERSION})
+ if (APPLE)
+ enable_language(OBJC OBJCXX)
+ endif ()
add_subdirectory(Tactility)
add_subdirectory(TactilityFreeRtos)
add_subdirectory(TactilityKernel)
@@ -137,6 +140,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
add_subdirectory(Modules/lvgl-window-manager-module)
add_subdirectory(Drivers/gps-generic-module)
add_subdirectory(Drivers/gps-meshtastic-module)
+ add_subdirectory(Drivers/audio-stream-module)
# FreeRTOS
set(FREERTOS_CONFIG_FILE_DIRECTORY ${PROJECT_SOURCE_DIR}/Devices/simulator/Source CACHE STRING "")
diff --git a/Devices/simulator/CMakeLists.txt b/Devices/simulator/CMakeLists.txt
index f51dbb59..57aec603 100644
--- a/Devices/simulator/CMakeLists.txt
+++ b/Devices/simulator/CMakeLists.txt
@@ -21,4 +21,13 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
target_link_libraries(simulator PRIVATE ${SDL2_LIBRARIES})
+ if (APPLE)
+ target_sources(simulator PRIVATE Source/drivers/sdl_audio_permission.mm)
+ target_link_libraries(simulator PRIVATE "-framework AVFoundation" "-framework Foundation")
+ # The developer executable can also request recording outside an .app bundle.
+ target_link_options(simulator INTERFACE
+ "LINKER:-sectcreate,__TEXT,__info_plist,${CMAKE_CURRENT_SOURCE_DIR}/macos-info.plist")
+ set_property(TARGET Tactility APPEND PROPERTY LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/macos-info.plist")
+ endif ()
+
endif()
diff --git a/Devices/simulator/Source/drivers/sdl_audio.cpp b/Devices/simulator/Source/drivers/sdl_audio.cpp
new file mode 100644
index 00000000..80e4c8ea
--- /dev/null
+++ b/Devices/simulator/Source/drivers/sdl_audio.cpp
@@ -0,0 +1,325 @@
+// SPDX-License-Identifier: Apache-2.0
+#include "sdl_audio.h"
+#include "sdl_audio_buffer.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+constexpr auto* TAG = "SdlAudio";
+constexpr uint32_t SAMPLE_RATE = 48000;
+
+struct AudioData {
+ SdlAudioBuffer buffer;
+ SemaphoreHandle_t mutex = nullptr; // task-side only; never used by the SDL callback
+ SDL_AudioDeviceID id = 0;
+ AudioCodecDirection direction;
+ std::atomic volume { 100.0f };
+ std::atomic muted { false };
+ std::atomic callbacks { 0 };
+};
+
+AudioData* get_data(Device* device) {
+ return static_cast(device_get_driver_data(device));
+}
+
+class Lock {
+ SemaphoreHandle_t mutex;
+public:
+ explicit Lock(AudioData* data) : mutex(data->mutex) { xSemaphoreTake(mutex, portMAX_DELAY); }
+ ~Lock() { xSemaphoreGive(mutex); }
+};
+
+const char* device_name(const AudioData* data) {
+ const char* name = std::getenv(data->direction == AUDIO_CODEC_DIR_INPUT ? "SIM_AUDIO_INPUT" : "SIM_AUDIO_OUTPUT");
+ return name != nullptr && name[0] != '\0' ? name : nullptr;
+}
+
+bool available(const AudioData* data) {
+ const char* name = device_name(data);
+ if (name != nullptr && std::strcmp(name, "none") == 0) return false;
+ const int capture = data->direction == AUDIO_CODEC_DIR_INPUT;
+ const int count = SDL_GetNumAudioDevices(capture);
+ if (name == nullptr) return count > 0;
+ for (int i = 0; i < count; ++i) {
+ const char* candidate = SDL_GetAudioDeviceName(i, capture);
+ if (candidate != nullptr && std::strcmp(candidate, name) == 0) return true;
+ }
+ return false;
+}
+
+void apply_volume(AudioData* data, void* bytes, size_t size) {
+ const float gain = data->muted.load() ? 0.0f : data->volume.load() / 100.0f;
+ auto* output = static_cast(bytes);
+ for (size_t i = 0; i < size; i += sizeof(int16_t)) {
+ int16_t sample;
+ std::memcpy(&sample, output + i, sizeof(sample));
+ sample = static_cast(sample * gain);
+ std::memcpy(output + i, &sample, sizeof(sample));
+ }
+}
+
+void audio_callback(void* context, Uint8* stream, int length) {
+ auto* data = static_cast(context);
+ const size_t count = static_cast(length) / sizeof(int16_t);
+ if (data->direction == AUDIO_CODEC_DIR_INPUT) {
+ // Drop incoming frames when the bounded capture buffer is full. Muted capture
+ // must not leave real microphone samples queued for a later unmute.
+ if (data->muted.load()) std::memset(stream, 0, length);
+ data->buffer.write(stream, count);
+ } else {
+ const size_t copied = data->buffer.read(stream, count) * sizeof(int16_t);
+ std::memset(stream + copied, 0, length - copied); // silence on underrun
+ apply_volume(data, stream, copied);
+ }
+ data->callbacks.fetch_add(1, std::memory_order_relaxed);
+}
+
+error_t start(Device* device) {
+ const auto* config = static_cast(device->config);
+ if (config == nullptr || (config->direction != AUDIO_CODEC_DIR_INPUT && config->direction != AUDIO_CODEC_DIR_OUTPUT)) {
+ return ERROR_INVALID_ARGUMENT;
+ }
+ if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) {
+ LOG_E(TAG, "Cannot initialize audio: %s", SDL_GetError());
+ return ERROR_RESOURCE;
+ }
+ auto* data = new (std::nothrow) AudioData;
+ if (data == nullptr) {
+ SDL_QuitSubSystem(SDL_INIT_AUDIO);
+ return ERROR_OUT_OF_MEMORY;
+ }
+ data->direction = config->direction;
+ data->mutex = xSemaphoreCreateMutex();
+ if (data->mutex == nullptr) {
+ delete data;
+ SDL_QuitSubSystem(SDL_INIT_AUDIO);
+ return ERROR_OUT_OF_MEMORY;
+ }
+ device_set_driver_data(device, data);
+ const int capture = data->direction == AUDIO_CODEC_DIR_INPUT;
+ for (int i = 0; i < SDL_GetNumAudioDevices(capture); ++i) {
+ LOG_I(TAG, "%s device: %s", capture ? "Input" : "Output", SDL_GetAudioDeviceName(i, capture));
+ }
+ LOG_I(TAG, "%s: %s (%s)", device->name, device_name(data) != nullptr ? device_name(data) : "system default",
+ available(data) ? "available" : "unavailable");
+ return ERROR_NONE;
+}
+
+error_t open(Device* device, const AudioCodecStreamConfig* config) {
+ auto* data = get_data(device);
+ if (config == nullptr) return ERROR_INVALID_ARGUMENT;
+ if (config->direction != data->direction) return ERROR_NOT_SUPPORTED;
+ // The shared stream module performs rate/channel conversion on S16 PCM.
+ if (config->bits_per_sample != 16) return ERROR_NOT_SUPPORTED;
+ const uint8_t channels = data->direction == AUDIO_CODEC_DIR_INPUT ? 1 : 2;
+ if (config->sample_rate != SAMPLE_RATE || config->channels != channels) return ERROR_INVALID_ARGUMENT;
+ Lock lock(data);
+ if (data->id != 0) return ERROR_INVALID_STATE;
+ if (!available(data)) {
+ LOG_W(TAG, "No selected %s device available", data->direction == AUDIO_CODEC_DIR_INPUT ? "input" : "output");
+ return ERROR_NOT_SUPPORTED;
+ }
+#ifdef __APPLE__
+ // Only ask when recording is requested, and only with real macOS audio (dummy
+ // and disk backends are also useful for automated tests).
+ if (data->direction == AUDIO_CODEC_DIR_INPUT && std::strcmp(SDL_GetCurrentAudioDriver(), "coreaudio") == 0) {
+ error_t permission;
+ while ((permission = sdl_audio_microphone_permission()) == ERROR_RESOURCE_BUSY) vTaskDelay(1);
+ if (permission != ERROR_NONE) {
+ LOG_W(TAG, "Microphone permission denied; enable access in macOS Privacy & Security > Microphone");
+ return permission;
+ }
+ }
+#endif
+ SDL_AudioSpec wanted {};
+ wanted.freq = SAMPLE_RATE;
+ wanted.format = AUDIO_S16SYS;
+ wanted.channels = channels;
+ wanted.samples = 512;
+ wanted.callback = audio_callback;
+ wanted.userdata = data;
+ data->buffer.reset();
+ data->callbacks.store(0);
+ // No allowed changes: SDL converts between our fixed PCM format and the host
+ // device's format when necessary. No hardware-specific format leaks to apps.
+ data->id = SDL_OpenAudioDevice(device_name(data), data->direction == AUDIO_CODEC_DIR_INPUT, &wanted, nullptr, 0);
+ if (data->id == 0) {
+ LOG_E(TAG, "Cannot open %s: %s", device->name, SDL_GetError());
+ return ERROR_RESOURCE;
+ }
+ SDL_PauseAudioDevice(data->id, 0);
+ return ERROR_NONE;
+}
+
+error_t close(Device* device) {
+ auto* data = get_data(device);
+ Lock lock(data);
+ if (data->id != 0) {
+ // Preserve the end of short sounds. Drain is bounded even if the device has
+ // disappeared; microphone close never waits for the capture buffer to empty.
+ const TickType_t start = xTaskGetTickCount();
+ while (data->direction == AUDIO_CODEC_DIR_OUTPUT && !data->buffer.empty()
+ && xTaskGetTickCount() - start < pdMS_TO_TICKS(250)
+ && SDL_GetAudioDeviceStatus(data->id) == SDL_AUDIO_PLAYING) {
+ vTaskDelay(1);
+ }
+ SDL_CloseAudioDevice(data->id);
+ data->id = 0;
+ data->buffer.reset();
+ }
+ return ERROR_NONE;
+}
+
+error_t stop(Device* device) {
+ auto* data = get_data(device);
+ close(device);
+ vSemaphoreDelete(data->mutex);
+ delete data;
+ device_set_driver_data(device, nullptr);
+ SDL_QuitSubSystem(SDL_INIT_AUDIO);
+ return ERROR_NONE;
+}
+
+error_t transfer(Device* device, void* destination, const void* source, size_t size, size_t* transferred, TickType_t timeout, bool capture) {
+ if (transferred != nullptr) *transferred = 0;
+ auto* data = get_data(device);
+ if ((data->direction == AUDIO_CODEC_DIR_INPUT) != capture) return ERROR_NOT_SUPPORTED;
+ const size_t frame_size = sizeof(int16_t) * (capture ? 1 : 2);
+ if (size % frame_size != 0 || (size != 0 && (capture ? destination == nullptr : source == nullptr))) return ERROR_INVALID_ARGUMENT;
+ Lock lock(data);
+ if (data->id == 0) return ERROR_INVALID_STATE;
+ const TickType_t start = xTaskGetTickCount();
+ TickType_t last_callback = start;
+ uint32_t callbacks = data->callbacks.load();
+ size_t done = 0;
+ error_t result = ERROR_NONE;
+ while (done < size) {
+ if (SDL_GetAudioDeviceStatus(data->id) != SDL_AUDIO_PLAYING) {
+ result = ERROR_RESOURCE;
+ break;
+ }
+ const size_t count = (size - done) / sizeof(int16_t);
+ const size_t copied = capture
+ ? data->buffer.read(static_cast(destination) + done, count)
+ : data->buffer.write(static_cast(source) + done, count);
+ done += copied * sizeof(int16_t);
+ if (done == size) break;
+ const TickType_t now = xTaskGetTickCount();
+ if (timeout != portMAX_DELAY && now - start >= timeout) {
+ result = ERROR_TIMEOUT;
+ break;
+ }
+ const uint32_t current_callbacks = data->callbacks.load();
+ if (current_callbacks != callbacks) {
+ callbacks = current_callbacks;
+ last_callback = now;
+ } else if (now - last_callback >= pdMS_TO_TICKS(2000)) {
+ // A stopped backend must not strand an infinite-timeout caller or a
+ // concurrent Settings disable waiting for that caller to finish.
+ result = ERROR_RESOURCE;
+ break;
+ }
+ vTaskDelay(1);
+ }
+ if (capture) apply_volume(data, destination, done);
+ if (transferred != nullptr) *transferred = done;
+ return result;
+}
+
+error_t read(Device* device, void* destination, size_t size, size_t* count, TickType_t timeout) {
+ return transfer(device, destination, nullptr, size, count, timeout, true);
+}
+
+error_t write(Device* device, const void* source, size_t size, size_t* count, TickType_t timeout) {
+ return transfer(device, nullptr, source, size, count, timeout, false);
+}
+
+error_t set_volume(Device* device, AudioCodecDirection direction, float volume) {
+ auto* data = get_data(device);
+ if (direction != data->direction) return ERROR_NOT_SUPPORTED;
+ if (!std::isfinite(volume) || volume < 0.0f || volume > 100.0f) return ERROR_INVALID_ARGUMENT;
+ data->volume.store(volume);
+ return ERROR_NONE;
+}
+
+error_t get_volume(Device* device, AudioCodecDirection direction, float* volume) {
+ auto* data = get_data(device);
+ if (direction != data->direction) return ERROR_NOT_SUPPORTED;
+ *volume = data->volume.load();
+ return ERROR_NONE;
+}
+
+error_t set_mute(Device* device, AudioCodecDirection direction, bool muted) {
+ auto* data = get_data(device);
+ if (direction != data->direction) return ERROR_NOT_SUPPORTED;
+ data->muted.store(muted);
+ return ERROR_NONE;
+}
+
+error_t get_mute(Device* device, AudioCodecDirection direction, bool* muted) {
+ auto* data = get_data(device);
+ if (direction != data->direction) return ERROR_NOT_SUPPORTED;
+ *muted = data->muted.load();
+ return ERROR_NONE;
+}
+
+error_t get_rate(Device* device, AudioCodecDirection direction, uint32_t* rate) {
+ if (direction != get_data(device)->direction) return ERROR_NOT_SUPPORTED;
+ *rate = SAMPLE_RATE;
+ return ERROR_NONE;
+}
+
+error_t get_channels(Device* device, AudioCodecDirection direction, uint8_t* channels) {
+ if (direction != get_data(device)->direction) return ERROR_NOT_SUPPORTED;
+ *channels = direction == AUDIO_CODEC_DIR_INPUT ? 1 : 2;
+ return ERROR_NONE;
+}
+
+error_t get_capabilities(Device* device, AudioCodecDirection* direction) {
+ auto* data = get_data(device);
+ if (!available(data)) return ERROR_NOT_SUPPORTED;
+ *direction = data->direction;
+ return ERROR_NONE;
+}
+
+const AudioCodecApi api = {
+ .open = open,
+ .close = close,
+ .read = read,
+ .write = write,
+ .set_volume = set_volume,
+ .get_volume = get_volume,
+ .set_mute = set_mute,
+ .get_mute = get_mute,
+ .get_native_sample_rate = get_rate,
+ .get_native_channels = get_channels,
+ .get_capabilities = get_capabilities,
+ .get_input_gain_multiplier = nullptr,
+};
+
+} // namespace
+
+extern "C" Module simulator_module;
+
+Driver sdl_audio_driver = {
+ .name = "sdl-audio",
+ .compatible = (const char*[]) { "tactility,sdl-audio", nullptr },
+ .start_device = start,
+ .stop_device = stop,
+ .api = &api,
+ .device_type = &AUDIO_CODEC_TYPE,
+ .owner = &simulator_module,
+ .internal = nullptr,
+};
diff --git a/Devices/simulator/Source/drivers/sdl_audio.h b/Devices/simulator/Source/drivers/sdl_audio.h
new file mode 100644
index 00000000..d38320a7
--- /dev/null
+++ b/Devices/simulator/Source/drivers/sdl_audio.h
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+struct SdlAudioConfig {
+ AudioCodecDirection direction;
+};
+
+#ifdef __APPLE__
+// Nonblocking: ERROR_RESOURCE_BUSY means that the user has not answered the prompt yet.
+error_t sdl_audio_microphone_permission();
+#endif
diff --git a/Devices/simulator/Source/drivers/sdl_audio_buffer.h b/Devices/simulator/Source/drivers/sdl_audio_buffer.h
new file mode 100644
index 00000000..a7cc318a
--- /dev/null
+++ b/Devices/simulator/Source/drivers/sdl_audio_buffer.h
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+// Single producer / single consumer. The SDL callback is a native thread: it must never
+// allocate, block, log, or call FreeRTOS. Unsigned counters also work across wraparound.
+class SdlAudioBuffer {
+ static constexpr uint32_t CAPACITY = 16384; // samples; ~171 ms of 48 kHz stereo
+ std::array samples {};
+ std::atomic read_position { 0 };
+ std::atomic write_position { 0 };
+
+public:
+ size_t write(const void* source, size_t count) {
+ const uint32_t write = write_position.load(std::memory_order_relaxed);
+ const uint32_t read = read_position.load(std::memory_order_acquire);
+ count = std::min(count, static_cast(CAPACITY - (write - read)));
+ const size_t first = std::min(count, static_cast(CAPACITY - write % CAPACITY));
+ const auto* bytes = static_cast(source);
+ std::memcpy(samples.data() + write % CAPACITY, bytes, first * sizeof(int16_t));
+ std::memcpy(samples.data(), bytes + first * sizeof(int16_t), (count - first) * sizeof(int16_t));
+ write_position.store(write + count, std::memory_order_release);
+ return count;
+ }
+
+ size_t read(void* destination, size_t count) {
+ const uint32_t read = read_position.load(std::memory_order_relaxed);
+ const uint32_t write = write_position.load(std::memory_order_acquire);
+ count = std::min(count, static_cast(write - read));
+ const size_t first = std::min(count, static_cast(CAPACITY - read % CAPACITY));
+ auto* bytes = static_cast(destination);
+ std::memcpy(bytes, samples.data() + read % CAPACITY, first * sizeof(int16_t));
+ std::memcpy(bytes + first * sizeof(int16_t), samples.data(), (count - first) * sizeof(int16_t));
+ read_position.store(read + count, std::memory_order_release);
+ return count;
+ }
+
+ bool empty() const {
+ return read_position.load(std::memory_order_acquire) == write_position.load(std::memory_order_acquire);
+ }
+
+ // Only while the SDL device is closed and no caller is doing I/O.
+ void reset() {
+ read_position.store(0);
+ write_position.store(0);
+ }
+};
diff --git a/Devices/simulator/Source/drivers/sdl_audio_permission.mm b/Devices/simulator/Source/drivers/sdl_audio_permission.mm
new file mode 100644
index 00000000..998b6e92
--- /dev/null
+++ b/Devices/simulator/Source/drivers/sdl_audio_permission.mm
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: Apache-2.0
+#include "sdl_audio.h"
+
+#import
+#include
+
+error_t sdl_audio_microphone_permission() {
+ @autoreleasepool {
+ switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]) {
+ case AVAuthorizationStatusAuthorized:
+ return ERROR_NONE;
+ case AVAuthorizationStatusDenied:
+ case AVAuthorizationStatusRestricted:
+ return ERROR_NOT_ALLOWED;
+ case AVAuthorizationStatusNotDetermined: {
+ static std::atomic requested { false };
+ if (!requested.exchange(true)) {
+ [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
+ requested.store(false);
+ }];
+ }
+ return ERROR_RESOURCE_BUSY;
+ }
+ }
+ return ERROR_NOT_ALLOWED;
+ }
+}
diff --git a/Devices/simulator/Source/module.cpp b/Devices/simulator/Source/module.cpp
index 447bad40..79fddab7 100644
--- a/Devices/simulator/Source/module.cpp
+++ b/Devices/simulator/Source/module.cpp
@@ -1,4 +1,5 @@
#include "drivers/sdl_display.h"
+#include "drivers/sdl_audio.h"
#include
#include
@@ -16,11 +17,13 @@ extern "C" {
extern Driver sdl_display_driver;
extern Driver sdl_pointer_driver;
extern Driver sdl_keyboard_driver;
+extern Driver sdl_audio_driver;
static Driver* const simulator_drivers[] = {
&sdl_display_driver,
&sdl_pointer_driver,
&sdl_keyboard_driver,
+ &sdl_audio_driver,
nullptr
};
@@ -54,6 +57,10 @@ static SdlDisplayConfig sdl_display_config = { 480, 320 };
static Device sdl_display_device {};
static Device sdl_pointer_device {};
static Device sdl_keyboard_device {};
+static Device sdl_speaker_device {};
+static Device sdl_microphone_device {};
+static const SdlAudioConfig sdl_speaker_config { AUDIO_CODEC_DIR_OUTPUT };
+static const SdlAudioConfig sdl_microphone_config { AUDIO_CODEC_DIR_INPUT };
static bool construct_add_start(Device* device, Device* parent, const char* name, const void* config, const char* compatible) {
device->address = 0;
@@ -109,6 +116,8 @@ static void on_root_started(Device* device, DeviceEvent event, void* context) {
construct_add_start(&sdl_display_device, device, "display0", &sdl_display_config, "tactility,sdl-display");
construct_add_start(&sdl_pointer_device, device, "pointer0", nullptr, "tactility,sdl-pointer");
construct_add_start(&sdl_keyboard_device, device, "keyboard0", nullptr, "tactility,sdl-keyboard");
+ construct_add_start(&sdl_speaker_device, device, "speaker0", &sdl_speaker_config, "tactility,sdl-audio");
+ construct_add_start(&sdl_microphone_device, device, "microphone0", &sdl_microphone_config, "tactility,sdl-audio");
}
extern "C" {
diff --git a/Devices/simulator/devicetree.yaml b/Devices/simulator/devicetree.yaml
index b6a0ebda..4153fab2 100644
--- a/Devices/simulator/devicetree.yaml
+++ b/Devices/simulator/devicetree.yaml
@@ -1,3 +1,4 @@
dependencies:
- Platforms/platform-posix
+ - Drivers/audio-stream-module
dts: simulator.dts
diff --git a/Devices/simulator/macos-info.plist b/Devices/simulator/macos-info.plist
new file mode 100644
index 00000000..9f4c3b06
--- /dev/null
+++ b/Devices/simulator/macos-info.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ CFBundleIdentifier
+ org.tactilityproject.simulator
+ CFBundleName
+ Tactility
+ NSMicrophoneUsageDescription
+ Tactility uses the microphone when a simulator app records audio.
+
+
diff --git a/Devices/simulator/tests/CMakeLists.txt b/Devices/simulator/tests/CMakeLists.txt
new file mode 100644
index 00000000..d0eefe12
--- /dev/null
+++ b/Devices/simulator/tests/CMakeLists.txt
@@ -0,0 +1,14 @@
+add_executable(SimulatorAudioTests EXCLUDE_FROM_ALL
+ audio.cpp
+ ../Source/drivers/sdl_audio.cpp
+)
+target_include_directories(SimulatorAudioTests PRIVATE ${DOCTESTINC} ../Source/drivers)
+target_link_libraries(SimulatorAudioTests PRIVATE TactilityKernel platform-posix audio-stream-module SDL2-static)
+if (APPLE)
+ target_sources(SimulatorAudioTests PRIVATE ../Source/drivers/sdl_audio_permission.mm)
+ target_link_libraries(SimulatorAudioTests PRIVATE "-framework AVFoundation" "-framework Foundation")
+endif ()
+add_test(NAME SimulatorAudioTests COMMAND SimulatorAudioTests)
+set_tests_properties(SimulatorAudioTests PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=dummy;SIM_AUDIO_INPUT=;SIM_AUDIO_OUTPUT=")
+add_test(NAME SimulatorAudioPcmTests COMMAND SimulatorAudioTests --no-skip "--test-case=disk PCM*")
+set_tests_properties(SimulatorAudioPcmTests PROPERTIES TIMEOUT 30 ENVIRONMENT "SDL_AUDIODRIVER=disk;SIM_AUDIO_INPUT=;SIM_AUDIO_OUTPUT=")
diff --git a/Devices/simulator/tests/audio.cpp b/Devices/simulator/tests/audio.cpp
new file mode 100644
index 00000000..a49562da
--- /dev/null
+++ b/Devices/simulator/tests/audio.cpp
@@ -0,0 +1,301 @@
+// SPDX-License-Identifier: Apache-2.0
+#define DOCTEST_CONFIG_IMPLEMENT
+#include "doctest.h"
+#include "sdl_audio.h"
+#include "sdl_audio_buffer.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+extern Driver sdl_audio_driver;
+extern "C" {
+extern Module platform_posix_module;
+extern Module audio_stream_module;
+extern Device audio_stream_device;
+static Driver* const drivers[] = { &sdl_audio_driver, nullptr };
+Module simulator_module = { .name = "simulator-audio-test", .drivers = drivers };
+}
+
+static const SdlAudioConfig output_config { AUDIO_CODEC_DIR_OUTPUT };
+static const SdlAudioConfig input_config { AUDIO_CODEC_DIR_INPUT };
+static Device speaker { .name = "speaker-test", .config = &output_config };
+static Device microphone { .name = "microphone-test", .config = &input_config };
+
+struct Stream {
+ AudioStreamHandle handle = nullptr;
+ ~Stream() { if (handle != nullptr) audio_stream_close(handle); }
+};
+
+TEST_CASE("bounded PCM buffer preserves data across wrap and overflow") {
+ SdlAudioBuffer buffer;
+ std::vector input(20000);
+ for (size_t i = 0; i < input.size(); ++i) input[i] = static_cast(i);
+ std::vector output(20000, -1);
+ CHECK(buffer.read(output.data(), output.size()) == 0);
+ REQUIRE(buffer.write(input.data(), input.size()) == 16384);
+ CHECK(buffer.write(input.data(), 2) == 0);
+ REQUIRE(buffer.read(output.data(), 10000) == 10000);
+ CHECK(std::equal(output.begin(), output.begin() + 10000, input.begin()));
+ REQUIRE(buffer.write(input.data(), 10000) == 10000);
+ REQUIRE(buffer.read(output.data(), output.size()) == 16384);
+ CHECK(std::equal(output.begin(), output.begin() + 6384, input.begin() + 10000));
+ CHECK(std::equal(output.begin() + 6384, output.begin() + 16384, input.begin()));
+ CHECK(buffer.empty());
+}
+
+TEST_CASE("simulator streams support independent full duplex and common PCM rates") {
+ for (uint32_t rate : { 16000u, 44100u, 48000u }) {
+ CAPTURE(rate);
+ const AudioStreamConfig config { rate, 16, 1 };
+ Stream input;
+ Stream output;
+ REQUIRE(audio_stream_open_input(&audio_stream_device, &config, &input.handle) == ERROR_NONE);
+ REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
+ AudioStreamHandle duplicate = nullptr;
+ CHECK(audio_stream_open_output(&audio_stream_device, &config, &duplicate) == ERROR_INVALID_STATE);
+ std::vector samples(rate / 20, 1234);
+ size_t count = 0;
+ REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
+ CHECK(count == samples.size() * 2);
+ REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
+ CHECK(count == samples.size() * 2);
+ CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 0; }));
+ REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
+ output.handle = nullptr;
+ // Closing the speaker must not stop the microphone.
+ REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
+ }
+}
+
+TEST_CASE("simulator audio controls persist across opens and disabling closes output") {
+ auto* device = &audio_stream_device;
+ REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_OUTPUT, 25.0f) == ERROR_NONE);
+ REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
+ const AudioStreamConfig config { 48000, 16, 2 };
+ Stream output;
+ REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
+ float volume = 0;
+ bool muted = false;
+ REQUIRE(audio_codec_get_volume(&speaker, AUDIO_CODEC_DIR_OUTPUT, &volume) == ERROR_NONE);
+ REQUIRE(audio_codec_get_mute(&speaker, AUDIO_CODEC_DIR_OUTPUT, &muted) == ERROR_NONE);
+ CHECK(volume == 25.0f);
+ CHECK(muted);
+ REQUIRE(audio_stream_set_enabled(device, AUDIO_CODEC_DIR_OUTPUT, false) == ERROR_NONE);
+ output.handle = nullptr; // set_enabled closes and owns destruction of the handle
+ CHECK(audio_stream_open_output(device, &config, &output.handle) == ERROR_NOT_ALLOWED);
+ REQUIRE(audio_stream_set_enabled(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
+ REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, false) == ERROR_NONE);
+ REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
+}
+
+TEST_CASE("bounded playback reports partial progress on a nonblocking timeout") {
+ const AudioStreamConfig config { 48000, 16, 2 };
+ Stream output;
+ REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
+ std::vector samples(48000 * 2, 0);
+ size_t count = 999;
+ CHECK(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
+ CHECK(count > 0);
+ CHECK(count < samples.size() * 2);
+ CHECK(count % 4 == 0);
+}
+
+TEST_CASE("converted streams report partial progress on timeout") {
+ const AudioStreamConfig config { 16000, 16, 1 };
+ Stream output;
+ Stream input;
+ REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
+ REQUIRE(audio_stream_open_input(&audio_stream_device, &config, &input.handle) == ERROR_NONE);
+ std::vector samples(16000, 0);
+ size_t count = 999;
+ CHECK(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
+ CHECK(count > 0);
+ CHECK(count < samples.size() * 2);
+ CHECK(count % 2 == 0);
+ vTaskDelay(pdMS_TO_TICKS(30)); // allow dummy capture to produce some samples
+ count = 999;
+ CHECK(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, 0) == ERROR_TIMEOUT);
+ CHECK(count > 0);
+ CHECK(count < samples.size() * 2);
+ CHECK(count % 2 == 0);
+}
+
+TEST_CASE("missing selected microphone does not prevent speaker playback") {
+ REQUIRE(SDL_setenv("SIM_AUDIO_INPUT", "tactility-nonexistent-microphone", 1) == 0);
+ AudioCodecDirection capability;
+ CHECK(audio_codec_get_capabilities(µphone, &capability) == ERROR_NOT_SUPPORTED);
+ const AudioCodecStreamConfig config { 48000, 16, 1, AUDIO_CODEC_DIR_INPUT };
+ CHECK(audio_codec_open(µphone, &config) == ERROR_NOT_SUPPORTED);
+ CHECK(audio_codec_get_capabilities(&speaker, &capability) == ERROR_NONE);
+ CHECK(capability == AUDIO_CODEC_DIR_OUTPUT);
+ REQUIRE(SDL_setenv("SIM_AUDIO_INPUT", "", 1) == 0);
+}
+
+TEST_CASE("unsupported sample widths fail without breaking a later open") {
+ AudioStreamConfig config { 16000, 24, 1 };
+ Stream output;
+ CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) != ERROR_NONE);
+ CHECK(output.handle == nullptr);
+ config.bits_per_sample = 16;
+ REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
+}
+
+TEST_CASE("zero sample rate is rejected before conversion") {
+ const AudioStreamConfig config { 0, 16, 1 };
+ Stream output;
+ CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_INVALID_ARGUMENT);
+}
+
+TEST_CASE("disabling while a codec is opening cancels the pending stream") {
+ const auto* original_api = static_cast(sdl_audio_driver.api);
+ static const AudioCodecApi* wrapped_api;
+ wrapped_api = original_api;
+ AudioCodecApi delayed_api = *original_api;
+ delayed_api.open = [](Device* device, const AudioCodecStreamConfig* config) {
+ vTaskDelay(pdMS_TO_TICKS(50)); // models waiting for microphone permission
+ return wrapped_api->open(device, config);
+ };
+ sdl_audio_driver.api = &delayed_api;
+ struct RestoreApi {
+ const AudioCodecApi* api;
+ ~RestoreApi() { sdl_audio_driver.api = api; }
+ } restore { original_api };
+ REQUIRE(xTaskCreate([](void*) {
+ vTaskDelay(pdMS_TO_TICKS(5));
+ audio_stream_set_enabled(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, false);
+ // Re-enabling does not resurrect the cancelled attempt.
+ audio_stream_set_enabled(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, true);
+ vTaskDelete(nullptr);
+ }, "disable-audio", 8192, nullptr, 1, nullptr) == pdPASS);
+ const AudioStreamConfig config { 48000, 16, 2 };
+ Stream output;
+ CHECK(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NOT_ALLOWED);
+ CHECK(output.handle == nullptr);
+ sdl_audio_driver.api = original_api;
+ REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
+}
+
+TEST_CASE("hardware output smoke test" * doctest::skip()) {
+ // Explicit opt-in only: SDL_AUDIODRIVER=coreaudio ... --no-skip --test-case='hardware output smoke test'
+ const AudioStreamConfig config { 48000, 16, 2 };
+ Stream output;
+ REQUIRE(audio_stream_set_volume(&audio_stream_device, AUDIO_CODEC_DIR_OUTPUT, 20) == ERROR_NONE);
+ REQUIRE(audio_stream_open_output(&audio_stream_device, &config, &output.handle) == ERROR_NONE);
+ std::vector samples(48000); // half a second, stereo, quiet 440 Hz tone
+ for (size_t frame = 0; frame < samples.size() / 2; ++frame) {
+ samples[frame * 2] = samples[frame * 2 + 1] = static_cast(3000 * std::sin(frame * 440.0 * 6.283185307 / 48000));
+ }
+ size_t count = 0;
+ REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(2000)) == ERROR_NONE);
+ CHECK(count == samples.size() * 2);
+}
+
+TEST_CASE("disk PCM capture and playback apply gain and mute" * doctest::skip()) {
+ REQUIRE(std::strcmp(SDL_GetCurrentAudioDriver(), "disk") == 0);
+ auto* device = &audio_stream_device;
+ const AudioStreamConfig config { 48000, 16, 1 };
+ Stream input;
+ Stream output;
+ REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_INPUT, 50) == ERROR_NONE);
+ REQUIRE(audio_stream_set_volume(device, AUDIO_CODEC_DIR_OUTPUT, 25) == ERROR_NONE);
+ REQUIRE(audio_stream_open_input(device, &config, &input.handle) == ERROR_NONE);
+ REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
+ std::vector samples(960, -1);
+ size_t count = 0;
+ REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
+ CHECK(count == samples.size() * 2);
+ // Fixture contains 10000; microphone gain is 50%.
+ CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 5000; }));
+ REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
+ REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
+ output.handle = nullptr;
+ {
+ std::ifstream file(std::getenv("SDL_DISKAUDIOFILE"), std::ios::binary);
+ REQUIRE(file.good());
+ size_t nonzero = 0;
+ int16_t sample;
+ while (file.read(reinterpret_cast(&sample), sizeof(sample))) {
+ CHECK((sample == 0 || sample == 1250)); // 25% output volume
+ if (sample != 0) nonzero++;
+ }
+ CHECK(nonzero == samples.size() * 2); // mono was duplicated to stereo
+ }
+ REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_INPUT, true) == ERROR_NONE);
+ REQUIRE(audio_stream_read(input.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
+ CHECK(std::all_of(samples.begin(), samples.end(), [](int16_t sample) { return sample == 0; }));
+ REQUIRE(audio_stream_set_mute(device, AUDIO_CODEC_DIR_OUTPUT, true) == ERROR_NONE);
+ REQUIRE(audio_stream_open_output(device, &config, &output.handle) == ERROR_NONE);
+ std::fill(samples.begin(), samples.end(), 5000);
+ REQUIRE(audio_stream_write(output.handle, samples.data(), samples.size() * 2, &count, pdMS_TO_TICKS(1000)) == ERROR_NONE);
+ REQUIRE(audio_stream_close(output.handle) == ERROR_NONE);
+ output.handle = nullptr;
+ std::ifstream file(std::getenv("SDL_DISKAUDIOFILE"), std::ios::binary);
+ REQUIRE(file.good());
+ size_t total = 0;
+ int16_t sample;
+ while (file.read(reinterpret_cast(&sample), sizeof(sample))) {
+ CHECK(sample == 0);
+ total++;
+ }
+ CHECK(total >= samples.size() * 2);
+}
+
+struct TestContext { int argc; char** argv; int result = 1; };
+
+static void run_tests(void* argument) {
+ auto* data = static_cast(argument);
+ Module* modules[] = { &platform_posix_module, &simulator_module, &audio_stream_module, nullptr };
+ DtsDevice devices[] = { DTS_DEVICE_TERMINATOR };
+ if (kernel_init(modules, devices) == ERROR_NONE
+ && device_construct_add_start(&speaker, "tactility,sdl-audio") == ERROR_NONE
+ && device_construct_add_start(µphone, "tactility,sdl-audio") == ERROR_NONE) {
+ doctest::Context context(data->argc, data->argv);
+ context.setOption("no-breaks", true);
+ data->result = context.run();
+ device_stop(µphone);
+ device_stop(&speaker);
+ }
+ vTaskEndScheduler();
+ vTaskDelete(nullptr);
+}
+
+int main(int argc, char** argv) {
+ if (std::getenv("SDL_AUDIODRIVER") == nullptr) SDL_setenv("SDL_AUDIODRIVER", "dummy", 1);
+ char input_path[] = "sim-audio-input-XXXXXX";
+ char output_path[] = "sim-audio-output-XXXXXX";
+ const bool disk = std::strcmp(std::getenv("SDL_AUDIODRIVER"), "disk") == 0;
+ if (disk) {
+ const int input_fd = mkstemp(input_path);
+ const int output_fd = mkstemp(output_path);
+ if (input_fd < 0 || output_fd < 0) return 1;
+ FILE* file = fdopen(input_fd, "wb");
+ if (file == nullptr) return 1;
+ const std::vector fixture(48000, 10000);
+ const size_t written = std::fwrite(fixture.data(), sizeof(int16_t), fixture.size(), file);
+ std::fclose(file);
+ ::close(output_fd);
+ if (written != fixture.size()) return 1;
+ SDL_setenv("SDL_DISKAUDIOFILEIN", input_path, 1);
+ SDL_setenv("SDL_DISKAUDIOFILE", output_path, 1);
+ }
+ TestContext data { argc, argv };
+ if (xTaskCreate(run_tests, "audio-test", 32768, &data, 1, nullptr) != pdPASS) return 1;
+ vTaskStartScheduler();
+ if (disk) {
+ std::remove(input_path);
+ std::remove(output_path);
+ }
+ return data.result;
+}
diff --git a/Documentation/simulator-audio.md b/Documentation/simulator-audio.md
new file mode 100644
index 00000000..6ef7b641
--- /dev/null
+++ b/Documentation/simulator-audio.md
@@ -0,0 +1,93 @@
+# Simulator audio
+
+The desktop simulator exposes an SDL speaker and microphone through Tactility's
+standard `audio_stream_*` API. On macOS, SDL uses CoreAudio. Audio Settings controls
+the simulator's input/output volume, mute, and enabled state; these controls do not
+change macOS's system volume.
+
+## Running on macOS
+
+Build the simulator in the usual host build environment (with `python`, `lark`,
+and `pyyaml` available, and without `ESP_IDF_VERSION`):
+
+```sh
+cmake -S . -B buildsim
+cmake --build buildsim --target Tactility -j 8
+```
+
+Create a fresh application bundle (the script deliberately refuses to overwrite
+an existing bundle):
+
+```sh
+sh Buildscripts/release-simulator-macos-app.sh buildsim release/Tactility-audio.app
+open release/Tactility-audio.app
+```
+
+Alternatively, run `../buildsim/Tactility/Tactility` with `Data/` as the working
+directory. Both the executable and the application bundle include a microphone
+usage description. macOS asks for microphone access on the first actual recording
+request, not at simulator startup. If denied, enable access in **System Settings
+→ Privacy & Security → Microphone** and relaunch. For command-line launches, macOS
+may attribute the permission to the terminal or launching application.
+
+A Mac mini needs an external input device, such as a USB mic or headset. Without
+an input device, speaker output still works and input is reported unavailable.
+Connect the input before launch for predictable discovery/UI behavior.
+
+## Selecting devices
+
+By default, each stream opens the system's default device. Startup logs list SDL's
+device names. Optional environment variables select an exact name:
+
+```sh
+SIM_AUDIO_OUTPUT="Mac mini Speakers" SIM_AUDIO_INPUT="USB Microphone" \
+ release/Tactility-audio.app/Contents/MacOS/Tactility
+```
+
+- `SIM_AUDIO_OUTPUT`: exact output name, or `none` to disable output.
+- `SIM_AUDIO_INPUT`: exact input name, or `none` to disable input.
+- Unset or empty values use the system default.
+
+Selection is applied when opening a stream. An already-open stream does not
+automatically switch when the system default changes; close/reopen it or relaunch.
+A missing selected device causes an open failure rather than silently selecting a
+different device. Permission/device errors are logged under `SdlAudio`.
+
+## Formats and behavior
+
+- Signed 16-bit PCM; the shared stream module converts app sample rates and channel
+ counts to 48 kHz mono capture / stereo playback. SDL handles host format conversion.
+- One input and one output stream can be open simultaneously.
+- Read/write from a worker task, never the LVGL thread.
+- Audio callbacks use bounded lock-free buffers, with silence on playback underrun
+ and dropped incoming frames on capture overflow.
+- Reads/writes report partial byte counts on timeout, including converted streams.
+- Closing output drains its bounded buffer for up to 250 ms. Closing input does not
+ affect output, and vice versa.
+- No audio mixing or acoustic echo cancellation is added by this backend. Use
+ headphones when testing simultaneous microphone capture and playback.
+
+## Verification
+
+```sh
+cmake --build buildsim --target SimulatorAudioTests -j 8
+ctest --test-dir buildsim/Tests -R SimulatorAudio --output-on-failure
+```
+
+These tests use SDL's dummy and disk backends without requiring microphone access.
+They cover full duplex, 16/44.1/48 kHz app formats, bounded buffering, partial
+timeouts, enable/disable, unavailable inputs, and sample-level input/output gain
+and mute. They also cover disabling audio while a slow codec open is pending,
+as can happen during a microphone permission prompt. Disk fixtures and output
+files are temporary and removed after the run.
+
+To explicitly play a quiet half-second 440 Hz tone through real Mac audio:
+
+```sh
+SDL_AUDIODRIVER=coreaudio SIM_AUDIO_OUTPUT= \
+ buildsim/Tests/simulator/SimulatorAudioTests \
+ --no-skip --test-case="hardware output smoke test"
+```
+
+Physical microphone capture, the first-use permission prompt, and live device
+unplugging require a separate manual check with an input device attached.
diff --git a/Drivers/audio-stream-module/source/audio_stream.cpp b/Drivers/audio-stream-module/source/audio_stream.cpp
index 2512d686..9cd278b9 100644
--- a/Drivers/audio-stream-module/source/audio_stream.cpp
+++ b/Drivers/audio-stream-module/source/audio_stream.cpp
@@ -113,6 +113,10 @@ struct AudioStreamHandleImpl : AudioStreamHandleData {
SemaphoreHandle_t drain_semaphore = nullptr;
};
+// A slow codec open (notably the macOS microphone permission prompt) reserves a
+// direction before a real handle exists. It must never be passed to close_stream().
+AudioStreamHandleImpl* const OPENING_STREAM = reinterpret_cast(1);
+
struct AudioStreamData {
Device* input_codec = nullptr;
Device* output_codec = nullptr;
@@ -130,6 +134,8 @@ struct AudioStreamData {
bool output_muted = false;
AudioStreamHandleImpl* open_input = nullptr;
AudioStreamHandleImpl* open_output = nullptr;
+ bool input_open_cancelled = false;
+ bool output_open_cancelled = false;
// Guards open_input/open_output and the closing/busy_count fields of any handle reachable
// through them, so close (possibly forced by set_enabled) can't race with read/write.
SemaphoreHandle_t mutex = nullptr;
@@ -261,7 +267,7 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
return ERROR_INVALID_ARGUMENT;
}
- if (config->channels == 0) {
+ if (config->channels == 0 || config->sample_rate == 0) {
return ERROR_INVALID_ARGUMENT;
}
@@ -291,7 +297,9 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
// Reserve the slot with a placeholder so concurrent opens can't race past the check
// above while we do the (potentially slow) codec open below outside the lock.
- auto* reservation = reinterpret_cast(1);
+ auto* reservation = OPENING_STREAM;
+ bool* open_cancelled = is_input ? &data->input_open_cancelled : &data->output_open_cancelled;
+ *open_cancelled = false;
*slot = reservation;
xSemaphoreGive(data->mutex);
@@ -361,6 +369,18 @@ error_t open_stream(Device* device, const struct AudioStreamConfig* config, Audi
}
xSemaphoreTake(data->mutex, portMAX_DELAY);
+ if (*open_cancelled) {
+ // Keep the reservation until the codec is closed, so re-enabling cannot
+ // open a second stream while this cancelled open is still cleaning up.
+ xSemaphoreGive(data->mutex);
+ vSemaphoreDelete(handle->drain_semaphore);
+ delete handle;
+ audio_codec_close(codec);
+ xSemaphoreTake(data->mutex, portMAX_DELAY);
+ *slot = nullptr;
+ xSemaphoreGive(data->mutex);
+ return ERROR_NOT_ALLOWED;
+ }
*slot = handle;
xSemaphoreGive(data->mutex);
@@ -377,6 +397,7 @@ error_t open_output(Device* device, const struct AudioStreamConfig* config, Audi
}
error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_size, size_t* bytes_read, TickType_t timeout) {
+ if (bytes_read != nullptr) *bytes_read = 0;
auto* handle = static_cast(handle_base);
if (handle->direction != AUDIO_CODEC_DIR_INPUT || handle->bytes_per_frame == 0) {
return ERROR_INVALID_STATE;
@@ -419,7 +440,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
size_t codec_bytes_read = 0;
result = audio_codec_read(data->input_codec, handle->codec_buffer.data(), codec_bytes_needed, &codec_bytes_read, timeout);
- if (result == ERROR_NONE) {
+ if (codec_bytes_read > 0) {
size_t codec_frames_read = codec_bytes_read / handle->codec_bytes_per_frame;
const int16_t* rate_input = reinterpret_cast(handle->codec_buffer.data());
uint8_t rate_input_channels = handle->codec_channels;
@@ -448,7 +469,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
}
}
- if (result == ERROR_NONE && handle->input_gain != 1.0f && bytes_read != nullptr && *bytes_read > 0) {
+ if (handle->input_gain != 1.0f && bytes_read != nullptr && *bytes_read > 0) {
auto* samples = reinterpret_cast(out_data);
size_t sample_count = *bytes_read / sizeof(int16_t);
for (size_t i = 0; i < sample_count; i++) {
@@ -462,6 +483,7 @@ error_t read_stream(AudioStreamHandle handle_base, void* out_data, size_t data_s
}
error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t data_size, size_t* bytes_written, TickType_t timeout) {
+ if (bytes_written != nullptr) *bytes_written = 0;
auto* handle = static_cast(handle_base);
if (handle->direction != AUDIO_CODEC_DIR_OUTPUT || handle->bytes_per_frame == 0) {
return ERROR_INVALID_STATE;
@@ -532,9 +554,13 @@ error_t write_stream(AudioStreamHandle handle_base, const void* in_data, size_t
size_t codec_bytes_to_write = codec_frames * handle->codec_bytes_per_frame;
size_t codec_bytes_written = 0;
result = audio_codec_write(data->output_codec, handle->codec_buffer.data(), codec_bytes_to_write, &codec_bytes_written, timeout);
- if (result == ERROR_NONE && bytes_written != nullptr) {
- // The caller provided `data_size` worth of input; we consumed all of it (resampled/converted).
- *bytes_written = data_size;
+ if (bytes_written != nullptr && codec_frames > 0) {
+ // A bounded host/hardware queue can accept only part of a converted write,
+ // including when returning ERROR_TIMEOUT. Report progress in app-side whole
+ // frames rather than leaving the count untouched or claiming the entire write.
+ size_t written_frames = codec_bytes_written / handle->codec_bytes_per_frame;
+ if (written_frames > codec_frames) written_frames = codec_frames;
+ *bytes_written = (in_frames * written_frames / codec_frames) * handle->bytes_per_frame;
}
}
@@ -686,13 +712,17 @@ error_t set_enabled(Device* device, AudioCodecDirection direction, bool enabled)
data->output_enabled = enabled;
}
- // Capture and clear the slot under the lock so we hand close_stream() a pointer that
- // can't simultaneously be torn down by a racing close from the owning app (close_stream
- // re-checks `*slot == handle` and no-ops if it's already been cleared/replaced).
+ // A pending open has no handle to close yet. Let its owning task clean up when
+ // the codec returns, even if the user re-enables the direction in the meantime.
AudioStreamHandleImpl* to_close = nullptr;
if (!enabled) {
AudioStreamHandleImpl** slot = is_input ? &data->open_input : &data->open_output;
- to_close = *slot;
+ if (*slot == OPENING_STREAM) {
+ if (is_input) data->input_open_cancelled = true;
+ else data->output_open_cancelled = true;
+ } else {
+ to_close = *slot;
+ }
}
xSemaphoreGive(data->mutex);
diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt
index b5b36a8d..27131aad 100644
--- a/Tests/CMakeLists.txt
+++ b/Tests/CMakeLists.txt
@@ -11,6 +11,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-posix-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module)
+add_subdirectory(${CMAKE_SOURCE_DIR}/Devices/simulator/tests ${CMAKE_CURRENT_BINARY_DIR}/simulator)
add_custom_target(build-tests)
add_dependencies(build-tests ServiceModuleTests)
@@ -21,3 +22,4 @@ add_dependencies(build-tests CryptModuleTests)
add_dependencies(build-tests AppModuleTests)
add_dependencies(build-tests AppPosixModuleTests)
add_dependencies(build-tests HttpModuleTests)
+add_dependencies(build-tests SimulatorAudioTests)