refactor: restructure to monorepo with npm workspaces (Phase 0)
Move frontend to packages/client/, server to packages/server/.
Root package.json uses npm workspaces to orchestrate both.
Structure:
reaktor/
packages/client/ (React + Vite + Tone.js frontend)
packages/server/ (static file server, future API)
dist/ (built output, shared)
docker-compose.yml (app + PostgreSQL for future backend)
- npm run dev → runs Vite dev server from client workspace
- npm run build → builds client, outputs to root dist/
- npm run start → runs server.js serving dist/
- Dockerfile updated for multi-stage monorepo build
- docker-compose.yml added with PostgreSQL service (ready for Phase 1)
- All imports and paths preserved, zero functionality change
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
193
packages/client/src/game/targetAudio.js
Normal file
193
packages/client/src/game/targetAudio.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* targetAudio.js — Plays the "target" sound for a puzzle level
|
||||
* Builds a temporary Tone.js graph from the level's target config
|
||||
*
|
||||
* Extended to support:
|
||||
* - Envelopes (amplitude shaping)
|
||||
* - LFO (modulation)
|
||||
* - Effects (delay, reverb, distortion)
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
|
||||
let _activeNodes = [];
|
||||
let _isPlaying = false;
|
||||
let _stopTimeout = null;
|
||||
let _loops = []; // Track Tone.Loop instances for cleanup
|
||||
|
||||
export function isTargetPlaying() {
|
||||
return _isPlaying;
|
||||
}
|
||||
|
||||
export async function playTarget(target) {
|
||||
if (_isPlaying) {
|
||||
stopTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
await Tone.start();
|
||||
_isPlaying = true;
|
||||
|
||||
const nodes = [];
|
||||
const output = new Tone.Gain(0.5).toDestination();
|
||||
nodes.push(output);
|
||||
|
||||
// Build effects chain (will connect to this)
|
||||
let effectChain = output;
|
||||
|
||||
// Effects array (in order: distortion → delay → reverb)
|
||||
if (target.effects && target.effects.length > 0) {
|
||||
const effectNodes = [];
|
||||
for (const effect of target.effects) {
|
||||
if (effect.type === 'distortion') {
|
||||
const distortion = new Tone.Distortion(effect.amount ?? 0.4);
|
||||
effectNodes.push(distortion);
|
||||
} else if (effect.type === 'delay') {
|
||||
const delay = new Tone.Delay(effect.time ?? 0.3);
|
||||
delay.feedback.value = effect.feedback ?? 0.3;
|
||||
delay.wet.value = effect.wet ?? 0.5;
|
||||
effectNodes.push(delay);
|
||||
} else if (effect.type === 'reverb') {
|
||||
const reverb = new Tone.Reverb(effect.decay ?? 2.5);
|
||||
reverb.wet.value = effect.wet ?? 0.5;
|
||||
effectNodes.push(reverb);
|
||||
}
|
||||
}
|
||||
|
||||
// Chain effects together, then to output
|
||||
if (effectNodes.length > 0) {
|
||||
for (let i = 0; i < effectNodes.length - 1; i++) {
|
||||
effectNodes[i].connect(effectNodes[i + 1]);
|
||||
}
|
||||
effectNodes[effectNodes.length - 1].connect(output);
|
||||
effectChain = effectNodes[0];
|
||||
nodes.push(...effectNodes);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional filter in the chain
|
||||
let destination = effectChain;
|
||||
if (target.filter) {
|
||||
const filter = new Tone.Filter({
|
||||
type: target.filter.type || 'lowpass',
|
||||
frequency: target.filter.frequency || 1000,
|
||||
Q: target.filter.Q || 1,
|
||||
});
|
||||
filter.connect(effectChain);
|
||||
destination = filter;
|
||||
nodes.push(filter);
|
||||
}
|
||||
|
||||
// Optional envelope
|
||||
let envelope = null;
|
||||
if (target.envelope) {
|
||||
envelope = new Tone.AmplitudeEnvelope({
|
||||
attack: target.envelope.attack ?? 0.01,
|
||||
decay: target.envelope.decay ?? 0.1,
|
||||
sustain: target.envelope.sustain ?? 0.3,
|
||||
release: target.envelope.release ?? 0.5,
|
||||
});
|
||||
envelope.connect(destination);
|
||||
destination = envelope;
|
||||
nodes.push(envelope);
|
||||
}
|
||||
|
||||
// Optional LFO for modulation
|
||||
let lfo = null;
|
||||
if (target.lfo) {
|
||||
lfo = new Tone.LFO({
|
||||
frequency: target.lfo.frequency ?? 5,
|
||||
type: target.lfo.type ?? 'sine',
|
||||
min: target.lfo.min ?? 0.5,
|
||||
max: target.lfo.max ?? 1.5,
|
||||
});
|
||||
|
||||
// Route LFO to the specified target
|
||||
if (target.lfo.target === 'amplitude' && envelope) {
|
||||
lfo.connect(envelope.gain);
|
||||
} else if (target.lfo.target === 'frequency' && target.build.length > 0) {
|
||||
// LFO will be connected to oscillators below
|
||||
}
|
||||
|
||||
lfo.start();
|
||||
nodes.push(lfo);
|
||||
}
|
||||
|
||||
// Build oscillators / noise from target.build
|
||||
for (const spec of target.build) {
|
||||
if (spec.type === 'oscillator') {
|
||||
const osc = new Tone.Oscillator({
|
||||
type: spec.params.waveform || 'sine',
|
||||
frequency: spec.params.frequency || 440,
|
||||
detune: spec.params.detune || 0,
|
||||
});
|
||||
osc.connect(destination);
|
||||
|
||||
// Connect LFO to frequency if specified
|
||||
if (lfo && target.lfo?.target === 'frequency') {
|
||||
lfo.connect(osc.frequency);
|
||||
}
|
||||
|
||||
osc.start();
|
||||
nodes.push(osc);
|
||||
} else if (spec.type === 'noise') {
|
||||
const noise = new Tone.Noise(spec.params.type || 'white');
|
||||
noise.connect(destination);
|
||||
noise.start();
|
||||
nodes.push(noise);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle envelope retriggering with triggerPattern
|
||||
if (envelope && target.triggerPattern) {
|
||||
const pattern = target.triggerPattern;
|
||||
const interval = pattern.interval ?? 0.5;
|
||||
const count = pattern.count ?? Math.ceil((target.duration || 2) / interval);
|
||||
|
||||
const loop = new Tone.Loop((time) => {
|
||||
envelope.triggerAttackRelease(
|
||||
target.envelope.attack + target.envelope.decay + target.envelope.release,
|
||||
time
|
||||
);
|
||||
}, interval);
|
||||
|
||||
loop.start(0);
|
||||
nodes.push(loop);
|
||||
_loops.push(loop);
|
||||
} else if (envelope) {
|
||||
// Single trigger if no pattern
|
||||
envelope.triggerAttack();
|
||||
}
|
||||
|
||||
_activeNodes = nodes;
|
||||
|
||||
// Auto-stop after duration
|
||||
const dur = (target.duration || 2) * 1000;
|
||||
_stopTimeout = setTimeout(() => stopTarget(), dur);
|
||||
}
|
||||
|
||||
export function stopTarget() {
|
||||
if (_stopTimeout) {
|
||||
clearTimeout(_stopTimeout);
|
||||
_stopTimeout = null;
|
||||
}
|
||||
|
||||
// Stop and cleanup loops
|
||||
for (const loop of _loops) {
|
||||
try {
|
||||
loop.stop();
|
||||
loop.dispose();
|
||||
} catch {}
|
||||
}
|
||||
_loops = [];
|
||||
|
||||
// Stop and cleanup nodes
|
||||
for (const node of _activeNodes) {
|
||||
try {
|
||||
if (node.stop) node.stop();
|
||||
if (node.disconnect) node.disconnect();
|
||||
if (node.dispose) node.dispose();
|
||||
} catch {}
|
||||
}
|
||||
_activeNodes = [];
|
||||
_isPlaying = false;
|
||||
}
|
||||
Reference in New Issue
Block a user