Compare commits
No commits in common. "main" and "develop" have entirely different histories.
100 changed files with 3785 additions and 6246 deletions
1
.dagger/.gitattributes
vendored
1
.dagger/.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
||||||
/sdk/** linguist-generated
|
|
||||||
4
.dagger/.gitignore
vendored
4
.dagger/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
||||||
/sdk
|
|
||||||
/**/node_modules/**
|
|
||||||
/**/.pnpm-store/**
|
|
||||||
/.env
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"typescript": "^5.5.4"
|
|
||||||
},
|
|
||||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
|
||||||
}
|
|
||||||
|
|
@ -1,393 +0,0 @@
|
||||||
/**
|
|
||||||
* A generated module for TestCard functions
|
|
||||||
*
|
|
||||||
* This module has been generated via dagger init and serves as a reference to
|
|
||||||
* basic module structure as you get started with Dagger.
|
|
||||||
*
|
|
||||||
* Two functions have been pre-created. You can modify, delete, or add to them,
|
|
||||||
* as needed. They demonstrate usage of arguments and return types using simple
|
|
||||||
* echo and grep commands. The functions can be called from the dagger CLI or
|
|
||||||
* from one of the SDKs.
|
|
||||||
*
|
|
||||||
* The first line in this comment block is a short description line and the
|
|
||||||
* rest is a long description with more detail on the module's purpose or usage,
|
|
||||||
* if appropriate. All modules should have a short description.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
dag,
|
|
||||||
Container,
|
|
||||||
Directory,
|
|
||||||
File,
|
|
||||||
Secret,
|
|
||||||
object,
|
|
||||||
func,
|
|
||||||
argument,
|
|
||||||
Platform
|
|
||||||
} from '@dagger.io/dagger';
|
|
||||||
|
|
||||||
@object()
|
|
||||||
export class TestCard {
|
|
||||||
// ============ Helpers ============
|
|
||||||
private auxMediaBase(): Container {
|
|
||||||
// Equivalent of Earthly target: aux-media
|
|
||||||
return dag
|
|
||||||
.container()
|
|
||||||
.from('debian:bookworm')
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'apt-get update && apt-get install -y ffmpeg sox && rm -rf /var/lib/apt/lists/*'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private nodeWithBunBase(platform?: Platform): Container {
|
|
||||||
return dag.container({ platform }).from('node:lts').withExec(['npm', 'install', '-g', 'bun']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ site ============
|
|
||||||
/**
|
|
||||||
* Build the website (equivalent to Earthly target `site`).
|
|
||||||
* Returns the built `build/` directory as a Dagger Directory.
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async site(@argument({ defaultPath: '/' }) source: Directory): Promise<Directory> {
|
|
||||||
const assets = await this.assetsGenerated(source);
|
|
||||||
|
|
||||||
const ctr = this.nodeWithBunBase()
|
|
||||||
.withMountedDirectory('/src', source)
|
|
||||||
.withMountedDirectory('/assets-generated', assets)
|
|
||||||
.withWorkdir('/src')
|
|
||||||
.withExec(['bun', 'install', '--frozen-lockfile'])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'mkdir -p /src/assets/generated && cp -a /assets-generated/. /src/assets/generated'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'sh',
|
|
||||||
'-lc',
|
|
||||||
'export VITE_BUILD_DATE=$(date -Iminutes -u | sed "s/+00:00//") && bun x svelte-kit sync && bun run build'
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ctr.directory('/src/build');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ deploy ============
|
|
||||||
/**
|
|
||||||
* Deploy the built site via rsync over SSH (equivalent to Earthly target `deploy`).
|
|
||||||
* Provide secrets corresponding to SSH config and target.
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async deploy(
|
|
||||||
sshConfig: Secret,
|
|
||||||
sshUploadKey: Secret,
|
|
||||||
sshKnownHosts: Secret,
|
|
||||||
sshTarget: Secret,
|
|
||||||
@argument({ defaultPath: '/build' }) build: Directory
|
|
||||||
): Promise<string> {
|
|
||||||
const ctr = dag
|
|
||||||
.container()
|
|
||||||
.from('alpine:latest')
|
|
||||||
.withExec(['sh', '-lc', 'apk add --no-cache openssh-client rsync'])
|
|
||||||
.withMountedDirectory('/build', build)
|
|
||||||
.withSecretVariable('SSH_CONFIG', sshConfig)
|
|
||||||
.withSecretVariable('SSH_UPLOAD_KEY', sshUploadKey)
|
|
||||||
.withSecretVariable('SSH_KNOWN_HOSTS', sshKnownHosts)
|
|
||||||
.withSecretVariable('SSH_TARGET', sshTarget)
|
|
||||||
.withExec([
|
|
||||||
'sh',
|
|
||||||
'-lc',
|
|
||||||
'mkdir -p "$HOME/.ssh" && echo "$SSH_CONFIG" > $HOME/.ssh/config && echo "$SSH_UPLOAD_KEY" > $HOME/.ssh/id_rsa && echo "$SSH_KNOWN_HOSTS" > $HOME/.ssh/known_hosts && chmod 600 $HOME/.ssh/*'
|
|
||||||
])
|
|
||||||
.withExec(['sh', '-lc', 'rsync -cvrz --delete /build/ "$SSH_TARGET"']);
|
|
||||||
|
|
||||||
return ctr.stdout();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ avsync-video-components ============
|
|
||||||
/**
|
|
||||||
* Render AV sync frames with Bun. Returns the frames directory.
|
|
||||||
* Mirrors the `avsync-video-components` video part.
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async avsyncFrames(
|
|
||||||
fps: number = 60,
|
|
||||||
size: number = 1200,
|
|
||||||
@argument({ defaultPath: '/' }) source: Directory
|
|
||||||
): Promise<Directory> {
|
|
||||||
const ctr = this.nodeWithBunBase()
|
|
||||||
// pptr troubleshooting libs
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'apt-get update && apt-get -y install chromium libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 && rm -rf /var/lib/apt/lists/*'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser && mkdir -p /home/pptruser && chown -R pptruser:pptruser /home/pptruser'
|
|
||||||
])
|
|
||||||
.withUser('pptruser')
|
|
||||||
.withDirectory('/site', source, { owner: 'pptruser', include: ['package.json', 'bun.lock'] })
|
|
||||||
.withWorkdir('/site')
|
|
||||||
.withExec(['bun', 'install', '--frozen-lockfile', '--verbose'])
|
|
||||||
.withEnvVariable('PUPPETEER_EXECUTABLE_PATH', '/usr/bin/chromium')
|
|
||||||
.withEnvVariable('PUPPETEER_SKIP_DOWNLOAD', '1')
|
|
||||||
.withDirectory('/site', source, { owner: 'pptruser' })
|
|
||||||
.withExec([
|
|
||||||
'bun',
|
|
||||||
'av:render:video',
|
|
||||||
'--fps',
|
|
||||||
String(fps),
|
|
||||||
'--cycles',
|
|
||||||
'1',
|
|
||||||
'--size',
|
|
||||||
String(size),
|
|
||||||
'--output',
|
|
||||||
'/var/tmp/frames'
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ctr.directory('/var/tmp/frames');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render AV sync audio track with Bun. Returns the WAV track file.
|
|
||||||
* Mirrors the `avsync-video-components` audio part.
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async avsyncTrack(
|
|
||||||
cycles: number = 16,
|
|
||||||
@argument({ defaultPath: '/' }) source: Directory
|
|
||||||
): Promise<File> {
|
|
||||||
const ctr = this.nodeWithBunBase()
|
|
||||||
.withDirectory('/site', source)
|
|
||||||
.withWorkdir('/site')
|
|
||||||
.withExec(['bun', 'install', '--frozen-lockfile'])
|
|
||||||
.withExec([
|
|
||||||
'bun',
|
|
||||||
'av:render:audio',
|
|
||||||
'-i',
|
|
||||||
'beep.wav',
|
|
||||||
'-o',
|
|
||||||
'/var/tmp/track.wav',
|
|
||||||
'--repeats',
|
|
||||||
String(cycles)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ctr.file('/var/tmp/track.wav');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ avsync-video ============
|
|
||||||
/**
|
|
||||||
* Compose frames and audio track into avsync.webm. Returns the resulting file.
|
|
||||||
* Mirrors the Earthly `avsync-video` target.
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async avsyncVideo(
|
|
||||||
fps: number = 60,
|
|
||||||
cycles: number = 16,
|
|
||||||
@argument({ defaultPath: '/' }) source: Directory
|
|
||||||
): Promise<File> {
|
|
||||||
const frames = await this.avsyncFrames(fps, 1200, source);
|
|
||||||
const track = await this.avsyncTrack(cycles, source);
|
|
||||||
|
|
||||||
const ctr = this.auxMediaBase()
|
|
||||||
.withMountedDirectory('/frames', frames)
|
|
||||||
.withMountedFile('/track.wav', track)
|
|
||||||
.withExec(['sh', '-lc', 'find /frames -type f | sort | sed "s#^#file #" > /frames.txt'])
|
|
||||||
.withEnvVariable('CYCLES', String(cycles))
|
|
||||||
.withExec([
|
|
||||||
'sh',
|
|
||||||
'-lc',
|
|
||||||
'for i in $(seq 1 $CYCLES); do cat /frames.txt >> /final-frames.txt; done'
|
|
||||||
])
|
|
||||||
.withEnvVariable('FPS', String(fps))
|
|
||||||
.withExec([
|
|
||||||
'sh',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -r "$FPS" -f concat -safe 0 -i /final-frames.txt -i /track.wav -c:v libvpx-vp9 -pix_fmt yuva420p -shortest /avsync.webm'
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ctr.file('/avsync.webm');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ audio-channel-tracks (WAV) ============
|
|
||||||
/**
|
|
||||||
* Generate stereo/5.1/7.1 WAV channel tracks from assets/audio/channels input.
|
|
||||||
* Mirrors the Earthly `audio-channel-tracks` target.
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async audioChannelTracksWav(@argument({ defaultPath: '/' }) src: Directory): Promise<Directory> {
|
|
||||||
const ctr = this.auxMediaBase()
|
|
||||||
.withMountedDirectory('/raw', src.directory('/assets/audio/channels'))
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'mkdir -p /input /output && cd /raw && for file in *.wav; do sox "$file" "/input/$file" silence 1 0.1 0.1% reverse silence 1 0.1 0.1% reverse; done'
|
|
||||||
])
|
|
||||||
.withWorkdir('/input')
|
|
||||||
.withExec(['bash', '-lc', 'mkdir -p /output/wav/stereo /output/wav/5.1 /output/wav/7.1'])
|
|
||||||
// stereo
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Left.wav -af "pan=stereo|FL=c0" /output/wav/stereo/Left.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Right.wav -af "pan=stereo|FR=c0" /output/wav/stereo/Right.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Center.wav -af "pan=stereo|FL=c0|FR=c0" /output/wav/stereo/Center.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
// 5.1
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Front_Left.wav -af "pan=5.1|FL=c0" /output/wav/5.1/Front_Left.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Front_Right.wav -af "pan=5.1|FR=c0" /output/wav/5.1/Front_Right.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Front_Center.wav -af "pan=5.1|FC=c0" /output/wav/5.1/Front_Center.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Noise.wav -af "pan=5.1|LFE=c0" /output/wav/5.1/LFE_Noise.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Rear_Left.wav -af "pan=5.1|BL=c0" /output/wav/5.1/Rear_Left.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Rear_Right.wav -af "pan=5.1|BR=c0" /output/wav/5.1/Rear_Right.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
// 7.1
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Front_Left.wav -af "pan=7.1|FL=c0" /output/wav/7.1/Front_Left.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Front_Right.wav -af "pan=7.1|FR=c0" /output/wav/7.1/Front_Right.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Front_Center.wav -af "pan=7.1|FC=c0" /output/wav/7.1/Front_Center.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Noise.wav -af "pan=7.1|LFE=c0" /output/wav/7.1/LFE_Noise.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Side_Left.wav -af "pan=7.1|SL=c0" /output/wav/7.1/Side_Left.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Side_Right.wav -af "pan=7.1|SR=c0" /output/wav/7.1/Side_Right.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Rear_Left.wav -af "pan=7.1|BL=c0" /output/wav/7.1/Rear_Left.wav -hide_banner -loglevel error'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'ffmpeg -i Rear_Right.wav -af "pan=7.1|BR=c0" /output/wav/7.1/Rear_Right.wav -hide_banner -loglevel error'
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ctr.directory('/output/wav');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ audio-channel-tracks-ogg ============
|
|
||||||
@func()
|
|
||||||
async audioChannelTracksOgg(
|
|
||||||
@argument({ defaultPath: '/' }) source: Directory
|
|
||||||
): Promise<Directory> {
|
|
||||||
const wavDir = await this.audioChannelTracksWav(source);
|
|
||||||
|
|
||||||
const ctr = this.auxMediaBase()
|
|
||||||
.withMountedDirectory('/output/wav', wavDir)
|
|
||||||
.withExec(['bash', '-lc', 'mkdir -p /output/ogg/stereo /output/ogg/5.1 /output/ogg/7.1'])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'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'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'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'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'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'
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ctr.directory('/output/ogg');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ audio-channel-tracks-mp3 ============
|
|
||||||
@func()
|
|
||||||
async audioChannelTracksMp3(
|
|
||||||
@argument({ defaultPath: '/' }) source: Directory
|
|
||||||
): Promise<Directory> {
|
|
||||||
const wavDir = await this.audioChannelTracksWav(source);
|
|
||||||
|
|
||||||
const ctr = this.auxMediaBase()
|
|
||||||
.withMountedDirectory('/output/wav', wavDir)
|
|
||||||
.withExec(['bash', '-lc', 'mkdir -p /output/mp3/stereo /output/mp3/5.1 /output/mp3/7.1'])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'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'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'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'
|
|
||||||
])
|
|
||||||
.withExec([
|
|
||||||
'bash',
|
|
||||||
'-lc',
|
|
||||||
'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'
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ctr.directory('/output/mp3');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ assets-generated ============
|
|
||||||
/**
|
|
||||||
* Aggregate generated assets into a directory containing:
|
|
||||||
* - avsync.webm
|
|
||||||
* - audio/ (MP3 variants)
|
|
||||||
*/
|
|
||||||
@func()
|
|
||||||
async assetsGenerated(@argument({ defaultPath: '/' }) source: Directory): Promise<Directory> {
|
|
||||||
const avsync = await this.avsyncVideo(60, 16, source);
|
|
||||||
const audioMp3 = await this.audioChannelTracksMp3(source);
|
|
||||||
|
|
||||||
return dag.directory().withFile('avsync.webm', avsync).withDirectory('audio', audioMp3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"paths": {
|
|
||||||
"@dagger.io/dagger": ["./sdk/index.ts"],
|
|
||||||
"@dagger.io/dagger/telemetry": ["./sdk/telemetry.ts"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
|
||||||
# yarn lockfile v1
|
|
||||||
|
|
||||||
|
|
||||||
typescript@^5.5.4:
|
|
||||||
version "5.9.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6"
|
|
||||||
integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==
|
|
||||||
|
|
@ -11,5 +11,3 @@ node_modules
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
.dagger
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
/** @type { import("eslint").Linter.Config } */
|
/** @type { import("eslint").Linter.Config } */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ignorePatterns: ['av-sync/**', '**/*.js'],
|
|
||||||
root: true,
|
root: true,
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
|
|
@ -13,8 +12,7 @@ module.exports = {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
extraFileExtensions: ['.svelte'],
|
extraFileExtensions: ['.svelte']
|
||||||
project: './tsconfig.json'
|
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
|
|
|
||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1,2 +1 @@
|
||||||
**/*.wav filter=lfs diff=lfs merge=lfs -text
|
**/*.wav filter=lfs diff=lfs merge=lfs -text
|
||||||
tests/output/* filter=lfs diff=lfs merge=lfs -text
|
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -11,9 +11,3 @@ node_modules
|
||||||
!.env.example
|
!.env.example
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
# Paraglide
|
|
||||||
src/lib/paraglide
|
|
||||||
|
|
||||||
tests/output/*-current.png
|
|
||||||
tests/output/*-diff.png
|
|
||||||
|
|
|
||||||
12
.idea/runConfigurations/dev.xml
generated
Normal file
12
.idea/runConfigurations/dev.xml
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="dev" type="js.build_tools.npm" nameIsGenerated="true">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="dev" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
|
|
@ -2,4 +2,3 @@
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
.dagger
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,22 @@
|
||||||
when:
|
pipeline:
|
||||||
- branch: main
|
|
||||||
event: push
|
|
||||||
|
|
||||||
steps:
|
|
||||||
update:
|
update:
|
||||||
image: earthly/earthly:v0.8.1
|
image: earthly/earthly:v0.8.1
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
FORCE_COLOR: 1
|
- FORCE_COLOR=1
|
||||||
EARTHLY_EXEC_CMD: '/bin/sh'
|
- EARTHLY_EXEC_CMD="/bin/sh"
|
||||||
EARTHLY_CONFIGURATION:
|
secrets:
|
||||||
from_secret: EARTHLY_CONFIGURATION
|
[
|
||||||
SSH_CONFIG:
|
EARTHLY_CONFIGURATION,
|
||||||
from_secret: THM_WEB_SSH_CONFIG
|
SSH_CONFIG,
|
||||||
SSH_UPLOAD_KEY:
|
SSH_UPLOAD_KEY,
|
||||||
from_secret: THM_WEB_DEPLOY_KEY
|
SSH_KNOWN_HOSTS,
|
||||||
SSH_KNOWN_HOSTS:
|
SSH_TARGET,
|
||||||
from_secret: THM_WEB_KNOWN_HOSTS
|
]
|
||||||
SSH_TARGET:
|
|
||||||
from_secret: SSH_TARGET
|
|
||||||
commands:
|
commands:
|
||||||
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
|
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
|
||||||
- earthly bootstrap
|
- earthly bootstrap
|
||||||
- earthly --secret SSH_CONFIG --secret SSH_UPLOAD_KEY --secret SSH_KNOWN_HOSTS --secret SSH_TARGET --push +deploy
|
- earthly --secret SSH_CONFIG --secret SSH_UPLOAD_KEY --secret SSH_KNOWN_HOSTS --secret SSH_TARGET --push +deploy
|
||||||
when:
|
when:
|
||||||
branch: ['main']
|
branch: ["main"]
|
||||||
|
|
|
||||||
175
AGENTS.md
175
AGENTS.md
|
|
@ -1,175 +0,0 @@
|
||||||
# AGENTS
|
|
||||||
|
|
||||||
This file orients coding agents for the test-card repo.
|
|
||||||
Stack: SvelteKit + Vite + TypeScript + Vitest + ESLint + Prettier.
|
|
||||||
Package scripts are in `package.json` at repo root.
|
|
||||||
|
|
||||||
## Repository layout
|
|
||||||
|
|
||||||
- `src/` SvelteKit app source.
|
|
||||||
- `src/routes/` route and load files.
|
|
||||||
- `src/lib/` shared code exposed via `$lib`.
|
|
||||||
- `tests/` Vitest tests and helpers.
|
|
||||||
- `av-sync/` small Vite + Svelte app for AV sync tools.
|
|
||||||
- `assets/` generated assets (see `npm run generate-assets`).
|
|
||||||
- `tests/output/` visual regression artifacts (baseline/current/diff).
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
- Use the existing package manager for scripts; root scripts assume `npm`.
|
|
||||||
- Install deps with `npm install` if needed.
|
|
||||||
- Do not add new dependencies without explicit permission.
|
|
||||||
|
|
||||||
## Common commands
|
|
||||||
|
|
||||||
- Dev server: `npm run dev`.
|
|
||||||
- Build: `npm run build`.
|
|
||||||
- Preview build: `npm run preview`.
|
|
||||||
- Typecheck: `npm run check`.
|
|
||||||
- Typecheck (watch): `npm run check:watch`.
|
|
||||||
- Lint: `npm run lint`.
|
|
||||||
- Format: `npm run format`.
|
|
||||||
- Generate assets: `npm run generate-assets`.
|
|
||||||
- AV sync dev server: `npm run av:dev` (runs inside `av-sync/`).
|
|
||||||
- AV render video: `npm run av:render:video -- <args>`.
|
|
||||||
- AV render audio: `npm run av:render:audio`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- Test runner: Vitest (no `test` script defined).
|
|
||||||
- Run all tests once: `npx vitest --run`.
|
|
||||||
- Run all tests in watch mode: `npx vitest`.
|
|
||||||
- Run a single file: `npx vitest --run tests/test-card.test.ts`.
|
|
||||||
- Run a single test by name: `npx vitest --run -t "matches baseline"`.
|
|
||||||
- Filter by file and name: `npx vitest --run tests/test-card.test.ts -t "matches baseline"`.
|
|
||||||
|
|
||||||
### Visual regression test notes
|
|
||||||
|
|
||||||
- The test starts its own dev server on port 5888.
|
|
||||||
- It uses Puppeteer; first run creates `tests/output/testcard-baseline.png`.
|
|
||||||
- If the baseline changes on purpose, delete the baseline and rerun.
|
|
||||||
- Diffs are written to `tests/output/testcard-diff.png` when mismatches exist.
|
|
||||||
|
|
||||||
## Formatting
|
|
||||||
|
|
||||||
- Prettier is the source of truth.
|
|
||||||
- Tabs for indentation (`useTabs: true`).
|
|
||||||
- Single quotes (`singleQuote: true`).
|
|
||||||
- No trailing commas (`trailingComma: none`).
|
|
||||||
- Max line width 100 (`printWidth: 100`).
|
|
||||||
- Svelte files use the Svelte parser.
|
|
||||||
|
|
||||||
## Linting
|
|
||||||
|
|
||||||
- ESLint with TypeScript + Svelte recommended rules and Prettier.
|
|
||||||
- ESLint ignores `av-sync/**` and all `*.js` files.
|
|
||||||
- Prefer fixing lint at the source rather than disabling rules.
|
|
||||||
|
|
||||||
## TypeScript and SvelteKit
|
|
||||||
|
|
||||||
- `tsconfig.json` is strict; keep types accurate.
|
|
||||||
- Prefer `type` imports: `import type { Foo } from '...'`.
|
|
||||||
- Keep `export const` config in SvelteKit route files.
|
|
||||||
- Use `+page.ts`/`+layout.ts` conventions and `$types` imports.
|
|
||||||
- Use `$lib` alias for shared code inside `src/lib/`.
|
|
||||||
- Keep server-only logic in `*.server.ts` or server load functions.
|
|
||||||
|
|
||||||
## Import style
|
|
||||||
|
|
||||||
- Order imports: external packages, internal `$lib` or alias, relative paths.
|
|
||||||
- Separate type-only imports from runtime imports when it improves clarity.
|
|
||||||
- Avoid deep relative paths when `$lib` can be used.
|
|
||||||
- Keep named imports sorted when it improves readability.
|
|
||||||
|
|
||||||
## Naming conventions
|
|
||||||
|
|
||||||
- Components: `PascalCase.svelte`.
|
|
||||||
- Functions/variables: `camelCase`.
|
|
||||||
- Constants: `UPPER_SNAKE_CASE` for true constants.
|
|
||||||
- Routes: use SvelteKit folder conventions; keep names short and descriptive.
|
|
||||||
- Files: favor `kebab-case` for non-component utilities.
|
|
||||||
|
|
||||||
## Code structure
|
|
||||||
|
|
||||||
- Use `const` by default; `let` only when reassigned.
|
|
||||||
- Prefer `async/await` over `.then()` chains.
|
|
||||||
- Keep functions small and focused; extract helpers to `src/lib/`.
|
|
||||||
- Keep Svelte components thin; move heavy logic to modules.
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- Throw `Error` objects with clear messages.
|
|
||||||
- Catch errors only when you can add context or recover.
|
|
||||||
- Avoid silent failures; log or rethrow with added context.
|
|
||||||
- For async utilities, reject with `Error` (not strings).
|
|
||||||
|
|
||||||
## Testing style
|
|
||||||
|
|
||||||
- Use Vitest `describe/it/expect`.
|
|
||||||
- Keep tests deterministic; avoid time-based flakiness.
|
|
||||||
- Store artifacts under `tests/output/` only.
|
|
||||||
- Use helper functions in `tests/utils.ts` for shared setup.
|
|
||||||
|
|
||||||
## Styling and UI
|
|
||||||
|
|
||||||
- This project uses Svelte; prefer local component styles.
|
|
||||||
- Keep global styles minimal and intentional.
|
|
||||||
- If editing fonts or assets, ensure they remain licensed in dependencies.
|
|
||||||
|
|
||||||
## Configuration files
|
|
||||||
|
|
||||||
- `vite.config.ts` defines the `@assets` alias and inlang plugin.
|
|
||||||
- `svelte.config.js` uses the static adapter.
|
|
||||||
- Keep config changes minimal and documented in code when needed.
|
|
||||||
|
|
||||||
## Dependency policy
|
|
||||||
|
|
||||||
- Do not add, remove, or upgrade deps without explicit permission.
|
|
||||||
- If you want a new dependency, explain why and ask first.
|
|
||||||
|
|
||||||
## Cursor/Copilot rules
|
|
||||||
|
|
||||||
- No `.cursor/rules/`, `.cursorrules`, or `.github/copilot-instructions.md` found.
|
|
||||||
|
|
||||||
## Agent workflow tips
|
|
||||||
|
|
||||||
- Prefer small, focused edits with Prettier-friendly formatting.
|
|
||||||
- Run `npm run lint` and `npx vitest --run` for changes touching logic.
|
|
||||||
- For UI-only tweaks, at least run `npm run lint`.
|
|
||||||
- For config changes, run `npm run check`.
|
|
||||||
- For AV sync changes, validate via `npm run av:dev`.
|
|
||||||
|
|
||||||
## When adding new files
|
|
||||||
|
|
||||||
- Follow the existing folder structure and naming conventions.
|
|
||||||
- Update tests if behavior changes.
|
|
||||||
- Avoid introducing new top-level folders without discussion.
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
- Use `debug` for structured logs in Node utilities.
|
|
||||||
- Avoid noisy `console.log` in production code.
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- Avoid unnecessary re-renders in Svelte components.
|
|
||||||
- Prefer derived values over repeated computation.
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
- Keep semantic HTML in Svelte templates.
|
|
||||||
- Ensure interactive elements are keyboard reachable.
|
|
||||||
|
|
||||||
## Data and assets
|
|
||||||
|
|
||||||
- Generated assets live under `assets/generated/`.
|
|
||||||
- Do not edit generated assets by hand.
|
|
||||||
|
|
||||||
## Build output
|
|
||||||
|
|
||||||
- `npm run build` uses Vite and SvelteKit static adapter.
|
|
||||||
- `npm run preview` serves the build for verification.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Keep this file updated when commands or conventions change.
|
|
||||||
111
Earthfile
Normal file
111
Earthfile
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
VERSION 0.7
|
||||||
|
FROM node:lts
|
||||||
|
|
||||||
|
site:
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
COPY package.json pnpm-lock.yaml /site
|
||||||
|
WORKDIR /site
|
||||||
|
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
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
FROM alpine
|
||||||
|
RUN apk add openssh-client rsync
|
||||||
|
RUN --secret SSH_CONFIG --secret SSH_UPLOAD_KEY --secret SSH_KNOWN_HOSTS \
|
||||||
|
mkdir -p $HOME/.ssh && \
|
||||||
|
echo "$SSH_CONFIG" > $HOME/.ssh/config && \
|
||||||
|
echo "$SSH_UPLOAD_KEY" > $HOME/.ssh/id_rsa && \
|
||||||
|
echo "$SSH_KNOWN_HOSTS" > $HOME/.ssh/known_hosts && \
|
||||||
|
chmod 600 $HOME/.ssh/*
|
||||||
|
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/
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>AV SYNC</title>
|
<title>AV SYNC</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '@fontsource/atkinson-hyperlegible';
|
import '@fontsource/b612';
|
||||||
import '@fontsource/atkinson-hyperlegible/700.css';
|
import '@fontsource/b612/700.css';
|
||||||
import 'normalize.css/normalize.css';
|
import 'normalize.css/normalize.css';
|
||||||
|
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
|
@ -8,25 +8,21 @@
|
||||||
import FlashIndicator from './components/FlashIndicator.svelte';
|
import FlashIndicator from './components/FlashIndicator.svelte';
|
||||||
import Scale from './components/Scale.svelte';
|
import Scale from './components/Scale.svelte';
|
||||||
|
|
||||||
interface Props {
|
export let frame = 0;
|
||||||
frame?: number;
|
export let fps = 60;
|
||||||
fps?: number;
|
export let debug = false;
|
||||||
debug?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { frame = $bindable(0), fps = $bindable(60), debug = $bindable(false) }: Props = $props();
|
|
||||||
|
|
||||||
window.setFps = async (newFps: number) => {
|
|
||||||
fps = newFps;
|
|
||||||
await tick();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.setFrame = async (frameNumber: number) => {
|
|
||||||
frame = frameNumber;
|
|
||||||
await tick();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
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')) {
|
if (window.location.search.includes('debug')) {
|
||||||
debug = true;
|
debug = true;
|
||||||
}
|
}
|
||||||
|
|
@ -84,8 +80,7 @@
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
font-family: 'Atkinson Hyperlegible', 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'B612', 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.circular {
|
.circular {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
html,
|
html, body {
|
||||||
body {
|
margin: 0;
|
||||||
margin: 0;
|
}
|
||||||
}
|
|
||||||
|
|
@ -1,24 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
export let frame: number;
|
||||||
frame: number;
|
export let fps: number;
|
||||||
fps: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { frame, fps }: Props = $props();
|
let el: SVGSVGElement;
|
||||||
|
$: center = el?.clientWidth / 2;
|
||||||
|
$: radius = center;
|
||||||
|
|
||||||
let el: SVGSVGElement | undefined = $state();
|
let opacity = 1;
|
||||||
|
$: opacity = ease(1 - ((frame % fps) / fps) * 2);
|
||||||
let opacity = $state(1);
|
|
||||||
|
|
||||||
function ease(x: number) {
|
function ease(x: number) {
|
||||||
x = Math.max(0, Math.min(1, x));
|
x = Math.max(0, Math.min(1, x));
|
||||||
return 1 - Math.cos((x * Math.PI) / 2);
|
return 1 - Math.cos((x * Math.PI) / 2);
|
||||||
}
|
}
|
||||||
let center = $derived((el?.clientWidth ?? 0) / 2);
|
|
||||||
let radius = $derived(center);
|
|
||||||
$effect(() => {
|
|
||||||
opacity = ease(1 - ((frame % fps) / fps) * 2);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg class="indicator" bind:this={el} style="--opacity: {opacity}">
|
<svg class="indicator" bind:this={el} style="--opacity: {opacity}">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
export let frame: number;
|
||||||
frame: number;
|
export let fps: number;
|
||||||
fps: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { frame, fps }: Props = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scale" style="--frame: {frame}; --fps: {fps}">
|
<div class="scale" style="--frame: {frame}; --fps: {fps}">
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
export let frame: number;
|
||||||
frame: number;
|
export let fps: number;
|
||||||
fps: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { frame, fps }: Props = $props();
|
let el: SVGSVGElement;
|
||||||
|
$: center = el?.clientWidth / 2;
|
||||||
|
$: radius = center;
|
||||||
|
let d = '';
|
||||||
|
let circleOpacity = 1;
|
||||||
|
|
||||||
let el: SVGSVGElement | undefined = $state();
|
$: {
|
||||||
let center = $derived((el?.clientWidth ?? 0) / 2);
|
|
||||||
let radius = $derived(center);
|
|
||||||
let d = $state('');
|
|
||||||
let circleOpacity = $state(1);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const angle = ((frame / fps) * 360) % 360;
|
const angle = ((frame / fps) * 360) % 360;
|
||||||
const radians = (angle * Math.PI) / 180;
|
const radians = (angle * Math.PI) / 180;
|
||||||
const x = center + radius * Math.cos(radians);
|
const x = center + radius * Math.cos(radians);
|
||||||
|
|
@ -21,7 +17,7 @@
|
||||||
|
|
||||||
const flashFrames = fps / 10;
|
const flashFrames = fps / 10;
|
||||||
circleOpacity = (flashFrames - (frame % fps)) / flashFrames;
|
circleOpacity = (flashFrames - (frame % fps)) / flashFrames;
|
||||||
});
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg class="indicator" style="--circle-opacity: {circleOpacity}" bind:this={el}>
|
<svg class="indicator" style="--circle-opacity: {circleOpacity}" bind:this={el}>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
import { mount } from "svelte";
|
|
||||||
|
|
||||||
const app = mount(App, {
|
const app = new App({
|
||||||
target: document.getElementById('app')!
|
target: document.getElementById('app')!
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess()
|
preprocess: vitePreprocess(),
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
{
|
{
|
||||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
/**
|
/**
|
||||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
* Note that setting allowJs false does not prevent the use
|
* Note that setting allowJs false does not prevent the use
|
||||||
* of JS in `.svelte` files.
|
* of JS in `.svelte` files.
|
||||||
*/
|
*/
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"isolatedModules": true
|
"isolatedModules": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite'
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()]
|
plugins: [svelte()],
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"name": "test-card",
|
|
||||||
"engineVersion": "v0.18.19",
|
|
||||||
"sdk": {
|
|
||||||
"source": "typescript"
|
|
||||||
},
|
|
||||||
"source": ".dagger"
|
|
||||||
}
|
|
||||||
12
index.html
Normal file
12
index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Expanded Modern Test Card</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
170
messages/cs.json
170
messages/cs.json
|
|
@ -1,170 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
||||||
"search": "Hledat",
|
|
||||||
"tests_audio_description": "Zkontrolujte své stereo kanály nebo prostorový zvukový výstup, ověřte, zda jsou reproduktory ve fázi.",
|
|
||||||
"tests_audio_label": "Zvuk",
|
|
||||||
"tests_av-sync_description": "Zkontrolujte, zda jsou zvuk a video synchronizovány, a změřte zpoždění.",
|
|
||||||
"tests_av-sync_label": "Synchronizace zvuku a videa",
|
|
||||||
"tests_card_description": "Testovací karta pro váš displej nebo projektor, zkontrolujte barvy, rozlišení a geometrii.",
|
|
||||||
"tests_card_label": "Karta",
|
|
||||||
"tests_camera_description": "Zkontrolujte, zda vaše webkamera nebo snímací zařízení funguje, jeho kvalitu obrazu, rozlišení a snímkovou frekvenci. Pořiďte snímek.",
|
|
||||||
"tests_camera_label": "Kamera",
|
|
||||||
"tests_gamepad_description": "Otestujte svůj gamepad, zkontrolujte, zda funguje, všechna tlačítka a joysticky, drift páček, mrtvé zóny a kalibraci.",
|
|
||||||
"tests_gamepad_label": "Gamepad",
|
|
||||||
"tests_keyboard_description": "Zkontrolujte, zda všechny klávesy fungují a jaké kódy kláves odesílají.",
|
|
||||||
"tests_keyboard_label": "Klávesnice",
|
|
||||||
"tests_microphone_description": "Zkontrolujte, zda váš mikrofon funguje, jeho kvalitu, hlasitost a šum.",
|
|
||||||
"tests_microphone_label": "Mikrofon",
|
|
||||||
"tests_mouse_description": "Zkontrolujte, zda vaše myš nebo dotykové zařízení funguje správně, zda existují mrtvé zóny nebo chvění.",
|
|
||||||
"tests_mouse_label": "Myš",
|
|
||||||
"tests_sensors_description": "Zobrazte výstup senzorů vašeho zařízení, např. GPS, akcelerometr, gyroskop, kompas atd.",
|
|
||||||
"tests_sensors_label": "Senzory",
|
|
||||||
"tests_internet_description": "Změřte rychlost a latenci internetu.",
|
|
||||||
"tests_internet_label": "Rychlost internetu",
|
|
||||||
"tests_timer_description": "Zkontrolujte, zda váš časovač s vysokým rozlišením funguje.",
|
|
||||||
"tests_timer_label": "Časovač s vysokým rozlišením",
|
|
||||||
"category_inputs": "Vstupy",
|
|
||||||
"category_outputs": "Výstupy",
|
|
||||||
"category_audio": "Zvuk",
|
|
||||||
"category_video": "Video",
|
|
||||||
"category_control": "Ovládání",
|
|
||||||
"category_misc": "Různé",
|
|
||||||
"noTestsFound": "Nebyly nalezeny žádné testy.",
|
|
||||||
"camera_title": "Test kamery",
|
|
||||||
"camera_device": "Zařízení",
|
|
||||||
"camera_noCameraFound": "Nebyla nalezena žádná kamera",
|
|
||||||
"camera_refresh": "Obnovit",
|
|
||||||
"camera_resolution": "Rozlišení",
|
|
||||||
"camera_frameRate": "Snímková frekvence",
|
|
||||||
"camera_noCameraSelected": "Není vybrána žádná kamera",
|
|
||||||
"camera_takePicture": "Vyfotit",
|
|
||||||
"camera_unflipImage": "Převrátit obrázek zpět",
|
|
||||||
"camera_flipImage": "Převrátit obrázek",
|
|
||||||
"camera_closeSnapshot": "Zavřít snímek",
|
|
||||||
"audio_channel_frontLeft": "Přední levý",
|
|
||||||
"audio_channel_frontCenter": "Přední středový",
|
|
||||||
"audio_channel_frontRight": "Přední pravý",
|
|
||||||
"audio_channel_sideLeft": "Boční levý",
|
|
||||||
"audio_channel_sideRight": "Boční pravý",
|
|
||||||
"audio_channel_rearLeft": "Zadní levý",
|
|
||||||
"audio_channel_rearRight": "Zadní pravý",
|
|
||||||
"audio_channel_lfe": "LFE",
|
|
||||||
"gamepad_title": "Testy gamepadu a joysticku",
|
|
||||||
"gamepad_device": "Zařízení",
|
|
||||||
"gamepad_noGamepadsDetected": "Nebyly detekovány žádné gamepady. (Zkuste stisknout tlačítko)",
|
|
||||||
"gamepad_refresh": "Obnovit",
|
|
||||||
"gamepad_buttons": "Tlačítka",
|
|
||||||
"gamepad_axes": "Osy",
|
|
||||||
"gamepad_history": "Historie",
|
|
||||||
"audio_channelTests": "Testy kanálů",
|
|
||||||
"audio_stereo": "Stereo",
|
|
||||||
"audio_surroundAudio": "Prostorový zvuk",
|
|
||||||
"audio_surround51": "5.1 Prostorový",
|
|
||||||
"audio_surround71": "7.1 Prostorový",
|
|
||||||
"audio_phaseTest": "Test fáze",
|
|
||||||
"audio_frequency": "Frekvence",
|
|
||||||
"audio_inPhase": "Ve fázi",
|
|
||||||
"audio_outOfPhase": "Mimo fázi",
|
|
||||||
"audio_stop": "Zastavit",
|
|
||||||
"screenInfo_screenResolution": "Rozlišení obrazovky",
|
|
||||||
"screenInfo_windowResolution": "Rozlišení okna",
|
|
||||||
"screenInfo_devicePixelRatio": "Poměr pixelů zařízení",
|
|
||||||
"audio_channel_left": "Levý",
|
|
||||||
"audio_channel_center": "Střed",
|
|
||||||
"audio_channel_right": "Pravý",
|
|
||||||
"keyboard_title": "Test klávesnice",
|
|
||||||
"keyboard_instruction": "Stiskněte klávesu na klávesnici pro zobrazení objektu události a kódu klávesy.",
|
|
||||||
"keyboard_pressedKeys": "Stisknuté klávesy:",
|
|
||||||
"timer_title": "Časovač s vysokým rozlišením",
|
|
||||||
"timer_fps": "FPS",
|
|
||||||
"timer_restart": "Restartovat",
|
|
||||||
"audio_stopCycling": "Zastavit cyklování",
|
|
||||||
"audio_cycleThrough": "Procházet",
|
|
||||||
"common_back": "Zpět",
|
|
||||||
"audio_title": "Test zvuku",
|
|
||||||
"avSync_title": "Synchronizace zvuku a videa",
|
|
||||||
"internet_title": "Rychlost internetu",
|
|
||||||
"tests_signal-generator_description": "Generujte sinusové vlny, šum (bílý, růžový, hnědý) a frekvenční přechody. Zahrnuje osciloskop a spektrum.",
|
|
||||||
"tests_signal-generator_label": "Generátor signálu",
|
|
||||||
"signalGen_title": "Generátor signálu",
|
|
||||||
"signalGen_type": "Typ",
|
|
||||||
"signalGen_sine": "Sinus",
|
|
||||||
"signalGen_sweep": "Přechod",
|
|
||||||
"signalGen_noiseWhite": "Bílý šum",
|
|
||||||
"signalGen_noisePink": "Růžový šum",
|
|
||||||
"signalGen_noiseBrown": "Hnědý šum",
|
|
||||||
"signalGen_frequency": "Frekvence",
|
|
||||||
"signalGen_from": "Od",
|
|
||||||
"signalGen_to": "Do",
|
|
||||||
"signalGen_duration": "Doba trvání",
|
|
||||||
"signalGen_gain": "Hlasitost",
|
|
||||||
"signalGen_start": "Start",
|
|
||||||
"signalGen_stop": "Stop",
|
|
||||||
"signalGen_scope": "Osciloskop",
|
|
||||||
"signalGen_spectrum": "Spektrum",
|
|
||||||
"signalGen_loop": "Smyčka",
|
|
||||||
"mic_title": "Test mikrofonu",
|
|
||||||
"mic_startMicrophone": "Spustit mikrofon",
|
|
||||||
"mic_stop": "Zastavit",
|
|
||||||
"mic_monitoringOn": "Monitorování: ZAP",
|
|
||||||
"mic_monitoringOff": "Monitorování: VYP",
|
|
||||||
"mic_gain": "Zesílení",
|
|
||||||
"mic_monitorDelay": "Zpoždění monitoru",
|
|
||||||
"mic_sampleRate": "Vzorkovací frekvence",
|
|
||||||
"mic_inputDevice": "Vstupní zařízení",
|
|
||||||
"mic_volume": "Hlasitost",
|
|
||||||
"mic_recording": "Nahrávání",
|
|
||||||
"mic_startRecording": "Spustit nahrávání",
|
|
||||||
"mic_stopRecording": "Zastavit nahrávání",
|
|
||||||
"mic_downloadRecording": "Stáhnout nahrávku",
|
|
||||||
"mic_device": "Zařízení",
|
|
||||||
"mic_noMicFound": "Nenalezen žádný mikrofon",
|
|
||||||
"mic_refresh": "Obnovit",
|
|
||||||
"mic_clipping": "Ořezávání",
|
|
||||||
"mic_constraints": "Omezení",
|
|
||||||
"mic_echoCancellation": "Potlačení ozvěny",
|
|
||||||
"mic_noiseSuppression": "Potlačení šumu",
|
|
||||||
"mic_agc": "Automatické řízení zisku",
|
|
||||||
"mic_applyConstraints": "Použít",
|
|
||||||
"mic_channels": "Kanály",
|
|
||||||
"mic_stereo": "Stereo",
|
|
||||||
"mic_requested": "Požadováno",
|
|
||||||
"mic_obtained": "Získáno",
|
|
||||||
"mic_peakNow": "Špička",
|
|
||||||
"mic_peakHold": "Držení špičky",
|
|
||||||
"mic_resetPeaks": "Resetovat špičky",
|
|
||||||
"mic_advanced": "Pokročilé",
|
|
||||||
"mic_default": "Výchozí",
|
|
||||||
"mic_on": "Zapnuto",
|
|
||||||
"mic_off": "Vypnuto",
|
|
||||||
"mic_mono": "Mono",
|
|
||||||
"sensors_title": "Senzory",
|
|
||||||
"sensors_geolocation": "Geolokace",
|
|
||||||
"sensors_start": "Start",
|
|
||||||
"sensors_stop": "Stop",
|
|
||||||
"sensors_accuracy": "Přesnost (m)",
|
|
||||||
"sensors_altitude": "Nadmořská výška (m)",
|
|
||||||
"sensors_heading": "Směr (stupně)",
|
|
||||||
"sensors_speed": "Rychlost (m/s)",
|
|
||||||
"sensors_timestamp": "Časové razítko",
|
|
||||||
"sensors_copy": "Kopírovat JSON",
|
|
||||||
"sensors_copied": "Zkopírováno do schránky",
|
|
||||||
"sensors_notSupported": "Není podporováno na tomto zařízení/prohlížeči",
|
|
||||||
"sensors_deviceMotion": "Pohyb zařízení",
|
|
||||||
"sensors_deviceOrientation": "Orientace zařízení",
|
|
||||||
"sensors_accelerometer": "Akcelerometr",
|
|
||||||
"sensors_gyroscope": "Gyroskop",
|
|
||||||
"sensors_magnetometer": "Magnetometr",
|
|
||||||
"sensors_ambientLight": "Okolní světlo",
|
|
||||||
"sensors_illuminance": "Osvětlení (lux)",
|
|
||||||
"sensors_barometer": "Barometr",
|
|
||||||
"sensors_pressure": "Tlak (hPa)",
|
|
||||||
"sensors_temperature": "Teplota (°C)",
|
|
||||||
"sensors_permissions": "Oprávnění",
|
|
||||||
"sensors_enableMotionOrientation": "Povolit pohyb/orientaci",
|
|
||||||
"sensors_motion": "Pohyb",
|
|
||||||
"sensors_orientation": "Orientace",
|
|
||||||
"sensors_status_granted": "Uděleno",
|
|
||||||
"sensors_status_denied": "Odepřeno",
|
|
||||||
"sensors_status_unknown": "Neznámý"
|
|
||||||
}
|
|
||||||
170
messages/de.json
170
messages/de.json
|
|
@ -1,170 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
||||||
"search": "Suchen",
|
|
||||||
"tests_audio_description": "Überprüfen Sie Ihre Stereokanäle oder den Surround-Audioausgang, überprüfen Sie, ob Ihre Lautsprecher in Phase sind.",
|
|
||||||
"tests_audio_label": "Audio",
|
|
||||||
"tests_av-sync_description": "Überprüfen Sie, ob Audio und Video synchron sind, und messen Sie die Verzögerung.",
|
|
||||||
"tests_av-sync_label": "Audio/Video-Synchronisation",
|
|
||||||
"tests_card_description": "Testkarte für Ihr Display oder Ihren Projektor, überprüfen Sie Farben, Auflösung und Geometrie.",
|
|
||||||
"tests_card_label": "Karte",
|
|
||||||
"tests_camera_description": "Überprüfen Sie, ob Ihre Webcam oder Ihr Aufnahmegerät funktioniert, die Bildqualität, Auflösung und Bildrate. Machen Sie einen Schnappschuss.",
|
|
||||||
"tests_camera_label": "Kamera",
|
|
||||||
"tests_gamepad_description": "Testen Sie Ihr Gamepad, überprüfen Sie, ob es funktioniert, alle Tasten und Joysticks, Stick-Drift, Totzonen und Kalibrierung.",
|
|
||||||
"tests_gamepad_label": "Gamepad",
|
|
||||||
"tests_keyboard_description": "Überprüfen Sie, ob alle Tasten funktionieren und welche Tastencodes sie senden.",
|
|
||||||
"tests_keyboard_label": "Tastatur",
|
|
||||||
"tests_microphone_description": "Überprüfen Sie, ob Ihr Mikrofon funktioniert, seine Qualität, Lautstärke und Rauschen.",
|
|
||||||
"tests_microphone_label": "Mikrofon",
|
|
||||||
"tests_mouse_description": "Überprüfen Sie, ob Ihre Maus oder Ihr Touch-Gerät ordnungsgemäß funktioniert, ob es tote Zonen oder Jitter gibt.",
|
|
||||||
"tests_mouse_label": "Maus",
|
|
||||||
"tests_sensors_description": "Sehen Sie sich die Ausgabe der Sensoren Ihres Geräts an, z. B. GPS, Beschleunigungsmesser, Gyroskop, Kompass usw.",
|
|
||||||
"tests_sensors_label": "Sensoren",
|
|
||||||
"tests_internet_description": "Messen Sie Ihre Internetgeschwindigkeit und Latenz.",
|
|
||||||
"tests_internet_label": "Internetgeschwindigkeit",
|
|
||||||
"tests_timer_description": "Überprüfen Sie, ob Ihr hochauflösender Timer funktioniert.",
|
|
||||||
"tests_timer_label": "Hochauflösender Timer",
|
|
||||||
"category_inputs": "Eingänge",
|
|
||||||
"category_outputs": "Ausgänge",
|
|
||||||
"category_audio": "Audio",
|
|
||||||
"category_video": "Video",
|
|
||||||
"category_control": "Steuerung",
|
|
||||||
"category_misc": "Sonstiges",
|
|
||||||
"noTestsFound": "Keine Tests gefunden.",
|
|
||||||
"camera_title": "Kameratest",
|
|
||||||
"camera_device": "Gerät",
|
|
||||||
"camera_noCameraFound": "Keine Kamera gefunden",
|
|
||||||
"camera_refresh": "Aktualisieren",
|
|
||||||
"camera_resolution": "Auflösung",
|
|
||||||
"camera_frameRate": "Bildrate",
|
|
||||||
"camera_noCameraSelected": "Keine Kamera ausgewählt",
|
|
||||||
"camera_takePicture": "Bild aufnehmen",
|
|
||||||
"camera_unflipImage": "Bild zurückklappen",
|
|
||||||
"camera_flipImage": "Bild spiegeln",
|
|
||||||
"camera_closeSnapshot": "Schnappschuss schließen",
|
|
||||||
"audio_channel_frontLeft": "Vorne links",
|
|
||||||
"audio_channel_frontCenter": "Vorne Mitte",
|
|
||||||
"audio_channel_frontRight": "Vorne rechts",
|
|
||||||
"audio_channel_sideLeft": "Seite links",
|
|
||||||
"audio_channel_sideRight": "Seite rechts",
|
|
||||||
"audio_channel_rearLeft": "Hinten links",
|
|
||||||
"audio_channel_rearRight": "Hinten rechts",
|
|
||||||
"audio_channel_lfe": "LFE",
|
|
||||||
"gamepad_title": "Gamepad- und Joystick-Tests",
|
|
||||||
"gamepad_device": "Gerät",
|
|
||||||
"gamepad_noGamepadsDetected": "Keine Gamepads erkannt. (Versuchen Sie, eine Taste zu drücken)",
|
|
||||||
"gamepad_refresh": "Aktualisieren",
|
|
||||||
"gamepad_buttons": "Tasten",
|
|
||||||
"gamepad_axes": "Achsen",
|
|
||||||
"gamepad_history": "Verlauf",
|
|
||||||
"audio_channelTests": "Kanaltester",
|
|
||||||
"audio_stereo": "Stereo",
|
|
||||||
"audio_surroundAudio": "Surround-Audio",
|
|
||||||
"audio_surround51": "5.1 Surround",
|
|
||||||
"audio_surround71": "7.1 Surround",
|
|
||||||
"audio_phaseTest": "Phasentest",
|
|
||||||
"audio_frequency": "Frequenz",
|
|
||||||
"audio_inPhase": "In Phase",
|
|
||||||
"audio_outOfPhase": "Außer Phase",
|
|
||||||
"audio_stop": "Stopp",
|
|
||||||
"screenInfo_screenResolution": "Bildschirmauflösung",
|
|
||||||
"screenInfo_windowResolution": "Fensterauflösung",
|
|
||||||
"screenInfo_devicePixelRatio": "Gerätepixelverhältnis",
|
|
||||||
"audio_channel_left": "Links",
|
|
||||||
"audio_channel_center": "Mitte",
|
|
||||||
"audio_channel_right": "Rechts",
|
|
||||||
"keyboard_title": "Tastaturtest",
|
|
||||||
"keyboard_instruction": "Drücken Sie eine Taste auf der Tastatur, um das Ereignisobjekt und den Tastencode anzuzeigen.",
|
|
||||||
"keyboard_pressedKeys": "Gedrückte Tasten:",
|
|
||||||
"timer_title": "Hochauflösender Timer",
|
|
||||||
"timer_fps": "FPS",
|
|
||||||
"timer_restart": "Neustart",
|
|
||||||
"audio_stopCycling": "Zyklus stoppen",
|
|
||||||
"audio_cycleThrough": "Durchlaufen",
|
|
||||||
"common_back": "Zurück",
|
|
||||||
"audio_title": "Audiotest",
|
|
||||||
"avSync_title": "Audio/Video-Synchronisation",
|
|
||||||
"internet_title": "Internetgeschwindigkeit",
|
|
||||||
"tests_signal-generator_description": "Erzeugen Sie Sinuswellen, Rauschen (weiß, pink, braun) und Frequenz-Sweeps. Mit Oszilloskop und Spektrum.",
|
|
||||||
"tests_signal-generator_label": "Signalgenerator",
|
|
||||||
"signalGen_title": "Signalgenerator",
|
|
||||||
"signalGen_type": "Typ",
|
|
||||||
"signalGen_sine": "Sinus",
|
|
||||||
"signalGen_sweep": "Sweep",
|
|
||||||
"signalGen_noiseWhite": "Weißes Rauschen",
|
|
||||||
"signalGen_noisePink": "Pinkes Rauschen",
|
|
||||||
"signalGen_noiseBrown": "Braunes Rauschen",
|
|
||||||
"signalGen_frequency": "Frequenz",
|
|
||||||
"signalGen_from": "Von",
|
|
||||||
"signalGen_to": "Bis",
|
|
||||||
"signalGen_duration": "Dauer",
|
|
||||||
"signalGen_gain": "Verstärkung",
|
|
||||||
"signalGen_start": "Start",
|
|
||||||
"signalGen_stop": "Stopp",
|
|
||||||
"signalGen_scope": "Oszilloskop",
|
|
||||||
"signalGen_spectrum": "Spektrum",
|
|
||||||
"signalGen_loop": "Schleife",
|
|
||||||
"mic_title": "Mikrofontest",
|
|
||||||
"mic_startMicrophone": "Mikrofon starten",
|
|
||||||
"mic_stop": "Stopp",
|
|
||||||
"mic_monitoringOn": "Monitoring: EIN",
|
|
||||||
"mic_monitoringOff": "Monitoring: AUS",
|
|
||||||
"mic_gain": "Verstärkung",
|
|
||||||
"mic_monitorDelay": "Monitor-Verzögerung",
|
|
||||||
"mic_sampleRate": "Abtastrate",
|
|
||||||
"mic_inputDevice": "Eingabegerät",
|
|
||||||
"mic_volume": "Lautstärke",
|
|
||||||
"mic_recording": "Aufnahme",
|
|
||||||
"mic_startRecording": "Aufnahme starten",
|
|
||||||
"mic_stopRecording": "Aufnahme stoppen",
|
|
||||||
"mic_downloadRecording": "Aufnahme herunterladen",
|
|
||||||
"mic_device": "Gerät",
|
|
||||||
"mic_noMicFound": "Kein Mikrofon gefunden",
|
|
||||||
"mic_refresh": "Aktualisieren",
|
|
||||||
"mic_clipping": "Übersteuerung",
|
|
||||||
"mic_constraints": "Einschränkungen",
|
|
||||||
"mic_echoCancellation": "Echounterdrückung",
|
|
||||||
"mic_noiseSuppression": "Rauschunterdrückung",
|
|
||||||
"mic_agc": "Automatische Verstärkungsregelung",
|
|
||||||
"mic_applyConstraints": "Anwenden",
|
|
||||||
"mic_channels": "Kanäle",
|
|
||||||
"mic_stereo": "Stereo",
|
|
||||||
"mic_requested": "Angefordert",
|
|
||||||
"mic_obtained": "Erhalten",
|
|
||||||
"mic_peakNow": "Spitze",
|
|
||||||
"mic_peakHold": "Spitze halten",
|
|
||||||
"mic_resetPeaks": "Spitzen zurücksetzen",
|
|
||||||
"mic_advanced": "Erweitert",
|
|
||||||
"mic_default": "Standard",
|
|
||||||
"mic_on": "Ein",
|
|
||||||
"mic_off": "Aus",
|
|
||||||
"mic_mono": "Mono",
|
|
||||||
"sensors_title": "Sensoren",
|
|
||||||
"sensors_geolocation": "Geolokalisierung",
|
|
||||||
"sensors_start": "Start",
|
|
||||||
"sensors_stop": "Stopp",
|
|
||||||
"sensors_accuracy": "Genauigkeit (m)",
|
|
||||||
"sensors_altitude": "Höhe (m)",
|
|
||||||
"sensors_heading": "Richtung (Grad)",
|
|
||||||
"sensors_speed": "Geschwindigkeit (m/s)",
|
|
||||||
"sensors_timestamp": "Zeitstempel",
|
|
||||||
"sensors_copy": "JSON kopieren",
|
|
||||||
"sensors_copied": "In die Zwischenablage kopiert",
|
|
||||||
"sensors_notSupported": "Auf diesem Gerät/Browser nicht unterstützt",
|
|
||||||
"sensors_deviceMotion": "Gerätebewegung",
|
|
||||||
"sensors_deviceOrientation": "Geräteausrichtung",
|
|
||||||
"sensors_accelerometer": "Beschleunigungsmesser",
|
|
||||||
"sensors_gyroscope": "Gyroskop",
|
|
||||||
"sensors_magnetometer": "Magnetometer",
|
|
||||||
"sensors_ambientLight": "Umgebungslicht",
|
|
||||||
"sensors_illuminance": "Beleuchtungsstärke (lux)",
|
|
||||||
"sensors_barometer": "Barometer",
|
|
||||||
"sensors_pressure": "Druck (hPa)",
|
|
||||||
"sensors_temperature": "Temperatur (°C)",
|
|
||||||
"sensors_permissions": "Berechtigungen",
|
|
||||||
"sensors_enableMotionOrientation": "Bewegung/Ausrichtung aktivieren",
|
|
||||||
"sensors_motion": "Bewegung",
|
|
||||||
"sensors_orientation": "Ausrichtung",
|
|
||||||
"sensors_status_granted": "Gewährt",
|
|
||||||
"sensors_status_denied": "Verweigert",
|
|
||||||
"sensors_status_unknown": "Unbekannt"
|
|
||||||
}
|
|
||||||
170
messages/en.json
170
messages/en.json
|
|
@ -1,170 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
||||||
"search": "Search",
|
|
||||||
"tests_audio_description": "Check your stereo channels or surround audio output, verify if your speakers are in phase.",
|
|
||||||
"tests_audio_label": "Audio",
|
|
||||||
"tests_av-sync_description": "Check if your audio and video are in sync, and measure the delay.",
|
|
||||||
"tests_av-sync_label": "Audio/Video Sync",
|
|
||||||
"tests_card_description": "Test card for your display or projector, check colors, resolution and geometry.",
|
|
||||||
"tests_card_label": "Card",
|
|
||||||
"tests_camera_description": "Check whether your webcam or capture device is working, its image quality, resolution and frame rate. Take a snapshot.",
|
|
||||||
"tests_camera_label": "Camera",
|
|
||||||
"tests_gamepad_description": "Test your gamepad, check if it's working, all the buttons and joysticks, stick drift, dead zones and calibration.",
|
|
||||||
"tests_gamepad_label": "Gamepad",
|
|
||||||
"tests_keyboard_description": "Check if all keys are working and what key codes they send.",
|
|
||||||
"tests_keyboard_label": "Keyboard",
|
|
||||||
"tests_microphone_description": "Check if your microphone is working, its quality, volume and noise.",
|
|
||||||
"tests_microphone_label": "Microphone",
|
|
||||||
"tests_mouse_description": "Check if your mouse or touch device works properly, if there are dead zones or jitter.",
|
|
||||||
"tests_mouse_label": "Mouse",
|
|
||||||
"tests_sensors_description": "See the output of your device's sensors, e.g. GPS, accelerometer, gyroscope, compass, etc.",
|
|
||||||
"tests_sensors_label": "Sensors",
|
|
||||||
"tests_internet_description": "Measure your internet speed and latency.",
|
|
||||||
"tests_internet_label": "Internet speed",
|
|
||||||
"tests_timer_description": "Check if your high resolution timer is working.",
|
|
||||||
"tests_timer_label": "High resolution timer",
|
|
||||||
"category_inputs": "Inputs",
|
|
||||||
"category_outputs": "Outputs",
|
|
||||||
"category_audio": "Audio",
|
|
||||||
"category_video": "Video",
|
|
||||||
"category_control": "Control",
|
|
||||||
"category_misc": "Miscellaneous",
|
|
||||||
"noTestsFound": "No tests found.",
|
|
||||||
"camera_title": "Camera test",
|
|
||||||
"camera_device": "Device",
|
|
||||||
"camera_noCameraFound": "No camera found",
|
|
||||||
"camera_refresh": "Refresh",
|
|
||||||
"camera_resolution": "Resolution",
|
|
||||||
"camera_frameRate": "Frame rate",
|
|
||||||
"camera_noCameraSelected": "No camera selected",
|
|
||||||
"camera_takePicture": "Take picture",
|
|
||||||
"camera_unflipImage": "Unflip image",
|
|
||||||
"camera_flipImage": "Flip image",
|
|
||||||
"camera_closeSnapshot": "Close snapshot",
|
|
||||||
"audio_channel_frontLeft": "Front Left",
|
|
||||||
"audio_channel_frontCenter": "Front Center",
|
|
||||||
"audio_channel_frontRight": "Front Right",
|
|
||||||
"audio_channel_sideLeft": "Side Left",
|
|
||||||
"audio_channel_sideRight": "Side Right",
|
|
||||||
"audio_channel_rearLeft": "Rear Left",
|
|
||||||
"audio_channel_rearRight": "Rear Right",
|
|
||||||
"audio_channel_lfe": "LFE",
|
|
||||||
"gamepad_title": "Gamepad & Joystick Tests",
|
|
||||||
"gamepad_device": "Device",
|
|
||||||
"gamepad_noGamepadsDetected": "No gamepads detected. (Try pressing a button)",
|
|
||||||
"gamepad_refresh": "Refresh",
|
|
||||||
"gamepad_buttons": "Buttons",
|
|
||||||
"gamepad_axes": "Axes",
|
|
||||||
"gamepad_history": "History",
|
|
||||||
"audio_channelTests": "Channel tests",
|
|
||||||
"audio_stereo": "Stereo",
|
|
||||||
"audio_surroundAudio": "Surround audio",
|
|
||||||
"audio_surround51": "5.1 Surround",
|
|
||||||
"audio_surround71": "7.1 Surround",
|
|
||||||
"audio_phaseTest": "Phase test",
|
|
||||||
"audio_frequency": "Frequency",
|
|
||||||
"audio_inPhase": "In Phase",
|
|
||||||
"audio_outOfPhase": "Out of Phase",
|
|
||||||
"audio_stop": "Stop",
|
|
||||||
"screenInfo_screenResolution": "Screen Resolution",
|
|
||||||
"screenInfo_windowResolution": "Window Resolution",
|
|
||||||
"screenInfo_devicePixelRatio": "Device Pixel Ratio",
|
|
||||||
"audio_channel_left": "Left",
|
|
||||||
"audio_channel_center": "Center",
|
|
||||||
"audio_channel_right": "Right",
|
|
||||||
"keyboard_title": "Keyboard testing",
|
|
||||||
"keyboard_instruction": "Press a key on the keyboard to see the event object and the key code.",
|
|
||||||
"keyboard_pressedKeys": "Pressed keys:",
|
|
||||||
"timer_title": "High resolution timer",
|
|
||||||
"timer_fps": "FPS",
|
|
||||||
"timer_restart": "Restart",
|
|
||||||
"audio_stopCycling": "Stop Cycling",
|
|
||||||
"audio_cycleThrough": "Cycle through",
|
|
||||||
"common_back": "Back",
|
|
||||||
"audio_title": "Audio test",
|
|
||||||
"avSync_title": "Audio/Video Synchronization",
|
|
||||||
"internet_title": "Internet speed",
|
|
||||||
"tests_signal-generator_description": "Generate sine waves, noise (white, pink, brown) and frequency sweeps. Includes oscilloscope and spectrum.",
|
|
||||||
"tests_signal-generator_label": "Signal Generator",
|
|
||||||
"signalGen_title": "Signal Generator",
|
|
||||||
"signalGen_type": "Type",
|
|
||||||
"signalGen_sine": "Sine",
|
|
||||||
"signalGen_sweep": "Sweep",
|
|
||||||
"signalGen_noiseWhite": "White noise",
|
|
||||||
"signalGen_noisePink": "Pink noise",
|
|
||||||
"signalGen_noiseBrown": "Brown noise",
|
|
||||||
"signalGen_frequency": "Frequency",
|
|
||||||
"signalGen_from": "From",
|
|
||||||
"signalGen_to": "To",
|
|
||||||
"signalGen_duration": "Duration",
|
|
||||||
"signalGen_gain": "Gain",
|
|
||||||
"signalGen_start": "Start",
|
|
||||||
"signalGen_stop": "Stop",
|
|
||||||
"signalGen_scope": "Oscilloscope",
|
|
||||||
"signalGen_spectrum": "Spectrum",
|
|
||||||
"signalGen_loop": "Loop",
|
|
||||||
"mic_title": "Microphone test",
|
|
||||||
"mic_startMicrophone": "Start microphone",
|
|
||||||
"mic_stop": "Stop",
|
|
||||||
"mic_monitoringOn": "Monitoring: ON",
|
|
||||||
"mic_monitoringOff": "Monitoring: OFF",
|
|
||||||
"mic_gain": "Gain",
|
|
||||||
"mic_monitorDelay": "Monitor delay",
|
|
||||||
"mic_sampleRate": "Sample rate",
|
|
||||||
"mic_inputDevice": "Input",
|
|
||||||
"mic_volume": "Volume",
|
|
||||||
"mic_recording": "Recording",
|
|
||||||
"mic_startRecording": "Start recording",
|
|
||||||
"mic_stopRecording": "Stop recording",
|
|
||||||
"mic_downloadRecording": "Download",
|
|
||||||
"mic_device": "Device",
|
|
||||||
"mic_noMicFound": "No microphone found",
|
|
||||||
"mic_refresh": "Refresh",
|
|
||||||
"mic_clipping": "Clipping",
|
|
||||||
"mic_constraints": "Constraints",
|
|
||||||
"mic_echoCancellation": "Echo cancellation",
|
|
||||||
"mic_noiseSuppression": "Noise suppression",
|
|
||||||
"mic_agc": "Auto gain control",
|
|
||||||
"mic_applyConstraints": "Apply",
|
|
||||||
"mic_channels": "Channels",
|
|
||||||
"mic_stereo": "Stereo",
|
|
||||||
"mic_requested": "Requested",
|
|
||||||
"mic_obtained": "Obtained",
|
|
||||||
"mic_peakNow": "Peak",
|
|
||||||
"mic_peakHold": "Peak hold",
|
|
||||||
"mic_resetPeaks": "Reset peaks",
|
|
||||||
"mic_advanced": "Advanced",
|
|
||||||
"mic_default": "Default",
|
|
||||||
"mic_on": "On",
|
|
||||||
"mic_off": "Off",
|
|
||||||
"mic_mono": "Mono",
|
|
||||||
"sensors_title": "Sensors",
|
|
||||||
"sensors_geolocation": "Geolocation",
|
|
||||||
"sensors_start": "Start",
|
|
||||||
"sensors_stop": "Stop",
|
|
||||||
"sensors_accuracy": "Accuracy (m)",
|
|
||||||
"sensors_altitude": "Altitude (m)",
|
|
||||||
"sensors_heading": "Heading (deg)",
|
|
||||||
"sensors_speed": "Speed (m/s)",
|
|
||||||
"sensors_timestamp": "Timestamp",
|
|
||||||
"sensors_copy": "Copy JSON",
|
|
||||||
"sensors_copied": "Copied to clipboard",
|
|
||||||
"sensors_notSupported": "Not supported on this device/browser",
|
|
||||||
"sensors_deviceMotion": "Device Motion",
|
|
||||||
"sensors_deviceOrientation": "Device Orientation",
|
|
||||||
"sensors_accelerometer": "Accelerometer",
|
|
||||||
"sensors_gyroscope": "Gyroscope",
|
|
||||||
"sensors_magnetometer": "Magnetometer",
|
|
||||||
"sensors_ambientLight": "Ambient Light",
|
|
||||||
"sensors_illuminance": "Illuminance (lux)",
|
|
||||||
"sensors_barometer": "Barometer",
|
|
||||||
"sensors_pressure": "Pressure (hPa)",
|
|
||||||
"sensors_temperature": "Temperature (°C)",
|
|
||||||
"sensors_permissions": "Permissions",
|
|
||||||
"sensors_enableMotionOrientation": "Enable Motion/Orientation",
|
|
||||||
"sensors_motion": "Motion",
|
|
||||||
"sensors_orientation": "Orientation",
|
|
||||||
"sensors_status_granted": "Granted",
|
|
||||||
"sensors_status_denied": "Denied",
|
|
||||||
"sensors_status_unknown": "Unknown"
|
|
||||||
}
|
|
||||||
170
messages/es.json
170
messages/es.json
|
|
@ -1,170 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
||||||
"search": "Buscar",
|
|
||||||
"tests_audio_description": "Comprueba tus canales estéreo o la salida de audio envolvente, verifica si tus altavoces están en fase.",
|
|
||||||
"tests_audio_label": "Audio",
|
|
||||||
"tests_av-sync_description": "Comprueba si el audio y el video están sincronizados y mide el retraso.",
|
|
||||||
"tests_av-sync_label": "Sincronización de Audio/Video",
|
|
||||||
"tests_card_description": "Tarjeta de prueba para tu pantalla o proyector, comprueba colores, resolución y geometría.",
|
|
||||||
"tests_card_label": "Tarjeta",
|
|
||||||
"tests_camera_description": "Comprueba si tu cámara web o dispositivo de captura funciona, su calidad de imagen, resolución y velocidad de fotogramas. Toma una instantánea.",
|
|
||||||
"tests_camera_label": "Cámara",
|
|
||||||
"tests_gamepad_description": "Prueba tu gamepad, comprueba si funciona, todos los botones y joysticks, deriva del stick, zonas muertas y calibración.",
|
|
||||||
"tests_gamepad_label": "Gamepad",
|
|
||||||
"tests_keyboard_description": "Comprueba si todas las teclas funcionan y qué códigos de tecla envían.",
|
|
||||||
"tests_keyboard_label": "Teclado",
|
|
||||||
"tests_microphone_description": "Comprueba si tu micrófono funciona, su calidad, volumen y ruido.",
|
|
||||||
"tests_microphone_label": "Micrófono",
|
|
||||||
"tests_mouse_description": "Comprueba si tu ratón o dispositivo táctil funciona correctamente, si hay zonas muertas o fluctuaciones.",
|
|
||||||
"tests_mouse_label": "Ratón",
|
|
||||||
"tests_sensors_description": "Consulta la salida de los sensores de tu dispositivo, p. ej., GPS, acelerómetro, giroscopio, brújula, etc.",
|
|
||||||
"tests_sensors_label": "Sensores",
|
|
||||||
"tests_internet_description": "Mide tu velocidad de internet y latencia.",
|
|
||||||
"tests_internet_label": "Velocidad de internet",
|
|
||||||
"tests_timer_description": "Comprueba si tu temporizador de alta resolución funciona.",
|
|
||||||
"tests_timer_label": "Temporizador de alta resolución",
|
|
||||||
"category_inputs": "Entradas",
|
|
||||||
"category_outputs": "Salidas",
|
|
||||||
"category_audio": "Audio",
|
|
||||||
"category_video": "Video",
|
|
||||||
"category_control": "Control",
|
|
||||||
"category_misc": "Misceláneo",
|
|
||||||
"noTestsFound": "No se encontraron pruebas.",
|
|
||||||
"camera_title": "Prueba de cámara",
|
|
||||||
"camera_device": "Dispositivo",
|
|
||||||
"camera_noCameraFound": "No se encontró ninguna cámara",
|
|
||||||
"camera_refresh": "Actualizar",
|
|
||||||
"camera_resolution": "Resolución",
|
|
||||||
"camera_frameRate": "Velocidad de fotogramas",
|
|
||||||
"camera_noCameraSelected": "No se ha seleccionado ninguna cámara",
|
|
||||||
"camera_takePicture": "Tomar foto",
|
|
||||||
"camera_unflipImage": "Desvoltear imagen",
|
|
||||||
"camera_flipImage": "Voltear imagen",
|
|
||||||
"camera_closeSnapshot": "Cerrar instantánea",
|
|
||||||
"audio_channel_frontLeft": "Frontal izquierdo",
|
|
||||||
"audio_channel_frontCenter": "Frontal central",
|
|
||||||
"audio_channel_frontRight": "Frontal derecho",
|
|
||||||
"audio_channel_sideLeft": "Lateral izquierdo",
|
|
||||||
"audio_channel_sideRight": "Lateral derecho",
|
|
||||||
"audio_channel_rearLeft": "Trasero izquierdo",
|
|
||||||
"audio_channel_rearRight": "Trasero derecho",
|
|
||||||
"audio_channel_lfe": "LFE",
|
|
||||||
"gamepad_title": "Pruebas de Gamepad y Joystick",
|
|
||||||
"gamepad_device": "Dispositivo",
|
|
||||||
"gamepad_noGamepadsDetected": "No se detectaron gamepads. (Intenta presionar un botón)",
|
|
||||||
"gamepad_refresh": "Actualizar",
|
|
||||||
"gamepad_buttons": "Botones",
|
|
||||||
"gamepad_axes": "Ejes",
|
|
||||||
"gamepad_history": "Historial",
|
|
||||||
"audio_channelTests": "Pruebas de canal",
|
|
||||||
"audio_stereo": "Estéreo",
|
|
||||||
"audio_surroundAudio": "Audio envolvente",
|
|
||||||
"audio_surround51": "Envolvente 5.1",
|
|
||||||
"audio_surround71": "Envolvente 7.1",
|
|
||||||
"audio_phaseTest": "Prueba de fase",
|
|
||||||
"audio_frequency": "Frecuencia",
|
|
||||||
"audio_inPhase": "En fase",
|
|
||||||
"audio_outOfPhase": "Fuera de fase",
|
|
||||||
"audio_stop": "Detener",
|
|
||||||
"screenInfo_screenResolution": "Resolución de pantalla",
|
|
||||||
"screenInfo_windowResolution": "Resolución de ventana",
|
|
||||||
"screenInfo_devicePixelRatio": "Relación de píxeles del dispositivo",
|
|
||||||
"audio_channel_left": "Izquierda",
|
|
||||||
"audio_channel_center": "Centro",
|
|
||||||
"audio_channel_right": "Derecha",
|
|
||||||
"keyboard_title": "Prueba de teclado",
|
|
||||||
"keyboard_instruction": "Presiona una tecla en el teclado para ver el objeto de evento y el código de tecla.",
|
|
||||||
"keyboard_pressedKeys": "Teclas presionadas:",
|
|
||||||
"timer_title": "Temporizador de alta resolución",
|
|
||||||
"timer_fps": "FPS",
|
|
||||||
"timer_restart": "Reiniciar",
|
|
||||||
"audio_stopCycling": "Detener ciclo",
|
|
||||||
"audio_cycleThrough": "Recorrer",
|
|
||||||
"common_back": "Atrás",
|
|
||||||
"audio_title": "Prueba de audio",
|
|
||||||
"avSync_title": "Sincronización de Audio/Video",
|
|
||||||
"internet_title": "Velocidad de internet",
|
|
||||||
"tests_signal-generator_description": "Genera ondas senoidales, ruido (blanco, rosa, marrón) y barridos de frecuencia. Incluye osciloscopio y espectro.",
|
|
||||||
"tests_signal-generator_label": "Generador de señal",
|
|
||||||
"signalGen_title": "Generador de señal",
|
|
||||||
"signalGen_type": "Tipo",
|
|
||||||
"signalGen_sine": "Senoidal",
|
|
||||||
"signalGen_sweep": "Barrido",
|
|
||||||
"signalGen_noiseWhite": "Ruido blanco",
|
|
||||||
"signalGen_noisePink": "Ruido rosa",
|
|
||||||
"signalGen_noiseBrown": "Ruido marrón",
|
|
||||||
"signalGen_frequency": "Frecuencia",
|
|
||||||
"signalGen_from": "Desde",
|
|
||||||
"signalGen_to": "Hasta",
|
|
||||||
"signalGen_duration": "Duración",
|
|
||||||
"signalGen_gain": "Ganancia",
|
|
||||||
"signalGen_start": "Iniciar",
|
|
||||||
"signalGen_stop": "Detener",
|
|
||||||
"signalGen_scope": "Osciloscopio",
|
|
||||||
"signalGen_spectrum": "Espectro",
|
|
||||||
"signalGen_loop": "Bucle",
|
|
||||||
"mic_title": "Prueba de micrófono",
|
|
||||||
"mic_startMicrophone": "Iniciar micrófono",
|
|
||||||
"mic_stop": "Detener",
|
|
||||||
"mic_monitoringOn": "Monitorización: ON",
|
|
||||||
"mic_monitoringOff": "Monitorización: OFF",
|
|
||||||
"mic_gain": "Ganancia",
|
|
||||||
"mic_monitorDelay": "Retraso del monitor",
|
|
||||||
"mic_sampleRate": "Tasa de muestreo",
|
|
||||||
"mic_inputDevice": "Dispositivo de entrada",
|
|
||||||
"mic_volume": "Volumen",
|
|
||||||
"mic_recording": "Grabación",
|
|
||||||
"mic_startRecording": "Iniciar grabación",
|
|
||||||
"mic_stopRecording": "Detener grabación",
|
|
||||||
"mic_downloadRecording": "Descargar grabación",
|
|
||||||
"mic_device": "Dispositivo",
|
|
||||||
"mic_noMicFound": "No se encontró ningún micrófono",
|
|
||||||
"mic_refresh": "Actualizar",
|
|
||||||
"mic_clipping": "Recorte",
|
|
||||||
"mic_constraints": "Restricciones",
|
|
||||||
"mic_echoCancellation": "Cancelación de eco",
|
|
||||||
"mic_noiseSuppression": "Supresión de ruido",
|
|
||||||
"mic_agc": "Control automático de ganancia",
|
|
||||||
"mic_applyConstraints": "Aplicar",
|
|
||||||
"mic_channels": "Canales",
|
|
||||||
"mic_stereo": "Estéreo",
|
|
||||||
"mic_requested": "Solicitado",
|
|
||||||
"mic_obtained": "Obtenido",
|
|
||||||
"mic_peakNow": "Pico",
|
|
||||||
"mic_peakHold": "Mantener pico",
|
|
||||||
"mic_resetPeaks": "Restablecer picos",
|
|
||||||
"mic_advanced": "Avanzado",
|
|
||||||
"mic_default": "Predeterminado",
|
|
||||||
"mic_on": "Encendido",
|
|
||||||
"mic_off": "Apagado",
|
|
||||||
"mic_mono": "Mono",
|
|
||||||
"sensors_title": "Sensores",
|
|
||||||
"sensors_geolocation": "Geolocalización",
|
|
||||||
"sensors_start": "Iniciar",
|
|
||||||
"sensors_stop": "Detener",
|
|
||||||
"sensors_accuracy": "Precisión (m)",
|
|
||||||
"sensors_altitude": "Altitud (m)",
|
|
||||||
"sensors_heading": "Rumbo (grados)",
|
|
||||||
"sensors_speed": "Velocidad (m/s)",
|
|
||||||
"sensors_timestamp": "Marca de tiempo",
|
|
||||||
"sensors_copy": "Copiar JSON",
|
|
||||||
"sensors_copied": "Copiado al portapapeles",
|
|
||||||
"sensors_notSupported": "No compatible con este dispositivo/navegador",
|
|
||||||
"sensors_deviceMotion": "Movimiento del dispositivo",
|
|
||||||
"sensors_deviceOrientation": "Orientación del dispositivo",
|
|
||||||
"sensors_accelerometer": "Acelerómetro",
|
|
||||||
"sensors_gyroscope": "Giroscopio",
|
|
||||||
"sensors_magnetometer": "Magnetómetro",
|
|
||||||
"sensors_ambientLight": "Luz ambiental",
|
|
||||||
"sensors_illuminance": "Iluminancia (lux)",
|
|
||||||
"sensors_barometer": "Barómetro",
|
|
||||||
"sensors_pressure": "Presión (hPa)",
|
|
||||||
"sensors_temperature": "Temperatura (°C)",
|
|
||||||
"sensors_permissions": "Permisos",
|
|
||||||
"sensors_enableMotionOrientation": "Activar Movimiento/Orientación",
|
|
||||||
"sensors_motion": "Movimiento",
|
|
||||||
"sensors_orientation": "Orientación",
|
|
||||||
"sensors_status_granted": "Concedido",
|
|
||||||
"sensors_status_denied": "Denegado",
|
|
||||||
"sensors_status_unknown": "Desconocido"
|
|
||||||
}
|
|
||||||
170
messages/fr.json
170
messages/fr.json
|
|
@ -1,170 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
||||||
"search": "Rechercher",
|
|
||||||
"tests_audio_description": "Vérifiez vos canaux stéréo ou votre sortie audio surround, vérifiez si vos haut-parleurs sont en phase.",
|
|
||||||
"tests_audio_label": "Audio",
|
|
||||||
"tests_av-sync_description": "Vérifiez si votre audio et votre vidéo sont synchronisés et mesurez le décalage.",
|
|
||||||
"tests_av-sync_label": "Synchronisation Audio/Vidéo",
|
|
||||||
"tests_card_description": "Carte de test pour votre écran ou projecteur, vérifiez les couleurs, la résolution et la géométrie.",
|
|
||||||
"tests_card_label": "Carte",
|
|
||||||
"tests_camera_description": "Vérifiez si votre webcam ou votre périphérique de capture fonctionne, sa qualité d'image, sa résolution et sa fréquence d'images. Prenez une photo.",
|
|
||||||
"tests_camera_label": "Caméra",
|
|
||||||
"tests_gamepad_description": "Testez votre manette de jeu, vérifiez si elle fonctionne, tous les boutons et joysticks, la dérive des sticks, les zones mortes et le calibrage.",
|
|
||||||
"tests_gamepad_label": "Manette de jeu",
|
|
||||||
"tests_keyboard_description": "Vérifiez si toutes les touches fonctionnent et quels codes de touche elles envoient.",
|
|
||||||
"tests_keyboard_label": "Clavier",
|
|
||||||
"tests_microphone_description": "Vérifiez si votre microphone fonctionne, sa qualité, son volume et le bruit.",
|
|
||||||
"tests_microphone_label": "Microphone",
|
|
||||||
"tests_mouse_description": "Vérifiez si votre souris ou votre périphérique tactile fonctionne correctement, s'il y a des zones mortes ou des sautillements.",
|
|
||||||
"tests_mouse_label": "Souris",
|
|
||||||
"tests_sensors_description": "Consultez la sortie des capteurs de votre appareil, par ex. GPS, accéléromètre, gyroscope, boussole, etc.",
|
|
||||||
"tests_sensors_label": "Capteurs",
|
|
||||||
"tests_internet_description": "Mesurez votre vitesse Internet et votre latence.",
|
|
||||||
"tests_internet_label": "Vitesse Internet",
|
|
||||||
"tests_timer_description": "Vérifiez si votre minuteur haute résolution fonctionne.",
|
|
||||||
"tests_timer_label": "Minuteur haute résolution",
|
|
||||||
"category_inputs": "Entrées",
|
|
||||||
"category_outputs": "Sorties",
|
|
||||||
"category_audio": "Audio",
|
|
||||||
"category_video": "Vidéo",
|
|
||||||
"category_control": "Contrôle",
|
|
||||||
"category_misc": "Divers",
|
|
||||||
"noTestsFound": "Aucun test trouvé.",
|
|
||||||
"camera_title": "Test de la caméra",
|
|
||||||
"camera_device": "Appareil",
|
|
||||||
"camera_noCameraFound": "Aucune caméra trouvée",
|
|
||||||
"camera_refresh": "Actualiser",
|
|
||||||
"camera_resolution": "Résolution",
|
|
||||||
"camera_frameRate": "Fréquence d'images",
|
|
||||||
"camera_noCameraSelected": "Aucune caméra sélectionnée",
|
|
||||||
"camera_takePicture": "Prendre une photo",
|
|
||||||
"camera_unflipImage": "Retourner l'image",
|
|
||||||
"camera_flipImage": "Inverser l'image",
|
|
||||||
"camera_closeSnapshot": "Fermer l'instantané",
|
|
||||||
"audio_channel_frontLeft": "Avant gauche",
|
|
||||||
"audio_channel_frontCenter": "Avant centre",
|
|
||||||
"audio_channel_frontRight": "Avant droit",
|
|
||||||
"audio_channel_sideLeft": "Côté gauche",
|
|
||||||
"audio_channel_sideRight": "Côté droit",
|
|
||||||
"audio_channel_rearLeft": "Arrière gauche",
|
|
||||||
"audio_channel_rearRight": "Arrière droit",
|
|
||||||
"audio_channel_lfe": "LFE",
|
|
||||||
"gamepad_title": "Tests de manette de jeu et de joystick",
|
|
||||||
"gamepad_device": "Appareil",
|
|
||||||
"gamepad_noGamepadsDetected": "Aucune manette de jeu détectée. (Essayez d'appuyer sur un bouton)",
|
|
||||||
"gamepad_refresh": "Actualiser",
|
|
||||||
"gamepad_buttons": "Boutons",
|
|
||||||
"gamepad_axes": "Axes",
|
|
||||||
"gamepad_history": "Historique",
|
|
||||||
"audio_channelTests": "Tests de canaux",
|
|
||||||
"audio_stereo": "Stéréo",
|
|
||||||
"audio_surroundAudio": "Audio surround",
|
|
||||||
"audio_surround51": "Surround 5.1",
|
|
||||||
"audio_surround71": "Surround 7.1",
|
|
||||||
"audio_phaseTest": "Test de phase",
|
|
||||||
"audio_frequency": "Fréquence",
|
|
||||||
"audio_inPhase": "En phase",
|
|
||||||
"audio_outOfPhase": "Hors phase",
|
|
||||||
"audio_stop": "Arrêter",
|
|
||||||
"screenInfo_screenResolution": "Résolution de l'écran",
|
|
||||||
"screenInfo_windowResolution": "Résolution de la fenêtre",
|
|
||||||
"screenInfo_devicePixelRatio": "Ratio de pixels de l'appareil",
|
|
||||||
"audio_channel_left": "Gauche",
|
|
||||||
"audio_channel_center": "Centre",
|
|
||||||
"audio_channel_right": "Droite",
|
|
||||||
"keyboard_title": "Test du clavier",
|
|
||||||
"keyboard_instruction": "Appuyez sur une touche du clavier pour voir l'objet événement et le code de la touche.",
|
|
||||||
"keyboard_pressedKeys": "Touches enfoncées :",
|
|
||||||
"timer_title": "Minuteur haute résolution",
|
|
||||||
"timer_fps": "FPS",
|
|
||||||
"timer_restart": "Redémarrer",
|
|
||||||
"audio_stopCycling": "Arrêter le cycle",
|
|
||||||
"audio_cycleThrough": "Parcourir",
|
|
||||||
"common_back": "Retour",
|
|
||||||
"audio_title": "Test audio",
|
|
||||||
"avSync_title": "Synchronisation Audio/Vidéo",
|
|
||||||
"internet_title": "Vitesse Internet",
|
|
||||||
"tests_signal-generator_description": "Générez des ondes sinusoïdales, du bruit (blanc, rose, marron) et des balayages de fréquence. Comprend un oscilloscope et un spectre.",
|
|
||||||
"tests_signal-generator_label": "Générateur de signaux",
|
|
||||||
"signalGen_title": "Générateur de signaux",
|
|
||||||
"signalGen_type": "Type",
|
|
||||||
"signalGen_sine": "Sinusoïdal",
|
|
||||||
"signalGen_sweep": "Balayage",
|
|
||||||
"signalGen_noiseWhite": "Bruit blanc",
|
|
||||||
"signalGen_noisePink": "Bruit rose",
|
|
||||||
"signalGen_noiseBrown": "Bruit marron",
|
|
||||||
"signalGen_frequency": "Fréquence",
|
|
||||||
"signalGen_from": "De",
|
|
||||||
"signalGen_to": "À",
|
|
||||||
"signalGen_duration": "Durée",
|
|
||||||
"signalGen_gain": "Volume",
|
|
||||||
"signalGen_start": "Démarrer",
|
|
||||||
"signalGen_stop": "Arrêter",
|
|
||||||
"signalGen_scope": "Oscilloscope",
|
|
||||||
"signalGen_spectrum": "Spectre",
|
|
||||||
"signalGen_loop": "Boucle",
|
|
||||||
"mic_title": "Test du microphone",
|
|
||||||
"mic_startMicrophone": "Démarrer le microphone",
|
|
||||||
"mic_stop": "Arrêter",
|
|
||||||
"mic_monitoringOn": "Monitoring : ON",
|
|
||||||
"mic_monitoringOff": "Monitoring : OFF",
|
|
||||||
"mic_gain": "Gain",
|
|
||||||
"mic_monitorDelay": "Délai du moniteur",
|
|
||||||
"mic_sampleRate": "Taux d'échantillonnage",
|
|
||||||
"mic_inputDevice": "Périphérique d'entrée",
|
|
||||||
"mic_volume": "Volume",
|
|
||||||
"mic_recording": "Enregistrement",
|
|
||||||
"mic_startRecording": "Démarrer l'enregistrement",
|
|
||||||
"mic_stopRecording": "Arrêter l'enregistrement",
|
|
||||||
"mic_downloadRecording": "Télécharger l'enregistrement",
|
|
||||||
"mic_device": "Appareil",
|
|
||||||
"mic_noMicFound": "Aucun microphone trouvé",
|
|
||||||
"mic_refresh": "Actualiser",
|
|
||||||
"mic_clipping": "Écrêtage",
|
|
||||||
"mic_constraints": "Contraintes",
|
|
||||||
"mic_echoCancellation": "Annulation de l'écho",
|
|
||||||
"mic_noiseSuppression": "Suppression du bruit",
|
|
||||||
"mic_agc": "Contrôle automatique du gain",
|
|
||||||
"mic_applyConstraints": "Appliquer",
|
|
||||||
"mic_channels": "Canaux",
|
|
||||||
"mic_stereo": "Stéréo",
|
|
||||||
"mic_requested": "Demandé",
|
|
||||||
"mic_obtained": "Obtenu",
|
|
||||||
"mic_peakNow": "Pic",
|
|
||||||
"mic_peakHold": "Maintien du pic",
|
|
||||||
"mic_resetPeaks": "Réinitialiser les pics",
|
|
||||||
"mic_advanced": "Avancé",
|
|
||||||
"mic_default": "Défaut",
|
|
||||||
"mic_on": "Activé",
|
|
||||||
"mic_off": "Désactivé",
|
|
||||||
"mic_mono": "Mono",
|
|
||||||
"sensors_title": "Capteurs",
|
|
||||||
"sensors_geolocation": "Géolocalisation",
|
|
||||||
"sensors_start": "Démarrer",
|
|
||||||
"sensors_stop": "Arrêter",
|
|
||||||
"sensors_accuracy": "Précision (m)",
|
|
||||||
"sensors_altitude": "Altitude (m)",
|
|
||||||
"sensors_heading": "Cap (deg)",
|
|
||||||
"sensors_speed": "Vitesse (m/s)",
|
|
||||||
"sensors_timestamp": "Horodatage",
|
|
||||||
"sensors_copy": "Copier JSON",
|
|
||||||
"sensors_copied": "Copié dans le presse-papiers",
|
|
||||||
"sensors_notSupported": "Non pris en charge sur cet appareil/navigateur",
|
|
||||||
"sensors_deviceMotion": "Mouvement de l'appareil",
|
|
||||||
"sensors_deviceOrientation": "Orientation de l'appareil",
|
|
||||||
"sensors_accelerometer": "Accéléromètre",
|
|
||||||
"sensors_gyroscope": "Gyroscope",
|
|
||||||
"sensors_magnetometer": "Magnétomètre",
|
|
||||||
"sensors_ambientLight": "Lumière ambiante",
|
|
||||||
"sensors_illuminance": "Éclairement (lux)",
|
|
||||||
"sensors_barometer": "Baromètre",
|
|
||||||
"sensors_pressure": "Pression (hPa)",
|
|
||||||
"sensors_temperature": "Température (°C)",
|
|
||||||
"sensors_permissions": "Autorisations",
|
|
||||||
"sensors_enableMotionOrientation": "Activer Mouvement/Orientation",
|
|
||||||
"sensors_motion": "Mouvement",
|
|
||||||
"sensors_orientation": "Orientation",
|
|
||||||
"sensors_status_granted": "Accordé",
|
|
||||||
"sensors_status_denied": "Refusé",
|
|
||||||
"sensors_status_unknown": "Inconnu"
|
|
||||||
}
|
|
||||||
170
messages/ja.json
170
messages/ja.json
|
|
@ -1,170 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
||||||
"search": "検索",
|
|
||||||
"tests_audio_description": "ステレオチャンネルまたはサラウンドオーディオ出力を確認し、スピーカーが同相であるかを確認します。",
|
|
||||||
"tests_audio_label": "オーディオ",
|
|
||||||
"tests_av-sync_description": "オーディオとビデオが同期しているかを確認し、遅延を測定します。",
|
|
||||||
"tests_av-sync_label": "オーディオ/ビデオ同期",
|
|
||||||
"tests_card_description": "ディスプレイまたはプロジェクターのテストカード、色、解像度、ジオメトリを確認します。",
|
|
||||||
"tests_card_label": "カード",
|
|
||||||
"tests_camera_description": "ウェブカメラまたはキャプチャデバイスが動作しているか、画質、解像度、フレームレートを確認します。スナップショットを撮ります。",
|
|
||||||
"tests_camera_label": "カメラ",
|
|
||||||
"tests_gamepad_description": "ゲームパッドをテストし、動作、すべてのボタンとジョイスティック、スティックのドリフト、デッドゾーン、キャリブレーションを確認します。",
|
|
||||||
"tests_gamepad_label": "ゲームパッド",
|
|
||||||
"tests_keyboard_description": "すべてのキーが機能しているかと、それらが送信するキーコードを確認します。",
|
|
||||||
"tests_keyboard_label": "キーボード",
|
|
||||||
"tests_microphone_description": "マイクが機能しているか、品質、音量、ノイズを確認します。",
|
|
||||||
"tests_microphone_label": "マイク",
|
|
||||||
"tests_mouse_description": "マウスまたはタッチデバイスが正しく機能しているか、デッドゾーンやジッターがないかを確認します。",
|
|
||||||
"tests_mouse_label": "マウス",
|
|
||||||
"tests_sensors_description": "デバイスのセンサー(GPS、加速度計、ジャイロスコープ、コンパスなど)の出力を確認します。",
|
|
||||||
"tests_sensors_label": "センサー",
|
|
||||||
"tests_internet_description": "インターネットの速度と遅延を測定します。",
|
|
||||||
"tests_internet_label": "インターネット速度",
|
|
||||||
"tests_timer_description": "高解像度タイマーが機能しているかを確認します。",
|
|
||||||
"tests_timer_label": "高解像度タイマー",
|
|
||||||
"category_inputs": "入力",
|
|
||||||
"category_outputs": "出力",
|
|
||||||
"category_audio": "オーディオ",
|
|
||||||
"category_video": "ビデオ",
|
|
||||||
"category_control": "コントロール",
|
|
||||||
"category_misc": "その他",
|
|
||||||
"noTestsFound": "テストが見つかりません。",
|
|
||||||
"camera_title": "カメラテスト",
|
|
||||||
"camera_device": "デバイス",
|
|
||||||
"camera_noCameraFound": "カメラが見つかりません",
|
|
||||||
"camera_refresh": "更新",
|
|
||||||
"camera_resolution": "解像度",
|
|
||||||
"camera_frameRate": "フレームレート",
|
|
||||||
"camera_noCameraSelected": "カメラが選択されていません",
|
|
||||||
"camera_takePicture": "写真を撮る",
|
|
||||||
"camera_unflipImage": "画像の反転を元に戻す",
|
|
||||||
"camera_flipImage": "画像を反転",
|
|
||||||
"camera_closeSnapshot": "スナップショットを閉じる",
|
|
||||||
"audio_channel_frontLeft": "フロント左",
|
|
||||||
"audio_channel_frontCenter": "フロントセンター",
|
|
||||||
"audio_channel_frontRight": "フロント右",
|
|
||||||
"audio_channel_sideLeft": "サイド左",
|
|
||||||
"audio_channel_sideRight": "サイド右",
|
|
||||||
"audio_channel_rearLeft": "リア左",
|
|
||||||
"audio_channel_rearRight": "リア右",
|
|
||||||
"audio_channel_lfe": "LFE",
|
|
||||||
"gamepad_title": "ゲームパッドとジョイスティックのテスト",
|
|
||||||
"gamepad_device": "デバイス",
|
|
||||||
"gamepad_noGamepadsDetected": "ゲームパッドが検出されません。(ボタンを押してみてください)",
|
|
||||||
"gamepad_refresh": "更新",
|
|
||||||
"gamepad_buttons": "ボタン",
|
|
||||||
"gamepad_axes": "軸",
|
|
||||||
"gamepad_history": "履歴",
|
|
||||||
"audio_channelTests": "チャンネルテスト",
|
|
||||||
"audio_stereo": "ステレオ",
|
|
||||||
"audio_surroundAudio": "サラウンドオーディオ",
|
|
||||||
"audio_surround51": "5.1サラウンド",
|
|
||||||
"audio_surround71": "7.1サラウンド",
|
|
||||||
"audio_phaseTest": "フェーズテスト",
|
|
||||||
"audio_frequency": "周波数",
|
|
||||||
"audio_inPhase": "同相",
|
|
||||||
"audio_outOfPhase": "逆相",
|
|
||||||
"audio_stop": "停止",
|
|
||||||
"screenInfo_screenResolution": "画面解像度",
|
|
||||||
"screenInfo_windowResolution": "ウィンドウ解像度",
|
|
||||||
"screenInfo_devicePixelRatio": "デバイスピクセル比",
|
|
||||||
"audio_channel_left": "左",
|
|
||||||
"audio_channel_center": "中央",
|
|
||||||
"audio_channel_right": "右",
|
|
||||||
"keyboard_title": "キーボードテスト",
|
|
||||||
"keyboard_instruction": "キーボードのキーを押して、イベントオブジェクトとキーコードを確認します。",
|
|
||||||
"keyboard_pressedKeys": "押されたキー:",
|
|
||||||
"timer_title": "高解像度タイマー",
|
|
||||||
"timer_fps": "FPS",
|
|
||||||
"timer_restart": "再起動",
|
|
||||||
"audio_stopCycling": "サイクリングを停止",
|
|
||||||
"audio_cycleThrough": "サイクルスルー",
|
|
||||||
"common_back": "戻る",
|
|
||||||
"audio_title": "オーディオテスト",
|
|
||||||
"avSync_title": "オーディオ/ビデオ同期",
|
|
||||||
"internet_title": "インターネット速度",
|
|
||||||
"tests_signal-generator_description": "正弦波、ノイズ(ホワイト、ピンク、ブラウン)、周波数スイープを生成します。オシロスコープとスペクトラムが含まれています。",
|
|
||||||
"tests_signal-generator_label": "信号発生器",
|
|
||||||
"signalGen_title": "信号発生器",
|
|
||||||
"signalGen_type": "タイプ",
|
|
||||||
"signalGen_sine": "正弦波",
|
|
||||||
"signalGen_sweep": "スイープ",
|
|
||||||
"signalGen_noiseWhite": "ホワイトノイズ",
|
|
||||||
"signalGen_noisePink": "ピンクノイズ",
|
|
||||||
"signalGen_noiseBrown": "ブラウンノイズ",
|
|
||||||
"signalGen_frequency": "周波数",
|
|
||||||
"signalGen_from": "から",
|
|
||||||
"signalGen_to": "まで",
|
|
||||||
"signalGen_duration": "期間",
|
|
||||||
"signalGen_gain": "音量",
|
|
||||||
"signalGen_start": "開始",
|
|
||||||
"signalGen_stop": "停止",
|
|
||||||
"signalGen_scope": "オシロスコープ",
|
|
||||||
"signalGen_spectrum": "スペクトラム",
|
|
||||||
"signalGen_loop": "ループ",
|
|
||||||
"mic_title": "マイクテスト",
|
|
||||||
"mic_startMicrophone": "マイクを開始",
|
|
||||||
"mic_stop": "停止",
|
|
||||||
"mic_monitoringOn": "モニタリング:オン",
|
|
||||||
"mic_monitoringOff": "モニタリング:オフ",
|
|
||||||
"mic_gain": "ゲイン",
|
|
||||||
"mic_monitorDelay": "モニター遅延",
|
|
||||||
"mic_sampleRate": "サンプルレート",
|
|
||||||
"mic_inputDevice": "入力デバイス",
|
|
||||||
"mic_volume": "音量",
|
|
||||||
"mic_recording": "録音",
|
|
||||||
"mic_startRecording": "録音を開始",
|
|
||||||
"mic_stopRecording": "録音を停止",
|
|
||||||
"mic_downloadRecording": "録音をダウンロード",
|
|
||||||
"mic_device": "デバイス",
|
|
||||||
"mic_noMicFound": "マイクが見つかりません",
|
|
||||||
"mic_refresh": "更新",
|
|
||||||
"mic_clipping": "クリッピング",
|
|
||||||
"mic_constraints": "制約",
|
|
||||||
"mic_echoCancellation": "エコーキャンセル",
|
|
||||||
"mic_noiseSuppression": "ノイズ抑制",
|
|
||||||
"mic_agc": "自動ゲインコントロール",
|
|
||||||
"mic_applyConstraints": "適用",
|
|
||||||
"mic_channels": "チャンネル",
|
|
||||||
"mic_stereo": "ステレオ",
|
|
||||||
"mic_requested": "要求",
|
|
||||||
"mic_obtained": "取得",
|
|
||||||
"mic_peakNow": "ピーク",
|
|
||||||
"mic_peakHold": "ピークホールド",
|
|
||||||
"mic_resetPeaks": "ピークをリセット",
|
|
||||||
"mic_advanced": "詳細",
|
|
||||||
"mic_default": "デフォルト",
|
|
||||||
"mic_on": "オン",
|
|
||||||
"mic_off": "オフ",
|
|
||||||
"mic_mono": "モノラル",
|
|
||||||
"sensors_title": "センサー",
|
|
||||||
"sensors_geolocation": "地理位置情報",
|
|
||||||
"sensors_start": "開始",
|
|
||||||
"sensors_stop": "停止",
|
|
||||||
"sensors_accuracy": "精度 (m)",
|
|
||||||
"sensors_altitude": "高度 (m)",
|
|
||||||
"sensors_heading": "方角 (度)",
|
|
||||||
"sensors_speed": "速度 (m/s)",
|
|
||||||
"sensors_timestamp": "タイムスタンプ",
|
|
||||||
"sensors_copy": "JSONをコピー",
|
|
||||||
"sensors_copied": "クリップボードにコピーしました",
|
|
||||||
"sensors_notSupported": "このデバイス/ブラウザではサポートされていません",
|
|
||||||
"sensors_deviceMotion": "デバイスの動き",
|
|
||||||
"sensors_deviceOrientation": "デバイスの向き",
|
|
||||||
"sensors_accelerometer": "加速度計",
|
|
||||||
"sensors_gyroscope": "ジャイロスコープ",
|
|
||||||
"sensors_magnetometer": "磁力計",
|
|
||||||
"sensors_ambientLight": "環境光",
|
|
||||||
"sensors_illuminance": "照度 (lux)",
|
|
||||||
"sensors_barometer": "気圧計",
|
|
||||||
"sensors_pressure": "圧力 (hPa)",
|
|
||||||
"sensors_temperature": "温度 (°C)",
|
|
||||||
"sensors_permissions": "権限",
|
|
||||||
"sensors_enableMotionOrientation": "モーション/オリエンテーションを有効にする",
|
|
||||||
"sensors_motion": "モーション",
|
|
||||||
"sensors_orientation": "オリエンテーション",
|
|
||||||
"sensors_status_granted": "許可",
|
|
||||||
"sensors_status_denied": "拒否",
|
|
||||||
"sensors_status_unknown": "不明"
|
|
||||||
}
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
||||||
"search": "Пошук",
|
|
||||||
"tests_audio_description": "Перевірте свої стереоканали або об'ємний аудіовихід, перевірте, чи ваші динаміки у фазі.",
|
|
||||||
"tests_audio_label": "Аудіо",
|
|
||||||
"tests_av-sync_description": "Перевірте, чи синхронізовані аудіо та відео, та виміряйте затримку.",
|
|
||||||
"tests_av-sync_label": "Синхронізація аудіо/відео",
|
|
||||||
"tests_card_description": "Тестова картка для вашого дисплея або проектора, перевірте кольори, роздільну здатність та геометрію.",
|
|
||||||
"tests_card_label": "Картка",
|
|
||||||
"tests_camera_description": "Перевірте, чи працює ваша веб-камера або пристрій захоплення, її якість зображення, роздільну здатність та частоту кадрів. Зробіть знімок.",
|
|
||||||
"tests_camera_label": "Камера",
|
|
||||||
"tests_gamepad_description": "Перевірте свій геймпад, перевірте, чи він працює, всі кнопки та джойстики, дрейф стіків, мертві зони та калібрування.",
|
|
||||||
"tests_gamepad_label": "Геймпад",
|
|
||||||
"tests_keyboard_description": "Перевірте, чи всі клавіші працюють і які коди клавіш вони надсилають.",
|
|
||||||
"tests_keyboard_label": "Клавіатура",
|
|
||||||
"tests_microphone_description": "Перевірте, чи працює ваш мікрофон, його якість, гучність та шум.",
|
|
||||||
"tests_microphone_label": "Мікрофон",
|
|
||||||
"tests_mouse_description": "Перевірте, чи правильно працює ваша миша або сенсорний пристрій, чи є мертві зони або тремтіння.",
|
|
||||||
"tests_mouse_label": "Миша",
|
|
||||||
"tests_sensors_description": "Перегляньте вивід датчиків вашого пристрою, наприклад, GPS, акселерометр, гіроскоп, компас тощо.",
|
|
||||||
"tests_sensors_label": "Датчики",
|
|
||||||
"tests_internet_description": "Виміряйте швидкість та затримку вашого Інтернету.",
|
|
||||||
"tests_internet_label": "Швидкість Інтернету",
|
|
||||||
"tests_timer_description": "Перевірте, чи працює ваш таймер високої роздільної здатності.",
|
|
||||||
"tests_timer_label": "Таймер високої роздільної здатності",
|
|
||||||
"category_inputs": "Входи",
|
|
||||||
"category_outputs": "Виходи",
|
|
||||||
"category_audio": "Аудіо",
|
|
||||||
"category_video": "Відео",
|
|
||||||
"category_control": "Управління",
|
|
||||||
"category_misc": "Різне",
|
|
||||||
"noTestsFound": "Тестів не знайдено.",
|
|
||||||
"camera_title": "Тест камери",
|
|
||||||
"camera_device": "Пристрій",
|
|
||||||
"camera_noCameraFound": "Камеру не знайдено",
|
|
||||||
"camera_refresh": "Оновити",
|
|
||||||
"camera_resolution": "Роздільна здатність",
|
|
||||||
"camera_frameRate": "Частота кадрів",
|
|
||||||
"camera_noCameraSelected": "Камеру не вибрано",
|
|
||||||
"camera_takePicture": "Зробити знімок",
|
|
||||||
"camera_unflipImage": "Повернути зображення",
|
|
||||||
"camera_flipImage": "Перевернути зображення",
|
|
||||||
"camera_closeSnapshot": "Закрити знімок",
|
|
||||||
"audio_channel_frontLeft": "Передній лівий",
|
|
||||||
"audio_channel_frontCenter": "Передній центральний",
|
|
||||||
"audio_channel_frontRight": "Передній правий",
|
|
||||||
"audio_channel_sideLeft": "Бічний лівий",
|
|
||||||
"audio_channel_sideRight": "Бічний правий",
|
|
||||||
"audio_channel_rearLeft": "Задній лівий",
|
|
||||||
"audio_channel_rearRight": "Задній правий",
|
|
||||||
"audio_channel_lfe": "LFE",
|
|
||||||
"gamepad_title": "Тести геймпада та джойстика",
|
|
||||||
"gamepad_device": "Пристрій",
|
|
||||||
"gamepad_noGamepadsDetected": "Геймпади не виявлено. (Спробуйте натиснути кнопку)",
|
|
||||||
"gamepad_refresh": "Оновити",
|
|
||||||
"gamepad_buttons": "Кнопки",
|
|
||||||
"gamepad_axes": "Осі",
|
|
||||||
"gamepad_history": "Історія",
|
|
||||||
"audio_channelTests": "Тести каналів",
|
|
||||||
"audio_stereo": "Стерео",
|
|
||||||
"audio_surroundAudio": "Об'ємний звук",
|
|
||||||
"audio_surround51": "5.1 Об'ємний",
|
|
||||||
"audio_surround71": "7.1 Об'ємний",
|
|
||||||
"audio_phaseTest": "Тест фази",
|
|
||||||
"audio_frequency": "Частота",
|
|
||||||
"audio_inPhase": "У фазі",
|
|
||||||
"audio_outOfPhase": "Поза фазою",
|
|
||||||
"audio_stop": "Зупинити",
|
|
||||||
"screenInfo_screenResolution": "Роздільна здатність екрана",
|
|
||||||
"screenInfo_windowResolution": "Роздільна здатність вікна",
|
|
||||||
"screenInfo_devicePixelRatio": "Співвідношення пікселів пристрою",
|
|
||||||
"audio_channel_left": "Лівий",
|
|
||||||
"audio_channel_center": "Центр",
|
|
||||||
"audio_channel_right": "Правий",
|
|
||||||
"keyboard_title": "Тест клавіатури",
|
|
||||||
"keyboard_instruction": "Натисніть клавішу на клавіатурі, щоб побачити об'єкт події та код клавіші.",
|
|
||||||
"keyboard_pressedKeys": "Натиснуті клавіші:",
|
|
||||||
"timer_title": "Таймер високої роздільної здатності",
|
|
||||||
"timer_fps": "FPS",
|
|
||||||
"timer_restart": "Перезапустити",
|
|
||||||
"audio_stopCycling": "Зупинити цикл",
|
|
||||||
"audio_cycleThrough": "Перебирати",
|
|
||||||
"common_back": "Назад",
|
|
||||||
"audio_title": "Тест аудіо",
|
|
||||||
"avSync_title": "Синхронізація аудіо/відео",
|
|
||||||
"internet_title": "Швидкість Інтернету",
|
|
||||||
"tests_signal-generator_description": "Генеруйте синусоїди, шум (білий, рожевий, коричневий) та частотні розгортки. Включає осцилограф та спектр.",
|
|
||||||
"tests_signal-generator_label": "Генератор сигналів",
|
|
||||||
"signalGen_title": "Генератор сигналів",
|
|
||||||
"signalGen_type": "Тип",
|
|
||||||
"signalGen_sine": "Синусоїда",
|
|
||||||
"signalGen_sweep": "Розгортка",
|
|
||||||
"signalGen_noiseWhite": "Білий шум",
|
|
||||||
"signalGen_noisePink": "Рожевий шум",
|
|
||||||
"signalGen_noiseBrown": "Коричневий шум",
|
|
||||||
"signalGen_frequency": "Частота",
|
|
||||||
"signalGen_from": "Від",
|
|
||||||
"signalGen_to": "До",
|
|
||||||
"signalGen_duration": "Тривалість",
|
|
||||||
"signalGen_gain": "Гучність",
|
|
||||||
"signalGen_start": "Старт",
|
|
||||||
"signalGen_stop": "Стоп",
|
|
||||||
"signalGen_scope": "Осцилограф",
|
|
||||||
"signalGen_spectrum": "Спектр",
|
|
||||||
"signalGen_loop": "Цикл",
|
|
||||||
"mic_title": "Тест мікрофона",
|
|
||||||
"mic_startMicrophone": "Почати мікрофон",
|
|
||||||
"mic_stop": "Зупинити",
|
|
||||||
"mic_monitoringOn": "Моніторинг: УВІМК",
|
|
||||||
"mic_monitoringOff": "Моніторинг: ВИМК",
|
|
||||||
"mic_gain": "Посилення",
|
|
||||||
"mic_monitorDelay": "Затримка монітора",
|
|
||||||
"mic_sampleRate": "Частота дискретизації",
|
|
||||||
"mic_inputDevice": "Пристрій введення",
|
|
||||||
"mic_volume": "Гучність",
|
|
||||||
"mic_recording": "Запис",
|
|
||||||
"mic_startRecording": "Почати запис",
|
|
||||||
"mic_stopRecording": "Зупинити запис",
|
|
||||||
"mic_downloadRecording": "Завантажити запис",
|
|
||||||
"mic_device": "Пристрій",
|
|
||||||
"mic_noMicFound": "Мікрофон не знайдено",
|
|
||||||
"mic_refresh": "Оновити",
|
|
||||||
"mic_clipping": "Відсікання",
|
|
||||||
"mic_constraints": "Обмеження",
|
|
||||||
"mic_echoCancellation": "Скасування відлуння",
|
|
||||||
"mic_noiseSuppression": "Придушення шуму",
|
|
||||||
"mic_agc": "Автоматичне регулювання посилення",
|
|
||||||
"mic_applyConstraints": "Застосувати",
|
|
||||||
"mic_channels": "Канали",
|
|
||||||
"mic_stereo": "Стерео",
|
|
||||||
"mic_requested": "Запитано",
|
|
||||||
"mic_obtained": "Отримано",
|
|
||||||
"mic_peakNow": "Пік",
|
|
||||||
"mic_peakHold": "Утримання піку",
|
|
||||||
"mic_resetPeaks": "Скинути піки",
|
|
||||||
"mic_advanced": "Розширений",
|
|
||||||
"mic_default": "За замовчуванням",
|
|
||||||
"mic_on": "Увімкнено",
|
|
||||||
"mic_off": "Вимкнено",
|
|
||||||
"mic_mono": "Моно",
|
|
||||||
"sensors_title": "Датчики",
|
|
||||||
"sensors_geolocation": "Геолокація",
|
|
||||||
"sensors_start": "Старт",
|
|
||||||
"sensors_stop": "Стоп",
|
|
||||||
"sensors_accuracy": "Точність (м)",
|
|
||||||
"sensors_altitude": "Висота (м)",
|
|
||||||
"sensors_heading": "Напрямок (град)",
|
|
||||||
"sensors_speed": "Швидкість (м/с)",
|
|
||||||
"sensors_timestamp": "Часова мітка",
|
|
||||||
"sensors_copy": "Копіювати JSON",
|
|
||||||
"sensors_copied": "Скопійовано в буфер обміну",
|
|
||||||
"sensors_notSupported": "Не підтримується на цьому пристрої/браузері",
|
|
||||||
"sensors_deviceMotion": "Рух пристрою",
|
|
||||||
"sensors_deviceOrientation": "Орієнтація пристрою",
|
|
||||||
"sensors_accelerometer": "Акселерометр",
|
|
||||||
"sensors_gyroscope": "Гіроскоп",
|
|
||||||
"sensors_magnetometer": "Магнітометр",
|
|
||||||
"sensors_ambientLight": "Освітленість",
|
|
||||||
"sensors_illuminance": "Освітленість (люкс)",
|
|
||||||
"sensors_barometer": "Барометр",
|
|
||||||
"sensors_pressure": "Тиск (гПа)",
|
|
||||||
"sensors_temperature": "Температура (°C)",
|
|
||||||
"sensors_permissions": "Дозволи",
|
|
||||||
"sensors_enableMotionOrientation": "Увімкнути рух/орієнтацію",
|
|
||||||
"sensors_motion": "Рух",
|
|
||||||
"sensors_orientation": "Орієнтація",
|
|
||||||
"sensors_status_granted": "Надано",
|
|
||||||
"sensors_status_denied": "Відмовлено",
|
|
||||||
"sensors_status_unknown": "Невідомо"
|
|
||||||
}
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
|
||||||
"search": "搜索",
|
|
||||||
"tests_audio_description": "检查您的立体声通道或环绕声音频输出,验证您的扬声器是否同相。",
|
|
||||||
"tests_audio_label": "音频",
|
|
||||||
"tests_av-sync_description": "检查您的音频和视频是否同步,并测量延迟。",
|
|
||||||
"tests_av-sync_label": "音视频同步",
|
|
||||||
"tests_card_description": "用于您的显示器或投影仪的测试卡,检查颜色、分辨率和几何形状。",
|
|
||||||
"tests_card_label": "测试卡",
|
|
||||||
"tests_camera_description": "检查您的网络摄像头或采集设备是否正常工作,其图像质量、分辨率和帧率。拍摄快照。",
|
|
||||||
"tests_camera_label": "摄像头",
|
|
||||||
"tests_gamepad_description": "测试您的游戏手柄,检查其是否正常工作,所有按钮和摇杆,摇杆漂移,死区和校准。",
|
|
||||||
"tests_gamepad_label": "游戏手柄",
|
|
||||||
"tests_keyboard_description": "检查所有按键是否正常工作以及它们发送的键码。",
|
|
||||||
"tests_keyboard_label": "键盘",
|
|
||||||
"tests_microphone_description": "检查您的麦克风是否正常工作,其质量、音量和噪音。",
|
|
||||||
"tests_microphone_label": "麦克风",
|
|
||||||
"tests_mouse_description": "检查您的鼠标或触摸设备是否正常工作,是否存在死区或抖动。",
|
|
||||||
"tests_mouse_label": "鼠标",
|
|
||||||
"tests_sensors_description": "查看您设备的传感器输出,例如GPS、加速度计、陀螺仪、指南针等。",
|
|
||||||
"tests_sensors_label": "传感器",
|
|
||||||
"tests_internet_description": "测量您的互联网速度和延迟。",
|
|
||||||
"tests_internet_label": "网速",
|
|
||||||
"tests_timer_description": "检查您的高分辨率计时器是否正常工作。",
|
|
||||||
"tests_timer_label": "高分辨率计时器",
|
|
||||||
"category_inputs": "输入",
|
|
||||||
"category_outputs": "输出",
|
|
||||||
"category_audio": "音频",
|
|
||||||
"category_video": "视频",
|
|
||||||
"category_control": "控制",
|
|
||||||
"category_misc": "杂项",
|
|
||||||
"noTestsFound": "未找到测试。",
|
|
||||||
"camera_title": "摄像头测试",
|
|
||||||
"camera_device": "设备",
|
|
||||||
"camera_noCameraFound": "未找到摄像头",
|
|
||||||
"camera_refresh": "刷新",
|
|
||||||
"camera_resolution": "分辨率",
|
|
||||||
"camera_frameRate": "帧率",
|
|
||||||
"camera_noCameraSelected": "未选择摄像头",
|
|
||||||
"camera_takePicture": "拍照",
|
|
||||||
"camera_unflipImage": "取消翻转图像",
|
|
||||||
"camera_flipImage": "翻转图像",
|
|
||||||
"camera_closeSnapshot": "关闭快照",
|
|
||||||
"audio_channel_frontLeft": "左前置",
|
|
||||||
"audio_channel_frontCenter": "中前置",
|
|
||||||
"audio_channel_frontRight": "右前置",
|
|
||||||
"audio_channel_sideLeft": "左侧",
|
|
||||||
"audio_channel_sideRight": "右侧",
|
|
||||||
"audio_channel_rearLeft": "左后置",
|
|
||||||
"audio_channel_rearRight": "右后置",
|
|
||||||
"audio_channel_lfe": "LFE",
|
|
||||||
"gamepad_title": "游戏手柄和摇杆测试",
|
|
||||||
"gamepad_device": "设备",
|
|
||||||
"gamepad_noGamepadsDetected": "未检测到游戏手柄。(请尝试按下一个按钮)",
|
|
||||||
"gamepad_refresh": "刷新",
|
|
||||||
"gamepad_buttons": "按钮",
|
|
||||||
"gamepad_axes": "轴",
|
|
||||||
"gamepad_history": "历史",
|
|
||||||
"audio_channelTests": "声道测试",
|
|
||||||
"audio_stereo": "立体声",
|
|
||||||
"audio_surroundAudio": "环绕声",
|
|
||||||
"audio_surround51": "5.1环绕声",
|
|
||||||
"audio_surround71": "7.1环绕声",
|
|
||||||
"audio_phaseTest": "相位测试",
|
|
||||||
"audio_frequency": "频率",
|
|
||||||
"audio_inPhase": "同相",
|
|
||||||
"audio_outOfPhase": "异相",
|
|
||||||
"audio_stop": "停止",
|
|
||||||
"screenInfo_screenResolution": "屏幕分辨率",
|
|
||||||
"screenInfo_windowResolution": "窗口分辨率",
|
|
||||||
"screenInfo_devicePixelRatio": "设备像素比",
|
|
||||||
"audio_channel_left": "左",
|
|
||||||
"audio_channel_center": "中",
|
|
||||||
"audio_channel_right": "右",
|
|
||||||
"keyboard_title": "键盘测试",
|
|
||||||
"keyboard_instruction": "按键盘上的一个键以查看事件对象和键码。",
|
|
||||||
"keyboard_pressedKeys": "按下的键:",
|
|
||||||
"timer_title": "高分辨率计时器",
|
|
||||||
"timer_fps": "FPS",
|
|
||||||
"timer_restart": "重新开始",
|
|
||||||
"audio_stopCycling": "停止循环",
|
|
||||||
"audio_cycleThrough": "循环浏览",
|
|
||||||
"common_back": "返回",
|
|
||||||
"audio_title": "音频测试",
|
|
||||||
"avSync_title": "音视频同步",
|
|
||||||
"internet_title": "网速",
|
|
||||||
"tests_signal-generator_description": "生成正弦波、噪声(白噪声、粉红噪声、布朗噪声)和频率扫描。包括示波器和频谱图。",
|
|
||||||
"tests_signal-generator_label": "信号发生器",
|
|
||||||
"signalGen_title": "信号发生器",
|
|
||||||
"signalGen_type": "类型",
|
|
||||||
"signalGen_sine": "正弦波",
|
|
||||||
"signalGen_sweep": "扫描",
|
|
||||||
"signalGen_noiseWhite": "白噪声",
|
|
||||||
"signalGen_noisePink": "粉红噪声",
|
|
||||||
"signalGen_noiseBrown": "布朗噪声",
|
|
||||||
"signalGen_frequency": "频率",
|
|
||||||
"signalGen_from": "从",
|
|
||||||
"signalGen_to": "到",
|
|
||||||
"signalGen_duration": "持续时间",
|
|
||||||
"signalGen_gain": "音量",
|
|
||||||
"signalGen_start": "开始",
|
|
||||||
"signalGen_stop": "停止",
|
|
||||||
"signalGen_scope": "示波器",
|
|
||||||
"signalGen_spectrum": "频谱图",
|
|
||||||
"signalGen_loop": "循环",
|
|
||||||
"mic_title": "麦克风测试",
|
|
||||||
"mic_startMicrophone": "开始麦克风",
|
|
||||||
"mic_stop": "停止",
|
|
||||||
"mic_monitoringOn": "监听:开",
|
|
||||||
"mic_monitoringOff": "监听:关",
|
|
||||||
"mic_gain": "增益",
|
|
||||||
"mic_monitorDelay": "监听延迟",
|
|
||||||
"mic_sampleRate": "采样率",
|
|
||||||
"mic_inputDevice": "输入设备",
|
|
||||||
"mic_volume": "音量",
|
|
||||||
"mic_recording": "录音",
|
|
||||||
"mic_startRecording": "开始录音",
|
|
||||||
"mic_stopRecording": "停止录音",
|
|
||||||
"mic_downloadRecording": "下载录音",
|
|
||||||
"mic_device": "设备",
|
|
||||||
"mic_noMicFound": "未找到麦克风",
|
|
||||||
"mic_refresh": "刷新",
|
|
||||||
"mic_clipping": "削波",
|
|
||||||
"mic_constraints": "约束",
|
|
||||||
"mic_echoCancellation": "回声消除",
|
|
||||||
"mic_noiseSuppression": "噪声抑制",
|
|
||||||
"mic_agc": "自动增益控制",
|
|
||||||
"mic_applyConstraints": "应用",
|
|
||||||
"mic_channels": "声道",
|
|
||||||
"mic_stereo": "立体声",
|
|
||||||
"mic_requested": "已请求",
|
|
||||||
"mic_obtained": "已获取",
|
|
||||||
"mic_peakNow": "峰值",
|
|
||||||
"mic_peakHold": "峰值保持",
|
|
||||||
"mic_resetPeaks": "重置峰值",
|
|
||||||
"mic_advanced": "高级",
|
|
||||||
"mic_default": "默认",
|
|
||||||
"mic_on": "开",
|
|
||||||
"mic_off": "关",
|
|
||||||
"mic_mono": "单声道",
|
|
||||||
"sensors_title": "传感器",
|
|
||||||
"sensors_geolocation": "地理位置",
|
|
||||||
"sensors_start": "开始",
|
|
||||||
"sensors_stop": "停止",
|
|
||||||
"sensors_accuracy": "精度 (m)",
|
|
||||||
"sensors_altitude": "高度 (m)",
|
|
||||||
"sensors_heading": "方向 (度)",
|
|
||||||
"sensors_speed": "速度 (m/s)",
|
|
||||||
"sensors_timestamp": "时间戳",
|
|
||||||
"sensors_copy": "复制JSON",
|
|
||||||
"sensors_copied": "已复制到剪贴板",
|
|
||||||
"sensors_notSupported": "此设备/浏览器不支持",
|
|
||||||
"sensors_deviceMotion": "设备运动",
|
|
||||||
"sensors_deviceOrientation": "设备方向",
|
|
||||||
"sensors_accelerometer": "加速度计",
|
|
||||||
"sensors_gyroscope": "陀螺仪",
|
|
||||||
"sensors_magnetometer": "磁力计",
|
|
||||||
"sensors_ambientLight": "环境光",
|
|
||||||
"sensors_illuminance": "照度 (lux)",
|
|
||||||
"sensors_barometer": "气压计",
|
|
||||||
"sensors_pressure": "压力 (hPa)",
|
|
||||||
"sensors_temperature": "温度 (°C)",
|
|
||||||
"sensors_permissions": "权限",
|
|
||||||
"sensors_enableMotionOrientation": "启用运动/方向",
|
|
||||||
"sensors_motion": "运动",
|
|
||||||
"sensors_orientation": "方向",
|
|
||||||
"sensors_status_granted": "已授予",
|
|
||||||
"sensors_status_denied": "已拒绝",
|
|
||||||
"sensors_status_unknown": "未知"
|
|
||||||
}
|
|
||||||
70
package.json
70
package.json
|
|
@ -12,66 +12,42 @@
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"generate-assets": "earthly +assets-generated",
|
"generate-assets": "earthly +assets-generated",
|
||||||
"av:dev": "cd av-sync && vite",
|
"av:dev": "cd av-sync && vite",
|
||||||
"av:render:video": "cd av-sync && concurrently -P -k -s command-1 \"vite --host 127.0.0.1 --port 8626\" \"wait-on http://127.0.0.1:8626 && node render-video.js --url http://127.0.0.1:8626 {@}\" --",
|
"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"
|
"av:render:audio": "cd av-sync && node render-audio.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inlang/paraglide-js": "^2.0.0",
|
"@tsconfig/svelte": "^5.0.2",
|
||||||
"@inlang/plugin-m-function-matcher": "^2.1.0",
|
|
||||||
"@inlang/plugin-message-format": "^4.0.0",
|
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
|
||||||
"@types/debug": "^4.1.12",
|
"@types/debug": "^4.1.12",
|
||||||
"@types/eslint": "8.56.0",
|
"@types/eslint": "8.56.0",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "^4.14.202",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@types/wait-on": "^5.3.4",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"commander": "^12.0.0",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
|
||||||
"autoprefixer": "^10.4.23",
|
|
||||||
"commander": "^12.1.0",
|
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.35.1",
|
||||||
"node-wav": "^0.0.2",
|
"node-wav": "^0.0.2",
|
||||||
"pixelmatch": "^7.1.0",
|
"prettier": "^3.1.1",
|
||||||
"pngjs": "^7.0.0",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
"postcss": "^8.5.6",
|
"puppeteer": "^22.1.0",
|
||||||
"prettier": "^3.5.0",
|
"svelte-check": "^3.6.0",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"wait-on": "^7.2.0"
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
||||||
"puppeteer": "^22.15.0",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"vitest": "^3.2.4",
|
|
||||||
"wait-on": "^9.0.1"
|
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/atkinson-hyperlegible": "^5.1.1",
|
"@fontsource/b612": "^5.0.8",
|
||||||
"@fontsource/b612": "^5.1.1",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-auto": "^3.3.1",
|
"@sveltejs/adapter-static": "^3.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/kit": "^2.17.1",
|
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
|
||||||
"@tabler/icons-webfont": "^2.47.0",
|
"@tabler/icons-webfont": "^2.47.0",
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.3.4",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^4.2.7",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.4.1",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^5.4.14"
|
"vite": "^5.0.3"
|
||||||
},
|
|
||||||
"trustedDependencies": [
|
|
||||||
"esbuild",
|
|
||||||
"puppeteer",
|
|
||||||
"svelte-preprocess"
|
|
||||||
],
|
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"esbuild"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3015
pnpm-lock.yaml
generated
Normal file
3015
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
1
project.inlang/.gitignore
vendored
1
project.inlang/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
||||||
cache
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
bkDf3wpqa4gWMSsmqk
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/project-settings",
|
|
||||||
"modules": [
|
|
||||||
"./node_modules/@inlang/plugin-message-format/dist/index.js",
|
|
||||||
"./node_modules/@inlang/plugin-m-function-matcher/dist/index.js"
|
|
||||||
],
|
|
||||||
"plugin.inlang.messageFormat": {
|
|
||||||
"pathPattern": "./messages/{locale}.json"
|
|
||||||
},
|
|
||||||
"baseLocale": "en",
|
|
||||||
"locales": ["en", "es", "fr", "de", "zh-CN", "ja", "cs", "ukr"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="%paraglide.lang%">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>TOTAL TECH TEST</title>
|
<title>TEST CARD</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import type { Handle } from '@sveltejs/kit';
|
|
||||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
|
||||||
|
|
||||||
const handleParaglide: Handle = ({ event, resolve }) =>
|
|
||||||
paraglideMiddleware(event.request, ({ request, locale }) => {
|
|
||||||
event.request = request;
|
|
||||||
|
|
||||||
return resolve(event, {
|
|
||||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export const handle: Handle = handleParaglide;
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { deLocalizeUrl } from '$lib/paraglide/runtime';
|
|
||||||
|
|
||||||
export const reroute = (request) => deLocalizeUrl(request.url).pathname;
|
|
||||||
113
src/index.css
113
src/index.css
|
|
@ -1,72 +1,53 @@
|
||||||
@import 'tailwindcss';
|
body, html {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
@theme {
|
color: white;
|
||||||
--font-sans:
|
background-color: black;
|
||||||
'Atkinson Hyperlegible', 'B612', 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
|
font-family: 'B612', 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: min(1.5vw, 1.5vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
* {
|
||||||
body,
|
box-sizing: border-box;
|
||||||
html {
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
color: white;
|
|
||||||
background-color: black;
|
|
||||||
font-size: 20px;
|
|
||||||
@apply font-sans;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
a {
|
||||||
button,
|
color: white;
|
||||||
.button {
|
|
||||||
@apply inline-flex cursor-pointer items-center gap-[0.25em] border border-white no-underline;
|
|
||||||
@apply rounded-[0.25em] px-[0.5em] py-[0.25em];
|
|
||||||
@apply bg-black text-white;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
@apply opacity-70;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='number'],
|
|
||||||
input[type='search'],
|
|
||||||
input[type='text'] {
|
|
||||||
@apply rounded border border-white bg-transparent text-white;
|
|
||||||
padding: 0.2em;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: solid rgba(255, 255, 255, 0.66);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
@apply pointer-events-none opacity-70;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
@apply rounded-[0.25em] border border-white bg-black px-[0.5em] py-[0.25em] text-white;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
@apply opacity-70;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let heightOdd = $state(false);
|
let heightOdd = false;
|
||||||
let widthOdd = $state(false);
|
let widthOdd = false;
|
||||||
|
|
||||||
function updateOdd() {
|
function updateOdd() {
|
||||||
heightOdd = window.innerHeight % 2 === 1;
|
heightOdd = window.innerHeight % 2 === 1;
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const START_COUNT = 27;
|
const START_COUNT = 27;
|
||||||
const MAX_COUNT = 33;
|
const MAX_COUNT = 33;
|
||||||
const MARGIN_SIZE = 16;
|
const MARGIN_SIZE = 16;
|
||||||
|
|
||||||
let horizontalCount = $state(START_COUNT);
|
let horizontalCount = START_COUNT;
|
||||||
let verticalCount = $state(START_COUNT);
|
let verticalCount = START_COUNT;
|
||||||
let blockSize = $state(64);
|
let blockSize = 64;
|
||||||
let cornerBlocks = $state(2);
|
let cornerBlocks = 2;
|
||||||
|
|
||||||
let horizontalMargin = $state(MARGIN_SIZE);
|
let horizontalMargin = MARGIN_SIZE;
|
||||||
let verticalMargin = $state(MARGIN_SIZE);
|
let verticalMargin = MARGIN_SIZE;
|
||||||
let unloaded = $state(true);
|
let unloaded = true;
|
||||||
|
|
||||||
interface Props {
|
export let transparent = false;
|
||||||
transparent?: boolean;
|
export let subdued = false;
|
||||||
subdued?: boolean;
|
|
||||||
onchange?: (detail: {
|
|
||||||
horizontalCount: number;
|
|
||||||
verticalCount: number;
|
|
||||||
blockSize: number;
|
|
||||||
horizontalMargin: number;
|
|
||||||
verticalMargin: number;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { transparent = false, subdued = false, onchange }: Props = $props();
|
|
||||||
|
|
||||||
function updateCounts() {
|
function updateCounts() {
|
||||||
const gridWidth = window.innerWidth - MARGIN_SIZE;
|
const gridWidth = window.innerWidth - MARGIN_SIZE;
|
||||||
|
|
@ -62,7 +52,7 @@
|
||||||
horizontalMargin = (window.innerWidth - blockSize * horizontalCount) / 2;
|
horizontalMargin = (window.innerWidth - blockSize * horizontalCount) / 2;
|
||||||
verticalMargin = (window.innerHeight - blockSize * verticalCount) / 2;
|
verticalMargin = (window.innerHeight - blockSize * verticalCount) / 2;
|
||||||
cornerBlocks = shorterCount > 8 ? 3 : Math.max(1, Math.floor(shorterCount / 4));
|
cornerBlocks = shorterCount > 8 ? 3 : Math.max(1, Math.floor(shorterCount / 4));
|
||||||
onchange?.({
|
dispatch('change', {
|
||||||
horizontalCount,
|
horizontalCount,
|
||||||
verticalCount,
|
verticalCount,
|
||||||
blockSize,
|
blockSize,
|
||||||
|
|
@ -121,9 +111,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each [...Array(verticalCount).keys()] as x}
|
{#each [...Array(verticalCount).keys()] as x}
|
||||||
<div class="row" data-idx={x}>
|
<div class="row">
|
||||||
{#each [...Array(horizontalCount).keys()] as y}
|
{#each [...Array(horizontalCount).keys()] as y}
|
||||||
<div class="block" data-idx={y}></div>
|
<div class="block"></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
let time = $state(new Date());
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
setInterval(() => {
|
|
||||||
time = new Date();
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="clock">
|
|
||||||
<div class="h">{time.getHours()}</div>
|
|
||||||
<div class="separator">:</div>
|
|
||||||
<div class="m">{time.getMinutes().toString().padStart(2, '0')}</div>
|
|
||||||
<div class="separator">:</div>
|
|
||||||
<div class="s">{time.getSeconds().toString().padStart(2, '0')}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.clock {
|
|
||||||
display: flex;
|
|
||||||
font-size: 3em;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: -0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
position: relative;
|
|
||||||
bottom: 0.15em;
|
|
||||||
margin: 0 0.05em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,20 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
let screenResolution = $state('... x ...');
|
let screenResolution = '... x ...';
|
||||||
let windowResolution = $state('');
|
let windowResolution = '';
|
||||||
let dpr = $state('1');
|
|
||||||
|
|
||||||
function updateResolution() {
|
function updateResolution() {
|
||||||
const realWidth = Math.round(screen.width) * window.devicePixelRatio;
|
const realWidth = Math.round(screen.width * window.devicePixelRatio);
|
||||||
const realHeight = Math.round(screen.height) * window.devicePixelRatio;
|
const realHeight = Math.round(screen.height * window.devicePixelRatio);
|
||||||
const windowWidth = Math.round(window.innerWidth * window.devicePixelRatio);
|
const windowWidth = Math.round(window.innerWidth * window.devicePixelRatio);
|
||||||
const windowHeight = Math.round(window.innerHeight * window.devicePixelRatio);
|
const windowHeight = Math.round(window.innerHeight * window.devicePixelRatio);
|
||||||
screenResolution = `${realWidth} x ${realHeight}`;
|
screenResolution = `${realWidth} x ${realHeight}`;
|
||||||
windowResolution = `${windowWidth} x ${windowHeight}`;
|
windowResolution = `${windowWidth} x ${windowHeight}`;
|
||||||
dpr = String(Math.round(window.devicePixelRatio * 100) / 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -27,32 +24,27 @@
|
||||||
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="resolution">
|
<div class="resolution">
|
||||||
<div class="title">{m.screenInfo_screenResolution()}</div>
|
<div class="title">Screen Resolution</div>
|
||||||
<div class="value">{screenResolution}</div>
|
<div class="value">{screenResolution}</div>
|
||||||
{#if windowResolution && windowResolution !== screenResolution}
|
{#if windowResolution && windowResolution !== screenResolution}
|
||||||
<div class="window" transition:fade>
|
<div class="window" transition:fade>
|
||||||
<div class="title">{m.screenInfo_windowResolution()}</div>
|
<div class="title">Window Resolution</div>
|
||||||
<div class="value">{windowResolution}</div>
|
<div class="value">{windowResolution}</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dpr !== '1'}
|
|
||||||
<div class="dpr">{m.screenInfo_devicePixelRatio()}: {dpr}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.info {
|
.info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.window,
|
.window {
|
||||||
.dpr {
|
|
||||||
margin-top: calc(1em / 0.8);
|
margin-top: calc(1em / 0.8);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
src/lib/Spinner.svelte
Normal file
22
src/lib/Spinner.svelte
Normal file
|
|
@ -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,38 +4,31 @@
|
||||||
import Axes from '$lib/Axes.svelte';
|
import Axes from '$lib/Axes.svelte';
|
||||||
import ColorGradient from '$lib/ColorGradient.svelte';
|
import ColorGradient from '$lib/ColorGradient.svelte';
|
||||||
import BrightnessGradient from '$lib/BrightnessGradient.svelte';
|
import BrightnessGradient from '$lib/BrightnessGradient.svelte';
|
||||||
import Clock from '$lib/Clock.svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
const dispatch = createEventDispatcher<{ focus: void }>();
|
||||||
|
|
||||||
interface Props {
|
export let full = false;
|
||||||
bg?: boolean;
|
|
||||||
onfocus?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { bg = false, onfocus }: Props = $props();
|
let sizes = {
|
||||||
|
|
||||||
let sizes = $state({
|
|
||||||
blockSize: 64,
|
blockSize: 64,
|
||||||
horizontalCount: 16,
|
horizontalCount: 16,
|
||||||
verticalCount: 16,
|
verticalCount: 16,
|
||||||
horizontalMargin: 0,
|
horizontalMargin: 0,
|
||||||
verticalMargin: 0
|
verticalMargin: 0
|
||||||
});
|
};
|
||||||
|
|
||||||
let columnWidth = $derived(sizes.horizontalCount % 2 === 0 ? 3 : 4);
|
$: columnWidth = sizes.horizontalCount % 2 === 0 ? 3 : 4;
|
||||||
let columnHeight = $derived(
|
$: columnHeight = 2 * Math.floor((sizes.verticalCount * 0.75) / 2) + (sizes.verticalCount % 2);
|
||||||
2 * Math.floor((sizes.verticalCount * 0.75) / 2) + (sizes.verticalCount % 2)
|
$: leftColumn = sizes.horizontalCount / 4 - columnWidth / 2;
|
||||||
);
|
$: circleBlocks =
|
||||||
let leftColumn = $derived(sizes.horizontalCount / 4 - columnWidth / 2);
|
|
||||||
let circleBlocks = $derived(
|
|
||||||
2 * Math.floor((Math.min(sizes.horizontalCount, sizes.verticalCount) * 0.66) / 2) +
|
2 * Math.floor((Math.min(sizes.horizontalCount, sizes.verticalCount) * 0.66) / 2) +
|
||||||
(sizes.horizontalCount % 2)
|
(sizes.horizontalCount % 2);
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div
|
<div
|
||||||
class="test-card"
|
class="test-card"
|
||||||
class:bg
|
class:full
|
||||||
style="--block-size: {sizes.blockSize}px;
|
style="--block-size: {sizes.blockSize}px;
|
||||||
--horizontal-margin: {sizes.horizontalMargin}px;
|
--horizontal-margin: {sizes.horizontalMargin}px;
|
||||||
--vertical-margin: {sizes.verticalMargin}px;
|
--vertical-margin: {sizes.verticalMargin}px;
|
||||||
|
|
@ -43,12 +36,9 @@
|
||||||
--column-width: {columnWidth};
|
--column-width: {columnWidth};
|
||||||
--column-height: {columnHeight};
|
--column-height: {columnHeight};
|
||||||
--left-column: {leftColumn};"
|
--left-column: {leftColumn};"
|
||||||
ondblclick={() => {
|
on:dblclick={() => dispatch('focus') && document.body.requestFullscreen()}
|
||||||
onfocus?.();
|
|
||||||
document.body.requestFullscreen();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<BackgroundGrid onchange={(detail) => (sizes = detail)} subdued={bg} />
|
<BackgroundGrid on:change={(ev) => (sizes = ev.detail)} subdued={!full} />
|
||||||
|
|
||||||
<div class="axes">
|
<div class="axes">
|
||||||
<Axes />
|
<Axes />
|
||||||
|
|
@ -58,7 +48,6 @@
|
||||||
<div class="inner"></div>
|
<div class="inner"></div>
|
||||||
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<Clock />
|
|
||||||
<ScreenInfo />
|
<ScreenInfo />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -89,10 +78,6 @@
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
font-family: 'Atkinson Hyperlegible', 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-size: min(4vw, 4vh);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner {
|
.inner {
|
||||||
|
|
@ -129,11 +114,6 @@
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
|
|
@ -161,7 +141,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-card.bg {
|
.test-card:not(.full) {
|
||||||
& .info,
|
& .info,
|
||||||
& .column,
|
& .column,
|
||||||
& .axes,
|
& .axes,
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
analyser: AnalyserNode;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { analyser, title = 'Oscilloscope' }: Props = $props();
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | null = null;
|
|
||||||
let animationId: number | null = null;
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
|
||||||
const w = canvas.clientWidth * dpr;
|
|
||||||
const h = canvas.clientHeight * dpr;
|
|
||||||
if (canvas.width !== w || canvas.height !== h) {
|
|
||||||
canvas.width = w;
|
|
||||||
canvas.height = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bufferLength = analyser.fftSize;
|
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
|
||||||
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
ctx.lineWidth = 2 * dpr;
|
|
||||||
ctx.strokeStyle = '#5cb85c';
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
const sliceWidth = w / bufferLength;
|
|
||||||
let x = 0;
|
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
|
||||||
const v = dataArray[i] / 128.0; // 0..255 -> 0..2
|
|
||||||
const y = (v * h) / 2;
|
|
||||||
if (i === 0) {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
x += sliceWidth;
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
animationId = requestAnimationFrame(draw);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
analyser.fftSize = 2048;
|
|
||||||
analyser.smoothingTimeConstant = 0.2;
|
|
||||||
animationId = requestAnimationFrame(draw);
|
|
||||||
return () => {
|
|
||||||
if (animationId) cancelAnimationFrame(animationId);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<div class="title">{title}</div>
|
|
||||||
<canvas bind:this={canvas} class="scope"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
opacity: 0.85;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.scope {
|
|
||||||
width: 100%;
|
|
||||||
height: 200px;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
analyser: AnalyserNode;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { analyser, title = 'Spectrum' }: Props = $props();
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | null = null;
|
|
||||||
let animationId: number | null = null;
|
|
||||||
|
|
||||||
// Format frequency values nicely for axis labels
|
|
||||||
function formatHz(f: number): string {
|
|
||||||
if (f >= 1000) {
|
|
||||||
const k = f / 1000;
|
|
||||||
// Show at most one decimal place when needed
|
|
||||||
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)} kHz`;
|
|
||||||
}
|
|
||||||
return `${Math.round(f)} Hz`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate 1-2-5 decade ticks up to Nyquist
|
|
||||||
function generateFreqTicks(maxF: number): number[] {
|
|
||||||
const ticks: number[] = [];
|
|
||||||
const bases = [1, 2, 5];
|
|
||||||
const maxExp = Math.floor(Math.log10(Math.max(1, maxF)));
|
|
||||||
for (let e = 0; e <= maxExp; e++) {
|
|
||||||
const pow = Math.pow(10, e);
|
|
||||||
for (const b of bases) {
|
|
||||||
const f = b * pow;
|
|
||||||
if (f >= 20 && f <= maxF) ticks.push(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ensure sorted unique ticks
|
|
||||||
return [...new Set(ticks)].sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
|
||||||
const w = canvas.clientWidth * dpr;
|
|
||||||
const h = canvas.clientHeight * dpr;
|
|
||||||
if (canvas.width !== w || canvas.height !== h) {
|
|
||||||
canvas.width = w;
|
|
||||||
canvas.height = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
|
||||||
analyser.getByteFrequencyData(dataArray);
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Layout for axis and grid
|
|
||||||
const sampleRate = analyser.context.sampleRate;
|
|
||||||
const nyquist = sampleRate / 2;
|
|
||||||
const axisPad = 24 * dpr; // space reserved at bottom for tick labels
|
|
||||||
const plotTop = 0;
|
|
||||||
const plotBottom = h - axisPad;
|
|
||||||
const plotH = Math.max(1, plotBottom - plotTop);
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(92, 184, 92, 0.25)';
|
|
||||||
ctx.strokeStyle = '#5cb85c';
|
|
||||||
ctx.lineWidth = 2 * dpr;
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
const barWidth = w / bufferLength;
|
|
||||||
let x = 0;
|
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
|
||||||
const v = dataArray[i] / 255.0;
|
|
||||||
const y = plotBottom - v * plotH;
|
|
||||||
if (i === 0) {
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
} else {
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
x += barWidth;
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw frequency axis, gridlines, and labels
|
|
||||||
ctx.save();
|
|
||||||
const gridColor = 'rgba(255, 255, 255, 0.15)';
|
|
||||||
const labelColor = 'rgba(255, 255, 255, 0.8)';
|
|
||||||
// Baseline
|
|
||||||
ctx.strokeStyle = gridColor;
|
|
||||||
ctx.lineWidth = 1 * dpr;
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, plotBottom);
|
|
||||||
ctx.lineTo(w, plotBottom);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Ticks
|
|
||||||
const ticks = generateFreqTicks(nyquist);
|
|
||||||
const tickLen = 6 * dpr;
|
|
||||||
ctx.font = `${Math.round(11 * dpr)}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
let lastLabelX = -Infinity;
|
|
||||||
const minLabelSpacing = 60 * dpr;
|
|
||||||
|
|
||||||
for (const f of ticks) {
|
|
||||||
const xTick = (f / nyquist) * w;
|
|
||||||
// Gridline
|
|
||||||
ctx.strokeStyle = gridColor;
|
|
||||||
ctx.lineWidth = 1 * dpr;
|
|
||||||
ctx.setLineDash([2 * dpr, 4 * dpr]);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(xTick, plotTop);
|
|
||||||
ctx.lineTo(xTick, plotBottom);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Tick mark at the axis
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(xTick, plotBottom);
|
|
||||||
ctx.lineTo(xTick, plotBottom + tickLen);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Label if there's room
|
|
||||||
if (xTick - lastLabelX >= minLabelSpacing) {
|
|
||||||
ctx.fillStyle = labelColor;
|
|
||||||
ctx.fillText(formatHz(f), xTick, plotBottom + tickLen + 2 * dpr);
|
|
||||||
lastLabelX = xTick;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
animationId = requestAnimationFrame(draw);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
analyser.fftSize = 2048;
|
|
||||||
analyser.smoothingTimeConstant = 0.8;
|
|
||||||
animationId = requestAnimationFrame(draw);
|
|
||||||
return () => {
|
|
||||||
if (animationId) cancelAnimationFrame(animationId);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<div class="title">{title}</div>
|
|
||||||
<canvas bind:this={canvas} class="spectrum"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
opacity: 0.85;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.spectrum {
|
|
||||||
width: 100%;
|
|
||||||
height: 200px;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
interface Props {
|
|
||||||
children?: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href=".."
|
|
||||||
class="hide-idle absolute top-8 right-8 z-50 flex items-center gap-2 rounded border border-white bg-black px-4 py-2 no-underline shadow-lg"
|
|
||||||
><i class="ti ti-arrow-back"></i> {m.common_back()}</a
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import TestCard from '$lib/TestCard.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TestCard />
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import TestCard from '$lib/TestCard.svelte';
|
|
||||||
|
|
||||||
let x = $state(-1);
|
|
||||||
let y = $state(-1);
|
|
||||||
let leftButton = $state(false);
|
|
||||||
let rightButton = $state(false);
|
|
||||||
|
|
||||||
function onMouseMove(ev: MouseEvent) {
|
|
||||||
x = ev.x;
|
|
||||||
y = ev.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseDown(ev: MouseEvent) {
|
|
||||||
if (ev.button === 0) {
|
|
||||||
leftButton = true;
|
|
||||||
} else if (ev.button === 2) {
|
|
||||||
rightButton = true;
|
|
||||||
}
|
|
||||||
ev.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseUp(ev: MouseEvent) {
|
|
||||||
if (ev.button === 0) {
|
|
||||||
leftButton = false;
|
|
||||||
} else if (ev.button === 2) {
|
|
||||||
rightButton = false;
|
|
||||||
}
|
|
||||||
ev.preventDefault();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:body
|
|
||||||
onmousemove={onMouseMove}
|
|
||||||
onmousedown={onMouseDown}
|
|
||||||
onmouseup={onMouseUp}
|
|
||||||
oncontextmenu={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="background">
|
|
||||||
<TestCard bg />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="indicator" style="--x: {x}px; --y: {y}px">
|
|
||||||
<div class="x"></div>
|
|
||||||
<div class="y"></div>
|
|
||||||
<div class="click left" class:pressed={leftButton}></div>
|
|
||||||
<div class="click right" class:pressed={rightButton}></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.background {
|
|
||||||
opacity: 0.33;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
|
|
||||||
& .x,
|
|
||||||
& .y {
|
|
||||||
position: absolute;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .x {
|
|
||||||
height: 100vh;
|
|
||||||
width: 1px;
|
|
||||||
top: 0;
|
|
||||||
left: var(--x);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .y {
|
|
||||||
height: 1px;
|
|
||||||
width: 100vw;
|
|
||||||
top: var(--y);
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .click {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--y);
|
|
||||||
left: var(--x);
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
opacity: 0;
|
|
||||||
|
|
||||||
&.left {
|
|
||||||
background: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.right {
|
|
||||||
background: yellow;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pressed {
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
transition: opacity 1s ease-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import TestCard from '$lib/TestCard.svelte';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
interface Props {
|
|
||||||
children?: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TestCard bg onfocus={() => goto('/card')} />
|
|
||||||
<main
|
|
||||||
class="absolute top-1/2 left-1/2 flex h-[90vh] w-[90vw] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-white bg-black/85 p-4"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href=".."
|
|
||||||
class="button button-back absolute top-4 right-4 opacity-66 transition-opacity hover:opacity-100"
|
|
||||||
class:hidden={page.data.root}><i class="ti ti-arrow-back"></i>{m.common_back()}</a
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,289 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { version } from '../../../package.json';
|
|
||||||
import type { Snapshot } from '@sveltejs/kit';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
const buildDate = import.meta.env.VITE_BUILD_DATE || '???';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import { setLocale } from '$lib/paraglide/runtime';
|
|
||||||
|
|
||||||
let search = $state('');
|
|
||||||
|
|
||||||
type Entry = {
|
|
||||||
id: string;
|
|
||||||
icon: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Test = Entry & {
|
|
||||||
categories: Array<Category>;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Category = (typeof categories)[number]['id'] | (typeof superCategories)[number]['id'];
|
|
||||||
|
|
||||||
let superCategories = [
|
|
||||||
{
|
|
||||||
id: 'inputs',
|
|
||||||
icon: 'ti-arrow-down-to-arc'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'outputs',
|
|
||||||
icon: 'ti-arrow-down-from-arc'
|
|
||||||
}
|
|
||||||
] as const satisfies Entry[];
|
|
||||||
|
|
||||||
let categories = [
|
|
||||||
{
|
|
||||||
id: 'audio',
|
|
||||||
icon: 'ti-volume'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'video',
|
|
||||||
icon: 'ti-video'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'control',
|
|
||||||
icon: 'ti-hand-finger'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'misc',
|
|
||||||
icon: 'ti-circle-plus'
|
|
||||||
}
|
|
||||||
] as const satisfies Entry[];
|
|
||||||
|
|
||||||
const tests = [
|
|
||||||
{
|
|
||||||
id: 'card',
|
|
||||||
icon: 'ti-device-desktop',
|
|
||||||
categories: ['outputs', 'video']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'audio',
|
|
||||||
icon: 'ti-volume',
|
|
||||||
categories: ['outputs', 'audio']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'signal-generator',
|
|
||||||
icon: 'ti-wave-sine',
|
|
||||||
categories: ['outputs', 'audio']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'av-sync',
|
|
||||||
icon: 'ti-time-duration-off',
|
|
||||||
categories: ['outputs', 'video', 'audio']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'keyboard',
|
|
||||||
icon: 'ti-keyboard',
|
|
||||||
categories: ['inputs', 'control']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mouse',
|
|
||||||
icon: 'ti-mouse',
|
|
||||||
categories: ['inputs', 'control']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gamepad',
|
|
||||||
icon: 'ti-device-gamepad',
|
|
||||||
categories: ['inputs', 'control']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'camera',
|
|
||||||
icon: 'ti-camera',
|
|
||||||
categories: ['inputs', 'video']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'microphone',
|
|
||||||
icon: 'ti-microphone',
|
|
||||||
categories: ['inputs', 'audio']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sensors',
|
|
||||||
icon: 'ti-cpu-2',
|
|
||||||
categories: ['inputs', 'misc']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'internet',
|
|
||||||
icon: 'ti-world',
|
|
||||||
categories: ['inputs', 'outputs', 'misc']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'timer',
|
|
||||||
icon: 'ti-alarm',
|
|
||||||
categories: ['video']
|
|
||||||
}
|
|
||||||
] as const satisfies Test[];
|
|
||||||
|
|
||||||
let categoriesToIcons = $derived.by(() => {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
for (const category of [...superCategories, ...categories]) {
|
|
||||||
map.set(category.id, category.icon);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
let filteredTests: Array<(typeof tests)[number]> = $state(tests);
|
|
||||||
let filteredCategories: Category[] = $state([]);
|
|
||||||
|
|
||||||
function doSearch(search: string) {
|
|
||||||
filteredTests = tests.filter((test) => {
|
|
||||||
if (!search) return true;
|
|
||||||
|
|
||||||
const searchValue = search.toLocaleLowerCase();
|
|
||||||
return (
|
|
||||||
test.id.includes(searchValue) ||
|
|
||||||
m[`tests_${test.id}_label`]().includes(searchValue) ||
|
|
||||||
m[`tests_${test.id}_description`]().includes(searchValue)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$effect(() => {
|
|
||||||
doSearch(search);
|
|
||||||
});
|
|
||||||
|
|
||||||
function setFilter(category: Category) {
|
|
||||||
if (filteredCategories.includes(category)) {
|
|
||||||
filteredCategories = filteredCategories.filter((c) => c !== category);
|
|
||||||
} else {
|
|
||||||
filteredCategories = [...filteredCategories, category];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nonEmptyCategories = $derived(
|
|
||||||
categories.filter((category) => {
|
|
||||||
const categoryTests = filteredTests.filter((test) =>
|
|
||||||
(test.categories as readonly Category[]).includes(category.id)
|
|
||||||
);
|
|
||||||
return categoryTests.some(
|
|
||||||
(test) =>
|
|
||||||
!filteredCategories.length ||
|
|
||||||
filteredCategories.every((f) => (test.categories as readonly Category[]).includes(f))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export const snapshot: Snapshot<string> = {
|
|
||||||
capture: () => JSON.stringify({ filtered: filteredCategories, search }),
|
|
||||||
restore: (value) => {
|
|
||||||
const { filtered: restoredFiltered, search: restoredSearch } = JSON.parse(value);
|
|
||||||
filteredCategories = restoredFiltered;
|
|
||||||
search = restoredSearch;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Locale selection (cookie-based persistence)
|
|
||||||
type LocaleOption = { tag: string; native: string };
|
|
||||||
const locales = [
|
|
||||||
{ tag: 'en', native: 'English' },
|
|
||||||
{ tag: 'es', native: 'Español' },
|
|
||||||
{ tag: 'fr', native: 'Français' },
|
|
||||||
{ tag: 'de', native: 'Deutsch' },
|
|
||||||
{ tag: 'zh-CN', native: '简体中文' },
|
|
||||||
{ tag: 'ja', native: '日本語' },
|
|
||||||
{ tag: 'cs', native: 'Čeština' },
|
|
||||||
{ tag: 'ukr', native: 'Українська' }
|
|
||||||
] as const satisfies LocaleOption[];
|
|
||||||
type Locale = (typeof locales)[number]['tag'];
|
|
||||||
|
|
||||||
let selectedLang = $state('en');
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const current = document.documentElement.getAttribute('lang') || selectedLang;
|
|
||||||
selectedLang = locales.some((l) => l.tag === current) ? current : selectedLang;
|
|
||||||
});
|
|
||||||
|
|
||||||
function changeLocale(tag: string) {
|
|
||||||
if (locales.some((l) => l.tag === tag)) {
|
|
||||||
setLocale(tag as Locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1 class="m-4 text-center text-5xl font-bold tracking-wide uppercase">Total Tech Test</h1>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute top-4 right-4 inline-flex items-center gap-2 self-end opacity-50 transition-opacity hover:opacity-100"
|
|
||||||
>
|
|
||||||
<label for="locale-picker"><i class="ti ti-language"></i></label>
|
|
||||||
<select
|
|
||||||
id="locale-picker"
|
|
||||||
bind:value={selectedLang}
|
|
||||||
onchange={(e) => changeLocale((e.target as HTMLSelectElement).value)}
|
|
||||||
class="rounded border border-current bg-white/8 px-2 py-1 text-sm text-inherit"
|
|
||||||
>
|
|
||||||
{#each locales as l}
|
|
||||||
<option value={l.tag}>{l.native}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="flex grow flex-col gap-4 overflow-hidden px-16">
|
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
|
||||||
<input type="search" placeholder={m.search()} bind:value={search} autofocus class="w-full px-2" />
|
|
||||||
|
|
||||||
<div class="flex items-center justify-around gap-8 text-xl">
|
|
||||||
{#each superCategories as category}
|
|
||||||
<button
|
|
||||||
onclick={() => setFilter(category.id)}
|
|
||||||
class:active={!filteredCategories.length || filteredCategories.includes(category.id)}
|
|
||||||
class="flex flex-col border-0 bg-transparent opacity-50 transition-opacity duration-200 ease-in-out hover:opacity-100"
|
|
||||||
class:opacity-100={!filteredCategories.length || filteredCategories.includes(category.id)}
|
|
||||||
>
|
|
||||||
<i class="ti {category.icon} block text-5xl"></i>
|
|
||||||
{m[`category_${category.id}`]()}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<div class="h-12 border-l border-current opacity-80"></div>
|
|
||||||
{#each categories as category}
|
|
||||||
<button
|
|
||||||
onclick={() => setFilter(category.id)}
|
|
||||||
class:active={!filteredCategories.length || filteredCategories.includes(category.id)}
|
|
||||||
class="flex flex-col border-0 bg-transparent opacity-50 transition-opacity duration-200 ease-in-out hover:opacity-100"
|
|
||||||
class:opacity-100={!filteredCategories.length || filteredCategories.includes(category.id)}
|
|
||||||
>
|
|
||||||
<i class="ti {category.icon} block text-5xl"></i>
|
|
||||||
{m[`category_${category.id}`]()}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="overflow-y-auto">
|
|
||||||
{#each nonEmptyCategories as category}
|
|
||||||
{#if tests.filter( (test) => (test.categories as readonly Category[]).includes(category.id) ).length > 0}
|
|
||||||
<h2 transition:fade={{ duration: 200 }} class="not-first:mt-4 text-2xl">
|
|
||||||
{m[`category_${category.id}`]()}
|
|
||||||
</h2>
|
|
||||||
{#each filteredTests.filter((test) => (test.categories as readonly Category[]).includes(category.id) && filteredCategories.every( (f) => (test.categories as readonly Category[]).includes(f) )) as test}
|
|
||||||
<a
|
|
||||||
class="mb-1 grid items-center gap-x-1 gap-y-0 text-base/tight text-inherit no-underline"
|
|
||||||
style="grid-template-columns: auto 1fr;"
|
|
||||||
href={test.id}
|
|
||||||
class:opacity-50={(test as Test).disabled}
|
|
||||||
class:pointer-events-none={(test as Test).disabled}
|
|
||||||
transition:fade={{ duration: 200 }}
|
|
||||||
>
|
|
||||||
<i class="ti {test.icon}"></i>
|
|
||||||
<div class="inline-flex items-baseline gap-1">
|
|
||||||
<span>{m[`tests_${test.id}_label`]()}</span>
|
|
||||||
{#each test.categories as category}
|
|
||||||
<span class="opacity-85"><i class="ti {categoriesToIcons.get(category)}"></i></span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="col-start-2 opacity-70">
|
|
||||||
{m[`tests_${test.id}_description`]()}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<p>
|
|
||||||
{m.noTestsFound()}
|
|
||||||
</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<footer class="mt-4 text-center opacity-60">
|
|
||||||
<a href="https://git.thm.place/thm/test-card" class="no-underline"
|
|
||||||
>testcard v{version}
|
|
||||||
{#if version.startsWith('0')}({buildDate}){/if}</a
|
|
||||||
>
|
|
||||||
</footer>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import leftUrl from '@assets/audio/stereo/Left.mp3';
|
|
||||||
import centerUrl from '@assets/audio/stereo/Center.mp3';
|
|
||||||
import rightUrl from '@assets/audio/stereo/Right.mp3';
|
|
||||||
import Speaker from './speaker.svelte';
|
|
||||||
import CycleButton from './cycle-button.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
let speakersEl: HTMLElement | undefined = $state();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="my-2 flex items-center gap-4">
|
|
||||||
<div class="mr-4 flex gap-4 text-xl" bind:this={speakersEl}>
|
|
||||||
<Speaker src={leftUrl} left inline>{m.audio_channel_left()}</Speaker>
|
|
||||||
<Speaker src={centerUrl} center inline>{m.audio_channel_center()}</Speaker>
|
|
||||||
<Speaker src={rightUrl} right inline>{m.audio_channel_right()}</Speaker>
|
|
||||||
</div>
|
|
||||||
<CycleButton element={speakersEl} />
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
interface Props {
|
|
||||||
children?: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2><i class="ti ti-volume"></i> {m.audio_title()}</h2>
|
|
||||||
{@render children?.()}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import StereoTest from './(channels)/stereo-test.svelte';
|
|
||||||
import PhaseTest from './phase.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<h3>{m.audio_channelTests()}</h3>
|
|
||||||
<h4 class="mb-0">{m.audio_stereo()}</h4>
|
|
||||||
<section class="my-4">
|
|
||||||
<StereoTest />
|
|
||||||
</section>
|
|
||||||
<h4 class="mb-0">{m.audio_surroundAudio()}</h4>
|
|
||||||
<section class="my-4">
|
|
||||||
<ul class="m-0 inline-flex list-none gap-4 p-0">
|
|
||||||
<li><a class="button" href="channels-5.1">{m.audio_surround51()}</a></li>
|
|
||||||
<li><a class="button" href="channels-7.1">{m.audio_surround71()}</a></li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
<h3>{m.audio_phaseTest()}</h3>
|
|
||||||
<PhaseTest />
|
|
||||||
</article>
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
let frequency = $state(60);
|
|
||||||
let playing = $state(false);
|
|
||||||
|
|
||||||
let audioCtx: AudioContext | undefined;
|
|
||||||
let oscillatorL: OscillatorNode | undefined;
|
|
||||||
let oscillatorR: OscillatorNode | undefined;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
audioCtx = new window.AudioContext();
|
|
||||||
});
|
|
||||||
|
|
||||||
function start(mode: 'inPhase' | 'outOfPhase') {
|
|
||||||
if (!audioCtx) return;
|
|
||||||
oscillatorL?.stop();
|
|
||||||
oscillatorR?.stop();
|
|
||||||
|
|
||||||
oscillatorL = audioCtx.createOscillator();
|
|
||||||
oscillatorR = audioCtx.createOscillator();
|
|
||||||
const gainNode = audioCtx.createGain();
|
|
||||||
|
|
||||||
const stereoPannerL = audioCtx.createStereoPanner();
|
|
||||||
const stereoPannerR = audioCtx.createStereoPanner();
|
|
||||||
|
|
||||||
oscillatorL.frequency.setValueAtTime(frequency, audioCtx.currentTime);
|
|
||||||
oscillatorR.frequency.setValueAtTime(frequency, audioCtx.currentTime);
|
|
||||||
|
|
||||||
stereoPannerL.pan.setValueAtTime(-1, audioCtx.currentTime);
|
|
||||||
stereoPannerR.pan.setValueAtTime(1, audioCtx.currentTime);
|
|
||||||
|
|
||||||
oscillatorL.connect(stereoPannerL).connect(audioCtx.destination);
|
|
||||||
oscillatorR.connect(gainNode).connect(stereoPannerR).connect(audioCtx.destination);
|
|
||||||
|
|
||||||
if (mode === 'inPhase') {
|
|
||||||
gainNode?.gain.setValueAtTime(1, audioCtx.currentTime); // Normal phase
|
|
||||||
} else {
|
|
||||||
gainNode?.gain.setValueAtTime(-1, audioCtx.currentTime); // Invert phase
|
|
||||||
}
|
|
||||||
|
|
||||||
oscillatorL?.start();
|
|
||||||
oscillatorR?.start();
|
|
||||||
playing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stop() {
|
|
||||||
oscillatorL?.stop();
|
|
||||||
oscillatorR?.stop();
|
|
||||||
playing = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="my-4">
|
|
||||||
<label>
|
|
||||||
{m.audio_frequency()}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
bind:value={frequency}
|
|
||||||
min="20"
|
|
||||||
max="20000"
|
|
||||||
disabled={playing}
|
|
||||||
class="w-20"
|
|
||||||
/>Hz
|
|
||||||
</label>
|
|
||||||
<div class="mt-2">
|
|
||||||
<button onclick={() => start('inPhase')}>{m.audio_inPhase()}</button>
|
|
||||||
<button onclick={() => start('outOfPhase')}>{m.audio_outOfPhase()}</button>
|
|
||||||
<button onclick={stop} disabled={!playing} class="ml-4" class:bg-red-900={!playing}
|
|
||||||
>{m.audio_stop()}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script>
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2><i class="ti ti-world"></i> {m.internet_title()}</h2>
|
|
||||||
|
|
||||||
<div class="flex flex-grow flex-col justify-center">
|
|
||||||
<iframe
|
|
||||||
src="//openspeedtest.com/speedtest"
|
|
||||||
title="OpenSpeedTest Embed"
|
|
||||||
class="max-h-[50vh] flex-grow border-none"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
Provided by <a href="https://openspeedtest.com">OpenSpeedtest.com</a>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
let key: string | undefined = $state();
|
|
||||||
let code: string | undefined = $state();
|
|
||||||
let pressedKeys: string[] = $state([]);
|
|
||||||
onMount(() => {
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
key = event.key;
|
|
||||||
code = event.code;
|
|
||||||
pressedKeys = [...pressedKeys, event.key];
|
|
||||||
pressedKeys = pressedKeys.slice(-50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2>{m.keyboard_title()}</h2>
|
|
||||||
<p>{m.keyboard_instruction()}</p>
|
|
||||||
<div class="flex">
|
|
||||||
{#if key}
|
|
||||||
<span>{key}</span>
|
|
||||||
{/if}
|
|
||||||
{#if code}
|
|
||||||
<span class="ml-4 opacity-80">({code})</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>{m.keyboard_pressedKeys()}</p>
|
|
||||||
<ul class="flex list-none flex-wrap gap-1 p-0">
|
|
||||||
{#each pressedKeys as key}
|
|
||||||
<li class="m-0 inline-block p-0">{key}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
@ -1,622 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import Spectrum from '$lib/audio/Spectrum.svelte';
|
|
||||||
import Oscilloscope from '$lib/audio/Oscilloscope.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
// Mic / audio graph state
|
|
||||||
let audioCtx: AudioContext | null = null;
|
|
||||||
let mediaStream: MediaStream | null = null;
|
|
||||||
let source: MediaStreamAudioSourceNode | null = null;
|
|
||||||
let analyserTime: AnalyserNode | null = $state(null);
|
|
||||||
let analyserFreq: AnalyserNode | null = $state(null);
|
|
||||||
let monitorGain: GainNode | null = null;
|
|
||||||
let delayNode: DelayNode | null = null;
|
|
||||||
|
|
||||||
// UI state (Svelte 5 runes-style)
|
|
||||||
let micActive = $state(false);
|
|
||||||
let monitoring = $state(false);
|
|
||||||
let gain = $state(1.0);
|
|
||||||
let delayMs = $state(0);
|
|
||||||
let sampleRate = $state<number | null>(null);
|
|
||||||
let deviceLabel = $state<string>('');
|
|
||||||
|
|
||||||
// Volume meter (RMS 0..1)
|
|
||||||
let volume = $state(0);
|
|
||||||
let rafId: number | null = null;
|
|
||||||
// Peak metrics
|
|
||||||
let peak = $state(0); // instantaneous peak 0..1
|
|
||||||
let peakHold = $state(0); // hold 0..1
|
|
||||||
let peakHoldDecayPerSec = 0.5; // how fast the hold marker decays
|
|
||||||
|
|
||||||
// Recording
|
|
||||||
let recorder: MediaRecorder | null = null;
|
|
||||||
let isRecording = $state(false);
|
|
||||||
let recordedChunks: BlobPart[] = [];
|
|
||||||
let recordingUrl = $state<string | null>(null);
|
|
||||||
|
|
||||||
// Devices
|
|
||||||
let devices: MediaDeviceInfo[] = $state([]);
|
|
||||||
let selectedDeviceId: string | null = $state(null);
|
|
||||||
|
|
||||||
// Clipping indicator
|
|
||||||
let clipping = $state(false);
|
|
||||||
let lastClipTs = 0;
|
|
||||||
|
|
||||||
// Track settings and constraints
|
|
||||||
let channels = $state<number | null>(null);
|
|
||||||
let obtainedEchoCancellation = $state<boolean | null>(null);
|
|
||||||
let obtainedNoiseSuppression = $state<boolean | null>(null);
|
|
||||||
let obtainedAGC = $state<boolean | null>(null);
|
|
||||||
|
|
||||||
// Requested constraints (best-effort, tri-state: null=default, true, false)
|
|
||||||
let reqEchoCancellation = $state<boolean | null>(null);
|
|
||||||
let reqNoiseSuppression = $state<boolean | null>(null);
|
|
||||||
let reqAGC = $state<boolean | null>(null);
|
|
||||||
|
|
||||||
async function startMic() {
|
|
||||||
if (micActive) return;
|
|
||||||
try {
|
|
||||||
const audioConstraints: MediaTrackConstraints = selectedDeviceId
|
|
||||||
? { deviceId: { exact: selectedDeviceId } }
|
|
||||||
: {};
|
|
||||||
if (reqEchoCancellation !== null) audioConstraints.echoCancellation = reqEchoCancellation;
|
|
||||||
if (reqNoiseSuppression !== null) audioConstraints.noiseSuppression = reqNoiseSuppression;
|
|
||||||
if (reqAGC !== null) audioConstraints.autoGainControl = reqAGC;
|
|
||||||
|
|
||||||
const constraints: MediaStreamConstraints = {
|
|
||||||
audio: Object.keys(audioConstraints).length ? audioConstraints : true,
|
|
||||||
video: false
|
|
||||||
};
|
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
||||||
deviceLabel = mediaStream.getAudioTracks()[0]?.label ?? '';
|
|
||||||
|
|
||||||
audioCtx = new AudioContext();
|
|
||||||
sampleRate = audioCtx.sampleRate;
|
|
||||||
|
|
||||||
source = audioCtx.createMediaStreamSource(mediaStream);
|
|
||||||
analyserTime = audioCtx.createAnalyser();
|
|
||||||
analyserFreq = audioCtx.createAnalyser();
|
|
||||||
monitorGain = audioCtx.createGain();
|
|
||||||
delayNode = audioCtx.createDelay(2.0); // up to 2 seconds
|
|
||||||
|
|
||||||
// Default params
|
|
||||||
analyserTime.fftSize = 2048;
|
|
||||||
analyserTime.smoothingTimeConstant = 0.2;
|
|
||||||
analyserFreq.fftSize = 2048;
|
|
||||||
analyserFreq.smoothingTimeConstant = 0.8;
|
|
||||||
monitorGain.gain.value = gain;
|
|
||||||
delayNode.delayTime.value = delayMs / 1000;
|
|
||||||
|
|
||||||
// Fan-out: source -> (analysers)
|
|
||||||
source.connect(analyserTime);
|
|
||||||
source.connect(analyserFreq);
|
|
||||||
|
|
||||||
// Monitoring path (initially disconnected; toggleMonitoring() handles it)
|
|
||||||
updateMonitoringGraph();
|
|
||||||
|
|
||||||
// Start volume meter loop
|
|
||||||
startVolumeLoop();
|
|
||||||
micActive = true;
|
|
||||||
// Update device list after permission to get labels
|
|
||||||
refreshDevices();
|
|
||||||
|
|
||||||
// Read obtained settings
|
|
||||||
const track = mediaStream.getAudioTracks()[0];
|
|
||||||
const s = track.getSettings?.() as MediaTrackSettings | undefined;
|
|
||||||
channels = s?.channelCount ?? null;
|
|
||||||
obtainedEchoCancellation = (s?.echoCancellation as boolean | undefined) ?? null;
|
|
||||||
obtainedNoiseSuppression = (s?.noiseSuppression as boolean | undefined) ?? null;
|
|
||||||
obtainedAGC = (s?.autoGainControl as boolean | undefined) ?? null;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to start microphone', err);
|
|
||||||
stopMic();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshDevices() {
|
|
||||||
try {
|
|
||||||
const list = await navigator.mediaDevices.enumerateDevices();
|
|
||||||
devices = list.filter((d) => d.kind === 'audioinput');
|
|
||||||
if (devices.length > 0 && !selectedDeviceId) {
|
|
||||||
selectedDeviceId = devices[0].deviceId;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('enumerateDevices failed', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDeviceChange(e: Event) {
|
|
||||||
selectedDeviceId = (e.target as HTMLSelectElement).value || null;
|
|
||||||
if (micActive) {
|
|
||||||
// Restart mic with the new device
|
|
||||||
const wasMonitoring = monitoring;
|
|
||||||
stopMic();
|
|
||||||
monitoring = wasMonitoring; // preserve desired state; will reconnect on start
|
|
||||||
startMic();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMonitoringGraph() {
|
|
||||||
if (!audioCtx || !source || !monitorGain || !delayNode) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Always disconnect monitoring path before reconnecting to avoid duplicate connections
|
|
||||||
monitorGain.disconnect();
|
|
||||||
delayNode.disconnect();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (monitoring) {
|
|
||||||
// source -> monitorGain -> delayNode -> destination
|
|
||||||
monitorGain.gain.value = gain;
|
|
||||||
delayNode.delayTime.value = delayMs / 1000;
|
|
||||||
|
|
||||||
source.connect(monitorGain);
|
|
||||||
monitorGain.connect(delayNode);
|
|
||||||
delayNode.connect(audioCtx.destination);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startVolumeLoop() {
|
|
||||||
cancelVolumeLoop();
|
|
||||||
const buf = new Uint8Array(analyserTime?.fftSize ?? 2048);
|
|
||||||
const loop = () => {
|
|
||||||
if (analyserTime) {
|
|
||||||
analyserTime.getByteTimeDomainData(buf);
|
|
||||||
// Compute RMS
|
|
||||||
let sum = 0;
|
|
||||||
let pk = 0;
|
|
||||||
for (let i = 0; i < buf.length; i++) {
|
|
||||||
const v = (buf[i] - 128) / 128; // -1..1
|
|
||||||
sum += v * v;
|
|
||||||
const a = Math.abs(v);
|
|
||||||
if (a > pk) pk = a;
|
|
||||||
}
|
|
||||||
const rms = Math.sqrt(sum / buf.length);
|
|
||||||
volume = rms; // 0..1
|
|
||||||
peak = pk;
|
|
||||||
// Update hold
|
|
||||||
const dt = 1 / 60; // approx
|
|
||||||
peakHold = Math.max(pk, Math.max(0, peakHold - peakHoldDecayPerSec * dt));
|
|
||||||
// Clipping detection (very near full-scale)
|
|
||||||
const now = performance.now();
|
|
||||||
if (pk >= 0.985) {
|
|
||||||
clipping = true;
|
|
||||||
lastClipTs = now;
|
|
||||||
} else if (clipping && now - lastClipTs > 500) {
|
|
||||||
// Auto clear after 500ms without clipping
|
|
||||||
clipping = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rafId = requestAnimationFrame(loop);
|
|
||||||
};
|
|
||||||
rafId = requestAnimationFrame(loop);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelVolumeLoop() {
|
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
|
||||||
rafId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopMic() {
|
|
||||||
cancelVolumeLoop();
|
|
||||||
micActive = false;
|
|
||||||
monitoring = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
recorder?.state !== 'inactive' && recorder?.stop();
|
|
||||||
} catch {}
|
|
||||||
recorder = null;
|
|
||||||
isRecording = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
source?.disconnect();
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
monitorGain?.disconnect();
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
delayNode?.disconnect();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
analyserTime = null;
|
|
||||||
analyserFreq = null;
|
|
||||||
monitorGain = null;
|
|
||||||
delayNode = null;
|
|
||||||
source = null;
|
|
||||||
|
|
||||||
if (audioCtx) {
|
|
||||||
audioCtx.close().catch(() => {});
|
|
||||||
audioCtx = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaStream) {
|
|
||||||
for (const t of mediaStream.getTracks()) t.stop();
|
|
||||||
mediaStream = null;
|
|
||||||
}
|
|
||||||
peak = 0;
|
|
||||||
peakHold = 0;
|
|
||||||
channels = null;
|
|
||||||
obtainedEchoCancellation = obtainedNoiseSuppression = obtainedAGC = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMonitoring() {
|
|
||||||
monitoring = !monitoring;
|
|
||||||
updateMonitoringGraph();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onGainChange(e: Event) {
|
|
||||||
gain = Number((e.target as HTMLInputElement).value);
|
|
||||||
if (monitorGain) monitorGain.gain.value = gain;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDelayChange(e: Event) {
|
|
||||||
delayMs = Number((e.target as HTMLInputElement).value);
|
|
||||||
if (delayNode) delayNode.delayTime.value = delayMs / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recording via MediaRecorder on raw MediaStream
|
|
||||||
function startRecording() {
|
|
||||||
if (!mediaStream || isRecording) return;
|
|
||||||
try {
|
|
||||||
// If there's an old blob URL, release it
|
|
||||||
if (recordingUrl) {
|
|
||||||
URL.revokeObjectURL(recordingUrl);
|
|
||||||
recordingUrl = null;
|
|
||||||
}
|
|
||||||
recordedChunks = [];
|
|
||||||
recorder = new MediaRecorder(mediaStream);
|
|
||||||
recorder.ondataavailable = (ev) => {
|
|
||||||
if (ev.data && ev.data.size > 0) recordedChunks.push(ev.data);
|
|
||||||
};
|
|
||||||
recorder.onstop = () => {
|
|
||||||
const blob = new Blob(recordedChunks, { type: recorder?.mimeType || 'audio/webm' });
|
|
||||||
recordingUrl = URL.createObjectURL(blob);
|
|
||||||
};
|
|
||||||
recorder.start();
|
|
||||||
isRecording = true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Unable to start recording', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecording() {
|
|
||||||
if (!recorder || recorder.state === 'inactive') return;
|
|
||||||
recorder.stop();
|
|
||||||
isRecording = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
// Try to prefetch devices (may require prior permission for labels)
|
|
||||||
refreshDevices();
|
|
||||||
return () => {
|
|
||||||
stopMic();
|
|
||||||
if (recordingUrl) URL.revokeObjectURL(recordingUrl);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<h3>{m.mic_title()}</h3>
|
|
||||||
|
|
||||||
<section class="controls">
|
|
||||||
<div class="row">
|
|
||||||
<button class="button" onclick={startMic} disabled={micActive}
|
|
||||||
>{m.mic_startMicrophone()}</button
|
|
||||||
>
|
|
||||||
<button class="button stop" onclick={stopMic} disabled={!micActive}>{m.mic_stop()}</button>
|
|
||||||
<button
|
|
||||||
class="button"
|
|
||||||
onclick={toggleMonitoring}
|
|
||||||
disabled={!micActive}
|
|
||||||
aria-pressed={monitoring}
|
|
||||||
>
|
|
||||||
{monitoring ? m.mic_monitoringOn() : m.mic_monitoringOff()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row inputs">
|
|
||||||
<label>
|
|
||||||
{m.mic_device()}
|
|
||||||
<select onchange={onDeviceChange} disabled={devices.length === 0}>
|
|
||||||
{#if devices.length === 0}
|
|
||||||
<option value="">{m.mic_noMicFound()}</option>
|
|
||||||
{:else}
|
|
||||||
{#each devices as d}
|
|
||||||
<option value={d.deviceId} selected={d.deviceId === selectedDeviceId}
|
|
||||||
>{d.label || m.mic_device()}</option
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button class="button" onclick={refreshDevices}>{m.mic_refresh()}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row inputs">
|
|
||||||
<label>
|
|
||||||
{m.mic_gain()}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="2"
|
|
||||||
step="0.01"
|
|
||||||
value={gain}
|
|
||||||
oninput={onGainChange}
|
|
||||||
disabled={!micActive}
|
|
||||||
/>
|
|
||||||
<span class="value">{gain.toFixed(2)}x</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{m.mic_monitorDelay()}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1000"
|
|
||||||
step="1"
|
|
||||||
value={delayMs}
|
|
||||||
oninput={onDelayChange}
|
|
||||||
disabled={!micActive}
|
|
||||||
/>
|
|
||||||
<span class="value">{delayMs} ms</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>{m.mic_advanced()}</summary>
|
|
||||||
<div class="row inputs">
|
|
||||||
<div class="label">{m.mic_constraints()}</div>
|
|
||||||
<label>
|
|
||||||
{m.mic_echoCancellation()}
|
|
||||||
<select
|
|
||||||
onchange={(e) => {
|
|
||||||
const v = (e.target as HTMLSelectElement).value;
|
|
||||||
reqEchoCancellation = v === 'default' ? null : v === 'on';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="default" selected>{m.mic_default()}</option>
|
|
||||||
<option value="on">{m.mic_on()}</option>
|
|
||||||
<option value="off">{m.mic_off()}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{m.mic_noiseSuppression()}
|
|
||||||
<select
|
|
||||||
onchange={(e) => {
|
|
||||||
const v = (e.target as HTMLSelectElement).value;
|
|
||||||
reqNoiseSuppression = v === 'default' ? null : v === 'on';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="default" selected>{m.mic_default()}</option>
|
|
||||||
<option value="on">{m.mic_on()}</option>
|
|
||||||
<option value="off">{m.mic_off()}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{m.mic_agc()}
|
|
||||||
<select
|
|
||||||
onchange={(e) => {
|
|
||||||
const v = (e.target as HTMLSelectElement).value;
|
|
||||||
reqAGC = v === 'default' ? null : v === 'on';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="default" selected>{m.mic_default()}</option>
|
|
||||||
<option value="on">{m.mic_on()}</option>
|
|
||||||
<option value="off">{m.mic_off()}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
class="button"
|
|
||||||
onclick={() => {
|
|
||||||
if (micActive) {
|
|
||||||
const wasMonitoring = monitoring;
|
|
||||||
stopMic();
|
|
||||||
monitoring = wasMonitoring;
|
|
||||||
}
|
|
||||||
startMic();
|
|
||||||
}}>{m.mic_applyConstraints()}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="obtained">
|
|
||||||
<div>
|
|
||||||
<strong>{m.mic_requested()}:</strong> EC={reqEchoCancellation === null
|
|
||||||
? '–'
|
|
||||||
: reqEchoCancellation
|
|
||||||
? 'on'
|
|
||||||
: 'off'}, NS={reqNoiseSuppression === null
|
|
||||||
? '–'
|
|
||||||
: reqNoiseSuppression
|
|
||||||
? 'on'
|
|
||||||
: 'off'}, AGC={reqAGC === null ? '–' : reqAGC ? 'on' : 'off'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{m.mic_obtained()}:</strong> EC={(obtainedEchoCancellation ??
|
|
||||||
'–') as unknown as string}, NS={(obtainedNoiseSuppression ?? '–') as unknown as string},
|
|
||||||
AGC={(obtainedAGC ?? '–') as unknown as string}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="info">
|
|
||||||
<div><strong>{m.mic_sampleRate()}:</strong> {sampleRate ?? '–'} Hz</div>
|
|
||||||
{#if deviceLabel}
|
|
||||||
<div><strong>{m.mic_inputDevice()}:</strong> {deviceLabel}</div>
|
|
||||||
{/if}
|
|
||||||
<div>
|
|
||||||
<strong>{m.mic_channels()}:</strong>
|
|
||||||
{#if channels === null}
|
|
||||||
–
|
|
||||||
{:else if channels === 1}
|
|
||||||
1 ({m.mic_mono()})
|
|
||||||
{:else if channels === 2}
|
|
||||||
2 ({m.mic_stereo()})
|
|
||||||
{:else}
|
|
||||||
{channels}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="meter">
|
|
||||||
<div class="label">{m.mic_volume()}</div>
|
|
||||||
<div class="bar">
|
|
||||||
<div class="fill" style={`transform: scaleX(${Math.min(1, volume).toFixed(3)})`}></div>
|
|
||||||
{#if peakHold > 0}
|
|
||||||
<div class="peak-hold" style={`left: ${Math.min(100, peakHold * 100)}%`}></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="volval">
|
|
||||||
{m.mic_peakNow()}: {(20 * Math.log10(Math.max(1e-5, peak))).toFixed(1)} dBFS ·
|
|
||||||
{m.mic_peakHold()}: {(20 * Math.log10(Math.max(1e-5, peakHold))).toFixed(1)} dBFS · RMS: {(
|
|
||||||
20 * Math.log10(Math.max(1e-5, volume))
|
|
||||||
).toFixed(1)} dBFS
|
|
||||||
<button class="button small" onclick={() => (peakHold = 0)}>{m.mic_resetPeaks()}</button>
|
|
||||||
</div>
|
|
||||||
{#if clipping}
|
|
||||||
<div class="clip">{m.mic_clipping()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="graphs">
|
|
||||||
{#if analyserTime && analyserFreq}
|
|
||||||
<Oscilloscope analyser={analyserTime} title={m.signalGen_scope()} />
|
|
||||||
<Spectrum analyser={analyserFreq} title={m.signalGen_spectrum()} />
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="recording">
|
|
||||||
<h4>{m.mic_recording()}</h4>
|
|
||||||
<div class="row">
|
|
||||||
<button class="button" onclick={startRecording} disabled={!micActive || isRecording}
|
|
||||||
>{m.mic_startRecording()}</button
|
|
||||||
>
|
|
||||||
<button class="button stop" onclick={stopRecording} disabled={!isRecording}
|
|
||||||
>{m.mic_stopRecording()}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if recordingUrl}
|
|
||||||
<audio src={recordingUrl} controls></audio>
|
|
||||||
<a class="button" href={recordingUrl} download="mic-test.webm">{m.mic_downloadRecording()}</a>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
article {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls .row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls .inputs {
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls input[type='range'] {
|
|
||||||
width: min(40vw, 280px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
opacity: 0.8;
|
|
||||||
min-width: 4ch;
|
|
||||||
text-align: right;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meter {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
position: relative;
|
|
||||||
height: 14px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar .fill {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(90deg, #5cb85c, #f0ad4e 70%, #d9534f);
|
|
||||||
transform-origin: left center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Peak hold marker */
|
|
||||||
.bar .peak-hold {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
background: #fff;
|
|
||||||
opacity: 0.9;
|
|
||||||
transform: translateX(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.volval {
|
|
||||||
opacity: 0.8;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.small {
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
font-size: 0.85em;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.graphs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recording .row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.obtained {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.stop:not(:disabled) {
|
|
||||||
background: darkred;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
|
||||||
.graphs {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,545 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
// Geolocation
|
|
||||||
let geoWatchId: number | null = $state(null);
|
|
||||||
let geolocation: GeolocationPosition | null = $state(null);
|
|
||||||
let geoError: string | null = $state(null);
|
|
||||||
let geoSupported = $state<boolean>(false);
|
|
||||||
|
|
||||||
// DeviceMotion / DeviceOrientation (useful fallbacks on iOS/Safari)
|
|
||||||
type Motion = { ax?: number; ay?: number; az?: number; gx?: number; gy?: number; gz?: number };
|
|
||||||
let deviceMotion: Motion | null = $state(null);
|
|
||||||
let deviceOrientation: { alpha?: number; beta?: number; gamma?: number } | null = $state(null);
|
|
||||||
let motionSupported = $state(false);
|
|
||||||
let orientationSupported = $state(false);
|
|
||||||
|
|
||||||
// iOS Safari permission flow for motion/orientation
|
|
||||||
let motionPermissionAvailable = $state(false);
|
|
||||||
let orientationPermissionAvailable = $state(false);
|
|
||||||
let motionPermission: 'unknown' | 'granted' | 'denied' = $state('unknown');
|
|
||||||
let orientationPermission: 'unknown' | 'granted' | 'denied' = $state('unknown');
|
|
||||||
|
|
||||||
// Generic Sensor API (subject to browser/flag support)
|
|
||||||
type SensorHandle = {
|
|
||||||
instance?: any;
|
|
||||||
supported: boolean;
|
|
||||||
active: boolean;
|
|
||||||
error?: string | null;
|
|
||||||
data: Record<string, number | string | undefined>;
|
|
||||||
};
|
|
||||||
|
|
||||||
let accelerometer: SensorHandle = $state({ supported: false, active: false, data: {} });
|
|
||||||
let gyroscope: SensorHandle = $state({ supported: false, active: false, data: {} });
|
|
||||||
let magnetometer: SensorHandle = $state({ supported: false, active: false, data: {} });
|
|
||||||
let ambientLight: SensorHandle = $state({ supported: false, active: false, data: {} });
|
|
||||||
let barometer: SensorHandle = $state({ supported: false, active: false, data: {} });
|
|
||||||
|
|
||||||
const w = browser ? (window as any) : undefined;
|
|
||||||
|
|
||||||
function detectSupport() {
|
|
||||||
geoSupported = browser && 'geolocation' in navigator;
|
|
||||||
motionSupported = browser && 'DeviceMotionEvent' in (window as any);
|
|
||||||
orientationSupported = browser && 'DeviceOrientationEvent' in (window as any);
|
|
||||||
|
|
||||||
accelerometer.supported = Boolean(w?.Accelerometer);
|
|
||||||
gyroscope.supported = Boolean(w?.Gyroscope);
|
|
||||||
magnetometer.supported = Boolean(w?.Magnetometer);
|
|
||||||
ambientLight.supported = Boolean(w?.AmbientLightSensor);
|
|
||||||
barometer.supported = Boolean(w?.Barometer);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
detectSupport();
|
|
||||||
|
|
||||||
// Check for iOS-style permission request APIs
|
|
||||||
motionPermissionAvailable =
|
|
||||||
browser &&
|
|
||||||
typeof (window as any).DeviceMotionEvent !== 'undefined' &&
|
|
||||||
typeof (DeviceMotionEvent as any).requestPermission === 'function';
|
|
||||||
orientationPermissionAvailable =
|
|
||||||
browser &&
|
|
||||||
typeof (window as any).DeviceOrientationEvent !== 'undefined' &&
|
|
||||||
typeof (DeviceOrientationEvent as any).requestPermission === 'function';
|
|
||||||
|
|
||||||
if (orientationSupported) {
|
|
||||||
const handler = (e: DeviceOrientationEvent) => {
|
|
||||||
deviceOrientation = {
|
|
||||||
alpha: e.alpha ?? undefined,
|
|
||||||
beta: e.beta ?? undefined,
|
|
||||||
gamma: e.gamma ?? undefined
|
|
||||||
};
|
|
||||||
};
|
|
||||||
window.addEventListener('deviceorientation', handler);
|
|
||||||
onDestroy(() => window.removeEventListener('deviceorientation', handler));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (motionSupported) {
|
|
||||||
const handler = (e: DeviceMotionEvent) => {
|
|
||||||
deviceMotion = {
|
|
||||||
ax: e.acceleration?.x ?? undefined,
|
|
||||||
ay: e.acceleration?.y ?? undefined,
|
|
||||||
az: e.acceleration?.z ?? undefined,
|
|
||||||
gx: e.rotationRate?.alpha ?? undefined,
|
|
||||||
gy: e.rotationRate?.beta ?? undefined,
|
|
||||||
gz: e.rotationRate?.gamma ?? undefined
|
|
||||||
};
|
|
||||||
};
|
|
||||||
window.addEventListener('devicemotion', handler);
|
|
||||||
onDestroy(() => window.removeEventListener('devicemotion', handler));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
stopGeolocation();
|
|
||||||
stopSensor(accelerometer);
|
|
||||||
stopSensor(gyroscope);
|
|
||||||
stopSensor(magnetometer);
|
|
||||||
stopSensor(ambientLight);
|
|
||||||
stopSensor(barometer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// (Permissions are requested implicitly when starting sensors where applicable)
|
|
||||||
|
|
||||||
// Geolocation controls
|
|
||||||
async function startGeolocation() {
|
|
||||||
if (!geoSupported) return;
|
|
||||||
try {
|
|
||||||
geoError = null;
|
|
||||||
geolocation = await new Promise<GeolocationPosition>((resolve, reject) => {
|
|
||||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 10000
|
|
||||||
});
|
|
||||||
});
|
|
||||||
geoWatchId = navigator.geolocation.watchPosition(
|
|
||||||
(pos) => (geolocation = pos),
|
|
||||||
(err) => (geoError = err?.message || String(err)),
|
|
||||||
{ enableHighAccuracy: true }
|
|
||||||
);
|
|
||||||
} catch (e: any) {
|
|
||||||
geoError = e?.message ?? String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopGeolocation() {
|
|
||||||
if (geoWatchId != null) {
|
|
||||||
navigator.geolocation.clearWatch(geoWatchId);
|
|
||||||
geoWatchId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic Sensor helpers
|
|
||||||
function startSensor(handle: SensorHandle, ctorName: string, options: any = { frequency: 60 }) {
|
|
||||||
try {
|
|
||||||
handle.error = null;
|
|
||||||
if (!w?.[ctorName]) {
|
|
||||||
handle.supported = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handle.instance = new w[ctorName](options);
|
|
||||||
handle.instance.addEventListener('reading', () => {
|
|
||||||
// Populate based on sensor type
|
|
||||||
if (ctorName === 'Accelerometer') {
|
|
||||||
handle.data = { x: handle.instance.x, y: handle.instance.y, z: handle.instance.z };
|
|
||||||
} else if (ctorName === 'Gyroscope') {
|
|
||||||
handle.data = { x: handle.instance.x, y: handle.instance.y, z: handle.instance.z };
|
|
||||||
} else if (ctorName === 'Magnetometer') {
|
|
||||||
handle.data = { x: handle.instance.x, y: handle.instance.y, z: handle.instance.z };
|
|
||||||
} else if (ctorName === 'AmbientLightSensor') {
|
|
||||||
handle.data = { illuminance: handle.instance.illuminance };
|
|
||||||
} else if (ctorName === 'Barometer') {
|
|
||||||
handle.data = {
|
|
||||||
pressure: handle.instance.pressure,
|
|
||||||
temperature: handle.instance?.temperature
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
handle.instance.addEventListener('error', (event: any) => {
|
|
||||||
handle.error = event?.error?.message || String(event);
|
|
||||||
});
|
|
||||||
handle.instance.start();
|
|
||||||
handle.active = true;
|
|
||||||
} catch (e: any) {
|
|
||||||
handle.error = e?.message ?? String(e);
|
|
||||||
handle.active = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSensor(handle: SensorHandle) {
|
|
||||||
try {
|
|
||||||
handle.instance?.stop?.();
|
|
||||||
} catch {}
|
|
||||||
handle.active = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI helpers
|
|
||||||
function toFixed(n: number | undefined, digits = 2) {
|
|
||||||
return typeof n === 'number' && Number.isFinite(n) ? n.toFixed(digits) : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyJSON(data: unknown) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
|
||||||
alert(m.sensors_copied());
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestMotionOrientation() {
|
|
||||||
try {
|
|
||||||
if (motionPermissionAvailable) {
|
|
||||||
const res = await (DeviceMotionEvent as any).requestPermission();
|
|
||||||
motionPermission = res === 'granted' ? 'granted' : 'denied';
|
|
||||||
}
|
|
||||||
if (orientationPermissionAvailable) {
|
|
||||||
const res2 = await (DeviceOrientationEvent as any).requestPermission();
|
|
||||||
orientationPermission = res2 === 'granted' ? 'granted' : 'denied';
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (motionPermissionAvailable && motionPermission === 'unknown') motionPermission = 'denied';
|
|
||||||
if (orientationPermissionAvailable && orientationPermission === 'unknown')
|
|
||||||
orientationPermission = 'denied';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kick off light permission checks lazily in UI; starting sensors will request permissions where needed.
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2><i class="ti ti-cpu-2"></i> {m.sensors_title()}</h2>
|
|
||||||
|
|
||||||
<div class="sections">
|
|
||||||
{#if motionPermissionAvailable || orientationPermissionAvailable}
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_permissions()}</h3>
|
|
||||||
<div class="row">
|
|
||||||
<button onclick={requestMotionOrientation}
|
|
||||||
><i class="ti ti-key"></i> {m.sensors_enableMotionOrientation()}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<ul class="kv">
|
|
||||||
{#if motionPermissionAvailable}
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_motion()}</span>
|
|
||||||
<span>
|
|
||||||
{#if motionPermission === 'granted'}{m.sensors_status_granted()}
|
|
||||||
{:else if motionPermission === 'denied'}{m.sensors_status_denied()}
|
|
||||||
{:else}{m.sensors_status_unknown()}{/if}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if orientationPermissionAvailable}
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_orientation()}</span>
|
|
||||||
<span>
|
|
||||||
{#if orientationPermission === 'granted'}{m.sensors_status_granted()}
|
|
||||||
{:else if orientationPermission === 'denied'}{m.sensors_status_denied()}
|
|
||||||
{:else}{m.sensors_status_unknown()}{/if}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_geolocation()}</h3>
|
|
||||||
{#if geoSupported}
|
|
||||||
<div class="row">
|
|
||||||
<button onclick={startGeolocation} disabled={geoWatchId !== null}>
|
|
||||||
<i class="ti ti-player-play"></i>
|
|
||||||
{m.sensors_start()}
|
|
||||||
</button>
|
|
||||||
<button onclick={stopGeolocation} disabled={geoWatchId === null}>
|
|
||||||
<i class="ti ti-player-stop"></i>
|
|
||||||
{m.sensors_stop()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if geoError}
|
|
||||||
<div class="error">{geoError}</div>
|
|
||||||
{/if}
|
|
||||||
{#if geolocation}
|
|
||||||
<ul class="kv">
|
|
||||||
<li><span class="key">lat</span><span>{geolocation.coords.latitude}</span></li>
|
|
||||||
<li><span class="key">lon</span><span>{geolocation.coords.longitude}</span></li>
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_accuracy()}</span><span
|
|
||||||
>{toFixed(geolocation.coords.accuracy)}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_altitude()}</span><span
|
|
||||||
>{geolocation.coords.altitude ?? '—'}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_heading()}</span><span
|
|
||||||
>{toFixed(geolocation.coords.heading ?? undefined)}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_speed()}</span><span
|
|
||||||
>{toFixed(geolocation.coords.speed ?? undefined)}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_timestamp()}</span><span
|
|
||||||
>{new Date(geolocation.timestamp).toLocaleString()}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="row">
|
|
||||||
<button onclick={() => copyJSON(geolocation)}
|
|
||||||
><i class="ti ti-copy"></i> {m.sensors_copy()}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="subdued">{m.sensors_notSupported()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_deviceMotion()}</h3>
|
|
||||||
{#if motionSupported}
|
|
||||||
<ul class="kv">
|
|
||||||
<li><span class="key">ax</span><span>{toFixed(deviceMotion?.ax)}</span></li>
|
|
||||||
<li><span class="key">ay</span><span>{toFixed(deviceMotion?.ay)}</span></li>
|
|
||||||
<li><span class="key">az</span><span>{toFixed(deviceMotion?.az)}</span></li>
|
|
||||||
<li><span class="key">α</span><span>{toFixed(deviceMotion?.gx)}</span></li>
|
|
||||||
<li><span class="key">β</span><span>{toFixed(deviceMotion?.gy)}</span></li>
|
|
||||||
<li><span class="key">γ</span><span>{toFixed(deviceMotion?.gz)}</span></li>
|
|
||||||
</ul>
|
|
||||||
<div class="row">
|
|
||||||
<button onclick={() => copyJSON(deviceMotion)}
|
|
||||||
><i class="ti ti-copy"></i> {m.sensors_copy()}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="subdued">{m.sensors_notSupported()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_deviceOrientation()}</h3>
|
|
||||||
{#if orientationSupported}
|
|
||||||
<ul class="kv">
|
|
||||||
<li><span class="key">alpha</span><span>{toFixed(deviceOrientation?.alpha)}</span></li>
|
|
||||||
<li><span class="key">beta</span><span>{toFixed(deviceOrientation?.beta)}</span></li>
|
|
||||||
<li><span class="key">gamma</span><span>{toFixed(deviceOrientation?.gamma)}</span></li>
|
|
||||||
</ul>
|
|
||||||
<div class="row">
|
|
||||||
<button onclick={() => copyJSON(deviceOrientation)}
|
|
||||||
><i class="ti ti-copy"></i> {m.sensors_copy()}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="subdued">{m.sensors_notSupported()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_accelerometer()}</h3>
|
|
||||||
{#if accelerometer.supported}
|
|
||||||
<div class="row">
|
|
||||||
{#if !accelerometer.active}
|
|
||||||
<button onclick={() => startSensor(accelerometer, 'Accelerometer')}
|
|
||||||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button onclick={() => stopSensor(accelerometer)}
|
|
||||||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if accelerometer.error}
|
|
||||||
<div class="error">{accelerometer.error}</div>
|
|
||||||
{/if}
|
|
||||||
<ul class="kv">
|
|
||||||
<li><span class="key">x</span><span>{toFixed(accelerometer.data.x as number)}</span></li>
|
|
||||||
<li><span class="key">y</span><span>{toFixed(accelerometer.data.y as number)}</span></li>
|
|
||||||
<li><span class="key">z</span><span>{toFixed(accelerometer.data.z as number)}</span></li>
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<div class="subdued">{m.sensors_notSupported()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_gyroscope()}</h3>
|
|
||||||
{#if gyroscope.supported}
|
|
||||||
<div class="row">
|
|
||||||
{#if !gyroscope.active}
|
|
||||||
<button onclick={() => startSensor(gyroscope, 'Gyroscope')}
|
|
||||||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button onclick={() => stopSensor(gyroscope)}
|
|
||||||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if gyroscope.error}
|
|
||||||
<div class="error">{gyroscope.error}</div>
|
|
||||||
{/if}
|
|
||||||
<ul class="kv">
|
|
||||||
<li><span class="key">x</span><span>{toFixed(gyroscope.data.x as number)}</span></li>
|
|
||||||
<li><span class="key">y</span><span>{toFixed(gyroscope.data.y as number)}</span></li>
|
|
||||||
<li><span class="key">z</span><span>{toFixed(gyroscope.data.z as number)}</span></li>
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<div class="subdued">{m.sensors_notSupported()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_magnetometer()}</h3>
|
|
||||||
{#if magnetometer.supported}
|
|
||||||
<div class="row">
|
|
||||||
{#if !magnetometer.active}
|
|
||||||
<button onclick={() => startSensor(magnetometer, 'Magnetometer', { frequency: 10 })}
|
|
||||||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button onclick={() => stopSensor(magnetometer)}
|
|
||||||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if magnetometer.error}
|
|
||||||
<div class="error">{magnetometer.error}</div>
|
|
||||||
{/if}
|
|
||||||
<ul class="kv">
|
|
||||||
<li><span class="key">x</span><span>{toFixed(magnetometer.data.x as number)}</span></li>
|
|
||||||
<li><span class="key">y</span><span>{toFixed(magnetometer.data.y as number)}</span></li>
|
|
||||||
<li><span class="key">z</span><span>{toFixed(magnetometer.data.z as number)}</span></li>
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<div class="subdued">{m.sensors_notSupported()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_ambientLight()}</h3>
|
|
||||||
{#if ambientLight.supported}
|
|
||||||
<div class="row">
|
|
||||||
{#if !ambientLight.active}
|
|
||||||
<button onclick={() => startSensor(ambientLight, 'AmbientLightSensor')}
|
|
||||||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button onclick={() => stopSensor(ambientLight)}
|
|
||||||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if ambientLight.error}
|
|
||||||
<div class="error">{ambientLight.error}</div>
|
|
||||||
{/if}
|
|
||||||
<ul class="kv">
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_illuminance()}</span><span
|
|
||||||
>{toFixed(ambientLight.data.illuminance as number)}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<div class="subdued">{m.sensors_notSupported()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>{m.sensors_barometer()}</h3>
|
|
||||||
{#if barometer.supported}
|
|
||||||
<div class="row">
|
|
||||||
{#if !barometer.active}
|
|
||||||
<button onclick={() => startSensor(barometer, 'Barometer')}
|
|
||||||
><i class="ti ti-player-play"></i> {m.sensors_start()}</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button onclick={() => stopSensor(barometer)}
|
|
||||||
><i class="ti ti-player-stop"></i> {m.sensors_stop()}</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if barometer.error}
|
|
||||||
<div class="error">{barometer.error}</div>
|
|
||||||
{/if}
|
|
||||||
<ul class="kv">
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_pressure()}</span><span
|
|
||||||
>{toFixed(barometer.data.pressure as number)}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="key">{m.sensors_temperature()}</span><span
|
|
||||||
>{barometer.data.temperature ?? '—'}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<div class="subdued">{m.sensors_notSupported()}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sections {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0.5rem 0 0 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 0.25rem 0.75rem;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv .key {
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-right: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #ff6b6b;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subdued {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,354 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
import Oscilloscope from '$lib/audio/Oscilloscope.svelte';
|
|
||||||
import Spectrum from '$lib/audio/Spectrum.svelte';
|
|
||||||
|
|
||||||
type GenType = 'off' | 'sine' | 'sweep' | 'white' | 'pink' | 'brown';
|
|
||||||
|
|
||||||
let ctx: AudioContext | null = null;
|
|
||||||
let gainNode: GainNode | null = null;
|
|
||||||
let analyser = $state<AnalyserNode | null>(null);
|
|
||||||
let source: AudioNode | null = null; // OscillatorNode or AudioBufferSourceNode
|
|
||||||
let started = $state(false);
|
|
||||||
|
|
||||||
let genType = $state<GenType>('sine');
|
|
||||||
let frequency = $state(440);
|
|
||||||
let volume = $state(0.2);
|
|
||||||
|
|
||||||
let sweepFrom = $state(20);
|
|
||||||
let sweepTo = $state(20000);
|
|
||||||
let sweepDuration = $state(10); // seconds
|
|
||||||
let sweepLoop = $state(false);
|
|
||||||
|
|
||||||
function ensureAudio() {
|
|
||||||
if (!ctx) {
|
|
||||||
ctx = new AudioContext();
|
|
||||||
gainNode = ctx.createGain();
|
|
||||||
gainNode.gain.value = volume;
|
|
||||||
analyser = ctx.createAnalyser();
|
|
||||||
analyser.fftSize = 2048;
|
|
||||||
analyser.smoothingTimeConstant = 0.7;
|
|
||||||
gainNode.connect(analyser);
|
|
||||||
analyser.connect(ctx.destination);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectSource(node: AudioNode) {
|
|
||||||
ensureAudio();
|
|
||||||
stopSource();
|
|
||||||
source = node;
|
|
||||||
node.connect(gainNode!);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSource() {
|
|
||||||
if (!source) return;
|
|
||||||
try {
|
|
||||||
// Try to stop if it has a stop() method
|
|
||||||
(source as any).stop?.();
|
|
||||||
(source as any).disconnect?.();
|
|
||||||
} catch {}
|
|
||||||
source = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
ensureAudio();
|
|
||||||
ctx!.resume();
|
|
||||||
started = true;
|
|
||||||
if (genType === 'sine') startSine();
|
|
||||||
else if (genType === 'sweep') startSweep();
|
|
||||||
else if (genType === 'white') startNoise('white');
|
|
||||||
else if (genType === 'pink') startNoise('pink');
|
|
||||||
else if (genType === 'brown') startNoise('brown');
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
stopSource();
|
|
||||||
started = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTypeChange(t: GenType) {
|
|
||||||
genType = t;
|
|
||||||
if (!started) return;
|
|
||||||
// Restart with new type
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFrequencyChange(v: number) {
|
|
||||||
frequency = v;
|
|
||||||
if (source && (source as OscillatorNode).frequency) {
|
|
||||||
(source as OscillatorNode).frequency.setValueAtTime(frequency, ctx!.currentTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onVolumeChange(v: number) {
|
|
||||||
volume = v;
|
|
||||||
if (gainNode) gainNode.gain.setValueAtTime(volume, ctx!.currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSine() {
|
|
||||||
const osc = ctx!.createOscillator();
|
|
||||||
osc.type = 'sine';
|
|
||||||
osc.frequency.setValueAtTime(frequency, ctx!.currentTime);
|
|
||||||
connectSource(osc);
|
|
||||||
osc.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSweep() {
|
|
||||||
const osc = ctx!.createOscillator();
|
|
||||||
osc.type = 'sine';
|
|
||||||
const startF = Math.max(0.1, sweepFrom);
|
|
||||||
const endF = Math.max(0.1, sweepTo);
|
|
||||||
const now = ctx!.currentTime;
|
|
||||||
osc.frequency.cancelScheduledValues(now);
|
|
||||||
osc.frequency.setValueAtTime(startF, now);
|
|
||||||
// Use exponential ramp if both positive and not equal; fall back to linear otherwise
|
|
||||||
if (startF > 0 && endF > 0 && startF !== endF) {
|
|
||||||
osc.frequency.exponentialRampToValueAtTime(endF, now + sweepDuration);
|
|
||||||
} else {
|
|
||||||
osc.frequency.linearRampToValueAtTime(endF, now + sweepDuration);
|
|
||||||
}
|
|
||||||
connectSource(osc);
|
|
||||||
osc.start();
|
|
||||||
// auto stop or loop after sweep
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
osc.stop();
|
|
||||||
} catch {}
|
|
||||||
if (started && genType === 'sweep') {
|
|
||||||
if (sweepLoop) {
|
|
||||||
startSweep(); // restart for loop
|
|
||||||
} else {
|
|
||||||
started = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, sweepDuration * 1000);
|
|
||||||
|
|
||||||
// When the source is replaced, clear this timeout
|
|
||||||
source?.addEventListener('disconnect', () => clearTimeout(timeoutId), { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeNoiseBuffer(kind: 'white' | 'pink' | 'brown') {
|
|
||||||
const sr = ctx!.sampleRate;
|
|
||||||
const seconds = 2;
|
|
||||||
const frameCount = sr * seconds;
|
|
||||||
const buffer = ctx!.createBuffer(2, frameCount, sr);
|
|
||||||
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
|
|
||||||
const data = buffer.getChannelData(ch);
|
|
||||||
if (kind === 'white') {
|
|
||||||
for (let i = 0; i < frameCount; i++) data[i] = Math.random() * 2 - 1;
|
|
||||||
} else if (kind === 'pink') {
|
|
||||||
// Voss-McCartney algorithm approximation
|
|
||||||
let b0 = 0,
|
|
||||||
b1 = 0,
|
|
||||||
b2 = 0,
|
|
||||||
b3 = 0,
|
|
||||||
b4 = 0,
|
|
||||||
b5 = 0,
|
|
||||||
b6 = 0;
|
|
||||||
for (let i = 0; i < frameCount; i++) {
|
|
||||||
const white = Math.random() * 2 - 1;
|
|
||||||
b0 = 0.99886 * b0 + white * 0.0555179;
|
|
||||||
b1 = 0.99332 * b1 + white * 0.0750759;
|
|
||||||
b2 = 0.969 * b2 + white * 0.153852;
|
|
||||||
b3 = 0.8665 * b3 + white * 0.3104856;
|
|
||||||
b4 = 0.55 * b4 + white * 0.5329522;
|
|
||||||
b5 = -0.7616 * b5 - white * 0.016898;
|
|
||||||
const pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
|
||||||
b6 = white * 0.115926;
|
|
||||||
data[i] = pink * 0.11; // gain normalize
|
|
||||||
}
|
|
||||||
} else if (kind === 'brown') {
|
|
||||||
let lastOut = 0;
|
|
||||||
for (let i = 0; i < frameCount; i++) {
|
|
||||||
const white = Math.random() * 2 - 1;
|
|
||||||
const brown = (lastOut + 0.02 * white) / 1.02;
|
|
||||||
lastOut = brown;
|
|
||||||
data[i] = brown * 3.5; // gain normalize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNoise(kind: 'white' | 'pink' | 'brown') {
|
|
||||||
const buf = makeNoiseBuffer(kind);
|
|
||||||
const src = ctx!.createBufferSource();
|
|
||||||
src.buffer = buf;
|
|
||||||
src.loop = true;
|
|
||||||
connectSource(src);
|
|
||||||
src.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
// prepare audio on mount but start only on user gesture
|
|
||||||
ensureAudio();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2><i class="ti ti-wave-sine"></i> {m.signalGen_title()}</h2>
|
|
||||||
|
|
||||||
<article class="layout">
|
|
||||||
<section class="controls">
|
|
||||||
<div class="row">
|
|
||||||
<span class="label">{m.signalGen_type()}:</span>
|
|
||||||
<div class="buttons">
|
|
||||||
<button class:active={genType === 'sine'} onclick={() => onTypeChange('sine')}
|
|
||||||
>{m.signalGen_sine()}</button
|
|
||||||
>
|
|
||||||
<button class:active={genType === 'sweep'} onclick={() => onTypeChange('sweep')}
|
|
||||||
>{m.signalGen_sweep()}</button
|
|
||||||
>
|
|
||||||
<button class:active={genType === 'white'} onclick={() => onTypeChange('white')}
|
|
||||||
>{m.signalGen_noiseWhite()}</button
|
|
||||||
>
|
|
||||||
<button class:active={genType === 'pink'} onclick={() => onTypeChange('pink')}
|
|
||||||
>{m.signalGen_noisePink()}</button
|
|
||||||
>
|
|
||||||
<button class:active={genType === 'brown'} onclick={() => onTypeChange('brown')}
|
|
||||||
>{m.signalGen_noiseBrown()}</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if genType === 'sine'}
|
|
||||||
<div class="row">
|
|
||||||
<label for="freq">{m.signalGen_frequency()}:</label>
|
|
||||||
<input
|
|
||||||
id="freq"
|
|
||||||
type="range"
|
|
||||||
min="20"
|
|
||||||
max="20000"
|
|
||||||
step="1"
|
|
||||||
bind:value={frequency}
|
|
||||||
oninput={(e) => onFrequencyChange(+e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="20"
|
|
||||||
max="20000"
|
|
||||||
class="exact-freq"
|
|
||||||
bind:value={frequency}
|
|
||||||
oninput={(e) => onFrequencyChange(+e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<span class="value">Hz</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if genType === 'sweep'}
|
|
||||||
<div class="row">
|
|
||||||
<label for="sweep-from">{m.signalGen_from()}:</label>
|
|
||||||
<input id="sweep-from" type="number" min="1" max="20000" bind:value={sweepFrom} />
|
|
||||||
<label for="sweep-to">{m.signalGen_to()}:</label>
|
|
||||||
<input id="sweep-to" type="number" min="1" max="20000" bind:value={sweepTo} />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="sweep-duration">{m.signalGen_duration()}:</label>
|
|
||||||
<input id="sweep-duration" type="number" min="1" max="120" bind:value={sweepDuration} />
|
|
||||||
<span class="value">s</span>
|
|
||||||
<label class="checkbox-label"
|
|
||||||
><input type="checkbox" bind:checked={sweepLoop} />{m.signalGen_loop()}</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<label for="gain">{m.signalGen_gain()}:</label>
|
|
||||||
<input
|
|
||||||
id="gain"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
bind:value={volume}
|
|
||||||
oninput={(e) => onVolumeChange(+e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<span class="value">{(volume * 100) | 0}%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
{#if !started}
|
|
||||||
<button class="primary" onclick={start}
|
|
||||||
><i class="ti ti-player-play"></i> {m.signalGen_start()}</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button onclick={stop}><i class="ti ti-player-stop"></i> {m.signalGen_stop()}</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="displays" aria-hidden={!started}>
|
|
||||||
{#if analyser}
|
|
||||||
<Oscilloscope {analyser} title={m.signalGen_scope()} />
|
|
||||||
<Spectrum {analyser} title={m.signalGen_spectrum()} />
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.controls .row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.controls label {
|
|
||||||
opacity: 0.8;
|
|
||||||
min-width: 6rem;
|
|
||||||
}
|
|
||||||
.controls .buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.controls button {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.controls button.active,
|
|
||||||
.controls button.primary {
|
|
||||||
background: rgba(92, 184, 92, 0.2);
|
|
||||||
border-color: #5cb85c;
|
|
||||||
}
|
|
||||||
.controls input[type='range'] {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
.controls .value {
|
|
||||||
min-width: 4rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exact-freq {
|
|
||||||
width: 5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.displays {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
let time = $state(0);
|
|
||||||
let fps = 0;
|
|
||||||
let start = 0;
|
|
||||||
let displayFps = $state('?');
|
|
||||||
let fpsInterval: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
const times: number[] = [];
|
|
||||||
function refreshLoop() {
|
|
||||||
const now = performance.now();
|
|
||||||
time = Math.floor(now - start);
|
|
||||||
while (times.length > 0 && times[0] <= now - 1000) {
|
|
||||||
times.shift();
|
|
||||||
}
|
|
||||||
times.push(now);
|
|
||||||
fps = times.length;
|
|
||||||
window.requestAnimationFrame(refreshLoop);
|
|
||||||
}
|
|
||||||
|
|
||||||
function restart() {
|
|
||||||
displayFps = '?';
|
|
||||||
times.length = 0;
|
|
||||||
start = performance.now();
|
|
||||||
clearInterval(fpsInterval);
|
|
||||||
fpsInterval = setInterval(() => {
|
|
||||||
displayFps = fps.toString();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
refreshLoop();
|
|
||||||
restart();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2><i class="ti ti-alarm"></i> {m.timer_title()}</h2>
|
|
||||||
<div class="flex flex-grow flex-col justify-center" style="font-variant-numeric: tabular-nums;">
|
|
||||||
<div class="mb-4 text-center text-[12rem] leading-none select-none">{time}</div>
|
|
||||||
<div class="mb-4 text-center text-4xl leading-none select-none">{displayFps} {m.timer_fps()}</div>
|
|
||||||
</div>
|
|
||||||
<button onclick={restart} class="mb-4 self-center text-center text-2xl leading-none select-none"
|
|
||||||
>{m.timer_restart()}</button
|
|
||||||
>
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '@fontsource/atkinson-hyperlegible';
|
import 'normalize.css/normalize.css';
|
||||||
import '@fontsource/atkinson-hyperlegible/700.css';
|
import '@fontsource/b612';
|
||||||
|
import '@fontsource/b612/700.css';
|
||||||
import '@tabler/icons-webfont/tabler-icons.css';
|
import '@tabler/icons-webfont/tabler-icons.css';
|
||||||
import '../index.css';
|
import '../index.css';
|
||||||
|
import TestCard from '$lib/TestCard.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
interface Props {
|
import { goto } from '$app/navigation';
|
||||||
children?: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children }: Props = $props();
|
|
||||||
|
|
||||||
let idleTimeout: NodeJS.Timeout | undefined;
|
let idleTimeout: NodeJS.Timeout | undefined;
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -20,11 +19,53 @@
|
||||||
}, 3000);
|
}, 3000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$: onlyCard = $page.data.card;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children?.()}
|
<TestCard full={onlyCard} on:focus={() => goto('/card')} />
|
||||||
|
<main class:content={!onlyCard} class:sub={!$page.data.root && !onlyCard}>
|
||||||
|
<a href=".." class="button button-back"><i class="ti ti-arrow-back" />Back</a>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
<style>
|
<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) {
|
:global(.hide-idle) {
|
||||||
transition: opacity 1s;
|
transition: opacity 1s;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
export const trailingSlash = 'always';
|
|
||||||
|
|
|
||||||
87
src/routes/+page.svelte
Normal file
87
src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<script>
|
||||||
|
import { version } from '../../package.json';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
@ -3,5 +3,5 @@ import type { PageLoad } from './$types';
|
||||||
export const load: PageLoad = () => {
|
export const load: PageLoad = () => {
|
||||||
return {
|
return {
|
||||||
root: true
|
root: true
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CycleButton from './cycle-button.svelte';
|
import CycleButton from './cycle-button.svelte';
|
||||||
interface Props {
|
|
||||||
children?: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let channelsEl: HTMLDivElement;
|
||||||
|
|
||||||
let channelsEl: HTMLDivElement | undefined = $state();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="channels" bind:this={channelsEl}>
|
<div class="channels" bind:this={channelsEl}>
|
||||||
{@render children?.()}
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<CycleButton element={channelsEl} />
|
<CycleButton element={channelsEl} />
|
||||||
|
|
@ -6,20 +6,19 @@
|
||||||
import rearLeftUrl from '@assets/audio/5.1/Rear_Left.mp3';
|
import rearLeftUrl from '@assets/audio/5.1/Rear_Left.mp3';
|
||||||
import rearRightUrl from '@assets/audio/5.1/Rear_Right.mp3';
|
import rearRightUrl from '@assets/audio/5.1/Rear_Right.mp3';
|
||||||
import LfeUrl from '@assets/audio/5.1/LFE_Noise.mp3';
|
import LfeUrl from '@assets/audio/5.1/LFE_Noise.mp3';
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<Speaker src={frontLeftUrl} left>{m.audio_channel_frontLeft()}</Speaker>
|
<Speaker src={frontLeftUrl} left>Front Left</Speaker>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<Speaker src={frontCenterUrl} center>{m.audio_channel_frontCenter()}</Speaker>
|
<Speaker src={frontCenterUrl} center>Front Center</Speaker>
|
||||||
</div>
|
</div>
|
||||||
<Speaker src={frontRightUrl} right>{m.audio_channel_frontRight()}</Speaker>
|
<Speaker src={frontRightUrl} right>Front Right</Speaker>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<Speaker src={rearLeftUrl} left>{m.audio_channel_rearLeft()}</Speaker>
|
<Speaker src={rearLeftUrl} left>Rear Left</Speaker>
|
||||||
<Speaker src={rearRightUrl} right>{m.audio_channel_rearRight()}</Speaker>
|
<Speaker src={rearRightUrl} right>Rear Right</Speaker>
|
||||||
</div>
|
</div>
|
||||||
<Speaker src={LfeUrl} lfe>{m.audio_channel_lfe()}</Speaker>
|
<Speaker src={LfeUrl} lfe>LFE</Speaker>
|
||||||
|
|
||||||
<div class="label">5.1</div>
|
<div class="label">5.1</div>
|
||||||
|
|
@ -8,25 +8,24 @@
|
||||||
import rearLeftUrl from '@assets/audio/7.1/Rear_Left.mp3';
|
import rearLeftUrl from '@assets/audio/7.1/Rear_Left.mp3';
|
||||||
import rearRightUrl from '@assets/audio/7.1/Rear_Right.mp3';
|
import rearRightUrl from '@assets/audio/7.1/Rear_Right.mp3';
|
||||||
import LfeUrl from '@assets/audio/7.1/LFE_Noise.mp3';
|
import LfeUrl from '@assets/audio/7.1/LFE_Noise.mp3';
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<Speaker src={frontLeftUrl} left>{m.audio_channel_frontLeft()}</Speaker>
|
<Speaker src={frontLeftUrl} left>Front Left</Speaker>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<Speaker src={frontCenterUrl} center>{m.audio_channel_frontCenter()}</Speaker>
|
<Speaker src={frontCenterUrl} center>Front Center</Speaker>
|
||||||
</div>
|
</div>
|
||||||
<Speaker src={frontRightUrl} right>{m.audio_channel_frontRight()}</Speaker>
|
<Speaker src={frontRightUrl} right>Front Right</Speaker>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<Speaker src={sideLeftUrl} left>{m.audio_channel_sideLeft()}</Speaker>
|
<Speaker src={sideLeftUrl} left>Side Left</Speaker>
|
||||||
<Speaker src={sideRightUrl} right>{m.audio_channel_sideRight()}</Speaker>
|
<Speaker src={sideRightUrl} right>Side Right</Speaker>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<Speaker src={rearLeftUrl} left>{m.audio_channel_rearLeft()}</Speaker>
|
<Speaker src={rearLeftUrl} left>Rear Left</Speaker>
|
||||||
<Speaker src={rearRightUrl} right>{m.audio_channel_rearRight()}</Speaker>
|
<Speaker src={rearRightUrl} right>Rear Right</Speaker>
|
||||||
</div>
|
</div>
|
||||||
<Speaker src={LfeUrl} lfe>{m.audio_channel_lfe()}</Speaker>
|
<Speaker src={LfeUrl} lfe>LFE</Speaker>
|
||||||
|
|
||||||
<div class="label">7.1</div>
|
<div class="label">7.1</div>
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
interface Props {
|
export let element: HTMLElement;
|
||||||
element: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { element }: Props = $props();
|
let cycling = false;
|
||||||
|
|
||||||
let cycling = $state(false);
|
|
||||||
let currentChannel: HTMLAudioElement | undefined;
|
let currentChannel: HTMLAudioElement | undefined;
|
||||||
async function cycleChannels() {
|
async function cycleChannels() {
|
||||||
cycling = true;
|
cycling = true;
|
||||||
|
|
@ -48,11 +43,11 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button onclick={onClick}>
|
<button on:click={onClick}>
|
||||||
<i class="ti ti-refresh"></i>
|
<i class="ti ti-refresh"></i>
|
||||||
{#if cycling}
|
{#if cycling}
|
||||||
{m.audio_stopCycling()}
|
Stop Cycling
|
||||||
{:else}
|
{:else}
|
||||||
{m.audio_cycleThrough()}
|
Cycle through
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1,26 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
export let src: string;
|
||||||
src: string;
|
export let left = false;
|
||||||
left?: boolean;
|
export let center = false;
|
||||||
center?: boolean;
|
export let right = false;
|
||||||
right?: boolean;
|
export let lfe = false;
|
||||||
lfe?: boolean;
|
export let inline = false;
|
||||||
inline?: boolean;
|
|
||||||
children?: import('svelte').Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
let currentTime = 0;
|
||||||
src,
|
let paused = true;
|
||||||
left = false,
|
|
||||||
center = false,
|
|
||||||
right = false,
|
|
||||||
lfe = false,
|
|
||||||
inline = false,
|
|
||||||
children
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let currentTime = $state(0);
|
|
||||||
let paused = $state(true);
|
|
||||||
function play() {
|
function play() {
|
||||||
currentTime = 0;
|
currentTime = 0;
|
||||||
paused = false;
|
paused = false;
|
||||||
|
|
@ -35,14 +22,14 @@
|
||||||
class:lfe
|
class:lfe
|
||||||
class:inline
|
class:inline
|
||||||
class:playing={!paused}
|
class:playing={!paused}
|
||||||
onclick={play}
|
on:click={play}
|
||||||
>
|
>
|
||||||
{#if !lfe}
|
{#if !lfe}
|
||||||
<i class="ti ti-volume"></i>
|
<i class="ti ti-volume"></i>
|
||||||
{:else}
|
{:else}
|
||||||
<i class="ti ti-wave-sine"></i>
|
<i class="ti ti-wave-sine"></i>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{@render children?.()}</span>
|
<span><slot /></span>
|
||||||
<audio bind:currentTime bind:paused {src}></audio>
|
<audio bind:currentTime bind:paused {src}></audio>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
34
src/routes/audio/(channels)/stereo-test.svelte
Normal file
34
src/routes/audio/(channels)/stereo-test.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import leftUrl from '@assets/audio/stereo/Left.mp3';
|
||||||
|
import centerUrl from '@assets/audio/stereo/Center.mp3';
|
||||||
|
import rightUrl from '@assets/audio/stereo/Right.mp3';
|
||||||
|
import Speaker from './speaker.svelte';
|
||||||
|
import CycleButton from './cycle-button.svelte';
|
||||||
|
|
||||||
|
let speakersEl: HTMLElement;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="test">
|
||||||
|
<div class="speakers" bind:this={speakersEl}>
|
||||||
|
<Speaker src={leftUrl} left inline>Left</Speaker>
|
||||||
|
<Speaker src={centerUrl} center inline>Center</Speaker>
|
||||||
|
<Speaker src={rightUrl} right inline>Right</Speaker>
|
||||||
|
</div>
|
||||||
|
<CycleButton element={speakersEl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.test {
|
||||||
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speakers {
|
||||||
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
font-size: 1.25em;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/routes/audio/+layout.svelte
Normal file
5
src/routes/audio/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2><i class="ti ti-volume"></i> Audio test</h2>
|
||||||
|
<slot />
|
||||||
1
src/routes/audio/+layout.ts
Normal file
1
src/routes/audio/+layout.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const trailingSlash = 'always';
|
||||||
37
src/routes/audio/+page.svelte
Normal file
37
src/routes/audio/+page.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import StereoTest from './(channels)/stereo-test.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<h3>Channel tests</h3>
|
||||||
|
<h4>Stereo</h4>
|
||||||
|
<section>
|
||||||
|
<StereoTest />
|
||||||
|
</section>
|
||||||
|
<h4>Surround audio</h4>
|
||||||
|
<section>
|
||||||
|
<ul>
|
||||||
|
<li><a class="button" href="channels-5.1">5.1 Surround</a></li>
|
||||||
|
<li><a class="button" href="channels-7.1">7.1 Surround</a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,24 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import videoUrl from '@assets/avsync.webm';
|
import videoUrl from '@assets/avsync.webm';
|
||||||
import { m } from '$lib/paraglide/messages';
|
let paused = true;
|
||||||
let paused = $state(true);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h2><i class="ti ti-time-duration-off"></i> {m.avSync_title()}</h2>
|
<h2><i class="ti ti-time-duration-off"></i> Audio/Video Synchronization</h2>
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
<video
|
<video
|
||||||
class:playing={!paused}
|
class:playing={!paused}
|
||||||
autoplay
|
autoplay
|
||||||
loop
|
loop
|
||||||
bind:paused
|
bind:paused
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
onclick={() => (paused = false)}
|
on:click={() => (paused = false)}
|
||||||
></video>
|
></video>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
video {
|
video {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
&:not(.playing) {
|
&:not(.playing) {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
@ -2,32 +2,27 @@
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
const dbg = debug('app:camera');
|
const dbg = debug('app:camera');
|
||||||
|
|
||||||
let video: HTMLVideoElement | undefined = $state();
|
let video: HTMLVideoElement;
|
||||||
let devices: MediaDeviceInfo[] = $state([]);
|
let devices: MediaDeviceInfo[] = [];
|
||||||
let currentDevice: string | undefined = $state();
|
let currentDevice: string | undefined;
|
||||||
|
|
||||||
let requestResolution: [number, number] | 'auto' = $state('auto');
|
let requestResolution: [number, number] | 'auto' = 'auto';
|
||||||
let requestFramerate: number | 'auto' = $state('auto');
|
let requestFramerate: number | 'auto' = 'auto';
|
||||||
let deviceInfo: {
|
let deviceInfo: {
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
frameRate?: number;
|
frameRate?: number;
|
||||||
} = $state({});
|
} = {};
|
||||||
let snapshot: string | undefined = $state();
|
let snapshot: string | undefined;
|
||||||
let flipped = $state(false);
|
let flipped = false;
|
||||||
|
|
||||||
$effect(() => {
|
$: dbg('devices %O', devices);
|
||||||
dbg('devices %O', devices);
|
$: dbg('currentDevice %s', currentDevice);
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
dbg('currentDevice %s', currentDevice);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
refreshDevices();
|
refreshDevices();
|
||||||
video?.addEventListener('playing', () => {
|
video.addEventListener('playing', () => {
|
||||||
if (browser && video?.srcObject instanceof MediaStream) {
|
if (browser && video?.srcObject instanceof MediaStream) {
|
||||||
deviceInfo = {
|
deviceInfo = {
|
||||||
resolution: `${video.videoWidth}x${video.videoHeight}`,
|
resolution: `${video.videoWidth}x${video.videoHeight}`,
|
||||||
|
|
@ -52,27 +47,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$: if (currentDevice) {
|
||||||
if (currentDevice) {
|
navigator.mediaDevices
|
||||||
navigator.mediaDevices
|
.getUserMedia({
|
||||||
.getUserMedia({
|
video: {
|
||||||
video: {
|
deviceId: currentDevice,
|
||||||
deviceId: currentDevice,
|
width: requestResolution === 'auto' ? undefined : requestResolution[0],
|
||||||
width: requestResolution === 'auto' ? undefined : requestResolution[0],
|
height: requestResolution === 'auto' ? undefined : requestResolution[1],
|
||||||
height: requestResolution === 'auto' ? undefined : requestResolution[1],
|
frameRate: requestFramerate === 'auto' ? undefined : requestFramerate
|
||||||
frameRate: requestFramerate === 'auto' ? undefined : requestFramerate
|
}
|
||||||
}
|
})
|
||||||
})
|
.then((stream) => {
|
||||||
.then((stream) => {
|
video.srcObject = stream;
|
||||||
if (!video) return;
|
refreshDevices();
|
||||||
video.srcObject = stream;
|
});
|
||||||
refreshDevices();
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function takeSnapshot() {
|
async function takeSnapshot() {
|
||||||
if (!video) return;
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth;
|
||||||
canvas.height = video.videoHeight;
|
canvas.height = video.videoHeight;
|
||||||
|
|
@ -87,26 +78,26 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h2><i class="ti ti-camera"></i> {m.camera_title()}</h2>
|
<h2><i class="ti ti-camera"></i> Camera test</h2>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<label>
|
<label>
|
||||||
{m.camera_device()}
|
Device
|
||||||
<select bind:value={currentDevice} disabled={!devices.length}>
|
<select bind:value={currentDevice} disabled={!devices.length}>
|
||||||
{#each devices as device}
|
{#each devices as device}
|
||||||
<option value={device.deviceId}>{device.label || '???'}</option>
|
<option value={device.deviceId}>{device.label || '???'}</option>
|
||||||
{:else}
|
{:else}
|
||||||
<option>{m.camera_noCameraFound()}</option>
|
<option>No camera found</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button onclick={refreshDevices}>
|
<button on:click={refreshDevices}>
|
||||||
<i class="ti ti-refresh"></i>
|
<i class="ti ti-refresh"></i>
|
||||||
{m.camera_refresh()}
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
<label>
|
<label>
|
||||||
{m.camera_resolution()}
|
Resolution
|
||||||
<select bind:value={requestResolution}>
|
<select bind:value={requestResolution}>
|
||||||
<option value="auto">Auto</option>
|
<option value="auto">Auto</option>
|
||||||
<option value={[4096, 2160]}>4096x2160</option>
|
<option value={[4096, 2160]}>4096x2160</option>
|
||||||
|
|
@ -118,7 +109,7 @@
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{m.camera_frameRate()}
|
Frame rate
|
||||||
<select bind:value={requestFramerate}>
|
<select bind:value={requestFramerate}>
|
||||||
<option value="auto">Auto</option>
|
<option value="auto">Auto</option>
|
||||||
<option value={120}>120 fps</option>
|
<option value={120}>120 fps</option>
|
||||||
|
|
@ -132,43 +123,41 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="display" class:snapshot={Boolean(snapshot)}>
|
<div class="display" class:snapshot={Boolean(snapshot)}>
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y-media-has-caption -->
|
||||||
<video class:flipped bind:this={video} autoplay class:unloaded={!currentDevice}></video>
|
<video class:flipped bind:this={video} autoplay class:unloaded={!currentDevice}></video>
|
||||||
{#if snapshot}
|
{#if snapshot}
|
||||||
<!-- svelte-ignore a11y_missing_attribute -->
|
<!-- svelte-ignore a11y-missing-attribute -->
|
||||||
<!--suppress HtmlRequiredAltAttribute -->
|
<!--suppress HtmlRequiredAltAttribute -->
|
||||||
<img src={snapshot} />
|
<img src={snapshot} />
|
||||||
<button onclick={() => (snapshot = undefined)} aria-label={m.camera_closeSnapshot()}
|
<button on:click={() => (snapshot = undefined)}><i class="ti ti-x"></i></button>
|
||||||
><i class="ti ti-x"></i></button
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
{#if !currentDevice}
|
{#if !currentDevice}
|
||||||
<span class="subdued">{m.camera_noCameraSelected()}</span>
|
<span class="subdued">No camera selected</span>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#key currentDevice}
|
{#key currentDevice}
|
||||||
<li>
|
<li>
|
||||||
{m.camera_resolution()}: <strong>{deviceInfo.resolution || '???'}</strong>
|
Resolution: <strong>{deviceInfo.resolution || '???'}</strong>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{m.camera_frameRate()}: <strong>{deviceInfo.frameRate || '???'}</strong>
|
Frame rate: <strong>{deviceInfo.frameRate || '???'}</strong>
|
||||||
</li>
|
</li>
|
||||||
{/key}
|
{/key}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button onclick={takeSnapshot}>
|
<button on:click={takeSnapshot}>
|
||||||
<i class="ti ti-camera"></i>
|
<i class="ti ti-camera"></i>
|
||||||
{m.camera_takePicture()}
|
Take picture
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => (flipped = !flipped)}>
|
<button on:click={() => (flipped = !flipped)}>
|
||||||
<i class="ti ti-flip-vertical"></i>
|
<i class="ti ti-flip-vertical"></i>
|
||||||
{#if flipped}
|
{#if flipped}
|
||||||
{m.camera_unflipImage()}
|
Unflip image
|
||||||
{:else}
|
{:else}
|
||||||
{m.camera_flipImage()}
|
Flip image
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
25
src/routes/card/+page.svelte
Normal file
25
src/routes/card/+page.svelte
Normal file
|
|
@ -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>
|
||||||
7
src/routes/card/+page.ts
Normal file
7
src/routes/card/+page.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = () => {
|
||||||
|
return {
|
||||||
|
card: true
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -2,79 +2,29 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
const dbg = debug('app:camera');
|
const dbg = debug('app:camera');
|
||||||
|
|
||||||
let gamepads: Gamepad[] = $state([]);
|
let gamepads: Gamepad[] = [];
|
||||||
let currentGamepad: Gamepad | undefined = $state();
|
let currentGamepad: Gamepad | undefined;
|
||||||
let buttons: GamepadButton[] = $state([]);
|
let buttons: GamepadButton[] = [];
|
||||||
let axes: number[] = $state([]);
|
let axes: number[] = [];
|
||||||
|
|
||||||
const axisHistory: number[][] = [];
|
$: {
|
||||||
const sizes: [number, number][] = [];
|
|
||||||
const contexts: CanvasRenderingContext2D[] = [];
|
|
||||||
|
|
||||||
function update() {
|
|
||||||
buttons = currentGamepad?.buttons.concat() || [];
|
|
||||||
axes = currentGamepad?.axes.concat() || [];
|
|
||||||
|
|
||||||
axisHistory.push(axes);
|
|
||||||
if (axisHistory.length > 1024) {
|
|
||||||
axisHistory.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < axes.length; i++) {
|
|
||||||
if (!contexts[i]) {
|
|
||||||
const canvas = document.querySelector(`canvas[data-axis="${i}"]`) as HTMLCanvasElement;
|
|
||||||
if (!canvas) continue;
|
|
||||||
if (!canvas.checkVisibility()) continue;
|
|
||||||
contexts[i] = canvas.getContext('2d') as CanvasRenderingContext2D;
|
|
||||||
sizes[i] = [canvas.width, canvas.height];
|
|
||||||
}
|
|
||||||
const ctx = contexts[i];
|
|
||||||
if (!ctx) continue;
|
|
||||||
|
|
||||||
const [width, height] = sizes[i];
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
ctx.strokeStyle = `rgba(255, 0, 0, 0.5)`;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, height / 2);
|
|
||||||
ctx.lineTo(width, height / 2);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.strokeStyle = 'white';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(width - axisHistory.length, height / 2);
|
|
||||||
for (let j = 0; j < axisHistory.length; j++) {
|
|
||||||
const x = width - axisHistory.length + j;
|
|
||||||
const y = ((axisHistory[j][i] + 1) * (height - 2)) / 2 + 1;
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(update);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (currentGamepad) {
|
if (currentGamepad) {
|
||||||
|
function update() {
|
||||||
|
buttons = currentGamepad?.buttons.concat() || [];
|
||||||
|
axes = currentGamepad?.axes.concat() || [];
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$: dbg('Gamepads %O', gamepads);
|
||||||
dbg('Gamepads %O', gamepads);
|
$: dbg('Current gamepad %s', currentGamepad);
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
dbg('Current gamepad %s', currentGamepad);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$: currentGamepad?.vibrationActuator?.playEffect('dual-rumble', {
|
||||||
currentGamepad?.vibrationActuator?.playEffect('dual-rumble', {
|
duration: 1000
|
||||||
duration: 1000
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -99,27 +49,27 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h2><i class="ti ti-device-gamepad"></i> {m.gamepad_title()}</h2>
|
<h2><i class="ti ti-device-gamepad"></i> Gamepad & Joystick Tests</h2>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<label>
|
<label>
|
||||||
{m.gamepad_device()}
|
Device
|
||||||
<select disabled={!gamepads.length}>
|
<select disabled={!gamepads.length}>
|
||||||
{#each gamepads as gamepad}
|
{#each gamepads as gamepad}
|
||||||
<option value={gamepad.index}>{gamepad.id}</option>
|
<option value={gamepad.index}>{gamepad.id}</option>
|
||||||
{:else}
|
{:else}
|
||||||
<option>{m.gamepad_noGamepadsDetected()}</option>
|
<option>No gamepads detected. (Try pressing a button)</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button onclick={refreshGamepads}>
|
<button on:click={refreshGamepads}>
|
||||||
<i class="ti ti-refresh"></i>
|
<i class="ti ti-refresh"></i>
|
||||||
{m.gamepad_refresh()}
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if currentGamepad}
|
{#if currentGamepad}
|
||||||
<section>
|
<section>
|
||||||
<h3>{m.gamepad_buttons()}</h3>
|
<h3>Buttons</h3>
|
||||||
<ul class="buttons">
|
<ul class="buttons">
|
||||||
{#each buttons as button, i}
|
{#each buttons as button, i}
|
||||||
<li class:pressed={button.pressed}>{i}</li>
|
<li class:pressed={button.pressed}>{i}</li>
|
||||||
|
|
@ -127,7 +77,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h3>{m.gamepad_axes()}</h3>
|
<h3>Axes</h3>
|
||||||
<div class="axes">
|
<div class="axes">
|
||||||
{#each axes as axis, i (i)}
|
{#each axes as axis, i (i)}
|
||||||
<div class="axis">
|
<div class="axis">
|
||||||
|
|
@ -136,10 +86,6 @@
|
||||||
<progress value={axis + 1} max="2"></progress>
|
<progress value={axis + 1} max="2"></progress>
|
||||||
<span>{axis.toFixed(2)}</span>
|
<span>{axis.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
<details>
|
|
||||||
<summary>{m.gamepad_history()}</summary>
|
|
||||||
<canvas width="512" height="128" data-axis={i}></canvas>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -214,9 +160,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas {
|
|
||||||
background: black;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
59
src/routes/keyboard/+page.svelte
Normal file
59
src/routes/keyboard/+page.svelte
Normal file
|
|
@ -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>
|
||||||
0
src/routes/microphone/+page.svelte
Normal file
0
src/routes/microphone/+page.svelte
Normal file
0
src/routes/mouse/+page.svelte
Normal file
0
src/routes/mouse/+page.svelte
Normal file
0
src/routes/sensors/+page.svelte
Normal file
0
src/routes/sensors/+page.svelte
Normal file
0
src/routes/video/+page.svelte
Normal file
0
src/routes/video/+page.svelte
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 316 KiB |
0
style.css
Normal file
0
style.css
Normal file
|
|
@ -1,34 +0,0 @@
|
||||||
import type { Config } from 'tailwindcss';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
|
||||||
theme: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: [
|
|
||||||
'Atkinson Hyperlegible',
|
|
||||||
'B612',
|
|
||||||
'IBM Plex Sans',
|
|
||||||
'Helvetica Neue',
|
|
||||||
'Arial',
|
|
||||||
'sans-serif'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
black: '#000000',
|
|
||||||
white: '#ffffff'
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
'0.25em': '0.25em',
|
|
||||||
'0.5em': '0.5em',
|
|
||||||
'0.2em': '0.2em'
|
|
||||||
},
|
|
||||||
opacity: {
|
|
||||||
66: '0.66',
|
|
||||||
85: '0.85',
|
|
||||||
8: '0.08'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
} satisfies Config;
|
|
||||||
BIN
tests/output/testcard-baseline.png
(Stored with Git LFS)
BIN
tests/output/testcard-baseline.png
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,86 +0,0 @@
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import puppeteer, { type Browser, type Page } from 'puppeteer';
|
|
||||||
import fs from 'fs';
|
|
||||||
import { PNG } from 'pngjs';
|
|
||||||
import pixelmatch from 'pixelmatch';
|
|
||||||
import { startDevServer, stopDevServer } from './utils';
|
|
||||||
|
|
||||||
let browser: Browser;
|
|
||||||
let page: Page;
|
|
||||||
|
|
||||||
const baseTestPath = 'tests/output';
|
|
||||||
const screenshotPath = `${baseTestPath}/testcard-current.png`;
|
|
||||||
const baselinePath = `${baseTestPath}/testcard-baseline.png`;
|
|
||||||
const diffPath = `${baseTestPath}/testcard-diff.png`;
|
|
||||||
|
|
||||||
describe('Test Card', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await startDevServer(); // boot SvelteKit dev server
|
|
||||||
browser = await puppeteer.launch();
|
|
||||||
page = await browser.newPage();
|
|
||||||
await page.goto('http://localhost:5888/card');
|
|
||||||
await page.waitForNetworkIdle();
|
|
||||||
await page.addStyleTag({
|
|
||||||
content: '.clock { opacity: 0; } * { transition: none !important; }'
|
|
||||||
});
|
|
||||||
await page.setViewport({ width: 1920, height: 1080 });
|
|
||||||
await page.evaluate(() => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(resolve);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await fs.promises.mkdir(baseTestPath, { recursive: true });
|
|
||||||
await page.screenshot({ path: screenshotPath });
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await browser.close();
|
|
||||||
await stopDevServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches baseline (visual regression)', () => {
|
|
||||||
if (!fs.existsSync(baselinePath)) {
|
|
||||||
fs.copyFileSync(screenshotPath, baselinePath);
|
|
||||||
console.log('Baseline image created. Re-run tests.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const img1 = PNG.sync.read(fs.readFileSync(baselinePath));
|
|
||||||
const img2 = PNG.sync.read(fs.readFileSync(screenshotPath));
|
|
||||||
const { width, height } = img1;
|
|
||||||
|
|
||||||
expect(img2.width).toBe(width);
|
|
||||||
expect(img2.height).toBe(height);
|
|
||||||
|
|
||||||
const diff = new PNG({ width, height });
|
|
||||||
const mismatches = pixelmatch(img1.data, img2.data, diff.data, width, height, {
|
|
||||||
threshold: 0.1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mismatches > 0) {
|
|
||||||
fs.writeFileSync(diffPath, PNG.sync.write(diff));
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mismatches).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// it('has a black vertical line exactly in the center', () => {
|
|
||||||
// const img = PNG.sync.read(fs.readFileSync(screenshotPath));
|
|
||||||
// const { width, height, data } = img;
|
|
||||||
// const midX = Math.floor(width / 2);
|
|
||||||
|
|
||||||
// function getPixel(x: number, y: number) {
|
|
||||||
// const idx = (width * y + x) << 2;
|
|
||||||
// return { r: data[idx], g: data[idx + 1], b: data[idx + 2] };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// for (let y = 0; y < height; y++) {
|
|
||||||
// const { r, g, b } = getPixel(midX, y);
|
|
||||||
// expect(r).toBeLessThan(20);
|
|
||||||
// expect(g).toBeLessThan(20);
|
|
||||||
// expect(b).toBeLessThan(20);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
});
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import { spawn, type ChildProcess } from 'child_process';
|
|
||||||
import waitOn from 'wait-on';
|
|
||||||
import debug from 'debug';
|
|
||||||
const dbg = debug('test-card:utils');
|
|
||||||
|
|
||||||
const SERVER_PORT = 5888;
|
|
||||||
|
|
||||||
let devServer: ChildProcess;
|
|
||||||
export async function startDevServer(): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
dbg('Starting dev server on http://localhost:%d...', SERVER_PORT);
|
|
||||||
devServer = spawn(
|
|
||||||
'npm',
|
|
||||||
['run', 'dev', '--', '--host', 'localhost', '--port', String(SERVER_PORT)],
|
|
||||||
{
|
|
||||||
stdio: 'pipe',
|
|
||||||
shell: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait until SvelteKit dev server responds
|
|
||||||
waitOn(
|
|
||||||
{
|
|
||||||
resources: [`http://localhost:${SERVER_PORT}`],
|
|
||||||
timeout: 30000
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
dbg('Dev server started');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopDevServer(): Promise<void> {
|
|
||||||
if (devServer) {
|
|
||||||
dbg('Stopping dev server...');
|
|
||||||
devServer.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [sveltekit()],
|
||||||
tailwindcss(),
|
|
||||||
sveltekit(),
|
|
||||||
paraglideVitePlugin({
|
|
||||||
project: './project.inlang',
|
|
||||||
outdir: './src/lib/paraglide'
|
|
||||||
})
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@assets': path.join(__dirname, 'assets/generated')
|
'@assets': path.join(__dirname, 'assets/generated')
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue