|
| 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