Clavier virtuel, barre d'aide des boutons et sons de navigation du thème Orbe (générés, tools/gen-sounds.mjs)
Some checks are pending
CI / windows (push) Waiting to run
Some checks are pending
CI / windows (push) Waiting to run
This commit is contained in:
parent
e7478d3c64
commit
b8b1afc626
11 changed files with 228 additions and 19 deletions
|
|
@ -1,21 +1,27 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import { Background } from './components/Background'
|
import { Background } from './components/Background'
|
||||||
import { SettingsProvider, useSettings } from './state/settings'
|
import { Keyboard } from './components/Keyboard'
|
||||||
import { ThemeProvider, useTheme } from './theme/theme'
|
import { HintBar } from './nav/glyphs'
|
||||||
|
import { NavProvider } from './nav/nav'
|
||||||
|
import { SettingsProvider } from './state/settings'
|
||||||
|
import { ThemeProvider } from './theme/theme'
|
||||||
import './styles/base.css'
|
import './styles/base.css'
|
||||||
|
|
||||||
function Preview() {
|
function Preview() {
|
||||||
const { themes, theme } = useTheme()
|
const [kb, setKb] = useState(false)
|
||||||
const { update } = useSettings()
|
const [text, setText] = useState('')
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: 'var(--pad)' }}>
|
<main style={{ padding: 'var(--pad)' }}>
|
||||||
<Background paused={false} />
|
<Background paused={false} />
|
||||||
<p className="kicker">Thèmes</p>
|
<p className="kicker">Navigation</p>
|
||||||
<h1 className="hero-title">Orbe <mark>Launcher</mark></h1>
|
<h1 className="hero-title">Orbe <mark>Launcher</mark></h1>
|
||||||
<div style={{ display: 'flex', gap: 12, marginTop: 32 }}>
|
<p className="muted">{text || '…'}</p>
|
||||||
{themes.map((t) => (
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 140px)', gap: 18, marginTop: 32 }}>
|
||||||
<button key={t.manifest.id} className={'btn' + (t === theme ? ' btn--primary' : '')} onClick={() => update({ theme: t.manifest.id })}>{t.manifest.name}</button>
|
{Array.from({ length: 12 }, (_, i) => <button key={i} className="card" data-nav style={{ height: 210 }} />)}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button className="btn btn--primary" data-nav style={{ marginTop: 32 }} onClick={() => setKb(true)}>Clavier</button>
|
||||||
|
{kb && <Keyboard value={text} onSubmit={(v) => { setText(v); setKb(false) }} onCancel={() => setKb(false)} />}
|
||||||
|
{!kb && <HintBar items={[['accept', 'hint.select'], ['back', 'hint.back']]} />}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -24,7 +30,9 @@ export function App() {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<Preview />
|
<NavProvider>
|
||||||
|
<Preview />
|
||||||
|
</NavProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
83
src/renderer/src/components/Keyboard.tsx
Normal file
83
src/renderer/src/components/Keyboard.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/* Clavier virtuel plein écran pour la manette. Le clavier physique tape aussi dedans. */
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { HintBar } from '../nav/glyphs'
|
||||||
|
import { useNav } from '../nav/nav'
|
||||||
|
import { useSettings } from '../state/settings'
|
||||||
|
import { useT } from '../i18n/i18n'
|
||||||
|
|
||||||
|
const LAYOUTS = {
|
||||||
|
fr: ['1234567890', 'azertyuiop', 'qsdfghjklm', "wxcvbn,.'-"],
|
||||||
|
en: ['1234567890', 'qwertyuiop', 'asdfghjkl-', "zxcvbnm,.'"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
value: string
|
||||||
|
max?: number
|
||||||
|
onChange?: (v: string) => void // à chaque frappe (recherche instantanée)
|
||||||
|
onSubmit: (v: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Keyboard({ title, value: initial, max = 120, onChange, onSubmit, onCancel }: Props) {
|
||||||
|
const t = useT()
|
||||||
|
const { settings } = useSettings()
|
||||||
|
const [value, setValue] = useState(initial)
|
||||||
|
const [shift, setShift] = useState(false)
|
||||||
|
const first = useRef(true)
|
||||||
|
const type = (c: string) => setValue((v) => (v + c).slice(0, max))
|
||||||
|
const typeKey = (c: string) => type(shift ? c.toUpperCase() : c)
|
||||||
|
const del = () => setValue((v) => v.slice(0, -1))
|
||||||
|
useEffect(() => { if (first.current) first.current = false; else onChange?.(value) }, [value])
|
||||||
|
|
||||||
|
/* Clavier physique : caractères et retour arrière vont dans le champ, avant la navigation */
|
||||||
|
useEffect(() => {
|
||||||
|
const k = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey || e.altKey || e.metaKey) return
|
||||||
|
if (e.key.length === 1) type(e.key)
|
||||||
|
else if (e.key === 'Backspace') del()
|
||||||
|
else if (e.key === 'Enter' && !(document.activeElement instanceof HTMLButtonElement)) onSubmit(value)
|
||||||
|
else return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
addEventListener('keydown', k, true)
|
||||||
|
return () => removeEventListener('keydown', k, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
useNav((a) => {
|
||||||
|
if (a === 'back') { onCancel(); return true }
|
||||||
|
if (a === 'x') { del(); return true }
|
||||||
|
if (a === 'y') { type(' '); return true }
|
||||||
|
if (a === 'lb' || a === 'lt') { setShift((s) => !s); return true }
|
||||||
|
if (a === 'menu' || a === 'rt') { onSubmit(value); return true }
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal osk" data-nav-scope role="dialog" aria-label={title ?? t('kb.title')}>
|
||||||
|
<div className="osk__box">
|
||||||
|
<p className="kicker">{title ?? t('kb.title')}</p>
|
||||||
|
<div className="osk__field" aria-live="polite">{value}<span className="osk__caret" /></div>
|
||||||
|
<div className="osk__keys">
|
||||||
|
{LAYOUTS[settings.language].map((row, i) => (
|
||||||
|
<div className="osk__row" key={i}>
|
||||||
|
{[...row].map((c, j) => (
|
||||||
|
<button key={c} className="osk__key" data-nav {...(i === 1 && j === 0 ? { 'data-nav-autofocus': true } : {})} onClick={() => typeKey(c)}>
|
||||||
|
{shift ? c.toUpperCase() : c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="osk__row">
|
||||||
|
<button className={'osk__key osk__key--wide' + (shift ? ' is-on' : '')} data-nav onClick={() => setShift((s) => !s)}>⇧</button>
|
||||||
|
<button className="osk__key osk__key--space" data-nav onClick={() => type(' ')}>{t('kb.space')}</button>
|
||||||
|
<button className="osk__key osk__key--wide" data-nav onClick={del}>⌫</button>
|
||||||
|
<button className="osk__key osk__key--ok" data-nav onClick={() => onSubmit(value)}>{t('kb.ok')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HintBar items={[['accept', 'hint.select'], ['x', 'hint.delete'], ['y', 'hint.space'], ['lb', 'hint.shift'], ['menu', 'hint.ok'], ['back', 'hint.back']]} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -47,3 +47,33 @@ mark { background: none; color: var(--accent); }
|
||||||
/* Fond animé */
|
/* Fond animé */
|
||||||
.bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
|
.bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
|
||||||
.bg-layer canvas, .bg-layer video, .bg-layer img { width: 100%; height: 100%; object-fit: cover; }
|
.bg-layer canvas, .bg-layer video, .bg-layer img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
/* Barre d'aide (boutons) */
|
||||||
|
.hintbar { position: fixed; inset: auto 0 0 0; height: 64px; display: flex; justify-content: flex-end; align-items: center; gap: 28px; padding: 0 var(--pad);
|
||||||
|
font: 400 14px/1 var(--font-mono); color: var(--muted); letter-spacing: .06em; z-index: 30; background: linear-gradient(transparent, color-mix(in srgb, var(--bg) 85%, transparent) 40%); }
|
||||||
|
.hint { display: inline-flex; align-items: center; gap: 10px; }
|
||||||
|
.glyph { display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 8px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface-2); color: var(--text); font: 700 13px/1 var(--font-mono); }
|
||||||
|
.glyph--xbox.glyph--accept { background: #107c10; border-color: #107c10; color: #fff; }
|
||||||
|
.glyph--xbox.glyph--back { background: #c4302b; border-color: #c4302b; color: #fff; }
|
||||||
|
.glyph--xbox.glyph--x { background: #1f6fd1; border-color: #1f6fd1; color: #fff; }
|
||||||
|
.glyph--xbox.glyph--y { background: #d6a400; border-color: #d6a400; color: #111; }
|
||||||
|
.glyph--keyboard, .glyph--mouse { border-radius: var(--radius-s); }
|
||||||
|
|
||||||
|
/* Fenêtres modales */
|
||||||
|
.modal { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px); animation: modal-in var(--dur-med) var(--ease); }
|
||||||
|
@keyframes modal-in { from { opacity: 0; } }
|
||||||
|
|
||||||
|
/* Clavier virtuel */
|
||||||
|
.osk__box { width: min(1100px, 92vw); padding-bottom: 60px; }
|
||||||
|
.osk__field { min-height: 76px; padding: 18px 22px; margin-bottom: 26px; border-radius: var(--radius-m); border: 1px solid var(--accent); background: var(--surface); font-size: 30px; white-space: pre; overflow: hidden; }
|
||||||
|
.osk__caret { display: inline-block; width: 3px; height: 1.1em; margin-left: 2px; vertical-align: -.15em; background: var(--accent); }
|
||||||
|
.osk__keys { display: grid; gap: 10px; }
|
||||||
|
.osk__row { display: flex; gap: 10px; justify-content: center; }
|
||||||
|
.osk__key { flex: 1; max-width: 96px; height: 68px; border-radius: var(--radius-s); background: var(--surface); border: 1px solid var(--line); font: 600 24px/1 var(--font-sans);
|
||||||
|
transition: transform var(--dur-fast) var(--ease), background var(--dur-fast), box-shadow var(--dur-fast); }
|
||||||
|
.osk__key--wide { max-width: 150px; }
|
||||||
|
.osk__key--space { max-width: 420px; flex: 4; font-size: 16px; }
|
||||||
|
.osk__key--ok { max-width: 180px; background: var(--accent); color: var(--on-accent); font-size: 16px; }
|
||||||
|
.osk__key.is-on { color: var(--accent); }
|
||||||
|
.osk__key.is-focused { box-shadow: var(--focus-ring); transform: scale(1.06); background: var(--surface-2); }
|
||||||
|
.osk__key--ok.is-focused { background: var(--accent); }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import type { ThemeEntry } from '@shared/ipc'
|
import type { ThemeEntry } from '@shared/ipc'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { loadSounds, setVolume } from '../nav/sounds'
|
||||||
import { useSettings } from '../state/settings'
|
import { useSettings } from '../state/settings'
|
||||||
|
|
||||||
interface Ctx {
|
interface Ctx {
|
||||||
|
|
@ -46,7 +47,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
return api.on('themes:changed', ({ themes }) => { setThemes(themes); setVersion(Date.now()) })
|
return api.on('themes:changed', ({ themes }) => { setThemes(themes); setVersion(Date.now()) })
|
||||||
}, [])
|
}, [])
|
||||||
const theme = themes.find((t) => t.manifest.id === settings.theme) ?? themes.find((t) => t.manifest.id === 'orbe') ?? themes[0] ?? null
|
const theme = themes.find((t) => t.manifest.id === settings.theme) ?? themes.find((t) => t.manifest.id === 'orbe') ?? themes[0] ?? null
|
||||||
useEffect(() => { if (theme && ready) apply(theme, version) }, [theme, version, ready])
|
useEffect(() => {
|
||||||
|
if (!theme || !ready) return
|
||||||
|
apply(theme, version)
|
||||||
|
loadSounds(theme.base, theme.manifest.sounds)
|
||||||
|
}, [theme, version, ready])
|
||||||
|
useEffect(() => setVolume(settings.volume), [settings.volume])
|
||||||
const url = (rel?: string) => (rel && theme ? theme.base + rel : undefined)
|
const url = (rel?: string) => (rel && theme ? theme.base + rel : undefined)
|
||||||
return <ThemeCtx.Provider value={{ theme, themes, url }}>{children}</ThemeCtx.Provider>
|
return <ThemeCtx.Provider value={{ theme, themes, url }}>{children}</ThemeCtx.Provider>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
themes/orbe/sounds/back.wav
Normal file
BIN
themes/orbe/sounds/back.wav
Normal file
Binary file not shown.
BIN
themes/orbe/sounds/error.wav
Normal file
BIN
themes/orbe/sounds/error.wav
Normal file
Binary file not shown.
BIN
themes/orbe/sounds/launch.wav
Normal file
BIN
themes/orbe/sounds/launch.wav
Normal file
Binary file not shown.
BIN
themes/orbe/sounds/move.wav
Normal file
BIN
themes/orbe/sounds/move.wav
Normal file
Binary file not shown.
BIN
themes/orbe/sounds/select.wav
Normal file
BIN
themes/orbe/sounds/select.wav
Normal file
Binary file not shown.
|
|
@ -28,14 +28,47 @@
|
||||||
"--ease": "cubic-bezier(.2, .8, .2, 1)"
|
"--ease": "cubic-bezier(.2, .8, .2, 1)"
|
||||||
},
|
},
|
||||||
"fonts": [
|
"fonts": [
|
||||||
{ "family": "Archivo", "src": "fonts/Archivo-100-900-latin.woff2", "weight": "100 900", "unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD" },
|
{
|
||||||
{ "family": "Archivo", "src": "fonts/Archivo-100-900-latin-ext.woff2", "weight": "100 900", "unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF" },
|
"family": "Archivo",
|
||||||
{ "family": "JetBrains Mono", "src": "fonts/JetBrainsMono-400-latin.woff2", "weight": "400 700", "unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD" },
|
"src": "fonts/Archivo-100-900-latin.woff2",
|
||||||
{ "family": "JetBrains Mono", "src": "fonts/JetBrainsMono-400-latin-ext.woff2", "weight": "400 700", "unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF" }
|
"weight": "100 900",
|
||||||
|
"unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Archivo",
|
||||||
|
"src": "fonts/Archivo-100-900-latin-ext.woff2",
|
||||||
|
"weight": "100 900",
|
||||||
|
"unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "JetBrains Mono",
|
||||||
|
"src": "fonts/JetBrainsMono-400-latin.woff2",
|
||||||
|
"weight": "400 700",
|
||||||
|
"unicodeRange": "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "JetBrains Mono",
|
||||||
|
"src": "fonts/JetBrainsMono-400-latin-ext.woff2",
|
||||||
|
"weight": "400 700",
|
||||||
|
"unicodeRange": "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+20A0-20AB, U+20AD-20C4, U+2C60-2C7F, U+A720-A7FF"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"css": "theme.css",
|
"css": "theme.css",
|
||||||
"layout": { "library": "grid" },
|
"layout": {
|
||||||
"background": { "type": "orb" },
|
"library": "grid"
|
||||||
"splash": { "type": "orb", "duration": 1800 },
|
},
|
||||||
"sounds": {}
|
"background": {
|
||||||
|
"type": "orb"
|
||||||
|
},
|
||||||
|
"splash": {
|
||||||
|
"type": "orb",
|
||||||
|
"duration": 1800
|
||||||
|
},
|
||||||
|
"sounds": {
|
||||||
|
"move": "sounds/move.wav",
|
||||||
|
"select": "sounds/select.wav",
|
||||||
|
"back": "sounds/back.wav",
|
||||||
|
"error": "sounds/error.wav",
|
||||||
|
"launch": "sounds/launch.wav"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
tools/gen-sounds.mjs
Normal file
49
tools/gen-sounds.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// Génère les sons de navigation du thème Orbe (WAV 16 bits mono, 44,1 kHz) : sobres, courts, sans dépendance.
|
||||||
|
// Usage : node tools/gen-sounds.mjs
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const RATE = 44100
|
||||||
|
const out = join(import.meta.dirname, '..', 'themes', 'orbe', 'sounds')
|
||||||
|
|
||||||
|
/* voix : fréquence qui glisse de f0 à f1, enveloppe attaque/déclin, timbre sinus + un peu d'harmonique */
|
||||||
|
function tone(dur, f0, f1, { gain = 0.5, attack = 0.004, harm = 0.15, delay = 0 } = {}) {
|
||||||
|
const n = Math.round((dur + delay) * RATE), s = new Float32Array(n)
|
||||||
|
let phase = 0
|
||||||
|
for (let i = Math.round(delay * RATE); i < n; i++) {
|
||||||
|
const t = i / RATE - delay, p = t / dur
|
||||||
|
const f = f0 * Math.pow(f1 / f0, p)
|
||||||
|
phase += (2 * Math.PI * f) / RATE
|
||||||
|
const env = Math.min(1, t / attack) * Math.pow(1 - p, 2.2)
|
||||||
|
s[i] = gain * env * (Math.sin(phase) + harm * Math.sin(2 * phase))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
const mix = (...parts) => {
|
||||||
|
const n = Math.max(...parts.map((p) => p.length)), s = new Float32Array(n)
|
||||||
|
for (const p of parts) for (let i = 0; i < p.length; i++) s[i] += p[i]
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function wav(samples) {
|
||||||
|
const b = Buffer.alloc(44 + samples.length * 2)
|
||||||
|
b.write('RIFF', 0); b.writeUInt32LE(36 + samples.length * 2, 4); b.write('WAVE', 8)
|
||||||
|
b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22)
|
||||||
|
b.writeUInt32LE(RATE, 24); b.writeUInt32LE(RATE * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34)
|
||||||
|
b.write('data', 36); b.writeUInt32LE(samples.length * 2, 40)
|
||||||
|
samples.forEach((v, i) => b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, v)) * 32767), 44 + i * 2))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
const sounds = {
|
||||||
|
move: tone(0.035, 2400, 2200, { gain: 0.18, harm: 0 }), // petit tic
|
||||||
|
select: mix(tone(0.07, 660, 680, { gain: 0.32 }), tone(0.09, 990, 1000, { gain: 0.28, delay: 0.045 })), // deux notes qui montent
|
||||||
|
back: mix(tone(0.07, 740, 720, { gain: 0.28 }), tone(0.09, 494, 480, { gain: 0.26, delay: 0.045 })), // deux notes qui descendent
|
||||||
|
error: mix(tone(0.08, 180, 170, { gain: 0.35, harm: 0.4 }), tone(0.08, 180, 170, { gain: 0.35, harm: 0.4, delay: 0.11 })),
|
||||||
|
launch: mix(tone(0.45, 330, 1320, { gain: 0.3, attack: 0.02 }), tone(0.5, 495, 1980, { gain: 0.12, attack: 0.05, delay: 0.06 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(out, { recursive: true })
|
||||||
|
for (const [name, s] of Object.entries(sounds)) writeFileSync(join(out, name + '.wav'), wav(s))
|
||||||
|
console.log('sons écrits dans', out)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue