Skip to content

Commit 93cbfec

Browse files
committed
feat(stage-ui,stage-pages,stage-tamagotchi): visualize beat sync target value, plugin-lize live2d motion, styles of beat sync
1 parent 347b55f commit 93cbfec

File tree

6 files changed

+1001
-134
lines changed

6 files changed

+1001
-134
lines changed

apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ const menu = computed(() => [
4747
icon: 'i-solar:sledgehammer-bold-duotone',
4848
to: '/devtools/providers-transcription-realtime-aliyun-nls',
4949
},
50+
{
51+
title: 'Beat Sync Visualizer',
52+
description: 'Plot V-motion targets, trajectory, and scalar Y/Z over time',
53+
icon: 'i-solar:chart-bold-duotone',
54+
to: '/devtools/beat-sync',
55+
},
5056
])
5157
5258
const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
<script setup lang="ts">
2+
import type { BeatSyncStyleName } from '../../../../stage-ui/src/composables/live2d/beat-sync'
3+
4+
import { Callout, Section } from '@proj-airi/stage-ui/components'
5+
import { Button, FieldCheckbox, FieldRange, FieldSelect } from '@proj-airi/ui'
6+
import { useRafFn } from '@vueuse/core'
7+
import { computed, onMounted, reactive, ref, watch } from 'vue'
8+
9+
import { createBeatSyncController } from '../../../../stage-ui/src/composables/live2d/beat-sync'
10+
11+
interface TrailPoint { x: number, y: number, t: number }
12+
interface ScalarSample { t: number, x: number, y: number, z: number }
13+
14+
const baseAngleX = ref(0)
15+
const baseAngleY = ref(0)
16+
const baseAngleZ = ref(0)
17+
const scale = ref(6) // px per degree
18+
const dampingOverlay = ref(0.08)
19+
const timeWindowMs = 4000
20+
const style = ref<BeatSyncStyleName>('punchy-v')
21+
const autoStyleShift = ref(false)
22+
23+
const controller = createBeatSyncController({
24+
baseAngles: () => ({ x: baseAngleX.value, y: baseAngleY.value, z: baseAngleZ.value }),
25+
initialStyle: style.value,
26+
autoStyleShift: autoStyleShift.value,
27+
})
28+
29+
const state = reactive({
30+
angleX: baseAngleX.value,
31+
angleY: baseAngleY.value,
32+
angleZ: baseAngleZ.value,
33+
velX: 0,
34+
velY: 0,
35+
velZ: 0,
36+
last: performance.now(),
37+
})
38+
39+
const trail = ref<TrailPoint[]>([])
40+
const scalars = ref<ScalarSample[]>([])
41+
const canvasXY = ref<HTMLCanvasElement>()
42+
const debugState = computed(() => controller.debugState())
43+
const nowTs = ref(performance.now())
44+
const styleOptions: Array<{ label: string, value: BeatSyncStyleName }> = [
45+
{ label: 'Punchy V (10/8/4)', value: 'punchy-v' },
46+
{ label: 'Balanced V (6/0/6)', value: 'balanced-v' },
47+
{ label: 'Swing L/R (A-shape side-to-side)', value: 'swing-lr' },
48+
{ label: 'Sway Sine (lifted arc between sides)', value: 'sway-sine' },
49+
]
50+
51+
watch(style, val => controller.setStyle(val))
52+
watch(autoStyleShift, enabled => controller.setAutoStyleShift(enabled))
53+
54+
const currentPose = computed(() => ({
55+
x: controller.targetX.value,
56+
y: controller.targetY.value,
57+
z: controller.targetZ.value,
58+
}))
59+
60+
const formatDegrees = (value: number) => `${value.toFixed(1)}°`
61+
const formatScale = (value: number) => `${value.toFixed(1)} px/deg`
62+
const formatFade = (value: number) => value.toFixed(2)
63+
64+
function springTowardTarget(now: number) {
65+
const dt = now - state.last
66+
if (!Number.isFinite(dt))
67+
return
68+
state.last = now
69+
70+
controller.updateTargets(now)
71+
72+
// Same semi-implicit Euler params as runtime
73+
const stiffness = 120
74+
const damping = 16
75+
const mass = 1
76+
77+
// X
78+
{
79+
const target = controller.targetX.value
80+
const pos = state.angleX
81+
const vel = state.velX
82+
const accel = (stiffness * (target - pos) - damping * vel) / mass
83+
state.velX = vel + accel * dt
84+
state.angleX = pos + state.velX * dt
85+
}
86+
87+
// Y
88+
{
89+
const target = controller.targetY.value
90+
const pos = state.angleY
91+
const vel = state.velY
92+
const accel = (stiffness * (target - pos) - damping * vel) / mass
93+
state.velY = vel + accel * dt
94+
state.angleY = pos + state.velY * dt
95+
}
96+
97+
// Z
98+
{
99+
const target = controller.targetZ.value
100+
const pos = state.angleZ
101+
const vel = state.velZ
102+
const accel = (stiffness * (target - pos) - damping * vel) / mass
103+
state.velZ = vel + accel * dt
104+
state.angleZ = pos + state.velZ * dt
105+
}
106+
}
107+
108+
function pushSamples(now: number) {
109+
if (!Number.isFinite(state.angleX) || !Number.isFinite(state.angleZ))
110+
return
111+
112+
trail.value.push({ x: state.angleX, y: state.angleZ, t: now })
113+
scalars.value.push({ t: now, x: state.angleX, y: state.angleY, z: state.angleZ })
114+
const cutoff = now - timeWindowMs
115+
while (trail.value.length && trail.value[0].t < cutoff)
116+
trail.value.shift()
117+
while (scalars.value.length && scalars.value[0].t < cutoff)
118+
scalars.value.shift()
119+
}
120+
121+
function drawXY() {
122+
const canvas = canvasXY.value
123+
if (!canvas)
124+
return
125+
126+
const dpr = window.devicePixelRatio || 1
127+
const { clientWidth, clientHeight } = canvas
128+
if (canvas.width !== clientWidth * dpr || canvas.height !== clientHeight * dpr) {
129+
canvas.width = clientWidth * dpr
130+
canvas.height = clientHeight * dpr
131+
}
132+
133+
const ctx = canvas.getContext('2d')
134+
if (!ctx)
135+
return
136+
137+
ctx.save()
138+
ctx.scale(dpr, dpr)
139+
140+
ctx.fillStyle = `rgba(0, 0, 0, ${dampingOverlay.value})`
141+
ctx.fillRect(0, 0, clientWidth, clientHeight)
142+
143+
const centerX = clientWidth / 2
144+
const centerY = clientHeight / 2
145+
146+
ctx.strokeStyle = 'rgba(255,255,255,0.12)'
147+
ctx.lineWidth = 1
148+
ctx.beginPath()
149+
ctx.moveTo(0, centerY)
150+
ctx.lineTo(clientWidth, centerY)
151+
ctx.moveTo(centerX, 0)
152+
ctx.lineTo(centerX, clientHeight)
153+
ctx.stroke()
154+
155+
ctx.fillStyle = 'rgba(94,234,212,0.4)'
156+
ctx.strokeStyle = 'rgba(94,234,212,1)'
157+
ctx.lineWidth = 2
158+
ctx.beginPath()
159+
trail.value.forEach((p, idx) => {
160+
const x = centerX + p.x * scale.value
161+
const y = centerY - p.y * scale.value
162+
if (idx === 0)
163+
ctx.moveTo(x, y)
164+
else
165+
ctx.lineTo(x, y)
166+
})
167+
ctx.stroke()
168+
169+
const head = trail.value[trail.value.length - 1]
170+
if (head) {
171+
ctx.beginPath()
172+
ctx.arc(centerX + head.x * scale.value, centerY - head.y * scale.value, 5, 0, Math.PI * 2)
173+
ctx.fill()
174+
}
175+
176+
// Current target marker
177+
ctx.fillStyle = 'rgba(244,114,182,0.8)'
178+
ctx.beginPath()
179+
ctx.arc(centerX + controller.targetY.value * scale.value, centerY - controller.targetZ.value * scale.value, 4, 0, Math.PI * 2)
180+
ctx.fill()
181+
182+
ctx.restore()
183+
}
184+
185+
useRafFn(({ timestamp }) => {
186+
nowTs.value = timestamp
187+
springTowardTarget(timestamp)
188+
pushSamples(timestamp)
189+
drawXY()
190+
})
191+
192+
onMounted(() => {
193+
// Seed initial trail
194+
const now = performance.now()
195+
pushSamples(now)
196+
drawXY()
197+
})
198+
199+
function hitBeat() {
200+
controller.scheduleBeat(performance.now())
201+
}
202+
203+
function hitVSequence() {
204+
const now = performance.now()
205+
const spacing = 180
206+
controller.scheduleBeat(now)
207+
controller.scheduleBeat(now + spacing)
208+
controller.scheduleBeat(now + spacing * 2)
209+
}
210+
</script>
211+
212+
<template>
213+
<div class="grid gap-4 p-4 lg:grid-cols-[2fr_1fr]">
214+
<Section
215+
title="Beat sync driver"
216+
icon="i-solar:cursor-linear"
217+
inner-class="gap-4"
218+
>
219+
<div class="flex flex-wrap items-center gap-3">
220+
<Button label="Hit beat" icon="i-solar:flash-bold-duotone" size="sm" @click="hitBeat" />
221+
<Button
222+
label="Hit V sequence"
223+
icon="i-solar:repeat-one-minimalistic-bold-duotone"
224+
size="sm"
225+
variant="secondary"
226+
@click="hitVSequence"
227+
/>
228+
<FieldCheckbox
229+
v-model="autoStyleShift"
230+
class="min-w-[240px]"
231+
label="Auto style by BPM"
232+
description="Switch styles based on detected tempo"
233+
/>
234+
</div>
235+
236+
<div class="grid gap-4 md:grid-cols-2">
237+
<FieldSelect
238+
v-model="style"
239+
label="Style"
240+
description="Choose how head motion is sculpted between beats"
241+
:options="styleOptions"
242+
layout="vertical"
243+
select-class="w-full"
244+
/>
245+
<Callout label="Current targets" theme="violet">
246+
<div class="text-sm text-neutral-800 dark:text-neutral-100">
247+
X/Y/Z: {{ currentPose.x.toFixed(2) }} / {{ currentPose.y.toFixed(2) }} / {{ currentPose.z.toFixed(2) }}
248+
</div>
249+
<div class="text-xs text-neutral-500 dark:text-neutral-400">
250+
Live targets fed into the spring solver.
251+
</div>
252+
</Callout>
253+
</div>
254+
255+
<div class="grid gap-4 md:grid-cols-2">
256+
<FieldRange
257+
v-model="baseAngleX"
258+
label="Base X"
259+
description="Baseline tilt forward/back"
260+
:min="-20"
261+
:max="20"
262+
:step="0.1"
263+
:format-value="formatDegrees"
264+
/>
265+
<FieldRange
266+
v-model="baseAngleY"
267+
label="Base Y"
268+
description="Baseline tilt left/right"
269+
:min="-20"
270+
:max="20"
271+
:step="0.1"
272+
:format-value="formatDegrees"
273+
/>
274+
<FieldRange
275+
v-model="baseAngleZ"
276+
label="Base Z"
277+
description="Baseline roll"
278+
:min="-20"
279+
:max="20"
280+
:step="0.1"
281+
:format-value="formatDegrees"
282+
/>
283+
<FieldRange
284+
v-model="scale"
285+
label="Scale (px/deg)"
286+
description="Trail & marker scale"
287+
:min="2"
288+
:max="18"
289+
:step="0.5"
290+
:format-value="formatScale"
291+
/>
292+
</div>
293+
294+
<div class="grid gap-4 md:grid-cols-2">
295+
<FieldRange
296+
v-model="dampingOverlay"
297+
label="Trail fade"
298+
description="Overlay alpha for XY trace"
299+
:min="0.02"
300+
:max="0.3"
301+
:step="0.01"
302+
:format-value="formatFade"
303+
/>
304+
<Callout label="Controller" theme="lime">
305+
<div class="text-xs text-neutral-700 dark:text-neutral-200">
306+
Beat targets update each frame; the spring here mirrors the runtime Live2D hook.
307+
</div>
308+
</Callout>
309+
</div>
310+
311+
<div class="h-80 w-full overflow-hidden border border-neutral-200/70 rounded-xl bg-neutral-900/80 dark:border-neutral-800/60">
312+
<canvas ref="canvasXY" class="h-full w-full" />
313+
</div>
314+
</Section>
315+
316+
<Section
317+
title="Signals & debug"
318+
icon="i-solar:chart-2-bold-duotone"
319+
inner-class="gap-4"
320+
>
321+
<div class="space-y-3">
322+
<div class="text-sm text-neutral-500 dark:text-neutral-400">
323+
Scalars (Y / Z over time, last {{ (timeWindowMs / 1000).toFixed(1) }}s)
324+
</div>
325+
</div>
326+
327+
<Callout label="Spring model" theme="orange">
328+
<div class="text-xs text-neutral-700 dark:text-neutral-200">
329+
Semi-implicit Euler spring matches Live2D hook (stiffness 120, damping 16). Targets driven by beat controller.
330+
</div>
331+
</Callout>
332+
333+
<div class="text-xs text-neutral-500 space-y-1 dark:text-neutral-400">
334+
<div>Style: {{ debugState.style }}</div>
335+
<div>BPM (avg): {{ debugState.bpm ? debugState.bpm.toFixed(1) : '—' }}</div>
336+
<div>Primed: {{ debugState.primed }}</div>
337+
<div>Pattern started: {{ debugState.patternStarted }}</div>
338+
<div>Segments: {{ debugState.segments.length }}</div>
339+
<div v-if="debugState.segments.length">
340+
Next segment: toY {{ debugState.segments[0].toY.toFixed(2) }}, toZ {{ debugState.segments[0].toZ.toFixed(2) }},
341+
starts in {{ Math.max(0, debugState.segments[0].start - nowTs).toFixed(0) }} ms
342+
</div>
343+
</div>
344+
</Section>
345+
</div>
346+
</template>

0 commit comments

Comments
 (0)