Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
11 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/host-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ jobs:
cmake --build build-san
ctest --test-dir build-san --output-on-failure

# --- avatar: balloon scroll layout (pure functions, header only) ------
- name: avatar host tests
working-directory: components/avatar/test/host
run: |
set -euo pipefail
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build --output-on-failure

# --- flash_layout: ADR-001 exttab / bootctl format --------------------
# format.c is the exact source the custom bootloader compiles, so the
# validation / A-B selection / boot-decision logic is proven here.
Expand Down Expand Up @@ -119,3 +128,14 @@ jobs:
cmake --build build
./build/jtts_test_vowels
./build/jtts_test_sano_ir
# sanoTTS clause-by-clause synthesis (clause split / edge-silence trim /
# inter-clause pause). The 1-clause synth is stubbed, so no weights needed.
./build/jtts_test_clause_stream
# Vowel lip-sync events, long-text chunking, streaming synthesis and
# subtitle mapping. Without arguments only the formant / pure-logic
# cases run; with .htsvoice files the HMM cases run too (16 kHz voice
# = native rate, fast).
./build/jtts_test_visemes
./build/jtts_test_hmm_chunk
./build/jtts_test_visemes ../../../../assets/voices/mei16.htsvoice
./build/jtts_test_hmm_chunk ../../../../assets/voices/mei16.htsvoice
12 changes: 8 additions & 4 deletions assets/default_face.avdsl
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@
-- A `fn draw()` is mandatory (entry point, fn id 0). Helper functions follow.

----------------------------------------------------------------------
-- Mouth: single rectangle whose width shrinks and height grows with
-- mouth_open. The group box covers the maximum extent so the direct
-- canvas strategy clears stale frames correctly.
-- Mouth: single rectangle whose height grows with mouth_open and whose width
-- shrinks with mouth_form (0 = wide, 1 = narrow). Hosts that only drive
-- mouth_open (level meters) get mouth_form == mouth_open, i.e. the classic
-- "narrower as it opens" look; TTS vowel lip-sync sets the two independently
-- (い = wide + slightly open, う = narrow, あ = wide-ish + fully open).
-- The group box covers the maximum extent so the direct canvas strategy
-- clears stale frames correctly.
----------------------------------------------------------------------
fn mouth(cx, cy, min_w, max_w, min_h, max_h, bo)
let h = min_h + (max_h - min_h) * mouth_open
let w = min_w + (max_w - min_w) * (1 - mouth_open)
let w = min_w + (max_w - min_w) * (1 - mouth_form)
let x = cx - w / 2
let y = cy - h / 2 + breath * 2 + bo
let gx = cx - max_w / 2 - 1
Expand Down
10 changes: 10 additions & 0 deletions components/avatar/avatar.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ void Avatar::set_mouth_open(float ratio) noexcept
impl_->context().mouth_open_ratio = ratio;
}

void Avatar::set_mouth_form(float ratio) noexcept
{
if (ratio > 1.0f) {
ratio = 1.0f;
} else if (ratio < 0.0f) {
ratio = -1.0f;
}
impl_->context().mouth_form_ratio = ratio;
}

void Avatar::set_gaze(float horizontal, float vertical) noexcept
{
impl_->context().gaze_horizontal = horizontal;
Expand Down
50 changes: 6 additions & 44 deletions components/avatar/balloon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

#include "balloon.hpp"

#include <algorithm>
#include "balloon_layout.hpp"

namespace stackchan::avatar::internal {

Expand All @@ -21,16 +21,6 @@ constexpr std::int16_t kSmallPanelHeightThreshold = 160;
constexpr std::int16_t kBigPanelH = 40; // for 24-px font
constexpr std::int16_t kSmallPanelH = 22; // for 12-px font

// Marquee tuning.
constexpr std::int32_t kScrollSpeedPxPerSec = 60;
// Gap (px) of "empty space" between the trailing edge of one pass and the
// leading edge of the next so the user perceives the message restarting.
constexpr std::int32_t kRepeatGapPx = 60;

// Default minimum display time for short (non-scrolling) text. The application
// can override with `Avatar::set_balloon_text(text, hold_ms)`.
constexpr std::uint32_t kDefaultStaticHoldMs = 3000;

} // namespace

void draw_balloon(RichCanvas& canvas, DrawContext& ctx)
Expand Down Expand Up @@ -71,42 +61,14 @@ void draw_balloon(RichCanvas& canvas, DrawContext& ctx)
const std::int32_t mid_y = panel_y + panel_h / 2;
const std::uint32_t elapsed_ms = ctx.now_ms - ctx.balloon_set_ms;

if (text_w <= inner_w) {
// Text fits — static centered. Mark done after the configured hold.
canvas.setTextDatum(lgfx::textdatum_t::middle_center);
canvas.drawString(text.c_str(), panel_x + panel_w / 2, mid_y);

const std::uint32_t hold_ms =
std::max(ctx.balloon_hold_ms, kDefaultStaticHoldMs);
if (elapsed_ms >= hold_ms) {
ctx.balloon_done = true;
}
canvas.end_group();
return;
}

// Marquee: text starts just past the right inner edge and scrolls left.
// A single "pass" travels `text_w + inner_w` pixels (entry + traverse +
// exit). One full cycle adds `kRepeatGapPx` so the message restarts with
// a perceivable gap.
const std::int32_t one_pass_px = text_w + inner_w;
const std::int32_t cycle_px = one_pass_px + kRepeatGapPx;
const std::int32_t offset_in_cycle =
static_cast<std::int32_t>(elapsed_ms) * kScrollSpeedPxPerSec / 1000 % cycle_px;
const std::int32_t x = inner_x + inner_w - offset_in_cycle;

// Left-aligned; scrolls only when the text overflows the balloon (starts at
// the beginning of the text, then reveals the rest — see balloon_layout.hpp).
const BalloonScroll scroll = compute_balloon_scroll(text_w, inner_w, elapsed_ms, ctx.balloon_hold_ms);
canvas.setClipRect(inner_x, panel_y, inner_w, panel_h);
canvas.setTextDatum(lgfx::textdatum_t::middle_left);
canvas.drawString(text.c_str(), x, mid_y);
canvas.drawString(text.c_str(), inner_x - scroll.offset_px, mid_y);
canvas.clearClipRect();

// Mark done once the message has scrolled across at least once
// (or the caller-requested hold time has elapsed, whichever is longer).
const std::uint32_t one_pass_ms =
static_cast<std::uint32_t>(one_pass_px) * 1000u /
static_cast<std::uint32_t>(kScrollSpeedPxPerSec);
const std::uint32_t complete_at = std::max(ctx.balloon_hold_ms, one_pass_ms);
if (elapsed_ms >= complete_at) {
if (scroll.done) {
ctx.balloon_done = true;
}
canvas.end_group();
Expand Down
65 changes: 65 additions & 0 deletions components/avatar/balloon_layout.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2026 Kenta IDA <fuga@fugafuga.org>
// SPDX-License-Identifier: BSL-1.0
//
// 吹き出しの文字列の表示位置 (スクロール) の計算。描画から切り離した純粋な関数で、
// ホストでテストできる。
//
// - 文字列が吹き出しに収まるなら、左詰めで静止表示する (スクロールしない)。
// - 溢れるときだけスクロールする: 最初は左詰め (文頭が見える) で少し止まり、
// 溢れた分だけ左へ動かして、文末が見えたところで止める。ループはしない。
// - 表示時間 (hold_ms) が分かっているときは、その間に文末まで読めるよう速度を
// 合わせる。チャンクごとに切り替わる吹き出しなら、次のチャンクが始まるまでに
// 文末が見える。
#pragma once

#include <algorithm>
#include <cstdint>

namespace stackchan::avatar::internal {

// 収まる文字列を最低限見せておく時間 (hold_ms 未指定のとき)。
constexpr std::uint32_t kBalloonStaticHoldMs = 3000;
// 溢れる文字列: スクロール前後の静止時間の上限と、既定 / 上限 / 下限のスクロール速度。
constexpr std::uint32_t kBalloonEdgeHoldMs = 1000;
constexpr std::int32_t kBalloonScrollPxPerSec = 60;
constexpr std::int32_t kBalloonScrollMaxPxPerSec = 120;
constexpr std::int32_t kBalloonScrollMinPxPerSec = 30;

struct BalloonScroll {
std::int32_t offset_px = 0; // 左へ動かした量 (0 = 左詰め、travel = 文末が右端に揃う)
bool done = false; // 表示を終えてよい (呼び出し側が吹き出しを閉じる)
};

// text_w: 文字列の幅、inner_w: 吹き出しの内側の幅 [px]。
// elapsed_ms: 表示してからの時間、hold_ms: 呼び出し側の指定表示時間 (0 = 未指定)。
constexpr BalloonScroll compute_balloon_scroll(std::int32_t text_w, std::int32_t inner_w, std::uint32_t elapsed_ms,
std::uint32_t hold_ms)
{
if (text_w <= inner_w) {
return {0, elapsed_ms >= std::max(hold_ms, kBalloonStaticHoldMs)};
}
const auto travel = static_cast<std::uint32_t>(text_w - inner_w);
const std::uint32_t default_ms = travel * 1000u / kBalloonScrollPxPerSec;
const std::uint32_t min_ms = travel * 1000u / kBalloonScrollMaxPxPerSec;
const std::uint32_t max_ms = travel * 1000u / kBalloonScrollMinPxPerSec;

std::uint32_t edge_ms = kBalloonEdgeHoldMs; // 動き出し前 / 動いた後の静止
std::uint32_t move_ms = default_ms;
if (hold_ms > 0) {
// 指定時間の中で、前後の静止 (各 1/5、最大 1 秒) を除いた分を動かす。
edge_ms = std::min(kBalloonEdgeHoldMs, hold_ms / 5);
const std::uint32_t avail = hold_ms > 2 * edge_ms ? hold_ms - 2 * edge_ms : 0;
move_ms = std::clamp(avail, min_ms, max_ms);
}

BalloonScroll s;
if (elapsed_ms > edge_ms) {
const std::uint32_t t = elapsed_ms - edge_ms;
s.offset_px = t >= move_ms ? static_cast<std::int32_t>(travel)
: static_cast<std::int32_t>(static_cast<std::uint64_t>(travel) * t / move_ms);
}
s.done = elapsed_ms >= std::max(hold_ms, edge_ms + move_ms + edge_ms);
return s;
}

} // namespace stackchan::avatar::internal
13 changes: 9 additions & 4 deletions components/avatar/include/avatar/avatar.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,24 @@ class Avatar {

void set_expression(Expression expression) noexcept;
void set_mouth_open(float ratio) noexcept;
// Mouth shape, 0 = wide .. 1 = narrow (see DrawContext::mouth_form_ratio).
// A negative value clears it: the shape then follows set_mouth_open.
void set_mouth_form(float ratio) noexcept;
void set_gaze(float horizontal, float vertical) noexcept;
void set_palette(const Palette& palette) noexcept;
// Rebuild the face layout from user tuning (eye/eyebrow/mouth geometry) and
// apply its face/background colours. Takes effect on the next tick(); safe
// to call live (e.g. from the render task on a config change).
void set_face_tuning(const FaceTuning& tuning) noexcept;
// Show `text` in the balloon. `hold_ms` overrides the default display
// time (0 = use balloon defaults: short text holds for a few seconds,
// long text plays one full marquee pass).
// Show `text` left-aligned in the balloon. Text that does not fit scrolls
// once (start of the text first, then the rest is revealed); text that fits
// never scrolls. `hold_ms` is the on-screen time: 0 = defaults (fitting text
// holds a few seconds, overflowing text scrolls at a fixed speed); when set,
// overflowing text is scrolled so its end is reached within that time.
void set_balloon_text(std::string_view text, std::uint32_t hold_ms = 0);
void clear_balloon() noexcept;
// True once the current balloon has been fully displayed (hold elapsed
// or one marquee pass completed). Stays true until the next
// or the scroll finished). Stays true until the next
// set_balloon_text / clear_balloon.
bool is_balloon_done() const noexcept;

Expand Down
18 changes: 18 additions & 0 deletions components/avatar/test/host/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2026 Kenta IDA <fuga@fugafuga.org>
# SPDX-License-Identifier: BSL-1.0
#
# Host-side (ESP-IDF independent) tests for the balloon scroll layout
# (components/avatar/balloon_layout.hpp — pure functions, header only).

cmake_minimum_required(VERSION 3.16)
project(avatar_host_test CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

enable_testing()

add_executable(avatar_test_balloon_layout test_balloon_layout.cpp)
target_compile_options(avatar_test_balloon_layout PRIVATE -Wall -Wextra)
add_test(NAME balloon_layout COMMAND avatar_test_balloon_layout)
94 changes: 94 additions & 0 deletions components/avatar/test/host/test_balloon_layout.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: 2026 Kenta IDA <fuga@fugafuga.org>
// SPDX-License-Identifier: BSL-1.0
//
// 吹き出しのスクロール計算 (balloon_layout.hpp) の検証。
#include <cstdio>

#include "../../balloon_layout.hpp"

using namespace stackchan::avatar::internal;

namespace {

int g_failures = 0;

#define CHECK(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)

} // namespace

int main()
{
constexpr std::int32_t kInner = 288; // CoreS3 の吹き出し内側 (320 - 2*4 - 2*8)

// 収まる文字列はスクロールしない (常に 0 = 左詰め)。既定では 3 秒見せて終了、
// hold_ms があればそれまで。
for (std::uint32_t t : {0u, 500u, 2999u}) {
const auto s = compute_balloon_scroll(200, kInner, t, 0);
CHECK(s.offset_px == 0 && !s.done);
}
CHECK(compute_balloon_scroll(200, kInner, 3000, 0).done);
CHECK(compute_balloon_scroll(kInner, kInner, 0, 0).offset_px == 0); // ちょうど収まる
CHECK(!compute_balloon_scroll(200, kInner, 3500, 5000).done);
CHECK(compute_balloon_scroll(200, kInner, 5000, 5000).done);

// 溢れる文字列 (hold なし): 最初は左詰めで 1 秒止まり、60 px/s で溢れた分だけ動き、
// 文末が右端に揃ったところで 1 秒止まって終了。ループしない。
{
const std::int32_t text_w = kInner + 300; // 溢れは 300 px → 5 秒で動く
CHECK(compute_balloon_scroll(text_w, kInner, 0, 0).offset_px == 0); // 文頭が見えている
CHECK(compute_balloon_scroll(text_w, kInner, 1000, 0).offset_px == 0); // まだ動かない
CHECK(compute_balloon_scroll(text_w, kInner, 2000, 0).offset_px == 60); // 1 秒動いた = 60 px
CHECK(compute_balloon_scroll(text_w, kInner, 6000, 0).offset_px == 300); // 文末が右端
CHECK(compute_balloon_scroll(text_w, kInner, 60000, 0).offset_px == 300); // それ以上は動かない
CHECK(!compute_balloon_scroll(text_w, kInner, 6999, 0).done);
CHECK(compute_balloon_scroll(text_w, kInner, 7000, 0).done); // 1 + 5 + 1 秒
// 単調増加 (途中で戻らない)。
std::int32_t prev = 0;
for (std::uint32_t t = 0; t <= 8000; t += 50) {
const auto s = compute_balloon_scroll(text_w, kInner, t, 0);
CHECK(s.offset_px >= prev && s.offset_px >= 0 && s.offset_px <= 300);
prev = s.offset_px;
}
}

// 溢れる文字列 (hold あり): その時間内に文末まで動く。前後の静止は 1/5 ずつ。
{
const std::int32_t text_w = kInner + 100; // 溢れ 100 px
const std::uint32_t hold = 3000; // 前後 600 ms、動くのは 1800 ms (≈ 55 px/s)
CHECK(compute_balloon_scroll(text_w, kInner, 0, hold).offset_px == 0);
CHECK(compute_balloon_scroll(text_w, kInner, 600, hold).offset_px == 0);
CHECK(compute_balloon_scroll(text_w, kInner, 2400, hold).offset_px == 100); // 動き終わり
CHECK(compute_balloon_scroll(text_w, kInner, hold - 1, hold).offset_px == 100);
CHECK(!compute_balloon_scroll(text_w, kInner, hold - 1, hold).done);
CHECK(compute_balloon_scroll(text_w, kInner, hold, hold).done);
}
// hold が短くて間に合わないときは最大速度 (120 px/s) で動く (それでも hold 内には終わらない)。
{
const std::int32_t text_w = kInner + 600; // 溢れ 600 px → 最速 120 px/s でも 5 秒
// hold=2000 → 前後の静止は各 400 ms。動き出して 800 ms 後 (elapsed=1200) は 120*0.8=96 px。
CHECK(compute_balloon_scroll(text_w, kInner, 1200, 2000).offset_px == 96);
CHECK(compute_balloon_scroll(text_w, kInner, 2000, 2000).offset_px == 192); // 動き出して 1600 ms = 120*1.6。hold を過ぎても動き続ける
CHECK(!compute_balloon_scroll(text_w, kInner, 2000, 2000).done);
CHECK(compute_balloon_scroll(text_w, kInner, 5400, 2000).offset_px == 600);
CHECK(!compute_balloon_scroll(text_w, kInner, 5799, 2000).done);
CHECK(compute_balloon_scroll(text_w, kInner, 5800, 2000).done); // 0.4 + 5 + 0.4 秒
}
// hold が長いときは最低速度 (30 px/s) まで落とす (それ以上は遅くしない)。
{
const std::int32_t text_w = kInner + 60; // 溢れ 60 px → 最長 2 秒
// hold = 30 秒: 前後は各 1 秒 (上限)、動くのは 2 秒 (30 px/s)、あとは静止のまま hold まで。
CHECK(compute_balloon_scroll(text_w, kInner, 1000, 30000).offset_px == 0);
CHECK(compute_balloon_scroll(text_w, kInner, 3000, 30000).offset_px == 60);
CHECK(!compute_balloon_scroll(text_w, kInner, 29999, 30000).done);
CHECK(compute_balloon_scroll(text_w, kInner, 30000, 30000).done);
}

if (g_failures == 0) std::puts("test_balloon_layout: all passed");
return g_failures == 0 ? 0 : 1;
}
16 changes: 11 additions & 5 deletions components/avatar_vm/include/avatar/draw_context.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,24 @@ struct DrawContext {
float gaze_saccade_v{0.0f};
float eye_open_ratio{1.0f};
float mouth_open_ratio{0.0f};
// Mouth shape: 0 = wide, 1 = narrow (pursed). Set by Avatar::set_mouth_form
// (e.g. TTS vowel lip-sync: い is wide, う is narrow). Negative = "not set":
// Var::MouthForm then falls back to mouth_open_ratio so faces driven only
// by a level meter keep the classic "narrower as it opens" behaviour.
float mouth_form_ratio{-1.0f};
Palette palette{kDefaultPalette};
std::uint32_t rng_state{0xC0FFEEu};
std::optional<std::string> balloon_text{};
// Wall-clock used for time-based animation (e.g. balloon marquee).
// Wall-clock used for time-based animation (e.g. balloon scroll).
std::uint32_t now_ms{0};
// Set to `now_ms` whenever balloon_text changes — drives marquee phase.
// Set to `now_ms` whenever balloon_text changes — drives the scroll phase.
std::uint32_t balloon_set_ms{0};
// Minimum display time. 0 means "use balloon defaults" (short = a fixed
// hold, long = one marquee pass).
// Display time. 0 means "use balloon defaults" (fitting text = a fixed
// hold, overflowing text = one scroll at a fixed speed); otherwise the
// scroll of overflowing text is timed to finish within it.
std::uint32_t balloon_hold_ms{0};
// Set by balloon rendering once the message has been displayed in full
// (i.e. hold time elapsed for short text, or one marquee cycle for long
// (i.e. hold time elapsed for short text, or the scroll finished for long
// text). The render task polls this and notifies the application.
bool balloon_done{false};
};
Expand Down
1 change: 1 addition & 0 deletions components/avatar_vm/include/avatar_vm/opcodes.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ enum class Var : std::uint8_t {
CheekRadius = 0x1C,
CheekOffX = 0x1D,
CheekOffY = 0x1E,
MouthForm = 0x1F,
Accessories = 0x20, // FaceTuning::accessories bitmask (0..255)
Accessory0 = 0x21, // (accessories >> n) & 1, n = 0..7
Accessory1 = 0x22,
Expand Down
Loading