diff --git a/.github/workflows/pr_staging_deploy.yml b/.github/workflows/pr_staging_deploy.yml index fa2a32c6..88934304 100644 --- a/.github/workflows/pr_staging_deploy.yml +++ b/.github/workflows/pr_staging_deploy.yml @@ -31,7 +31,7 @@ jobs: git config --global user.name github-actions git config --global user.email github-actions@github.com - name: Checkout Repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: path: "pr-build" - uses: pnpm/action-setup@v2 @@ -45,7 +45,7 @@ jobs: env: CI: "" - name: Checkout temporary deployment target repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: repository: dcyoung/${{ env.PR_REPO_NAME }} fetch-depth: 0 diff --git a/.github/workflows/pr_staging_teardown.yml b/.github/workflows/pr_staging_teardown.yml index a68db189..30a71639 100644 --- a/.github/workflows/pr_staging_teardown.yml +++ b/.github/workflows/pr_staging_teardown.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Delete repository for temporary deployment uses: dcyoung/ga-delete-git-repo@v1.0.0 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 44bffe27..d007c414 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: 8 diff --git a/app/package.json b/app/package.json index 7975bb92..854720d8 100644 --- a/app/package.json +++ b/app/package.json @@ -39,6 +39,7 @@ "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.6", "three": "^0.161.0", + "three-stdlib": "^2.29.4", "zod": "^3.22.4", "zod-fetch": "^0.1.1", "zustand": "^4.5.0" diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 4ba66b0c..0a99d966 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -80,6 +80,9 @@ dependencies: three: specifier: ^0.161.0 version: 0.161.0 + three-stdlib: + specifier: ^2.29.4 + version: 2.29.4(three@0.161.0) zod: specifier: ^3.22.4 version: 3.22.4 diff --git a/app/src/components/canvas/AutoOrbitCamera.tsx b/app/src/components/canvas/AutoOrbitCamera.tsx index de967c55..e5764327 100644 --- a/app/src/components/canvas/AutoOrbitCamera.tsx +++ b/app/src/components/canvas/AutoOrbitCamera.tsx @@ -2,6 +2,8 @@ import { useVisualContext } from "@/context/visual"; import { useFrame, useThree } from "@react-three/fiber"; import { Spherical, type Vector3 } from "three"; +import { VISUAL } from "../visualizers/common"; + const setFromSphericalZUp = (vec: Vector3, s: Spherical) => { const sinPhiRadius = Math.sin(s.phi) * s.radius; vec.x = sinPhiRadius * Math.sin(s.theta); @@ -16,7 +18,7 @@ const useSphericalLimits = () => { // theta is the equator angle // phi is the polar angle switch (visual) { - case "ribbons": + case VISUAL.RIBBONS: return { rMin: 10, rMax: 15, @@ -28,7 +30,7 @@ const useSphericalLimits = () => { phiMax: Math.PI / 2.1, phiSpeed: 0.25, }; - case "sphere": + case VISUAL.SPHERE: return { rMin: 10, rMax: 15, @@ -40,7 +42,7 @@ const useSphericalLimits = () => { phiMax: Math.PI / 2, phiSpeed: 0.25, }; - case "cube": + case VISUAL.CUBE: return { rMin: 12, rMax: 20, @@ -52,7 +54,7 @@ const useSphericalLimits = () => { phiMax: Math.PI / 2, phiSpeed: 0.25, }; - case "diffusedRing": + case VISUAL.DIFFUSED_RING: return { rMin: 10, rMax: 18, @@ -64,9 +66,21 @@ const useSphericalLimits = () => { phiMax: Math.PI / 2.25, phiSpeed: 0.25, }; - case "boxes": - case "dna": - case "grid": + case VISUAL.WALK: + return { + rMin: 15, + rMax: 22, + rSpeed: 0.1, + thetaMin: 0, + thetaMax: 2 * Math.PI, + thetaSpeed: 0.025, + phiMin: Math.PI / 3.5, + phiMax: Math.PI / 2.25, + phiSpeed: 0.25, + }; + case VISUAL.BOXES: + case VISUAL.DNA: + case VISUAL.GRID: return { rMin: 15, rMax: 22, diff --git a/app/src/components/controls/dock.tsx b/app/src/components/controls/dock.tsx index e9ebf9c4..ebdf6fd0 100644 --- a/app/src/components/controls/dock.tsx +++ b/app/src/components/controls/dock.tsx @@ -51,7 +51,7 @@ export const Dock = ({ return (
{
{showUI && (
- {mode !== APPLICATION_MODE.AUDIO_SCOPE && } + {mode !== APPLICATION_MODE.AUDIO_SCOPE && ( + + )}
{
1} defaultChecked={waveformFrequenciesHz.length > 1} onCheckedChange={(e) => { setWaveformFrequenciesHz(e ? [2.0, 10.0] : [2.0]); diff --git a/app/src/components/controls/visualSettingsSheet.tsx b/app/src/components/controls/visualSettingsSheet.tsx index 10769081..1306bcf8 100644 --- a/app/src/components/controls/visualSettingsSheet.tsx +++ b/app/src/components/controls/visualSettingsSheet.tsx @@ -19,6 +19,7 @@ import { } from "@/lib/palettes"; import { cn } from "@/lib/utils"; +import { VISUAL } from "../visualizers/common"; import { CubeVisualSettingsControls } from "./visual/cube"; import { DiffusedRingVisualSettingsControls } from "./visual/diffusedRing"; import { GridVisualSettingsControls } from "./visual/grid"; @@ -64,17 +65,18 @@ const PaletteIcon = ({ const VisualSettingsControls = () => { const { visual } = useVisualContext(); switch (visual) { - case "cube": + case VISUAL.CUBE: return CubeVisualSettingsControls(); - case "grid": + case VISUAL.GRID: return GridVisualSettingsControls(); - case "sphere": + case VISUAL.SPHERE: return SphereVisualSettingsControls(); - case "diffusedRing": + case VISUAL.DIFFUSED_RING: return DiffusedRingVisualSettingsControls(); - case "ribbons": - case "dna": - case "boxes": + case VISUAL.RIBBONS: + case VISUAL.DNA: + case VISUAL.BOXES: + case VISUAL.WALK: return null; default: return visual satisfies never; diff --git a/app/src/components/controls/visualsDock.tsx b/app/src/components/controls/visualsDock.tsx index 435199df..71bc3234 100644 --- a/app/src/components/controls/visualsDock.tsx +++ b/app/src/components/controls/visualsDock.tsx @@ -1,6 +1,7 @@ import { type HTMLAttributes } from "react"; import { AVAILABLE_VISUALS, + VISUAL, type VisualType, } from "@/components/visualizers/common"; import { useVisualContext, useVisualContextSetters } from "@/context/visual"; @@ -9,8 +10,10 @@ import { Boxes, CircleDashed, Dna, + Footprints, Globe, Grid3x3, + HelpCircle, Ribbon, } from "lucide-react"; @@ -18,22 +21,24 @@ import { Dock, DockItem, DockNav } from "./dock"; const VisualIcon = ({ visual }: { visual: VisualType }) => { switch (visual) { - case "grid": + case VISUAL.GRID: return ; - case "cube": + case VISUAL.CUBE: return ; - case "sphere": + case VISUAL.SPHERE: return ; - case "diffusedRing": + case VISUAL.DIFFUSED_RING: return ; - case "dna": + case VISUAL.DNA: return ; - case "boxes": + case VISUAL.BOXES: return ; - case "ribbons": + case VISUAL.RIBBONS: return ; + case VISUAL.WALK: + return ; default: - return visual satisfies never; + return ; } }; diff --git a/app/src/components/visualizers/common.ts b/app/src/components/visualizers/common.ts index afaff8d6..26c30348 100644 --- a/app/src/components/visualizers/common.ts +++ b/app/src/components/visualizers/common.ts @@ -12,16 +12,18 @@ export interface MotionVisualProps { scalarTracker?: IScalarTracker; } -export const AVAILABLE_VISUALS = [ - "grid", - "sphere", - "cube", - "diffusedRing", - "dna", - "boxes", - "ribbons", - // "stencil", - // "swarm", -] as const; +export const VISUAL = { + GRID: "grid", + SPHERE: "sphere", + CUBE: "cube", + DIFFUSED_RING: "diffusedRing", + DNA: "dna", + BOXES: "boxes", + RIBBONS: "ribbons", + WALK: "walk", + // STENCIL: "stencil", + // SWARM: "swarm", +} as const; -export type VisualType = (typeof AVAILABLE_VISUALS)[number]; +export const AVAILABLE_VISUALS = Object.values(VISUAL); +export type VisualType = (typeof VISUAL)[keyof typeof VISUAL]; diff --git a/app/src/components/visualizers/visual.tsx b/app/src/components/visualizers/visual.tsx new file mode 100644 index 00000000..628974e7 --- /dev/null +++ b/app/src/components/visualizers/visual.tsx @@ -0,0 +1,26 @@ +import { Suspense } from "react"; +import { useVisualComponent } from "@/hooks/useVisualComponent"; +import { type ICoordinateMapper } from "@/lib/mappers/coordinateMappers/common"; +import { type IScalarTracker } from "@/lib/mappers/valueTracker/common"; + +import { type VisualType } from "./common"; + +export const Visual = ({ + visual, + coordinateMapper, + scalarTracker, +}: { + visual: VisualType; + coordinateMapper?: ICoordinateMapper; + scalarTracker?: IScalarTracker; +}) => { + const VisualComponent = useVisualComponent(visual); + return ( + + + + ); +}; diff --git a/app/src/components/visualizers/visualizerAudio.tsx b/app/src/components/visualizers/visualizerAudio.tsx index 851d6200..35950ad9 100644 --- a/app/src/components/visualizers/visualizerAudio.tsx +++ b/app/src/components/visualizers/visualizerAudio.tsx @@ -1,10 +1,11 @@ -import { lazy, Suspense, useMemo } from "react"; import { type VisualType } from "@/components/visualizers/common"; import { useFFTAnalyzerContext } from "@/context/fftAnalyzer"; import { useEnergyInfo, useVisualSourceDataX } from "@/lib/appState"; import { CoordinateMapper_Data } from "@/lib/mappers/coordinateMappers/data"; import { EnergyTracker } from "@/lib/mappers/valueTracker/energyTracker"; +import { Visual } from "./visual"; + const AudioVisual = ({ visual }: { visual: VisualType }) => { const freqData = useVisualSourceDataX(); const energyInfo = useEnergyInfo(); @@ -14,22 +15,12 @@ const AudioVisual = ({ visual }: { visual: VisualType }) => { const coordinateMapper = new CoordinateMapper_Data(amplitude, freqData); const energyTracker = new EnergyTracker(energyInfo); - const VisualComponent = useMemo( - () => - lazy( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - async () => await import(`./${visual}/reactive.tsx`), - ), - [visual], - ); - return ( - - - + ); }; diff --git a/app/src/components/visualizers/visualizerNoise.tsx b/app/src/components/visualizers/visualizerNoise.tsx index 62791033..ace0b68c 100644 --- a/app/src/components/visualizers/visualizerNoise.tsx +++ b/app/src/components/visualizers/visualizerNoise.tsx @@ -1,18 +1,10 @@ -import { lazy, Suspense, useMemo } from "react"; import { type VisualType } from "@/components/visualizers/common"; import { useNoiseGeneratorContext } from "@/context/noiseGenerator"; import { CoordinateMapper_Noise } from "@/lib/mappers/coordinateMappers/noise"; -const NoiseVisual = ({ visual }: { visual: VisualType }) => { - const VisualComponent = useMemo( - () => - lazy( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - async () => await import(`./${visual}/reactive.tsx`), - ), - [visual], - ); +import { Visual } from "./visual"; +const NoiseVisual = ({ visual }: { visual: VisualType }) => { const { amplitude, spatialScale, timeScale, nIterations } = useNoiseGeneratorContext(); @@ -23,13 +15,7 @@ const NoiseVisual = ({ visual }: { visual: VisualType }) => { nIterations, ); - return ( - <> - - - - - ); + return ; }; export default NoiseVisual; diff --git a/app/src/components/visualizers/visualizerWaveform.tsx b/app/src/components/visualizers/visualizerWaveform.tsx index bfbf8e53..4b333d30 100644 --- a/app/src/components/visualizers/visualizerWaveform.tsx +++ b/app/src/components/visualizers/visualizerWaveform.tsx @@ -1,18 +1,10 @@ -import { lazy, Suspense, useMemo } from "react"; import { type VisualType } from "@/components/visualizers/common"; import { useWaveGeneratorContext } from "@/context/waveGenerator"; import { CoordinateMapper_WaveformSuperposition } from "@/lib/mappers/coordinateMappers/waveform"; -const WaveformVisual = ({ visual }: { visual: VisualType }) => { - const VisualComponent = useMemo( - () => - lazy( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - async () => await import(`./${visual}/reactive.tsx`), - ), - [visual], - ); +import { Visual } from "./visual"; +const WaveformVisual = ({ visual }: { visual: VisualType }) => { const { maxAmplitude, waveformFrequenciesHz, amplitudeSplitRatio } = useWaveGeneratorContext(); @@ -22,11 +14,7 @@ const WaveformVisual = ({ visual }: { visual: VisualType }) => { amplitudeSplitRatio, ); - return ( - - - - ); + return ; }; export default WaveformVisual; diff --git a/app/src/components/visualizers/walk/horse.png b/app/src/components/visualizers/walk/horse.png new file mode 100644 index 00000000..66fb3026 Binary files /dev/null and b/app/src/components/visualizers/walk/horse.png differ diff --git a/app/src/components/visualizers/walk/horse.tsx b/app/src/components/visualizers/walk/horse.tsx new file mode 100644 index 00000000..29a1006c --- /dev/null +++ b/app/src/components/visualizers/walk/horse.tsx @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useRef } from "react"; +import { type VisualProps } from "@/components/visualizers/common"; +import { usePalette } from "@/lib/appState"; +import { ColorPalette } from "@/lib/palettes"; +import { useAnimations, useGLTF } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { type Group } from "three"; +import { type GLTF } from "three-stdlib"; + +import MODEL_HORSE from "./horse.png"; + +type GLTFResult = GLTF & { + nodes: { + mesh_0: THREE.Mesh; + }; + materials: Record; + animations: GLTFAction[]; +}; + +type ActionName = "horse_A_"; + +interface GLTFAction extends THREE.AnimationClip { + name: ActionName; +} + +const Horse = (_: VisualProps) => { + const group = useRef(null); + const { nodes, animations } = useGLTF(MODEL_HORSE) as GLTFResult; + const palette = usePalette(); + const lut = ColorPalette.getPalette(palette).buildLut(); + + const material = useMemo(() => { + // const mat = nodes.mesh_0.material as MeshStandardMaterial; + return ( + + ); + }, [lut]); + + const { actions } = useAnimations(animations, group); + + useEffect(() => { + actions?.horse_A_?.play(); + }); + + useFrame(({ clock }) => { + const t = clock.getElapsedTime(); + + const rateOfChange = 0.5; + const tScale = (Math.sin(rateOfChange * t) + 1) / 2; + actions?.horse_A_?.setEffectiveTimeScale(tScale); + }); + + return ( + + + + + {material} + + + + ); +}; + +export default Horse; diff --git a/app/src/components/visualizers/walk/reactive.tsx b/app/src/components/visualizers/walk/reactive.tsx new file mode 100644 index 00000000..fcea4ec9 --- /dev/null +++ b/app/src/components/visualizers/walk/reactive.tsx @@ -0,0 +1,15 @@ +import { type VisualProps } from "@/components/visualizers/common"; + +import Horse from "./horse"; +import { Treadmill } from "./treadmill"; + +const WalkVisual = ({ ...props }: VisualProps) => { + return ( + <> + + + + ); +}; + +export default WalkVisual; diff --git a/app/src/components/visualizers/walk/treadmill.tsx b/app/src/components/visualizers/walk/treadmill.tsx new file mode 100644 index 00000000..73c59cef --- /dev/null +++ b/app/src/components/visualizers/walk/treadmill.tsx @@ -0,0 +1,132 @@ +import { useEffect, useMemo, useRef } from "react"; +import { usePalette } from "@/lib/appState"; +import { easeInOut, EASING_FUNCTION } from "@/lib/easing"; +import { COORDINATE_TYPE } from "@/lib/mappers/coordinateMappers/common"; +import { ColorPalette } from "@/lib/palettes"; +import { useFrame } from "@react-three/fiber"; +import { + BoxGeometry, + CatmullRomCurve3, + Matrix4, + MeshStandardMaterial, + Quaternion, + Vector3, + type InstancedMesh, +} from "three"; + +import { type VisualProps } from "../common"; + +const curvePoints = [ + [-1, 1], + [-0.75, -0.1], + [-0.6, 0], + [0, 0], + [0.6, 0], + [0.75, -0.1], + [1, 1], +] as const; + +export const Treadmill = ({ + nStones = 30, + stoneWidth = 5, + stoneHeight = 0.1, + stoneLength = 1, + coordinateMapper, +}: VisualProps & { + nStones?: number; + stoneWidth?: number; + stoneHeight?: number; + stoneLength?: number; +}) => { + const stoneRef = useRef(null); + const [tmpMatrix, tmpVecPosition, tmpQuat, tmpVecScale] = useMemo( + () => [new Matrix4(), new Vector3(), new Quaternion(), new Vector3()], + [], + ); + + const curve = useMemo(() => { + const scale = 10.0; + return new CatmullRomCurve3( + curvePoints.map((v) => new Vector3(0, v[0], v[1]).multiplyScalar(scale)), + false, + "catmullrom", + 0.1, + ); + }, []); + + const palette = usePalette(); + const lut = ColorPalette.getPalette(palette).buildLut(); + useEffect(() => { + if (!stoneRef.current) { + return; + } + for (let instanceIdx = 0; instanceIdx < nStones; instanceIdx++) { + stoneRef.current.setColorAt( + instanceIdx, + lut.getColor(instanceIdx / (nStones - 1)), + ); + } + stoneRef.current.instanceColor!.needsUpdate = true; + }, [stoneRef, lut, nStones]); + + useFrame(({ clock }) => { + if (!stoneRef.current) { + return; + } + const t = clock.getElapsedTime(); + const speed = 0.025; + + const q = 0.5; + // assume v(t) = (sin(q * t) + 1) / 2... integrate to find position + const normPosition = (speed * (t - Math.cos(q * t) / q)) % 1; + + const alphaRaw = normPosition; + const alpha = 1 - easeInOut(alphaRaw, EASING_FUNCTION.LINEAR); + + for (let instanceIdx = 0; instanceIdx < nStones; instanceIdx++) { + const stoneAlpha = (alpha + instanceIdx / nStones) % 1; + const widthScalar = 1 - 2 * Math.abs(stoneAlpha - 0.5); + // const widthScalar = 1; + + const dataAlpha = Math.abs(stoneAlpha - 0.5); + const mappedWidthScalar = + 0.5 + + coordinateMapper.map(COORDINATE_TYPE.CARTESIAN_1D, dataAlpha, t) / 2; + const finalWidthScalar = widthScalar * (1 + mappedWidthScalar); + + // + stoneRef.current.getMatrixAt(instanceIdx, tmpMatrix); + tmpMatrix.decompose(tmpVecPosition, tmpQuat, tmpVecScale); + + curve.getPointAt(stoneAlpha, tmpVecPosition); + tmpVecScale.set(finalWidthScalar, 1, 1); + + tmpMatrix.compose(tmpVecPosition, tmpQuat, tmpVecScale); + stoneRef.current.setMatrixAt(instanceIdx, tmpMatrix); + } + + stoneRef.current.instanceMatrix.needsUpdate = true; + }); + + return ( + <> + + + + + + ); +}; diff --git a/app/src/context/visual.tsx b/app/src/context/visual.tsx index 483e51cc..08ce7ce6 100644 --- a/app/src/context/visual.tsx +++ b/app/src/context/visual.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { AVAILABLE_VISUALS, + VISUAL, type VisualType, } from "@/components/visualizers/common"; import { APPLICATION_MODE } from "@/lib/applicationModes"; @@ -62,7 +63,7 @@ export const VisualContextProvider = ({ useEffect(() => { if (mode === APPLICATION_MODE.WAVE_FORM) { switch (visual) { - case "diffusedRing": + case VISUAL.DIFFUSED_RING: setWaveformFrequenciesHz([2.0, 10.0]); setMaxAmplitude(1.0); break; diff --git a/app/src/hooks/useVisualComponent.ts b/app/src/hooks/useVisualComponent.ts new file mode 100644 index 00000000..cf44c16a --- /dev/null +++ b/app/src/hooks/useVisualComponent.ts @@ -0,0 +1,14 @@ +import { lazy, useMemo } from "react"; +import { type VisualType } from "@/components/visualizers/common"; + +export const useVisualComponent = (visual: VisualType) => { + return useMemo( + () => + lazy( + async () => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + await import(`@/components/visualizers/${visual}/reactive.tsx`), + ), + [visual], + ); +}; diff --git a/app/vite.config.ts b/app/vite.config.ts index 5e84c4c6..858ab509 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -11,4 +11,5 @@ export default defineConfig({ }, }, base: "/r3f-audio-visualizer/", + assetsInclude: ["**/*.glb"], });