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:
Jose Luis
2026-03-21 19:52:57 +01:00
parent 4baa86eed0
commit b058997889
59 changed files with 96 additions and 33 deletions

View 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;
}