很多修改
This commit is contained in:
+39
-2
@@ -115,14 +115,50 @@ else()
|
||||
endif()
|
||||
|
||||
if(UNIX AND NOT APPLE)
|
||||
find_package(ALSA QUIET)
|
||||
set(IMX6U_ALSA_INCLUDE_AFTER "")
|
||||
if(TARGET_IMX)
|
||||
set(IMX6U_BUNDLED_ALSA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/third_party/arm-linux-gnueabihf/alsa")
|
||||
set(IMX6U_BUNDLED_ALSA_INCLUDE_DIR "${IMX6U_BUNDLED_ALSA_ROOT}/include")
|
||||
set(IMX6U_BUNDLED_ALSA_LIBRARY "${IMX6U_BUNDLED_ALSA_ROOT}/lib/libasound.so")
|
||||
|
||||
if(EXISTS "${IMX6U_BUNDLED_ALSA_LIBRARY}")
|
||||
set(ALSA_FOUND TRUE)
|
||||
set(ALSA_LIBRARIES "${IMX6U_BUNDLED_ALSA_LIBRARY}")
|
||||
if(EXISTS "${IMX6U_BUNDLED_ALSA_INCLUDE_DIR}/alsa/asoundlib.h")
|
||||
set(ALSA_INCLUDE_DIRS "${IMX6U_BUNDLED_ALSA_INCLUDE_DIR}")
|
||||
elseif(EXISTS "/usr/include/alsa/asoundlib.h")
|
||||
set(IMX6U_ALSA_INCLUDE_AFTER "/usr/include")
|
||||
else()
|
||||
message(FATAL_ERROR "TARGET_IMX requires ALSA headers. Install libasound2-dev:armhf or copy ALSA headers to third_party/arm-linux-gnueabihf/alsa/include.")
|
||||
endif()
|
||||
message(STATUS "Using bundled ARM ALSA library: ${IMX6U_BUNDLED_ALSA_LIBRARY}")
|
||||
else()
|
||||
find_package(ALSA QUIET)
|
||||
if(NOT ALSA_FOUND)
|
||||
message(FATAL_ERROR "TARGET_IMX requires ALSA. Copy board libasound.so* to third_party/arm-linux-gnueabihf/alsa/lib or provide a compatible ALSA package.")
|
||||
endif()
|
||||
endif()
|
||||
else()
|
||||
find_package(ALSA QUIET)
|
||||
endif()
|
||||
if(ALSA_FOUND)
|
||||
target_compile_definitions(imx6u_core PUBLIC PLATFORM_HAS_ALSA)
|
||||
target_include_directories(imx6u_core PUBLIC ${ALSA_INCLUDE_DIRS})
|
||||
if(IMX6U_ALSA_INCLUDE_AFTER)
|
||||
target_compile_options(imx6u_core PUBLIC -idirafter ${IMX6U_ALSA_INCLUDE_AFTER})
|
||||
elseif(ALSA_INCLUDE_DIRS)
|
||||
target_include_directories(imx6u_core PUBLIC ${ALSA_INCLUDE_DIRS})
|
||||
elseif(ALSA_INCLUDE_DIR)
|
||||
target_include_directories(imx6u_core PUBLIC ${ALSA_INCLUDE_DIR})
|
||||
endif()
|
||||
target_link_libraries(imx6u_core PUBLIC ${ALSA_LIBRARIES})
|
||||
if(TARGET_IMX)
|
||||
target_link_libraries(imx6u_core PUBLIC dl pthread rt m)
|
||||
endif()
|
||||
else()
|
||||
message(STATUS "ALSA was not found; AlsaAudioInput and AlsaAudioOutput will be unavailable backends")
|
||||
endif()
|
||||
elseif(TARGET_IMX)
|
||||
message(FATAL_ERROR "TARGET_IMX requires ALSA headers and libasound.")
|
||||
endif()
|
||||
|
||||
if(MSVC)
|
||||
@@ -160,6 +196,7 @@ function(imx6u_configure_app_target target_name)
|
||||
endfunction()
|
||||
|
||||
add_subdirectory(src/Apps/Game)
|
||||
add_subdirectory(src/Apps/Desktop)
|
||||
add_subdirectory(src/Apps/Demo)
|
||||
add_subdirectory(src/Apps/LightGame)
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
set(DESKTOP_TARGET IMX6U-Desktop)
|
||||
set(DESKTOP_ATLAS_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/generated/desktop_atlas.h")
|
||||
set_source_files_properties(${DESKTOP_ATLAS_HEADER} PROPERTIES GENERATED TRUE)
|
||||
|
||||
add_executable(${DESKTOP_TARGET}
|
||||
Main.cpp
|
||||
src/app/DesktopApp.cpp
|
||||
src/ui/GameCatalog.cpp
|
||||
src/ui/GameCarousel.cpp
|
||||
src/ui/GameLauncher.cpp
|
||||
${DESKTOP_ATLAS_HEADER}
|
||||
)
|
||||
|
||||
set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/src/ui/GameCatalog.cpp
|
||||
PROPERTIES OBJECT_DEPENDS "${DESKTOP_ATLAS_HEADER}"
|
||||
)
|
||||
|
||||
target_include_directories(${DESKTOP_TARGET} PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/app
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/ui
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generated
|
||||
)
|
||||
|
||||
imx6u_configure_app_target(${DESKTOP_TARGET})
|
||||
|
||||
if(CMAKE_CROSSCOMPILING AND NOT EXISTS "${DESKTOP_ATLAS_HEADER}")
|
||||
message(FATAL_ERROR
|
||||
"Desktop atlas header is missing. Run GenerateDesktopAtlasHeader in a host build before cross compiling."
|
||||
)
|
||||
endif()
|
||||
|
||||
if(NOT TARGET_IMX)
|
||||
set(DESKTOP_ATLAS_TOOL_SOURCES
|
||||
tools/asset_pipeline/DesktopAssetTool.cpp
|
||||
)
|
||||
|
||||
if(WIN32)
|
||||
add_executable(DesktopAtlasTool EXCLUDE_FROM_ALL ${DESKTOP_ATLAS_TOOL_SOURCES})
|
||||
target_include_directories(DesktopAtlasTool PRIVATE
|
||||
${PROJECT_SOURCE_DIR}/libs/Win/SDL2/include
|
||||
${PROJECT_SOURCE_DIR}/libs/Win/SDL_image/include
|
||||
)
|
||||
target_link_directories(DesktopAtlasTool PRIVATE
|
||||
${SDL2_LIB_DIR}
|
||||
${SDL2_IMAGE_LIB_DIR}
|
||||
)
|
||||
target_link_libraries(DesktopAtlasTool PRIVATE SDL2main SDL2 SDL2_image)
|
||||
|
||||
add_custom_command(TARGET DesktopAtlasTool POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${SDL2_DLL}"
|
||||
"$<TARGET_FILE_DIR:DesktopAtlasTool>"
|
||||
)
|
||||
add_custom_command(TARGET DesktopAtlasTool POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${SDL2_IMAGE_DLL}"
|
||||
"$<TARGET_FILE_DIR:DesktopAtlasTool>"
|
||||
)
|
||||
elseif(SDL2_image_FOUND)
|
||||
add_executable(DesktopAtlasTool EXCLUDE_FROM_ALL ${DESKTOP_ATLAS_TOOL_SOURCES})
|
||||
target_include_directories(DesktopAtlasTool PRIVATE
|
||||
${SDL2_image_INCLUDE_DIRS}
|
||||
${SDL2_IMAGE_INCLUDE_DIRS}
|
||||
)
|
||||
target_link_libraries(DesktopAtlasTool PRIVATE SDL2::SDL2)
|
||||
|
||||
if(TARGET SDL2_image::SDL2_image)
|
||||
target_link_libraries(DesktopAtlasTool PRIVATE SDL2_image::SDL2_image)
|
||||
else()
|
||||
target_link_libraries(DesktopAtlasTool PRIVATE
|
||||
${SDL2_image_LIBRARIES}
|
||||
${SDL2_IMAGE_LIBRARIES}
|
||||
)
|
||||
endif()
|
||||
else()
|
||||
message(STATUS "DesktopAtlasTool disabled: SDL2_image was not found")
|
||||
endif()
|
||||
|
||||
if(TARGET DesktopAtlasTool)
|
||||
if(NOT CMAKE_CROSSCOMPILING)
|
||||
add_custom_target(GenerateDesktopAtlasHeader
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||
"src/Apps/Desktop/generated"
|
||||
COMMAND $<TARGET_FILE:DesktopAtlasTool>
|
||||
WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}"
|
||||
DEPENDS DesktopAtlasTool
|
||||
COMMENT "Generating desktop atlas header from PNG assets"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_dependencies(${DESKTOP_TARGET} GenerateDesktopAtlasHeader)
|
||||
else()
|
||||
add_custom_target(GenerateDesktopAtlasHeader
|
||||
COMMAND ${CMAKE_COMMAND} -E echo
|
||||
"GenerateDesktopAtlasHeader is host-only. Run it in build-win or build-linux before cross compiling."
|
||||
VERBATIM
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
@@ -0,0 +1,150 @@
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
#include "DefaultHardware.h"
|
||||
#include "DrawContext.h"
|
||||
#include "IDisplay.h"
|
||||
#include "TimeSource.h"
|
||||
#include "Timer.h"
|
||||
#include "app/DesktopApp.h"
|
||||
#include "ui/GameLauncher.h"
|
||||
|
||||
#ifdef TARGET_IMX
|
||||
#include "FBDisplay.h"
|
||||
#endif
|
||||
#ifdef TARGET_PC
|
||||
#include "SDLDisplay.h"
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
const int32_t ScreenWidth = 1024;
|
||||
const int32_t ScreenHeight = 600;
|
||||
const int32_t RestoreRetryCount = 5;
|
||||
const uint32_t RestoreRetryDelayMs = 300;
|
||||
|
||||
static Platform::IDisplay* CreateDisplay()
|
||||
{
|
||||
#ifdef TARGET_IMX
|
||||
return new Platform::FBDisplay();
|
||||
#endif
|
||||
#ifdef TARGET_PC
|
||||
return new Platform::SDLDisplay();
|
||||
#endif
|
||||
}
|
||||
|
||||
static void SleepRemainingFrameTime(const Core::Timer& timer, const Platform::ITimeSource& timeSource)
|
||||
{
|
||||
const uint32_t sleepMs = timer.remaining_frame_ms(timeSource.get_time_ms());
|
||||
if (sleepMs > 0u)
|
||||
{
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs));
|
||||
}
|
||||
}
|
||||
|
||||
static bool RestoreDesktopHardware(
|
||||
Platform::IDisplay* display,
|
||||
Platform::DefaultPointerInput& pointerInput)
|
||||
{
|
||||
for (int32_t attempt = 1; attempt <= RestoreRetryCount; ++attempt)
|
||||
{
|
||||
if (display->init(ScreenWidth, ScreenHeight))
|
||||
{
|
||||
if (!pointerInput.init("", ScreenWidth, ScreenHeight))
|
||||
{
|
||||
std::cerr << "[WARN] Pointer input restore failed; desktop touch is disabled." << std::endl;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::cerr << "[WARN] Failed to restore desktop display, retry "
|
||||
<< attempt << "/" << RestoreRetryCount << "." << std::endl;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(RestoreRetryDelayMs));
|
||||
}
|
||||
|
||||
std::cerr << "[ERROR] Failed to restore desktop display after retries." << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool LaunchGameAndRestoreDesktop(
|
||||
const Desktop::GameEntry& game,
|
||||
Desktop::GameLauncher& launcher,
|
||||
Platform::IDisplay* display,
|
||||
Platform::DefaultPointerInput& pointerInput)
|
||||
{
|
||||
pointerInput.shutdown();
|
||||
display->shutdown();
|
||||
|
||||
const bool launched = launcher.launch_and_wait(game);
|
||||
if (!launched)
|
||||
{
|
||||
std::cerr << "[WARN] Game launch failed: " << (game.title ? game.title : "(unnamed)") << std::endl;
|
||||
}
|
||||
|
||||
return RestoreDesktopHardware(display, pointerInput);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
Platform::IDisplay* display = CreateDisplay();
|
||||
if (!display->init(ScreenWidth, ScreenHeight))
|
||||
{
|
||||
delete display;
|
||||
return -1;
|
||||
}
|
||||
|
||||
Platform::DefaultPointerInput pointerInput;
|
||||
if (!pointerInput.init("", ScreenWidth, ScreenHeight))
|
||||
{
|
||||
std::cerr << "[WARN] Pointer input init failed; desktop touch is disabled." << std::endl;
|
||||
}
|
||||
|
||||
Core::DrawContext ctx(ScreenWidth, ScreenHeight);
|
||||
Core::Timer timer(30);
|
||||
Platform::SteadyTimeSource timeSource;
|
||||
Desktop::DesktopApp app(ScreenWidth, ScreenHeight, &pointerInput);
|
||||
Desktop::GameLauncher launcher;
|
||||
|
||||
std::cout << "[INFO] Desktop started. Target FPS: " << timer.target_fps() << std::endl;
|
||||
|
||||
bool isRunning = true;
|
||||
while (isRunning)
|
||||
{
|
||||
timer.begin_frame(timeSource.get_time_ms());
|
||||
|
||||
bool shouldQuit = false;
|
||||
display->poll_events(shouldQuit);
|
||||
if (shouldQuit)
|
||||
{
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
app.update(timer.fixed_delta_ms());
|
||||
const Desktop::GameEntry* launchGame = app.consume_pending_launch();
|
||||
if (launchGame != nullptr)
|
||||
{
|
||||
if (!LaunchGameAndRestoreDesktop(*launchGame, launcher, display, pointerInput))
|
||||
{
|
||||
isRunning = false;
|
||||
break;
|
||||
}
|
||||
timer.begin_frame(timeSource.get_time_ms());
|
||||
}
|
||||
|
||||
app.draw(ctx);
|
||||
ctx.present(display);
|
||||
SleepRemainingFrameTime(timer, timeSource);
|
||||
}
|
||||
|
||||
pointerInput.shutdown();
|
||||
display->shutdown();
|
||||
delete display;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
IMX6U Desktop
|
||||
=============
|
||||
|
||||
Game-console desktop application for the 1024x600 screen.
|
||||
|
||||
Interaction:
|
||||
- Tap the left or right screen edge to switch games.
|
||||
- Swipe horizontally to switch games.
|
||||
- Tap the centered cover card to launch the selected game.
|
||||
|
||||
Adding a game:
|
||||
1. Add a PNG cover to `assets/raw`.
|
||||
2. Add it to `DesktopAssetTool.cpp`.
|
||||
3. Add a `GameEntry` in `src/ui/GameCatalog.cpp`.
|
||||
4. Rebuild the Desktop target to regenerate `generated/desktop_atlas.h`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
#include "DesktopApp.h"
|
||||
|
||||
#include <iostream>
|
||||
|
||||
#include "DrawContext.h"
|
||||
#include "PointerInput.h"
|
||||
#include "font_atlas.h"
|
||||
|
||||
namespace Desktop
|
||||
{
|
||||
DesktopApp::DesktopApp(int32_t width, int32_t height, Platform::IPointerInput* input)
|
||||
: pointerInput(input),
|
||||
screenWidth(width),
|
||||
screenHeight(height),
|
||||
pendingLaunchGame(nullptr)
|
||||
{
|
||||
font.mask_bits = font_atlas_mask;
|
||||
font.atlas_width = font_atlas_width;
|
||||
font.atlas_height = font_atlas_height;
|
||||
font.char_w = font_char_w;
|
||||
font.char_h = font_char_h;
|
||||
font.columns = font_columns;
|
||||
font.first_char = font_first_char;
|
||||
|
||||
carousel.configure(GetGameCatalog(), GetGameCatalogCount(), screenWidth, screenHeight);
|
||||
}
|
||||
|
||||
void DesktopApp::update(uint32_t deltaMs)
|
||||
{
|
||||
if (pointerInput != nullptr)
|
||||
{
|
||||
pointerInput->update();
|
||||
}
|
||||
|
||||
const CarouselAction action = carousel.update(deltaMs, pointerInput);
|
||||
if (action == CarouselAction::Launch)
|
||||
{
|
||||
const GameEntry* game = carousel.get_current_game();
|
||||
if (game != nullptr)
|
||||
{
|
||||
std::cout << "[INFO] Launching " << game->title << std::endl;
|
||||
pendingLaunchGame = game;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DesktopApp::draw(Core::DrawContext& ctx)
|
||||
{
|
||||
carousel.draw(ctx, font);
|
||||
}
|
||||
|
||||
const GameEntry* DesktopApp::consume_pending_launch()
|
||||
{
|
||||
const GameEntry* game = pendingLaunchGame;
|
||||
pendingLaunchGame = nullptr;
|
||||
return game;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "BitmapFont.h"
|
||||
#include "GameCatalog.h"
|
||||
#include "GameCarousel.h"
|
||||
|
||||
namespace Core
|
||||
{
|
||||
class DrawContext;
|
||||
}
|
||||
|
||||
namespace Platform
|
||||
{
|
||||
class IPointerInput;
|
||||
}
|
||||
|
||||
namespace Desktop
|
||||
{
|
||||
class DesktopApp
|
||||
{
|
||||
private:
|
||||
Platform::IPointerInput* pointerInput;
|
||||
GameCarousel carousel;
|
||||
RenderData::BitmapFont font;
|
||||
int32_t screenWidth;
|
||||
int32_t screenHeight;
|
||||
const GameEntry* pendingLaunchGame;
|
||||
|
||||
public:
|
||||
DesktopApp(int32_t width, int32_t height, Platform::IPointerInput* pointerInput);
|
||||
|
||||
void update(uint32_t deltaMs);
|
||||
void draw(Core::DrawContext& ctx);
|
||||
const GameEntry* consume_pending_launch();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
#include "GameCarousel.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "Color.h"
|
||||
#include "DrawContext.h"
|
||||
#include "PointerInput.h"
|
||||
|
||||
namespace Desktop
|
||||
{
|
||||
namespace
|
||||
{
|
||||
const int32_t EdgeHotZone = 120;
|
||||
const int32_t SwipeThreshold = 120;
|
||||
const int32_t TapSlop = 18;
|
||||
const uint32_t SlideDurationMs = 220;
|
||||
|
||||
static int32_t AbsInt(int32_t value)
|
||||
{
|
||||
return value < 0 ? -value : value;
|
||||
}
|
||||
|
||||
static int32_t LerpInt(int32_t from, int32_t to, uint32_t elapsed, uint32_t duration)
|
||||
{
|
||||
if (duration == 0 || elapsed >= duration)
|
||||
{
|
||||
return to;
|
||||
}
|
||||
const int32_t delta = to - from;
|
||||
return from + static_cast<int32_t>((static_cast<int64_t>(delta) * static_cast<int64_t>(elapsed)) / static_cast<int64_t>(duration));
|
||||
}
|
||||
}
|
||||
|
||||
GameCarousel::GameCarousel()
|
||||
: games(nullptr),
|
||||
gameCount(0),
|
||||
currentIndex(0),
|
||||
targetIndex(0),
|
||||
screenWidth(0),
|
||||
screenHeight(0),
|
||||
dragStartX(0),
|
||||
dragCurrentX(0),
|
||||
dragStartY(0),
|
||||
dragging(false),
|
||||
pointerWasDown(false),
|
||||
animationFromOffset(0),
|
||||
animationToOffset(0),
|
||||
animationElapsedMs(0),
|
||||
animationDurationMs(SlideDurationMs)
|
||||
{
|
||||
}
|
||||
|
||||
void GameCarousel::configure(const GameEntry* catalog, size_t catalogCount, int32_t width, int32_t height)
|
||||
{
|
||||
games = catalog;
|
||||
gameCount = catalogCount;
|
||||
screenWidth = width;
|
||||
screenHeight = height;
|
||||
currentIndex = 0;
|
||||
targetIndex = 0;
|
||||
}
|
||||
|
||||
size_t GameCarousel::previous_index(size_t index) const
|
||||
{
|
||||
if (gameCount == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return index == 0 ? gameCount - 1 : index - 1;
|
||||
}
|
||||
|
||||
size_t GameCarousel::next_index(size_t index) const
|
||||
{
|
||||
return gameCount == 0 ? 0 : (index + 1) % gameCount;
|
||||
}
|
||||
|
||||
int32_t GameCarousel::get_card_width() const
|
||||
{
|
||||
return screenWidth - 144;
|
||||
}
|
||||
|
||||
int32_t GameCarousel::get_card_height() const
|
||||
{
|
||||
return screenHeight - 156;
|
||||
}
|
||||
|
||||
int32_t GameCarousel::get_card_x() const
|
||||
{
|
||||
return (screenWidth - get_card_width()) / 2;
|
||||
}
|
||||
|
||||
int32_t GameCarousel::get_card_y() const
|
||||
{
|
||||
return 70;
|
||||
}
|
||||
|
||||
bool GameCarousel::is_animating() const
|
||||
{
|
||||
return animationElapsedMs < animationDurationMs && currentIndex != targetIndex;
|
||||
}
|
||||
|
||||
int32_t GameCarousel::get_current_offset() const
|
||||
{
|
||||
if (dragging)
|
||||
{
|
||||
return dragCurrentX - dragStartX;
|
||||
}
|
||||
if (is_animating())
|
||||
{
|
||||
return LerpInt(animationFromOffset, animationToOffset, animationElapsedMs, animationDurationMs);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool GameCarousel::is_inside_card(int32_t x, int32_t y) const
|
||||
{
|
||||
const int32_t cardX = get_card_x();
|
||||
const int32_t cardY = get_card_y();
|
||||
return x >= cardX && x < cardX + get_card_width() && y >= cardY && y < cardY + get_card_height();
|
||||
}
|
||||
|
||||
void GameCarousel::begin_slide(size_t index, int32_t direction)
|
||||
{
|
||||
if (gameCount <= 1 || index == currentIndex)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
targetIndex = index;
|
||||
animationFromOffset = direction * screenWidth;
|
||||
animationToOffset = 0;
|
||||
animationElapsedMs = 0;
|
||||
animationDurationMs = SlideDurationMs;
|
||||
}
|
||||
|
||||
CarouselAction GameCarousel::update(uint32_t deltaMs, Platform::IPointerInput* pointerInput)
|
||||
{
|
||||
if (is_animating())
|
||||
{
|
||||
animationElapsedMs += deltaMs;
|
||||
if (animationElapsedMs >= animationDurationMs)
|
||||
{
|
||||
currentIndex = targetIndex;
|
||||
animationElapsedMs = animationDurationMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (gameCount == 0 || pointerInput == nullptr)
|
||||
{
|
||||
return CarouselAction::None;
|
||||
}
|
||||
|
||||
const bool down = pointerInput->is_down();
|
||||
const int32_t x = pointerInput->get_x();
|
||||
const int32_t y = pointerInput->get_y();
|
||||
|
||||
if (down && !pointerWasDown && !is_animating())
|
||||
{
|
||||
dragging = true;
|
||||
dragStartX = x;
|
||||
dragCurrentX = x;
|
||||
dragStartY = y;
|
||||
}
|
||||
else if (down && dragging)
|
||||
{
|
||||
dragCurrentX = x;
|
||||
}
|
||||
else if (!down && pointerWasDown && dragging)
|
||||
{
|
||||
const int32_t dx = x - dragStartX;
|
||||
const int32_t dy = y - dragStartY;
|
||||
dragging = false;
|
||||
|
||||
if (is_animating())
|
||||
{
|
||||
pointerWasDown = down;
|
||||
return CarouselAction::None;
|
||||
}
|
||||
|
||||
if (AbsInt(dx) > SwipeThreshold && AbsInt(dx) > AbsInt(dy))
|
||||
{
|
||||
begin_slide(dx < 0 ? next_index(currentIndex) : previous_index(currentIndex), dx < 0 ? 1 : -1);
|
||||
}
|
||||
else if (AbsInt(dx) <= TapSlop && AbsInt(dy) <= TapSlop)
|
||||
{
|
||||
if (dragStartX < EdgeHotZone)
|
||||
{
|
||||
begin_slide(previous_index(currentIndex), -1);
|
||||
}
|
||||
else if (dragStartX > screenWidth - EdgeHotZone)
|
||||
{
|
||||
begin_slide(next_index(currentIndex), 1);
|
||||
}
|
||||
else if (is_inside_card(dragStartX, dragStartY))
|
||||
{
|
||||
pointerWasDown = down;
|
||||
return CarouselAction::Launch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pointerWasDown = down;
|
||||
return CarouselAction::None;
|
||||
}
|
||||
|
||||
void GameCarousel::draw_card(Core::DrawContext& ctx, const GameEntry& game, int32_t x, int32_t y, bool active) const
|
||||
{
|
||||
const int32_t cardW = get_card_width();
|
||||
const int32_t cardH = get_card_height();
|
||||
const RenderData::Color shadow(0, 0, 0, 255);
|
||||
const RenderData::Color border = active ? RenderData::Color(250, 230, 145, 255) : RenderData::Color(74, 82, 105, 255);
|
||||
const RenderData::Color panel(20, 24, 36, 255);
|
||||
|
||||
ctx.fill_rect(x + 12, y + 14, cardW, cardH, shadow);
|
||||
ctx.fill_rect(x - 4, y - 4, cardW + 8, cardH + 8, border);
|
||||
ctx.fill_rect(x, y, cardW, cardH, panel);
|
||||
|
||||
if (game.cover != nullptr && game.cover->atlas != nullptr)
|
||||
{
|
||||
const int32_t coverX = x + (cardW - game.cover->width) / 2;
|
||||
const int32_t coverY = y + (cardH - game.cover->height) / 2;
|
||||
ctx.draw_sprite(coverX, coverY, *game.cover);
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.fill_rect(x + 28, y + 28, cardW - 56, cardH - 56, RenderData::Color(48, 56, 82, 255));
|
||||
}
|
||||
}
|
||||
|
||||
void GameCarousel::draw_text_centered(Core::DrawContext& ctx, const RenderData::BitmapFont& font, int32_t y, const char* text) const
|
||||
{
|
||||
if (text == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int32_t len = 0;
|
||||
for (const char* p = text; *p; ++p)
|
||||
{
|
||||
++len;
|
||||
}
|
||||
|
||||
const int32_t x = (screenWidth - len * font.char_w) / 2;
|
||||
ctx.draw_text(font, x, y, RenderData::Color(245, 248, 255, 255), text);
|
||||
}
|
||||
|
||||
void GameCarousel::draw_edge_hints(Core::DrawContext& ctx) const
|
||||
{
|
||||
ctx.fill_rect(0, 0, 42, screenHeight, RenderData::Color(9, 12, 20, 255));
|
||||
ctx.fill_rect(screenWidth - 42, 0, 42, screenHeight, RenderData::Color(9, 12, 20, 255));
|
||||
|
||||
const int32_t cy = screenHeight / 2;
|
||||
for (int32_t i = 0; i < 24; ++i)
|
||||
{
|
||||
ctx.fill_rect(24 - i / 2, cy - 24 + i, 3, 3, RenderData::Color(154, 168, 210, 255));
|
||||
ctx.fill_rect(screenWidth - 27 + i / 2, cy - 24 + i, 3, 3, RenderData::Color(154, 168, 210, 255));
|
||||
}
|
||||
}
|
||||
|
||||
void GameCarousel::draw_page_dots(Core::DrawContext& ctx) const
|
||||
{
|
||||
if (gameCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int32_t dotSize = 10;
|
||||
const int32_t gap = 18;
|
||||
const int32_t totalW = static_cast<int32_t>(gameCount) * dotSize + static_cast<int32_t>(gameCount - 1) * gap;
|
||||
const int32_t startX = (screenWidth - totalW) / 2;
|
||||
const int32_t y = screenHeight - 42;
|
||||
|
||||
for (size_t i = 0; i < gameCount; ++i)
|
||||
{
|
||||
const bool active = i == currentIndex || (is_animating() && i == targetIndex);
|
||||
const RenderData::Color color = active ? RenderData::Color(250, 230, 145, 255) : RenderData::Color(90, 100, 128, 255);
|
||||
ctx.fill_rect(startX + static_cast<int32_t>(i) * (dotSize + gap), y, dotSize, dotSize, color);
|
||||
}
|
||||
}
|
||||
|
||||
void GameCarousel::draw(Core::DrawContext& ctx, const RenderData::BitmapFont& font) const
|
||||
{
|
||||
ctx.clear_color(RenderData::Color(10, 14, 24, 255));
|
||||
ctx.fill_rect(0, 0, screenWidth, 56, RenderData::Color(16, 22, 35, 255));
|
||||
draw_text_centered(ctx, font, 20, "IMX6U GAME DESKTOP");
|
||||
|
||||
if (gameCount == 0)
|
||||
{
|
||||
draw_text_centered(ctx, font, screenHeight / 2, "NO GAMES");
|
||||
return;
|
||||
}
|
||||
|
||||
draw_edge_hints(ctx);
|
||||
|
||||
const int32_t baseX = get_card_x();
|
||||
const int32_t baseY = get_card_y();
|
||||
const int32_t offset = get_current_offset();
|
||||
|
||||
if (is_animating())
|
||||
{
|
||||
const int32_t currentOffset = animationFromOffset > 0 ? offset - screenWidth : offset + screenWidth;
|
||||
draw_card(ctx, games[currentIndex], baseX + currentOffset, baseY, false);
|
||||
draw_card(ctx, games[targetIndex], baseX + offset, baseY, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (dragging && gameCount > 1)
|
||||
{
|
||||
if (offset > 0)
|
||||
{
|
||||
draw_card(ctx, games[previous_index(currentIndex)], baseX + offset - screenWidth, baseY, false);
|
||||
}
|
||||
else if (offset < 0)
|
||||
{
|
||||
draw_card(ctx, games[next_index(currentIndex)], baseX + offset + screenWidth, baseY, false);
|
||||
}
|
||||
}
|
||||
draw_card(ctx, games[currentIndex], baseX + offset, baseY, true);
|
||||
}
|
||||
|
||||
draw_text_centered(ctx, font, screenHeight - 78, games[currentIndex].title);
|
||||
draw_page_dots(ctx);
|
||||
}
|
||||
|
||||
const GameEntry* GameCarousel::get_current_game() const
|
||||
{
|
||||
if (games == nullptr || gameCount == 0)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
return &games[currentIndex];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "BitmapFont.h"
|
||||
#include "GameCatalog.h"
|
||||
|
||||
namespace Core
|
||||
{
|
||||
class DrawContext;
|
||||
}
|
||||
|
||||
namespace Platform
|
||||
{
|
||||
class IPointerInput;
|
||||
}
|
||||
|
||||
namespace Desktop
|
||||
{
|
||||
enum class CarouselAction
|
||||
{
|
||||
None,
|
||||
Launch
|
||||
};
|
||||
|
||||
class GameCarousel
|
||||
{
|
||||
private:
|
||||
const GameEntry* games;
|
||||
size_t gameCount;
|
||||
size_t currentIndex;
|
||||
size_t targetIndex;
|
||||
int32_t screenWidth;
|
||||
int32_t screenHeight;
|
||||
int32_t dragStartX;
|
||||
int32_t dragCurrentX;
|
||||
int32_t dragStartY;
|
||||
bool dragging;
|
||||
bool pointerWasDown;
|
||||
int32_t animationFromOffset;
|
||||
int32_t animationToOffset;
|
||||
uint32_t animationElapsedMs;
|
||||
uint32_t animationDurationMs;
|
||||
|
||||
size_t previous_index(size_t index) const;
|
||||
size_t next_index(size_t index) const;
|
||||
int32_t get_card_width() const;
|
||||
int32_t get_card_height() const;
|
||||
int32_t get_card_x() const;
|
||||
int32_t get_card_y() const;
|
||||
int32_t get_current_offset() const;
|
||||
bool is_animating() const;
|
||||
bool is_inside_card(int32_t x, int32_t y) const;
|
||||
void begin_slide(size_t index, int32_t direction);
|
||||
void draw_card(Core::DrawContext& ctx, const GameEntry& game, int32_t x, int32_t y, bool active) const;
|
||||
void draw_text_centered(Core::DrawContext& ctx, const RenderData::BitmapFont& font, int32_t y, const char* text) const;
|
||||
void draw_edge_hints(Core::DrawContext& ctx) const;
|
||||
void draw_page_dots(Core::DrawContext& ctx) const;
|
||||
|
||||
public:
|
||||
GameCarousel();
|
||||
|
||||
void configure(const GameEntry* catalog, size_t catalogCount, int32_t width, int32_t height);
|
||||
CarouselAction update(uint32_t deltaMs, Platform::IPointerInput* pointerInput);
|
||||
void draw(Core::DrawContext& ctx, const RenderData::BitmapFont& font) const;
|
||||
const GameEntry* get_current_game() const;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "GameCatalog.h"
|
||||
|
||||
#include "desktop_atlas.h"
|
||||
|
||||
namespace Desktop
|
||||
{
|
||||
namespace
|
||||
{
|
||||
const GameEntry Games[] = {
|
||||
{ "tom", "TomGame", &DesktopAtlas::TomGame, "IMX6U-Game", true },
|
||||
{ "light", "LightGame", &DesktopAtlas::LightGame, "IMX6U-LightGame", true },
|
||||
{ "demo", "Demo", &DesktopAtlas::Demo, "IMX6U-Demo", true },
|
||||
};
|
||||
}
|
||||
|
||||
const GameEntry* GetGameCatalog()
|
||||
{
|
||||
return Games;
|
||||
}
|
||||
|
||||
size_t GetGameCatalogCount()
|
||||
{
|
||||
return sizeof(Games) / sizeof(Games[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "Sprite.h"
|
||||
|
||||
namespace Desktop
|
||||
{
|
||||
struct GameEntry
|
||||
{
|
||||
const char* id;
|
||||
const char* title;
|
||||
const RenderData::Sprite* cover;
|
||||
const char* executable;
|
||||
bool enabled;
|
||||
};
|
||||
|
||||
const GameEntry* GetGameCatalog();
|
||||
size_t GetGameCatalogCount();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "GameLauncher.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <direct.h>
|
||||
#include <windows.h>
|
||||
#include <process.h>
|
||||
#else
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace Desktop
|
||||
{
|
||||
namespace
|
||||
{
|
||||
static bool HasExeExtension(const std::string& command)
|
||||
{
|
||||
if (command.size() < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const size_t offset = command.size() - 4;
|
||||
return (command[offset] == '.') &&
|
||||
(command[offset + 1] == 'e' || command[offset + 1] == 'E') &&
|
||||
(command[offset + 2] == 'x' || command[offset + 2] == 'X') &&
|
||||
(command[offset + 3] == 'e' || command[offset + 3] == 'E');
|
||||
}
|
||||
|
||||
static std::string BuildExecutableName(const char* executable)
|
||||
{
|
||||
std::string command = executable ? executable : "";
|
||||
#ifdef _WIN32
|
||||
if (!HasExeExtension(command))
|
||||
{
|
||||
command += ".exe";
|
||||
}
|
||||
#endif
|
||||
return command;
|
||||
}
|
||||
|
||||
static std::string GetExecutableDirectory()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
char path[MAX_PATH];
|
||||
const DWORD length = GetModuleFileNameA(nullptr, path, MAX_PATH);
|
||||
if (length == 0 || length >= MAX_PATH)
|
||||
{
|
||||
char cwd[1024];
|
||||
return _getcwd(cwd, sizeof(cwd)) == nullptr ? std::string(".") : std::string(cwd);
|
||||
}
|
||||
std::string fullPath(path, length);
|
||||
const size_t slash = fullPath.find_last_of("\\/");
|
||||
return slash == std::string::npos ? std::string(".") : fullPath.substr(0, slash);
|
||||
#else
|
||||
char path[PATH_MAX];
|
||||
const ssize_t length = readlink("/proc/self/exe", path, sizeof(path) - 1);
|
||||
if (length <= 0)
|
||||
{
|
||||
char cwd[PATH_MAX];
|
||||
return getcwd(cwd, sizeof(cwd)) == nullptr ? std::string(".") : std::string(cwd);
|
||||
}
|
||||
path[length] = '\0';
|
||||
std::string fullPath(path);
|
||||
const size_t slash = fullPath.find_last_of('/');
|
||||
return slash == std::string::npos ? std::string(".") : fullPath.substr(0, slash);
|
||||
#endif
|
||||
}
|
||||
|
||||
static std::string BuildLocalExecutablePath(const std::string& executable)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
return GetExecutableDirectory() + "\\" + executable;
|
||||
#else
|
||||
return GetExecutableDirectory() + "/" + executable;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
bool GameLauncher::launch_and_wait(const GameEntry& game) const
|
||||
{
|
||||
if (!game.enabled || game.executable == nullptr || game.executable[0] == '\0')
|
||||
{
|
||||
std::cerr << "[WARN] Game is not launchable: " << (game.title ? game.title : "(unnamed)") << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string executable = BuildExecutableName(game.executable);
|
||||
const std::string localPath = BuildLocalExecutablePath(executable);
|
||||
|
||||
#ifdef _WIN32
|
||||
intptr_t result = _spawnl(_P_WAIT, localPath.c_str(), executable.c_str(), nullptr);
|
||||
if (result == -1)
|
||||
{
|
||||
result = _spawnlp(_P_WAIT, executable.c_str(), executable.c_str(), nullptr);
|
||||
}
|
||||
if (result == -1)
|
||||
{
|
||||
std::cerr << "[WARN] Failed to launch " << executable << std::endl;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
const pid_t pid = fork();
|
||||
if (pid < 0)
|
||||
{
|
||||
std::cerr << "[WARN] fork failed for " << executable << std::endl;
|
||||
return false;
|
||||
}
|
||||
if (pid == 0)
|
||||
{
|
||||
execl(localPath.c_str(), executable.c_str(), static_cast<char*>(nullptr));
|
||||
execlp(executable.c_str(), executable.c_str(), static_cast<char*>(nullptr));
|
||||
std::exit(127);
|
||||
}
|
||||
|
||||
int status = 0;
|
||||
while (waitpid(pid, &status, 0) < 0)
|
||||
{
|
||||
if (errno != EINTR)
|
||||
{
|
||||
std::cerr << "[WARN] waitpid failed for " << executable << std::endl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include "GameCatalog.h"
|
||||
|
||||
namespace Desktop
|
||||
{
|
||||
class GameLauncher
|
||||
{
|
||||
public:
|
||||
bool launch_and_wait(const GameEntry& game) const;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
#include <SDL.h>
|
||||
#include <SDL_image.h>
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
const char* InputDirectory = "src/Apps/Desktop/assets/raw";
|
||||
const char* OutputHeaderPath = "src/Apps/Desktop/generated/desktop_atlas.h";
|
||||
const int32_t AtlasWidth = 1024;
|
||||
const int32_t AtlasPadding = 1;
|
||||
const int32_t CoverMaxWidth = 860;
|
||||
const int32_t CoverMaxHeight = 420;
|
||||
|
||||
struct SourceSpec
|
||||
{
|
||||
const char* file_name;
|
||||
const char* region_name;
|
||||
bool allow_generated_fallback;
|
||||
};
|
||||
|
||||
const SourceSpec Sources[] = {
|
||||
{ "TomGame.png", "TomGame", false },
|
||||
{ "LightGame.png", "LightGame", false },
|
||||
{ "Demo.png", "Demo", true },
|
||||
};
|
||||
|
||||
struct Image
|
||||
{
|
||||
std::vector<uint16_t> pixels;
|
||||
int32_t width;
|
||||
int32_t height;
|
||||
|
||||
Image() : width(0), height(0) {}
|
||||
|
||||
bool is_valid() const
|
||||
{
|
||||
return width > 0 && height > 0 && !pixels.empty();
|
||||
}
|
||||
};
|
||||
|
||||
struct AtlasRegion
|
||||
{
|
||||
const SourceSpec* source;
|
||||
Image image;
|
||||
int32_t x;
|
||||
int32_t y;
|
||||
|
||||
AtlasRegion() : source(nullptr), x(0), y(0) {}
|
||||
};
|
||||
|
||||
static std::string JoinPath(const std::string& directory, const std::string& fileName)
|
||||
{
|
||||
if (directory.empty())
|
||||
{
|
||||
return fileName;
|
||||
}
|
||||
const char last = directory[directory.size() - 1];
|
||||
if (last == '/' || last == '\\')
|
||||
{
|
||||
return directory + fileName;
|
||||
}
|
||||
return directory + "/" + fileName;
|
||||
}
|
||||
|
||||
static uint16_t PackRgba5551(uint8_t r, uint8_t g, uint8_t b, uint8_t a)
|
||||
{
|
||||
return static_cast<uint16_t>(
|
||||
((static_cast<uint16_t>(r) >> 3) << 11) |
|
||||
((static_cast<uint16_t>(g) >> 3) << 6) |
|
||||
((static_cast<uint16_t>(b) >> 3) << 1) |
|
||||
(a ? 1u : 0u));
|
||||
}
|
||||
|
||||
static Uint32 ReadSurfacePixel(const SDL_Surface* surface, int32_t x, int32_t y)
|
||||
{
|
||||
const uint8_t* row = static_cast<const uint8_t*>(surface->pixels) + static_cast<size_t>(y) * static_cast<size_t>(surface->pitch);
|
||||
const uint8_t* src = row + static_cast<size_t>(x) * static_cast<size_t>(surface->format->BytesPerPixel);
|
||||
|
||||
switch (surface->format->BytesPerPixel)
|
||||
{
|
||||
case 1:
|
||||
return src[0];
|
||||
case 2:
|
||||
{
|
||||
uint16_t pixel = 0;
|
||||
std::memcpy(&pixel, src, sizeof(pixel));
|
||||
return static_cast<Uint32>(pixel);
|
||||
}
|
||||
case 3:
|
||||
if (SDL_BYTEORDER == SDL_BIG_ENDIAN)
|
||||
{
|
||||
return (static_cast<Uint32>(src[0]) << 16) | (static_cast<Uint32>(src[1]) << 8) | static_cast<Uint32>(src[2]);
|
||||
}
|
||||
return static_cast<Uint32>(src[0]) | (static_cast<Uint32>(src[1]) << 8) | (static_cast<Uint32>(src[2]) << 16);
|
||||
case 4:
|
||||
{
|
||||
Uint32 pixel = 0;
|
||||
std::memcpy(&pixel, src, sizeof(pixel));
|
||||
return pixel;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static bool LoadPngImage(const std::string& path, Image& image)
|
||||
{
|
||||
SDL_Surface* surface = IMG_Load(path.c_str());
|
||||
if (surface == nullptr)
|
||||
{
|
||||
std::cerr << "IMG_Load failed: " << path << " : " << IMG_GetError() << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SDL_LockSurface(surface) != 0)
|
||||
{
|
||||
std::cerr << "SDL_LockSurface failed: " << SDL_GetError() << std::endl;
|
||||
SDL_FreeSurface(surface);
|
||||
return false;
|
||||
}
|
||||
|
||||
image.width = surface->w;
|
||||
image.height = surface->h;
|
||||
image.pixels.assign(static_cast<size_t>(image.width) * static_cast<size_t>(image.height), 0);
|
||||
|
||||
for (int32_t y = 0; y < image.height; ++y)
|
||||
{
|
||||
for (int32_t x = 0; x < image.width; ++x)
|
||||
{
|
||||
uint8_t r = 0;
|
||||
uint8_t g = 0;
|
||||
uint8_t b = 0;
|
||||
uint8_t a = 0;
|
||||
SDL_GetRGBA(ReadSurfacePixel(surface, x, y), surface->format, &r, &g, &b, &a);
|
||||
image.pixels[static_cast<size_t>(y) * static_cast<size_t>(image.width) + static_cast<size_t>(x)] =
|
||||
a == 0 ? 0u : PackRgba5551(r, g, b, a);
|
||||
}
|
||||
}
|
||||
|
||||
SDL_UnlockSurface(surface);
|
||||
SDL_FreeSurface(surface);
|
||||
return image.is_valid();
|
||||
}
|
||||
|
||||
static Image ResizeNearest(const Image& source, int32_t width, int32_t height)
|
||||
{
|
||||
Image result;
|
||||
if (!source.is_valid() || width <= 0 || height <= 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result.width = width;
|
||||
result.height = height;
|
||||
result.pixels.assign(static_cast<size_t>(width) * static_cast<size_t>(height), 0);
|
||||
|
||||
for (int32_t y = 0; y < height; ++y)
|
||||
{
|
||||
const int32_t sourceY = y * source.height / height;
|
||||
for (int32_t x = 0; x < width; ++x)
|
||||
{
|
||||
const int32_t sourceX = x * source.width / width;
|
||||
result.pixels[static_cast<size_t>(y) * static_cast<size_t>(width) + static_cast<size_t>(x)] =
|
||||
source.pixels[static_cast<size_t>(sourceY) * static_cast<size_t>(source.width) + static_cast<size_t>(sourceX)];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static Image ResizeToFit(const Image& source, int32_t maxWidth, int32_t maxHeight)
|
||||
{
|
||||
if (!source.is_valid() || maxWidth <= 0 || maxHeight <= 0)
|
||||
{
|
||||
return Image();
|
||||
}
|
||||
|
||||
const float scaleX = static_cast<float>(maxWidth) / static_cast<float>(source.width);
|
||||
const float scaleY = static_cast<float>(maxHeight) / static_cast<float>(source.height);
|
||||
const float scale = std::min(scaleX, scaleY);
|
||||
const int32_t width = std::max(1, static_cast<int32_t>(static_cast<float>(source.width) * scale));
|
||||
const int32_t height = std::max(1, static_cast<int32_t>(static_cast<float>(source.height) * scale));
|
||||
return ResizeNearest(source, width, height);
|
||||
}
|
||||
|
||||
static Image GenerateDemoCover()
|
||||
{
|
||||
Image image;
|
||||
image.width = CoverMaxWidth;
|
||||
image.height = CoverMaxHeight;
|
||||
image.pixels.assign(static_cast<size_t>(image.width) * static_cast<size_t>(image.height), 0u);
|
||||
|
||||
for (int32_t y = 0; y < image.height; ++y)
|
||||
{
|
||||
for (int32_t x = 0; x < image.width; ++x)
|
||||
{
|
||||
const uint8_t r = static_cast<uint8_t>(24 + (x * 48) / image.width);
|
||||
const uint8_t g = static_cast<uint8_t>(36 + (y * 72) / image.height);
|
||||
const uint8_t b = static_cast<uint8_t>(80 + ((x + y) * 64) / (image.width + image.height));
|
||||
image.pixels[static_cast<size_t>(y) * static_cast<size_t>(image.width) + static_cast<size_t>(x)] =
|
||||
PackRgba5551(r, g, b, 255);
|
||||
}
|
||||
}
|
||||
|
||||
const int32_t boxX = image.width / 2 - 170;
|
||||
const int32_t boxY = image.height / 2 - 62;
|
||||
for (int32_t y = 0; y < 124; ++y)
|
||||
{
|
||||
for (int32_t x = 0; x < 340; ++x)
|
||||
{
|
||||
const bool border = x < 6 || y < 6 || x >= 334 || y >= 118;
|
||||
const uint16_t pixel = border ? PackRgba5551(250, 230, 145, 255) : PackRgba5551(18, 24, 42, 255);
|
||||
const int32_t dstX = boxX + x;
|
||||
const int32_t dstY = boxY + y;
|
||||
if (dstX >= 0 && dstX < image.width && dstY >= 0 && dstY < image.height)
|
||||
{
|
||||
image.pixels[static_cast<size_t>(dstY) * static_cast<size_t>(image.width) + static_cast<size_t>(dstX)] = pixel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
static bool LoadAndResizeSource(const SourceSpec& source, AtlasRegion& region)
|
||||
{
|
||||
region.source = &source;
|
||||
Image image;
|
||||
const std::string inputPath = JoinPath(InputDirectory, source.file_name);
|
||||
if (!LoadPngImage(inputPath, image))
|
||||
{
|
||||
if (!source.allow_generated_fallback)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
region.image = GenerateDemoCover();
|
||||
std::cout << "(generated fallback) -> " << source.region_name << " ("
|
||||
<< region.image.width << "x" << region.image.height << ")" << std::endl;
|
||||
return region.image.is_valid();
|
||||
}
|
||||
|
||||
region.image = ResizeToFit(image, CoverMaxWidth, CoverMaxHeight);
|
||||
if (!region.image.is_valid())
|
||||
{
|
||||
std::cerr << "Resize failed: " << inputPath << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::cout << source.file_name << " -> " << source.region_name << " ("
|
||||
<< region.image.width << "x" << region.image.height << ")" << std::endl;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool PackAtlas(std::vector<AtlasRegion>& regions, int32_t& atlasHeight)
|
||||
{
|
||||
int32_t cursorX = 0;
|
||||
int32_t cursorY = 0;
|
||||
int32_t rowHeight = 0;
|
||||
|
||||
for (size_t i = 0; i < regions.size(); ++i)
|
||||
{
|
||||
AtlasRegion& region = regions[i];
|
||||
if (region.image.width > AtlasWidth)
|
||||
{
|
||||
std::cerr << "AtlasWidth is smaller than source image: " << region.source->file_name << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cursorX > 0 && cursorX + region.image.width > AtlasWidth)
|
||||
{
|
||||
cursorY += rowHeight + AtlasPadding;
|
||||
cursorX = 0;
|
||||
rowHeight = 0;
|
||||
}
|
||||
|
||||
region.x = cursorX;
|
||||
region.y = cursorY;
|
||||
cursorX += region.image.width + AtlasPadding;
|
||||
rowHeight = std::max(rowHeight, region.image.height);
|
||||
}
|
||||
|
||||
atlasHeight = cursorY + rowHeight;
|
||||
return atlasHeight > 0;
|
||||
}
|
||||
|
||||
static std::vector<uint16_t> BuildAtlasPixels(const std::vector<AtlasRegion>& regions, int32_t atlasHeight)
|
||||
{
|
||||
std::vector<uint16_t> atlasPixels(static_cast<size_t>(AtlasWidth) * static_cast<size_t>(atlasHeight), 0u);
|
||||
|
||||
for (size_t i = 0; i < regions.size(); ++i)
|
||||
{
|
||||
const AtlasRegion& region = regions[i];
|
||||
for (int32_t y = 0; y < region.image.height; ++y)
|
||||
{
|
||||
const size_t srcOffset = static_cast<size_t>(y) * static_cast<size_t>(region.image.width);
|
||||
const size_t dstOffset = static_cast<size_t>(region.y + y) * static_cast<size_t>(AtlasWidth) + static_cast<size_t>(region.x);
|
||||
std::copy(
|
||||
region.image.pixels.begin() + static_cast<std::vector<uint16_t>::difference_type>(srcOffset),
|
||||
region.image.pixels.begin() + static_cast<std::vector<uint16_t>::difference_type>(srcOffset + static_cast<size_t>(region.image.width)),
|
||||
atlasPixels.begin() + static_cast<std::vector<uint16_t>::difference_type>(dstOffset));
|
||||
}
|
||||
}
|
||||
|
||||
return atlasPixels;
|
||||
}
|
||||
|
||||
static void WritePixel5551(std::ofstream& file, uint16_t pixel)
|
||||
{
|
||||
file << "0x"
|
||||
<< std::uppercase
|
||||
<< std::hex
|
||||
<< std::setw(4)
|
||||
<< std::setfill('0')
|
||||
<< pixel
|
||||
<< std::dec
|
||||
<< std::nouppercase
|
||||
<< std::setfill(' ');
|
||||
}
|
||||
|
||||
static bool WriteAtlasHeader(const std::vector<AtlasRegion>& regions, const std::vector<uint16_t>& atlasPixels, int32_t atlasHeight)
|
||||
{
|
||||
std::ofstream file(OutputHeaderPath, std::ios::binary);
|
||||
if (!file.good())
|
||||
{
|
||||
std::cerr << "Open output header failed: " << OutputHeaderPath << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
file << "// Auto-generated by DesktopAssetTool.cpp\n";
|
||||
file << "#pragma once\n";
|
||||
file << "#include <cstdint>\n";
|
||||
file << "#include \"Image.h\"\n";
|
||||
file << "#include \"Sprite.h\"\n\n";
|
||||
file << "namespace DesktopAtlas\n";
|
||||
file << "{\n";
|
||||
file << "\tstatic const int32_t desktop_atlas_width = " << AtlasWidth << ";\n";
|
||||
file << "\tstatic const int32_t desktop_atlas_height = " << atlasHeight << ";\n\n";
|
||||
file << "\tstatic const uint16_t desktop_atlas_pixels[] = {\n";
|
||||
|
||||
for (size_t i = 0; i < atlasPixels.size(); i += 12)
|
||||
{
|
||||
file << "\t\t";
|
||||
const size_t end = std::min(i + 12, atlasPixels.size());
|
||||
for (size_t j = i; j < end; ++j)
|
||||
{
|
||||
if (j > i)
|
||||
{
|
||||
file << " ";
|
||||
}
|
||||
WritePixel5551(file, atlasPixels[j]);
|
||||
if (j + 1 < atlasPixels.size())
|
||||
{
|
||||
file << ",";
|
||||
}
|
||||
}
|
||||
file << "\n";
|
||||
}
|
||||
|
||||
file << "\t};\n\n";
|
||||
file << "\tstatic const RenderData::Image image(desktop_atlas_pixels, desktop_atlas_width, desktop_atlas_height, 0x0000, RenderData::PixelFormat::RGBA5551);\n\n";
|
||||
|
||||
for (size_t i = 0; i < regions.size(); ++i)
|
||||
{
|
||||
const AtlasRegion& region = regions[i];
|
||||
file << "\tstatic const RenderData::Sprite "
|
||||
<< region.source->region_name
|
||||
<< "(&image, "
|
||||
<< region.x << ", "
|
||||
<< region.y << ", "
|
||||
<< region.image.width << ", "
|
||||
<< region.image.height << ");\n";
|
||||
}
|
||||
|
||||
file << "}\n";
|
||||
return file.good();
|
||||
}
|
||||
|
||||
static bool GenerateDesktopAtlasHeader()
|
||||
{
|
||||
std::vector<AtlasRegion> regions;
|
||||
const size_t sourceCount = sizeof(Sources) / sizeof(Sources[0]);
|
||||
regions.reserve(sourceCount);
|
||||
|
||||
for (size_t i = 0; i < sourceCount; ++i)
|
||||
{
|
||||
AtlasRegion region;
|
||||
if (!LoadAndResizeSource(Sources[i], region))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
regions.push_back(region);
|
||||
}
|
||||
|
||||
int32_t atlasHeight = 0;
|
||||
if (!PackAtlas(regions, atlasHeight))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::vector<uint16_t> atlasPixels = BuildAtlasPixels(regions, atlasHeight);
|
||||
if (!WriteAtlasHeader(regions, atlasPixels, atlasHeight))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::cout << "Generated " << OutputHeaderPath << " (" << AtlasWidth << "x" << atlasHeight << ", "
|
||||
<< regions.size() << " regions)" << std::endl;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
if (argc > 1)
|
||||
{
|
||||
std::cout << "Usage: DesktopAssetTool\n";
|
||||
std::cout << "Generates " << OutputHeaderPath << " from PNG files in " << InputDirectory << "." << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int imageFlags = IMG_INIT_PNG;
|
||||
if ((IMG_Init(imageFlags) & imageFlags) != imageFlags)
|
||||
{
|
||||
std::cerr << "IMG_Init failed: " << IMG_GetError() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bool ok = GenerateDesktopAtlasHeader();
|
||||
IMG_Quit();
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ add_executable(${TOM_GAME_TARGET}
|
||||
src/recognition/KeywordRecognizer.cpp
|
||||
src/recognition/TinyKwsRecognizer.cpp
|
||||
src/recognition/TinyKwsModelData.cpp
|
||||
src/ui/TomSettingsPanel.cpp
|
||||
${TOM_ATLAS_HEADER}
|
||||
)
|
||||
|
||||
@@ -25,6 +26,7 @@ target_include_directories(${TOM_GAME_TARGET} PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/audio
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/gameplay
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/recognition
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/ui
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generated
|
||||
)
|
||||
|
||||
|
||||
+150
-2
@@ -30,14 +30,28 @@ namespace
|
||||
struct ProgramOptions
|
||||
{
|
||||
uint32_t target_fps;
|
||||
uint32_t audio_sample_rate;
|
||||
uint32_t audio_channels;
|
||||
float kws_threshold;
|
||||
float kws_input_gain;
|
||||
std::string audio_input_device;
|
||||
std::string audio_output_device;
|
||||
std::string settings_path;
|
||||
bool show_help;
|
||||
|
||||
ProgramOptions()
|
||||
: target_fps(Core::Timer::DefaultFps),
|
||||
audio_sample_rate(16000),
|
||||
audio_channels(1),
|
||||
kws_threshold(0.75f),
|
||||
kws_input_gain(1.0f),
|
||||
audio_input_device("default"),
|
||||
audio_output_device("default"),
|
||||
#ifdef TARGET_IMX
|
||||
settings_path("/opt/imx6u-game/tom_settings.cfg"),
|
||||
#else
|
||||
settings_path("tom_settings.cfg"),
|
||||
#endif
|
||||
show_help(false)
|
||||
{
|
||||
}
|
||||
@@ -57,7 +71,9 @@ namespace
|
||||
{
|
||||
std::cout
|
||||
<< "Usage: " << program_name << " [--fps 30|45|60] [--kws-threshold 0.75] [--kws-input-gain 1.0]\n"
|
||||
<< " " << program_name << " [--fps=30|45|60] [--kws-threshold=0.75] [--kws-input-gain=1.0]\n";
|
||||
<< " " << program_name << " [--audio-input-device default] [--audio-output-device default]\n"
|
||||
<< " " << program_name << " [--audio-rate 16000] [--audio-channels 1]\n"
|
||||
<< " " << program_name << " [--settings-path tom_settings.cfg]\n";
|
||||
}
|
||||
|
||||
static ProgramOptions ParseProgramOptions(int argc, char *argv[])
|
||||
@@ -68,8 +84,13 @@ namespace
|
||||
{
|
||||
const char *arg = argv[i];
|
||||
const char *fps_value = nullptr;
|
||||
const char *audio_rate_value = nullptr;
|
||||
const char *audio_channels_value = nullptr;
|
||||
const char *kws_threshold_value = nullptr;
|
||||
const char *kws_input_gain_value = nullptr;
|
||||
const char *audio_input_device_value = nullptr;
|
||||
const char *audio_output_device_value = nullptr;
|
||||
const char *settings_path_value = nullptr;
|
||||
|
||||
if (std::strcmp(arg, "--help") == 0 || std::strcmp(arg, "-h") == 0)
|
||||
{
|
||||
@@ -91,6 +112,81 @@ namespace
|
||||
{
|
||||
fps_value = arg + 6;
|
||||
}
|
||||
else if (std::strcmp(arg, "--audio-rate") == 0)
|
||||
{
|
||||
if (i + 1 < argc)
|
||||
{
|
||||
audio_rate_value = argv[++i];
|
||||
}
|
||||
else
|
||||
{
|
||||
std::cerr << "Missing value for --audio-rate, using default 16000.\n";
|
||||
}
|
||||
}
|
||||
else if (std::strncmp(arg, "--audio-rate=", 13) == 0)
|
||||
{
|
||||
audio_rate_value = arg + 13;
|
||||
}
|
||||
else if (std::strcmp(arg, "--audio-channels") == 0)
|
||||
{
|
||||
if (i + 1 < argc)
|
||||
{
|
||||
audio_channels_value = argv[++i];
|
||||
}
|
||||
else
|
||||
{
|
||||
std::cerr << "Missing value for --audio-channels, using default 1.\n";
|
||||
}
|
||||
}
|
||||
else if (std::strncmp(arg, "--audio-channels=", 17) == 0)
|
||||
{
|
||||
audio_channels_value = arg + 17;
|
||||
}
|
||||
else if (std::strcmp(arg, "--audio-input-device") == 0)
|
||||
{
|
||||
if (i + 1 < argc)
|
||||
{
|
||||
audio_input_device_value = argv[++i];
|
||||
}
|
||||
else
|
||||
{
|
||||
std::cerr << "Missing value for --audio-input-device, using default.\n";
|
||||
}
|
||||
}
|
||||
else if (std::strncmp(arg, "--audio-input-device=", 21) == 0)
|
||||
{
|
||||
audio_input_device_value = arg + 21;
|
||||
}
|
||||
else if (std::strcmp(arg, "--audio-output-device") == 0)
|
||||
{
|
||||
if (i + 1 < argc)
|
||||
{
|
||||
audio_output_device_value = argv[++i];
|
||||
}
|
||||
else
|
||||
{
|
||||
std::cerr << "Missing value for --audio-output-device, using default.\n";
|
||||
}
|
||||
}
|
||||
else if (std::strncmp(arg, "--audio-output-device=", 22) == 0)
|
||||
{
|
||||
audio_output_device_value = arg + 22;
|
||||
}
|
||||
else if (std::strcmp(arg, "--settings-path") == 0)
|
||||
{
|
||||
if (i + 1 < argc)
|
||||
{
|
||||
settings_path_value = argv[++i];
|
||||
}
|
||||
else
|
||||
{
|
||||
std::cerr << "Missing value for --settings-path, using default.\n";
|
||||
}
|
||||
}
|
||||
else if (std::strncmp(arg, "--settings-path=", 16) == 0)
|
||||
{
|
||||
settings_path_value = arg + 16;
|
||||
}
|
||||
else if (std::strcmp(arg, "--kws-model") == 0 ||
|
||||
std::strncmp(arg, "--kws-model=", 12) == 0)
|
||||
{
|
||||
@@ -145,6 +241,49 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
if (audio_rate_value != nullptr)
|
||||
{
|
||||
const uint32_t parsed_rate = static_cast<uint32_t>(std::strtoul(audio_rate_value, nullptr, 10));
|
||||
if (parsed_rate > 0u)
|
||||
{
|
||||
options.audio_sample_rate = parsed_rate;
|
||||
}
|
||||
else
|
||||
{
|
||||
std::cerr << "Unsupported audio rate '" << audio_rate_value << "', using default 16000.\n";
|
||||
options.audio_sample_rate = 16000;
|
||||
}
|
||||
}
|
||||
|
||||
if (audio_channels_value != nullptr)
|
||||
{
|
||||
const uint32_t parsed_channels = static_cast<uint32_t>(std::strtoul(audio_channels_value, nullptr, 10));
|
||||
if (parsed_channels > 0u)
|
||||
{
|
||||
options.audio_channels = parsed_channels;
|
||||
}
|
||||
else
|
||||
{
|
||||
std::cerr << "Unsupported audio channels '" << audio_channels_value << "', using default 1.\n";
|
||||
options.audio_channels = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (audio_input_device_value != nullptr && audio_input_device_value[0] != '\0')
|
||||
{
|
||||
options.audio_input_device = audio_input_device_value;
|
||||
}
|
||||
|
||||
if (audio_output_device_value != nullptr && audio_output_device_value[0] != '\0')
|
||||
{
|
||||
options.audio_output_device = audio_output_device_value;
|
||||
}
|
||||
|
||||
if (settings_path_value != nullptr && settings_path_value[0] != '\0')
|
||||
{
|
||||
options.settings_path = settings_path_value;
|
||||
}
|
||||
|
||||
if (kws_threshold_value != nullptr)
|
||||
{
|
||||
char *threshold_end = nullptr;
|
||||
@@ -233,7 +372,10 @@ int main(int argc, char *argv[])
|
||||
&audioOutput,
|
||||
&buttonInput,
|
||||
&pointerInput,
|
||||
&keywordRecognizer);
|
||||
&keywordRecognizer,
|
||||
options.settings_path);
|
||||
app.configure_audio(options.audio_sample_rate, options.audio_channels);
|
||||
app.configure_audio_devices(options.audio_input_device, options.audio_output_device);
|
||||
|
||||
std::cout << "[INFO] Tom game started. Target FPS: " << timer.target_fps() << std::endl;
|
||||
|
||||
@@ -250,6 +392,12 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
|
||||
app.update(timer.fixed_delta_ms());
|
||||
if (app.should_exit())
|
||||
{
|
||||
is_running = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
app.draw(ctx);
|
||||
ctx.present(display);
|
||||
SleepRemainingFrameTime(timer, time_source);
|
||||
|
||||
+70
-10
@@ -114,6 +114,24 @@ cmake -B build-arm-fb \
|
||||
cmake --build build-arm-fb --target IMX6U-Game -j$(nproc)
|
||||
```
|
||||
|
||||
`TARGET_IMX=ON` 必须能找到 ALSA 头文件和 ARMHF 版 `libasound`。在 WSL 中推荐直接安装 ARMHF 开发包:
|
||||
|
||||
```bash
|
||||
sudo dpkg --add-architecture armhf
|
||||
sudo apt update
|
||||
sudo apt install -y libasound2-dev:armhf
|
||||
```
|
||||
|
||||
由于 ATK-IMX6U 的系统较老,不要链接 WSL 自带的新版 ARMHF `libasound.so`。从板子复制匹配的 ALSA 运行库到项目内:
|
||||
|
||||
```text
|
||||
third_party/arm-linux-gnueabihf/alsa/lib/libasound.so
|
||||
third_party/arm-linux-gnueabihf/alsa/lib/libasound.so.2
|
||||
third_party/arm-linux-gnueabihf/alsa/lib/libasound.so.2.0.0
|
||||
```
|
||||
|
||||
CMake 会优先使用这个项目内库目录;ALSA 头文件可继续来自 WSL 的 `libasound2-dev:armhf`。
|
||||
|
||||
不需要再传这些旧参数:
|
||||
|
||||
```bash
|
||||
@@ -148,17 +166,56 @@ kws_ref_model_float32.tflite
|
||||
|
||||
```bash
|
||||
arecord -l
|
||||
arecord -D default -f S16_LE -r 16000 -c 1 -d 2 /tmp/kws_mic_test.wav
|
||||
aplay /tmp/kws_mic_test.wav
|
||||
aplay -l
|
||||
arecord -L
|
||||
aplay -L
|
||||
|
||||
arecord -D sysdefault:CARD=wm8960audio -f S16_LE -r 16000 -c 1 -d 2 /tmp/kws_mic_test.wav
|
||||
aplay -D sysdefault:CARD=wm8960audio /tmp/kws_mic_test.wav
|
||||
```
|
||||
|
||||
当前音频输入使用 ALSA `"default"` 设备。如果 `arecord -D default` 录不到声音,需要先配置 `/etc/asound.conf` 或 `~/.asoundrc`,把 USB 麦克风设为默认输入。
|
||||
ATK-IMX6U 板载 WM8960 通常显示为 `wm8960audio`。如果 `sysdefault:CARD=wm8960audio` 不可用,可尝试硬件设备:
|
||||
|
||||
```bash
|
||||
arecord -D plughw:0,0 -f S16_LE -r 16000 -c 1 -d 2 /tmp/kws_mic_test.wav
|
||||
aplay -D plughw:0,0 /tmp/kws_mic_test.wav
|
||||
```
|
||||
|
||||
程序默认仍使用 ALSA `"default"`,也可以通过启动参数显式指定输入/输出设备,避免依赖 `/etc/asound.conf` 或 `~/.asoundrc`。
|
||||
|
||||
启动游戏:
|
||||
|
||||
```bash
|
||||
cd /opt/imx6u-game
|
||||
./IMX6U-Game --fps 30 --kws-threshold 0.75 --kws-input-gain 1.0
|
||||
cd /home/root/shell/audio
|
||||
./mic_in_config.sh
|
||||
amixer cset numid=41 1
|
||||
|
||||
cd /opt/imx6u-game
|
||||
./IMX6U-Game \
|
||||
--fps 30 \
|
||||
--kws-threshold 0.70 \
|
||||
--kws-input-gain 1.5 \
|
||||
--audio-input-device sysdefault:CARD=wm8960audio \
|
||||
--audio-output-device sysdefault:CARD=wm8960audio
|
||||
```
|
||||
|
||||
其中 `amixer cset numid=41 1` 将 WM8960 的左 ADC 同时输出到左右声道。IMX6ULL-ALPHA 板载 MIC 实测主要接在 Left ADC,如果不设置该项,录音可能只有左声道有效,播放或游戏回放时可能只听到噪声。
|
||||
|
||||
如果 `sysdefault:CARD=wm8960audio` 初始化失败,再换成:
|
||||
|
||||
```bash
|
||||
cd /home/root/shell/audio
|
||||
./mic_in_config.sh
|
||||
amixer cset numid=41 1
|
||||
|
||||
cd /opt/imx6u-game
|
||||
./IMX6U-Game \
|
||||
--fps 30 \
|
||||
--kws-threshold 0.70 \
|
||||
--kws-input-gain 1.5 \
|
||||
--audio-input-device plughw:0,0 \
|
||||
--audio-output-device plughw:0,0
|
||||
```
|
||||
|
||||
进入游戏后:
|
||||
@@ -171,9 +228,12 @@ cd /opt/imx6u-game
|
||||
## 排查顺序
|
||||
|
||||
1. `IMX6U-Game` 能启动并正常刷新画面。
|
||||
2. `arecord -D default` 能录到 16-bit 单声道音频。
|
||||
3. 游戏中已切到左侧 `ui-hand` 语音识别模式。
|
||||
4. 试用较低阈值确认链路,例如 `--kws-threshold 0.70`。
|
||||
5. 如果模型日志长期显示 `class=Unknown`,尝试提高输入增益,例如 `--kws-input-gain 1.5`。
|
||||
6. 如果始终没有识别结果,优先检查麦克风音量、采样率、ALSA 默认设备和录音幅度。
|
||||
7. 如果边界词不稳定,再对齐 C++ MFCC 与 Python/TFLite 前处理细节。
|
||||
2. 程序启动后不再打印 `AlsaAudioInput backend is unavailable`;如果仍打印,说明当前二进制没有编进 ALSA,需要重新交叉编译并部署。
|
||||
3. 运行 `/home/root/shell/audio/mic_in_config.sh`,再执行 `amixer cset numid=41 1`,确保板载 MIC 的 Left ADC 映射到左右声道。
|
||||
4. `arecord -D sysdefault:CARD=wm8960audio` 或 `arecord -D plughw:0,0` 能录到 16-bit 音频,且说话时 `arecord -vv` 音量条有明显波动。
|
||||
5. `aplay -D sysdefault:CARD=wm8960audio` 或 `aplay -D plughw:0,0` 能从扬声器放出录音。
|
||||
6. 游戏中已切到左侧 `ui-hand` 语音识别模式。
|
||||
7. 试用较低阈值确认链路,例如 `--kws-threshold 0.70`。
|
||||
8. 如果模型日志长期显示 `class=Unknown`,尝试提高输入增益,例如 `--kws-input-gain 1.5`。
|
||||
9. 如果仍然没有识别结果,优先检查 WM8960 混音器开关、麦克风增益、采样率、ALSA 设备名和录音幅度。
|
||||
10. 如果边界词不稳定,再对齐 C++ MFCC 与 Python/TFLite 前处理细节。
|
||||
|
||||
@@ -4,9 +4,51 @@
|
||||
#include "DrawContext.h"
|
||||
#include "PointerInput.h"
|
||||
#include "tom_atlas.h"
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
namespace Game
|
||||
{
|
||||
namespace
|
||||
{
|
||||
const int32_t ExitButtonX = 0;
|
||||
const int32_t ExitButtonY = 0;
|
||||
const int32_t ExitButtonTouchPadding = 8;
|
||||
const int32_t SettingsButtonY = 0;
|
||||
const int32_t SettingsButtonTouchPadding = 8;
|
||||
|
||||
static int32_t ClampPercent(int32_t value)
|
||||
{
|
||||
return std::max(0, std::min(value, 100));
|
||||
}
|
||||
|
||||
static int32_t SettingsButtonX(int32_t screenWidth)
|
||||
{
|
||||
return screenWidth - TomAtlas::ui_i.width;
|
||||
}
|
||||
|
||||
static float PercentToRecordGain(int32_t percent)
|
||||
{
|
||||
return 0.5f + (static_cast<float>(ClampPercent(percent)) / 100.0f) * 1.5f;
|
||||
}
|
||||
|
||||
static float PercentToSpeakerGain(int32_t percent)
|
||||
{
|
||||
return (static_cast<float>(ClampPercent(percent)) / 100.0f) * 2.0f;
|
||||
}
|
||||
|
||||
static int32_t GainToRecordPercent(float gain)
|
||||
{
|
||||
return ClampPercent(static_cast<int32_t>(((gain - 0.5f) / 1.5f) * 100.0f + 0.5f));
|
||||
}
|
||||
|
||||
static int32_t GainToSpeakerPercent(float gain)
|
||||
{
|
||||
return ClampPercent(static_cast<int32_t>((gain / 2.0f) * 100.0f + 0.5f));
|
||||
}
|
||||
}
|
||||
|
||||
TomGameApp::TomGameApp(
|
||||
int32_t screenWidth,
|
||||
int32_t screenHeight,
|
||||
@@ -14,19 +56,29 @@ namespace Game
|
||||
Platform::IAudioOutput *audioOutput,
|
||||
Platform::IButtonInput *buttonInput,
|
||||
Platform::IPointerInput *pointerInput,
|
||||
IKeywordRecognizer *keywordRecognizer)
|
||||
IKeywordRecognizer *keywordRecognizer,
|
||||
const std::string& settingsPath)
|
||||
: buttonInput(buttonInput),
|
||||
pointerInput(pointerInput),
|
||||
state(TomGameState::Idle),
|
||||
voiceMode(VoiceMode::PitchRepeat),
|
||||
recordingVoiceMode(VoiceMode::PitchRepeat),
|
||||
voiceController(audioInput, audioOutput, keywordRecognizer),
|
||||
keywordRecognizer(keywordRecognizer),
|
||||
screenWidth(screenWidth),
|
||||
screenHeight(screenHeight)
|
||||
screenHeight(screenHeight),
|
||||
settingsPanel(),
|
||||
settingsPath(settingsPath),
|
||||
exitRequested(false),
|
||||
settingsOpen(false),
|
||||
settingsValues()
|
||||
{
|
||||
tomAnimator.layout(screenWidth, screenHeight);
|
||||
hud.layout(screenWidth, screenHeight);
|
||||
hud.set_active_mode(voiceMode);
|
||||
settingsPanel.layout(screenWidth, screenHeight);
|
||||
load_settings();
|
||||
apply_settings();
|
||||
}
|
||||
|
||||
void TomGameApp::configure_audio(uint32_t sampleRate, uint32_t channels)
|
||||
@@ -34,10 +86,19 @@ namespace Game
|
||||
voiceController.configure_audio(sampleRate, channels);
|
||||
}
|
||||
|
||||
void TomGameApp::configure_audio_devices(const std::string& inputDevice, const std::string& outputDevice)
|
||||
{
|
||||
voiceController.configure_audio_devices(inputDevice, outputDevice);
|
||||
}
|
||||
|
||||
void TomGameApp::update(uint32_t deltaMs)
|
||||
{
|
||||
bool recordTrigger = false;
|
||||
update_input(recordTrigger);
|
||||
if (settingsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state)
|
||||
{
|
||||
@@ -86,12 +147,29 @@ namespace Game
|
||||
ctx.draw_sprite(0, 0, TomAtlas::background);
|
||||
tomAnimator.draw(ctx, state);
|
||||
hud.draw(ctx);
|
||||
ctx.draw_sprite(ExitButtonX, ExitButtonY, TomAtlas::ui_tom);
|
||||
ctx.draw_sprite(SettingsButtonX(screenWidth), SettingsButtonY, TomAtlas::ui_i);
|
||||
|
||||
if (settingsOpen)
|
||||
{
|
||||
settingsPanel.draw(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
void TomGameApp::update_input(bool &recordTrigger)
|
||||
{
|
||||
recordTrigger = false;
|
||||
|
||||
if (settingsOpen)
|
||||
{
|
||||
if (pointerInput != nullptr)
|
||||
{
|
||||
pointerInput->update();
|
||||
update_settings_input();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (buttonInput != nullptr)
|
||||
{
|
||||
buttonInput->update();
|
||||
@@ -101,6 +179,23 @@ namespace Game
|
||||
if (pointerInput != nullptr)
|
||||
{
|
||||
pointerInput->update();
|
||||
if (pointerInput->was_pressed() &&
|
||||
hit_exit_button(pointerInput->get_x(), pointerInput->get_y()))
|
||||
{
|
||||
exitRequested = true;
|
||||
voiceController.stop_all();
|
||||
recordTrigger = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerInput->was_pressed() &&
|
||||
hit_settings_button(pointerInput->get_x(), pointerInput->get_y()))
|
||||
{
|
||||
open_settings();
|
||||
recordTrigger = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const TomHudAction action = hud.handle_pointer(
|
||||
pointerInput->get_x(),
|
||||
pointerInput->get_y(),
|
||||
@@ -111,6 +206,120 @@ namespace Game
|
||||
}
|
||||
}
|
||||
|
||||
bool TomGameApp::hit_exit_button(int32_t pointerX, int32_t pointerY) const
|
||||
{
|
||||
return pointerX >= ExitButtonX &&
|
||||
pointerY >= ExitButtonY &&
|
||||
pointerX < ExitButtonX + TomAtlas::ui_tom.width + ExitButtonTouchPadding &&
|
||||
pointerY < ExitButtonY + TomAtlas::ui_tom.height + ExitButtonTouchPadding;
|
||||
}
|
||||
|
||||
bool TomGameApp::hit_settings_button(int32_t pointerX, int32_t pointerY) const
|
||||
{
|
||||
const int32_t x = SettingsButtonX(screenWidth);
|
||||
return pointerX >= x - SettingsButtonTouchPadding &&
|
||||
pointerY >= SettingsButtonY &&
|
||||
pointerX < screenWidth &&
|
||||
pointerY < SettingsButtonY + TomAtlas::ui_i.height + SettingsButtonTouchPadding;
|
||||
}
|
||||
|
||||
void TomGameApp::open_settings()
|
||||
{
|
||||
settingsOpen = true;
|
||||
settingsPanel.set_values(settingsValues);
|
||||
voiceController.stop_all();
|
||||
set_state(TomGameState::Idle);
|
||||
}
|
||||
|
||||
void TomGameApp::update_settings_input()
|
||||
{
|
||||
if (pointerInput == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int32_t x = pointerInput->get_x();
|
||||
const int32_t y = pointerInput->get_y();
|
||||
const bool pressed = pointerInput->was_pressed();
|
||||
const bool down = pointerInput->is_down();
|
||||
const TomSettingsPanelAction action = settingsPanel.handle_pointer(x, y, down, pressed);
|
||||
if (action == TomSettingsPanelAction::Changed)
|
||||
{
|
||||
settingsValues = settingsPanel.get_values();
|
||||
apply_settings();
|
||||
return;
|
||||
}
|
||||
if (action == TomSettingsPanelAction::Save)
|
||||
{
|
||||
settingsValues = settingsPanel.get_values();
|
||||
apply_settings();
|
||||
save_settings();
|
||||
settingsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
void TomGameApp::apply_settings()
|
||||
{
|
||||
if (keywordRecognizer != nullptr)
|
||||
{
|
||||
keywordRecognizer->set_input_gain(PercentToRecordGain(settingsValues.recordGainPercent));
|
||||
}
|
||||
voiceController.set_input_gain(PercentToRecordGain(settingsValues.recordGainPercent));
|
||||
voiceController.set_output_gain(PercentToSpeakerGain(settingsValues.speakerVolumePercent));
|
||||
}
|
||||
|
||||
void TomGameApp::load_settings()
|
||||
{
|
||||
if (keywordRecognizer != nullptr)
|
||||
{
|
||||
settingsValues.recordGainPercent = GainToRecordPercent(keywordRecognizer->get_input_gain());
|
||||
}
|
||||
else
|
||||
{
|
||||
settingsValues.recordGainPercent = GainToRecordPercent(voiceController.get_input_gain());
|
||||
}
|
||||
settingsValues.speakerVolumePercent = GainToSpeakerPercent(voiceController.get_output_gain());
|
||||
|
||||
std::ifstream file(settingsPath.c_str());
|
||||
if (!file)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
std::string key;
|
||||
char eq;
|
||||
int32_t value;
|
||||
while (file >> key >> eq >> value)
|
||||
{
|
||||
if (eq != '=')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (key == "record_gain")
|
||||
{
|
||||
settingsValues.recordGainPercent = ClampPercent(value);
|
||||
}
|
||||
else if (key == "speaker_volume")
|
||||
{
|
||||
settingsValues.speakerVolumePercent = ClampPercent(value);
|
||||
}
|
||||
}
|
||||
settingsPanel.set_values(settingsValues);
|
||||
}
|
||||
|
||||
void TomGameApp::save_settings() const
|
||||
{
|
||||
std::ofstream file(settingsPath.c_str());
|
||||
if (!file)
|
||||
{
|
||||
std::cerr << "[WARN] Failed to save Tom settings: " << settingsPath << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
file << "record_gain = " << settingsValues.recordGainPercent << "\n";
|
||||
file << "speaker_volume = " << settingsValues.speakerVolumePercent << "\n";
|
||||
}
|
||||
|
||||
void TomGameApp::handle_hud_action(TomHudAction action)
|
||||
{
|
||||
switch (action)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include "../gameplay/KeywordCommandRouter.h"
|
||||
#include "../gameplay/TomAnimator.h"
|
||||
#include "../gameplay/TomGameState.h"
|
||||
#include "../gameplay/TomHud.h"
|
||||
#include "../gameplay/VoiceInteractionController.h"
|
||||
#include "../gameplay/VoiceMode.h"
|
||||
#include "../ui/TomSettingsPanel.h"
|
||||
|
||||
namespace Core
|
||||
{
|
||||
@@ -36,13 +38,26 @@ namespace Game
|
||||
VoiceMode recordingVoiceMode;
|
||||
VoiceInteractionController voiceController;
|
||||
KeywordCommandRouter keywordCommandRouter;
|
||||
IKeywordRecognizer* keywordRecognizer;
|
||||
TomAnimator tomAnimator;
|
||||
TomHud hud;
|
||||
|
||||
int32_t screenWidth;
|
||||
int32_t screenHeight;
|
||||
TomSettingsPanel settingsPanel;
|
||||
std::string settingsPath;
|
||||
bool exitRequested;
|
||||
bool settingsOpen;
|
||||
TomSettingsValues settingsValues;
|
||||
|
||||
void update_input(bool& recordTrigger);
|
||||
bool hit_exit_button(int32_t pointerX, int32_t pointerY) const;
|
||||
bool hit_settings_button(int32_t pointerX, int32_t pointerY) const;
|
||||
void open_settings();
|
||||
void update_settings_input();
|
||||
void apply_settings();
|
||||
void load_settings();
|
||||
void save_settings() const;
|
||||
void handle_hud_action(TomHudAction action);
|
||||
void handle_voice_result(const VoiceInteractionResult& result);
|
||||
void handle_keyword_action(KeywordGameAction action);
|
||||
@@ -60,12 +75,15 @@ namespace Game
|
||||
Platform::IAudioOutput* audioOutput,
|
||||
Platform::IButtonInput* buttonInput,
|
||||
Platform::IPointerInput* pointerInput,
|
||||
IKeywordRecognizer* keywordRecognizer);
|
||||
IKeywordRecognizer* keywordRecognizer,
|
||||
const std::string& settingsPath);
|
||||
|
||||
void configure_audio(uint32_t sampleRate = 16000, uint32_t channels = 1);
|
||||
void configure_audio_devices(const std::string& inputDevice, const std::string& outputDevice);
|
||||
void update(uint32_t deltaMs);
|
||||
void draw(Core::DrawContext& ctx);
|
||||
|
||||
bool should_exit() const { return exitRequested; }
|
||||
TomGameState get_state() const { return state; }
|
||||
float get_record_volume() const { return voiceController.get_record_volume(); }
|
||||
float get_play_progress() const { return voiceController.get_play_progress(); }
|
||||
|
||||
@@ -45,7 +45,10 @@ namespace Game
|
||||
maxRecordMs(DefaultMaxRecordMs),
|
||||
recordingElapsedMs(0),
|
||||
pitchFactor(1.45f),
|
||||
outputGain(1.15f)
|
||||
inputGain(1.0f),
|
||||
outputGain(1.15f),
|
||||
audioInputDevice("default"),
|
||||
audioOutputDevice("default")
|
||||
{
|
||||
configure_audio(audioSampleRate, audioChannels);
|
||||
|
||||
@@ -79,6 +82,18 @@ namespace Game
|
||||
0.0f);
|
||||
}
|
||||
|
||||
void VoiceInteractionController::configure_audio_devices(const std::string& inputDevice, const std::string& outputDevice)
|
||||
{
|
||||
if (!inputDevice.empty())
|
||||
{
|
||||
audioInputDevice = inputDevice;
|
||||
}
|
||||
if (!outputDevice.empty())
|
||||
{
|
||||
audioOutputDevice = outputDevice;
|
||||
}
|
||||
}
|
||||
|
||||
VoiceInteractionResult VoiceInteractionController::start_recording()
|
||||
{
|
||||
if (audioInput == nullptr)
|
||||
@@ -90,9 +105,15 @@ namespace Game
|
||||
stop_output();
|
||||
|
||||
if (!audioInput->is_open() &&
|
||||
!audioInput->init("default", audioSampleRate, audioChannels))
|
||||
!audioInput->init(audioInputDevice, audioSampleRate, audioChannels))
|
||||
{
|
||||
std::cerr << "[WARN] Audio input init failed." << std::endl;
|
||||
std::cerr << "[WARN] Audio input init failed: device="
|
||||
<< audioInputDevice
|
||||
<< ", rate="
|
||||
<< audioSampleRate
|
||||
<< ", channels="
|
||||
<< audioChannels
|
||||
<< std::endl;
|
||||
return VoiceInteractionResult();
|
||||
}
|
||||
|
||||
@@ -220,9 +241,15 @@ namespace Game
|
||||
}
|
||||
|
||||
if (!audioOutput->is_open() &&
|
||||
!audioOutput->init("default", audioSampleRate, audioChannels))
|
||||
!audioOutput->init(audioOutputDevice, audioSampleRate, audioChannels))
|
||||
{
|
||||
std::cerr << "[WARN] Audio output init failed." << std::endl;
|
||||
std::cerr << "[WARN] Audio output init failed: device="
|
||||
<< audioOutputDevice
|
||||
<< ", rate="
|
||||
<< audioSampleRate
|
||||
<< ", channels="
|
||||
<< audioChannels
|
||||
<< std::endl;
|
||||
return VoiceInteractionResult(VoiceInteractionEvent::IdleRequested);
|
||||
}
|
||||
|
||||
@@ -274,8 +301,41 @@ namespace Game
|
||||
|
||||
VoiceInteractionResult VoiceInteractionController::repeat_last_recording()
|
||||
{
|
||||
std::vector<int16_t> samples = recorder.get_samples();
|
||||
samples = VoiceEffect::trim_silence(samples, 0.02f, audioChannels);
|
||||
const std::vector<int16_t>& recordedSamples = recorder.get_samples();
|
||||
std::cout
|
||||
<< "[INFO] Recording finished: samples="
|
||||
<< recordedSamples.size()
|
||||
<< ", duration="
|
||||
<< recorder.get_duration_seconds()
|
||||
<< "s, last_volume="
|
||||
<< recorder.get_last_volume()
|
||||
<< std::endl;
|
||||
|
||||
std::vector<int16_t> samples = VoiceEffect::amplify(recordedSamples, inputGain);
|
||||
const std::vector<int16_t> trimmedSamples = VoiceEffect::trim_silence(samples, 0.02f, audioChannels);
|
||||
const size_t minPlaybackSamples = static_cast<size_t>(audioSampleRate * audioChannels / 4u);
|
||||
if (!samples.empty() && trimmedSamples.size() < minPlaybackSamples)
|
||||
{
|
||||
std::cerr
|
||||
<< "[WARN] Trimmed recording is too short ("
|
||||
<< trimmedSamples.size()
|
||||
<< " samples); playing untrimmed samples for diagnostics."
|
||||
<< std::endl;
|
||||
}
|
||||
else
|
||||
{
|
||||
samples = trimmedSamples;
|
||||
}
|
||||
|
||||
std::cout
|
||||
<< "[INFO] Playback prepared: samples="
|
||||
<< samples.size()
|
||||
<< ", rate="
|
||||
<< audioSampleRate
|
||||
<< ", channels="
|
||||
<< audioChannels
|
||||
<< std::endl;
|
||||
|
||||
samples = VoiceEffect::pitch_up(samples, pitchFactor, audioChannels);
|
||||
samples = VoiceEffect::amplify(samples, outputGain);
|
||||
return start_speaking(samples);
|
||||
@@ -293,6 +353,16 @@ namespace Game
|
||||
stop_output();
|
||||
}
|
||||
|
||||
void VoiceInteractionController::set_output_gain(float gain)
|
||||
{
|
||||
outputGain = std::max(0.0f, std::min(gain, 2.0f));
|
||||
}
|
||||
|
||||
void VoiceInteractionController::set_input_gain(float gain)
|
||||
{
|
||||
inputGain = std::max(0.5f, std::min(gain, 2.0f));
|
||||
}
|
||||
|
||||
void VoiceInteractionController::stop_input()
|
||||
{
|
||||
if (audioInput != nullptr && audioInput->is_open())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "../audio/VoicePlayer.h"
|
||||
#include "../audio/VoiceRecorder.h"
|
||||
@@ -50,7 +51,10 @@ namespace Game
|
||||
uint32_t maxRecordMs;
|
||||
uint32_t recordingElapsedMs;
|
||||
float pitchFactor;
|
||||
float inputGain;
|
||||
float outputGain;
|
||||
std::string audioInputDevice;
|
||||
std::string audioOutputDevice;
|
||||
|
||||
void stop_input();
|
||||
void stop_output();
|
||||
@@ -66,14 +70,19 @@ namespace Game
|
||||
IKeywordRecognizer* keywordRecognizer);
|
||||
|
||||
void configure_audio(uint32_t sampleRate = 16000, uint32_t channels = 1);
|
||||
void configure_audio_devices(const std::string& inputDevice, const std::string& outputDevice);
|
||||
VoiceInteractionResult start_recording();
|
||||
VoiceInteractionResult update_pitch_repeat_recording(uint32_t deltaMs);
|
||||
VoiceInteractionResult update_keyword_recording(uint32_t deltaMs);
|
||||
VoiceInteractionResult update_speaking(uint32_t deltaMs);
|
||||
VoiceInteractionResult repeat_last_recording();
|
||||
void stop_all();
|
||||
void set_input_gain(float gain);
|
||||
void set_output_gain(float gain);
|
||||
|
||||
float get_record_volume() const { return recorder.get_last_volume(); }
|
||||
float get_play_progress() const { return player.get_progress(); }
|
||||
float get_input_gain() const { return inputGain; }
|
||||
float get_output_gain() const { return outputGain; }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ namespace Game
|
||||
const std::vector<int16_t>& samples,
|
||||
uint32_t sampleRate,
|
||||
uint32_t channels) = 0;
|
||||
virtual void set_input_gain(float gain) { (void)gain; }
|
||||
virtual float get_input_gain() const { return 1.0f; }
|
||||
};
|
||||
|
||||
class NullKeywordRecognizer : public IKeywordRecognizer
|
||||
|
||||
@@ -28,8 +28,8 @@ namespace Game
|
||||
|
||||
void set_confidence_threshold(float threshold);
|
||||
float get_confidence_threshold() const { return confidenceThreshold; }
|
||||
void set_input_gain(float gain);
|
||||
float get_input_gain() const { return inputGain; }
|
||||
void set_input_gain(float gain) override;
|
||||
float get_input_gain() const override { return inputGain; }
|
||||
bool is_initialized() const { return initialized; }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
#include "TomSettingsPanel.h"
|
||||
#include "Color.h"
|
||||
#include "DrawContext.h"
|
||||
#include "font_atlas.h"
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Game
|
||||
{
|
||||
namespace
|
||||
{
|
||||
const int32_t PanelW = 600;
|
||||
const int32_t PanelH = 360;
|
||||
const int32_t SliderX = 330;
|
||||
const int32_t SliderW = 210;
|
||||
const int32_t SliderH = 10;
|
||||
const int32_t RecordSliderY = 128;
|
||||
const int32_t SpeakerSliderY = 208;
|
||||
const int32_t SliderTouchPadding = 22;
|
||||
const int32_t SaveButtonW = 160;
|
||||
const int32_t SaveButtonH = 52;
|
||||
|
||||
static int32_t ClampPercent(int32_t value)
|
||||
{
|
||||
return std::max(0, std::min(value, 100));
|
||||
}
|
||||
}
|
||||
|
||||
TomSlider::TomSlider()
|
||||
: label(""),
|
||||
x(0),
|
||||
y(0),
|
||||
width(0),
|
||||
height(0),
|
||||
touchPadding(0),
|
||||
value(0),
|
||||
dragging(false)
|
||||
{
|
||||
}
|
||||
|
||||
void TomSlider::configure(const char* label, int32_t x, int32_t y, int32_t width, int32_t height, int32_t touchPadding)
|
||||
{
|
||||
this->label = label != nullptr ? label : "";
|
||||
this->x = x;
|
||||
this->y = y;
|
||||
this->width = width;
|
||||
this->height = height;
|
||||
this->touchPadding = touchPadding;
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
void TomSlider::set_value(int32_t value)
|
||||
{
|
||||
this->value = ClampPercent(value);
|
||||
}
|
||||
|
||||
bool TomSlider::hit_test(int32_t pointerX, int32_t pointerY) const
|
||||
{
|
||||
return pointerX >= x - touchPadding &&
|
||||
pointerX < x + width + touchPadding &&
|
||||
pointerY >= y - touchPadding &&
|
||||
pointerY < y + height + touchPadding;
|
||||
}
|
||||
|
||||
bool TomSlider::handle_pointer(int32_t pointerX, int32_t pointerY, bool pointerDown, bool pointerPressed)
|
||||
{
|
||||
if (pointerPressed && hit_test(pointerX, pointerY))
|
||||
{
|
||||
dragging = true;
|
||||
}
|
||||
|
||||
if (!pointerDown)
|
||||
{
|
||||
dragging = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!dragging)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32_t oldValue = value;
|
||||
const int32_t relativeX = std::max(0, std::min(pointerX - x, width));
|
||||
value = ClampPercent((relativeX * 100 + width / 2) / width);
|
||||
return value != oldValue;
|
||||
}
|
||||
|
||||
void TomSlider::draw(Core::DrawContext& ctx, const RenderData::BitmapFont& font) const
|
||||
{
|
||||
const int32_t fillW = (width * value) / 100;
|
||||
const int32_t knobX = x + fillW - 7;
|
||||
const int32_t knobDrawX = std::max(x - 2, std::min(knobX, x + width - 12));
|
||||
char text[8];
|
||||
std::snprintf(text, sizeof(text), "%03d", ClampPercent(value));
|
||||
|
||||
ctx.draw_text(font, x - 242, y - 10, RenderData::Color::White(), label);
|
||||
ctx.fill_rect(x, y, width, height, RenderData::Color(88, 82, 42, 255));
|
||||
ctx.fill_rect(x, y, fillW, height, RenderData::Color(250, 205, 60, 255));
|
||||
ctx.fill_rect(knobDrawX, y - 9, 14, height + 18, RenderData::Color(255, 230, 100, 255));
|
||||
ctx.draw_text(font, x + width + 24, y - 14, RenderData::Color::White(), text);
|
||||
}
|
||||
|
||||
TomSettingsPanel::TomSettingsPanel()
|
||||
: screenWidth(0),
|
||||
screenHeight(0),
|
||||
panelX(0),
|
||||
panelY(0),
|
||||
recordGainSlider(),
|
||||
speakerVolumeSlider()
|
||||
{
|
||||
}
|
||||
|
||||
void TomSettingsPanel::layout(int32_t screenWidth, int32_t screenHeight)
|
||||
{
|
||||
this->screenWidth = screenWidth;
|
||||
this->screenHeight = screenHeight;
|
||||
panelX = (screenWidth - PanelW) / 2;
|
||||
panelY = (screenHeight - PanelH) / 2;
|
||||
|
||||
recordGainSlider.configure(
|
||||
"MIC GAIN",
|
||||
panelX + SliderX,
|
||||
panelY + RecordSliderY,
|
||||
SliderW,
|
||||
SliderH,
|
||||
SliderTouchPadding);
|
||||
speakerVolumeSlider.configure(
|
||||
"SPEAKER",
|
||||
panelX + SliderX,
|
||||
panelY + SpeakerSliderY,
|
||||
SliderW,
|
||||
SliderH,
|
||||
SliderTouchPadding);
|
||||
}
|
||||
|
||||
void TomSettingsPanel::set_values(const TomSettingsValues& values)
|
||||
{
|
||||
recordGainSlider.set_value(values.recordGainPercent);
|
||||
speakerVolumeSlider.set_value(values.speakerVolumePercent);
|
||||
}
|
||||
|
||||
TomSettingsValues TomSettingsPanel::get_values() const
|
||||
{
|
||||
TomSettingsValues values;
|
||||
values.recordGainPercent = recordGainSlider.get_value();
|
||||
values.speakerVolumePercent = speakerVolumeSlider.get_value();
|
||||
return values;
|
||||
}
|
||||
|
||||
TomSettingsPanelAction TomSettingsPanel::handle_pointer(
|
||||
int32_t pointerX,
|
||||
int32_t pointerY,
|
||||
bool pointerDown,
|
||||
bool pointerPressed)
|
||||
{
|
||||
if (pointerPressed && hit_save_button(pointerX, pointerY))
|
||||
{
|
||||
return TomSettingsPanelAction::Save;
|
||||
}
|
||||
|
||||
bool changed = recordGainSlider.handle_pointer(pointerX, pointerY, pointerDown, pointerPressed);
|
||||
changed = speakerVolumeSlider.handle_pointer(pointerX, pointerY, pointerDown, pointerPressed) || changed;
|
||||
return changed ? TomSettingsPanelAction::Changed : TomSettingsPanelAction::None;
|
||||
}
|
||||
|
||||
void TomSettingsPanel::draw(Core::DrawContext& ctx) const
|
||||
{
|
||||
const RenderData::BitmapFont font = create_font();
|
||||
const int32_t saveX = save_button_x();
|
||||
const int32_t saveY = save_button_y();
|
||||
|
||||
ctx.fill_rect_alpha(0, 0, screenWidth, screenHeight, RenderData::Color(0, 0, 0, 180));
|
||||
ctx.fill_rect(panelX, panelY, PanelW, PanelH, RenderData::Color(38, 40, 44, 255));
|
||||
ctx.fill_rect(panelX, panelY, PanelW, 4, RenderData::Color(250, 205, 60, 255));
|
||||
ctx.fill_rect(panelX, panelY + PanelH - 4, PanelW, 4, RenderData::Color(250, 205, 60, 255));
|
||||
ctx.fill_rect(panelX, panelY, 4, PanelH, RenderData::Color(250, 205, 60, 255));
|
||||
ctx.fill_rect(panelX + PanelW - 4, panelY, 4, PanelH, RenderData::Color(250, 205, 60, 255));
|
||||
|
||||
ctx.draw_text(font, panelX + 202, panelY + 34, RenderData::Color::White(), "SETTINGS");
|
||||
recordGainSlider.draw(ctx, font);
|
||||
speakerVolumeSlider.draw(ctx, font);
|
||||
|
||||
ctx.fill_rect(saveX, saveY, SaveButtonW, SaveButtonH, RenderData::Color(250, 205, 60, 255));
|
||||
ctx.fill_rect(saveX + 4, saveY + 4, SaveButtonW - 8, SaveButtonH - 8, RenderData::Color(48, 48, 42, 255));
|
||||
ctx.draw_text(font, saveX + 52, saveY + 17, RenderData::Color::White(), "SAVE");
|
||||
}
|
||||
|
||||
int32_t TomSettingsPanel::save_button_x() const
|
||||
{
|
||||
return (screenWidth - SaveButtonW) / 2;
|
||||
}
|
||||
|
||||
int32_t TomSettingsPanel::save_button_y() const
|
||||
{
|
||||
return panelY + PanelH - 76;
|
||||
}
|
||||
|
||||
bool TomSettingsPanel::hit_save_button(int32_t pointerX, int32_t pointerY) const
|
||||
{
|
||||
const int32_t x = save_button_x();
|
||||
const int32_t y = save_button_y();
|
||||
return pointerX >= x &&
|
||||
pointerX < x + SaveButtonW &&
|
||||
pointerY >= y &&
|
||||
pointerY < y + SaveButtonH;
|
||||
}
|
||||
|
||||
RenderData::BitmapFont TomSettingsPanel::create_font() const
|
||||
{
|
||||
RenderData::BitmapFont font;
|
||||
font.mask_bits = font_atlas_mask;
|
||||
font.atlas_width = font_atlas_width;
|
||||
font.atlas_height = font_atlas_height;
|
||||
font.char_w = font_char_w;
|
||||
font.char_h = font_char_h;
|
||||
font.columns = font_columns;
|
||||
font.first_char = font_first_char;
|
||||
return font;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include "BitmapFont.h"
|
||||
|
||||
namespace Core
|
||||
{
|
||||
class DrawContext;
|
||||
}
|
||||
|
||||
namespace Game
|
||||
{
|
||||
struct TomSettingsValues
|
||||
{
|
||||
int32_t recordGainPercent;
|
||||
int32_t speakerVolumePercent;
|
||||
|
||||
TomSettingsValues()
|
||||
: recordGainPercent(33),
|
||||
speakerVolumePercent(58)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
enum class TomSettingsPanelAction
|
||||
{
|
||||
None,
|
||||
Changed,
|
||||
Save
|
||||
};
|
||||
|
||||
class TomSlider
|
||||
{
|
||||
private:
|
||||
const char* label;
|
||||
int32_t x;
|
||||
int32_t y;
|
||||
int32_t width;
|
||||
int32_t height;
|
||||
int32_t touchPadding;
|
||||
int32_t value;
|
||||
bool dragging;
|
||||
|
||||
public:
|
||||
TomSlider();
|
||||
|
||||
void configure(const char* label, int32_t x, int32_t y, int32_t width, int32_t height, int32_t touchPadding);
|
||||
void set_value(int32_t value);
|
||||
int32_t get_value() const { return value; }
|
||||
bool is_dragging() const { return dragging; }
|
||||
bool hit_test(int32_t pointerX, int32_t pointerY) const;
|
||||
bool handle_pointer(int32_t pointerX, int32_t pointerY, bool pointerDown, bool pointerPressed);
|
||||
void draw(Core::DrawContext& ctx, const RenderData::BitmapFont& font) const;
|
||||
};
|
||||
|
||||
class TomSettingsPanel
|
||||
{
|
||||
private:
|
||||
int32_t screenWidth;
|
||||
int32_t screenHeight;
|
||||
int32_t panelX;
|
||||
int32_t panelY;
|
||||
TomSlider recordGainSlider;
|
||||
TomSlider speakerVolumeSlider;
|
||||
|
||||
int32_t save_button_x() const;
|
||||
int32_t save_button_y() const;
|
||||
bool hit_save_button(int32_t pointerX, int32_t pointerY) const;
|
||||
RenderData::BitmapFont create_font() const;
|
||||
|
||||
public:
|
||||
TomSettingsPanel();
|
||||
|
||||
void layout(int32_t screenWidth, int32_t screenHeight);
|
||||
void set_values(const TomSettingsValues& values);
|
||||
TomSettingsValues get_values() const;
|
||||
TomSettingsPanelAction handle_pointer(
|
||||
int32_t pointerX,
|
||||
int32_t pointerY,
|
||||
bool pointerDown,
|
||||
bool pointerPressed);
|
||||
void draw(Core::DrawContext& ctx) const;
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,19 @@ namespace Core
|
||||
const uint16_t g6 = static_cast<uint16_t>((g << 1) | (g >> 4));
|
||||
return static_cast<uint16_t>((r << 11) | (g6 << 5) | b);
|
||||
}
|
||||
|
||||
static uint16_t blend_rgb565(uint16_t dst, const RenderData::Color& src)
|
||||
{
|
||||
const uint32_t alpha = src.a;
|
||||
const uint32_t invAlpha = 255u - alpha;
|
||||
const uint32_t dstR = ((dst >> 11) & 0x1Fu) * 255u / 31u;
|
||||
const uint32_t dstG = ((dst >> 5) & 0x3Fu) * 255u / 63u;
|
||||
const uint32_t dstB = (dst & 0x1Fu) * 255u / 31u;
|
||||
const uint32_t r = (static_cast<uint32_t>(src.r) * alpha + dstR * invAlpha) / 255u;
|
||||
const uint32_t g = (static_cast<uint32_t>(src.g) * alpha + dstG * invAlpha) / 255u;
|
||||
const uint32_t b = (static_cast<uint32_t>(src.b) * alpha + dstB * invAlpha) / 255u;
|
||||
return static_cast<uint16_t>(((r & 0xF8u) << 8) | ((g & 0xFCu) << 3) | (b >> 3));
|
||||
}
|
||||
}
|
||||
|
||||
DrawContext::DrawContext(int32_t width, int32_t height)
|
||||
@@ -336,6 +349,39 @@ namespace Core
|
||||
}
|
||||
}
|
||||
|
||||
void DrawContext::fill_rect_alpha(int32_t x, int32_t y, int32_t w, int32_t h, const RenderData::Color& color)
|
||||
{
|
||||
if (color.a == 255u)
|
||||
{
|
||||
fill_rect(x, y, w, h, color);
|
||||
return;
|
||||
}
|
||||
if (color.a == 0u)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int32_t fb_w = frameBuffer->get_width();
|
||||
const int32_t fb_h = frameBuffer->get_height();
|
||||
|
||||
int32_t x0 = std::max(0, x);
|
||||
int32_t y0 = std::max(0, y);
|
||||
int32_t x1 = std::min(fb_w, x + w);
|
||||
int32_t y1 = std::min(fb_h, y + h);
|
||||
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
|
||||
Core::FramePixel* buf = static_cast<Core::FramePixel*>(frameBuffer->get_buffer());
|
||||
for (int32_t row = y0; row < y1; ++row)
|
||||
{
|
||||
Core::FramePixel* dst = buf + row * fb_w + x0;
|
||||
for (int32_t i = 0; i < x1 - x0; ++i)
|
||||
{
|
||||
dst[i] = blend_rgb565(dst[i], color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DrawContext::draw_font_mask_region(const RenderData::BitmapFont& font,
|
||||
int32_t src_x, int32_t src_y,
|
||||
int32_t src_w, int32_t src_h,
|
||||
|
||||
@@ -81,6 +81,7 @@ namespace Core
|
||||
int32_t shade_numerator, int32_t shade_shift);
|
||||
|
||||
void fill_rect(int32_t x, int32_t y, int32_t w, int32_t h, const RenderData::Color& color);
|
||||
void fill_rect_alpha(int32_t x, int32_t y, int32_t w, int32_t h, const RenderData::Color& color);
|
||||
void draw_text(const RenderData::BitmapFont& font, int32_t x, int32_t y,
|
||||
const RenderData::Color& color, const char* text);
|
||||
void draw_text(const RenderData::BitmapFont& font, int32_t x, int32_t y,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user