From 84e6473c60f1846ec29d650bd48bb236cabdbb37 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Fri, 25 Sep 2026 18:44:27 -0600 Subject: [PATCH 1/7] Tone app --- Apps/Tone/CMakeLists.txt | 13 + Apps/Tone/main/CMakeLists.txt | 4 + Apps/Tone/main/Source/App.cpp | 432 ++++++++++++++++++++++++++++++++++ Apps/Tone/manifest.properties | 11 + 4 files changed, 460 insertions(+) create mode 100644 Apps/Tone/CMakeLists.txt create mode 100644 Apps/Tone/main/CMakeLists.txt create mode 100644 Apps/Tone/main/Source/App.cpp create mode 100644 Apps/Tone/manifest.properties 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/main/CMakeLists.txt b/Apps/Tone/main/CMakeLists.txt new file mode 100644 index 0000000..2c68aad --- /dev/null +++ b/Apps/Tone/main/CMakeLists.txt @@ -0,0 +1,4 @@ +include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) +tactility_component_register(SRCS ${SOURCE_FILES}) \ No newline at end of file diff --git a/Apps/Tone/main/Source/App.cpp b/Apps/Tone/main/Source/App.cpp new file mode 100644 index 0000000..7bb8522 --- /dev/null +++ b/Apps/Tone/main/Source/App.cpp @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: GPL-3.0 + +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include + +#include +#include +#include +#include + +namespace { + +constexpr auto* TAG = "Tone"; + +constexpr uint32_t SAMPLE_RATE = 48000; +constexpr uint8_t BITS_PER_SAMPLE = 16; +constexpr uint8_t CHANNELS = 2; + +constexpr size_t CHUNK_SAMPLES = 512; +constexpr int32_t AMPLITUDE = 8000; // ~25% of full scale + +constexpr uint32_t SWEEP_FREQUENCY = 0; +constexpr uint32_t SWEEP_START_HZ = 200; +constexpr uint32_t SWEEP_END_HZ = 1000; +constexpr uint32_t SWEEP_PERIOD_SAMPLES = 2u * SAMPLE_RATE; + +constexpr double PI = 3.14159265358979323846; + +constexpr uint32_t PLAYBACK_TASK_STACK_BYTES = 4096; +constexpr uint32_t PLAYBACK_TASK_PRIORITY = 9; + +constexpr lv_coord_t MAX_CONTENT_WIDTH = 560; + +struct FrequencyPreset { + const char* label; + uint32_t frequency; +}; + +constexpr FrequencyPreset FREQUENCY_PRESETS[] = { + { "440 Hz", 440u }, + { "622 Hz", 622u }, + { "880 Hz", 880u }, + { "Sweep", SWEEP_FREQUENCY }, +}; + +constexpr uint32_t PRESET_COUNT = sizeof(FREQUENCY_PRESETS) / sizeof(FREQUENCY_PRESETS[0]); + +struct Context { + uint32_t appInstanceId; + + Device* streamDevice = nullptr; + + // Read/written from the LVGL thread and the playback task. + std::atomic playing { false }; + std::atomic frequency { SWEEP_FREQUENCY }; + + TaskHandle_t playbackTask = nullptr; + AudioStreamHandle streamHandle = nullptr; + + lv_obj_t* presetButtons[PRESET_COUNT] = {}; + uint32_t selectedPreset = PRESET_COUNT - 1; // Sweep is selected by default + + lv_obj_t* playButton = nullptr; + lv_obj_t* playButtonLabel = nullptr; + lv_obj_t* statusLabel = nullptr; + lv_obj_t* volumeValueLabel = nullptr; +}; + +void updateStatusLabel(Context* ctx) { + if (ctx->statusLabel == nullptr) { + return; + } + if (ctx->playing.load()) { + if (ctx->frequency.load() == SWEEP_FREQUENCY) { + lv_label_set_text(ctx->statusLabel, "Playing sweep 200-1000 Hz"); + } else { + lv_label_set_text_fmt(ctx->statusLabel, "Playing %" PRIu32 " Hz", static_cast(ctx->frequency.load())); + } + } else { + if (ctx->frequency.load() == SWEEP_FREQUENCY) { + lv_label_set_text(ctx->statusLabel, "Sweep 200-1000 Hz. Press play."); + } else { + lv_label_set_text_fmt(ctx->statusLabel, "%" PRIu32 " Hz. Press play.", static_cast(ctx->frequency.load())); + } + } +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +void onFrequencyPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* button = static_cast(lv_event_get_current_target_obj(event)); + uint32_t frequency = static_cast(reinterpret_cast(lv_obj_get_user_data(button))); + + for (uint32_t i = 0; i < PRESET_COUNT; i++) { + if (ctx->presetButtons[i] == button) { + ctx->selectedPreset = i; + lv_obj_add_state(button, LV_STATE_CHECKED); + } else if (ctx->presetButtons[i] != nullptr) { + lv_obj_clear_state(ctx->presetButtons[i], LV_STATE_CHECKED); + } + } + + ctx->frequency.store(frequency); + updateStatusLabel(ctx); +} + +void updatePlayButton(Context* ctx) { + if (ctx->playButton == nullptr || ctx->playButtonLabel == nullptr) { + return; + } + if (ctx->playing.load()) { + lv_label_set_text(ctx->playButtonLabel, LV_SYMBOL_PAUSE " Stop"); + lv_obj_set_style_bg_color(ctx->playButton, lv_palette_main(LV_PALETTE_RED), LV_PART_MAIN); + lv_obj_set_style_bg_color(ctx->playButton, lv_palette_darken(LV_PALETTE_RED, 2), LV_STATE_PRESSED); + } else { + lv_label_set_text(ctx->playButtonLabel, LV_SYMBOL_PLAY " Play"); + lv_obj_set_style_bg_color(ctx->playButton, lv_palette_main(LV_PALETTE_BLUE), LV_PART_MAIN); + lv_obj_set_style_bg_color(ctx->playButton, lv_palette_darken(LV_PALETTE_BLUE, 2), LV_STATE_PRESSED); + } +} + +void playbackTask(void* argument) { + auto* ctx = static_cast(argument); + + if (ctx->streamHandle == nullptr) { + ctx->playing.store(false); + ctx->playbackTask = nullptr; + vTaskDelete(nullptr); + return; + } + + int16_t chunk[CHUNK_SAMPLES * CHANNELS]; + uint32_t sweepSample = 0; + double phase = 0.0; + + while (ctx->playing.load()) { + uint32_t frequency = ctx->frequency.load(); + for (size_t i = 0; i < CHUNK_SAMPLES; i++) { + double currentFrequency; + if (frequency == SWEEP_FREQUENCY) { + double progress = static_cast(sweepSample % SWEEP_PERIOD_SAMPLES) / SWEEP_PERIOD_SAMPLES; + currentFrequency = SWEEP_START_HZ + (SWEEP_END_HZ - SWEEP_START_HZ) * progress; + sweepSample++; + } else { + currentFrequency = frequency; + } + + int16_t sample = static_cast(AMPLITUDE * std::sin(phase)); + for (size_t c = 0; c < CHANNELS; c++) { + chunk[i * CHANNELS + c] = sample; + } + phase += 2.0 * PI / SAMPLE_RATE * currentFrequency; + } + + size_t bytesWritten = 0; + // Block instead of timing out + error_t result = audio_stream_write(ctx->streamHandle, chunk, sizeof(chunk), &bytesWritten, portMAX_DELAY); + if (result != ERROR_NONE) { + ESP_LOGE(TAG, "Write failed: %s", error_to_string(result)); + ctx->playing.store(false); + break; + } + } + + audio_stream_close(ctx->streamHandle); + ctx->streamHandle = nullptr; + ctx->playbackTask = nullptr; + + lvgl_lock(); + updateStatusLabel(ctx); + updatePlayButton(ctx); + lvgl_unlock(); + + vTaskDelete(nullptr); +} + +void onPlayPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + + if (ctx->playing.load()) { + ctx->playing.store(false); + lv_label_set_text(ctx->statusLabel, "Stopping..."); + updatePlayButton(ctx); + return; + } + + Device* streamDevice = nullptr; + if (device_get_first_by_type(&AUDIO_STREAM_TYPE, &streamDevice) != ERROR_NONE || streamDevice == nullptr) { + lv_label_set_text(ctx->statusLabel, "No audio stream device"); + return; + } + ctx->streamDevice = streamDevice; + + const AudioStreamConfig config = { + .sample_rate = SAMPLE_RATE, + .bits_per_sample = BITS_PER_SAMPLE, + .channels = CHANNELS, + }; + + AudioStreamHandle handle = nullptr; + if (audio_stream_open_output(streamDevice, &config, &handle) != ERROR_NONE) { + lv_label_set_text(ctx->statusLabel, "Failed to open output stream"); + return; + } + + ctx->streamHandle = handle; + ctx->playing.store(true); + BaseType_t taskResult = xTaskCreate(playbackTask, "tone-playback", PLAYBACK_TASK_STACK_BYTES, ctx, PLAYBACK_TASK_PRIORITY, &ctx->playbackTask); + if (taskResult != pdPASS) { + ctx->playing.store(false); + ctx->streamHandle = nullptr; + audio_stream_close(handle); + lv_label_set_text(ctx->statusLabel, "Failed to start playback task"); + updatePlayButton(ctx); + return; + } + + updateStatusLabel(ctx); + updatePlayButton(ctx); +} + +void onVolumeSlider(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + lv_obj_t* slider = static_cast(lv_event_get_target(event)); + int32_t percent = lv_slider_get_value(slider); + if (ctx->volumeValueLabel != nullptr) { + lv_label_set_text_fmt(ctx->volumeValueLabel, "%" PRId32, percent); + } + if (ctx->streamDevice != nullptr) { + audio_stream_set_volume(ctx->streamDevice, AUDIO_CODEC_DIR_OUTPUT, static_cast(percent)); + } +} + +lv_obj_t* createFrequencyButton(lv_obj_t* parent, const FrequencyPreset& preset, Context* ctx) { + auto* button = lv_button_create(parent); + auto* buttonLabel = lv_label_create(button); + lv_label_set_text(buttonLabel, preset.label); + lv_obj_center(buttonLabel); + lv_obj_set_user_data(button, reinterpret_cast(static_cast(preset.frequency))); + lv_obj_add_event_cb(button, onFrequencyPressed, LV_EVENT_SHORT_CLICKED, ctx); + + lv_obj_set_style_bg_color(button, lv_palette_main(LV_PALETTE_BLUE), LV_STATE_CHECKED); + lv_obj_set_style_bg_color(button, lv_palette_darken(LV_PALETTE_BLUE, 2), LV_STATE_CHECKED | LV_STATE_PRESSED); + lv_obj_set_style_bg_color(button, lv_palette_lighten(LV_PALETTE_GREY, 3), LV_STATE_PRESSED); + lv_obj_set_style_text_color(button, lv_color_white(), LV_STATE_CHECKED); + lv_obj_set_style_outline_width(button, 2, LV_STATE_FOCUS_KEY); + lv_obj_set_style_outline_color(button, lv_palette_main(LV_PALETTE_BLUE), LV_STATE_FOCUS_KEY); + + return button; +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + float initialVolume = 100.0f; + Device* volumeDevice = nullptr; + if (device_get_first_by_type(&AUDIO_STREAM_TYPE, &volumeDevice) == ERROR_NONE && volumeDevice != nullptr) { + audio_stream_get_volume(volumeDevice, AUDIO_CODEC_DIR_OUTPUT, &initialVolume); + ctx->streamDevice = volumeDevice; + } + + const lv_coord_t pad = 12; + const lv_coord_t gap = 10; + const uint32_t textHeight = lvgl_get_text_font_height(FONT_SIZE_DEFAULT); + const lv_coord_t controlHeight = static_cast(textHeight * 2.0f); + const lv_coord_t playHeight = static_cast(textHeight * 2.4f); + const lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); + const lv_coord_t contentWidth = screenWidth < MAX_CONTENT_WIDTH ? screenWidth : MAX_CONTENT_WIDTH; + + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + auto* toolbar = lvgl_toolbar_create(parent, "Tone"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + // Content area fills everything below the toolbar; its flex alignment centers + // the control block as a group on both axes. + auto* content = lv_obj_create(parent); + lv_obj_set_width(content, contentWidth); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(content, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(content, pad, LV_STATE_DEFAULT); + lv_obj_set_style_pad_gap(content, gap, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_remove_flag(content, LV_OBJ_FLAG_SCROLLABLE); + + auto* presetRow = lv_obj_create(content); + lv_obj_set_width(presetRow, LV_PCT(100)); + lv_obj_set_height(presetRow, controlHeight); + lv_obj_set_flex_flow(presetRow, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_all(presetRow, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(presetRow, gap, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(presetRow, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(presetRow, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_remove_flag(presetRow, LV_OBJ_FLAG_SCROLLABLE); + + for (uint32_t i = 0; i < PRESET_COUNT; i++) { + auto* button = createFrequencyButton(presetRow, FREQUENCY_PRESETS[i], ctx); + lv_obj_set_height(button, controlHeight); + lv_obj_set_style_pad_hor(button, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_grow(button, 1); + ctx->presetButtons[i] = button; + } + lv_obj_add_state(ctx->presetButtons[ctx->selectedPreset], LV_STATE_CHECKED); + + // Natural-width play button, centered by the content flex. + ctx->playButton = lv_button_create(content); + lv_obj_set_width(ctx->playButton, LV_SIZE_CONTENT); + lv_obj_set_height(ctx->playButton, playHeight); + lv_obj_set_style_pad_hor(ctx->playButton, 24, LV_STATE_DEFAULT); + ctx->playButtonLabel = lv_label_create(ctx->playButton); + lv_label_set_text(ctx->playButtonLabel, LV_SYMBOL_PLAY " Play"); + lv_obj_center(ctx->playButtonLabel); + lv_obj_set_style_text_color(ctx->playButtonLabel, lv_color_white(), LV_STATE_DEFAULT); + lv_obj_add_event_cb(ctx->playButton, onPlayPressed, LV_EVENT_SHORT_CLICKED, ctx); + updatePlayButton(ctx); + + auto* volumeRow = lv_obj_create(content); + lv_obj_set_width(volumeRow, LV_PCT(100)); + lv_obj_set_height(volumeRow, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(volumeRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(volumeRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(volumeRow, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(volumeRow, gap, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(volumeRow, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(volumeRow, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_remove_flag(volumeRow, LV_OBJ_FLAG_SCROLLABLE); + + auto* volumeIcon = lv_label_create(volumeRow); + lv_label_set_text(volumeIcon, LV_SYMBOL_VOLUME_MAX); + + // Thin-track slider: the object height IS the track thickness; knob paddles + // inflate the knob above/below it (see lv_slider.c position_knob()). + auto* volumeSlider = lv_slider_create(volumeRow); + lv_slider_set_range(volumeSlider, 0, 100); + lv_slider_set_value(volumeSlider, static_cast(initialVolume), LV_ANIM_OFF); + lv_obj_set_flex_grow(volumeSlider, 1); + lv_obj_set_height(volumeSlider, 6); + lv_obj_set_style_pad_ver(volumeSlider, 5, LV_PART_KNOB); + lv_obj_set_style_bg_opa(volumeSlider, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_color(volumeSlider, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN); + lv_obj_set_style_bg_opa(volumeSlider, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(volumeSlider, lv_palette_main(LV_PALETTE_BLUE), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(volumeSlider, LV_OPA_COVER, LV_PART_KNOB); + lv_obj_set_style_bg_color(volumeSlider, lv_color_white(), LV_PART_KNOB); + lv_obj_set_style_border_color(volumeSlider, lv_palette_main(LV_PALETTE_BLUE), LV_PART_KNOB); + lv_obj_set_style_border_width(volumeSlider, 2, LV_PART_KNOB); + lv_obj_set_style_radius(volumeSlider, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_radius(volumeSlider, LV_RADIUS_CIRCLE, LV_PART_INDICATOR); + lv_obj_set_style_radius(volumeSlider, LV_RADIUS_CIRCLE, LV_PART_KNOB); + lv_obj_set_style_shadow_width(volumeSlider, 0, LV_PART_KNOB); + lv_obj_add_event_cb(volumeSlider, onVolumeSlider, LV_EVENT_VALUE_CHANGED, ctx); + + ctx->volumeValueLabel = lv_label_create(volumeRow); + lv_label_set_text_fmt(ctx->volumeValueLabel, "%" PRId32, static_cast(initialVolume)); + lv_obj_set_width(ctx->volumeValueLabel, 24); + lv_obj_set_style_text_align(ctx->volumeValueLabel, LV_TEXT_ALIGN_RIGHT, LV_STATE_DEFAULT); + + ctx->statusLabel = lv_label_create(content); + lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); + lv_obj_set_style_text_align(ctx->statusLabel, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); + lv_obj_set_style_text_color(ctx->statusLabel, lv_palette_darken(LV_PALETTE_GREY, 3), LV_STATE_DEFAULT); + updateStatusLabel(ctx); +} + +} // namespace + +extern "C" { + +int main(int argc, char* argv[]) { + uint32_t appInstanceId = app_scheduler_current_app_id(); + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + + AppEventSubscription sub {}; + check(app_event_subscribe(&sub, &eventGroup) == ERROR_NONE); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &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) { + // Stop playback so the stream closes before our task dies. + ctx.playing.store(false); + while (ctx.playbackTask != nullptr) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + shouldClose = true; + } + if (shouldClose) break; + } + } + + window_manager_remove(window); + check(app_event_unsubscribe(&sub) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + + 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 From 5546e2af399e5737784c73102208e3991ecb9bc8 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Fri, 25 Sep 2026 18:45:36 -0600 Subject: [PATCH 2/7] Update CI --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0d4d89f..ae35101 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: Build: strategy: matrix: - app_name: [Brainfuck, Breakout, Calculator, Diceware, Doom, EpubReader, EspNowBridge, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven] + app_name: [Brainfuck, Breakout, Calculator, Diceware, Doom, EpubReader, EspNowBridge, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, Tone, TodoList, TwoEleven] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 2e37c3a37b4f2e42515416c49ff09098d0c6db2e Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Fri, 9 Oct 2026 02:17:14 -0600 Subject: [PATCH 3/7] Remake (STILL WIP) --- Apps/Tone/README.md | 25 ++ Apps/Tone/main/CMakeLists.txt | 16 +- Apps/Tone/main/Source/App.cpp | 432 ------------------ Apps/Tone/main/Source/Tone.h | 45 ++ Apps/Tone/main/Source/TonePlayback.cpp | 163 +++++++ Apps/Tone/main/Source/TonePlayback.h | 48 ++ Apps/Tone/main/Source/ToneSynth.cpp | 86 ++++ Apps/Tone/main/Source/ToneSynth.h | 45 ++ Apps/Tone/main/Source/ToneUi.cpp | 584 +++++++++++++++++++++++++ Apps/Tone/main/Source/main.cpp | 56 +++ 10 files changed, 1067 insertions(+), 433 deletions(-) create mode 100644 Apps/Tone/README.md delete mode 100644 Apps/Tone/main/Source/App.cpp create mode 100644 Apps/Tone/main/Source/Tone.h create mode 100644 Apps/Tone/main/Source/TonePlayback.cpp create mode 100644 Apps/Tone/main/Source/TonePlayback.h create mode 100644 Apps/Tone/main/Source/ToneSynth.cpp create mode 100644 Apps/Tone/main/Source/ToneSynth.h create mode 100644 Apps/Tone/main/Source/ToneUi.cpp create mode 100644 Apps/Tone/main/Source/main.cpp 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 index 2c68aad..4fe0468 100644 --- a/Apps/Tone/main/CMakeLists.txt +++ b/Apps/Tone/main/CMakeLists.txt @@ -1,4 +1,18 @@ include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake") file(GLOB_RECURSE SOURCE_FILES Source/*.c*) -tactility_component_register(SRCS ${SOURCE_FILES}) \ No newline at end of file +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/App.cpp b/Apps/Tone/main/Source/App.cpp deleted file mode 100644 index 7bb8522..0000000 --- a/Apps/Tone/main/Source/App.cpp +++ /dev/null @@ -1,432 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -#include -#include -#include - -#include - -#include -#include -#include -#include - -#include -#include -#include - -#include -#include - -#include - -#include -#include -#include -#include - -namespace { - -constexpr auto* TAG = "Tone"; - -constexpr uint32_t SAMPLE_RATE = 48000; -constexpr uint8_t BITS_PER_SAMPLE = 16; -constexpr uint8_t CHANNELS = 2; - -constexpr size_t CHUNK_SAMPLES = 512; -constexpr int32_t AMPLITUDE = 8000; // ~25% of full scale - -constexpr uint32_t SWEEP_FREQUENCY = 0; -constexpr uint32_t SWEEP_START_HZ = 200; -constexpr uint32_t SWEEP_END_HZ = 1000; -constexpr uint32_t SWEEP_PERIOD_SAMPLES = 2u * SAMPLE_RATE; - -constexpr double PI = 3.14159265358979323846; - -constexpr uint32_t PLAYBACK_TASK_STACK_BYTES = 4096; -constexpr uint32_t PLAYBACK_TASK_PRIORITY = 9; - -constexpr lv_coord_t MAX_CONTENT_WIDTH = 560; - -struct FrequencyPreset { - const char* label; - uint32_t frequency; -}; - -constexpr FrequencyPreset FREQUENCY_PRESETS[] = { - { "440 Hz", 440u }, - { "622 Hz", 622u }, - { "880 Hz", 880u }, - { "Sweep", SWEEP_FREQUENCY }, -}; - -constexpr uint32_t PRESET_COUNT = sizeof(FREQUENCY_PRESETS) / sizeof(FREQUENCY_PRESETS[0]); - -struct Context { - uint32_t appInstanceId; - - Device* streamDevice = nullptr; - - // Read/written from the LVGL thread and the playback task. - std::atomic playing { false }; - std::atomic frequency { SWEEP_FREQUENCY }; - - TaskHandle_t playbackTask = nullptr; - AudioStreamHandle streamHandle = nullptr; - - lv_obj_t* presetButtons[PRESET_COUNT] = {}; - uint32_t selectedPreset = PRESET_COUNT - 1; // Sweep is selected by default - - lv_obj_t* playButton = nullptr; - lv_obj_t* playButtonLabel = nullptr; - lv_obj_t* statusLabel = nullptr; - lv_obj_t* volumeValueLabel = nullptr; -}; - -void updateStatusLabel(Context* ctx) { - if (ctx->statusLabel == nullptr) { - return; - } - if (ctx->playing.load()) { - if (ctx->frequency.load() == SWEEP_FREQUENCY) { - lv_label_set_text(ctx->statusLabel, "Playing sweep 200-1000 Hz"); - } else { - lv_label_set_text_fmt(ctx->statusLabel, "Playing %" PRIu32 " Hz", static_cast(ctx->frequency.load())); - } - } else { - if (ctx->frequency.load() == SWEEP_FREQUENCY) { - lv_label_set_text(ctx->statusLabel, "Sweep 200-1000 Hz. Press play."); - } else { - lv_label_set_text_fmt(ctx->statusLabel, "%" PRIu32 " Hz. Press play.", static_cast(ctx->frequency.load())); - } - } -} - -void onBackPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - app_event_emit_close(ctx->appInstanceId); -} - -void onFrequencyPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - auto* button = static_cast(lv_event_get_current_target_obj(event)); - uint32_t frequency = static_cast(reinterpret_cast(lv_obj_get_user_data(button))); - - for (uint32_t i = 0; i < PRESET_COUNT; i++) { - if (ctx->presetButtons[i] == button) { - ctx->selectedPreset = i; - lv_obj_add_state(button, LV_STATE_CHECKED); - } else if (ctx->presetButtons[i] != nullptr) { - lv_obj_clear_state(ctx->presetButtons[i], LV_STATE_CHECKED); - } - } - - ctx->frequency.store(frequency); - updateStatusLabel(ctx); -} - -void updatePlayButton(Context* ctx) { - if (ctx->playButton == nullptr || ctx->playButtonLabel == nullptr) { - return; - } - if (ctx->playing.load()) { - lv_label_set_text(ctx->playButtonLabel, LV_SYMBOL_PAUSE " Stop"); - lv_obj_set_style_bg_color(ctx->playButton, lv_palette_main(LV_PALETTE_RED), LV_PART_MAIN); - lv_obj_set_style_bg_color(ctx->playButton, lv_palette_darken(LV_PALETTE_RED, 2), LV_STATE_PRESSED); - } else { - lv_label_set_text(ctx->playButtonLabel, LV_SYMBOL_PLAY " Play"); - lv_obj_set_style_bg_color(ctx->playButton, lv_palette_main(LV_PALETTE_BLUE), LV_PART_MAIN); - lv_obj_set_style_bg_color(ctx->playButton, lv_palette_darken(LV_PALETTE_BLUE, 2), LV_STATE_PRESSED); - } -} - -void playbackTask(void* argument) { - auto* ctx = static_cast(argument); - - if (ctx->streamHandle == nullptr) { - ctx->playing.store(false); - ctx->playbackTask = nullptr; - vTaskDelete(nullptr); - return; - } - - int16_t chunk[CHUNK_SAMPLES * CHANNELS]; - uint32_t sweepSample = 0; - double phase = 0.0; - - while (ctx->playing.load()) { - uint32_t frequency = ctx->frequency.load(); - for (size_t i = 0; i < CHUNK_SAMPLES; i++) { - double currentFrequency; - if (frequency == SWEEP_FREQUENCY) { - double progress = static_cast(sweepSample % SWEEP_PERIOD_SAMPLES) / SWEEP_PERIOD_SAMPLES; - currentFrequency = SWEEP_START_HZ + (SWEEP_END_HZ - SWEEP_START_HZ) * progress; - sweepSample++; - } else { - currentFrequency = frequency; - } - - int16_t sample = static_cast(AMPLITUDE * std::sin(phase)); - for (size_t c = 0; c < CHANNELS; c++) { - chunk[i * CHANNELS + c] = sample; - } - phase += 2.0 * PI / SAMPLE_RATE * currentFrequency; - } - - size_t bytesWritten = 0; - // Block instead of timing out - error_t result = audio_stream_write(ctx->streamHandle, chunk, sizeof(chunk), &bytesWritten, portMAX_DELAY); - if (result != ERROR_NONE) { - ESP_LOGE(TAG, "Write failed: %s", error_to_string(result)); - ctx->playing.store(false); - break; - } - } - - audio_stream_close(ctx->streamHandle); - ctx->streamHandle = nullptr; - ctx->playbackTask = nullptr; - - lvgl_lock(); - updateStatusLabel(ctx); - updatePlayButton(ctx); - lvgl_unlock(); - - vTaskDelete(nullptr); -} - -void onPlayPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - - if (ctx->playing.load()) { - ctx->playing.store(false); - lv_label_set_text(ctx->statusLabel, "Stopping..."); - updatePlayButton(ctx); - return; - } - - Device* streamDevice = nullptr; - if (device_get_first_by_type(&AUDIO_STREAM_TYPE, &streamDevice) != ERROR_NONE || streamDevice == nullptr) { - lv_label_set_text(ctx->statusLabel, "No audio stream device"); - return; - } - ctx->streamDevice = streamDevice; - - const AudioStreamConfig config = { - .sample_rate = SAMPLE_RATE, - .bits_per_sample = BITS_PER_SAMPLE, - .channels = CHANNELS, - }; - - AudioStreamHandle handle = nullptr; - if (audio_stream_open_output(streamDevice, &config, &handle) != ERROR_NONE) { - lv_label_set_text(ctx->statusLabel, "Failed to open output stream"); - return; - } - - ctx->streamHandle = handle; - ctx->playing.store(true); - BaseType_t taskResult = xTaskCreate(playbackTask, "tone-playback", PLAYBACK_TASK_STACK_BYTES, ctx, PLAYBACK_TASK_PRIORITY, &ctx->playbackTask); - if (taskResult != pdPASS) { - ctx->playing.store(false); - ctx->streamHandle = nullptr; - audio_stream_close(handle); - lv_label_set_text(ctx->statusLabel, "Failed to start playback task"); - updatePlayButton(ctx); - return; - } - - updateStatusLabel(ctx); - updatePlayButton(ctx); -} - -void onVolumeSlider(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - lv_obj_t* slider = static_cast(lv_event_get_target(event)); - int32_t percent = lv_slider_get_value(slider); - if (ctx->volumeValueLabel != nullptr) { - lv_label_set_text_fmt(ctx->volumeValueLabel, "%" PRId32, percent); - } - if (ctx->streamDevice != nullptr) { - audio_stream_set_volume(ctx->streamDevice, AUDIO_CODEC_DIR_OUTPUT, static_cast(percent)); - } -} - -lv_obj_t* createFrequencyButton(lv_obj_t* parent, const FrequencyPreset& preset, Context* ctx) { - auto* button = lv_button_create(parent); - auto* buttonLabel = lv_label_create(button); - lv_label_set_text(buttonLabel, preset.label); - lv_obj_center(buttonLabel); - lv_obj_set_user_data(button, reinterpret_cast(static_cast(preset.frequency))); - lv_obj_add_event_cb(button, onFrequencyPressed, LV_EVENT_SHORT_CLICKED, ctx); - - lv_obj_set_style_bg_color(button, lv_palette_main(LV_PALETTE_BLUE), LV_STATE_CHECKED); - lv_obj_set_style_bg_color(button, lv_palette_darken(LV_PALETTE_BLUE, 2), LV_STATE_CHECKED | LV_STATE_PRESSED); - lv_obj_set_style_bg_color(button, lv_palette_lighten(LV_PALETTE_GREY, 3), LV_STATE_PRESSED); - lv_obj_set_style_text_color(button, lv_color_white(), LV_STATE_CHECKED); - lv_obj_set_style_outline_width(button, 2, LV_STATE_FOCUS_KEY); - lv_obj_set_style_outline_color(button, lv_palette_main(LV_PALETTE_BLUE), LV_STATE_FOCUS_KEY); - - return button; -} - -void createWidgets(lv_obj_t* parent, void* userData) { - auto* ctx = static_cast(userData); - - float initialVolume = 100.0f; - Device* volumeDevice = nullptr; - if (device_get_first_by_type(&AUDIO_STREAM_TYPE, &volumeDevice) == ERROR_NONE && volumeDevice != nullptr) { - audio_stream_get_volume(volumeDevice, AUDIO_CODEC_DIR_OUTPUT, &initialVolume); - ctx->streamDevice = volumeDevice; - } - - const lv_coord_t pad = 12; - const lv_coord_t gap = 10; - const uint32_t textHeight = lvgl_get_text_font_height(FONT_SIZE_DEFAULT); - const lv_coord_t controlHeight = static_cast(textHeight * 2.0f); - const lv_coord_t playHeight = static_cast(textHeight * 2.4f); - const lv_coord_t screenWidth = lv_display_get_horizontal_resolution(nullptr); - const lv_coord_t contentWidth = screenWidth < MAX_CONTENT_WIDTH ? screenWidth : MAX_CONTENT_WIDTH; - - lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - auto* toolbar = lvgl_toolbar_create(parent, "Tone"); - lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - - // Content area fills everything below the toolbar; its flex alignment centers - // the control block as a group on both axes. - auto* content = lv_obj_create(parent); - lv_obj_set_width(content, contentWidth); - lv_obj_set_flex_grow(content, 1); - lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(content, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(content, pad, LV_STATE_DEFAULT); - lv_obj_set_style_pad_gap(content, gap, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_remove_flag(content, LV_OBJ_FLAG_SCROLLABLE); - - auto* presetRow = lv_obj_create(content); - lv_obj_set_width(presetRow, LV_PCT(100)); - lv_obj_set_height(presetRow, controlHeight); - lv_obj_set_flex_flow(presetRow, LV_FLEX_FLOW_ROW); - lv_obj_set_style_pad_all(presetRow, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(presetRow, gap, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(presetRow, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(presetRow, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_remove_flag(presetRow, LV_OBJ_FLAG_SCROLLABLE); - - for (uint32_t i = 0; i < PRESET_COUNT; i++) { - auto* button = createFrequencyButton(presetRow, FREQUENCY_PRESETS[i], ctx); - lv_obj_set_height(button, controlHeight); - lv_obj_set_style_pad_hor(button, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_grow(button, 1); - ctx->presetButtons[i] = button; - } - lv_obj_add_state(ctx->presetButtons[ctx->selectedPreset], LV_STATE_CHECKED); - - // Natural-width play button, centered by the content flex. - ctx->playButton = lv_button_create(content); - lv_obj_set_width(ctx->playButton, LV_SIZE_CONTENT); - lv_obj_set_height(ctx->playButton, playHeight); - lv_obj_set_style_pad_hor(ctx->playButton, 24, LV_STATE_DEFAULT); - ctx->playButtonLabel = lv_label_create(ctx->playButton); - lv_label_set_text(ctx->playButtonLabel, LV_SYMBOL_PLAY " Play"); - lv_obj_center(ctx->playButtonLabel); - lv_obj_set_style_text_color(ctx->playButtonLabel, lv_color_white(), LV_STATE_DEFAULT); - lv_obj_add_event_cb(ctx->playButton, onPlayPressed, LV_EVENT_SHORT_CLICKED, ctx); - updatePlayButton(ctx); - - auto* volumeRow = lv_obj_create(content); - lv_obj_set_width(volumeRow, LV_PCT(100)); - lv_obj_set_height(volumeRow, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(volumeRow, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(volumeRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(volumeRow, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(volumeRow, gap, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(volumeRow, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(volumeRow, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_remove_flag(volumeRow, LV_OBJ_FLAG_SCROLLABLE); - - auto* volumeIcon = lv_label_create(volumeRow); - lv_label_set_text(volumeIcon, LV_SYMBOL_VOLUME_MAX); - - // Thin-track slider: the object height IS the track thickness; knob paddles - // inflate the knob above/below it (see lv_slider.c position_knob()). - auto* volumeSlider = lv_slider_create(volumeRow); - lv_slider_set_range(volumeSlider, 0, 100); - lv_slider_set_value(volumeSlider, static_cast(initialVolume), LV_ANIM_OFF); - lv_obj_set_flex_grow(volumeSlider, 1); - lv_obj_set_height(volumeSlider, 6); - lv_obj_set_style_pad_ver(volumeSlider, 5, LV_PART_KNOB); - lv_obj_set_style_bg_opa(volumeSlider, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_bg_color(volumeSlider, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN); - lv_obj_set_style_bg_opa(volumeSlider, LV_OPA_COVER, LV_PART_INDICATOR); - lv_obj_set_style_bg_color(volumeSlider, lv_palette_main(LV_PALETTE_BLUE), LV_PART_INDICATOR); - lv_obj_set_style_bg_opa(volumeSlider, LV_OPA_COVER, LV_PART_KNOB); - lv_obj_set_style_bg_color(volumeSlider, lv_color_white(), LV_PART_KNOB); - lv_obj_set_style_border_color(volumeSlider, lv_palette_main(LV_PALETTE_BLUE), LV_PART_KNOB); - lv_obj_set_style_border_width(volumeSlider, 2, LV_PART_KNOB); - lv_obj_set_style_radius(volumeSlider, LV_RADIUS_CIRCLE, LV_PART_MAIN); - lv_obj_set_style_radius(volumeSlider, LV_RADIUS_CIRCLE, LV_PART_INDICATOR); - lv_obj_set_style_radius(volumeSlider, LV_RADIUS_CIRCLE, LV_PART_KNOB); - lv_obj_set_style_shadow_width(volumeSlider, 0, LV_PART_KNOB); - lv_obj_add_event_cb(volumeSlider, onVolumeSlider, LV_EVENT_VALUE_CHANGED, ctx); - - ctx->volumeValueLabel = lv_label_create(volumeRow); - lv_label_set_text_fmt(ctx->volumeValueLabel, "%" PRId32, static_cast(initialVolume)); - lv_obj_set_width(ctx->volumeValueLabel, 24); - lv_obj_set_style_text_align(ctx->volumeValueLabel, LV_TEXT_ALIGN_RIGHT, LV_STATE_DEFAULT); - - ctx->statusLabel = lv_label_create(content); - lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); - lv_obj_set_style_text_align(ctx->statusLabel, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); - lv_obj_set_style_text_color(ctx->statusLabel, lv_palette_darken(LV_PALETTE_GREY, 3), LV_STATE_DEFAULT); - updateStatusLabel(ctx); -} - -} // namespace - -extern "C" { - -int main(int argc, char* argv[]) { - uint32_t appInstanceId = app_scheduler_current_app_id(); - Context ctx {}; - ctx.appInstanceId = appInstanceId; - - TaskEventGroup eventGroup {}; - task_event_group_construct(&eventGroup); - - AppEventSubscription sub {}; - check(app_event_subscribe(&sub, &eventGroup) == ERROR_NONE); - - WindowId window = window_manager_create(appInstanceId, createWidgets, &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) { - // Stop playback so the stream closes before our task dies. - ctx.playing.store(false); - while (ctx.playbackTask != nullptr) { - vTaskDelay(pdMS_TO_TICKS(10)); - } - shouldClose = true; - } - if (shouldClose) break; - } - } - - window_manager_remove(window); - check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&eventGroup); - - return 0; -} - -} // extern "C" \ 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..7658453 --- /dev/null +++ b/Apps/Tone/main/Source/Tone.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include + +#include + +#include "TonePlayback.h" + +#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; + + // 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..98d7333 --- /dev/null +++ b/Apps/Tone/main/Source/TonePlayback.cpp @@ -0,0 +1,163 @@ +// 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 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); + + const uint8_t channels = playback->channels; + const size_t samplesPerChunk = CHUNK_FRAMES * channels; + auto* chunk = new (std::nothrow) int16_t[samplesPerChunk]; + if (chunk == nullptr) { + LOG_E(TAG, "Out of memory for chunk buffer"); + playback->playing.store(false); + playback->currentHz.store(0); + playback->task.store(nullptr); + vTaskDelete(nullptr); + return; + } + + 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(); + 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( + synthState.sampleIndex, fixedHz, sweep, sweepMinHz, sweepMaxHz, sweepPeriodSamples); + 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) / FADE_SAMPLES; + } + if (stopping) { + gain *= static_cast(fadeOutRemaining) / 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 (playback->streamHandle != nullptr) { + audio_stream_close(playback->streamHandle); + playback->streamHandle = nullptr; + } + playback->playing.store(false); + playback->currentHz.store(0); + playback->task.store(nullptr); + + delete[] chunk; + vTaskDelete(nullptr); +} + +} // namespace + +error_t tone_playback_start(TonePlayback* playback) { + if (playback->playing.load() || playback->task.load() != nullptr) { + 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) { + 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)); + 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); + return ERROR_OUT_OF_MEMORY; + } + playback->task.store(playbackTaskHandle); + + return ERROR_NONE; +} + +void tone_playback_stop(TonePlayback* playback) { + playback->playing.store(false); + 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..c4f2fdd --- /dev/null +++ b/Apps/Tone/main/Source/TonePlayback.h @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include "ToneSynth.h" + +#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 }; +}; + +/** + * 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..df6e1c9 --- /dev/null +++ b/Apps/Tone/main/Source/ToneSynth.cpp @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0 +#include "ToneSynth.h" + +#include +#include + +namespace tone_synth { + +namespace { + +constexpr double TWO_PI = 6.28318530717958647692; + +} // 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 sampleIndex, uint32_t fixedHz, bool sweep, + uint32_t minHz, uint32_t maxHz, uint32_t periodSamples) { + 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)); + if (low == high) { + return low; + } + + const uint32_t halfPeriod = periodSamples / 2; + if (halfPeriod == 0) { + return low; + } + + const uint32_t t = sampleIndex % periodSamples; + // Triangle sweep: rise through the first half of the period, fall through the second. + const double normalized = t < halfPeriod + ? static_cast(t) / halfPeriod + : 2.0 - static_cast(t) / halfPeriod; + return low + static_cast(static_cast(high - low) * normalized + 0.5); +} + +float next_sample(ToneWaveform waveform, uint32_t frequency, SynthState& state) { + const double phaseFraction = state.phase / TWO_PI; + float sample = 0.0f; + switch (waveform) { + case ToneWaveform::Sine: + sample = static_cast(std::sin(state.phase)); + break; + case ToneWaveform::Square: + sample = phaseFraction < 0.5 ? 1.0f : -1.0f; + break; + case ToneWaveform::Saw: + sample = static_cast(2.0 * phaseFraction - 1.0); + break; + case ToneWaveform::Triangle: + if (phaseFraction < 0.25) { + sample = static_cast(4.0 * phaseFraction); + } else if (phaseFraction < 0.75) { + sample = static_cast(2.0 - 4.0 * phaseFraction); + } else { + sample = static_cast(4.0 * phaseFraction - 4.0); + } + 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 * frequency / 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..a855daf --- /dev/null +++ b/Apps/Tone/main/Source/ToneSynth.h @@ -0,0 +1,45 @@ +// 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 { + double phase = 0.0; + uint32_t noiseSeed = 0x12345678u; + uint32_t sampleIndex = 0; +}; + +/** Clamps a requested frequency into the audible range [MIN_FREQUENCY_HZ, MAX_FREQUENCY_HZ]. */ +uint32_t clamp_frequency(uint32_t frequency); + +/** + * Frequency to produce at the given sample position 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 sampleIndex, uint32_t fixedHz, bool sweep, + uint32_t minHz, uint32_t maxHz, uint32_t periodSamples); + +/** + * 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..0d64c36 --- /dev/null +++ b/Apps/Tone/main/Source/ToneUi.cpp @@ -0,0 +1,584 @@ +// 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; + } + char* end = nullptr; + const unsigned long value = strtoul(text, &end, 10); + if (end == text || *end != '\0' || value == 0 || value > 100000) { + return false; + } + *out = static_cast(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)); + + if (ctx->playback.playing.load()) { + tone_playback_stop(&ctx->playback); + updatePlayButton(ctx); + updateChannelAvailability(ctx); + updateReadout(ctx); + 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)); + } + return; + } + updatePlayButton(ctx); + updateChannelAvailability(ctx); + updateReadout(ctx); +} + +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); +} + +// Accepts a non-zero value up to one decimal place; returns the clamped millisecond period. +bool resolveSweepPeriodText(lv_obj_t* textarea, uint32_t fallbackMs, std::atomic* target) { + const char* text = lv_textarea_get_text(textarea); + if (text == nullptr || *text == '\0') { + target->store(fallbackMs); + return false; + } + char* end = nullptr; + const float seconds = strtof(text, &end); + if (end == text || *end != '\0' || !(seconds > 0.0f)) { + target->store(fallbackMs); + return false; + } + uint32_t ms = static_cast(seconds * 1000.0f + 0.5f); + ms = std::max(250u, std::min(ms, 600000u)); + target->store(ms); + 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)); + resolveSweepPeriodText(ctx->sweepPeriodInput, DEFAULT_SWEEP_PERIOD_MS, &ctx->playback.sweepPeriodMs); +} + +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]; + snprintf(periodBuffer, sizeof(periodBuffer), "%" PRIu32, + ctx->playback.sweepPeriodMs.load() / 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..b2cecde --- /dev/null +++ b/Apps/Tone/main/Source/main.cpp @@ -0,0 +1,56 @@ +// 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; + + 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; + } + } + } + + // 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); + + return 0; +} + +} // extern "C" \ No newline at end of file From 5f83eb73554d20c8664aed40f72ed02e3f3c6af3 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Fri, 9 Oct 2026 05:03:01 -0600 Subject: [PATCH 4/7] Fixes --- Apps/Tone/main/Source/TonePlayback.cpp | 16 +++--- Apps/Tone/main/Source/ToneSynth.cpp | 80 ++++++++++++++++++-------- Apps/Tone/main/Source/ToneSynth.h | 14 +++-- Apps/Tone/main/Source/ToneUi.cpp | 70 ++++++++++++++++------ 4 files changed, 130 insertions(+), 50 deletions(-) diff --git a/Apps/Tone/main/Source/TonePlayback.cpp b/Apps/Tone/main/Source/TonePlayback.cpp index 98d7333..f1e1574 100644 --- a/Apps/Tone/main/Source/TonePlayback.cpp +++ b/Apps/Tone/main/Source/TonePlayback.cpp @@ -5,7 +5,7 @@ #include #include -#include +#include namespace { @@ -14,6 +14,7 @@ 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; @@ -25,7 +26,7 @@ void playbackTask(void* argument) { const uint8_t channels = playback->channels; const size_t samplesPerChunk = CHUNK_FRAMES * channels; - auto* chunk = new (std::nothrow) int16_t[samplesPerChunk]; + 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); @@ -50,7 +51,8 @@ void playbackTask(void* argument) { } const uint32_t sweepPeriodMs = playback->sweepPeriodMs.load(); - const uint32_t sweepPeriodSamples = sweepPeriodMs * tone_synth::SAMPLE_RATE / 1000; + // 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(); @@ -59,16 +61,16 @@ void playbackTask(void* argument) { for (uint16_t frame = 0; frame < CHUNK_FRAMES; frame++) { const uint32_t frequency = tone_synth::frequency_at( - synthState.sampleIndex, fixedHz, sweep, sweepMinHz, sweepMaxHz, sweepPeriodSamples); + 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) / FADE_SAMPLES; + gain = static_cast(synthState.sampleIndex) * INVERSE_FADE_SAMPLES; } if (stopping) { - gain *= static_cast(fadeOutRemaining) / FADE_SAMPLES; + gain *= static_cast(fadeOutRemaining) * INVERSE_FADE_SAMPLES; } const int16_t value = static_cast(sample * AMPLITUDE * gain); @@ -101,7 +103,7 @@ void playbackTask(void* argument) { playback->currentHz.store(0); playback->task.store(nullptr); - delete[] chunk; + free(chunk); vTaskDelete(nullptr); } diff --git a/Apps/Tone/main/Source/ToneSynth.cpp b/Apps/Tone/main/Source/ToneSynth.cpp index df6e1c9..11b5210 100644 --- a/Apps/Tone/main/Source/ToneSynth.cpp +++ b/Apps/Tone/main/Source/ToneSynth.cpp @@ -8,7 +8,9 @@ namespace tone_synth { namespace { -constexpr double TWO_PI = 6.28318530717958647692; +constexpr float TWO_PI = 6.28318530717958647692f; +constexpr float INVERSE_TWO_PI = 0.15915494309189535f; +constexpr float INVERSE_SAMPLE_RATE = 1.0f / SAMPLE_RATE; } // namespace @@ -16,51 +18,83 @@ 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 sampleIndex, uint32_t fixedHz, bool sweep, - uint32_t minHz, uint32_t maxHz, uint32_t periodSamples) { +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)); - if (low == high) { + const uint32_t q = high - low; + const uint32_t riseLen = periodSamples / 2; + if (q == 0 || riseLen == 0) { return low; } - const uint32_t halfPeriod = periodSamples / 2; - if (halfPeriod == 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 t = sampleIndex % periodSamples; - // Triangle sweep: rise through the first half of the period, fall through the second. - const double normalized = t < halfPeriod - ? static_cast(t) / halfPeriod - : 2.0 - static_cast(t) / halfPeriod; - return low + static_cast(static_cast(high - low) * normalized + 0.5); + 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 double phaseFraction = state.phase / TWO_PI; + const float phaseFraction = state.phase * INVERSE_TWO_PI; float sample = 0.0f; switch (waveform) { case ToneWaveform::Sine: - sample = static_cast(std::sin(state.phase)); + sample = std::sinf(state.phase); break; case ToneWaveform::Square: - sample = phaseFraction < 0.5 ? 1.0f : -1.0f; + sample = phaseFraction < 0.5f ? 1.0f : -1.0f; break; case ToneWaveform::Saw: - sample = static_cast(2.0 * phaseFraction - 1.0); + sample = 2.0f * phaseFraction - 1.0f; break; case ToneWaveform::Triangle: - if (phaseFraction < 0.25) { - sample = static_cast(4.0 * phaseFraction); - } else if (phaseFraction < 0.75) { - sample = static_cast(2.0 - 4.0 * phaseFraction); + if (phaseFraction < 0.25f) { + sample = 4.0f * phaseFraction; + } else if (phaseFraction < 0.75f) { + sample = 2.0f - 4.0f * phaseFraction; } else { - sample = static_cast(4.0 * phaseFraction - 4.0); + sample = 4.0f * phaseFraction - 4.0f; } break; case ToneWaveform::Noise: { @@ -75,7 +109,7 @@ float next_sample(ToneWaveform waveform, uint32_t frequency, SynthState& state) } } - state.phase += TWO_PI * frequency / SAMPLE_RATE; + state.phase += TWO_PI * static_cast(frequency) * INVERSE_SAMPLE_RATE; if (state.phase >= TWO_PI) { state.phase -= TWO_PI; } diff --git a/Apps/Tone/main/Source/ToneSynth.h b/Apps/Tone/main/Source/ToneSynth.h index a855daf..5b43a97 100644 --- a/Apps/Tone/main/Source/ToneSynth.h +++ b/Apps/Tone/main/Source/ToneSynth.h @@ -20,21 +20,27 @@ constexpr uint32_t MAX_FREQUENCY_HZ = 20000; /** Mutable per-run state of the sample generator. */ struct SynthState { - double phase = 0.0; + 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 at the given sample position of a playback run + * 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 sampleIndex, uint32_t fixedHz, bool sweep, - uint32_t minHz, uint32_t maxHz, uint32_t periodSamples); +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, diff --git a/Apps/Tone/main/Source/ToneUi.cpp b/Apps/Tone/main/Source/ToneUi.cpp index 0d64c36..a258fde 100644 --- a/Apps/Tone/main/Source/ToneUi.cpp +++ b/Apps/Tone/main/Source/ToneUi.cpp @@ -105,12 +105,20 @@ bool parsePositiveInteger(lv_obj_t* textarea, uint32_t* out) { if (text == nullptr || *text == '\0') { return false; } - char* end = nullptr; - const unsigned long value = strtoul(text, &end, 10); - if (end == text || *end != '\0' || value == 0 || value > 100000) { + 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 = static_cast(value); + *out = value; return true; } @@ -308,22 +316,47 @@ void onChannelPressed(lv_event_t* event) { updateReadout(ctx); } -// Accepts a non-zero value up to one decimal place; returns the clamped millisecond period. -bool resolveSweepPeriodText(lv_obj_t* textarea, uint32_t fallbackMs, std::atomic* target) { - const char* text = lv_textarea_get_text(textarea); +// 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') { - target->store(fallbackMs); return false; } - char* end = nullptr; - const float seconds = strtof(text, &end); - if (end == text || *end != '\0' || !(seconds > 0.0f)) { - target->store(fallbackMs); + 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; } - uint32_t ms = static_cast(seconds * 1000.0f + 0.5f); - ms = std::max(250u, std::min(ms, 600000u)); - target->store(ms); + *outMs = std::max(250u, std::min(intPart * 1000 + fracMs, 600000u)); return true; } @@ -345,7 +378,12 @@ void onSweepMaxChanged(lv_event_t* event) { void onSweepPeriodChanged(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); - resolveSweepPeriodText(ctx->sweepPeriodInput, DEFAULT_SWEEP_PERIOD_MS, &ctx->playback.sweepPeriodMs); + 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) { From 652a5306b5374b2be47588c7ab37fc9fe6a9edc7 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Fri, 9 Oct 2026 06:15:46 -0600 Subject: [PATCH 5/7] Review comments --- Apps/Tone/main/Source/Tone.h | 7 ++++ Apps/Tone/main/Source/TonePlayback.cpp | 52 ++++++++++++++++++++++---- Apps/Tone/main/Source/TonePlayback.h | 6 +++ Apps/Tone/main/Source/ToneUi.cpp | 20 +++++++++- Apps/Tone/main/Source/main.cpp | 13 +++++++ 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/Apps/Tone/main/Source/Tone.h b/Apps/Tone/main/Source/Tone.h index 7658453..3a70536 100644 --- a/Apps/Tone/main/Source/Tone.h +++ b/Apps/Tone/main/Source/Tone.h @@ -8,6 +8,7 @@ #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 @@ -19,6 +20,12 @@ struct Context { 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 diff --git a/Apps/Tone/main/Source/TonePlayback.cpp b/Apps/Tone/main/Source/TonePlayback.cpp index f1e1574..a8c5d8a 100644 --- a/Apps/Tone/main/Source/TonePlayback.cpp +++ b/Apps/Tone/main/Source/TonePlayback.cpp @@ -24,6 +24,15 @@ 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))); @@ -32,9 +41,11 @@ void playbackTask(void* argument) { playback->playing.store(false); playback->currentHz.store(0); playback->task.store(nullptr); + xSemaphoreGive(playback->lifecycleMutex); vTaskDelete(nullptr); return; } + xSemaphoreGive(playback->lifecycleMutex); tone_synth::SynthState synthState; bool stopping = false; @@ -95,13 +106,16 @@ void playbackTask(void* argument) { } } - if (playback->streamHandle != nullptr) { - audio_stream_close(playback->streamHandle); - playback->streamHandle = nullptr; + 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); + playback->task.store(nullptr); + xSemaphoreGive(playback->lifecycleMutex); } - playback->playing.store(false); - playback->currentHz.store(0); - playback->task.store(nullptr); free(chunk); vTaskDelete(nullptr); @@ -110,13 +124,25 @@ void playbackTask(void* argument) { } // 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; @@ -132,6 +158,7 @@ error_t tone_playback_start(TonePlayback* playback) { 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; @@ -146,15 +173,26 @@ error_t tone_playback_start(TonePlayback* playback) { 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) { - playback->playing.store(false); + 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)); } diff --git a/Apps/Tone/main/Source/TonePlayback.h b/Apps/Tone/main/Source/TonePlayback.h index c4f2fdd..a635e26 100644 --- a/Apps/Tone/main/Source/TonePlayback.h +++ b/Apps/Tone/main/Source/TonePlayback.h @@ -7,6 +7,7 @@ #include #include +#include #include /** @@ -30,6 +31,11 @@ struct TonePlayback { // 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; }; /** diff --git a/Apps/Tone/main/Source/ToneUi.cpp b/Apps/Tone/main/Source/ToneUi.cpp index a258fde..0c2c271 100644 --- a/Apps/Tone/main/Source/ToneUi.cpp +++ b/Apps/Tone/main/Source/ToneUi.cpp @@ -223,11 +223,22 @@ void updateReadout(Context* ctx) { 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; } @@ -236,11 +247,13 @@ void onPlayPressed(lv_event_t* event) { 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) { @@ -544,8 +557,11 @@ void toneCreateWidgetsImpl(lv_obj_t* parent, Context* ctx) { setTextareaNumber(ctx->sweepMaxInput, ctx->playback.sweepMaxHz.load()); { char periodBuffer[16]; - snprintf(periodBuffer, sizeof(periodBuffer), "%" PRIu32, - ctx->playback.sweepPeriodMs.load() / 1000); + 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); } diff --git a/Apps/Tone/main/Source/main.cpp b/Apps/Tone/main/Source/main.cpp index b2cecde..18f114e 100644 --- a/Apps/Tone/main/Source/main.cpp +++ b/Apps/Tone/main/Source/main.cpp @@ -22,6 +22,11 @@ int main(int argc, char* argv[]) { 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); @@ -43,12 +48,20 @@ int main(int argc, char* argv[]) { } } + // 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; } From 9b027944979496725f67a61182d6f74dc9319c70 Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Sat, 10 Oct 2026 08:59:52 -0600 Subject: [PATCH 6/7] Fix build and review comment --- Apps/Tone/main/Source/TonePlayback.cpp | 4 ++-- Apps/Tone/main/Source/ToneSynth.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Apps/Tone/main/Source/TonePlayback.cpp b/Apps/Tone/main/Source/TonePlayback.cpp index a8c5d8a..db0334c 100644 --- a/Apps/Tone/main/Source/TonePlayback.cpp +++ b/Apps/Tone/main/Source/TonePlayback.cpp @@ -40,8 +40,8 @@ void playbackTask(void* argument) { LOG_E(TAG, "Out of memory for chunk buffer"); playback->playing.store(false); playback->currentHz.store(0); - playback->task.store(nullptr); xSemaphoreGive(playback->lifecycleMutex); + playback->task.store(nullptr); vTaskDelete(nullptr); return; } @@ -116,7 +116,7 @@ void playbackTask(void* argument) { playback->task.store(nullptr); xSemaphoreGive(playback->lifecycleMutex); } - + playback->task.store(nullptr); free(chunk); vTaskDelete(nullptr); } diff --git a/Apps/Tone/main/Source/ToneSynth.cpp b/Apps/Tone/main/Source/ToneSynth.cpp index 11b5210..a3c45d2 100644 --- a/Apps/Tone/main/Source/ToneSynth.cpp +++ b/Apps/Tone/main/Source/ToneSynth.cpp @@ -80,7 +80,7 @@ float next_sample(ToneWaveform waveform, uint32_t frequency, SynthState& state) float sample = 0.0f; switch (waveform) { case ToneWaveform::Sine: - sample = std::sinf(state.phase); + sample = std::sin(state.phase); break; case ToneWaveform::Square: sample = phaseFraction < 0.5f ? 1.0f : -1.0f; From 74f4363eb34cdf71d1f92ad06330cf386c11938b Mon Sep 17 00:00:00 2001 From: NellowTCS Date: Sat, 10 Oct 2026 09:06:17 -0600 Subject: [PATCH 7/7] Remove unnecessary store --- Apps/Tone/main/Source/TonePlayback.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/Apps/Tone/main/Source/TonePlayback.cpp b/Apps/Tone/main/Source/TonePlayback.cpp index db0334c..e164b74 100644 --- a/Apps/Tone/main/Source/TonePlayback.cpp +++ b/Apps/Tone/main/Source/TonePlayback.cpp @@ -113,7 +113,6 @@ void playbackTask(void* argument) { } playback->playing.store(false); playback->currentHz.store(0); - playback->task.store(nullptr); xSemaphoreGive(playback->lifecycleMutex); } playback->task.store(nullptr);