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)