temporary
This commit is contained in:
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
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>bytesample</title>
|
||||||
|
</head>
|
||||||
|
<body class="m-0 p-0">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
package.json
Normal file
37
package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "bytesample",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.44.0",
|
||||||
|
"vite": "npm:rolldown-vite@7.1.12"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.1.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
2463
pnpm-lock.yaml
generated
Normal file
2463
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
173
src/App.tsx
Normal file
173
src/App.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
import { BytebeatGenerator } from './lib/bytebeat'
|
||||||
|
import { generateFormulaGrid } from './utils/bytebeatFormulas'
|
||||||
|
import { BytebeatTile } from './components/BytebeatTile'
|
||||||
|
import { EffectsBar } from './components/EffectsBar'
|
||||||
|
import { getDefaultEffectValues } from './config/effects'
|
||||||
|
import type { EffectValues } from './types/effects'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [formulas, setFormulas] = useState<string[][]>(() => generateFormulaGrid(100, 2))
|
||||||
|
const [playing, setPlaying] = useState<string | null>(null)
|
||||||
|
const [queued, setQueued] = useState<string | null>(null)
|
||||||
|
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
||||||
|
const [downloading, setDownloading] = useState(false)
|
||||||
|
const [effectValues, setEffectValues] = useState<EffectValues>(getDefaultEffectValues())
|
||||||
|
const generatorRef = useRef<BytebeatGenerator | null>(null)
|
||||||
|
const animationFrameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const handleRandom = () => {
|
||||||
|
if (generatorRef.current) {
|
||||||
|
generatorRef.current.stop()
|
||||||
|
setPlaying(null)
|
||||||
|
}
|
||||||
|
setFormulas(generateFormulaGrid(100, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const playFormula = (formula: string, id: string) => {
|
||||||
|
if (!generatorRef.current) {
|
||||||
|
generatorRef.current = new BytebeatGenerator({ duration: 30 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
generatorRef.current.stop()
|
||||||
|
generatorRef.current.setFormula(formula)
|
||||||
|
generatorRef.current.setEffects(effectValues)
|
||||||
|
generatorRef.current.play()
|
||||||
|
setPlaying(id)
|
||||||
|
setQueued(null)
|
||||||
|
startPlaybackTracking()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play formula:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPlaybackTracking = () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (generatorRef.current) {
|
||||||
|
const position = generatorRef.current.getPlaybackPosition()
|
||||||
|
setPlaybackPosition(position)
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updatePosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTileClick = (formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
||||||
|
const id = `${row}-${col}`
|
||||||
|
|
||||||
|
if (playing === id) {
|
||||||
|
generatorRef.current?.stop()
|
||||||
|
setPlaying(null)
|
||||||
|
setQueued(null)
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current)
|
||||||
|
animationFrameRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDoubleClick || playing === null) {
|
||||||
|
playFormula(formula, id)
|
||||||
|
} else {
|
||||||
|
setQueued(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTileDoubleClick = (formula: string, row: number, col: number) => {
|
||||||
|
handleTileClick(formula, row, col, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleEffectChange = (parameterId: string, value: number) => {
|
||||||
|
setEffectValues(prev => {
|
||||||
|
const newValues = { ...prev, [parameterId]: value }
|
||||||
|
if (generatorRef.current) {
|
||||||
|
generatorRef.current.setEffects(newValues)
|
||||||
|
}
|
||||||
|
return newValues
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadAll = async () => {
|
||||||
|
setDownloading(true)
|
||||||
|
const zip = new JSZip()
|
||||||
|
const gen = new BytebeatGenerator({ duration: 10 })
|
||||||
|
|
||||||
|
formulas.forEach((row, i) => {
|
||||||
|
row.forEach((formula, j) => {
|
||||||
|
try {
|
||||||
|
gen.setFormula(formula)
|
||||||
|
const blob = gen.exportWAV(8)
|
||||||
|
zip.file(`bytebeat_${i}_${j}.wav`, blob)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate ${i}_${j}:`, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = await zip.generateAsync({ type: 'blob' })
|
||||||
|
const url = URL.createObjectURL(content)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'bytebeats.zip'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
gen.dispose()
|
||||||
|
setDownloading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
||||||
|
<header className="bg-black border-b-2 border-white flex items-center justify-between px-6 py-3">
|
||||||
|
<h1 className="font-mono text-sm tracking-[0.3em] text-white">BYTEBEAT</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleRandom}
|
||||||
|
className="px-6 py-2 bg-white text-black font-mono text-[11px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
||||||
|
>
|
||||||
|
RANDOM
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadAll}
|
||||||
|
disabled={downloading}
|
||||||
|
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{downloading ? 'DOWNLOADING...' : 'DOWNLOAD ALL'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px] overflow-auto">
|
||||||
|
{formulas.map((row, i) =>
|
||||||
|
row.map((formula, j) => {
|
||||||
|
const id = `${i}-${j}`
|
||||||
|
return (
|
||||||
|
<BytebeatTile
|
||||||
|
key={id}
|
||||||
|
formula={formula}
|
||||||
|
row={i}
|
||||||
|
col={j}
|
||||||
|
isPlaying={playing === id}
|
||||||
|
isQueued={queued === id}
|
||||||
|
playbackPosition={playing === id ? playbackPosition : 0}
|
||||||
|
onPlay={handleTileClick}
|
||||||
|
onDoubleClick={handleTileDoubleClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EffectsBar values={effectValues} onChange={handleEffectChange} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
56
src/components/BytebeatTile.tsx
Normal file
56
src/components/BytebeatTile.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { BytebeatGenerator } from '../lib/bytebeat'
|
||||||
|
|
||||||
|
interface BytebeatTileProps {
|
||||||
|
formula: string
|
||||||
|
row: number
|
||||||
|
col: number
|
||||||
|
isPlaying: boolean
|
||||||
|
isQueued: boolean
|
||||||
|
playbackPosition: number
|
||||||
|
onPlay: (formula: string, row: number, col: number) => void
|
||||||
|
onDoubleClick: (formula: string, row: number, col: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, playbackPosition, onPlay, onDoubleClick }: BytebeatTileProps) {
|
||||||
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const gen = new BytebeatGenerator({ duration: 10 })
|
||||||
|
try {
|
||||||
|
gen.setFormula(formula)
|
||||||
|
gen.downloadWAV(`bytebeat_${row}_${col}.wav`, 8)
|
||||||
|
gen.dispose()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to download ${row}_${col}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onPlay(formula, row, col)}
|
||||||
|
onDoubleClick={() => onDoubleClick(formula, row, col)}
|
||||||
|
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-3 cursor-pointer overflow-hidden ${
|
||||||
|
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : 'bg-black text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPlaying && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 bg-black opacity-10 transition-all duration-75 ease-linear"
|
||||||
|
style={{ width: `${playbackPosition * 100}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="text-xs break-all font-light flex-1 relative z-10">
|
||||||
|
{formula}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={handleDownload}
|
||||||
|
className={`px-3 py-1 text-[10px] tracking-[0.15em] border transition-all duration-150 cursor-pointer hover:scale-105 flex-shrink-0 relative z-10 ${
|
||||||
|
isPlaying
|
||||||
|
? 'bg-black text-white border-black'
|
||||||
|
: 'bg-white text-black border-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
DL
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/components/EffectsBar.tsx
Normal file
31
src/components/EffectsBar.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Slider } from './Slider'
|
||||||
|
import { EFFECTS } from '../config/effects'
|
||||||
|
import type { EffectValues } from '../types/effects'
|
||||||
|
|
||||||
|
interface EffectsBarProps {
|
||||||
|
values: EffectValues
|
||||||
|
onChange: (parameterId: string, value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EffectsBar({ values, onChange }: EffectsBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-black border-t-2 border-white px-6 py-4">
|
||||||
|
<div className="grid grid-cols-4 gap-6">
|
||||||
|
{EFFECTS.flatMap(effect =>
|
||||||
|
effect.parameters.map(param => (
|
||||||
|
<Slider
|
||||||
|
key={param.id}
|
||||||
|
label={param.label}
|
||||||
|
value={values[param.id] ?? param.default}
|
||||||
|
min={param.min}
|
||||||
|
max={param.max}
|
||||||
|
step={param.step}
|
||||||
|
unit={param.unit}
|
||||||
|
onChange={(value) => onChange(param.id, value)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/components/Slider.tsx
Normal file
33
src/components/Slider.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
interface SliderProps {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step: number
|
||||||
|
unit?: string
|
||||||
|
onChange: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Slider({ label, value, min, max, step, unit, onChange }: SliderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<label className="font-mono text-[10px] tracking-[0.2em] text-white">
|
||||||
|
{label.toUpperCase()}
|
||||||
|
</label>
|
||||||
|
<span className="font-mono text-[10px] text-white">
|
||||||
|
{value}{unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="w-full h-[2px] bg-white appearance-none cursor-pointer slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/config/effects.ts
Normal file
67
src/config/effects.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import type { EffectConfig } from '../types/effects'
|
||||||
|
|
||||||
|
export const EFFECTS: EffectConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'reverb',
|
||||||
|
name: 'Reverb',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
id: 'reverbWetDry',
|
||||||
|
label: 'Reverb',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
default: 0,
|
||||||
|
step: 1,
|
||||||
|
unit: '%'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delay',
|
||||||
|
name: 'Ping Pong Delay',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
id: 'delayTime',
|
||||||
|
label: 'Time',
|
||||||
|
min: 0,
|
||||||
|
max: 1000,
|
||||||
|
default: 250,
|
||||||
|
step: 10,
|
||||||
|
unit: 'ms'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delayFeedback',
|
||||||
|
label: 'Feedback',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
default: 50,
|
||||||
|
step: 1,
|
||||||
|
unit: '%'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tbd',
|
||||||
|
name: 'TBD',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
id: 'tbdParam',
|
||||||
|
label: 'TBD',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
default: 0,
|
||||||
|
step: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getDefaultEffectValues(): Record<string, number> {
|
||||||
|
const defaults: Record<string, number> = {}
|
||||||
|
EFFECTS.forEach(effect => {
|
||||||
|
effect.parameters.forEach(param => {
|
||||||
|
defaults[param.id] = param.default
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
34
src/index.css
Normal file
34
src/index.css
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-track {
|
||||||
|
background: #fff;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-track {
|
||||||
|
background: #fff;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: #fff;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
background: #fff;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
154
src/lib/bytebeat/BytebeatGenerator.ts
Normal file
154
src/lib/bytebeat/BytebeatGenerator.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import type { BytebeatOptions, BitDepth } from './types'
|
||||||
|
import { encodeWAV } from './wavEncoder'
|
||||||
|
import { EffectsChain } from './EffectsChain'
|
||||||
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
|
||||||
|
export class BytebeatGenerator {
|
||||||
|
private sampleRate: number
|
||||||
|
private duration: number
|
||||||
|
private formula: string | null = null
|
||||||
|
private compiledFormula: ((t: number) => number) | null = null
|
||||||
|
private audioBuffer: Float32Array | null = null
|
||||||
|
private audioContext: AudioContext | null = null
|
||||||
|
private sourceNode: AudioBufferSourceNode | null = null
|
||||||
|
private effectsChain: EffectsChain | null = null
|
||||||
|
private effectValues: EffectValues = {}
|
||||||
|
private startTime: number = 0
|
||||||
|
private pauseTime: number = 0
|
||||||
|
private isLooping: boolean = true
|
||||||
|
|
||||||
|
constructor(options: BytebeatOptions = {}) {
|
||||||
|
this.sampleRate = options.sampleRate ?? 8000
|
||||||
|
this.duration = options.duration ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormula(formula: string): void {
|
||||||
|
this.formula = formula
|
||||||
|
try {
|
||||||
|
this.compiledFormula = new Function('t', `return ${formula}`) as (t: number) => number
|
||||||
|
this.audioBuffer = null
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid formula: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(): Float32Array {
|
||||||
|
if (!this.compiledFormula) {
|
||||||
|
throw new Error('No formula set. Call setFormula() first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const numSamples = Math.floor(this.sampleRate * this.duration)
|
||||||
|
const buffer = new Float32Array(numSamples)
|
||||||
|
|
||||||
|
for (let t = 0; t < numSamples; t++) {
|
||||||
|
try {
|
||||||
|
const value = this.compiledFormula(t)
|
||||||
|
const byteValue = value & 0xFF
|
||||||
|
buffer[t] = (byteValue - 128) / 128
|
||||||
|
} catch (error) {
|
||||||
|
buffer[t] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioBuffer = buffer
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffects(values: EffectValues): void {
|
||||||
|
this.effectValues = values
|
||||||
|
if (this.effectsChain) {
|
||||||
|
this.effectsChain.updateEffects(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackPosition(): number {
|
||||||
|
if (!this.audioContext || !this.sourceNode || this.startTime === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const elapsed = this.audioContext.currentTime - this.startTime
|
||||||
|
return (elapsed % this.duration) / this.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
play(): void {
|
||||||
|
if (!this.audioBuffer) {
|
||||||
|
this.generate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.audioContext) {
|
||||||
|
this.audioContext = new AudioContext({ sampleRate: this.sampleRate })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.effectsChain) {
|
||||||
|
this.effectsChain = new EffectsChain(this.audioContext)
|
||||||
|
this.effectsChain.updateEffects(this.effectValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sourceNode) {
|
||||||
|
this.sourceNode.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = this.audioContext.createBuffer(1, this.audioBuffer!.length, this.sampleRate)
|
||||||
|
audioBuffer.getChannelData(0).set(this.audioBuffer!)
|
||||||
|
|
||||||
|
this.sourceNode = this.audioContext.createBufferSource()
|
||||||
|
this.sourceNode.buffer = audioBuffer
|
||||||
|
this.sourceNode.loop = this.isLooping
|
||||||
|
this.sourceNode.connect(this.effectsChain.getInputNode())
|
||||||
|
this.effectsChain.getOutputNode().connect(this.audioContext.destination)
|
||||||
|
|
||||||
|
if (this.pauseTime > 0) {
|
||||||
|
this.sourceNode.start(0, this.pauseTime)
|
||||||
|
this.startTime = this.audioContext.currentTime - this.pauseTime
|
||||||
|
this.pauseTime = 0
|
||||||
|
} else {
|
||||||
|
this.sourceNode.start(0)
|
||||||
|
this.startTime = this.audioContext.currentTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
if (this.sourceNode && this.audioContext) {
|
||||||
|
this.pauseTime = this.audioContext.currentTime - this.startTime
|
||||||
|
this.sourceNode.stop()
|
||||||
|
this.sourceNode = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.sourceNode) {
|
||||||
|
this.sourceNode.stop()
|
||||||
|
this.sourceNode = null
|
||||||
|
}
|
||||||
|
this.startTime = 0
|
||||||
|
this.pauseTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
exportWAV(bitDepth: BitDepth = 8): Blob {
|
||||||
|
if (!this.audioBuffer) {
|
||||||
|
this.generate()
|
||||||
|
}
|
||||||
|
return encodeWAV(this.audioBuffer!, this.sampleRate, bitDepth)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadWAV(filename: string = 'bytebeat.wav', bitDepth: BitDepth = 8): void {
|
||||||
|
const blob = this.exportWAV(bitDepth)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stop()
|
||||||
|
if (this.effectsChain) {
|
||||||
|
this.effectsChain.dispose()
|
||||||
|
this.effectsChain = null
|
||||||
|
}
|
||||||
|
if (this.audioContext) {
|
||||||
|
this.audioContext.close()
|
||||||
|
this.audioContext = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/lib/bytebeat/EffectsChain.ts
Normal file
111
src/lib/bytebeat/EffectsChain.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import type { EffectValues } from '../../types/effects'
|
||||||
|
|
||||||
|
export class EffectsChain {
|
||||||
|
private audioContext: AudioContext
|
||||||
|
private inputNode: GainNode
|
||||||
|
private outputNode: GainNode
|
||||||
|
|
||||||
|
private delayNode: DelayNode
|
||||||
|
private delayFeedbackNode: GainNode
|
||||||
|
private delayWetNode: GainNode
|
||||||
|
private delayDryNode: GainNode
|
||||||
|
|
||||||
|
private convolverNode: ConvolverNode
|
||||||
|
private reverbWetNode: GainNode
|
||||||
|
private reverbDryNode: GainNode
|
||||||
|
|
||||||
|
private tbdNode: GainNode
|
||||||
|
|
||||||
|
constructor(audioContext: AudioContext) {
|
||||||
|
this.audioContext = audioContext
|
||||||
|
|
||||||
|
this.inputNode = audioContext.createGain()
|
||||||
|
this.outputNode = audioContext.createGain()
|
||||||
|
|
||||||
|
this.delayNode = audioContext.createDelay(2.0)
|
||||||
|
this.delayFeedbackNode = audioContext.createGain()
|
||||||
|
this.delayWetNode = audioContext.createGain()
|
||||||
|
this.delayDryNode = audioContext.createGain()
|
||||||
|
|
||||||
|
this.convolverNode = audioContext.createConvolver()
|
||||||
|
this.reverbWetNode = audioContext.createGain()
|
||||||
|
this.reverbDryNode = audioContext.createGain()
|
||||||
|
|
||||||
|
this.tbdNode = audioContext.createGain()
|
||||||
|
|
||||||
|
this.setupChain()
|
||||||
|
this.generateImpulseResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupChain(): void {
|
||||||
|
this.delayDryNode.gain.value = 1
|
||||||
|
this.delayWetNode.gain.value = 0
|
||||||
|
|
||||||
|
this.inputNode.connect(this.delayDryNode)
|
||||||
|
this.inputNode.connect(this.delayNode)
|
||||||
|
this.delayNode.connect(this.delayFeedbackNode)
|
||||||
|
this.delayFeedbackNode.connect(this.delayNode)
|
||||||
|
this.delayNode.connect(this.delayWetNode)
|
||||||
|
|
||||||
|
this.delayDryNode.connect(this.reverbDryNode)
|
||||||
|
this.delayWetNode.connect(this.reverbDryNode)
|
||||||
|
|
||||||
|
this.delayDryNode.connect(this.convolverNode)
|
||||||
|
this.delayWetNode.connect(this.convolverNode)
|
||||||
|
this.convolverNode.connect(this.reverbWetNode)
|
||||||
|
|
||||||
|
this.reverbDryNode.connect(this.tbdNode)
|
||||||
|
this.reverbWetNode.connect(this.tbdNode)
|
||||||
|
|
||||||
|
this.tbdNode.connect(this.outputNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateImpulseResponse(): void {
|
||||||
|
const length = this.audioContext.sampleRate * 2
|
||||||
|
const impulse = this.audioContext.createBuffer(2, length, this.audioContext.sampleRate)
|
||||||
|
const left = impulse.getChannelData(0)
|
||||||
|
const right = impulse.getChannelData(1)
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const n = length - i
|
||||||
|
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
||||||
|
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.convolverNode.buffer = impulse
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEffects(values: EffectValues): void {
|
||||||
|
const reverbWet = values.reverbWetDry / 100
|
||||||
|
this.reverbWetNode.gain.value = reverbWet
|
||||||
|
this.reverbDryNode.gain.value = 1 - reverbWet
|
||||||
|
|
||||||
|
this.delayNode.delayTime.value = values.delayTime / 1000
|
||||||
|
this.delayFeedbackNode.gain.value = values.delayFeedback / 100
|
||||||
|
|
||||||
|
const delayAmount = Math.min(values.delayTime / 1000, 0.5)
|
||||||
|
this.delayWetNode.gain.value = delayAmount
|
||||||
|
this.delayDryNode.gain.value = 1 - delayAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputNode(): AudioNode {
|
||||||
|
return this.inputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputNode(): AudioNode {
|
||||||
|
return this.outputNode
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.inputNode.disconnect()
|
||||||
|
this.outputNode.disconnect()
|
||||||
|
this.delayNode.disconnect()
|
||||||
|
this.delayFeedbackNode.disconnect()
|
||||||
|
this.delayWetNode.disconnect()
|
||||||
|
this.delayDryNode.disconnect()
|
||||||
|
this.convolverNode.disconnect()
|
||||||
|
this.reverbWetNode.disconnect()
|
||||||
|
this.reverbDryNode.disconnect()
|
||||||
|
this.tbdNode.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/lib/bytebeat/index.ts
Normal file
15
src/lib/bytebeat/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export { BytebeatGenerator } from './BytebeatGenerator'
|
||||||
|
export type { BytebeatOptions, BytebeatState, BitDepth } from './types'
|
||||||
|
|
||||||
|
export const EXAMPLE_FORMULAS = {
|
||||||
|
classic: 't * ((t>>12)|(t>>8))&(63&(t>>4))',
|
||||||
|
melody: 't>>6^t&0x25|t+(t^t>>11)',
|
||||||
|
simple: 't & (t>>4)|(t>>8)',
|
||||||
|
harmony: '(t>>10&42)*t',
|
||||||
|
glitch: 't*(t>>8*((t>>15)|(t>>8))&(20|(t>>19)*5>>t|(t>>3)))',
|
||||||
|
drums: '((t>>10)&42)*(t>>8)',
|
||||||
|
ambient: '(t*5&t>>7)|(t*3&t>>10)',
|
||||||
|
noise: 't>>6&1?t>>5:-t>>4',
|
||||||
|
arpeggio: 't*(((t>>9)|(t>>13))&25&t>>6)',
|
||||||
|
chaos: 't*(t^t+(t>>15|1))',
|
||||||
|
} as const
|
||||||
12
src/lib/bytebeat/types.ts
Normal file
12
src/lib/bytebeat/types.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export interface BytebeatOptions {
|
||||||
|
sampleRate?: number
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BytebeatState {
|
||||||
|
isPlaying: boolean
|
||||||
|
isPaused: boolean
|
||||||
|
currentTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BitDepth = 8 | 16
|
||||||
49
src/lib/bytebeat/wavEncoder.ts
Normal file
49
src/lib/bytebeat/wavEncoder.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { BitDepth } from './types'
|
||||||
|
|
||||||
|
function writeString(view: DataView, offset: number, str: string): void {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
view.setUint8(offset + i, str.charCodeAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeWAV(samples: Float32Array, sampleRate: number, bitDepth: BitDepth): Blob {
|
||||||
|
const numChannels = 1
|
||||||
|
const bytesPerSample = bitDepth / 8
|
||||||
|
const blockAlign = numChannels * bytesPerSample
|
||||||
|
const dataSize = samples.length * bytesPerSample
|
||||||
|
const buffer = new ArrayBuffer(44 + dataSize)
|
||||||
|
const view = new DataView(buffer)
|
||||||
|
|
||||||
|
writeString(view, 0, 'RIFF')
|
||||||
|
view.setUint32(4, 36 + dataSize, true)
|
||||||
|
writeString(view, 8, 'WAVE')
|
||||||
|
|
||||||
|
writeString(view, 12, 'fmt ')
|
||||||
|
view.setUint32(16, 16, true)
|
||||||
|
view.setUint16(20, 1, true)
|
||||||
|
view.setUint16(22, numChannels, true)
|
||||||
|
view.setUint32(24, sampleRate, true)
|
||||||
|
view.setUint32(28, sampleRate * blockAlign, true)
|
||||||
|
view.setUint16(32, blockAlign, true)
|
||||||
|
view.setUint16(34, bitDepth, true)
|
||||||
|
|
||||||
|
writeString(view, 36, 'data')
|
||||||
|
view.setUint32(40, dataSize, true)
|
||||||
|
|
||||||
|
let offset = 44
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const sample = Math.max(-1, Math.min(1, samples[i]))
|
||||||
|
|
||||||
|
if (bitDepth === 8) {
|
||||||
|
const value = Math.floor((sample + 1) * 127.5)
|
||||||
|
view.setUint8(offset, value)
|
||||||
|
offset += 1
|
||||||
|
} else {
|
||||||
|
const value = Math.floor(sample * 32767)
|
||||||
|
view.setInt16(offset, value, true)
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([buffer], { type: 'audio/wav' })
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
17
src/types/effects.ts
Normal file
17
src/types/effects.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export interface EffectParameter {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
default: number
|
||||||
|
step: number
|
||||||
|
unit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
parameters: EffectParameter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EffectValues = Record<string, number>
|
||||||
43
src/utils/bytebeatFormulas.ts
Normal file
43
src/utils/bytebeatFormulas.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
const operators = ['&', '|', '^', '+', '-', '*', '%']
|
||||||
|
const shifts = ['>>', '<<']
|
||||||
|
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 25, 32, 42, 63, 64, 127, 128, 255]
|
||||||
|
|
||||||
|
function randomElement<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTerm(depth: number = 0): string {
|
||||||
|
if (depth > 2 || Math.random() < 0.3) {
|
||||||
|
const shift = randomElement(shifts)
|
||||||
|
const num = randomElement(numbers)
|
||||||
|
return `(t${shift}${num})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const op = randomElement(operators)
|
||||||
|
const left = generateTerm(depth + 1)
|
||||||
|
const right = Math.random() < 0.5 ? generateTerm(depth + 1) : randomElement(numbers).toString()
|
||||||
|
return `(${left}${op}${right})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomFormula(): string {
|
||||||
|
const numTerms = Math.floor(Math.random() * 3) + 1
|
||||||
|
const terms: string[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < numTerms; i++) {
|
||||||
|
terms.push(generateTerm())
|
||||||
|
}
|
||||||
|
|
||||||
|
return terms.join(randomElement(operators))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFormulaGrid(rows: number, cols: number): string[][] {
|
||||||
|
const grid: string[][] = []
|
||||||
|
for (let i = 0; i < rows; i++) {
|
||||||
|
const row: string[] = []
|
||||||
|
for (let j = 0; j < cols; j++) {
|
||||||
|
row.push(generateRandomFormula())
|
||||||
|
}
|
||||||
|
grid.push(row)
|
||||||
|
}
|
||||||
|
return grid
|
||||||
|
}
|
||||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": [],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user