diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f83c241..d8f0426 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,4 +53,4 @@ jobs: steps: - name: Deploy id: deployment - uses: actions/deploy-pages@v2 \ No newline at end of file + uses: actions/deploy-pages@v2 diff --git a/.vscode/settings.json b/.vscode/settings.json index fae3c6f..9506aa9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,11 @@ { - "cSpell.words": [ - "browserconfig", - "controlslist", - "lightskyblue", - "MaruMinya", - "mimoz", - "nodownload", - "ondataavailable" - ] -} \ No newline at end of file + "cSpell.words": [ + "browserconfig", + "controlslist", + "lightskyblue", + "MaruMinya", + "mimoz", + "nodownload", + "ondataavailable" + ] +} diff --git a/README.md b/README.md index b046b00..a8b9e77 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # .Spectrum + dot spectrum is a pixel-art-like spectrum analyzer. w/ Web Audio API. ![preview](./preview.gif) - ## Developing Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: @@ -28,13 +28,15 @@ You can preview the production build with `npm run preview`. > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. ## TODO -- ドット化サイズを1/nと、ピクセルサイズの両方で指定可能にする + +- ドット化サイズを 1/n と、ピクセルサイズの両方で指定可能にする - キャンバスの大きさ テンプレート - Media Recorder - - mp4対応 + - mp4 対応 - audio プレイヤー デザイン ## 📚 Tech Stack + - Canvas API - Web Audio API - PWA diff --git a/src/app.html b/src/app.html index d1cc288..96f2361 100644 --- a/src/app.html +++ b/src/app.html @@ -3,8 +3,8 @@ - - + + %sveltekit.head% diff --git a/src/lib/ffmpeg.ts b/src/lib/ffmpeg.ts index 1c29f4c..efd5fc7 100644 --- a/src/lib/ffmpeg.ts +++ b/src/lib/ffmpeg.ts @@ -5,23 +5,22 @@ import { createFFmpeg, fetchFile, type FFmpeg } from '@ffmpeg/ffmpeg'; // TODO: ログは dev環境のみで流す const ffmpeg: FFmpeg = createFFmpeg({ - log: true + log: true }); export const load = async () => { - await ffmpeg.load(); -} + await ffmpeg.load(); +}; -const VIDEO_KEY = "video"; +const VIDEO_KEY = 'video'; // return: blob url -export const convertMp4 = async (file: File, outputName = "output.mp4") => { - ffmpeg.FS("writeFile", VIDEO_KEY, await fetchFile(file)); - console.time("exec"); - await ffmpeg.run("-i", VIDEO_KEY, outputName); - console.timeEnd("exec"); - const data = ffmpeg.FS("readFile", outputName); - - return URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' })); -} - +export const convertMp4 = async (file: File, outputName = 'output.mp4') => { + ffmpeg.FS('writeFile', VIDEO_KEY, await fetchFile(file)); + console.time('exec'); + await ffmpeg.run('-i', VIDEO_KEY, outputName); + console.timeEnd('exec'); + const data = ffmpeg.FS('readFile', outputName); + + return URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' })); +}; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index d65e095..12d3df5 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -1,16 +1,16 @@ -import { browser } from "$app/environment"; -import { init, register } from "svelte-i18n"; +import { browser } from '$app/environment'; +import { init, register } from 'svelte-i18n'; -export const defaultLocale = "ja"; +export const defaultLocale = 'ja'; export const languagesList = [ - { lang: "ja", name: "日本語" }, - { lang: "en", name: "English" } + { lang: 'ja', name: '日本語' }, + { lang: 'en', name: 'English' } ]; -register("ja", () => import("./locales/ja.json")); -register("en", () => import("./locales/en.json")); +register('ja', () => import('./locales/ja.json')); +register('en', () => import('./locales/en.json')); init({ fallbackLocale: defaultLocale, - initialLocale: browser ? window.navigator.language : defaultLocale, + initialLocale: browser ? window.navigator.language : defaultLocale }); diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index b7be6f4..43ea4c2 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -1,33 +1,33 @@ { - "usage": "Usage", - "usage_1": "Upload pixel art (png, jpg...)", - "usage_2": "Upload music (wav, mp3...)", - "usage_3": "Play music!", - "supported_version": "Supported Versions", - "upload_image": "Upload image file", - "upload_image_error": "Image is too large. Please pixelate. (Images of 256px x 256px or less can be specified.)", - "image_info": "Image size ( width: {width}px, height: {height}px )", - "is_pixelation": "Do you want to pixelate?", - "do_pixelation": " yes", - "do_not_pixelation": " no", - "upload_audio": "Upload audio file.", - "parameters_setting": "⚙️ Setting", - "parameters_window_size": "window size: ", - "parameters_custom": "custom", - "parameters_width": "width: ", - "parameters_height": "height: ", - "parameters_note": "NOTE: The value should be n times the number of pixels in the image to display it nicely", - "parameters_background": "background: ", - "parameters_fft_size": "FFT size: ", - "parameters_spectrum_type": "spectrum type: ", - "parameters_sensitivity": "sensitivity: ", - "parameters_grid_size": "grid size: ", - "parameters_smooth": "smooth: ", - "change_full_screen": "full screen mode", - "disabled_full_screen": "Full screen mode is not available", - "generate_video": "Generate video", - "generate_video_message": "Generating video... Please keep the screen open.", - "generate_video_error": "Upload your image and audio.", - "ffmpeg_error": "error: ffmpeg is not available.", - "download_video": "Download video" -} \ No newline at end of file + "usage": "Usage", + "usage_1": "Upload pixel art (png, jpg...)", + "usage_2": "Upload music (wav, mp3...)", + "usage_3": "Play music!", + "supported_version": "Supported Versions", + "upload_image": "Upload image file", + "upload_image_error": "Image is too large. Please pixelate. (Images of 256px x 256px or less can be specified.)", + "image_info": "Image size ( width: {width}px, height: {height}px )", + "is_pixelation": "Do you want to pixelate?", + "do_pixelation": " yes", + "do_not_pixelation": " no", + "upload_audio": "Upload audio file.", + "parameters_setting": "⚙️ Setting", + "parameters_window_size": "window size: ", + "parameters_custom": "custom", + "parameters_width": "width: ", + "parameters_height": "height: ", + "parameters_note": "NOTE: The value should be n times the number of pixels in the image to display it nicely", + "parameters_background": "background: ", + "parameters_fft_size": "FFT size: ", + "parameters_spectrum_type": "spectrum type: ", + "parameters_sensitivity": "sensitivity: ", + "parameters_grid_size": "grid size: ", + "parameters_smooth": "smooth: ", + "change_full_screen": "full screen mode", + "disabled_full_screen": "Full screen mode is not available", + "generate_video": "Generate video", + "generate_video_message": "Generating video... Please keep the screen open.", + "generate_video_error": "Upload your image and audio.", + "ffmpeg_error": "error: ffmpeg is not available.", + "download_video": "Download video" +} diff --git a/src/lib/i18n/locales/ja.json b/src/lib/i18n/locales/ja.json index 69fb1f9..c3b7a39 100644 --- a/src/lib/i18n/locales/ja.json +++ b/src/lib/i18n/locales/ja.json @@ -1,33 +1,33 @@ { - "usage": "使い方", - "usage_1": "ドット絵をアップ (png, jpg...)", - "usage_2": "音声をアップ (wav, mp3...)", - "usage_3": "音声を再生!", - "supported_version": "サポートされているバージョン", - "upload_image": "画像をアップロード", - "upload_image_error": "画像が大き過ぎます。ドット化してください。(256px × 256px以下の画像が指定可能です。)", - "image_info": "画像サイズ(幅: {width}px, 高さ: {height}px)", - "is_pixelation": "ドット化しますか?", - "do_pixelation": "する", - "do_not_pixelation": "しない", - "upload_audio": "音声をアップロード", - "parameters_setting": "⚙️ 設定", - "parameters_window_size": "画面サイズ: ", - "parameters_custom": "カスタム", - "parameters_width": "幅: ", - "parameters_height": "高さ: ", - "parameters_note": "※ ドット絵のピクセル数×n倍の値にすると、きれいに表示できます", - "parameters_background": "背景色: ", - "parameters_fft_size": "FFTサイズ: ", - "parameters_spectrum_type": "タイプ: ", - "parameters_sensitivity": "感度: ", - "parameters_grid_size": "グリッドサイズ: ", - "parameters_smooth": "スムーズ: ", - "change_full_screen": "全画面モードに切り替える", - "disabled_full_screen": "全画面モードが使用できません", - "generate_video": "動画を作成する", - "generate_video_message": "動画を作成中です... 画面を開いたままにしてください", - "generate_video_error": "画像と音声をアップロードしてください", - "ffmpeg_error": "エラー: ffmpegが使用できません", - "download_video": "動画をダウンロードする" + "usage": "使い方", + "usage_1": "ドット絵をアップ (png, jpg...)", + "usage_2": "音声をアップ (wav, mp3...)", + "usage_3": "音声を再生!", + "supported_version": "サポートされているバージョン", + "upload_image": "画像をアップロード", + "upload_image_error": "画像が大き過ぎます。ドット化してください。(256px × 256px以下の画像が指定可能です。)", + "image_info": "画像サイズ(幅: {width}px, 高さ: {height}px)", + "is_pixelation": "ドット化しますか?", + "do_pixelation": "する", + "do_not_pixelation": "しない", + "upload_audio": "音声をアップロード", + "parameters_setting": "⚙️ 設定", + "parameters_window_size": "画面サイズ: ", + "parameters_custom": "カスタム", + "parameters_width": "幅: ", + "parameters_height": "高さ: ", + "parameters_note": "※ ドット絵のピクセル数×n倍の値にすると、きれいに表示できます", + "parameters_background": "背景色: ", + "parameters_fft_size": "FFTサイズ: ", + "parameters_spectrum_type": "タイプ: ", + "parameters_sensitivity": "感度: ", + "parameters_grid_size": "グリッドサイズ: ", + "parameters_smooth": "スムーズ: ", + "change_full_screen": "全画面モードに切り替える", + "disabled_full_screen": "全画面モードが使用できません", + "generate_video": "動画を作成する", + "generate_video_message": "動画を作成中です... 画面を開いたままにしてください", + "generate_video_error": "画像と音声をアップロードしてください", + "ffmpeg_error": "エラー: ffmpegが使用できません", + "download_video": "動画をダウンロードする" } diff --git a/src/lib/image.ts b/src/lib/image.ts index 047da03..ee438e2 100644 --- a/src/lib/image.ts +++ b/src/lib/image.ts @@ -1,57 +1,62 @@ -export const toBase64 = (file: File) => new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; -}); - -export const loadImage = (src: string) => new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = (e) => reject(e); - img.src = src; -}); +export const toBase64 = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + }); + +export const loadImage = (src: string) => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (e) => reject(e); + img.src = src; + }); export const getRGBAbyImageData = (imageData: ImageData, x: number, y: number, width: number) => { - return [ - imageData.data[(x + y * width) * 4], - imageData.data[(x + y * width) * 4 + 1], - imageData.data[(x + y * width) * 4 + 2], - imageData.data[(x + y * width) * 4 + 3] / 255 - ]; -} + return [ + imageData.data[(x + y * width) * 4], + imageData.data[(x + y * width) * 4 + 1], + imageData.data[(x + y * width) * 4 + 2], + imageData.data[(x + y * width) * 4 + 3] / 255 + ]; +}; // ピクセル化(サンプリング) (pixelSizeの大きさ) -export const executePixelationSampling = (context: CanvasRenderingContext2D, img: HTMLImageElement, pixelSize: number) => { - const newWidth = Math.floor(img.width / pixelSize); - const newHeight = Math.floor(img.height / pixelSize); - - // draw original image - context.canvas.width = img.width; - context.canvas.height = img.height; - context.drawImage(img, 0, 0); - - const imageData = context.getImageData(0, 0, img.width, img.height); - const newImageData = new ImageData(newWidth, newHeight); - - for (let y = 0; y < img.height; y += pixelSize) { - for (let x = 0; x < img.width; x += pixelSize) { - - const red = imageData.data[((img.width * y) + x) * 4]; - const green = imageData.data[((img.width * y) + x) * 4 + 1]; - const blue = imageData.data[((img.width * y) + x) * 4 + 2]; - const alpha = imageData.data[((img.width * y) + x) * 4 + 3]; - - newImageData.data[((newWidth * (y / pixelSize)) + (x / pixelSize)) * 4] = red; - newImageData.data[((newWidth * (y / pixelSize)) + (x / pixelSize)) * 4 + 1] = green; - newImageData.data[((newWidth * (y / pixelSize)) + (x / pixelSize)) * 4 + 2] = blue; - newImageData.data[((newWidth * (y / pixelSize)) + (x / pixelSize)) * 4 + 3] = alpha; - } - } - - context.canvas.width = newWidth; - context.canvas.height = newHeight; - context.putImageData(newImageData, 0, 0); - - return newImageData; -} +export const executePixelationSampling = ( + context: CanvasRenderingContext2D, + img: HTMLImageElement, + pixelSize: number +) => { + const newWidth = Math.floor(img.width / pixelSize); + const newHeight = Math.floor(img.height / pixelSize); + + // draw original image + context.canvas.width = img.width; + context.canvas.height = img.height; + context.drawImage(img, 0, 0); + + const imageData = context.getImageData(0, 0, img.width, img.height); + const newImageData = new ImageData(newWidth, newHeight); + + for (let y = 0; y < img.height; y += pixelSize) { + for (let x = 0; x < img.width; x += pixelSize) { + const red = imageData.data[(img.width * y + x) * 4]; + const green = imageData.data[(img.width * y + x) * 4 + 1]; + const blue = imageData.data[(img.width * y + x) * 4 + 2]; + const alpha = imageData.data[(img.width * y + x) * 4 + 3]; + + newImageData.data[(newWidth * (y / pixelSize) + x / pixelSize) * 4] = red; + newImageData.data[(newWidth * (y / pixelSize) + x / pixelSize) * 4 + 1] = green; + newImageData.data[(newWidth * (y / pixelSize) + x / pixelSize) * 4 + 2] = blue; + newImageData.data[(newWidth * (y / pixelSize) + x / pixelSize) * 4 + 3] = alpha; + } + } + + context.canvas.width = newWidth; + context.canvas.height = newHeight; + context.putImageData(newImageData, 0, 0); + + return newImageData; +}; diff --git a/src/lib/index.ts b/src/lib/index.ts index 4a3ba49..5fb34ec 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,58 +1,62 @@ -export const spectrumTypes = ["liner", "log", "pixel"] as const; -export type SpectrumType = typeof spectrumTypes[number]; +export const spectrumTypes = ['liner', 'log', 'pixel'] as const; +export type SpectrumType = (typeof spectrumTypes)[number]; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const isSpectrumType = (arg: any): arg is SpectrumType => { - return spectrumTypes.indexOf(arg) !== -1; -} + return spectrumTypes.indexOf(arg) !== -1; +}; export type LogToPixelIndexMap = { - begin: number | null; - end: number | null; + begin: number | null; + end: number | null; }[]; export const generateLogToPixelIndexMap = (fftSize: number, imageWidth: number) => { - const map: LogToPixelIndexMap = structuredClone([...Array(imageWidth)].map(() => { return { begin: null, end: null }})); - let count = 0; - map[0].begin = 0; - - for (let i = 0; i < fftSize / 2; i++) { - // cf. (w / imageWidth) * (count + 1) < Math.log(i) / Math.log(fftSize / 2) * w - if (count + 1 < Math.log(i) / Math.log(fftSize / 2) * imageWidth) { - map[count].end = i - 1; - count++; - map[count].begin = i; - if (count >= imageWidth) { - break; - } - } - } - if (!map[map.length - 1].end) { - map[map.length - 1].end = fftSize / 2 - 1; - } - return structuredClone(map); -} + const map: LogToPixelIndexMap = structuredClone( + [...Array(imageWidth)].map(() => { + return { begin: null, end: null }; + }) + ); + let count = 0; + map[0].begin = 0; + + for (let i = 0; i < fftSize / 2; i++) { + // cf. (w / imageWidth) * (count + 1) < Math.log(i) / Math.log(fftSize / 2) * w + if (count + 1 < (Math.log(i) / Math.log(fftSize / 2)) * imageWidth) { + map[count].end = i - 1; + count++; + map[count].begin = i; + if (count >= imageWidth) { + break; + } + } + } + if (!map[map.length - 1].end) { + map[map.length - 1].end = fftSize / 2 - 1; + } + return structuredClone(map); +}; // utility export const clamp = (num: number, min: number, max: number) => { - return Math.max(min, Math.min(num, max)); -} + return Math.max(min, Math.min(num, max)); +}; export const padding = (size: number, numString: number | string) => { - return ("0".repeat(size) + String(numString)).slice(-size) -} + return ('0'.repeat(size) + String(numString)).slice(-size); +}; export const formatDate = (date: Date) => { - const Y = date.getFullYear(); - const M = padding(2, date.getMonth() + 1); - const D = padding(2, date.getDate()); - const h = padding(2, date.getHours()); - const m = padding(2, date.getMinutes()); - const s = padding(2, date.getSeconds()); - return (Y + "_" + M + "_" + D + "_" + h + "_" + m + "_" + s); -} + const Y = date.getFullYear(); + const M = padding(2, date.getMonth() + 1); + const D = padding(2, date.getDate()); + const h = padding(2, date.getHours()); + const m = padding(2, date.getMinutes()); + const s = padding(2, date.getSeconds()); + return Y + '_' + M + '_' + D + '_' + h + '_' + m + '_' + s; +}; export const capitalize = (str: string) => { - return str[0].toUpperCase() + str.slice(1); -} + return str[0].toUpperCase() + str.slice(1); +}; diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 878e8fe..da06ab5 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,13 +1,13 @@ import { browser } from '$app/environment'; -import '$lib/i18n' // Import to initialize. Important :) +import '$lib/i18n'; // Import to initialize. Important :) import { locale, waitLocale } from 'svelte-i18n'; import type { LayoutLoad } from './$types'; export const load: LayoutLoad = async () => { if (browser) { - locale.set(window.navigator.language) + locale.set(window.navigator.language); } - await waitLocale() + await waitLocale(); }; // This can be false if you're using a fallback (i.e. SPA mode) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b82076a..ddb1ecb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,38 +1,44 @@ + + - .Spectrum | ドット絵風スペクトルアナライザー - - - - - - - - + .Spectrum | ドット絵風スペクトルアナライザー + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - diff --git a/src/service-worker.js b/src/service-worker.js index 41fbd79..7f59291 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -3,28 +3,28 @@ /// /// -import { build, files, version } from "$service-worker"; +import { build, files, version } from '$service-worker'; // Create a unique cache name for this deployment const CACHE = `cache-${version}`; const ASSETS = [ ...build, // the app itself - ...files // everything in `static` + ...files // everything in `static` ]; const coepCredentialless = false; // TODO -self.addEventListener("install", (event) => { - async function addFilesToCache() { +self.addEventListener('install', (event) => { + async function addFilesToCache() { const cache = await caches.open(CACHE); await cache.addAll(ASSETS); } - console.log("install service worker"); + console.log('install service worker'); event.waitUntil(addFilesToCache()); }); -self.addEventListener("activate", (event) => { +self.addEventListener('activate', (event) => { // Remove previous cached data from disk async function deleteOldCaches() { for (const key of await caches.keys()) { @@ -35,16 +35,19 @@ self.addEventListener("activate", (event) => { event.waitUntil(deleteOldCaches()); }); -self.addEventListener("fetch", (event) => { +self.addEventListener('fetch', (event) => { // ignore POST requests etc - const url = new URL(event.request.url); - if (event.request.method !== "GET" || - url.origin.startsWith('chrome-extension') || - url.origin.includes('extension') || - !(url.origin.indexOf('http') === 0)) return; - - async function respond() { - const cache = await caches.open(CACHE); + const url = new URL(event.request.url); + if ( + event.request.method !== 'GET' || + url.origin.startsWith('chrome-extension') || + url.origin.includes('extension') || + !(url.origin.indexOf('http') === 0) + ) + return; + + async function respond() { + const cache = await caches.open(CACHE); // `build`/`files` can always be served from the cache if (ASSETS.includes(url.pathname)) { @@ -65,15 +68,16 @@ self.addEventListener("fetch", (event) => { } const newHeaders = new Headers(response.headers); - newHeaders.set("Cross-Origin-Embedder-Policy", - coepCredentialless ? "credentialless" : "require-corp" + newHeaders.set( + 'Cross-Origin-Embedder-Policy', + coepCredentialless ? 'credentialless' : 'require-corp' ); - newHeaders.set("Cross-Origin-Opener-Policy", "same-origin"); + newHeaders.set('Cross-Origin-Opener-Policy', 'same-origin'); return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: newHeaders, + status: response.status, + statusText: response.statusText, + headers: newHeaders }); } catch { return cache.match(event.request); diff --git a/static/manifest.json b/static/manifest.json index 345b6c3..ec7639d 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,21 +1,21 @@ { - "name": ".Spectrum", - "short_name": ".Spectrum", - "theme_color": "#579BB1", - "background_color": "#ECE8DD", - "start_url": "./index.html", - "display": "standalone", - "icons": [ - { - "src": "./assets/icons/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "./assets/icons/android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - } - ] + "name": ".Spectrum", + "short_name": ".Spectrum", + "theme_color": "#579BB1", + "background_color": "#ECE8DD", + "start_url": "./index.html", + "display": "standalone", + "icons": [ + { + "src": "./assets/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "./assets/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] } diff --git a/svelte.config.js b/svelte.config.js index d9cc468..69819d6 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -18,6 +18,6 @@ const config = { } }; -config.paths = { base: process.argv.includes('dev') ? '' : process.env.BASE_PATH } +config.paths = { base: process.argv.includes('dev') ? '' : process.env.BASE_PATH }; export default config;