Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Apps/Tone/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions Apps/Tone/README.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions Apps/Tone/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake")

file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
tactility_component_register(SRCS ${SOURCE_FILES})

# Mach-O links shared libraries with undefined-symbol errors by default, but posix apps are
# dlopen()ed into a running Tactility process (app-posix-module) and resolve Tactility's own
# symbols at load time
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
target_link_options(${PROJECT_NAME} PRIVATE -Wl,-undefined,dynamic_lookup)
# tactility.py packages a posix app by scanning for a "<name>.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 $<TARGET_FILE:${PROJECT_NAME}>
${CMAKE_BINARY_DIR}/lib${PROJECT_NAME}.so
COMMENT "Publishing lib${PROJECT_NAME}.so for the posix app loader"
)
endif()
52 changes: 52 additions & 0 deletions Apps/Tone/main/Source/Tone.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-3.0
#pragma once

#include <lvgl/lvgl.h>

#include <app/instance.h>

#include "TonePlayback.h"

#include <tactility/device.h>
#include <tactility/freertos/semphr.h>

/** Frequency presets excluding the "Sweep" option, which is appended by the UI. */
constexpr uint32_t TONE_FREQUENCY_PRESET_COUNT = 9; // 8 fixed tones + sweep
constexpr uint32_t TONE_WAVEFORM_COUNT = 5;
constexpr uint32_t TONE_CHANNEL_COUNT = 2;

struct Context {
AppInstanceId appInstanceId = 0;

TonePlayback playback;

// Serializes UI playback control against app teardown: once `closing` is set under
// this gate, onPlayPressed() backs out so it cannot start a task on a context that
// main() is about to destroy.
SemaphoreHandle_t playbackGate = nullptr;
bool closing = false;

// UI-only state, touched exclusively on the LVGL thread.
uint8_t volumePercent = 100;
uint8_t selectedChannel = 1; // index into channelButtons; 0 = mono, 1 = stereo
bool updatingFrequencyInput = false;

// Widget handles; nulled in toneDestroyWidgets() once the live widgets are gone.
lv_obj_t* playButton = nullptr;
lv_obj_t* playButtonLabel = nullptr;
lv_obj_t* readoutFrequencyLabel = nullptr;
lv_obj_t* readoutStatusLabel = nullptr;
lv_obj_t* frequencyInput = nullptr;
lv_obj_t* presetButtons[TONE_FREQUENCY_PRESET_COUNT] = {};
lv_obj_t* waveformButtons[TONE_WAVEFORM_COUNT] = {};
lv_obj_t* channelButtons[TONE_CHANNEL_COUNT] = {};
lv_obj_t* sweepCard = nullptr;
lv_obj_t* sweepMinInput = nullptr;
lv_obj_t* sweepMaxInput = nullptr;
lv_obj_t* sweepPeriodInput = nullptr;
lv_obj_t* volumeBox = nullptr;
lv_timer_t* refreshTimer = nullptr;
};

void toneCreateWidgets(lv_obj_t* root, void* userData);
void toneDestroyWidgets(void* userData);
202 changes: 202 additions & 0 deletions Apps/Tone/main/Source/TonePlayback.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: GPL-3.0
#include "TonePlayback.h"

#include <tactility/device.h>
#include <tactility/freertos/freertos.h>
#include <tactility/log.h>

#include <cstdlib>

namespace {

constexpr auto* TAG = "tone_playback";

constexpr uint16_t CHUNK_FRAMES = 512;
constexpr uint16_t MAX_CHANNELS = 2;
constexpr uint32_t FADE_SAMPLES = tone_synth::SAMPLE_RATE * 5 / 1000;
constexpr float INVERSE_FADE_SAMPLES = 1.0f / FADE_SAMPLES;
constexpr int16_t AMPLITUDE = 8000; // ~25% of full scale

constexpr uint16_t PLAYBACK_TASK_STACK_BYTES = 4096;
constexpr uint16_t PLAYBACK_TASK_PRIORITY = 9;
constexpr char PLAYBACK_TASK_NAME[] = "tone-playback";

void playbackTask(void* argument) {
auto* playback = static_cast<TonePlayback*>(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<int16_t*>(malloc(samplesPerChunk * sizeof(int16_t)));
if (chunk == nullptr) {
LOG_E(TAG, "Out of memory for chunk buffer");
playback->playing.store(false);
playback->currentHz.store(0);
xSemaphoreGive(playback->lifecycleMutex);
playback->task.store(nullptr);
vTaskDelete(nullptr);
return;
}
xSemaphoreGive(playback->lifecycleMutex);

tone_synth::SynthState synthState;
bool stopping = false;
uint32_t fadeOutRemaining = 0;
uint32_t lastFrequency = 0;

while (true) {
if (!playback->playing.load() && !stopping) {
stopping = true;
fadeOutRemaining = FADE_SAMPLES;
}
if (stopping && fadeOutRemaining == 0) {
break;
}

const uint32_t sweepPeriodMs = playback->sweepPeriodMs.load();
// SAMPLE_RATE is a multiple of 1000, so this is exact without an overflowing multiply.
const uint32_t sweepPeriodSamples = sweepPeriodMs * (tone_synth::SAMPLE_RATE / 1000);
const bool sweep = playback->sweep.load();
const uint32_t fixedHz = playback->fixedHz.load();
const uint32_t sweepMinHz = playback->sweepMinHz.load();
const uint32_t sweepMaxHz = playback->sweepMaxHz.load();
const auto waveform = static_cast<ToneWaveform>(playback->waveform.load());

for (uint16_t frame = 0; frame < CHUNK_FRAMES; frame++) {
const uint32_t frequency = tone_synth::frequency_at(
fixedHz, sweep, sweepMinHz, sweepMaxHz, sweepPeriodSamples, synthState);
const float sample = tone_synth::next_sample(waveform, frequency, synthState);
lastFrequency = frequency;

float gain = 1.0f;
if (synthState.sampleIndex < FADE_SAMPLES) {
gain = static_cast<float>(synthState.sampleIndex) * INVERSE_FADE_SAMPLES;
}
if (stopping) {
gain *= static_cast<float>(fadeOutRemaining) * INVERSE_FADE_SAMPLES;
}

const int16_t value = static_cast<int16_t>(sample * AMPLITUDE * gain);
for (uint8_t c = 0; c < channels; c++) {
chunk[frame * channels + c] = value;
}

if (stopping && fadeOutRemaining > 0) {
fadeOutRemaining--;
}
}

playback->currentHz.store(lastFrequency);

size_t bytesWritten = 0;
const error_t result = audio_stream_write(
playback->streamHandle, chunk, samplesPerChunk * sizeof(int16_t), &bytesWritten, portMAX_DELAY);
if (result != ERROR_NONE) {
LOG_E(TAG, "Stream write failed: %s", error_to_string(result));
playback->playing.store(false);
break;
}
}

if (xSemaphoreTake(playback->lifecycleMutex, portMAX_DELAY) == pdTRUE) {
if (playback->streamHandle != nullptr) {
audio_stream_close(playback->streamHandle);
playback->streamHandle = nullptr;
}
playback->playing.store(false);
playback->currentHz.store(0);
xSemaphoreGive(playback->lifecycleMutex);
}
playback->task.store(nullptr);
free(chunk);
vTaskDelete(nullptr);
}

} // namespace

error_t tone_playback_start(TonePlayback* playback) {
if (playback->lifecycleMutex == nullptr) {
return ERROR_INVALID_STATE;
}
// Hold the mutex from the busy-state check through task creation and handle
// publication so the family of publications is atomic: the new task blocks on the
// same mutex before it can clear its own handle, and a concurrent stop() cannot
// observe a half-initialized start.
if (xSemaphoreTake(playback->lifecycleMutex, portMAX_DELAY) != pdTRUE) {
return ERROR_RESOURCE_BUSY;
}
if (playback->playing.load() || playback->task.load() != nullptr) {
xSemaphoreGive(playback->lifecycleMutex);
return ERROR_RESOURCE_BUSY;
}

Device* streamDevice = playback->streamDevice;
if (streamDevice == nullptr) {
if (device_get_first_by_type(&AUDIO_STREAM_TYPE, &streamDevice) != ERROR_NONE || streamDevice == nullptr) {
xSemaphoreGive(playback->lifecycleMutex);
return ERROR_NOT_FOUND;
}
playback->streamDevice = streamDevice;
}

const AudioStreamConfig config = {
.sample_rate = tone_synth::SAMPLE_RATE,
.bits_per_sample = 16,
.channels = playback->channels,
};

AudioStreamHandle handle = nullptr;
const error_t openResult = audio_stream_open_output(streamDevice, &config, &handle);
if (openResult != ERROR_NONE) {
LOG_E(TAG, "open_output failed: %s", error_to_string(openResult));
xSemaphoreGive(playback->lifecycleMutex);
return openResult;
}
playback->streamHandle = handle;

playback->playing.store(true);
playback->currentHz.store(0);

TaskHandle_t playbackTaskHandle = nullptr;
if (xTaskCreate(
playbackTask, PLAYBACK_TASK_NAME, PLAYBACK_TASK_STACK_BYTES, playback,
PLAYBACK_TASK_PRIORITY, &playbackTaskHandle) != pdPASS) {
playback->playing.store(false);
playback->streamHandle = nullptr;
audio_stream_close(handle);
xSemaphoreGive(playback->lifecycleMutex);
return ERROR_OUT_OF_MEMORY;
}
playback->task.store(playbackTaskHandle);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
xSemaphoreGive(playback->lifecycleMutex);

return ERROR_NONE;
}

void tone_playback_stop(TonePlayback* playback) {
if (playback->lifecycleMutex == nullptr) {
return;
}
// Clear the play flag under the mutex so a racing start() cannot republish the
// task handle while we tear it down; release before waiting on the task itself.
if (xSemaphoreTake(playback->lifecycleMutex, portMAX_DELAY) == pdTRUE) {
playback->playing.store(false);
xSemaphoreGive(playback->lifecycleMutex);
}

while (playback->task.load() != nullptr) {
vTaskDelay(pdMS_TO_TICKS(10));
}
}

bool tone_playback_is_playing(const TonePlayback* playback) {
return playback->playing.load();
}
54 changes: 54 additions & 0 deletions Apps/Tone/main/Source/TonePlayback.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0
#pragma once

#include "ToneSynth.h"

#include <atomic>
#include <cstdint>

#include <tactility/drivers/audio_stream.h>
#include <tactility/freertos/semphr.h>
#include <tactility/freertos/task.h>

/**
* 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<bool> playing { false };
std::atomic<bool> sweep { false };
std::atomic<uint32_t> fixedHz { 440 };
std::atomic<uint32_t> sweepMinHz { 200 };
std::atomic<uint32_t> sweepMaxHz { 1000 };
std::atomic<uint32_t> sweepPeriodMs { 4000 };
std::atomic<uint32_t> waveform { static_cast<uint32_t>(ToneWaveform::Sine) };

// Most recently produced frequency; the UI polls this to show live progress.
std::atomic<uint32_t> currentHz { 0 };

// Non-null while the playback task is alive; nulled by the task just before it deletes itself.
std::atomic<TaskHandle_t> task { nullptr };

// Serializes task creation against handle publication from tone_playback_start()
// and against the task's own startup and cleanup, so a stale handle is never
// published after the task has already cleared it and deleted itself.
SemaphoreHandle_t lifecycleMutex = nullptr;
};

/**
* Opens an output stream with the current channels setting and starts the playback
* task. Rejects the call while playback is already active.
*/
error_t tone_playback_start(TonePlayback* playback);

/**
* Requests the playback task to fade out and shut down, then blocks until it is gone.
* Safe to call when playback is not running.
*/
void tone_playback_stop(TonePlayback* playback);

/** @return true while the playback task is producing audio. */
bool tone_playback_is_playing(const TonePlayback* playback);
Loading
Loading