From c39bda5e4c249dfaec3d7de185e47a4548a7fbc2 Mon Sep 17 00:00:00 2001 From: zhouli <1932489836@qq.com> Date: Sat, 18 Apr 2026 17:33:02 +0800 Subject: [PATCH] feat(lua): add tone playback and runtime cleanup Add runtime cleanup registration for Lua modules. Release the display during script teardown. Expose audio.play_tone and update related demos and skills. Add the flappy bird demo and refine the clock dial rendering. --- .../fatfs_image/scripts/audio_demo.lua | 11 +- .../fatfs_image/scripts/clock_dial_demo.lua | 17 +- .../fatfs_image/scripts/flappybird.lua | 529 ++++++++++++++++++ .../fatfs_image/scripts/led_strip_demo.lua | 2 +- .../cap_lua/include/cap_lua.h | 3 + .../claw_capabilities/cap_lua/src/cap_lua.c | 58 ++ .../cap_lua/src/cap_lua_internal.h | 2 + .../cap_lua/src/cap_lua_runtime.c | 14 + .../lua_module_audio/lua_module_audio.c | 105 +++- .../skills/lua_module_audio.md | 2 + .../src/lua_module_display.c | 21 +- .../skills/lua_module_led_strip.md | 1 + 12 files changed, 748 insertions(+), 17 deletions(-) create mode 100644 application/basic_demo/fatfs_image/scripts/flappybird.lua diff --git a/application/basic_demo/fatfs_image/scripts/audio_demo.lua b/application/basic_demo/fatfs_image/scripts/audio_demo.lua index b8c8f4f..c0522d9 100644 --- a/application/basic_demo/fatfs_image/scripts/audio_demo.lua +++ b/application/basic_demo/fatfs_image/scripts/audio_demo.lua @@ -41,12 +41,21 @@ local function close_all() end local ok, err = xpcall(function() - -- ── 1. volume ──────────────────────────────────────────────────────────── + -- ── 1. volume and tone ─────────────────────────────────────────────────── print("[audio_demo] setting volume to 100...") audio.set_volume(output, 100) local vol = audio.get_volume(output) print("[audio_demo] current volume: " .. tostring(vol)) + print("[audio_demo] playing a short tone sequence ...") + audio.play_tone(output, 523, 180, 100) + delay.delay_ms(100) + audio.play_tone(output, 659, 180, 100) + delay.delay_ms(100) + audio.play_tone(output, 784, 240, 100) + delay.delay_ms(100) + print("[audio_demo] tone sequence done") + -- ── 2. record ──────────────────────────────────────────────────────────── print("[audio_demo] recording 3 seconds to " .. REC_PATH .. " ...") local info = audio.record_wav(input, REC_PATH, 3000) diff --git a/application/basic_demo/fatfs_image/scripts/clock_dial_demo.lua b/application/basic_demo/fatfs_image/scripts/clock_dial_demo.lua index 1fb0cd6..0cfb49e 100644 --- a/application/basic_demo/fatfs_image/scripts/clock_dial_demo.lua +++ b/application/basic_demo/fatfs_image/scripts/clock_dial_demo.lua @@ -107,14 +107,6 @@ local function draw_dial_shell() draw_ticks() end -local function draw_labels() - display.fill_round_rect(card_x + 16, card_y + 14, card_w - 32, 12, 6, 12, 28, 48) - display.fill_round_rect(card_x + 24, card_y + 18, 42, 4, 2, GLOW_R, GLOW_G, GLOW_B) - display.fill_round_rect(card_x + card_w - 66, card_y + 18, 42, 4, 2, 28, 110, 188) - display.fill_round_rect(cx - 26, digital_y + 55, 52, 4, 2, 18, 52, 88) - display.fill_round_rect(cx - 12, digital_y + 55, 24, 4, 2, GLOW_R, GLOW_G, GLOW_B) -end - local function parse_now() local h = tonumber(system.date("%H")) or 0 local m = tonumber(system.date("%M")) or 0 @@ -129,10 +121,10 @@ local function draw_time(h, m, s) local digital = string.format("%02d:%02d:%02d", h, m, s) local digital_w = display.measure_text(digital, { font_size = 28 }) - draw_hand(hour_angle, -10, radius - 56, HOUR_R, HOUR_G, HOUR_B) - draw_hand(hour_angle + 1, -10, radius - 56, HOUR_R, HOUR_G, HOUR_B) - draw_hand(minute_angle, -12, radius - 36, MIN_R, MIN_G, MIN_B) - draw_hand(second_angle, -18, radius - 28, SEC_R, SEC_G, SEC_B) + draw_hand(hour_angle, 0, radius - 56, HOUR_R, HOUR_G, HOUR_B) + draw_hand(hour_angle + 1, 0, radius - 56, HOUR_R, HOUR_G, HOUR_B) + draw_hand(minute_angle, 0, radius - 36, MIN_R, MIN_G, MIN_B) + draw_hand(second_angle, 0, radius - 28, SEC_R, SEC_G, SEC_B) display.fill_circle(cx, dial_cy, 12, 12, 26, 44) display.fill_circle(cx, dial_cy, 7, SEC_R, SEC_G, SEC_B) @@ -162,7 +154,6 @@ local run_ok, run_err = xpcall(function() display.begin_frame({ clear = true, r = BG_R, g = BG_G, b = BG_B }) draw_dial_shell() - draw_labels() draw_time(hour, minute, second) display.present() display.end_frame() diff --git a/application/basic_demo/fatfs_image/scripts/flappybird.lua b/application/basic_demo/fatfs_image/scripts/flappybird.lua new file mode 100644 index 0000000..748c575 --- /dev/null +++ b/application/basic_demo/fatfs_image/scripts/flappybird.lua @@ -0,0 +1,529 @@ +local bm = require("board_manager") +local display = require("display") +local delay = require("delay") +local audio_ok, audio = pcall(require, "audio") + +local lcd_touch_ok, lcd_touch = pcall(require, "lcd_touch") +local button_ok, button = pcall(require, "button") + +local FRAME_MS = 33 +local RUN_TIME_MS = 180000 +local GRAVITY = 1.22 +local FLAP_VELOCITY = -7.2 +local PIPE_SPEED = 3.8 +local PIPE_WIDTH = 34 +local PIPE_GAP = 74 +local PIPE_SPAWN_MS = 1500 +local GROUND_HEIGHT = 28 +local BIRD_RADIUS = 10 +local BIRD_X_RATIO = 0.28 +local CLOUD_COUNT = 4 +local PIPE_MARGIN = 42 +local PIPE_STEP = 10 +local MAX_PIPE_SHIFT = 26 +local SOUND_VOLUME = 90 + +local SKY_R, SKY_G, SKY_B = 138, 213, 255 +local SUN_R, SUN_G, SUN_B = 255, 223, 120 +local CLOUD_R, CLOUD_G, CLOUD_B = 248, 252, 255 +local PIPE_R, PIPE_G, PIPE_B = 67, 166, 74 +local PIPE_SHADE_R, PIPE_SHADE_G, PIPE_SHADE_B = 44, 118, 55 +local PIPE_CAP_R, PIPE_CAP_G, PIPE_CAP_B = 102, 204, 96 +local GROUND_R, GROUND_G, GROUND_B = 217, 182, 92 +local DIRT_R, DIRT_G, DIRT_B = 158, 112, 56 +local BIRD_R, BIRD_G, BIRD_B = 255, 229, 76 +local BIRD_WING_R, BIRD_WING_G, BIRD_WING_B = 245, 182, 45 +local BEAK_R, BEAK_G, BEAK_B = 255, 136, 58 +local EYE_R, EYE_G, EYE_B = 36, 32, 32 +local TEXT_R, TEXT_G, TEXT_B = 20, 36, 54 +local PANEL_R, PANEL_G, PANEL_B = 248, 251, 255 +local PANEL_BORDER_R, PANEL_BORDER_G, PANEL_BORDER_B = 98, 132, 168 +local DANGER_R, DANGER_G, DANGER_B = 214, 74, 74 +local input_mode = "none" +local touch_handle = nil +local button_handle = nil +local button_active_level = 0 +local button_last_level = 1 +local audio_output = nil + +local panel_handle, io_handle, width, height, panel_if = bm.get_display_lcd_params("display_lcd") +if not panel_handle then + print("[lappybird] ERROR: get_display_lcd_params(display_lcd) failed: " .. tostring(io_handle)) + return +end + +local ok, err = pcall(display.init, panel_handle, io_handle, width, height, panel_if) +if not ok then + print("[lappybird] ERROR: init failed: " .. tostring(err)) + return +end + +local screen_created = true + +local function cleanup() + if button_handle then + pcall(button.off, button_handle) + pcall(button.close, button_handle) + button_handle = nil + end + + if audio_output then + pcall(audio.close, audio_output) + audio_output = nil + end + + if screen_created then + pcall(display.end_frame) + pcall(display.deinit) + screen_created = false + end +end + +width = display.width() +height = display.height() + +if width <= 0 or height <= 0 then + print("[lappybird] ERROR: invalid display size after init") + cleanup() + return +end + +local play_top = 0 +local play_bottom = height - GROUND_HEIGHT +local play_height = play_bottom - play_top +local bird_x = math.floor(width * BIRD_X_RATIO) +local score = 0 +local best_score = 0 +local frame_count = 0 +local state = "title" +local bird_y = math.floor(play_height * 0.45) +local bird_vy = 0 +local spawn_timer_ms = 0 +local pipes = {} +local cloud_offsets = {} +local touch_consumed = false +local last_gap_top = nil + +math.randomseed(os.time() + width * 13 + height * 17) + +for i = 1, CLOUD_COUNT do + cloud_offsets[i] = { + x = ((i - 1) * width) // CLOUD_COUNT + math.random(0, 24), + y = 12 + math.random(0, math.max(10, math.floor(play_height * 0.18))), + size = 12 + math.random(0, 10), + speed = 0.3 + math.random() * 0.4, + } +end + +local function clamp(v, min_v, max_v) + if v < min_v then + return min_v + end + if v > max_v then + return max_v + end + return v +end + +local function snap_step(v, step) + return (v // step) * step +end + +local function new_pipe(x) + local min_gap_top = PIPE_MARGIN + local max_gap_top = play_bottom - PIPE_GAP - PIPE_MARGIN + local base_gap_top + + if last_gap_top == nil then + local range = math.max(0, max_gap_top - min_gap_top) + base_gap_top = min_gap_top + math.random(0, range) + else + local shift = math.random(-MAX_PIPE_SHIFT, MAX_PIPE_SHIFT) + base_gap_top = last_gap_top + shift + end + + local gap_top = clamp(base_gap_top, min_gap_top, max_gap_top) + gap_top = snap_step(gap_top, PIPE_STEP) + gap_top = clamp(gap_top, min_gap_top, max_gap_top) + last_gap_top = gap_top + + return { + x = x, + gap_top = gap_top, + gap_bottom = gap_top + PIPE_GAP, + scored = false, + } +end + +local function reset_round(next_state) + score = 0 + bird_y = math.floor(play_height * 0.45) + bird_vy = 0 + spawn_timer_ms = 0 + last_gap_top = nil + pipes = { + new_pipe(width + 48), + new_pipe(width + 48 + math.floor(width * 0.56)), + } + state = next_state or "title" +end + +local function flap() + bird_vy = FLAP_VELOCITY + if audio_output then + pcall(audio.play_tone, audio_output, 920, 35) + end +end + +local function draw_cloud(x, y, size) + display.fill_circle(x, y, size, CLOUD_R, CLOUD_G, CLOUD_B) + display.fill_circle(x + size, y - 2, math.floor(size * 0.85), CLOUD_R, CLOUD_G, CLOUD_B) + display.fill_circle(x + size * 2 - 2, y, math.floor(size * 0.72), CLOUD_R, CLOUD_G, CLOUD_B) + display.fill_rect(x, y - math.floor(size * 0.5), size * 2, size, CLOUD_R, CLOUD_G, CLOUD_B) +end + +local function draw_background() + display.clear(SKY_R, SKY_G, SKY_B) + display.fill_circle(width - 34, 28, 18, SUN_R, SUN_G, SUN_B) + + for i = 1, CLOUD_COUNT do + local cloud = cloud_offsets[i] + local drift = math.floor((frame_count * cloud.speed + cloud.x) % (width + 56)) - 28 + draw_cloud(drift, cloud.y, cloud.size) + end + + display.fill_rect(0, play_bottom, width, GROUND_HEIGHT, GROUND_R, GROUND_G, GROUND_B) + display.fill_rect(0, play_bottom + GROUND_HEIGHT - 8, width, 8, DIRT_R, DIRT_G, DIRT_B) + + local stripe_w = 14 + for x = 0, width + stripe_w, stripe_w * 2 do + local offset = (frame_count * 2) % (stripe_w * 2) + display.fill_rect(x - offset, play_bottom, stripe_w, 6, 234, 208, 108) + end +end + +local function draw_pipe(pipe) + local x = math.floor(pipe.x) + local top_h = pipe.gap_top + local bottom_y = pipe.gap_bottom + local bottom_h = play_bottom - bottom_y + + display.fill_rect(x, 0, PIPE_WIDTH, top_h, PIPE_R, PIPE_G, PIPE_B) + display.fill_rect(x + PIPE_WIDTH - 7, 0, 7, top_h, PIPE_SHADE_R, PIPE_SHADE_G, PIPE_SHADE_B) + display.fill_rect(x - 2, top_h - 10, PIPE_WIDTH + 4, 10, PIPE_CAP_R, PIPE_CAP_G, PIPE_CAP_B) + + display.fill_rect(x, bottom_y, PIPE_WIDTH, bottom_h, PIPE_R, PIPE_G, PIPE_B) + display.fill_rect(x + PIPE_WIDTH - 7, bottom_y, 7, bottom_h, PIPE_SHADE_R, PIPE_SHADE_G, PIPE_SHADE_B) + display.fill_rect(x - 2, bottom_y, PIPE_WIDTH + 4, 10, PIPE_CAP_R, PIPE_CAP_G, PIPE_CAP_B) +end + +local function draw_bird() + local bx = bird_x + local by = math.floor(bird_y) + local tilt = math.max(-8, math.min(8, math.floor(bird_vy))) + local leg_y = by + BIRD_RADIUS + 2 + (tilt // 2) + + display.fill_circle(bx, by, BIRD_RADIUS, BIRD_R, BIRD_G, BIRD_B) + display.fill_circle(bx - 2, by + 2, math.floor(BIRD_RADIUS * 0.65), BIRD_WING_R, BIRD_WING_G, BIRD_WING_B) + display.fill_triangle( + bx + BIRD_RADIUS - 1, by - 2, + bx + BIRD_RADIUS + 10, by + 1, + bx + BIRD_RADIUS - 1, by + 5, + BEAK_R, BEAK_G, BEAK_B + ) + display.fill_circle(bx + 3, by - 3, 3, 255, 255, 255) + display.fill_circle(bx + 4, by - 3, 1, EYE_R, EYE_G, EYE_B) + display.draw_line(bx - 6, by + BIRD_RADIUS - 2, bx - 2, leg_y, EYE_R, EYE_G, EYE_B) + display.draw_line(bx + 1, by + BIRD_RADIUS - 2, bx + 5, leg_y, EYE_R, EYE_G, EYE_B) +end + +local function draw_scoreboard() + local box_w = 112 + local left_x = 8 + local right_x = width - box_w - 8 + + display.fill_round_rect(left_x, 8, box_w, 40, 8, PANEL_R, PANEL_G, PANEL_B) + display.draw_round_rect(left_x, 8, box_w, 40, 8, PANEL_BORDER_R, PANEL_BORDER_G, PANEL_BORDER_B) + display.draw_text(left_x + 8, 18, "SCORE " .. tostring(score), { + r = TEXT_R, + g = TEXT_G, + b = TEXT_B, + font_size = 14, + bg_r = PANEL_R, + bg_g = PANEL_G, + bg_b = PANEL_B, + }) + + display.fill_round_rect(right_x, 8, box_w, 40, 8, PANEL_R, PANEL_G, PANEL_B) + display.draw_round_rect(right_x, 8, box_w, 40, 8, PANEL_BORDER_R, PANEL_BORDER_G, PANEL_BORDER_B) + display.draw_text(right_x + 8, 18, "BEST " .. tostring(best_score), { + r = TEXT_R, + g = TEXT_G, + b = TEXT_B, + font_size = 14, + bg_r = PANEL_R, + bg_g = PANEL_G, + bg_b = PANEL_B, + }) +end + +local function draw_center_panel(title, subtitle, subtitle_color) + local panel_w = math.min(width - 8, 232) + local panel_h = 88 + local panel_x = (width - panel_w) // 2 + local panel_y = math.floor(play_height * 0.18) + + display.fill_round_rect(panel_x, panel_y, panel_w, panel_h, 12, PANEL_R, PANEL_G, PANEL_B) + display.draw_round_rect(panel_x, panel_y, panel_w, panel_h, 12, PANEL_BORDER_R, PANEL_BORDER_G, PANEL_BORDER_B) + display.draw_text_aligned(panel_x, panel_y + 8, panel_w, 24, title, { + r = TEXT_R, + g = TEXT_G, + b = TEXT_B, + font_size = 22, + bg_r = PANEL_R, + bg_g = PANEL_G, + bg_b = PANEL_B, + align = "center", + valign = "middle", + }) + display.draw_text_aligned(panel_x + 12, panel_y + 42, panel_w - 24, 18, subtitle, { + r = subtitle_color.r, + g = subtitle_color.g, + b = subtitle_color.b, + font_size = 14, + bg_r = PANEL_R, + bg_g = PANEL_G, + bg_b = PANEL_B, + align = "center", + valign = "middle", + }) +end + +local function action_label(verb) + if input_mode == "lcd_touch" then + return "tap to " .. verb + end + if input_mode == "button" then + return "press button to " .. verb + end + return verb +end + +local function render() + draw_background() + + for i = 1, #pipes do + draw_pipe(pipes[i]) + end + + draw_bird() + draw_scoreboard() + + if state == "title" then + draw_center_panel("Lappy Bird", action_label("start") .. " and " .. action_label("flap"), { r = 44, g = 86, b = 128 }) + elseif state == "crashed" then + draw_center_panel("Crash", action_label("restart"), { r = DANGER_R, g = DANGER_G, b = DANGER_B }) + end + + display.present() +end + +local function circle_rect_hit(cx, cy, radius, rx, ry, rw, rh) + local nearest_x = math.max(rx, math.min(cx, rx + rw)) + local nearest_y = math.max(ry, math.min(cy, ry + rh)) + local dx = cx - nearest_x + local dy = cy - nearest_y + return dx * dx + dy * dy <= radius * radius +end + +local function set_crashed() + if state == "crashed" then + return + end + if score > best_score then + best_score = score + end + if audio_output then + pcall(audio.play_tone, audio_output, 180, 220) + end + state = "crashed" +end + +local function update_playing() + bird_vy = bird_vy + GRAVITY + bird_y = bird_y + bird_vy + spawn_timer_ms = spawn_timer_ms + FRAME_MS + + if spawn_timer_ms >= PIPE_SPAWN_MS then + spawn_timer_ms = spawn_timer_ms - PIPE_SPAWN_MS + pipes[#pipes + 1] = new_pipe(width + PIPE_WIDTH + 8) + end + + for i = #pipes, 1, -1 do + local pipe = pipes[i] + pipe.x = pipe.x - PIPE_SPEED + + if not pipe.scored and pipe.x + PIPE_WIDTH < bird_x then + pipe.scored = true + score = score + 1 + if score > best_score then + best_score = score + end + if audio_output then + pcall(audio.play_tone, audio_output, 1320, 50) + end + end + + if pipe.x + PIPE_WIDTH < -4 then + table.remove(pipes, i) + elseif circle_rect_hit(bird_x, bird_y, BIRD_RADIUS, pipe.x, 0, PIPE_WIDTH, pipe.gap_top) + or circle_rect_hit(bird_x, bird_y, BIRD_RADIUS, pipe.x, pipe.gap_bottom, PIPE_WIDTH, play_bottom - pipe.gap_bottom) then + set_crashed() + end + end + + if bird_y - BIRD_RADIUS <= play_top or bird_y + BIRD_RADIUS >= play_bottom then + set_crashed() + end +end + +local function init_input() + if lcd_touch_ok then + local touch_err + touch_handle, touch_err = bm.get_lcd_touch_handle("lcd_touch") + if touch_handle then + ok, err = pcall(lcd_touch.sync, touch_handle) + if ok then + input_mode = "lcd_touch" + return true + end + print("[lappybird] WARN: lcd_touch.sync failed: " .. tostring(err)) + else + print("[lappybird] WARN: get_lcd_touch_handle(lcd_touch) failed: " .. tostring(touch_err)) + end + else + print("[lappybird] WARN: require(lcd_touch) failed") + end + + if not button_ok then + print("[lappybird] ERROR: require(button) failed") + return false + end + + local button_err + button_handle, button_err = button.new(0, 0) + if not button_handle then + print("[lappybird] ERROR: button.new failed: " .. tostring(button_err)) + return false + end + + local level, level_err = button.get_key_level(button_handle) + if level == nil then + print("[lappybird] ERROR: button.get_key_level failed: " .. tostring(level_err)) + cleanup() + return false + end + button_last_level = level + + input_mode = "button" + return true +end + +local function consume_input_tap() + if input_mode == "lcd_touch" then + local polled, info = pcall(lcd_touch.poll, touch_handle) + if not polled then + print("[lappybird] ERROR: lcd_touch.poll failed: " .. tostring(info)) + return nil + end + + local tapped = info.just_pressed and not touch_consumed + touch_consumed = info.pressed + return tapped + end + + if input_mode == "button" then + local level, level_err = button.get_key_level(button_handle) + if level == nil then + print("[lappybird] ERROR: button.get_key_level failed: " .. tostring(level_err)) + return nil + end + + local tapped = level == button_active_level and button_last_level ~= button_active_level + button_last_level = level + return tapped + end + + return false +end + +local function init_audio() + if not audio_ok then + print("[lappybird] WARN: require(audio) failed") + return + end + + local output_codec, output_rate, output_channels, output_bits = + bm.get_audio_codec_output_params("audio_dac") + if not output_codec then + print("[lappybird] WARN: get_audio_codec_output_params(audio_dac) failed: " .. tostring(output_rate)) + return + end + + local output, out_err = audio.new_output(output_codec, output_rate, output_channels, output_bits) + if not output then + print("[lappybird] WARN: audio.new_output failed: " .. tostring(out_err)) + return + end + + audio_output = output + pcall(audio.set_volume, audio_output, SOUND_VOLUME) +end + +if not init_input() then + cleanup() + return +end + +init_audio() + +reset_round("title") + +display.begin_frame({ clear = true, r = SKY_R, g = SKY_G, b = SKY_B }) + +print(string.format("[lappybird] ready screen=%dx%d", width, height)) +if input_mode == "lcd_touch" then + print("[lappybird] lcd_touch ready, tap anywhere to flap, tap after crashing to restart") +else + print("[lappybird] lcd_touch unavailable, using button to flap and restart") +end + +for _ = 1, RUN_TIME_MS // FRAME_MS do + local tapped = consume_input_tap() + if tapped == nil then + break + end + + if tapped then + if state == "title" then + reset_round("playing") + flap() + elseif state == "playing" then + flap() + elseif state == "crashed" then + reset_round("playing") + flap() + end + end + + if state == "playing" then + update_playing() + end + + render() + frame_count = frame_count + 1 + delay.delay_ms(FRAME_MS) +end + +cleanup() +print("[lappybird] done") diff --git a/application/basic_demo/fatfs_image/scripts/led_strip_demo.lua b/application/basic_demo/fatfs_image/scripts/led_strip_demo.lua index a5099a5..1b40c3e 100644 --- a/application/basic_demo/fatfs_image/scripts/led_strip_demo.lua +++ b/application/basic_demo/fatfs_image/scripts/led_strip_demo.lua @@ -2,7 +2,7 @@ local ls = require("led_strip") local delay = require("delay") -- Update these values to match the LED strip wiring on the target board. -local LED_GPIO_NUM = 26 +local LED_GPIO_NUM = 38 local LED_COUNT = 16 -- Create the strip handle and drive all pixels together. diff --git a/components/claw_capabilities/cap_lua/include/cap_lua.h b/components/claw_capabilities/cap_lua/include/cap_lua.h index 57e370a..461e9ce 100644 --- a/components/claw_capabilities/cap_lua/include/cap_lua.h +++ b/components/claw_capabilities/cap_lua/include/cap_lua.h @@ -21,11 +21,14 @@ typedef struct { lua_CFunction open_fn; } cap_lua_module_t; +typedef void (*cap_lua_runtime_cleanup_fn_t)(void); + const char *cap_lua_get_base_dir(void); esp_err_t cap_lua_register_group(void); esp_err_t cap_lua_set_base_dir(const char *base_dir); esp_err_t cap_lua_register_module(const char *name, lua_CFunction open_fn); esp_err_t cap_lua_register_modules(const cap_lua_module_t *modules, size_t count); +esp_err_t cap_lua_register_runtime_cleanup(cap_lua_runtime_cleanup_fn_t cleanup_fn); esp_err_t cap_lua_list_scripts(const char *prefix, char *output, size_t output_size); esp_err_t cap_lua_write_script(const char *path, const char *content, diff --git a/components/claw_capabilities/cap_lua/src/cap_lua.c b/components/claw_capabilities/cap_lua/src/cap_lua.c index 0cb678a..a7de26b 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua.c +++ b/components/claw_capabilities/cap_lua/src/cap_lua.c @@ -22,9 +22,16 @@ static const char *TAG = "cap_lua"; +typedef struct cap_lua_runtime_cleanup_node { + cap_lua_runtime_cleanup_fn_t cleanup_fn; + struct cap_lua_runtime_cleanup_node *next; +} cap_lua_runtime_cleanup_node_t; + static char s_lua_base_dir[128] = CAP_LUA_DEFAULT_BASE_DIR; static cap_lua_module_t s_modules[CAP_LUA_MAX_MODULES]; static size_t s_module_count; +static cap_lua_runtime_cleanup_node_t *s_runtime_cleanups; +static size_t s_runtime_cleanup_count; static bool s_builtin_modules_registered; static bool s_module_registration_locked; @@ -879,6 +886,36 @@ esp_err_t cap_lua_register_modules(const cap_lua_module_t *modules, size_t count return ESP_OK; } +esp_err_t cap_lua_register_runtime_cleanup(cap_lua_runtime_cleanup_fn_t cleanup_fn) +{ + cap_lua_runtime_cleanup_node_t *node = NULL; + cap_lua_runtime_cleanup_node_t *it = NULL; + + if (!cleanup_fn) { + return ESP_ERR_INVALID_ARG; + } + if (s_module_registration_locked) { + return ESP_ERR_INVALID_STATE; + } + + for (it = s_runtime_cleanups; it != NULL; it = it->next) { + if (it->cleanup_fn == cleanup_fn) { + return ESP_ERR_INVALID_STATE; + } + } + + node = calloc(1, sizeof(*node)); + if (!node) { + return ESP_ERR_NO_MEM; + } + + node->cleanup_fn = cleanup_fn; + node->next = s_runtime_cleanups; + s_runtime_cleanups = node; + s_runtime_cleanup_count++; + return ESP_OK; +} + esp_err_t cap_lua_register_builtin_modules(void) { s_builtin_modules_registered = true; @@ -898,3 +935,24 @@ const cap_lua_module_t *cap_lua_get_module(size_t index) return &s_modules[index]; } + +size_t cap_lua_get_runtime_cleanup_count(void) +{ + return s_runtime_cleanup_count; +} + +cap_lua_runtime_cleanup_fn_t cap_lua_get_runtime_cleanup(size_t index) +{ + size_t i = 0; + cap_lua_runtime_cleanup_node_t *it = s_runtime_cleanups; + + while (it != NULL) { + if (i == index) { + return it->cleanup_fn; + } + i++; + it = it->next; + } + + return NULL; +} diff --git a/components/claw_capabilities/cap_lua/src/cap_lua_internal.h b/components/claw_capabilities/cap_lua/src/cap_lua_internal.h index 07c5681..a0fc017 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua_internal.h +++ b/components/claw_capabilities/cap_lua/src/cap_lua_internal.h @@ -52,6 +52,8 @@ esp_err_t cap_lua_runtime_execute_file(const char *path, esp_err_t cap_lua_register_builtin_modules(void); size_t cap_lua_get_module_count(void); const cap_lua_module_t *cap_lua_get_module(size_t index); +size_t cap_lua_get_runtime_cleanup_count(void); +cap_lua_runtime_cleanup_fn_t cap_lua_get_runtime_cleanup(size_t index); esp_err_t cap_lua_async_init(void); esp_err_t cap_lua_async_start(void); diff --git a/components/claw_capabilities/cap_lua/src/cap_lua_runtime.c b/components/claw_capabilities/cap_lua/src/cap_lua_runtime.c index cf0fa4f..9090bde 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua_runtime.c +++ b/components/claw_capabilities/cap_lua/src/cap_lua_runtime.c @@ -172,6 +172,19 @@ static void cap_lua_set_args_global(lua_State *L, const char *args_json) lua_setglobal(L, "args"); } +static void cap_lua_run_runtime_cleanups(void) +{ + size_t i; + + for (i = 0; i < cap_lua_get_runtime_cleanup_count(); i++) { + cap_lua_runtime_cleanup_fn_t cleanup_fn = cap_lua_get_runtime_cleanup(i); + + if (cleanup_fn) { + cleanup_fn(); + } + } +} + esp_err_t cap_lua_runtime_init(void) { ESP_LOGI(TAG, @@ -236,6 +249,7 @@ esp_err_t cap_lua_runtime_execute_file(const char *path, lua_sethook(L, cap_lua_timeout_hook, LUA_MASKCOUNT, 1000); status = luaL_dofile(L, path); + cap_lua_run_runtime_cleanups(); if (status != LUA_OK) { const char *msg = lua_tostring(L, -1); if (ctx.len > 0) { diff --git a/components/lua_modules/lua_module_audio/lua_module_audio.c b/components/lua_modules/lua_module_audio/lua_module_audio.c index c3e243d..94ad074 100644 --- a/components/lua_modules/lua_module_audio/lua_module_audio.c +++ b/components/lua_modules/lua_module_audio/lua_module_audio.c @@ -11,6 +11,8 @@ #include #include #include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "esp_codec_dev.h" #include "esp_err.h" #include "esp_log.h" @@ -19,12 +21,16 @@ static const char *TAG = "lua_audio"; +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + /* -------------------------------------------------------------------------- * Audio constants * -------------------------------------------------------------------------- */ #define AUDIO_CHUNK_BYTES 512 #define AUDIO_DEFAULT_VOL 80 -#define AUDIO_DEFAULT_GAIN_DB 30.0f +#define AUDIO_DEFAULT_GAIN_DB 10.0f #define AUDIO_HANDLE_METATABLE "lua_audio_handle" typedef enum { @@ -432,6 +438,102 @@ cleanup: return 0; } +/* -------------------------------------------------------------------------- + * audio.play_tone(output_handle, freq_hz, duration_ms [, volume_pct [, wait_done]]) -> nil + * -------------------------------------------------------------------------- */ +static int lua_audio_play_tone(lua_State *L) +{ + audio_lua_handle_t *dac = lua_audio_check_handle(L, 1, AUDIO_HANDLE_OUTPUT, "play_tone"); + uint32_t freq_hz = lua_audio_check_u32_arg(L, 2, "freq_hz"); + uint32_t duration_ms = lua_audio_check_u32_arg(L, 3, "duration_ms"); + int volume_pct = (int)luaL_optinteger(L, 4, 90); + bool wait_done = false; + int16_t *buf = NULL; + uint32_t total_frames; + uint32_t frames_written = 0; + uint32_t chunk_frames; + float amplitude; + float phase = 0.0f; + float phase_step; + float gain_scale; + TickType_t start_tick; + TickType_t target_ticks; + + if (volume_pct < 0 || volume_pct > 100) { + return luaL_error(L, "audio play_tone: volume_pct must be 0..100"); + } + if (!lua_isnoneornil(L, 5)) { + wait_done = lua_toboolean(L, 5); + } + if (dac->bits_per_sample != 16 || dac->bytes_per_sample != sizeof(int16_t)) { + return luaL_error(L, "audio play_tone: only 16-bit PCM output is supported"); + } + if (freq_hz >= dac->sample_rate / 2) { + return luaL_error(L, "audio play_tone: freq_hz must be less than half of sample_rate"); + } + + chunk_frames = AUDIO_CHUNK_BYTES / (dac->channels * dac->bytes_per_sample); + if (chunk_frames == 0) { + return luaL_error(L, "audio play_tone: invalid output frame size"); + } + + total_frames = (uint32_t)(((uint64_t)dac->sample_rate * duration_ms) / 1000); + start_tick = xTaskGetTickCount(); + target_ticks = pdMS_TO_TICKS(duration_ms); + if (target_ticks == 0 && duration_ms > 0) { + target_ticks = 1; + } + if (volume_pct == 0) { + gain_scale = 0.0f; + } else { + /* Match esp_codec_dev default volume semantics: [1, 100] -> [-49.5 dB, 0 dB]. */ + float gain_db = ((float)volume_pct - 100.0f) * 0.5f; + gain_scale = powf(10.0f, gain_db / 20.0f); + } + amplitude = 32767.0f * gain_scale; + phase_step = 2.0f * (float)M_PI * (float)freq_hz / (float)dac->sample_rate; + + buf = (int16_t *)malloc(chunk_frames * dac->channels * sizeof(int16_t)); + if (!buf) { + return luaL_error(L, "audio play_tone: out of memory"); + } + + /* Generate one tone chunk at a time to avoid a large temporary buffer. */ + while (frames_written < total_frames) { + uint32_t frames_this_chunk = total_frames - frames_written; + if (frames_this_chunk > chunk_frames) { + frames_this_chunk = chunk_frames; + } + + for (uint32_t i = 0; i < frames_this_chunk; i++) { + int16_t sample = (int16_t)(sinf(phase) * amplitude); + for (uint8_t ch = 0; ch < dac->channels; ch++) { + buf[i * dac->channels + ch] = sample; + } + phase += phase_step; + if (phase >= 2.0f * (float)M_PI) { + phase -= 2.0f * (float)M_PI; + } + } + + if (esp_codec_dev_write(dac->codec_dev, buf, (int)(frames_this_chunk * dac->channels * sizeof(int16_t))) != ESP_CODEC_DEV_OK) { + free(buf); + return luaL_error(L, "audio play_tone: write failed"); + } + frames_written += frames_this_chunk; + } + + if (wait_done) { + TickType_t elapsed_ticks = xTaskGetTickCount() - start_tick; + if (elapsed_ticks < target_ticks) { + vTaskDelay(target_ticks - elapsed_ticks); + } + } + + free(buf); + return 0; +} + /* -------------------------------------------------------------------------- * audio.record_wav(input_handle, path, duration_ms) -> { path, duration_ms, bytes } * path must be a .wav file and must not contain "..". @@ -714,6 +816,7 @@ int luaopen_audio(lua_State *L) {"new_input", lua_audio_new_input}, {"new_output", lua_audio_new_output}, {"close", lua_audio_close}, + {"play_tone", lua_audio_play_tone}, {"play_wav", lua_audio_play_wav}, {"record_wav", lua_audio_record_wav}, {"loopback", lua_audio_loopback}, diff --git a/components/lua_modules/lua_module_audio/skills/lua_module_audio.md b/components/lua_modules/lua_module_audio/skills/lua_module_audio.md index e93dd42..d5124e0 100644 --- a/components/lua_modules/lua_module_audio/skills/lua_module_audio.md +++ b/components/lua_modules/lua_module_audio/skills/lua_module_audio.md @@ -6,6 +6,7 @@ This skill describes how to correctly use audio when writing Lua scripts. - Import it with `local audio = require("audio")` - Call `audio.new_input(codec_dev_handle, sample_rate, channels, bits_per_sample [, gain_db])` to create an input handle - Call `audio.new_output(codec_dev_handle, sample_rate, channels, bits_per_sample [, volume])` to create an output handle +- Call `audio.play_tone(output_handle, freq_hz, duration_ms [, volume_pct [, wait_done]])` to generate and play a sine tone on a 16-bit PCM output handle, where `wait_done` defaults to `false` and can be set to `true` to wait for playback completion - Call `audio.play_wav(output_handle, path)` to play a WAV file under the current storage root - Call `audio.record_wav(input_handle, path, duration_ms)` to record audio to a WAV file under the current storage root - Call `audio.loopback(input_handle, output_handle [, duration_ms])` to route input to output for monitoring @@ -23,6 +24,7 @@ local output_codec, rate, channels, bits = local output = audio.new_output(output_codec, rate, channels, bits) audio.set_volume(output, 60) +audio.play_tone(output, 880, 200, 35) local storage = require("storage") audio.play_wav(output, storage.join_path(storage.get_root_dir(), "test.wav")) audio.close(output) diff --git a/components/lua_modules/lua_module_display/src/lua_module_display.c b/components/lua_modules/lua_module_display/src/lua_module_display.c index 0f86959..79c0690 100644 --- a/components/lua_modules/lua_module_display/src/lua_module_display.c +++ b/components/lua_modules/lua_module_display/src/lua_module_display.c @@ -16,6 +16,7 @@ #include "display_arbiter.h" #include "display_hal.h" #include "esp_err.h" +#include "esp_log.h" #include "lauxlib.h" #include "png.h" @@ -24,6 +25,8 @@ #include #include +static const char *TAG = "lua_display"; + /* ------------------------------------------------------------------------- * Argument helpers (mirrors the reference implementation) * ---------------------------------------------------------------------- */ @@ -136,6 +139,18 @@ static display_hal_panel_if_t lua_display_parse_panel_if(lua_State *L, int index * Screen lifecycle * ---------------------------------------------------------------------- */ +static void lua_display_runtime_cleanup(void) +{ + if (!display_arbiter_is_owner(DISPLAY_ARBITER_OWNER_LUA)) { + return; + } + ESP_LOGI(TAG, "Lua runtime cleanup: display still owned by Lua, releasing"); + + if (display_hal_destroy() == ESP_OK) { + display_arbiter_release(DISPLAY_ARBITER_OWNER_LUA); + } +} + static int lua_display_init(lua_State *L) { esp_lcd_panel_handle_t panel_handle = @@ -1464,5 +1479,9 @@ int luaopen_display(lua_State *L) esp_err_t lua_module_display_register(void) { - return cap_lua_register_module("display", luaopen_display); + esp_err_t err = cap_lua_register_module("display", luaopen_display); + if (err != ESP_OK) { + return err; + } + return cap_lua_register_runtime_cleanup(lua_display_runtime_cleanup); } diff --git a/components/lua_modules/lua_module_led_strip/skills/lua_module_led_strip.md b/components/lua_modules/lua_module_led_strip/skills/lua_module_led_strip.md index aba47c0..17511e3 100644 --- a/components/lua_modules/lua_module_led_strip/skills/lua_module_led_strip.md +++ b/components/lua_modules/lua_module_led_strip/skills/lua_module_led_strip.md @@ -1,6 +1,7 @@ # Lua LED Strip This skill describes how to correctly use led_strip when writing Lua scripts. +When a request mentions `ws2812`, use this `led_strip` module by default. ## How to call - Import it with `local led_strip = require("led_strip")`