hyperspace-jam-lyria / MusicManager.js
Solshine's picture
Upload folder using huggingface_hub
74ccb11 verified
Raw
History Blame Contribute Delete
20 kB
// Lyria RealTime MusicManager — Drop-in replacement for Tone.js synth engine
// Sends gesture data to Python backend via WebSocket, receives PCM audio
export class MusicManager {
constructor() {
this.isStarted = false;
this._panicMuted = false;
this.analyser = null;
this.currentStyleLabel = '';
this._handFists = [false, false]; // Track fist state per hand
this._bassDropActive = false; // True while both fists held
// Spacebar listener — skip if typing in an input
document.addEventListener('keydown', (e) => {
if (e.code === 'Space' || e.key === ' ') {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
e.stopPropagation();
this.panic();
}
}, true);
// Genre override — persists even when legend is closed
this._genreOverride = '';
window._setGenreOverride = (genre) => {
this._genreOverride = genre.trim();
if (this._wsReady) {
this._ws.send(JSON.stringify({ type: 'genre_override', genre: this._genreOverride }));
}
// Show/hide indicator
let el = document.getElementById('genre-lock-indicator');
if (!el) {
el = document.createElement('div');
el.id = 'genre-lock-indicator';
el.style.cssText = 'position:fixed;top:12px;left:12px;padding:4px 10px;border-radius:6px;font:11px monospace;z-index:9999;pointer-events:none;background:rgba(0,180,255,0.7);color:#000;transition:opacity 0.3s';
document.body.appendChild(el);
}
if (this._genreOverride) {
el.textContent = '\uD83C\uDFB5 ' + this._genreOverride.toUpperCase();
el.style.opacity = '1';
console.log('[Lyria] Genre locked:', this._genreOverride);
} else {
el.style.opacity = '0';
console.log('[Lyria] Genre lock cleared');
}
};
// Gesture state accumulator (sent to backend)
this._gestureState = {
handY: 0.5,
handSpread: 0.5,
wristAngle: 0.0,
volume: 0.5,
handVelocity: { x: 0, y: 0 },
fingerExtensions: {},
shapeActive: false,
shapeProximity: 0.5,
drumHandActive: false,
drumTempo: null,
muteDrums: false,
muteBass: false,
fistGesture: false,
};
// WebSocket to backend
this._ws = null;
this._wsReady = false;
// Audio playback via Web Audio API
this._audioCtx = null;
this._pcmQueue = [];
this._isPlaying = false;
this._nextPlayTime = 0;
// Analyser for visualizer compatibility
this._analyserNode = null;
this._analyserData = null;
// Throttle gesture sends (every 100ms)
this._lastSendTime = 0;
this._SEND_INTERVAL = 1000; // Send gesture updates every 1s — Lyria keeps playing between
// Compat: pad preset cycling
this.padPresets = [
{ name: 'Ambient Sub' },
{ name: 'Dub Growl' },
{ name: 'Dream Wash' },
{ name: 'Detuned Saw' },
{ name: 'Warm Square' },
];
this.currentSynthIndex = 0;
// Smoothing (compat with game.js references)
this._smoothDist = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 };
this._smoothDrumDist = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 };
this._SMOOTH_ALPHA = 0.3;
this._frameCount = 0;
this._prevExtensions = {};
this._prevDrumExtensions = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 };
this._drumFingerCooldowns = { thumb: 0, index: 0, middle: 0, ring: 0, pinky: 0 };
this.handVolumes = new Map();
this.fingerCooldowns = new Map();
this.percCooldown = 0;
this.padSynths = new Map();
this.activePatterns = this.padSynths;
this.scale = [
'C1','Eb1','G1','Bb1','C2','Eb2','F2','G2','Bb2',
'C3','Eb3','F3','G3','Bb3','C4','Eb4','F4','G4','Bb4','C5','Eb5','F5'
];
}
_debug(msg) {
console.log('[Lyria] ' + msg);
try {
let el = document.getElementById('lyria-debug');
if (!el && document.body) {
el = document.createElement('div');
el.id = 'lyria-debug';
el.style.cssText = 'position:fixed;bottom:4px;left:4px;padding:4px 8px;border-radius:4px;font:10px monospace;z-index:9999;pointer-events:none;background:rgba(0,0,0,0.7);color:#0f0;max-width:500px;word-wrap:break-word';
document.body.appendChild(el);
}
if (el) el.textContent = msg;
} catch(e) { /* DOM not ready */ }
}
async start() {
if (this.isStarted) return;
this._debug('start() called');
// Create AudioContext for PCM playback
this._audioCtx = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 48000
});
this._debug('AudioContext: ' + this._audioCtx.state);
// Create analyser node for visualizer
this._analyserNode = this._audioCtx.createAnalyser();
this._analyserNode.fftSize = 2048;
this._analyserNode.connect(this._audioCtx.destination);
this._analyserData = new Float32Array(this._analyserNode.fftSize);
// Expose analyser in Tone.js-compatible format
this.analyser = {
getValue: () => {
this._analyserNode.getFloatTimeDomainData(this._analyserData);
return this._analyserData;
}
};
// Connect to backend WebSocket with auto-reconnect
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this._wsUrl = `${wsProto}//${window.location.host}/ws`;
this._debug('WS URL: ' + this._wsUrl);
this._reconnectAttempts = 0;
this._maxReconnectAttempts = 10;
this._connectWebSocket();
}
_connectWebSocket() {
this._debug('Connecting WS...');
this._ws = new WebSocket(this._wsUrl);
this._ws.binaryType = 'arraybuffer';
this._ws.onopen = () => {
this._debug('WS CONNECTED');
this._wsReady = true;
this._reconnectAttempts = 0;
this._nextPlayTime = 0; // reset audio scheduling on reconnect
};
this._ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
this._handleAudioChunkBinary(event.data);
} else if (event.data instanceof Blob) {
event.data.arrayBuffer().then(ab => this._handleAudioChunkBinary(ab));
} else if (typeof event.data === 'string') {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'error') {
console.error('[Lyria] Backend error:', msg.message);
} else if (msg.type === 'connected') {
console.log('[Lyria] Backend session ready');
} else if (msg.type === 'fallback') {
this._setFallbackIndicator(msg.active);
if (msg.style) {
this.currentStyleLabel = msg.style;
}
} else if (msg.type === 'session_restart') {
this._showSessionRestart(msg.phase);
}
} catch (e) {
console.warn('[Lyria] Unparseable message:', event.data.substring(0, 50));
}
}
};
this._ws.onerror = (e) => {
this._debug('WS ERROR: ' + (e.message || 'unknown'));
};
this._ws.onclose = (e) => {
this._debug('WS CLOSED: code=' + e.code + ' reason=' + e.reason);
this._wsReady = false;
// Auto-reconnect with backoff
if (this._reconnectAttempts < this._maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(1.5, this._reconnectAttempts), 10000);
this._reconnectAttempts++;
console.log(`[Lyria] Reconnecting in ${(delay/1000).toFixed(1)}s (attempt ${this._reconnectAttempts})...`);
setTimeout(() => this._connectWebSocket(), delay);
} else {
console.error('[Lyria] Max reconnect attempts reached');
}
};
this.isStarted = true;
}
_handleAudioChunkBinary(arrayBuffer) {
// Drop audio chunks while paused
if (this._panicMuted) return;
if (!this._chunkCount) this._chunkCount = 0;
this._chunkCount++;
if (this._chunkCount <= 3 || this._chunkCount % 20 === 0) {
console.log(`[Lyria] Audio chunk ${this._chunkCount}: ${arrayBuffer.byteLength} bytes`);
}
// Convert 16-bit PCM to Float32 stereo directly from ArrayBuffer
const int16 = new Int16Array(arrayBuffer);
const numSamples = int16.length / 2; // stereo
const audioBuffer = this._audioCtx.createBuffer(2, numSamples, 48000);
const left = audioBuffer.getChannelData(0);
const right = audioBuffer.getChannelData(1);
for (let i = 0; i < numSamples; i++) {
left[i] = int16[i * 2] / 32768;
right[i] = int16[i * 2 + 1] / 32768;
}
// Schedule seamless playback — drop chunks if we fall too far behind
const now = this._audioCtx.currentTime;
if (this._nextPlayTime > 0 && this._nextPlayTime > now + 6) {
console.warn('[Lyria] Audio buffer too far ahead, resetting');
this._nextPlayTime = now + 0.05;
}
const startTime = Math.max(now + 0.05, this._nextPlayTime);
const source = this._audioCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(this._analyserNode);
source.start(startTime);
// Disconnect source after playback to free memory
source.onended = () => {
source.disconnect();
};
this._nextPlayTime = startTime + audioBuffer.duration;
}
_sendGestureState() {
if (this._panicMuted || this._bassDropActive) return; // Don't send during pause or bass drop
const now = performance.now();
if (!this._wsReady || now - this._lastSendTime < this._SEND_INTERVAL) return;
this._lastSendTime = now;
this._ws.send(JSON.stringify({
type: 'gesture',
data: this._gestureState
}));
}
// === Public API matching original MusicManager interface ===
startArpeggio(handId, note) {
// Track that a hand appeared, update base note
this._gestureState.handY = this.scale.indexOf(note) / (this.scale.length - 1);
if (!this._isPlaying && this._wsReady) {
this._ws.send(JSON.stringify({ type: 'play' }));
this._isPlaying = true;
}
}
stopArpeggio(handId) {
// Hand disappeared
}
updateGesture(handId, gestureData) {
if (!this.isStarted || this._panicMuted) return;
// Map gesture data to our state format
const rootNote = gestureData.rootNote || 'C3';
const noteIdx = this.scale.indexOf(rootNote);
this._gestureState.handY = noteIdx >= 0 ? noteIdx / (this.scale.length - 1) : 0.5;
this._gestureState.volume = gestureData.volume || 0;
this._gestureState.wristAngle = gestureData.wristAngle || 0;
this._gestureState.handSpread = gestureData.handSpread || 0;
this._gestureState.handVelocity = gestureData.handVelocity || { x: 0, y: 0 };
this._gestureState.fingerExtensions = gestureData.fingerExtensions || {};
this._gestureState.fistGesture = false;
this._sendGestureState();
}
updateDrumGesture(gestureData) {
if (!this.isStarted || this._panicMuted) return;
this._gestureState.drumHandActive = true;
// Average finger extension as tempo control
const ext = gestureData.fingerExtensions || {};
const vals = Object.values(ext).filter(v => typeof v === 'number');
if (vals.length > 0) {
this._gestureState.drumTempo = vals.reduce((a, b) => a + b, 0) / vals.length;
}
this._sendGestureState();
}
updateShapeBass(shapeData) {
if (!this.isStarted) return;
this._gestureState.shapeActive = true;
this._gestureState.shapeProximity = shapeData?.proximity || 0.5;
this._sendGestureState();
}
stopShapeBass() {
this._gestureState.shapeActive = false;
this._sendGestureState();
}
triggerFingerTouch(finger1, finger2) {
// Finger touches are sent as part of gesture state
// The Qwen model will interpret the overall gesture
}
cycleSynth() {
// Synth hand made a fist
this._handFists[0] = true;
this._checkDoubleFist();
if (!this._bassDropActive) {
this.currentSynthIndex = (this.currentSynthIndex + 1) % this.padPresets.length;
this._gestureState.fistGesture = true;
this._sendGestureState();
}
}
setDrumHandFist(isFist) {
this._handFists[1] = isFist;
if (isFist) {
this._checkDoubleFist();
} else {
this._endDoubleFist();
}
}
// Called by game.js when synth hand opens from fist
setSynthHandOpen() {
this._handFists[0] = false;
this._endDoubleFist();
}
_checkDoubleFist() {
if (this._handFists[0] && this._handFists[1] && !this._bassDropActive) {
this._bassDropActive = true;
console.log('[Lyria] BASS DROP LOCKED!');
if (this._wsReady) {
this._ws.send(JSON.stringify({ type: 'bassdrop' }));
}
}
}
_endDoubleFist() {
if (this._bassDropActive && !(this._handFists[0] && this._handFists[1])) {
this._bassDropActive = false;
console.log('[Lyria] BASS DROP RELEASED');
if (this._wsReady) {
this._ws.send(JSON.stringify({ type: 'bassdrop_end' }));
}
}
}
panic() {
// Toggle pause/resume
this._panicMuted = !this._panicMuted;
if (this._panicMuted) {
// PAUSE everything
console.log('[Lyria] PAUSED');
if (this._wsReady) {
this._ws.send(JSON.stringify({ type: 'panic' }));
}
// Suspend audio context to silence all scheduled buffers
if (this._audioCtx && this._audioCtx.state === 'running') {
this._audioCtx.suspend();
}
// Show pause indicator
this._setPauseIndicator(true);
} else {
// RESUME everything
console.log('[Lyria] RESUMED');
if (this._audioCtx && this._audioCtx.state === 'suspended') {
this._audioCtx.resume();
}
// Reset audio scheduling so we don't play stale buffered chunks
this._nextPlayTime = 0;
if (this._wsReady) {
this._ws.send(JSON.stringify({ type: 'resume' }));
}
this._setPauseIndicator(false);
}
}
_showSessionRestart(phase) {
let el = document.getElementById('lyria-restart-indicator');
if (!el) {
el = document.createElement('div');
el.id = 'lyria-restart-indicator';
el.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);font:bold 28px monospace;color:rgba(255,200,0,0.95);z-index:9999;pointer-events:none;text-shadow:0 0 30px rgba(255,150,0,0.6);text-align:center;transition:opacity 0.5s';
document.body.appendChild(el);
}
if (phase === 'starting') {
const msgs = [
'RELOADING THE COSMIC JUKEBOX...',
'REWINDING THE UNIVERSE...',
'REFUELING THE BASS CANNON...',
'SHUFFLING THE QUANTUM DECK...',
'REBOOTING THE GROOVE MATRIX...',
'TUNING THE HYPERSPACE ANTENNA...',
'SUMMONING FRESH FREQUENCIES...',
];
el.textContent = msgs[Math.floor(Math.random() * msgs.length)];
el.style.opacity = '1';
this._nextPlayTime = 0; // reset audio scheduling
} else if (phase === 'done') {
el.textContent = 'LOCKED BACK IN';
setTimeout(() => { el.style.opacity = '0'; }, 1500);
}
}
_setPauseIndicator(paused) {
let el = document.getElementById('lyria-pause-indicator');
if (!el) {
el = document.createElement('div');
el.id = 'lyria-pause-indicator';
el.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);font:bold 48px monospace;color:rgba(255,255,255,0.8);z-index:9999;pointer-events:none;text-shadow:0 0 20px rgba(0,0,0,0.8);transition:opacity 0.3s';
document.body.appendChild(el);
}
if (paused) {
el.textContent = '|| PAUSED';
el.style.opacity = '1';
} else {
el.style.opacity = '0';
}
}
handLeft(handId) {
if (handId !== 0) {
this._gestureState.drumHandActive = false;
this._gestureState.drumTempo = null;
}
// Notify backend
if (this._wsReady) {
this._ws.send(JSON.stringify({ type: 'hands_lost' }));
}
}
// === Additional stubs for game.js compatibility ===
setProximityFilter(proximity) {
// Proximity affects brightness in the gesture state
this._gestureState.shapeProximity = proximity;
}
updateArpeggio(handId, note) {
const noteIdx = this.scale.indexOf(note);
this._gestureState.handY = noteIdx >= 0 ? noteIdx / (this.scale.length - 1) : 0.5;
}
updateArpeggioVolume(handId, volume) {
this._gestureState.volume = volume;
}
updateFingerExpression(data) {
// Finger expression data folded into gesture state via updateGesture
}
getAnalyser() {
return this.analyser;
}
ensureAudioActive() {
if (this._audioCtx && this._audioCtx.state === 'suspended') {
this._audioCtx.resume();
}
}
updateShapeAudio(shapeData) {
this.updateShapeBass(shapeData);
}
getAnalyserData() {
if (this.analyser) {
return this.analyser.getValue();
}
return new Float32Array(2048);
}
_setFallbackIndicator(active) {
let el = document.getElementById('qwen-fallback-indicator');
if (!el) {
el = document.createElement('div');
el.id = 'qwen-fallback-indicator';
el.style.cssText = 'position:fixed;top:12px;right:12px;padding:4px 10px;border-radius:6px;font:11px monospace;z-index:9999;pointer-events:none;transition:opacity 0.3s';
document.body.appendChild(el);
}
if (active) {
el.textContent = '\u2699 RULES';
el.style.background = 'rgba(255,160,0,0.7)';
el.style.color = '#000';
el.style.opacity = '1';
} else {
el.textContent = '\u2728 QWEN';
el.style.background = 'rgba(0,200,120,0.5)';
el.style.color = '#fff';
el.style.opacity = '0.6';
}
}
dispose() {
if (this._ws) {
this._ws.close();
}
if (this._audioCtx) {
this._audioCtx.close();
}
}
}