Skip to content

Commit c6675dc

Browse files
committed
Fix remaining PR 365 review comments
1 parent 53996bb commit c6675dc

13 files changed

Lines changed: 351 additions & 28 deletions

File tree

‎firmware/mods/chat_audioio/mod.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ export function onRobotCreated(robot) {
2020
...rawChatConfig,
2121
instructions: rawChatConfig.instructions ?? 'あなたは丁寧なアシスタントロボットです。',
2222
}
23-
if (!chatConfig?.type) {
23+
if (typeof chatConfig?.type !== 'string' || chatConfig.type.length === 0) {
2424
trace(
25-
'[chat_audioio] config.chat.type is missing. Set config.chat.type (for example "openAIRealtime"). Chat disabled.\n',
25+
'[chat_audioio] config.chat.type must be a non-empty string. Set config.chat.type (for example "openAIRealtime"). Chat disabled.\n',
2626
)
2727
return
2828
}

‎firmware/stackchan/default-mods/on-robot-created.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import type { StackchanMod } from 'default-mods/mod'
2-
import Timer from 'timer'
3-
import { randomBetween, asyncWait } from 'stackchan-util'
4-
import { Emotion } from 'face-context'
51
import { DogFace, ImageFace, SimpleFace } from 'behaviors/face'
2+
import type { StackchanMod } from 'default-mods/mod'
63
import { Emoticon, type EmoticonKey } from 'effects/emoticon'
4+
import { Emotion } from 'face-context'
75
import type { Content as PiuContent } from 'piu/MC'
6+
import { asyncWait, randomBetween } from 'stackchan-util'
7+
import Timer from 'timer'
88

99
const FORWARD = {
1010
y: 0,
@@ -35,6 +35,12 @@ export const onRobotCreated: StackchanMod['onRobotCreated'] = (robot) => {
3535
let emoticonEffect: PiuContent | null = null
3636

3737
let faceMode: 'simple' | 'dog' | 'image' = 'simple'
38+
const syncFaceMode = (
39+
app = robot.renderer?.application as { distribute?: (event: string, payload: unknown) => void } | undefined,
40+
) => {
41+
robot.application.setDrawerButtonState('toggleFace', faceMode !== 'simple')
42+
app?.distribute?.('onFaceMode', faceMode)
43+
}
3844
robot.application.addDrawerButton({
3945
key: 'toggleFace',
4046
label: 'Face',
@@ -45,11 +51,11 @@ export const onRobotCreated: StackchanMod['onRobotCreated'] = (robot) => {
4551
const nextFace =
4652
faceMode === 'dog' ? new DogFace({}) : faceMode === 'image' ? new ImageFace({}) : new SimpleFace({})
4753
target.renderer?.setFace?.(nextFace)
48-
robot.application.setDrawerButtonState('toggleFace', faceMode !== 'simple')
4954
const app = target.renderer?.application as { distribute?: (event: string, payload: unknown) => void } | undefined
50-
app?.distribute?.('onFaceMode', faceMode)
55+
syncFaceMode(app)
5156
},
5257
})
58+
syncFaceMode()
5359
robot.application.addDrawerButton({
5460
key: 'cycleEmotion',
5561
label: 'Emotion',

‎firmware/stackchan/renderers-piu/behaviors/face.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { type Container as PiuContainer, type Content as PiuContent } from 'piu/MC'
21
import { copyFaceContext, createFaceContext, defaultFaceContext, type FaceContext } from 'face-context'
3-
import { updateFaceSkinPalette, type FaceSkinPalette } from 'face-skin'
2+
import { type FaceSkinPalette, updateFaceSkinPalette } from 'face-skin'
43
import { createBlinkMotion } from 'motions/blink'
54
import { createBreathMotion } from 'motions/breath'
65
import type { FaceMotion } from 'motions/types'
7-
import { EyeSprite } from 'parts/image/eye-sprite'
8-
import { MouthSprite } from 'parts/image/mouth-sprite'
96
import { DogEyebrow } from 'parts/dog/eyebrow'
107
import { DogMouth } from 'parts/dog/mouth'
118
import { DogNose } from 'parts/dog/nose'
129
import { Eye } from 'parts/eye'
10+
import { EyeSprite } from 'parts/image/eye-sprite'
11+
import { MouthSprite } from 'parts/image/mouth-sprite'
1312
import { Mouth } from 'parts/mouth'
13+
import type { Container as PiuContainer, Content as PiuContent } from 'piu/MC'
1414

1515
type TemplateCtor<TData> = {
1616
new (behaviorData?: TData, dictionary?: Record<string, unknown>): PiuContainer
@@ -65,6 +65,7 @@ export class FaceBehavior extends Behavior {
6565

6666
onCreate(container: PiuContainer) {
6767
container.interval = this.intervalMs
68+
copyFaceContext(defaultFaceContext, this.#current)
6869
copyFaceContext(defaultFaceContext, this.#desired)
6970
this.updateSkinPalette(container, defaultFaceContext)
7071
if (this.#skinPalette) {
@@ -98,6 +99,10 @@ export class FaceBehavior extends Behavior {
9899
copyFaceContext(face, this.#desired)
99100
}
100101

102+
onFaceSkin(_container: PiuContainer, palette: FaceSkinPalette) {
103+
this.#skinPalette = palette
104+
}
105+
101106
onTimeChanged(container: PiuContainer) {
102107
if (this.#paused) {
103108
return
@@ -145,6 +150,24 @@ export class FaceBehavior extends Behavior {
145150
return { ...this.#baseCoordinates }
146151
}
147152

153+
rehydrate(container: PiuContainer, face: Readonly<FaceContext>, palette?: FaceSkinPalette | null) {
154+
copyFaceContext(face, this.#current)
155+
copyFaceContext(face, this.#desired)
156+
if (palette !== undefined) {
157+
this.#skinPalette = palette
158+
;(container as PiuContainer & { faceSkin?: FaceSkinPalette }).faceSkin = this.#skinPalette ?? undefined
159+
} else {
160+
this.updateSkinPalette(container, face)
161+
}
162+
if (this.#baseCoordinates === null) {
163+
const coordinates = container.coordinates
164+
this.#baseCoordinates = {
165+
left: coordinates?.left ?? 0,
166+
top: coordinates?.top ?? 0,
167+
}
168+
}
169+
}
170+
148171
pause(container: PiuContainer) {
149172
if (this.#paused) return
150173
this.#paused = true

‎firmware/stackchan/renderers-piu/effects/speech-balloon.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import { defaultFaceContext, type FaceContext } from 'face-context'
12
import type {
23
Container as PiuContainer,
34
Content as PiuContent,
45
Style as PiuStyle,
56
Text as PiuText,
67
Texture as PiuTexture,
78
} from 'piu/MC'
8-
import { defaultFaceContext, type FaceContext } from 'face-context'
99

1010
let bubbleTexture: PiuTexture | null = null
1111

@@ -73,6 +73,7 @@ export const SpeechBalloon = Container.template((opts: BalloonOptions = {}) => {
7373
let currentText = o.text ?? ''
7474
let currentPrimary: string | null = null
7575
let currentSecondary: string | null = null
76+
let currentFace: Readonly<FaceContext> = defaultFaceContext
7677
let layoutWidth = 0
7778

7879
const left = opts.left ?? defaultOptions.left
@@ -129,6 +130,8 @@ export const SpeechBalloon = Container.template((opts: BalloonOptions = {}) => {
129130
background = null
130131
bodyText = null
131132
}
133+
currentPrimary = null
134+
currentSecondary = null
132135
layoutWidth = w
133136
background = new Content(null, { left: 0, right: 0, top: 0, bottom: 0 }) as WithSkin
134137
bodyText = new Text(null, {
@@ -140,11 +143,12 @@ export const SpeechBalloon = Container.template((opts: BalloonOptions = {}) => {
140143
})
141144
self.add(background)
142145
self.add(bodyText)
143-
this.updatePalette(defaultFaceContext)
146+
this.updatePalette(currentFace)
144147
this.updateText(self, currentText)
145148
}
146149

147150
updatePalette(face: FaceContext) {
151+
currentFace = face
148152
if (!background || !bodyText) return
149153
if (!bubbleTexture) bubbleTexture = new Texture('bubble.png')
150154
const primary = face.theme.primary
@@ -190,7 +194,7 @@ export const SpeechBalloon = Container.template((opts: BalloonOptions = {}) => {
190194

191195
onDisplaying(content: PiuContainer) {
192196
this.ensureParts(content)
193-
this.updatePalette(defaultFaceContext)
197+
this.updatePalette(currentFace)
194198
}
195199

196200
onFaceContext(content: PiuContainer, face: FaceContext) {

‎firmware/stackchan/renderers-piu/face-view.ts‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type FaceViewBaseParams = CommonViewParams
2626
type DieRegion = PiuContainer & { set: (x: number, y: number, w: number, h: number) => DieRegion; cut: () => void }
2727
type FaceContainerBehavior = {
2828
onFaceUpdate?: (container: PiuContainer, face: FaceContext) => void
29+
rehydrate?: (container: PiuContainer, face: Readonly<FaceContext>, palette?: FaceSkinPalette | null) => void
2930
getBaseCoordinates?: (container: PiuContainer) => { left: number; top: number }
3031
}
3132

@@ -69,14 +70,14 @@ class FaceViewBehavior extends CommonViewBehavior {
6970
}
7071

7172
onFaceUpdate(_container: PiuContainer, faceContext: Readonly<FaceContext>) {
72-
const palette = updateFaceSkinPalette(this.lastPalette, faceContext)
73-
if (palette !== this.lastPalette) {
74-
this.onFaceSkin(_container, palette)
75-
}
7673
if (this.lastFaceContext === null) {
7774
this.lastFaceContext = createFaceContext()
7875
}
7976
copyFaceContext(faceContext, this.lastFaceContext)
77+
const palette = updateFaceSkinPalette(this.lastPalette, faceContext)
78+
if (palette !== this.lastPalette) {
79+
this.onFaceSkin(_container, palette)
80+
}
8081
const face = this.face
8182
const behavior = face?.behavior as FaceContainerBehavior | undefined
8283
behavior?.onFaceUpdate?.(face as PiuContainer, faceContext as FaceContext)
@@ -89,6 +90,9 @@ class FaceViewBehavior extends CommonViewBehavior {
8990
this.main.skin = palette.secondary
9091
}
9192
this.face?.distribute?.('onFaceSkin', palette)
93+
if (this.face) {
94+
this.rehydrateFace(this.face, this.lastFaceContext ?? defaultFaceContext, palette)
95+
}
9296
this.effects?.distribute('onFaceSkin', palette)
9397
this.overlay?.distribute('onFaceSkin', palette)
9498
this.appBar?.distribute?.('onFaceSkin', palette)
@@ -139,16 +143,22 @@ class FaceViewBehavior extends CommonViewBehavior {
139143
}
140144
}
141145

146+
rehydrateFace(face: PiuContainer, faceContext: Readonly<FaceContext>, palette = this.lastPalette): void {
147+
const behavior = face.behavior as FaceContainerBehavior | undefined
148+
if (behavior?.rehydrate) {
149+
behavior.rehydrate(face, faceContext, palette)
150+
return
151+
}
152+
behavior?.onFaceUpdate?.(face, faceContext as FaceContext)
153+
}
154+
142155
applyFaceState(face: PiuContainer): void {
156+
const faceContext = this.lastFaceContext ?? defaultFaceContext
143157
if (this.lastPalette) {
144158
face.distribute?.('onFaceSkin', this.lastPalette)
145159
}
146-
if (!this.lastFaceContext) {
147-
return
148-
}
149-
const behavior = face.behavior as FaceContainerBehavior | undefined
150-
behavior?.onFaceUpdate?.(face, this.lastFaceContext)
151-
face.distribute?.('onFaceContext', this.lastFaceContext)
160+
face.distribute?.('onFaceContext', faceContext)
161+
this.rehydrateFace(face, faceContext, this.lastPalette)
152162
}
153163

154164
setFace(face: PiuContainer): void {

‎firmware/tests/chats/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ source /home/sskw/.local/share/xs-dev-export.sh
88
mcconfig -m -d -p sim/m5stack -t run ./tests/chats/chat-service/manifest.json
99
mcconfig -m -d -p sim/m5stack -t run ./tests/chats/chat-statusbar/manifest.json
1010
mcconfig -m -d -p sim/m5stack -t run ./tests/chats/chat-balloon/manifest.json
11+
mcconfig -m -d -p sim/m5stack -t run ./tests/chats/chat-audioio-config/manifest.json
1112
```
1213

1314
## テスト一覧
1415
- `chat-service`: ChatService と ChatAudioIO の橋渡し、tools変換の検証(Mock ChatAudioIO)
1516
- `chat-statusbar`: AppBar 状態表示の切替・入力ゲイン反映
1617
- `chat-balloon`: SpeechBalloon の生成/更新が成立することの確認
18+
- `chat-audioio-config`: `config.chat.type` のガードが不正値を弾くことを確認
1719

1820
## 備考
1921
- ChatService のユニットテストは `ChatAudioIO` を `tests/chats/mocks/ChatAudioIO.js` に差し替えて実行します。
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import config from 'mc/config'
2+
import { equal } from 'mocks/assert'
3+
import { onRobotCreated } from '../../../mods/chat_audioio/mod.js'
4+
5+
trace('=== chat-audioio-config test ===\n')
6+
7+
type MutableConfig = {
8+
chat?: unknown
9+
}
10+
11+
type StubRobot = {
12+
renderer: {
13+
setFace: () => void
14+
application: {
15+
distribute: (_event: string, _payload: unknown) => void
16+
}
17+
}
18+
application: {
19+
addDrawerButton: () => void
20+
}
21+
}
22+
23+
function createStubRobot() {
24+
const calls = {
25+
setFace: 0,
26+
addDrawerButton: 0,
27+
}
28+
const robot: StubRobot = {
29+
renderer: {
30+
setFace: () => {
31+
calls.setFace += 1
32+
},
33+
application: {
34+
distribute: () => {},
35+
},
36+
},
37+
application: {
38+
addDrawerButton: () => {
39+
calls.addDrawerButton += 1
40+
},
41+
},
42+
}
43+
return { calls, robot }
44+
}
45+
46+
const mutableConfig = config as MutableConfig
47+
const originalChat = mutableConfig.chat
48+
49+
mutableConfig.chat = { type: { invalid: true } }
50+
let test = createStubRobot()
51+
onRobotCreated(test.robot)
52+
equal(test.calls.setFace, 0, 'non-string chat type should not initialize the face')
53+
equal(test.calls.addDrawerButton, 0, 'non-string chat type should not add drawer buttons')
54+
55+
mutableConfig.chat = { type: '' }
56+
test = createStubRobot()
57+
onRobotCreated(test.robot)
58+
equal(test.calls.setFace, 0, 'empty chat type should not initialize the face')
59+
equal(test.calls.addDrawerButton, 0, 'empty chat type should not add drawer buttons')
60+
61+
mutableConfig.chat = originalChat
62+
63+
trace('ok\n')
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"include": [
3+
"../../../stackchan/services/manifest_service.json",
4+
"../../../stackchan/renderers-piu/manifest_renderer_piu.json"
5+
],
6+
"modules": {
7+
"mocks/assert": "../mocks/assert",
8+
"*": ["./main"]
9+
}
10+
}

‎firmware/tests/chats/chat-balloon/main.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Application, type Content, Skin, Style } from 'piu/MC'
21
import { SpeechBalloon } from 'effects/speech-balloon'
3-
import { defaultFaceContext, type FaceContext } from 'face-context'
4-
import { assert } from 'mocks/assert'
2+
import { createFaceContext, defaultFaceContext, type FaceContext } from 'face-context'
3+
import { assert, equal } from 'mocks/assert'
4+
import { Application, type Content, Skin, Style } from 'piu/MC'
55
import Timer from 'timer'
66

77
trace('=== chat-balloon test ===\n')
@@ -37,9 +37,21 @@ type BalloonBehavior = {
3737
}
3838

3939
type BalloonContent = {
40+
first?: BalloonNode | null
4041
behavior?: BalloonBehavior
4142
}
4243

44+
type BalloonNode = Content & {
45+
next?: BalloonNode | null
46+
skin?: { color?: string | string[] | null } | null
47+
style?: { color?: string | string[] | null } | null
48+
}
49+
50+
function resolveColor(color: string | string[] | null | undefined): string | null {
51+
if (Array.isArray(color)) return color[0] ?? null
52+
return color ?? null
53+
}
54+
4355
// Force behavior initialization
4456
const fixedBalloonAny = fixedBalloon as unknown as BalloonContent
4557
fixedBalloonAny.behavior?.onDisplaying?.(fixedBalloon)
@@ -50,6 +62,34 @@ const streamBalloonAny = streamBalloon as unknown as BalloonContent
5062
streamBalloonAny.behavior?.onDisplaying?.(streamBalloon)
5163
streamBalloonAny.behavior?.onFaceContext?.(streamBalloon, defaultFaceContext)
5264

65+
const themedFace = createFaceContext()
66+
themedFace.theme.primary = '#123456'
67+
themedFace.theme.secondary = '#abcdef'
68+
streamBalloonAny.behavior?.onFaceContext?.(streamBalloon, themedFace)
69+
70+
const themedBackground = streamBalloonAny.first as BalloonNode
71+
const themedText = themedBackground.next as BalloonNode
72+
equal(resolveColor(themedBackground.skin?.color), themedFace.theme.primary, 'balloon should use themed bubble color')
73+
equal(resolveColor(themedText.style?.color), themedFace.theme.secondary, 'balloon should use themed text color')
74+
75+
streamBalloon.width = Math.max(64, streamBalloon.width - 32)
76+
streamBalloonAny.behavior?.onDisplaying?.(streamBalloon)
77+
78+
const rebuiltBackground = streamBalloonAny.first as BalloonNode
79+
const rebuiltText = rebuiltBackground.next as BalloonNode
80+
assert(rebuiltBackground !== themedBackground, 'width change should rebuild balloon background')
81+
assert(rebuiltText !== themedText, 'width change should rebuild balloon text node')
82+
equal(
83+
resolveColor(rebuiltBackground.skin?.color),
84+
themedFace.theme.primary,
85+
'rebuilt balloon should keep themed bubble color',
86+
)
87+
equal(
88+
resolveColor(rebuiltText.style?.color),
89+
themedFace.theme.secondary,
90+
'rebuilt balloon should keep themed text color',
91+
)
92+
5393
const chunks = [
5494
'このテキストは SpeechBalloon の',
5595
' 自動折り返しと高さ伸長の',

0 commit comments

Comments
 (0)