diff --git a/ai_animation/.env.example b/ai_animation/.env.example new file mode 100644 index 0000000..8fbf5bc --- /dev/null +++ b/ai_animation/.env.example @@ -0,0 +1,4 @@ +# Copy this file to .env and fill in your actual API keys +VITE_ELEVENLABS_API_KEY=your_elevenlabs_api_key_here +ELEVENLABS_API_KEY=your_elevenlabs_api_key_here +VITE_WEBHOOK_URL=your_webhook_url_here \ No newline at end of file diff --git a/ai_animation/Dockerfile b/ai_animation/Dockerfile index 5f63c66..7d529ca 100644 --- a/ai_animation/Dockerfile +++ b/ai_animation/Dockerfile @@ -13,6 +13,14 @@ RUN npm install # Now copy everything else COPY . . +# Accept build arguments for environment variables +ARG VITE_ELEVENLABS_API_KEY +ARG VITE_WEBHOOK_URL + +# Set environment variables for the build +ENV VITE_ELEVENLABS_API_KEY=$VITE_ELEVENLABS_API_KEY +ENV VITE_WEBHOOK_URL=$VITE_WEBHOOK_URL + # Build the production-ready files (dist/) RUN npm run build diff --git a/ai_animation/src/config.ts b/ai_animation/src/config.ts index 82a6174..6459539 100644 --- a/ai_animation/src/config.ts +++ b/ai_animation/src/config.ts @@ -60,5 +60,22 @@ export const config = { */ get effectivePlaybackSpeed(): number { return this.isInstantMode ? 10 : this.playbackSpeed; + }, + + // Animation timing configuration + animation: { + // Unit movement wave frequencies (Hz) + unitBobFrequency: 0.8, + fleetRollFrequency: 0.5, + fleetPitchFrequency: 0.3, + + // Supply center pulse frequency (Hz) + supplyPulseFrequency: 1.0, + + // Province highlight flash frequency (Hz) + provinceFlashFrequency: 2.0, + + // Maximum frame delta time (seconds) to prevent animation jumps + maxDeltaTime: 0.1 } } diff --git a/ai_animation/src/debug/provinceHighlight.ts b/ai_animation/src/debug/provinceHighlight.ts index 0964bf3..1759b21 100644 --- a/ai_animation/src/debug/provinceHighlight.ts +++ b/ai_animation/src/debug/provinceHighlight.ts @@ -2,12 +2,15 @@ import { gameState } from "../gameState"; import { provinceInput, highlightProvinceBtn } from "../domElements"; import { ProvinceENUM } from "../types/map"; import { MeshBasicMaterial } from "three"; +import { oscillate, msToSeconds } from "../utils/timing"; +import { config } from "../config"; interface FlashAnimation { mesh: THREE.Mesh; originalColor: number; startTime: number; duration: number; + animationId?: number; // For cancelling the animation } let currentFlashAnimation: FlashAnimation | null = null; @@ -46,23 +49,23 @@ export function highlightProvince(provinceName: string): void { currentFlashAnimation = { mesh: province.mesh, originalColor, - startTime: Date.now(), + startTime: performance.now(), duration: 2000 // 2 seconds }; console.log(`Highlighting province: ${normalizedName}`); // Start the animation loop - animateFlash(); + currentFlashAnimation.animationId = requestAnimationFrame(animateFlash); } /** * Animates the flashing effect */ -function animateFlash(): void { +function animateFlash(currentTime: number = 0): void { if (!currentFlashAnimation) return; - const elapsed = Date.now() - currentFlashAnimation.startTime; + const elapsed = currentTime - currentFlashAnimation.startTime; const progress = elapsed / currentFlashAnimation.duration; if (progress >= 1) { @@ -72,7 +75,9 @@ function animateFlash(): void { } // Calculate flash intensity using sine wave for smooth pulsing - const flashIntensity = Math.sin(elapsed * 0.01) * 0.5 + 0.5; // 0 to 1 + // Use elapsed time in seconds for consistent animation speed + const elapsedSeconds = msToSeconds(elapsed); + const flashIntensity = oscillate(config.animation.provinceFlashFrequency, elapsedSeconds); // Interpolate between original color and bright yellow const material = currentFlashAnimation.mesh.material as MeshBasicMaterial; @@ -98,7 +103,7 @@ function animateFlash(): void { material.color.setHex(newColor); // Continue animation - requestAnimationFrame(animateFlash); + currentFlashAnimation.animationId = requestAnimationFrame(animateFlash); } /** @@ -107,6 +112,11 @@ function animateFlash(): void { function stopCurrentFlash(): void { if (!currentFlashAnimation) return; + // Cancel the animation frame if it exists + if (currentFlashAnimation.animationId) { + cancelAnimationFrame(currentFlashAnimation.animationId); + } + // Restore original color const material = currentFlashAnimation.mesh.material as MeshBasicMaterial; material.color.setHex(currentFlashAnimation.originalColor); diff --git a/ai_animation/src/gameState.ts b/ai_animation/src/gameState.ts index cac5d54..1391723 100644 --- a/ai_animation/src/gameState.ts +++ b/ai_animation/src/gameState.ts @@ -115,6 +115,10 @@ class GameState { // Camera Animation during playing cameraPanAnim: TweenGroup | undefined + // Global timing for animations + globalTime: number + deltaTime: number + constructor(boardName: AvailableMaps) { this.phaseIndex = 0 this.boardName = boardName @@ -132,6 +136,8 @@ class GameState { this.scene = new THREE.Scene() this.unitMeshes = [] this.unitAnimations = [] + this.globalTime = 0 + this.deltaTime = 0 this.loadBoardState() } diff --git a/ai_animation/src/main.ts b/ai_animation/src/main.ts index 65e4b67..0f7b3b2 100644 --- a/ai_animation/src/main.ts +++ b/ai_animation/src/main.ts @@ -13,6 +13,7 @@ import { initRotatingDisplay, updateRotatingDisplay } from "./components/rotatin import { closeTwoPowerConversation, showTwoPowerConversation } from "./components/twoPowerConversation"; import { PowerENUM } from "./types/map"; import { debugMenuInstance } from "./debug/debugMenu"; +import { sineWave } from "./utils/timing"; //TODO: Create a function that finds a suitable unit location within a given polygon, for placing units better // Currently the location for label, unit, and SC are all the same manually picked location @@ -90,7 +91,7 @@ function initScene() { window.addEventListener('resize', onWindowResize); // Kick off animation loop - animate(); + requestAnimationFrame(animate); } @@ -133,14 +134,24 @@ function createCameraPan(): Group { * Main animation loop that runs continuously * Handles camera movement, animations, and game state transitions */ -function animate() { +let lastTime = 0; +function animate(currentTime: number = 0) { + // Calculate delta time in seconds + let deltaTime = lastTime ? (currentTime - lastTime) / 1000 : 0; + lastTime = currentTime; + + // Clamp delta time to prevent animation jumps when tab loses focus + deltaTime = Math.min(deltaTime, config.animation.maxDeltaTime); + + // Update global timing in gameState + gameState.deltaTime = deltaTime; + gameState.globalTime = currentTime / 1000; // Store in seconds requestAnimationFrame(animate); if (gameState.isPlaying) { - // Update the camera angle - // FIXME: This has to call the update functino twice inorder to avoid a bug in Tween.js, see here https://github.com/tweenjs/tween.js/issues/677 - gameState.cameraPanAnim.update(); - gameState.cameraPanAnim.update(); + // Update the camera angle with delta time + // Pass currentTime to update() to fix the Tween.js bug properly + gameState.cameraPanAnim.update(currentTime); } else { // Manual camera controls when not in playback mode @@ -158,8 +169,8 @@ function animate() { console.log("All unit animations have completed"); } - // Call update on each active animation - gameState.unitAnimations.forEach((anim) => anim.update()) + // Call update on each active animation with current time + gameState.unitAnimations.forEach((anim) => anim.update(currentTime)) } @@ -184,18 +195,16 @@ function animate() { gameState.scene.userData.animatedObjects.forEach(obj => { if (obj.userData.pulseAnimation) { const anim = obj.userData.pulseAnimation; - anim.time += anim.speed; + // Use delta time for consistent animation speed regardless of frame rate + anim.time += anim.speed * deltaTime; if (obj.userData.glowMesh) { - const pulseValue = Math.sin(anim.time) * anim.intensity + 0.5; + const pulseValue = sineWave(config.animation.supplyPulseFrequency, anim.time, anim.intensity, 0.5); obj.userData.glowMesh.material.opacity = 0.2 + (pulseValue * 0.3); - obj.userData.glowMesh.scale.set( - 1 + (pulseValue * 0.1), - 1 + (pulseValue * 0.1), - 1 + (pulseValue * 0.1) - ); + const scale = 1 + (pulseValue * 0.1); + obj.userData.glowMesh.scale.set(scale, scale, scale); } // Subtle bobbing up/down - obj.position.y = 2 + Math.sin(anim.time) * 0.5; + obj.position.y = 2 + sineWave(config.animation.supplyPulseFrequency, anim.time, 0.5); } }); } diff --git a/ai_animation/src/units/animate.ts b/ai_animation/src/units/animate.ts index 463d214..79452cb 100644 --- a/ai_animation/src/units/animate.ts +++ b/ai_animation/src/units/animate.ts @@ -8,6 +8,7 @@ import { logger } from "../logger"; import { config } from "../config"; // Assuming config is defined in a separate file import { PowerENUM, ProvinceENUM } from "../types/map"; import { UnitTypeENUM } from "../types/units"; +import { sineWave, getTimeInSeconds } from "../utils/timing"; function getUnit(unitOrder: UnitOrder, power: string) { if (power === undefined) throw new Error("Must pass the power argument, cannot be undefined") @@ -49,6 +50,10 @@ function createMoveAnimation(unitMesh: THREE.Group, orderDestination: ProvinceEN } unitMesh.userData.province = orderDestination; unitMesh.userData.isAnimating = true + + // Store animation start time for consistent wave motion + const animStartTime = getTimeInSeconds(); + let anim = new Tween(unitMesh.position) .to({ x: destinationVector.x, @@ -57,10 +62,12 @@ function createMoveAnimation(unitMesh: THREE.Group, orderDestination: ProvinceEN }, config.effectiveAnimationDuration) .easing(Easing.Quadratic.InOut) .onUpdate(() => { - unitMesh.position.y = 10 + Math.sin(Date.now() * 0.05) * 2; + // Use elapsed time from animation start for consistent wave motion + const elapsedTime = getTimeInSeconds() - animStartTime; + unitMesh.position.y = 10 + sineWave(config.animation.unitBobFrequency, elapsedTime, 2); // 2 units amplitude if (unitMesh.userData.type === 'F') { - unitMesh.rotation.z = Math.sin(Date.now() * 0.03) * 0.1; - unitMesh.rotation.x = Math.sin(Date.now() * 0.02) * 0.1; + unitMesh.rotation.z = sineWave(config.animation.fleetRollFrequency, elapsedTime, 0.1); + unitMesh.rotation.x = sineWave(config.animation.fleetPitchFrequency, elapsedTime, 0.1); } }) .onComplete(() => { diff --git a/ai_animation/src/utils/timing.ts b/ai_animation/src/utils/timing.ts new file mode 100644 index 0000000..b7f61d5 --- /dev/null +++ b/ai_animation/src/utils/timing.ts @@ -0,0 +1,69 @@ +/** + * Timing utilities for consistent animation across different frame rates + */ + +/** + * Creates a time-based animation value that oscillates between 0 and 1 + * @param frequency - Oscillations per second + * @param elapsedTime - Time elapsed in seconds + * @returns Value between 0 and 1 + */ +export function oscillate(frequency: number, elapsedTime: number): number { + return (Math.sin(elapsedTime * frequency * Math.PI * 2) + 1) / 2; +} + +/** + * Creates a time-based sine wave value + * @param frequency - Oscillations per second + * @param elapsedTime - Time elapsed in seconds + * @param amplitude - Maximum displacement from center + * @param offset - Center position + * @returns Sine wave value + */ +export function sineWave( + frequency: number, + elapsedTime: number, + amplitude: number = 1, + offset: number = 0 +): number { + return Math.sin(elapsedTime * frequency * Math.PI * 2) * amplitude + offset; +} + +/** + * Converts milliseconds to seconds + * @param ms - Time in milliseconds + * @returns Time in seconds + */ +export function msToSeconds(ms: number): number { + return ms / 1000; +} + +/** + * Gets current high-resolution time in seconds + * @returns Current time in seconds + */ +export function getTimeInSeconds(): number { + return performance.now() / 1000; +} + +/** + * Clamps a value between min and max + * @param value - Value to clamp + * @param min - Minimum value + * @param max - Maximum value + * @returns Clamped value + */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Linear interpolation between two values based on time + * @param start - Start value + * @param end - End value + * @param progress - Progress from 0 to 1 + * @returns Interpolated value + */ +export function lerp(start: number, end: number, progress: number): number { + return start + (end - start) * clamp(progress, 0, 1); +} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 2963c1a..7cf9d07 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,10 +9,14 @@ services: ports: - "9222:9222" ipc: host - shm_size: "1gb" + shm_size: "2gb" diplomacy: - build: ai_animation + build: + context: ai_animation + args: + - VITE_ELEVENLABS_API_KEY=${VITE_ELEVENLABS_API_KEY} + - VITE_WEBHOOK_URL=${VITE_WEBHOOK_URL} env_file: "./ai_animation/.env" ports: - "4173:4173" diff --git a/twitch-streamer/entrypoint.sh b/twitch-streamer/entrypoint.sh index 93a3e75..2fd2e6d 100755 --- a/twitch-streamer/entrypoint.sh +++ b/twitch-streamer/entrypoint.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # X display can put a lock in, that sometimes will stay in the container. Nuke it as it isn't needed -rm /tmp/.X99-lock +rm -f /tmp/.X99-lock /tmp/.X1-lock set -e # Check if STREAM_KEY is empty if [ -z "$STREAM_KEY" ]; then @@ -23,29 +23,45 @@ mkdir -p /home/chrome # --disable-background-timer-throttling & related flags to prevent fps throttling in headless/Xvfb DISPLAY=$DISPLAY google-chrome \ --remote-debugging-port=9222 \ - --disable-gpu \ - --disable-infobars \ + --no-sandbox \ + --disable-setuid-sandbox \ + --disable-dev-shm-usage \ --no-first-run \ --disable-background-timer-throttling \ --disable-renderer-backgrounding \ --disable-backgrounding-occluded-windows \ + --disable-features=TranslateUI \ + --disable-ipc-flooding-protection \ + --disable-frame-rate-limit \ + --enable-precise-memory-info \ + --max-gum-fps=30 \ --user-data-dir=/home/chrome/chrome-data \ --window-size=1920,1080 --window-position=0,0 \ --kiosk \ + --autoplay-policy=no-user-gesture-required \ + --enable-gpu \ + --use-gl=swiftshader \ + --disable-gpu-vsync \ + --force-device-scale-factor=1 \ "http://diplomacy:4173" & sleep 5 # let the page load or animations start +# Set PulseAudio environment +export PULSE_RUNTIME_PATH=/tmp/pulse +export PULSE_SERVER=unix:/tmp/pulse/native + # Start streaming with FFmpeg. # - For video: x11grab at 30fps -# - For audio: pulse from the default device +# - For audio: pulse from the dummy sink monitor exec ffmpeg -y \ - -f x11grab -video_size 1920x1080 -framerate 30 -thread_queue_size 512 -i $DISPLAY \ - -f pulse -thread_queue_size 512 -i default \ - -c:v libx264 -preset ultrafast -b:v 6000k -maxrate 6000k -bufsize 12000k \ + -f x11grab -video_size 1920x1080 -framerate 30 -thread_queue_size 1024 -i $DISPLAY \ + -f pulse -thread_queue_size 1024 -i dummy_sink.monitor \ + -c:v libx264 -preset fast -tune animation -b:v 4500k -maxrate 4500k -bufsize 9000k \ + -g 60 -keyint_min 60 \ -pix_fmt yuv420p \ - -c:a aac -b:a 160k \ - -vsync 1 -async 1 \ + -c:a aac -b:a 128k \ + -vsync cfr \ -f flv "rtmp://ingest.global-contribute.live-video.net/app/$STREAM_KEY" # 'exec' ensures ffmpeg catches any SIGTERM and stops gracefully, diff --git a/twitch-streamer/pulse-virtual-audio.sh b/twitch-streamer/pulse-virtual-audio.sh index 6e2f3e6..e7c1917 100644 --- a/twitch-streamer/pulse-virtual-audio.sh +++ b/twitch-streamer/pulse-virtual-audio.sh @@ -1,14 +1,34 @@ #!/usr/bin/env bash set -e -# Start pulseaudio with a null sink so we can capture audio -pulseaudio -D --exit-idle-time=-1 --disable-shm=true --system=false +# Create runtime directory for PulseAudio +mkdir -p /tmp/pulse +export PULSE_RUNTIME_PATH=/tmp/pulse -# The above automatically loads module-null-sink by default in most distros, -# but if you need it explicitly, you can do something like: -# pactl load-module module-null-sink sink_name=MySink -# pactl set-default-sink MySink -# For many cases, the default config is enough. +# Kill any existing pulseaudio instances +pulseaudio --kill 2>/dev/null || true -# Just sleep forever to keep the container from stopping if we ran only this script -sleep infinity +# Start pulseaudio with a dummy sink for capturing +pulseaudio --start \ + --exit-idle-time=-1 \ + --disallow-module-loading=false \ + --disallow-exit=true \ + --log-target=stderr \ + --load="module-null-sink sink_name=dummy_sink sink_properties=device.description='Dummy_Output'" \ + --load="module-native-protocol-unix auth-anonymous=1 socket=/tmp/pulse/native" + +# Wait for PulseAudio to be ready +for i in {1..10}; do + if pactl info >/dev/null 2>&1; then + echo "PulseAudio started successfully" + break + fi + echo "Waiting for PulseAudio to start... ($i/10)" + sleep 1 +done + +# Set the dummy sink as default +pactl set-default-sink dummy_sink || true +pactl set-default-source dummy_sink.monitor || true + +echo "PulseAudio virtual audio setup complete"