Compare commits
12 Commits
ee4673737f
...
de48213dce
Author | SHA1 | Date |
---|---|---|
Tomáš Mládek | de48213dce | |
Tomáš Mládek | 5bbd505ff6 | |
Tomáš Mládek | dd4bd1d497 | |
Tomáš Mládek | 589e235756 | |
Tomáš Mládek | c06273269e | |
Tomáš Mládek | d55deee33a | |
Tomáš Mládek | 76e81c1f60 | |
Tomáš Mládek | 2d44469d0e | |
Tomáš Mládek | 2ac8cf0e7c | |
Tomáš Mládek | a00f2091e5 | |
Tomáš Mládek | 01e5968b5c | |
Tomáš Mládek | b97fc46a5a |
|
@ -0,0 +1,2 @@
|
|||
*/node_modules
|
||||
Earthfile
|
|
@ -0,0 +1 @@
|
|||
**/*.wav filter=lfs diff=lfs merge=lfs -text
|
|
@ -1,3 +1,6 @@
|
|||
assets/generated/*
|
||||
!assets/generated/.gitkeep
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
|
|
90
Earthfile
90
Earthfile
|
@ -5,9 +5,10 @@ site:
|
|||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml /site
|
||||
WORKDIR /site
|
||||
CACHE $HOME/.local/share/pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
CACHE --id=pnpm $HOME/.local/share/pnpm
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY . /site
|
||||
COPY +assets-generated/* /site/assets/generated
|
||||
RUN pnpm build
|
||||
SAVE ARTIFACT build AS LOCAL build
|
||||
|
||||
|
@ -23,3 +24,88 @@ deploy:
|
|||
COPY +site/build /build
|
||||
RUN --secret SSH_TARGET --push rsync -cvrz --delete /build/ $SSH_TARGET
|
||||
|
||||
|
||||
avsync-video-components:
|
||||
# https://pptr.dev/troubleshooting
|
||||
RUN apt-get update && apt-get -y install libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 && rm -rf /var/lib/apt/lists/*
|
||||
RUN npm install -g pnpm
|
||||
RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser && mkdir /home/pptruser && chown -R pptruser:pptruser /home/pptruser
|
||||
USER pptruser
|
||||
COPY package.json pnpm-lock.yaml /site
|
||||
WORKDIR /site
|
||||
CACHE --id=pnpm /home/pptruser/.local/share/pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY av-sync av-sync
|
||||
ARG FPS=60
|
||||
ARG CYCLES=16
|
||||
ARG SIZE=1200
|
||||
RUN pnpm av:render:video --fps $FPS --cycles 1 --size $SIZE --output /var/tmp/frames
|
||||
SAVE ARTIFACT /var/tmp/frames
|
||||
RUN pnpm av:render:audio -i beep.wav -o /var/tmp/track.wav --repeats $CYCLES
|
||||
SAVE ARTIFACT /var/tmp/track.wav
|
||||
|
||||
aux-media:
|
||||
FROM debian:bookworm
|
||||
RUN apt-get update && apt-get install -y ffmpeg sox && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
avsync-video:
|
||||
FROM +aux-media
|
||||
RUN apt-get update && apt-get install -y ffmpeg sox && rm -rf /var/lib/apt/lists/*
|
||||
COPY +avsync-video-components/track.wav /track.wav
|
||||
COPY +avsync-video-components/frames /frames
|
||||
RUN find frames -type f | sort | xargs -I {} sh -c 'echo "file {}" >> /frames.txt'
|
||||
ARG CYCLES=16
|
||||
RUN for i in $(seq 1 $CYCLES); do cat /frames.txt >> /final-frames.txt; done
|
||||
ARG FPS=60
|
||||
RUN ffmpeg -r $FPS -f concat -i /final-frames.txt -i track.wav -c:v libvpx-vp9 -pix_fmt yuva420p -shortest avsync.webm
|
||||
SAVE ARTIFACT avsync.webm
|
||||
|
||||
audio-channel-tracks:
|
||||
FROM +aux-media
|
||||
RUN mkdir -p /input /output
|
||||
COPY assets/audio/channels /raw
|
||||
WORKDIR /raw
|
||||
RUN for file in *.wav; do sox $file /input/$file silence 1 0.1 0.1% reverse silence 1 0.1 0.1% reverse; done
|
||||
WORKDIR /input
|
||||
RUN mkdir -p /output/wav/stereo /output/wav/5.1 /output/wav/7.1
|
||||
RUN ffmpeg -i Left.wav -af "pan=stereo|FL=c0" /output/wav/stereo/Left.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Right.wav -af "pan=stereo|FR=c0" /output/wav/stereo/Right.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Center.wav -af "pan=stereo|FL=c0|FR=c0" /output/wav/stereo/Center.wav -hide_banner -loglevel error && \
|
||||
# 5.1
|
||||
ffmpeg -i Front_Left.wav -af "pan=5.1|FL=c0" /output/wav/5.1/Front_Left.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Front_Right.wav -af "pan=5.1|FR=c0" /output/wav/5.1/Front_Right.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Front_Center.wav -af "pan=5.1|FC=c0" /output/wav/5.1/Front_Center.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Noise.wav -af "pan=5.1|LFE=c0" /output/wav/5.1/LFE_Noise.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Rear_Left.wav -af "pan=5.1|BL=c0" /output/wav/5.1/Rear_Left.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Rear_Right.wav -af "pan=5.1|BR=c0" /output/wav/5.1/Rear_Right.wav -hide_banner -loglevel error && \
|
||||
# 7.1
|
||||
ffmpeg -i Front_Left.wav -af "pan=7.1|FL=c0" /output/wav/7.1/Front_Left.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Front_Right.wav -af "pan=7.1|FR=c0" /output/wav/7.1/Front_Right.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Front_Center.wav -af "pan=7.1|FC=c0" /output/wav/7.1/Front_Center.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Noise.wav -af "pan=7.1|LFE=c0" /output/wav/7.1/LFE_Noise.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Side_Left.wav -af "pan=7.1|SL=c0" /output/wav/7.1/Side_Left.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Side_Right.wav -af "pan=7.1|SR=c0" /output/wav/7.1/Side_Right.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Rear_Left.wav -af "pan=7.1|BL=c0" /output/wav/7.1/Rear_Left.wav -hide_banner -loglevel error && \
|
||||
ffmpeg -i Rear_Right.wav -af "pan=7.1|BR=c0" /output/wav/7.1/Rear_Right.wav -hide_banner -loglevel error
|
||||
SAVE ARTIFACT /output/wav/
|
||||
|
||||
audio-channel-tracks-ogg:
|
||||
FROM +audio-channel-tracks
|
||||
RUN mkdir -p /output/ogg/stereo /output/ogg/5.1 /output/ogg/7.1
|
||||
RUN for file in /output/wav/stereo/*.wav; do ffmpeg -i $file -c:a libvorbis /output/ogg/stereo/$(basename $file .wav).ogg -hide_banner -loglevel error; done && \
|
||||
for file in /output/wav/5.1/*.wav; do ffmpeg -i $file -c:a libvorbis /output/ogg/5.1/$(basename $file .wav).ogg -hide_banner -loglevel error; done && \
|
||||
for file in /output/wav/7.1/*.wav; do ffmpeg -i $file -c:a libvorbis /output/ogg/7.1/$(basename $file .wav).ogg -hide_banner -loglevel error; done
|
||||
SAVE ARTIFACT /output/ogg
|
||||
|
||||
audio-channel-tracks-mp3:
|
||||
FROM +audio-channel-tracks
|
||||
RUN mkdir -p /output/mp3/stereo /output/mp3/5.1 /output/mp3/7.1
|
||||
RUN for file in /output/wav/stereo/*.wav; do ffmpeg -i $file -c:a libmp3lame /output/mp3/stereo/$(basename $file .wav).mp3 -hide_banner -loglevel error; done && \
|
||||
for file in /output/wav/5.1/*.wav; do ffmpeg -i $file -c:a libmp3lame /output/mp3/5.1/$(basename $file .wav).mp3 -hide_banner -loglevel error; done && \
|
||||
for file in /output/wav/7.1/*.wav; do ffmpeg -i $file -c:a libmp3lame /output/mp3/7.1/$(basename $file .wav).mp3 -hide_banner -loglevel error; done
|
||||
SAVE ARTIFACT /output/mp3
|
||||
|
||||
assets-generated:
|
||||
COPY +avsync-video/avsync.webm /assets/avsync.webm
|
||||
COPY +audio-channel-tracks-mp3/mp3 /assets/audio/
|
||||
SAVE ARTIFACT /assets/* AS LOCAL assets/generated/
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
Binary file not shown.
|
@ -0,0 +1,11 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>AV SYNC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,36 @@
|
|||
import { Command } from 'commander';
|
||||
import wav from 'node-wav';
|
||||
import fs from 'fs';
|
||||
|
||||
const program = new Command();
|
||||
program
|
||||
.requiredOption('-i, --input <input>', 'Input file')
|
||||
.requiredOption('-o, --output <output>', 'Output file')
|
||||
.requiredOption('--repeats <repeats>', 'Number of repeats')
|
||||
.parse(process.argv);
|
||||
const options = program.opts();
|
||||
|
||||
let beep = wav.decode(fs.readFileSync(options.input));
|
||||
let samples = beep.channelData[0];
|
||||
|
||||
const sampleRate = beep.sampleRate;
|
||||
const silenceDuration = sampleRate - samples.length;
|
||||
const silenceSamples = new Float32Array(silenceDuration).fill(0);
|
||||
|
||||
let oneSecondChunk = new Float32Array(sampleRate);
|
||||
oneSecondChunk.set(samples, 0);
|
||||
oneSecondChunk.set(silenceSamples, samples.length);
|
||||
|
||||
let numberOfRepeats = parseInt(options.repeats);
|
||||
let finalSamples = new Float32Array(sampleRate * numberOfRepeats);
|
||||
|
||||
for (let i = 0; i < numberOfRepeats; i++) {
|
||||
finalSamples.set(oneSecondChunk, i * sampleRate);
|
||||
}
|
||||
|
||||
let halfSecondSilence = new Float32Array(sampleRate / 2).fill(0);
|
||||
finalSamples = Float32Array.from([...halfSecondSilence, ...finalSamples]);
|
||||
|
||||
let finalBuffer = wav.encode([finalSamples], { sampleRate: sampleRate, float: true, bitDepth: 32 });
|
||||
|
||||
fs.writeFileSync(options.output, finalBuffer);
|
|
@ -0,0 +1,51 @@
|
|||
import { Command } from 'commander';
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'fs';
|
||||
|
||||
const program = new Command();
|
||||
program
|
||||
.requiredOption('-o, --output <output>', 'Output directory')
|
||||
.requiredOption('--fps <fps>', 'Frames per second')
|
||||
.requiredOption('--cycles <cycles>', 'Number of cycles')
|
||||
.requiredOption('--size <size>', 'Size of the output in pixels')
|
||||
.requiredOption('--url <url>', 'URL to render')
|
||||
.parse(process.argv);
|
||||
const options = program.opts();
|
||||
|
||||
// mkdir p output path
|
||||
if (!fs.existsSync(options.output)) {
|
||||
fs.mkdirSync(options.output, { recursive: true });
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
args: ['--no-sandbox']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.setViewport({ width: parseInt(options.size, 10), height: parseInt(options.size, 10) });
|
||||
await page.goto(options.url);
|
||||
|
||||
await page.evaluate(async (fps) => {
|
||||
// @ts-ignore
|
||||
await window.setFps(fps);
|
||||
}, options.fps);
|
||||
|
||||
const totalFrames = parseInt(options.fps) * parseInt(options.cycles);
|
||||
const half = Math.floor(parseInt(options.fps) / 2);
|
||||
for (let frame = 0; frame < totalFrames; frame++) {
|
||||
let start = Date.now();
|
||||
await page.evaluate(async (n) => {
|
||||
// @ts-ignore
|
||||
await window.setFrame(n);
|
||||
}, frame + half);
|
||||
const path = `${options.output}/${frame.toString().padStart(Math.log10(totalFrames) + 1, '0')}.png`;
|
||||
await page.screenshot({ path, omitBackground: true });
|
||||
let end = Date.now();
|
||||
console.log(
|
||||
`Captured frame ${frame + half}: ${frame + 1}/${totalFrames} (took ${end - start}ms)`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Done.');
|
||||
|
||||
await browser.close();
|
|
@ -0,0 +1,119 @@
|
|||
<script lang="ts">
|
||||
import '@fontsource/b612';
|
||||
import '@fontsource/b612/700.css';
|
||||
import 'normalize.css/normalize.css';
|
||||
|
||||
import { onMount, tick } from 'svelte';
|
||||
import SectorIndicator from './components/SectorIndicator.svelte';
|
||||
import FlashIndicator from './components/FlashIndicator.svelte';
|
||||
import Scale from './components/Scale.svelte';
|
||||
|
||||
export let frame = 0;
|
||||
export let fps = 60;
|
||||
export let debug = false;
|
||||
|
||||
onMount(() => {
|
||||
window.setFps = async (newFps: number) => {
|
||||
fps = newFps;
|
||||
await tick();
|
||||
};
|
||||
|
||||
window.setFrame = async (frameNumber: number) => {
|
||||
frame = frameNumber;
|
||||
await tick();
|
||||
};
|
||||
|
||||
if (window.location.search.includes('debug')) {
|
||||
debug = true;
|
||||
}
|
||||
|
||||
if (window.location.search.includes('play')) {
|
||||
setInterval(() => {
|
||||
frame++;
|
||||
frame %= fps * 4;
|
||||
}, 1000 / fps);
|
||||
}
|
||||
|
||||
if (window.location.search.includes('frame')) {
|
||||
const frameNumber = parseInt(window.location.search.split('frame=')[1]);
|
||||
if (!isNaN(frameNumber)) {
|
||||
frame = frameNumber;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class:debug>
|
||||
<div class="cyclic">
|
||||
<div class="circular sector">
|
||||
<SectorIndicator {frame} {fps} />
|
||||
</div>
|
||||
|
||||
<div class="circular flash">
|
||||
<FlashIndicator {frame} {fps} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scale">
|
||||
<Scale {frame} {fps} />
|
||||
</div>
|
||||
|
||||
{#if debug}
|
||||
<div class="controls">
|
||||
<input type="range" min="0" max={fps * 4} bind:value={frame} />
|
||||
<div class="label">{frame} ({frame % fps}) / {Math.round((frame / fps) * 100) / 100} s)</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
color: white;
|
||||
--color-active: red;
|
||||
--color-inactive: white;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
|
||||
font-family: 'B612', 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.circular {
|
||||
width: 25vw;
|
||||
height: 25vw;
|
||||
}
|
||||
|
||||
.cyclic {
|
||||
width: 100vw;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.scale {
|
||||
width: 80vw;
|
||||
}
|
||||
|
||||
main.debug {
|
||||
background: black;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80vw;
|
||||
|
||||
text-align: center;
|
||||
|
||||
& input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,3 @@
|
|||
html, body {
|
||||
margin: 0;
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
export let frame: number;
|
||||
export let fps: number;
|
||||
|
||||
let el: SVGSVGElement;
|
||||
$: center = el?.clientWidth / 2;
|
||||
$: radius = center;
|
||||
|
||||
let opacity = 1;
|
||||
$: opacity = ease(1 - ((frame % fps) / fps) * 2);
|
||||
|
||||
function ease(x: number) {
|
||||
x = Math.max(0, Math.min(1, x));
|
||||
return 1 - Math.cos((x * Math.PI) / 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svg class="indicator" bind:this={el} style="--opacity: {opacity}">
|
||||
<circle cx={center} cy={center} r={radius}></circle>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.indicator {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
circle {
|
||||
fill: var(--color-active);
|
||||
opacity: var(--opacity);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,113 @@
|
|||
<script lang="ts">
|
||||
export let frame: number;
|
||||
export let fps: number;
|
||||
</script>
|
||||
|
||||
<div class="scale" style="--frame: {frame}; --fps: {fps}">
|
||||
<div class="labels">
|
||||
<div>Video Late</div>
|
||||
<div>Audio Late</div>
|
||||
</div>
|
||||
<div class="zero">
|
||||
<div class="label">0</div>
|
||||
<div class="mark"></div>
|
||||
</div>
|
||||
<div class="indicator"></div>
|
||||
<div class="ticks">
|
||||
<div class="tick"></div>
|
||||
{#each Array.from({ length: fps }, (_, i) => i) as i}
|
||||
<div class="spacer" class:active={i === (frame + fps / 2) % fps}>
|
||||
{#if i % (fps / 10) === 0 && i !== 0 && i !== fps / 2}
|
||||
<div class="label">{(i - fps / 2) * (1000 / fps)}ms</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tick"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="axis"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scale {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.labels {
|
||||
position: absolute;
|
||||
top: -3vw;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
font-size: 2vw;
|
||||
}
|
||||
|
||||
.labels > div {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.axis {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
|
||||
width: 100%;
|
||||
background: white;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
|
||||
& .spacer {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
|
||||
&.active {
|
||||
background: var(--color-active);
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
& .label {
|
||||
position: absolute;
|
||||
top: calc(100% + 2em);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(90deg);
|
||||
|
||||
line-height: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
& .tick {
|
||||
width: 2px;
|
||||
height: 5vh;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
.zero {
|
||||
position: absolute;
|
||||
top: calc(50% - 5vh * 2.5 / 2);
|
||||
left: calc(50%);
|
||||
width: calc(100% / var(--fps) - 1px);
|
||||
font-size: 2vw;
|
||||
|
||||
& .label {
|
||||
position: absolute;
|
||||
top: -1.2em;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
& .mark {
|
||||
width: 2px;
|
||||
height: calc(5vh * 2.5);
|
||||
background: white;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
export let frame: number;
|
||||
export let fps: number;
|
||||
|
||||
let el: SVGSVGElement;
|
||||
$: center = el?.clientWidth / 2;
|
||||
$: radius = center;
|
||||
let d = '';
|
||||
let circleOpacity = 1;
|
||||
|
||||
$: {
|
||||
const angle = ((frame / fps) * 360) % 360;
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const x = center + radius * Math.cos(radians);
|
||||
const y = center + radius * Math.sin(radians);
|
||||
d = `M${center},${center} L${center + radius},${center} A${radius},${radius} 0 ${angle > 180 ? 1 : 0} 1 ${x},${y} Z`;
|
||||
|
||||
const flashFrames = fps / 10;
|
||||
circleOpacity = (flashFrames - (frame % fps)) / flashFrames;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svg class="indicator" style="--circle-opacity: {circleOpacity}" bind:this={el}>
|
||||
<circle cx={center} cy={center} r={radius}></circle>
|
||||
<path {d}></path>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.indicator {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
circle {
|
||||
fill: var(--color-active);
|
||||
opacity: var(--circle-opacity);
|
||||
}
|
||||
|
||||
path {
|
||||
fill: var(--color-inactive);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,8 @@
|
|||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')!
|
||||
});
|
||||
|
||||
export default app;
|
|
@ -0,0 +1,11 @@
|
|||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
setFps: (fps: number) => Promise<void>;
|
||||
setFrame: (frame: number) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
|
@ -0,0 +1,7 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
})
|
33
package.json
33
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "testcard",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
@ -9,32 +9,45 @@
|
|||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"generate-assets": "earthly +assets-generated",
|
||||
"av:dev": "cd av-sync && vite",
|
||||
"av:render:video": "cd av-sync && concurrently -P -k -s command-1 \"vite --port 8626\" \"wait-on http://localhost:8626 && node render-video.js --url http://localhost:8626 {@}\" --",
|
||||
"av:render:audio": "cd av-sync && node render-audio.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tsconfig/svelte": "^5.0.2",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/eslint": "8.56.0",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"commander": "^12.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"node-wav": "^0.0.2",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.7",
|
||||
"puppeteer": "^22.1.0",
|
||||
"svelte-check": "^3.6.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3"
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fontsource/b612": "^5.0.8",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tabler/icons-webfont": "^2.47.0",
|
||||
"debug": "^4.3.4",
|
||||
"lodash": "^4.17.21",
|
||||
"normalize.css": "^8.0.1"
|
||||
"normalize.css": "^8.0.1",
|
||||
"svelte": "^4.2.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
|
1035
pnpm-lock.yaml
1035
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -9,9 +9,45 @@ body, html {
|
|||
background-color: black;
|
||||
|
||||
font-family: 'B612', 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1.5vw;
|
||||
font-size: min(1.5vw, 1.5vh);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
h1, h2, h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
text-decoration: none;
|
||||
border: 1px solid white;
|
||||
cursor: pointer;
|
||||
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 0.25em;
|
||||
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
select {
|
||||
background: black;
|
||||
color: white;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 0.25em;
|
||||
border: 1px solid white;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
|
@ -16,7 +16,8 @@
|
|||
let verticalMargin = MARGIN_SIZE;
|
||||
let unloaded = true;
|
||||
|
||||
let transparent = false;
|
||||
export let transparent = false;
|
||||
export let subdued = false;
|
||||
|
||||
function updateCounts() {
|
||||
const gridWidth = window.innerWidth - MARGIN_SIZE;
|
||||
|
@ -72,6 +73,7 @@
|
|||
class="background"
|
||||
class:unloaded
|
||||
class:transparent
|
||||
class:subdued
|
||||
class:even-vertical={verticalCount % 2 === 0}
|
||||
style="--horizontal-count: {horizontalCount};
|
||||
--vertical-count: {verticalCount};
|
||||
|
@ -266,6 +268,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
.background.subdued {
|
||||
& .edge,
|
||||
& .corner {
|
||||
opacity: 0.33;
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--horizontal-count), var(--block-size));
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<script>
|
||||
import { IconSpiral } from '@tabler/icons-svelte';
|
||||
export let size = 32;
|
||||
</script>
|
||||
|
||||
<div class="spinner"><IconSpiral {size} class="spinner-icon" /></div>
|
||||
|
||||
<style>
|
||||
.spinner {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:global(.spinner-icon) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -4,7 +4,10 @@
|
|||
import Axes from '$lib/Axes.svelte';
|
||||
import ColorGradient from '$lib/ColorGradient.svelte';
|
||||
import BrightnessGradient from '$lib/BrightnessGradient.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher<{ focus: void }>();
|
||||
|
||||
export let full = false;
|
||||
|
||||
let sizes = {
|
||||
blockSize: 64,
|
||||
|
@ -20,16 +23,12 @@
|
|||
$: circleBlocks =
|
||||
2 * Math.floor((Math.min(sizes.horizontalCount, sizes.verticalCount) * 0.66) / 2) +
|
||||
(sizes.horizontalCount % 2);
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('dblclick', () => {
|
||||
document.body.requestFullscreen();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="test-card"
|
||||
class:full
|
||||
style="--block-size: {sizes.blockSize}px;
|
||||
--horizontal-margin: {sizes.horizontalMargin}px;
|
||||
--vertical-margin: {sizes.verticalMargin}px;
|
||||
|
@ -37,9 +36,13 @@
|
|||
--column-width: {columnWidth};
|
||||
--column-height: {columnHeight};
|
||||
--left-column: {leftColumn};"
|
||||
on:dblclick={() => dispatch('focus') && document.body.requestFullscreen()}
|
||||
>
|
||||
<BackgroundGrid on:change={(ev) => (sizes = ev.detail)} />
|
||||
<Axes />
|
||||
<BackgroundGrid on:change={(ev) => (sizes = ev.detail)} subdued={!full} />
|
||||
|
||||
<div class="axes">
|
||||
<Axes />
|
||||
</div>
|
||||
|
||||
<div class="outer"></div>
|
||||
<div class="inner"></div>
|
||||
|
@ -137,4 +140,13 @@
|
|||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.test-card:not(.full) {
|
||||
& .info,
|
||||
& .column,
|
||||
& .axes,
|
||||
& .inner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts">
|
||||
import 'normalize.css/normalize.css';
|
||||
import '@fontsource/b612';
|
||||
import '@fontsource/b612/700.css';
|
||||
import '@tabler/icons-webfont/tabler-icons.css';
|
||||
import '../index.css';
|
||||
import TestCard from '$lib/TestCard.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let idleTimeout: NodeJS.Timeout | undefined;
|
||||
onMount(() => {
|
||||
window.addEventListener('mousemove', () => {
|
||||
clearTimeout(idleTimeout);
|
||||
document.body.classList.remove('idle');
|
||||
idleTimeout = setTimeout(() => {
|
||||
document.body.classList.add('idle');
|
||||
}, 3000);
|
||||
});
|
||||
});
|
||||
|
||||
$: onlyCard = $page.url.pathname === '/card';
|
||||
</script>
|
||||
|
||||
<TestCard full={onlyCard} on:focus={() => goto('/card')} />
|
||||
<main class:content={!onlyCard} class:sub={$page.url.pathname !== '/' && !onlyCard}>
|
||||
<a href=".." class="button button-back"><i class="ti ti-arrow-back" />Back</a>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main.content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid white;
|
||||
|
||||
padding: 1rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main.sub {
|
||||
height: 90vh;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.button-back {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
|
||||
opacity: 0.66;
|
||||
transition: opacity 0.3s;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
main:not(.sub) .button-back {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.hide-idle) {
|
||||
transition: opacity 1s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global(body.idle .hide-idle) {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
|
@ -1,9 +1,87 @@
|
|||
<script>
|
||||
import 'normalize.css/normalize.css';
|
||||
import '@fontsource/b612';
|
||||
import '@fontsource/b612/700.css';
|
||||
import '../index.css';
|
||||
import TestCard from '$lib/TestCard.svelte';
|
||||
import { version } from '../../package.json';
|
||||
</script>
|
||||
|
||||
<TestCard />
|
||||
<nav>
|
||||
<h1>Universal Test Card</h1>
|
||||
|
||||
<div class="options">
|
||||
<a href="card">
|
||||
<i class="ti ti-device-desktop"></i>
|
||||
Screen
|
||||
</a>
|
||||
<a href="audio">
|
||||
<i class="ti ti-volume"></i>
|
||||
Audio
|
||||
</a>
|
||||
<a href="av-sync">
|
||||
<i class="ti ti-time-duration-off"></i>
|
||||
AV Sync
|
||||
</a>
|
||||
<a href="keyboard">
|
||||
<i class="ti ti-keyboard"></i>
|
||||
Keyboard
|
||||
</a>
|
||||
<a href="mouse" class="disabled">
|
||||
<i class="ti ti-mouse"></i>
|
||||
Mouse
|
||||
</a>
|
||||
<a href="gamepad">
|
||||
<i class="ti ti-device-gamepad"></i>
|
||||
Gamepad
|
||||
</a>
|
||||
<a href="camera">
|
||||
<i class="ti ti-camera"></i>
|
||||
Camera
|
||||
</a>
|
||||
<a href="microphone" class="disabled">
|
||||
<i class="ti ti-microphone"></i>
|
||||
Microphone
|
||||
</a>
|
||||
<a href="sensors" class="disabled">
|
||||
<i class="ti ti-cpu-2"></i>
|
||||
Sensors
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<footer><a href="https://git.thm.place/thm/test-card">testcard v{version}</a></footer>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 3rem;
|
||||
margin: 1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
gap: 2em;
|
||||
|
||||
& a {
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
& .ti {
|
||||
display: block;
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
margin-top: 1rem;
|
||||
& a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<h2><i class="ti ti-volume"></i> Audio test</h2>
|
||||
<slot />
|
|
@ -0,0 +1 @@
|
|||
export const trailingSlash = 'always';
|
|
@ -0,0 +1,20 @@
|
|||
<h3>Channel tests</h3>
|
||||
<ul>
|
||||
<li><a href="channels/stereo">Stereo</a></li>
|
||||
<li><a href="channels/5.1">5.1 Surround</a></li>
|
||||
<li><a href="channels/7.1">7.1 Surround</a></li>
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let channelsEl: HTMLDivElement;
|
||||
|
||||
let cycling = false;
|
||||
async function cycleChannels() {
|
||||
cycling = true;
|
||||
const buttons = channelsEl.querySelectorAll('button');
|
||||
buttons.forEach((button) => (button.disabled = true));
|
||||
const channels = channelsEl.querySelectorAll('audio');
|
||||
while (cycling) {
|
||||
for (const channel of channels) {
|
||||
await channel.play();
|
||||
await new Promise((resolve) => {
|
||||
channel.onended = resolve;
|
||||
});
|
||||
if (!cycling) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
buttons.forEach((button) => (button.disabled = false));
|
||||
}
|
||||
|
||||
function startCycle() {
|
||||
cycling = !cycling;
|
||||
if (cycling) {
|
||||
cycleChannels();
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
cycling = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="channels" bind:this={channelsEl}>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button on:click={startCycle}>Cycle all</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
font-size: 2rem;
|
||||
flex-grow: 1;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:global(.channels .row) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.controls {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
:global(.channels .center) {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
:global(.channels .label) {
|
||||
opacity: 0.2;
|
||||
font-size: 6rem;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
onMount(() => {
|
||||
goto('..');
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
import Speaker from '../speaker.svelte';
|
||||
import frontLeftUrl from '@assets/audio/5.1/Front_Left.mp3';
|
||||
import frontCenterUrl from '@assets/audio/5.1/Front_Center.mp3';
|
||||
import frontRightUrl from '@assets/audio/5.1/Front_Right.mp3';
|
||||
import rearLeftUrl from '@assets/audio/5.1/Rear_Left.mp3';
|
||||
import rearRightUrl from '@assets/audio/5.1/Rear_Right.mp3';
|
||||
import LfeUrl from '@assets/audio/5.1/LFE_Noise.mp3';
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<Speaker src={frontLeftUrl} left>Front Left</Speaker>
|
||||
<div class="center">
|
||||
<Speaker src={frontCenterUrl} center>Front Center</Speaker>
|
||||
</div>
|
||||
<Speaker src={frontRightUrl} right>Front Right</Speaker>
|
||||
</div>
|
||||
<div class="row">
|
||||
<Speaker src={rearLeftUrl} left>Rear Left</Speaker>
|
||||
<Speaker src={rearRightUrl} right>Rear Right</Speaker>
|
||||
</div>
|
||||
<Speaker src={LfeUrl} lfe>LFE</Speaker>
|
||||
|
||||
<div class="label">5.1</div>
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
import Speaker from '../speaker.svelte';
|
||||
import frontLeftUrl from '@assets/audio/7.1/Front_Left.mp3';
|
||||
import frontCenterUrl from '@assets/audio/7.1/Front_Center.mp3';
|
||||
import frontRightUrl from '@assets/audio/7.1/Front_Right.mp3';
|
||||
import sideLeftUrl from '@assets/audio/7.1/Side_Left.mp3';
|
||||
import sideRightUrl from '@assets/audio/7.1/Side_Right.mp3';
|
||||
import rearLeftUrl from '@assets/audio/7.1/Rear_Left.mp3';
|
||||
import rearRightUrl from '@assets/audio/7.1/Rear_Right.mp3';
|
||||
import LfeUrl from '@assets/audio/7.1/LFE_Noise.mp3';
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<Speaker src={frontLeftUrl} left>Front Left</Speaker>
|
||||
<div class="center">
|
||||
<Speaker src={frontCenterUrl} center>Front Center</Speaker>
|
||||
</div>
|
||||
<Speaker src={frontRightUrl} right>Front Right</Speaker>
|
||||
</div>
|
||||
<div class="row">
|
||||
<Speaker src={sideLeftUrl} left>Side Left</Speaker>
|
||||
<Speaker src={sideRightUrl} right>Side Right</Speaker>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<Speaker src={rearLeftUrl} left>Rear Left</Speaker>
|
||||
<Speaker src={rearRightUrl} right>Rear Right</Speaker>
|
||||
</div>
|
||||
<Speaker src={LfeUrl} lfe>LFE</Speaker>
|
||||
|
||||
<div class="label">7.1</div>
|
|
@ -0,0 +1,68 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
const dispatch = createEventDispatcher<{ end: void }>();
|
||||
|
||||
export let src: string;
|
||||
export let left = false;
|
||||
export let center = false;
|
||||
export let right = false;
|
||||
export let lfe = false;
|
||||
|
||||
let currentTime = 0;
|
||||
let paused = true;
|
||||
function play() {
|
||||
currentTime = 0;
|
||||
paused = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="speaker"
|
||||
class:left
|
||||
class:right
|
||||
class:center
|
||||
class:lfe
|
||||
class:playing={!paused}
|
||||
on:click={play}
|
||||
>
|
||||
{#if !lfe}
|
||||
<i class="ti ti-volume"></i>
|
||||
{:else}
|
||||
<i class="ti ti-wave-sine"></i>
|
||||
{/if}
|
||||
<label><slot /></label>
|
||||
<audio bind:currentTime bind:paused {src}></audio>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.speaker {
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
& .ti {
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
&.right .ti {
|
||||
display: block;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
&.center .ti {
|
||||
display: block;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.33;
|
||||
}
|
||||
|
||||
&.playing {
|
||||
opacity: 0.66;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import Speaker from '../speaker.svelte';
|
||||
import leftUrl from '@assets/audio/stereo/Left.mp3';
|
||||
import centerUrl from '@assets/audio/stereo/Center.mp3';
|
||||
import rightUrl from '@assets/audio/stereo/Right.mp3';
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<Speaker src={leftUrl} left>Left</Speaker>
|
||||
<div class="center">
|
||||
<Speaker src={centerUrl} center>Center</Speaker>
|
||||
</div>
|
||||
<Speaker src={rightUrl} right>Right</Speaker>
|
||||
</div>
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import videoUrl from '@assets/avsync.webm';
|
||||
let paused = true;
|
||||
</script>
|
||||
|
||||
<h2><i class="ti ti-time-duration-off"></i> Audio/Video Synchronization</h2>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
class:playing={!paused}
|
||||
autoplay
|
||||
loop
|
||||
bind:paused
|
||||
src={videoUrl}
|
||||
on:click={() => (paused = false)}
|
||||
></video>
|
||||
|
||||
<style>
|
||||
video {
|
||||
flex-grow: 1;
|
||||
|
||||
&:not(.playing) {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
filter 0.3s;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,259 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import debug from 'debug';
|
||||
const dbg = debug('app:camera');
|
||||
|
||||
let video: HTMLVideoElement;
|
||||
let devices: MediaDeviceInfo[] = [];
|
||||
let currentDevice: string | undefined;
|
||||
|
||||
let requestResolution: [number, number] | 'auto' = 'auto';
|
||||
let requestFramerate: number | 'auto' = 'auto';
|
||||
let deviceInfo: {
|
||||
resolution?: string;
|
||||
frameRate?: number;
|
||||
} = {};
|
||||
let snapshot: string | undefined;
|
||||
let flipped = false;
|
||||
|
||||
$: dbg('devices %O', devices);
|
||||
$: dbg('currentDevice %s', currentDevice);
|
||||
|
||||
onMount(() => {
|
||||
refreshDevices();
|
||||
video.addEventListener('playing', () => {
|
||||
if (browser && video?.srcObject instanceof MediaStream) {
|
||||
deviceInfo = {
|
||||
resolution: `${video.videoWidth}x${video.videoHeight}`,
|
||||
frameRate: video?.srcObject?.getVideoTracks()[0]?.getSettings().frameRate
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser && video?.srcObject instanceof MediaStream) {
|
||||
video.srcObject.getTracks().forEach((t) => t.stop());
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshDevices() {
|
||||
devices = (await navigator.mediaDevices.enumerateDevices()).filter(
|
||||
(d) => d.kind === 'videoinput'
|
||||
);
|
||||
if (!currentDevice) {
|
||||
currentDevice = devices[0]?.deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
$: if (currentDevice) {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
video: {
|
||||
deviceId: currentDevice,
|
||||
width: requestResolution === 'auto' ? undefined : requestResolution[0],
|
||||
height: requestResolution === 'auto' ? undefined : requestResolution[1],
|
||||
frameRate: requestFramerate === 'auto' ? undefined : requestFramerate
|
||||
}
|
||||
})
|
||||
.then((stream) => {
|
||||
video.srcObject = stream;
|
||||
refreshDevices();
|
||||
});
|
||||
}
|
||||
|
||||
async function takeSnapshot() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
if (flipped) {
|
||||
ctx.scale(-1, 1);
|
||||
ctx.translate(-canvas.width, 0);
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
snapshot = canvas.toDataURL('image/png');
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2><i class="ti ti-camera"></i> Camera test</h2>
|
||||
|
||||
<div class="controls">
|
||||
<label>
|
||||
Device
|
||||
<select bind:value={currentDevice} disabled={!devices.length}>
|
||||
{#each devices as device}
|
||||
<option value={device.deviceId}>{device.label || '???'}</option>
|
||||
{:else}
|
||||
<option>No camera found</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button on:click={refreshDevices}>
|
||||
<i class="ti ti-refresh"></i>
|
||||
Refresh
|
||||
</button>
|
||||
<div class="separator"></div>
|
||||
<label>
|
||||
Resolution
|
||||
<select bind:value={requestResolution}>
|
||||
<option value="auto">Auto</option>
|
||||
<option value={[4096, 2160]}>4096x2160</option>
|
||||
<option value={[3840, 2160]}>3840x2160</option>
|
||||
<option value={[1920, 1080]}>1920x1080</option>
|
||||
<option value={[1280, 720]}>1280x720</option>
|
||||
<option value={[640, 480]}>640x480</option>
|
||||
<option value={[320, 240]}>320x240</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Frame rate
|
||||
<select bind:value={requestFramerate}>
|
||||
<option value="auto">Auto</option>
|
||||
<option value={120}>120 fps</option>
|
||||
<option value={60}>60 fps</option>
|
||||
<option value={30}>30 fps</option>
|
||||
<option value={15}>15 fps</option>
|
||||
<option value={10}>10 fps</option>
|
||||
<option value={5}>5 fps</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="display" class:snapshot={Boolean(snapshot)}>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video class:flipped bind:this={video} autoplay class:unloaded={!currentDevice}></video>
|
||||
{#if snapshot}
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<!--suppress HtmlRequiredAltAttribute -->
|
||||
<img src={snapshot} />
|
||||
<button on:click={() => (snapshot = undefined)}><i class="ti ti-x"></i></button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
{#if !currentDevice}
|
||||
<span class="subdued">No camera selected</span>
|
||||
{:else}
|
||||
<ul>
|
||||
{#key currentDevice}
|
||||
<li>
|
||||
Resolution: <strong>{deviceInfo.resolution || '???'}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Frame rate: <strong>{deviceInfo.frameRate || '???'}</strong>
|
||||
</li>
|
||||
{/key}
|
||||
</ul>
|
||||
<div class="controls">
|
||||
<button on:click={takeSnapshot}>
|
||||
<i class="ti ti-camera"></i>
|
||||
Take picture
|
||||
</button>
|
||||
<button on:click={() => (flipped = !flipped)}>
|
||||
<i class="ti ti-flip-vertical"></i>
|
||||
{#if flipped}
|
||||
Unflip image
|
||||
{:else}
|
||||
Flip image
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: stretch;
|
||||
gap: 1em;
|
||||
|
||||
& label:first-child {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
background: black;
|
||||
color: white;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 0.25em;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2em;
|
||||
|
||||
font-size: 0.8em;
|
||||
& select {
|
||||
font-size: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.display {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
|
||||
& img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
& button {
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
}
|
||||
|
||||
&.snapshot {
|
||||
& video {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
video {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
||||
margin: 1em 0;
|
||||
|
||||
&.unloaded {
|
||||
background: repeating-linear-gradient(45deg, gray, gray 20px, darkgray 20px, darkgray 40px);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&.flipped {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.subdued {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,25 @@
|
|||
<script>
|
||||
</script>
|
||||
|
||||
<a href="/" class="hide-idle"><i class="ti ti-arrow-back"></i> Back</a>
|
||||
|
||||
<style>
|
||||
a {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
|
||||
background: black;
|
||||
border: 1px solid white;
|
||||
border-radius: 0.2em;
|
||||
padding: 0.5em 1em;
|
||||
|
||||
box-shadow: 0 0 0.5em rgba(255, 255, 255, 0.5);
|
||||
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,163 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import debug from 'debug';
|
||||
const dbg = debug('app:camera');
|
||||
|
||||
let gamepads: Gamepad[] = [];
|
||||
let currentGamepad: Gamepad | undefined;
|
||||
let buttons: GamepadButton[] = [];
|
||||
let axes: number[] = [];
|
||||
|
||||
$: {
|
||||
if (currentGamepad) {
|
||||
function update() {
|
||||
buttons = currentGamepad?.buttons.concat() || [];
|
||||
axes = currentGamepad?.axes.concat() || [];
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
$: dbg('Gamepads %O', gamepads);
|
||||
$: dbg('Current gamepad %s', currentGamepad);
|
||||
|
||||
$: currentGamepad?.vibrationActuator?.playEffect('dual-rumble', {
|
||||
duration: 1000
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
refreshGamepads();
|
||||
});
|
||||
|
||||
async function refreshGamepads() {
|
||||
gamepads = browser ? (navigator.getGamepads().filter(Boolean) as Gamepad[]) : [];
|
||||
currentGamepad = gamepads[0];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('gamepadconnected', (e) => {
|
||||
dbg('Gamepad connected', e);
|
||||
refreshGamepads();
|
||||
});
|
||||
|
||||
window.addEventListener('gamepaddisconnected', (e) => {
|
||||
dbg('Gamepad disconnected', e);
|
||||
refreshGamepads();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2><i class="ti ti-device-gamepad"></i> Gamepad & Joystick Tests</h2>
|
||||
<div class="controls">
|
||||
<label>
|
||||
Device
|
||||
<select disabled={!gamepads.length}>
|
||||
{#each gamepads as gamepad}
|
||||
<option value={gamepad.index}>{gamepad.id}</option>
|
||||
{:else}
|
||||
<option>No gamepads detected. (Try pressing a button)</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button on:click={refreshGamepads}>
|
||||
<i class="ti ti-refresh"></i>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if currentGamepad}
|
||||
<section>
|
||||
<h3>Buttons</h3>
|
||||
<ul class="buttons">
|
||||
{#each buttons as button, i}
|
||||
<li class:pressed={button.pressed}>{i}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Axes</h3>
|
||||
<div class="axes">
|
||||
{#each axes as axis, i (i)}
|
||||
<div class="axis">
|
||||
<div>
|
||||
<span>{i}</span>
|
||||
<progress value={axis + 1} max="2"></progress>
|
||||
<span>{axis.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: stretch;
|
||||
gap: 1em;
|
||||
|
||||
& label:first-child {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2em;
|
||||
|
||||
font-size: 0.8em;
|
||||
& select {
|
||||
font-size: initial;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
|
||||
& li {
|
||||
display: block;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border: 1px solid white;
|
||||
border-radius: 0.75em;
|
||||
text-align: center;
|
||||
line-height: 2em;
|
||||
|
||||
&.pressed {
|
||||
background-color: darkred;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.axes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5em 2em;
|
||||
|
||||
& .axis div {
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
|
||||
& progress {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let key: string;
|
||||
let code: string;
|
||||
let pressedKeys: string[] = [];
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
key = event.key;
|
||||
code = event.code;
|
||||
pressedKeys = [...pressedKeys, event.key];
|
||||
pressedKeys = pressedKeys.slice(-50);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2>Keyboard testing</h2>
|
||||
<p>Press a key on the keyboard to see the event object and the key code.</p>
|
||||
<div class="current">
|
||||
{#if key}
|
||||
<span>{key}</span>
|
||||
{/if}
|
||||
{#if code}
|
||||
<span class="code">({code})</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p>Pressed keys:</p>
|
||||
<ul>
|
||||
{#each pressedKeys as key}
|
||||
<li>{key}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.current {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin-left: 1em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
|
@ -1,6 +1,17 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import * as path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
plugins: [sveltekit()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@assets': path.join(__dirname, 'assets/generated')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: [path.join(__dirname, 'assets/generated'), path.join(__dirname, 'package.json')]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue