From b804a85f4dc4ec2b15f2ec7e80740091f27ad257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Tue, 30 Sep 2025 12:21:27 +0200 Subject: [PATCH] ok --- package.json | 4 + pnpm-lock.yaml | 49 +++++++++ src/App.tsx | 95 +++++++++++------ src/components/BytebeatTile.tsx | 5 +- src/components/EngineControls.tsx | 47 +++++++++ src/config/effects.ts | 66 ++++++++++++ src/lib/bytebeat/BytebeatGenerator.ts | 30 ++++++ src/lib/bytebeat/EffectsChain.ts | 10 +- src/stores/settings.ts | 9 ++ src/utils/bytebeatFormulas.ts | 146 ++++++++++++++++++++++---- 10 files changed, 403 insertions(+), 58 deletions(-) create mode 100644 src/components/EngineControls.tsx create mode 100644 src/stores/settings.ts diff --git a/package.json b/package.json index aa1aede7..0c5a6f44 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@nanostores/persistent": "^1.1.0", + "@nanostores/react": "^1.0.0", "jszip": "^3.10.1", + "lucide-react": "^0.544.0", + "nanostores": "^1.0.1", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dbb4a99..79483537 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,21 @@ importers: .: dependencies: + '@nanostores/persistent': + specifier: ^1.1.0 + version: 1.1.0(nanostores@1.0.1) + '@nanostores/react': + specifier: ^1.0.0 + version: 1.0.0(nanostores@1.0.1)(react@19.1.1) jszip: specifier: ^3.10.1 version: 3.10.1 + lucide-react: + specifier: ^0.544.0 + version: 0.544.0(react@19.1.1) + nanostores: + specifier: ^1.0.1 + version: 1.0.1 react: specifier: ^19.1.1 version: 19.1.1 @@ -236,6 +248,19 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@nanostores/persistent@1.1.0': + resolution: {integrity: sha512-e6vfv7H99VkCfSoNTR/qNVMj6vXwWcsEL+LCQQamej5GK9iDefKxPCJjdOpBi1p4lNCFIQ+9VjYF1spvvc2p6A==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 + + '@nanostores/react@1.0.0': + resolution: {integrity: sha512-eDduyNy+lbQJMg6XxZ/YssQqF6b4OXMFEZMYKPJCCmBevp1lg0g+4ZRi94qGHirMtsNfAWKNwsjOhC+q1gvC+A==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 + react: '>=18.0.0' + '@napi-rs/wasm-runtime@1.0.5': resolution: {integrity: sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==} @@ -1013,6 +1038,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.544.0: + resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} @@ -1047,6 +1077,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.0.1: + resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==} + engines: {node: ^20.0.0 || >=22.0.0} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1515,6 +1549,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@nanostores/persistent@1.1.0(nanostores@1.0.1)': + dependencies: + nanostores: 1.0.1 + + '@nanostores/react@1.0.0(nanostores@1.0.1)(react@19.1.1)': + dependencies: + nanostores: 1.0.1 + react: 19.1.1 + '@napi-rs/wasm-runtime@1.0.5': dependencies: '@emnapi/core': 1.5.0 @@ -2220,6 +2263,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.544.0(react@19.1.1): + dependencies: + react: 19.1.1 + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2249,6 +2296,8 @@ snapshots: nanoid@3.3.11: {} + nanostores@1.0.1: {} + natural-compare@1.4.0: {} node-releases@2.0.21: {} diff --git a/src/App.tsx b/src/App.tsx index 0fd652fe..3364dfe9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,46 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' +import { useStore } from '@nanostores/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 { EngineControls } from './components/EngineControls' +import { getSampleRateFromIndex } from './config/effects' +import { engineSettings, effectSettings } from './stores/settings' import type { EffectValues } from './types/effects' function App() { - const [formulas, setFormulas] = useState(() => generateFormulaGrid(100, 2)) + const engineValues = useStore(engineSettings) + const effectValues = useStore(effectSettings) + + const [formulas, setFormulas] = useState(() => + generateFormulaGrid(100, 2, engineValues.complexity) + ) const [playing, setPlaying] = useState(null) const [queued, setQueued] = useState(null) const [playbackPosition, setPlaybackPosition] = useState(0) const [downloading, setDownloading] = useState(false) - const [effectValues, setEffectValues] = useState(getDefaultEffectValues()) const generatorRef = useRef(null) const animationFrameRef = useRef(null) + useEffect(() => { + effectSettings.setKey('masterVolume', engineValues.masterVolume) + }, [engineValues.masterVolume]) + const handleRandom = () => { - if (generatorRef.current) { - generatorRef.current.stop() - setPlaying(null) - } - setFormulas(generateFormulaGrid(100, 2)) + setFormulas(generateFormulaGrid(100, 2, engineValues.complexity)) + setQueued(null) } const playFormula = (formula: string, id: string) => { + const sampleRate = getSampleRateFromIndex(engineValues.sampleRate) + const duration = engineValues.loopDuration + if (!generatorRef.current) { - generatorRef.current = new BytebeatGenerator({ duration: 30 }) + generatorRef.current = new BytebeatGenerator({ sampleRate, duration }) + } else { + generatorRef.current.updateOptions({ sampleRate, duration }) } try { @@ -76,6 +89,14 @@ function App() { playFormula(formula, id) } else { setQueued(id) + if (generatorRef.current) { + generatorRef.current.scheduleNextTrack(() => { + const queuedFormula = formulas.flat()[parseInt(id.split('-')[0]) * 2 + parseInt(id.split('-')[1])] + if (queuedFormula) { + playFormula(queuedFormula, id) + } + }) + } } } @@ -84,14 +105,19 @@ function App() { } + const handleEngineChange = (parameterId: string, value: number) => { + engineSettings.setKey(parameterId, value) + + if (parameterId === 'masterVolume' && generatorRef.current) { + generatorRef.current.setEffects(effectValues) + } + } + const handleEffectChange = (parameterId: string, value: number) => { - setEffectValues(prev => { - const newValues = { ...prev, [parameterId]: value } - if (generatorRef.current) { - generatorRef.current.setEffects(newValues) - } - return newValues - }) + effectSettings.setKey(parameterId, value) + if (generatorRef.current) { + generatorRef.current.setEffects(effectValues) + } } const handleDownloadAll = async () => { @@ -125,23 +151,26 @@ function App() { return (
-
-

BYTEBEAT

-
- - +
+
+

BYTEBEAT

+
+ + +
+
diff --git a/src/components/BytebeatTile.tsx b/src/components/BytebeatTile.tsx index 2daa67f7..24719bc6 100644 --- a/src/components/BytebeatTile.tsx +++ b/src/components/BytebeatTile.tsx @@ -1,4 +1,5 @@ import { BytebeatGenerator } from '../lib/bytebeat' +import { Download } from 'lucide-react' interface BytebeatTileProps { formula: string @@ -43,13 +44,13 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, playbackP
- DL +
) diff --git a/src/components/EngineControls.tsx b/src/components/EngineControls.tsx new file mode 100644 index 00000000..2370bf0b --- /dev/null +++ b/src/components/EngineControls.tsx @@ -0,0 +1,47 @@ +import { ENGINE_CONTROLS, SAMPLE_RATES, getComplexityLabel } from '../config/effects' +import type { EffectValues } from '../types/effects' + +interface EngineControlsProps { + values: EffectValues + onChange: (parameterId: string, value: number) => void +} + +export function EngineControls({ values, onChange }: EngineControlsProps) { + const formatValue = (id: string, value: number): string => { + switch (id) { + case 'sampleRate': + return `${SAMPLE_RATES[value]}Hz` + case 'complexity': + return getComplexityLabel(value) + default: + const param = ENGINE_CONTROLS[0].parameters.find(p => p.id === id) + return `${value}${param?.unit || ''}` + } + } + + return ( +
+ {ENGINE_CONTROLS[0].parameters.map(param => ( +
+
+ + + {formatValue(param.id, values[param.id] ?? param.default)} + +
+ onChange(param.id, Number(e.target.value))} + className="w-full h-[2px] bg-white appearance-none cursor-pointer" + /> +
+ ))} +
+ ) +} \ No newline at end of file diff --git a/src/config/effects.ts b/src/config/effects.ts index 446ffb7e..612904b1 100644 --- a/src/config/effects.ts +++ b/src/config/effects.ts @@ -1,5 +1,50 @@ import type { EffectConfig } from '../types/effects' +export const ENGINE_CONTROLS: EffectConfig[] = [ + { + id: 'engine', + name: 'Engine', + parameters: [ + { + id: 'sampleRate', + label: 'Sample Rate', + min: 0, + max: 3, + default: 1, + step: 1, + unit: '' + }, + { + id: 'loopDuration', + label: 'Loop', + min: 2, + max: 8, + default: 4, + step: 2, + unit: 's' + }, + { + id: 'complexity', + label: 'Complexity', + min: 0, + max: 2, + default: 1, + step: 1, + unit: '' + }, + { + id: 'masterVolume', + label: 'Volume', + min: 0, + max: 100, + default: 75, + step: 1, + unit: '%' + } + ] + } +] + export const EFFECTS: EffectConfig[] = [ { id: 'reverb', @@ -64,4 +109,25 @@ export function getDefaultEffectValues(): Record { }) }) return defaults +} + +export function getDefaultEngineValues(): Record { + const defaults: Record = {} + ENGINE_CONTROLS.forEach(control => { + control.parameters.forEach(param => { + defaults[param.id] = param.default + }) + }) + return defaults +} + +export const SAMPLE_RATES = [4000, 8000, 16000, 22050] + +export function getSampleRateFromIndex(index: number): number { + return SAMPLE_RATES[index] || 8000 +} + +export function getComplexityLabel(index: number): string { + const labels = ['Simple', 'Medium', 'Complex'] + return labels[index] || 'Medium' } \ No newline at end of file diff --git a/src/lib/bytebeat/BytebeatGenerator.ts b/src/lib/bytebeat/BytebeatGenerator.ts index 9f0ab389..c09a65ee 100644 --- a/src/lib/bytebeat/BytebeatGenerator.ts +++ b/src/lib/bytebeat/BytebeatGenerator.ts @@ -22,6 +22,17 @@ export class BytebeatGenerator { this.duration = options.duration ?? 10 } + updateOptions(options: Partial): void { + if (options.sampleRate !== undefined) { + this.sampleRate = options.sampleRate + this.audioBuffer = null + } + if (options.duration !== undefined) { + this.duration = options.duration + this.audioBuffer = null + } + } + setFormula(formula: string): void { this.formula = formula try { @@ -106,6 +117,25 @@ export class BytebeatGenerator { } } + onLoopEnd(callback: () => void): void { + if (this.sourceNode && !this.sourceNode.loop) { + this.sourceNode.onended = callback + } + } + + setLooping(loop: boolean): void { + this.isLooping = loop + } + + scheduleNextTrack(callback: () => void): void { + if (this.audioContext && this.sourceNode) { + this.sourceNode.loop = false + this.sourceNode.onended = () => { + callback() + } + } + } + pause(): void { if (this.sourceNode && this.audioContext) { this.pauseTime = this.audioContext.currentTime - this.startTime diff --git a/src/lib/bytebeat/EffectsChain.ts b/src/lib/bytebeat/EffectsChain.ts index 461bd6f7..56d6d340 100644 --- a/src/lib/bytebeat/EffectsChain.ts +++ b/src/lib/bytebeat/EffectsChain.ts @@ -4,6 +4,7 @@ export class EffectsChain { private audioContext: AudioContext private inputNode: GainNode private outputNode: GainNode + private masterGainNode: GainNode private delayNode: DelayNode private delayFeedbackNode: GainNode @@ -20,6 +21,7 @@ export class EffectsChain { this.audioContext = audioContext this.inputNode = audioContext.createGain() + this.masterGainNode = audioContext.createGain() this.outputNode = audioContext.createGain() this.delayNode = audioContext.createDelay(2.0) @@ -57,7 +59,8 @@ export class EffectsChain { this.reverbDryNode.connect(this.tbdNode) this.reverbWetNode.connect(this.tbdNode) - this.tbdNode.connect(this.outputNode) + this.tbdNode.connect(this.masterGainNode) + this.masterGainNode.connect(this.outputNode) } private generateImpulseResponse(): void { @@ -86,6 +89,10 @@ export class EffectsChain { const delayAmount = Math.min(values.delayTime / 1000, 0.5) this.delayWetNode.gain.value = delayAmount this.delayDryNode.gain.value = 1 - delayAmount + + if (values.masterVolume !== undefined) { + this.masterGainNode.gain.value = values.masterVolume / 100 + } } getInputNode(): AudioNode { @@ -99,6 +106,7 @@ export class EffectsChain { dispose(): void { this.inputNode.disconnect() this.outputNode.disconnect() + this.masterGainNode.disconnect() this.delayNode.disconnect() this.delayFeedbackNode.disconnect() this.delayWetNode.disconnect() diff --git a/src/stores/settings.ts b/src/stores/settings.ts new file mode 100644 index 00000000..026d8c51 --- /dev/null +++ b/src/stores/settings.ts @@ -0,0 +1,9 @@ +import { persistentMap } from '@nanostores/persistent' +import { getDefaultEngineValues, getDefaultEffectValues } from '../config/effects' + +export const engineSettings = persistentMap>('engine:', getDefaultEngineValues()) + +export const effectSettings = persistentMap>('effects:', { + ...getDefaultEffectValues(), + masterVolume: 75 +}) \ No newline at end of file diff --git a/src/utils/bytebeatFormulas.ts b/src/utils/bytebeatFormulas.ts index 89567617..34b1926e 100644 --- a/src/utils/bytebeatFormulas.ts +++ b/src/utils/bytebeatFormulas.ts @@ -1,41 +1,143 @@ -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] +interface Template { + pattern: string + weight: number +} + +const SHIFT_LOW = [4, 5, 6, 7] +const SHIFT_MID = [8, 9, 10, 11, 12] +const SHIFT_HIGH = [13, 14, 15, 16] +const SHIFTS = [...SHIFT_LOW, ...SHIFT_MID, ...SHIFT_HIGH] + +const MASKS = [1, 2, 3, 4, 5, 7, 8, 11, 13, 15, 16, 31, 32, 42, 63, 64, 127, 128, 255] +const MULTIPLIERS = [2, 3, 5, 7, 11, 13] +const SMALL_NUMS = [1, 2, 3, 4, 5, 6, 7, 8] + +const TEMPLATES: Template[] = [ + { pattern: "t*(N&t>>S)", weight: 8 }, + { pattern: "t*((t>>S)&N)", weight: 8 }, + { pattern: "t*(N|(t>>S))", weight: 5 }, + { pattern: "(t*M&t>>S1)|(t*M2&t>>S2)", weight: 10 }, + { pattern: "t*(t>>S1|t>>S2)", weight: 10 }, + { pattern: "t*(t>>S1&t>>S2)", weight: 8 }, + { pattern: "(t>>S1)|(t>>S2)", weight: 7 }, + { pattern: "(t>>S1)&(t>>S2)", weight: 7 }, + { pattern: "t&t>>S", weight: 6 }, + { pattern: "(t&t>>S1)*(t>>S2|t>>S3)", weight: 8 }, + { pattern: "(t>>S)&N", weight: 5 }, + { pattern: "t*(t>>S)", weight: 4 }, + { pattern: "(t*M)&(t>>S)", weight: 6 }, + { pattern: "t^(t>>S)", weight: 5 }, + { pattern: "(t>>S1)^(t>>S2)", weight: 6 }, + { pattern: "t*(N&(t>>S1|t>>S2))", weight: 7 }, + { pattern: "((t>>S1)&N)*(t>>S2)", weight: 6 }, + { pattern: "t*((t>>S1)|(t>>S2))&N", weight: 7 }, + { pattern: "(t&(t>>S1))^(t>>S2)", weight: 5 }, + { pattern: "t/(t%(t>>S|t>>S2))", weight: 3 }, + { pattern: "t<<((t>>S1|t>>S2)^(t>>S3))", weight: 4 }, + { pattern: "(t>>S)%(N)", weight: 3 }, + { pattern: "t%(N)+(t>>S)", weight: 4 }, + { pattern: "(t*M&t>>S1)^(t>>S2)", weight: 5 }, + { pattern: "((t>>S1)|(t>>S2))&((t>>S3)|(t>>S4))", weight: 6 } +] + +const TOTAL_WEIGHT = TEMPLATES.reduce((sum, t) => sum + t.weight, 0) function randomElement(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})` +function pickTemplate(): Template { + let random = Math.random() * TOTAL_WEIGHT + for (const template of TEMPLATES) { + random -= template.weight + if (random <= 0) { + return template + } } - - 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})` + return TEMPLATES[0] } -export function generateRandomFormula(): string { - const numTerms = Math.floor(Math.random() * 3) + 1 - const terms: string[] = [] +function fillTemplate(pattern: string): string { + let formula = pattern - for (let i = 0; i < numTerms; i++) { - terms.push(generateTerm()) - } + const sMatches = formula.match(/S\d*/g) || [] + const uniqueShifts = [...new Set(sMatches)] + uniqueShifts.forEach(placeholder => { + const shift = randomElement(SHIFTS) + formula = formula.replace(new RegExp(placeholder, 'g'), shift.toString()) + }) - return terms.join(randomElement(operators)) + const nMatches = formula.match(/N\d*/g) || [] + const uniqueMasks = [...new Set(nMatches)] + uniqueMasks.forEach(placeholder => { + const mask = randomElement(MASKS) + formula = formula.replace(new RegExp(placeholder, 'g'), mask.toString()) + }) + + const mMatches = formula.match(/M\d*/g) || [] + const uniqueMults = [...new Set(mMatches)] + uniqueMults.forEach(placeholder => { + const mult = randomElement(MULTIPLIERS) + formula = formula.replace(new RegExp(placeholder, 'g'), mult.toString()) + }) + + return formula } -export function generateFormulaGrid(rows: number, cols: number): string[][] { +export function generateRandomFormula(complexity: number = 1): string { + const complexityWeights = [ + { simple: 0.6, medium: 0.3, complex: 0.1 }, + { simple: 0.3, medium: 0.5, complex: 0.2 }, + { simple: 0.1, medium: 0.3, complex: 0.6 } + ] + + const weights = complexityWeights[complexity] || complexityWeights[1] + + const filteredTemplates = TEMPLATES.map(t => { + const patternComplexity = (t.pattern.match(/[S|N|M]\d*/g) || []).length + let weight = t.weight + + if (patternComplexity <= 2) { + weight *= weights.simple + } else if (patternComplexity <= 4) { + weight *= weights.medium + } else { + weight *= weights.complex + } + + return { ...t, weight } + }) + + const totalWeight = filteredTemplates.reduce((sum, t) => sum + t.weight, 0) + let random = Math.random() * totalWeight + + let selectedTemplate = filteredTemplates[0] + for (const template of filteredTemplates) { + random -= template.weight + if (random <= 0) { + selectedTemplate = template + break + } + } + + let formula = fillTemplate(selectedTemplate.pattern) + + const extraLayerChance = complexity === 0 ? 0.05 : complexity === 1 ? 0.15 : 0.25 + if (Math.random() < extraLayerChance) { + const op = randomElement(['&', '|', '^']) + const num = randomElement(SMALL_NUMS) + formula = `(${formula})${op}${num}` + } + + return formula +} + +export function generateFormulaGrid(rows: number, cols: number, complexity: number = 1): string[][] { const grid: string[][] = [] for (let i = 0; i < rows; i++) { const row: string[] = [] for (let j = 0; j < cols; j++) { - row.push(generateRandomFormula()) + row.push(generateRandomFormula(complexity)) } grid.push(row) }