diff --git a/Apps/Tone/CMakeLists.txt b/Apps/Tone/CMakeLists.txt new file mode 100644 index 0000000..d8136ed --- /dev/null +++ b/Apps/Tone/CMakeLists.txt @@ -0,0 +1,13 @@ +# tactility-cmakelists-version: 1 +cmake_minimum_required(VERSION 3.20) + +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") +endif() + +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +tactility_project_pre(tone) +project(tone) +tactility_project_post(tone) \ No newline at end of file diff --git a/Apps/Tone/README.md b/Apps/Tone/README.md new file mode 100644 index 0000000..99c0a1b --- /dev/null +++ b/Apps/Tone/README.md @@ -0,0 +1,25 @@ +# Tone + +A signal generator for Tactility devices. Plays oscillator waveforms straight to the audio codec with live control over frequency, waveform, and a configurable frequency sweep. + +## Features + +- **5 waveforms**: sine, square, sawtooth, triangle, and white noise +- **9 frequency presets**: 50 Hz - 10 kHz, or a free-entry frequency field +- **Frequency sweep**: triangle up-down sweep between a min and max frequency with an adjustable period +- **Mono and stereo output** (configurable before playback) +- **Live updates**: frequency, waveform, and sweep settings apply immediately while playing, via lock-free atomics +- **100% volume control** through the sliderbox +- **Adaptive layout** for Cardputer, T-Deck, CYD, and compact panels + +## Controls + +- **Play/Pause** (toolbar button): start or stop the tone. Channel selection locks while playing. +- **Frequency chips**: select a preset or the Sweep mode. Type into the frequency field for a custom value. +- **Sweep card**: shown only in sweep mode; edit min Hz, max Hz, and period (in seconds). +- **Mono/Stereo chips**: pick the output channel config (only while stopped). +- **Volume slider**: adjusts the codec output volume live. + +## Notes + +- Frequencies are clamped to the 20 Hz - 20 kHz audible range. diff --git a/Apps/Tone/main/CMakeLists.txt b/Apps/Tone/main/CMakeLists.txt new file mode 100644 index 0000000..4fe0468 --- /dev/null +++ b/Apps/Tone/main/CMakeLists.txt @@ -0,0 +1,18 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) +tactility_component_register(SRCS ${SOURCE_FILES}) + +# Mach-O links shared libraries with undefined-symbol errors by default, but posix apps are +# dlopen()ed into a running Tactility process (app-posix-module) and resolve Tactility's own +# symbols at load time +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + target_link_options(${PROJECT_NAME} PRIVATE -Wl,-undefined,dynamic_lookup) + # tactility.py packages a posix app by scanning for a ".so" artifact (find_elf_file()). + # On macOS CMake names a SHARED target ".dylib", so publish a ".so"-suffixed copy for it. + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy $ + ${CMAKE_BINARY_DIR}/lib${PROJECT_NAME}.so + COMMENT "Publishing lib${PROJECT_NAME}.so for the posix app loader" + ) +endif() \ No newline at end of file diff --git a/Apps/Tone/main/Source/Tone.h b/Apps/Tone/main/Source/Tone.h new file mode 100644 index 0000000..3a70536 --- /dev/null +++ b/Apps/Tone/main/Source/Tone.h @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include + +#include + +#include "TonePlayback.h" + +#include +#include + +/** Frequency presets excluding the "Sweep" option, which is appended by the UI. */ +constexpr uint32_t TONE_FREQUENCY_PRESET_COUNT = 9; // 8 fixed tones + sweep +constexpr uint32_t TONE_WAVEFORM_COUNT = 5; +constexpr uint32_t TONE_CHANNEL_COUNT = 2; + +struct Context { + AppInstanceId appInstanceId = 0; + + TonePlayback playback; + + // Serializes UI playback control against app teardown: once `closing` is set under + // this gate, onPlayPressed() backs out so it cannot start a task on a context that + // main() is about to destroy. + SemaphoreHandle_t playbackGate = nullptr; + bool closing = false; + + // UI-only state, touched exclusively on the LVGL thread. + uint8_t volumePercent = 100; + uint8_t selectedChannel = 1; // index into channelButtons; 0 = mono, 1 = stereo + bool updatingFrequencyInput = false; + + // Widget handles; nulled in toneDestroyWidgets() once the live widgets are gone. + lv_obj_t* playButton = nullptr; + lv_obj_t* playButtonLabel = nullptr; + lv_obj_t* readoutFrequencyLabel = nullptr; + lv_obj_t* readoutStatusLabel = nullptr; + lv_obj_t* frequencyInput = nullptr; + lv_obj_t* presetButtons[TONE_FREQUENCY_PRESET_COUNT] = {}; + lv_obj_t* waveformButtons[TONE_WAVEFORM_COUNT] = {}; + lv_obj_t* channelButtons[TONE_CHANNEL_COUNT] = {}; + lv_obj_t* sweepCard = nullptr; + lv_obj_t* sweepMinInput = nullptr; + lv_obj_t* sweepMaxInput = nullptr; + lv_obj_t* sweepPeriodInput = nullptr; + lv_obj_t* volumeBox = nullptr; + lv_timer_t* refreshTimer = nullptr; +}; + +void toneCreateWidgets(lv_obj_t* root, void* userData); +void toneDestroyWidgets(void* userData); \ No newline at end of file diff --git a/Apps/Tone/main/Source/TonePlayback.cpp b/Apps/Tone/main/Source/TonePlayback.cpp new file mode 100644 index 0000000..e164b74 --- /dev/null +++ b/Apps/Tone/main/Source/TonePlayback.cpp @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: GPL-3.0 +#include "TonePlayback.h" + +#include +#include +#include + +#include + +namespace { + +constexpr auto* TAG = "tone_playback"; + +constexpr uint16_t CHUNK_FRAMES = 512; +constexpr uint16_t MAX_CHANNELS = 2; +constexpr uint32_t FADE_SAMPLES = tone_synth::SAMPLE_RATE * 5 / 1000; +constexpr float INVERSE_FADE_SAMPLES = 1.0f / FADE_SAMPLES; +constexpr int16_t AMPLITUDE = 8000; // ~25% of full scale + +constexpr uint16_t PLAYBACK_TASK_STACK_BYTES = 4096; +constexpr uint16_t PLAYBACK_TASK_PRIORITY = 9; +constexpr char PLAYBACK_TASK_NAME[] = "tone-playback"; + +void playbackTask(void* argument) { + auto* playback = static_cast(argument); + + // Block until tone_playback_start() has published this task's handle. Without this, + // the task can run on a second core and clear its own handle (or exit via an early + // allocation failure) before the creator's store lands, leaving a stale handle that + // tone_playback_stop() would wait on forever. + if (xSemaphoreTake(playback->lifecycleMutex, portMAX_DELAY) != pdTRUE) { + vTaskDelete(nullptr); + return; + } + + const uint8_t channels = playback->channels; + const size_t samplesPerChunk = CHUNK_FRAMES * channels; + auto* chunk = static_cast(malloc(samplesPerChunk * sizeof(int16_t))); + if (chunk == nullptr) { + LOG_E(TAG, "Out of memory for chunk buffer"); + playback->playing.store(false); + playback->currentHz.store(0); + xSemaphoreGive(playback->lifecycleMutex); + playback->task.store(nullptr); + vTaskDelete(nullptr); + return; + } + xSemaphoreGive(playback->lifecycleMutex); + + tone_synth::SynthState synthState; + bool stopping = false; + uint32_t fadeOutRemaining = 0; + uint32_t lastFrequency = 0; + + while (true) { + if (!playback->playing.load() && !stopping) { + stopping = true; + fadeOutRemaining = FADE_SAMPLES; + } + if (stopping && fadeOutRemaining == 0) { + break; + } + + const uint32_t sweepPeriodMs = playback->sweepPeriodMs.load(); + // SAMPLE_RATE is a multiple of 1000, so this is exact without an overflowing multiply. + const uint32_t sweepPeriodSamples = sweepPeriodMs * (tone_synth::SAMPLE_RATE / 1000); + const bool sweep = playback->sweep.load(); + const uint32_t fixedHz = playback->fixedHz.load(); + const uint32_t sweepMinHz = playback->sweepMinHz.load(); + const uint32_t sweepMaxHz = playback->sweepMaxHz.load(); + const auto waveform = static_cast(playback->waveform.load()); + + for (uint16_t frame = 0; frame < CHUNK_FRAMES; frame++) { + const uint32_t frequency = tone_synth::frequency_at( + fixedHz, sweep, sweepMinHz, sweepMaxHz, sweepPeriodSamples, synthState); + const float sample = tone_synth::next_sample(waveform, frequency, synthState); + lastFrequency = frequency; + + float gain = 1.0f; + if (synthState.sampleIndex < FADE_SAMPLES) { + gain = static_cast(synthState.sampleIndex) * INVERSE_FADE_SAMPLES; + } + if (stopping) { + gain *= static_cast(fadeOutRemaining) * INVERSE_FADE_SAMPLES; + } + + const int16_t value = static_cast(sample * AMPLITUDE * gain); + for (uint8_t c = 0; c < channels; c++) { + chunk[frame * channels + c] = value; + } + + if (stopping && fadeOutRemaining > 0) { + fadeOutRemaining--; + } + } + + playback->currentHz.store(lastFrequency); + + size_t bytesWritten = 0; + const error_t result = audio_stream_write( + playback->streamHandle, chunk, samplesPerChunk * sizeof(int16_t), &bytesWritten, portMAX_DELAY); + if (result != ERROR_NONE) { + LOG_E(TAG, "Stream write failed: %s", error_to_string(result)); + playback->playing.store(false); + break; + } + } + + if (xSemaphoreTake(playback->lifecycleMutex, portMAX_DELAY) == pdTRUE) { + if (playback->streamHandle != nullptr) { + audio_stream_close(playback->streamHandle); + playback->streamHandle = nullptr; + } + playback->playing.store(false); + playback->currentHz.store(0); + xSemaphoreGive(playback->lifecycleMutex); + } + playback->task.store(nullptr); + free(chunk); + vTaskDelete(nullptr); +} + +} // namespace + +error_t tone_playback_start(TonePlayback* playback) { + if (playback->lifecycleMutex == nullptr) { + return ERROR_INVALID_STATE; + } + // Hold the mutex from the busy-state check through task creation and handle + // publication so the family of publications is atomic: the new task blocks on the + // same mutex before it can clear its own handle, and a concurrent stop() cannot + // observe a half-initialized start. + if (xSemaphoreTake(playback->lifecycleMutex, portMAX_DELAY) != pdTRUE) { + return ERROR_RESOURCE_BUSY; + } + if (playback->playing.load() || playback->task.load() != nullptr) { + xSemaphoreGive(playback->lifecycleMutex); + return ERROR_RESOURCE_BUSY; + } + + Device* streamDevice = playback->streamDevice; + if (streamDevice == nullptr) { + if (device_get_first_by_type(&AUDIO_STREAM_TYPE, &streamDevice) != ERROR_NONE || streamDevice == nullptr) { + xSemaphoreGive(playback->lifecycleMutex); + return ERROR_NOT_FOUND; + } + playback->streamDevice = streamDevice; + } + + const AudioStreamConfig config = { + .sample_rate = tone_synth::SAMPLE_RATE, + .bits_per_sample = 16, + .channels = playback->channels, + }; + + AudioStreamHandle handle = nullptr; + const error_t openResult = audio_stream_open_output(streamDevice, &config, &handle); + if (openResult != ERROR_NONE) { + LOG_E(TAG, "open_output failed: %s", error_to_string(openResult)); + xSemaphoreGive(playback->lifecycleMutex); + return openResult; + } + playback->streamHandle = handle; + + playback->playing.store(true); + playback->currentHz.store(0); + + TaskHandle_t playbackTaskHandle = nullptr; + if (xTaskCreate( + playbackTask, PLAYBACK_TASK_NAME, PLAYBACK_TASK_STACK_BYTES, playback, + PLAYBACK_TASK_PRIORITY, &playbackTaskHandle) != pdPASS) { + playback->playing.store(false); + playback->streamHandle = nullptr; + audio_stream_close(handle); + xSemaphoreGive(playback->lifecycleMutex); + return ERROR_OUT_OF_MEMORY; + } + playback->task.store(playbackTaskHandle); + xSemaphoreGive(playback->lifecycleMutex); + + return ERROR_NONE; +} + +void tone_playback_stop(TonePlayback* playback) { + if (playback->lifecycleMutex == nullptr) { + return; + } + // Clear the play flag under the mutex so a racing start() cannot republish the + // task handle while we tear it down; release before waiting on the task itself. + if (xSemaphoreTake(playback->lifecycleMutex, portMAX_DELAY) == pdTRUE) { + playback->playing.store(false); + xSemaphoreGive(playback->lifecycleMutex); + } + + while (playback->task.load() != nullptr) { + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + +bool tone_playback_is_playing(const TonePlayback* playback) { + return playback->playing.load(); +} \ No newline at end of file diff --git a/Apps/Tone/main/Source/TonePlayback.h b/Apps/Tone/main/Source/TonePlayback.h new file mode 100644 index 0000000..a635e26 --- /dev/null +++ b/Apps/Tone/main/Source/TonePlayback.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include "ToneSynth.h" + +#include +#include + +#include +#include +#include + +/** + * Playback state shared between the UI thread and the playback task + */ +struct TonePlayback { + Device* streamDevice = nullptr; + AudioStreamHandle streamHandle = nullptr; + uint8_t channels = 2; // 1 = mono, 2 = stereo; fixed at stream-open time + + std::atomic playing { false }; + std::atomic sweep { false }; + std::atomic fixedHz { 440 }; + std::atomic sweepMinHz { 200 }; + std::atomic sweepMaxHz { 1000 }; + std::atomic sweepPeriodMs { 4000 }; + std::atomic waveform { static_cast(ToneWaveform::Sine) }; + + // Most recently produced frequency; the UI polls this to show live progress. + std::atomic currentHz { 0 }; + + // Non-null while the playback task is alive; nulled by the task just before it deletes itself. + std::atomic task { nullptr }; + + // Serializes task creation against handle publication from tone_playback_start() + // and against the task's own startup and cleanup, so a stale handle is never + // published after the task has already cleared it and deleted itself. + SemaphoreHandle_t lifecycleMutex = nullptr; +}; + +/** + * Opens an output stream with the current channels setting and starts the playback + * task. Rejects the call while playback is already active. + */ +error_t tone_playback_start(TonePlayback* playback); + +/** + * Requests the playback task to fade out and shut down, then blocks until it is gone. + * Safe to call when playback is not running. + */ +void tone_playback_stop(TonePlayback* playback); + +/** @return true while the playback task is producing audio. */ +bool tone_playback_is_playing(const TonePlayback* playback); \ No newline at end of file diff --git a/Apps/Tone/main/Source/ToneSynth.cpp b/Apps/Tone/main/Source/ToneSynth.cpp new file mode 100644 index 0000000..a3c45d2 --- /dev/null +++ b/Apps/Tone/main/Source/ToneSynth.cpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0 +#include "ToneSynth.h" + +#include +#include + +namespace tone_synth { + +namespace { + +constexpr float TWO_PI = 6.28318530717958647692f; +constexpr float INVERSE_TWO_PI = 0.15915494309189535f; +constexpr float INVERSE_SAMPLE_RATE = 1.0f / SAMPLE_RATE; + +} // namespace + +uint32_t clamp_frequency(uint32_t frequency) { + return std::max(MIN_FREQUENCY_HZ, std::min(frequency, MAX_FREQUENCY_HZ)); +} + +uint32_t frequency_at(uint32_t fixedHz, bool sweep, uint32_t minHz, uint32_t maxHz, + uint32_t periodSamples, SynthState& state) { + if (!sweep || periodSamples == 0) { + return clamp_frequency(fixedHz); + } + + const uint32_t low = clamp_frequency(std::min(minHz, maxHz)); + const uint32_t high = clamp_frequency(std::max(minHz, maxHz)); + const uint32_t q = high - low; + const uint32_t riseLen = periodSamples / 2; + if (q == 0 || riseLen == 0) { + return low; + } + + if (state.sweepPeriod != periodSamples || state.sweepQ != q) { + // Period or range changed since the last sample: restart the sweep cycle. + state.sweepPeriod = periodSamples; + state.sweepQ = q; + state.sweepT = 0; + state.sweepOffset = 0; + state.sweepAcc = 0; + state.sweepRising = true; + } + + const uint32_t result = low + state.sweepOffset; + const uint32_t step = q / riseLen; + const uint32_t rem = q % riseLen; + state.sweepT++; + if (state.sweepT >= periodSamples) { + state.sweepT = 0; + state.sweepOffset = 0; + state.sweepAcc = 0; + state.sweepRising = true; + } else if (state.sweepRising) { + if (state.sweepT == riseLen) { + state.sweepRising = false; + state.sweepOffset = q; + state.sweepAcc = 0; + } else { + state.sweepAcc += static_cast(rem); + state.sweepOffset += step; + if (state.sweepAcc >= static_cast(riseLen)) { + state.sweepAcc -= static_cast(riseLen); + state.sweepOffset += 1; + } + } + } else { + state.sweepAcc -= static_cast(rem); + state.sweepOffset -= step; + if (state.sweepAcc < 0) { + state.sweepAcc += static_cast(riseLen); + state.sweepOffset -= 1; + } + } + return result; +} + +float next_sample(ToneWaveform waveform, uint32_t frequency, SynthState& state) { + const float phaseFraction = state.phase * INVERSE_TWO_PI; + float sample = 0.0f; + switch (waveform) { + case ToneWaveform::Sine: + sample = std::sin(state.phase); + break; + case ToneWaveform::Square: + sample = phaseFraction < 0.5f ? 1.0f : -1.0f; + break; + case ToneWaveform::Saw: + sample = 2.0f * phaseFraction - 1.0f; + break; + case ToneWaveform::Triangle: + if (phaseFraction < 0.25f) { + sample = 4.0f * phaseFraction; + } else if (phaseFraction < 0.75f) { + sample = 2.0f - 4.0f * phaseFraction; + } else { + sample = 4.0f * phaseFraction - 4.0f; + } + break; + case ToneWaveform::Noise: { + uint32_t x = state.noiseSeed; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + state.noiseSeed = x; + // Top 16 bits scaled to [-1, 1). + sample = static_cast(static_cast(x >> 16)) / 32768.0f - 1.0f; + break; + } + } + + state.phase += TWO_PI * static_cast(frequency) * INVERSE_SAMPLE_RATE; + if (state.phase >= TWO_PI) { + state.phase -= TWO_PI; + } + state.sampleIndex++; + return sample; +} + +} // namespace tone_synth \ No newline at end of file diff --git a/Apps/Tone/main/Source/ToneSynth.h b/Apps/Tone/main/Source/ToneSynth.h new file mode 100644 index 0000000..5b43a97 --- /dev/null +++ b/Apps/Tone/main/Source/ToneSynth.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include + +/** Waveforms the tone generator can produce, in sync with TONE_WAVEFORM_LABELS. */ +enum class ToneWaveform : uint8_t { + Sine, + Square, + Saw, + Triangle, + Noise, +}; + +namespace tone_synth { + +constexpr uint32_t SAMPLE_RATE = 48000; +constexpr uint32_t MIN_FREQUENCY_HZ = 20; +constexpr uint32_t MAX_FREQUENCY_HZ = 20000; + +/** Mutable per-run state of the sample generator. */ +struct SynthState { + float phase = 0.0f; + uint32_t noiseSeed = 0x12345678u; + uint32_t sampleIndex = 0; + uint32_t sweepPeriod = 0; + uint32_t sweepQ = 0; + uint32_t sweepT = 0; + uint32_t sweepOffset = 0; + int32_t sweepAcc = 0; + bool sweepRising = true; +}; + +/** Clamps a requested frequency into the audible range [MIN_FREQUENCY_HZ, MAX_FREQUENCY_HZ]. */ +uint32_t clamp_frequency(uint32_t frequency); + +/** + * Frequency to produce for the current sample of a playback run. + * A sweep produces a triangle (up-down) ramp between minHz and maxHz covering a + * full cycle every periodSamples samples + */ +uint32_t frequency_at(uint32_t fixedHz, bool sweep, uint32_t minHz, uint32_t maxHz, + uint32_t periodSamples, SynthState& state); + +/** + * Produces the next monophonic sample for the given waveform at the given frequency, + * advancing @a state. The returned value is in [-1, 1] regardless of waveform. + */ +float next_sample(ToneWaveform waveform, uint32_t frequency, SynthState& state); + +} \ No newline at end of file diff --git a/Apps/Tone/main/Source/ToneUi.cpp b/Apps/Tone/main/Source/ToneUi.cpp new file mode 100644 index 0000000..0c2c271 --- /dev/null +++ b/Apps/Tone/main/Source/ToneUi.cpp @@ -0,0 +1,638 @@ +// SPDX-License-Identifier: GPL-3.0 +#include "Tone.h" +#include "ToneSynth.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr int32_t REFRESH_PERIOD_MS = 200; + +constexpr uint32_t DEFAULT_FREQUENCY_HZ = 440; +constexpr uint32_t DEFAULT_SWEEP_MIN_HZ = 200; +constexpr uint32_t DEFAULT_SWEEP_MAX_HZ = 1000; +constexpr uint32_t DEFAULT_SWEEP_PERIOD_MS = 4000; + +struct FrequencyPreset { + const char* label; + uint32_t frequency; // 0 marks the "Sweep" option +}; + +constexpr FrequencyPreset FREQUENCY_PRESETS[] = { + { "50", 50 }, + { "100", 100 }, + { "220", 220 }, + { "440", 440 }, + { "880", 880 }, + { "1K", 1000 }, + { "5K", 5000 }, + { "10K", 10000 }, + { "Sweep", 0 }, +}; +static_assert( + sizeof(FREQUENCY_PRESETS) / sizeof(FREQUENCY_PRESETS[0]) == TONE_FREQUENCY_PRESET_COUNT, + "preset list must match TONE_FREQUENCY_PRESET_COUNT"); +constexpr const char* TONE_WAVEFORM_LABELS[TONE_WAVEFORM_COUNT] = { + "Sine", "Square", "Saw", "Triangle", "Noise", +}; +constexpr const char* TONE_CHANNEL_LABELS[TONE_CHANNEL_COUNT] = { + "Mono", "Stereo", +}; + +const char* waveformName(ToneWaveform waveform) { + const uint32_t index = static_cast(waveform); + return index < TONE_WAVEFORM_COUNT ? TONE_WAVEFORM_LABELS[index] : "?"; +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +void wireTextarea(lv_obj_t* textarea) { +#ifndef ESP_PLATFORM + auto* keyboard = lvgl_software_keyboard_get_last(); + if (keyboard != nullptr && keyboard->object != nullptr) { + lvgl_keyboard_add_textarea(keyboard, textarea); + } +#endif +} + +lv_obj_t* createNumberField(lv_obj_t* parent, uint16_t maxLength) { + auto* textarea = lv_textarea_create(parent); + lv_textarea_set_one_line(textarea, true); + lv_textarea_set_accepted_chars(textarea, "0123456789"); + lv_textarea_set_max_length(textarea, maxLength); + wireTextarea(textarea); + return textarea; +} + +uint32_t displayedFrequency(const Context* ctx) { + const uint32_t hz = ctx->playback.fixedHz.load(); + if (hz == 0) { + return DEFAULT_FREQUENCY_HZ; + } + return tone_synth::clamp_frequency(hz); +} + +void setTextareaNumber(lv_obj_t* textarea, uint32_t value) { + char buffer[16]; + snprintf(buffer, sizeof(buffer), "%" PRIu32, value); + lv_textarea_set_text(textarea, buffer); +} + +// Returns true when the field holds a complete positive integer +bool parsePositiveInteger(lv_obj_t* textarea, uint32_t* out) { + const char* text = lv_textarea_get_text(textarea); + if (text == nullptr || *text == '\0') { + return false; + } + uint32_t value = 0; + for (const char* c = text; *c != '\0'; c++) { + if (*c < '0' || *c > '9') { + return false; + } + value = value * 10 + static_cast(*c - '0'); + if (value > 100000) { + return false; + } + } + if (value == 0) { + return false; + } + *out = value; + return true; +} + +void resolveTextareaNumber(lv_obj_t* textarea, uint32_t fallback, std::atomic* target) { + uint32_t value = 0; + target->store(parsePositiveInteger(textarea, &value) ? tone_synth::clamp_frequency(value) : fallback); +} + +void refreshChipSelection(lv_obj_t** buttons, uint32_t count, int32_t selectedIndex) { + for (uint32_t i = 0; i < count; i++) { + if (buttons[i] != nullptr) { + lv_obj_set_state(buttons[i], LV_STATE_CHECKED, static_cast(i) == selectedIndex); + } + } +} + +void syncPresetChips(Context* ctx) { + if (ctx->playback.sweep.load()) { + refreshChipSelection(ctx->presetButtons, TONE_FREQUENCY_PRESET_COUNT, + static_cast(TONE_FREQUENCY_PRESET_COUNT) - 1); + return; + } + const uint32_t hz = ctx->playback.fixedHz.load(); + int32_t match = -1; + for (uint32_t i = 0; i < TONE_FREQUENCY_PRESET_COUNT - 1; i++) { + if (FREQUENCY_PRESETS[i].frequency == hz) { + match = static_cast(i); + break; + } + } + refreshChipSelection(ctx->presetButtons, TONE_FREQUENCY_PRESET_COUNT, match); +} + +void updateFrequencyInput(Context* ctx) { + ctx->updatingFrequencyInput = true; + if (ctx->playback.sweep.load()) { + lv_textarea_set_text(ctx->frequencyInput, ""); + } else { + const uint32_t hz = ctx->playback.fixedHz.load(); + if (hz == 0) { + lv_textarea_set_text(ctx->frequencyInput, ""); + } else { + setTextareaNumber(ctx->frequencyInput, hz); + } + } + ctx->updatingFrequencyInput = false; +} + +void updateSweepCard(Context* ctx) { + if (ctx->sweepCard != nullptr) { + if (ctx->playback.sweep.load()) { + lv_obj_remove_flag(ctx->sweepCard, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(ctx->sweepCard, LV_OBJ_FLAG_HIDDEN); + } + } +} + +void updateChannelAvailability(Context* ctx) { + const bool playing = ctx->playback.playing.load(); + for (auto* button : ctx->channelButtons) { + if (button != nullptr) { + lv_obj_set_state(button, LV_STATE_DISABLED, playing); + } + } +} + +void updatePlayButton(Context* ctx) { + if (ctx->playButtonLabel == nullptr) { + return; + } + lv_label_set_text(ctx->playButtonLabel, ctx->playback.playing.load() ? LV_SYMBOL_PAUSE : LV_SYMBOL_PLAY); +} + +void updateReadout(Context* ctx) { + if (ctx->readoutFrequencyLabel == nullptr || ctx->readoutStatusLabel == nullptr) { + return; + } + + const bool playing = ctx->playback.playing.load(); + const bool sweep = ctx->playback.sweep.load(); + const auto waveform = static_cast(ctx->playback.waveform.load()); + const uint32_t currentHz = ctx->playback.currentHz.load(); + + if (playing && currentHz > 0) { + lv_label_set_text_fmt(ctx->readoutFrequencyLabel, "%" PRIu32 " Hz", currentHz); + } else if (sweep) { + lv_label_set_text_fmt(ctx->readoutFrequencyLabel, "Sweep %" PRIu32 "-%" PRIu32 " Hz", + ctx->playback.sweepMinHz.load(), ctx->playback.sweepMaxHz.load()); + } else { + lv_label_set_text_fmt(ctx->readoutFrequencyLabel, "%" PRIu32 " Hz", displayedFrequency(ctx)); + } + + const char* channel = ctx->selectedChannel == 0 ? "mono" : "stereo"; + if (playing) { + lv_label_set_text_fmt(ctx->readoutStatusLabel, "Playing %s - %s", waveformName(waveform), channel); + } else { + lv_label_set_text_fmt(ctx->readoutStatusLabel, "Ready - %s - %s", waveformName(waveform), channel); + } +} + +void onPlayPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + + // Serialize against main()'s close path: once `closing` is set, a late play press + // must not start a task on a context that is about to be torn down. + if (ctx->playbackGate == nullptr || xSemaphoreTake(ctx->playbackGate, portMAX_DELAY) != pdTRUE) { + return; + } + if (ctx->closing) { + xSemaphoreGive(ctx->playbackGate); + return; + } + + if (ctx->playback.playing.load()) { + tone_playback_stop(&ctx->playback); + updatePlayButton(ctx); + updateChannelAvailability(ctx); + updateReadout(ctx); + xSemaphoreGive(ctx->playbackGate); + return; + } + + const error_t result = tone_playback_start(&ctx->playback); + if (result != ERROR_NONE) { + if (ctx->readoutStatusLabel != nullptr) { + lv_label_set_text_fmt(ctx->readoutStatusLabel, "Playback failed (%s)", error_to_string(result)); + } + xSemaphoreGive(ctx->playbackGate); + return; + } + updatePlayButton(ctx); + updateChannelAvailability(ctx); + updateReadout(ctx); + xSemaphoreGive(ctx->playbackGate); +} + +void onPresetPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* chip = static_cast(lv_event_get_current_target_obj(event)); + + for (uint32_t i = 0; i < TONE_FREQUENCY_PRESET_COUNT; i++) { + if (ctx->presetButtons[i] != chip) { + continue; + } + if (FREQUENCY_PRESETS[i].frequency == 0) { + ctx->playback.sweep.store(true); + } else { + ctx->playback.sweep.store(false); + ctx->playback.fixedHz.store(FREQUENCY_PRESETS[i].frequency); + } + break; + } + + syncPresetChips(ctx); + updateFrequencyInput(ctx); + updateSweepCard(ctx); + updateReadout(ctx); +} + +void onFrequencyInputChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (ctx->updatingFrequencyInput) { + return; + } + + uint32_t frequency = 0; + if (parsePositiveInteger(ctx->frequencyInput, &frequency)) { + ctx->playback.sweep.store(false); + ctx->playback.fixedHz.store(tone_synth::clamp_frequency(frequency)); + } + + syncPresetChips(ctx); + updateSweepCard(ctx); + updateReadout(ctx); +} + +void onWaveformPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* chip = static_cast(lv_event_get_current_target_obj(event)); + + for (uint32_t i = 0; i < TONE_WAVEFORM_COUNT; i++) { + if (ctx->waveformButtons[i] == chip) { + ctx->playback.waveform.store(i); + break; + } + } + refreshChipSelection(ctx->waveformButtons, TONE_WAVEFORM_COUNT, + static_cast(ctx->playback.waveform.load())); + updateReadout(ctx); +} + +void onChannelPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (ctx->playback.playing.load()) { + return; // channels are fixed at stream-open time + } + auto* chip = static_cast(lv_event_get_current_target_obj(event)); + + for (uint32_t i = 0; i < TONE_CHANNEL_COUNT; i++) { + if (ctx->channelButtons[i] == chip) { + ctx->selectedChannel = static_cast(i); + ctx->playback.channels = i == 0 ? 1 : 2; + break; + } + } + refreshChipSelection(ctx->channelButtons, TONE_CHANNEL_COUNT, ctx->selectedChannel); + updateReadout(ctx); +} + +// Parses a seconds value with up to three decimal places (e.g. "4.5") into milliseconds. +bool parsePeriodMs(const char* text, uint32_t* outMs) { + if (text == nullptr || *text == '\0') { + return false; + } + uint32_t intPart = 0; + uint32_t fracMs = 0; + uint32_t fracDigits = 0; + bool pointSeen = false; + for (const char* c = text; *c != '\0'; c++) { + if (*c == '.') { + if (pointSeen) { + return false; + } + pointSeen = true; + continue; + } + if (*c < '0' || *c > '9') { + return false; + } + if (pointSeen) { + if (fracDigits == 3) { + return false; + } + fracMs = fracMs * 10 + static_cast(*c - '0'); + fracDigits++; + } else { + intPart = intPart * 10 + static_cast(*c - '0'); + if (intPart > 600) { + return false; + } + } + } + while (fracDigits < 3) { + fracMs *= 10; + fracDigits++; + } + if (intPart == 0 && fracMs == 0) { + return false; + } + *outMs = std::max(250u, std::min(intPart * 1000 + fracMs, 600000u)); + return true; +} + +void onSweepMinChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + resolveTextareaNumber(ctx->sweepMinInput, DEFAULT_SWEEP_MIN_HZ, &ctx->playback.sweepMinHz); + if (ctx->playback.sweep.load()) { + updateReadout(ctx); + } +} + +void onSweepMaxChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + resolveTextareaNumber(ctx->sweepMaxInput, DEFAULT_SWEEP_MAX_HZ, &ctx->playback.sweepMaxHz); + if (ctx->playback.sweep.load()) { + updateReadout(ctx); + } +} + +void onSweepPeriodChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + uint32_t ms = 0; + if (parsePeriodMs(lv_textarea_get_text(ctx->sweepPeriodInput), &ms)) { + ctx->playback.sweepPeriodMs.store(ms); + } else { + ctx->playback.sweepPeriodMs.store(DEFAULT_SWEEP_PERIOD_MS); + } +} + +void onVolumeChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* box = static_cast(lv_event_get_target(event)); + const int32_t percent = lvgl_sliderbox_get_value(box); + ctx->volumePercent = static_cast(percent); + if (ctx->playback.streamDevice != nullptr) { + audio_stream_set_volume(ctx->playback.streamDevice, AUDIO_CODEC_DIR_OUTPUT, static_cast(percent)); + } +} + +void onRefreshTimer(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + if (ctx == nullptr) { + return; + } + updatePlayButton(ctx); + updateChannelAvailability(ctx); + updateReadout(ctx); +} + +lv_obj_t* createCard(lv_obj_t* parent) { + auto* card = lvgl_card_create(parent); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + return card; +} + +lv_obj_t* createRow(lv_obj_t* parent, lv_coord_t gap) { + auto* row = lv_obj_create(parent); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_gap(row, gap, LV_STATE_DEFAULT); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + return row; +} + +lv_obj_t* createChip(lv_obj_t* parent, const char* text, lv_coord_t minWidth) { + auto* chip = lvgl_chip_create(parent); + if (minWidth > 0) { + lv_obj_set_style_min_width(chip, minWidth, LV_STATE_DEFAULT); + } + auto* label = lv_label_create(chip); + lv_label_set_text(label, text); + lv_obj_center(label); + return chip; +} + +void toneCreateWidgetsImpl(lv_obj_t* parent, Context* ctx) { + const bool compact = lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT; + const lv_coord_t pad = compact ? 8 : 12; + const lv_coord_t gap = compact ? 4 : 8; + const lv_coord_t chipMinWidth = compact ? 48 : 56; + const lv_coord_t fontHeight = lvgl_get_text_font_height(FONT_SIZE_DEFAULT); + const lv_coord_t fieldHeight = static_cast(fontHeight * 1.8f); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + + auto* toolbar = lvgl_toolbar_create(parent, "Tone"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + ctx->playButton = lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLAY, onPlayPressed, ctx); + ctx->playButtonLabel = lv_obj_get_child(ctx->playButton, 0); + + auto* mainWrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(mainWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(mainWrapper, LV_PCT(100)); + lv_obj_set_flex_grow(mainWrapper, 1); + lv_obj_set_style_pad_all(mainWrapper, pad, LV_STATE_DEFAULT); + lv_obj_set_style_pad_gap(mainWrapper, gap, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(mainWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(mainWrapper, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_scroll_dir(mainWrapper, LV_DIR_VER); + + // Readout + auto* readoutCard = createCard(mainWrapper); + ctx->readoutFrequencyLabel = lv_label_create(readoutCard); + lv_obj_set_width(ctx->readoutFrequencyLabel, LV_PCT(100)); + lv_obj_set_style_text_align(ctx->readoutFrequencyLabel, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); + lv_obj_set_style_text_font(ctx->readoutFrequencyLabel, lvgl_get_text_font(FONT_SIZE_LARGE), LV_STATE_DEFAULT); + ctx->readoutStatusLabel = lv_label_create(readoutCard); + lv_obj_set_width(ctx->readoutStatusLabel, LV_PCT(100)); + lv_obj_set_style_text_align(ctx->readoutStatusLabel, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); + lv_obj_set_style_text_color(ctx->readoutStatusLabel, lv_palette_darken(LV_PALETTE_GREY, 3), LV_STATE_DEFAULT); + + // Frequency presets + free entry + auto* frequencyCard = createCard(mainWrapper); + auto* presetRow = createRow(frequencyCard, gap); + for (uint32_t i = 0; i < TONE_FREQUENCY_PRESET_COUNT; i++) { + auto* chip = createChip(presetRow, FREQUENCY_PRESETS[i].label, chipMinWidth); + lv_obj_add_event_cb(chip, onPresetPressed, LV_EVENT_SHORT_CLICKED, ctx); + ctx->presetButtons[i] = chip; + } + + ctx->frequencyInput = createNumberField(frequencyCard, 5); + lv_textarea_set_placeholder_text(ctx->frequencyInput, "Frequency in Hz"); + lv_textarea_set_align(ctx->frequencyInput, LV_TEXT_ALIGN_CENTER); + lv_obj_set_size(ctx->frequencyInput, LV_PCT(100), fieldHeight); + lv_obj_add_event_cb(ctx->frequencyInput, onFrequencyInputChanged, LV_EVENT_VALUE_CHANGED, ctx); + + // Waveform + auto* waveformCard = createCard(mainWrapper); + auto* waveformRow = createRow(waveformCard, gap); + for (uint32_t i = 0; i < TONE_WAVEFORM_COUNT; i++) { + auto* chip = createChip(waveformRow, TONE_WAVEFORM_LABELS[i], chipMinWidth); + lv_obj_add_event_cb(chip, onWaveformPressed, LV_EVENT_SHORT_CLICKED, ctx); + ctx->waveformButtons[i] = chip; + } + + // Sweep settings (shown only while sweep mode is active) + ctx->sweepCard = createCard(mainWrapper); + auto* sweepTitle = lv_label_create(ctx->sweepCard); + lv_label_set_text(sweepTitle, "Sweep"); + lv_obj_set_style_text_font(sweepTitle, lvgl_get_text_font(FONT_SIZE_LARGE), LV_STATE_DEFAULT); + + ctx->sweepMinInput = createNumberField(ctx->sweepCard, 5); + lv_textarea_set_placeholder_text(ctx->sweepMinInput, "Min Hz"); + lv_textarea_set_align(ctx->sweepMinInput, LV_TEXT_ALIGN_CENTER); + lv_obj_set_size(ctx->sweepMinInput, LV_PCT(100), fieldHeight); + lv_obj_add_event_cb(ctx->sweepMinInput, onSweepMinChanged, LV_EVENT_VALUE_CHANGED, ctx); + + ctx->sweepMaxInput = createNumberField(ctx->sweepCard, 5); + lv_textarea_set_placeholder_text(ctx->sweepMaxInput, "Max Hz"); + lv_textarea_set_align(ctx->sweepMaxInput, LV_TEXT_ALIGN_CENTER); + lv_obj_set_size(ctx->sweepMaxInput, LV_PCT(100), fieldHeight); + lv_obj_add_event_cb(ctx->sweepMaxInput, onSweepMaxChanged, LV_EVENT_VALUE_CHANGED, ctx); + + ctx->sweepPeriodInput = createNumberField(ctx->sweepCard, 6); + lv_textarea_set_placeholder_text(ctx->sweepPeriodInput, "Period (s)"); + lv_textarea_set_accepted_chars(ctx->sweepPeriodInput, "0123456789."); + lv_textarea_set_align(ctx->sweepPeriodInput, LV_TEXT_ALIGN_CENTER); + lv_obj_set_size(ctx->sweepPeriodInput, LV_PCT(100), fieldHeight); + lv_obj_add_event_cb(ctx->sweepPeriodInput, onSweepPeriodChanged, LV_EVENT_VALUE_CHANGED, ctx); + + // Output (channels + volume) + auto* outputCard = createCard(mainWrapper); + auto* channelRow = createRow(outputCard, gap); + for (uint32_t i = 0; i < TONE_CHANNEL_COUNT; i++) { + auto* chip = createChip(channelRow, TONE_CHANNEL_LABELS[i], chipMinWidth); + lv_obj_add_event_cb(chip, onChannelPressed, LV_EVENT_SHORT_CLICKED, ctx); + ctx->channelButtons[i] = chip; + } + + auto* volumeRow = createRow(outputCard, gap); + auto* volumeLabel = lv_label_create(volumeRow); + lv_label_set_text(volumeLabel, "Volume"); + ctx->volumeBox = lvgl_sliderbox_create(volumeRow, 0, 100, 5, ctx->volumePercent); + lv_obj_set_width(ctx->volumeBox, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->volumeBox, 1); + lvgl_sliderbox_add_value_changed_cb(ctx->volumeBox, onVolumeChanged, ctx); + + // Seed the sweep fields from the current settings. + setTextareaNumber(ctx->sweepMinInput, ctx->playback.sweepMinHz.load()); + setTextareaNumber(ctx->sweepMaxInput, ctx->playback.sweepMaxHz.load()); + { + char periodBuffer[16]; + const uint32_t periodMs = ctx->playback.sweepPeriodMs.load(); + // Integer-only: { "4.000" for 4000, "0.250" for 250 }. Avoids double math + // (%f / double division), which the Xtensa firmware cannot resolve for app code. + snprintf(periodBuffer, sizeof(periodBuffer), "%" PRIu32 ".%03" PRIu32, + periodMs / 1000, periodMs % 1000); + lv_textarea_set_text(ctx->sweepPeriodInput, periodBuffer); + } + + refreshChipSelection(ctx->channelButtons, TONE_CHANNEL_COUNT, ctx->selectedChannel); + ctx->playback.channels = ctx->selectedChannel == 0 ? 1 : 2; + + syncPresetChips(ctx); + refreshChipSelection(ctx->waveformButtons, TONE_WAVEFORM_COUNT, + static_cast(ctx->playback.waveform.load())); + updateFrequencyInput(ctx); + updateSweepCard(ctx); + updateReadout(ctx); + updatePlayButton(ctx); + updateChannelAvailability(ctx); + + ctx->refreshTimer = lv_timer_create(onRefreshTimer, REFRESH_PERIOD_MS, ctx); +} + +} // namespace + +void toneCreateWidgets(lv_obj_t* root, void* userData) { + auto* ctx = static_cast(userData); + + if (ctx->refreshTimer != nullptr) { + lv_timer_delete(ctx->refreshTimer); + ctx->refreshTimer = nullptr; + } + + // The audio-stream device may appear at any point (e.g. a USB codec) so re-query on + // every window build rather than caching a one-time lookup. + Device* streamDevice = ctx->playback.streamDevice; + if (streamDevice == nullptr) { + if (device_get_first_by_type(&AUDIO_STREAM_TYPE, &streamDevice) == ERROR_NONE && streamDevice != nullptr) { + ctx->playback.streamDevice = streamDevice; + } + } + if (ctx->playback.streamDevice != nullptr) { + float initialVolume = 100.0f; + if (audio_stream_get_volume(ctx->playback.streamDevice, AUDIO_CODEC_DIR_OUTPUT, &initialVolume) == ERROR_NONE) { + ctx->volumePercent = static_cast(std::max(0.0f, std::min(initialVolume, 100.0f))); + } + } + + toneCreateWidgetsImpl(root, ctx); +} + +void toneDestroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + + if (ctx->refreshTimer != nullptr) { + lv_timer_delete(ctx->refreshTimer); + ctx->refreshTimer = nullptr; + } + + ctx->playButton = nullptr; + ctx->playButtonLabel = nullptr; + ctx->readoutFrequencyLabel = nullptr; + ctx->readoutStatusLabel = nullptr; + ctx->frequencyInput = nullptr; + for (auto*& button : ctx->presetButtons) { + button = nullptr; + } + for (auto*& button : ctx->waveformButtons) { + button = nullptr; + } + for (auto*& button : ctx->channelButtons) { + button = nullptr; + } + ctx->sweepCard = nullptr; + ctx->sweepMinInput = nullptr; + ctx->sweepMaxInput = nullptr; + ctx->sweepPeriodInput = nullptr; + ctx->volumeBox = nullptr; +} \ No newline at end of file diff --git a/Apps/Tone/main/Source/main.cpp b/Apps/Tone/main/Source/main.cpp new file mode 100644 index 0000000..18f114e --- /dev/null +++ b/Apps/Tone/main/Source/main.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0 +#include "Tone.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include + +extern "C" { + +int main(int argc, char* argv[]) { + AppInstanceId appInstanceId = app_scheduler_current_app_id(); + + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.selectedChannel = 1; + ctx.playback.channels = 2; + + ctx.playbackGate = xSemaphoreCreateMutex(); + check(ctx.playbackGate != nullptr); + ctx.playback.lifecycleMutex = xSemaphoreCreateMutex(); + check(ctx.playback.lifecycleMutex != nullptr); + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + + AppEventSubscription sub {}; + check(app_event_subscribe(&sub, &eventGroup) == ERROR_NONE); + + WindowId window = window_manager_create_ext(appInstanceId, toneCreateWidgets, toneDestroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + task_event_group_wait_any(&eventGroup, nullptr, portMAX_DELAY); + + AppEvent event {}; + while (app_event_poll(&sub, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + } + } + + // Block a play callback that may already be dispatching on the LVGL thread from + // starting a new task on this context after playback stops and the window goes away. + xSemaphoreTake(ctx.playbackGate, portMAX_DELAY); + ctx.closing = true; + xSemaphoreGive(ctx.playbackGate); + + // Stop playback and join the audio task before the window (and its widgets) are destroyed. + tone_playback_stop(&ctx.playback); + + window_manager_remove(window); + check(app_event_unsubscribe(&sub) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + vSemaphoreDelete(ctx.playbackGate); + vSemaphoreDelete(ctx.playback.lifecycleMutex); + + return 0; +} + +} // extern "C" \ No newline at end of file diff --git a/Apps/Tone/manifest.properties b/Apps/Tone/manifest.properties new file mode 100644 index 0000000..283add6 --- /dev/null +++ b/Apps/Tone/manifest.properties @@ -0,0 +1,11 @@ +manifest.version=0.3 +target.sdk=0.8.0-dev +target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 +id=tactility.tone +version.name=0.1.0 +version.code=1 +name=Tone +description=Tone generator and frequency sweeper +app.0.id=tactility.tone +app.0.name=Tone +app.0.binary=tone \ No newline at end of file