Spaces:
Running
Running
| /** | |
| * Virtual MIDI Keyboard - Main JavaScript | |
| * | |
| * This file handles: | |
| * - Keyboard rendering and layout | |
| * - Audio synthesis (Tone.js) | |
| * - MIDI event recording | |
| * - Computer keyboard input | |
| * - MIDI monitor/terminal | |
| * - File export | |
| */ | |
| // ============================================================================= | |
| // CONFIGURATION | |
| // ============================================================================= | |
| const baseMidi = 60; // C4 | |
| const numOctaves = 2; | |
| // Keyboard layout with sharps flagged | |
| const keys = [ | |
| {name:'C', offset:0, black:false}, | |
| {name:'C#', offset:1, black:true}, | |
| {name:'D', offset:2, black:false}, | |
| {name:'D#', offset:3, black:true}, | |
| {name:'E', offset:4, black:false}, | |
| {name:'F', offset:5, black:false}, | |
| {name:'F#', offset:6, black:true}, | |
| {name:'G', offset:7, black:false}, | |
| {name:'G#', offset:8, black:true}, | |
| {name:'A', offset:9, black:false}, | |
| {name:'A#', offset:10, black:true}, | |
| {name:'B', offset:11, black:false} | |
| ]; | |
| // Computer keyboard mapping (fallback) | |
| const keyMap = { | |
| 'a': 60, // C4 | |
| 'w': 61, // C#4 | |
| 's': 62, // D4 | |
| 'e': 63, // D#4 | |
| 'd': 64, // E4 | |
| 'f': 65, // F4 | |
| 't': 66, // F#4 | |
| 'g': 67, // G4 | |
| 'y': 68, // G#4 | |
| 'h': 69, // A4 | |
| 'u': 70, // A#4 | |
| 'j': 71, // B4 | |
| 'k': 72, // C5 | |
| 'o': 73, // C#5 | |
| 'l': 74, // D5 | |
| 'p': 75, // D#5 | |
| ';': 76 // E5 | |
| }; | |
| // Keyboard shortcuts displayed on keys (fallback) | |
| const keyShortcuts = { | |
| 60: 'A', 61: 'W', 62: 'S', 63: 'E', 64: 'D', 65: 'F', | |
| 66: 'T', 67: 'G', 68: 'Y', 69: 'H', 70: 'U', 71: 'J', | |
| 72: 'K', 73: 'O', 74: 'L', 75: 'P', 76: ';' | |
| }; | |
| // ============================================================================= | |
| // DOM ELEMENTS | |
| // ============================================================================= | |
| let keyboardEl = null; | |
| let statusEl = null; | |
| let recordBtn = null; | |
| let stopBtn = null; | |
| let playbackBtn = null; | |
| let gameStartBtn = null; | |
| let gameStopBtn = null; | |
| let saveBtn = null; | |
| let panicBtn = null; | |
| let keyboardToggle = null; | |
| let instrumentSelect = null; | |
| let aiInstrumentSelect = null; | |
| let engineSelect = null; | |
| let runtimeSelect = null; | |
| let responseStyleSelect = null; | |
| let responseModeSelect = null; | |
| let responseLengthSelect = null; | |
| let quantizationSelect = null; | |
| let userBarsSelect = null; | |
| let aiBarsSelect = null; | |
| let terminal = null; | |
| let clearTerminal = null; | |
| let countdownOverlay = null; | |
| let countdownText = null; | |
| let userGridCanvas = null; | |
| let aiGridCanvas = null; | |
| let userGridMeta = null; | |
| let aiGridMeta = null; | |
| let gridPhaseBadge = null; | |
| let gameToggleBtn = null; | |
| let settingsToggleBtn = null; | |
| let settingsPanel = null; | |
| let settingsCloseBtn = null; | |
| let settingsBackdrop = null; | |
| // ============================================================================= | |
| // STATE | |
| // ============================================================================= | |
| let synth = null; | |
| let aiSynth = null; | |
| let recording = false; | |
| let startTime = 0; | |
| let events = []; | |
| const pressedKeys = new Set(); | |
| let selectedEngine = 'parrot'; // Default engine | |
| let serverConfig = null; // Will hold instruments and keyboard config from server | |
| let gameActive = false; | |
| let gameTurn = 0; | |
| const GAME_BPM = 75; | |
| const GAME_BEATS_PER_BAR = 4; | |
| const GAME_COUNTIN_BEATS = 3; | |
| const GAME_RETRY_DELAY_MS = 500; | |
| let gameSessionId = 0; | |
| let gameClockOriginSec = 0; | |
| let gamePhase = 'idle'; | |
| let gameCaptureActive = false; | |
| let gameCaptureStartWallSec = 0; | |
| let gameCapturedEvents = []; | |
| const gameCaptureActiveNotes = new Set(); | |
| const gameTimeoutIds = new Set(); | |
| let metronomeBeatIndex = 0; | |
| let metronomeKick = null; | |
| let metronomeSnare = null; | |
| let metronomeHat = null; | |
| let gameGridUserEvents = []; | |
| let gameGridAIEvents = []; | |
| let resolvedAutoRuntimeMode = null; | |
| let autoRuntimeProbeInFlight = false; | |
| let gridAnimationFrameId = null; | |
| const gridPlayheads = { | |
| user: { active: false, startWallSec: 0, durationSec: 1 }, | |
| ai: { active: false, startWallSec: 0, durationSec: 1 } | |
| }; | |
| const RESPONSE_MODES = { | |
| raw_godzilla: { label: 'Raw Godzilla' }, | |
| current_pipeline: { label: 'Current Pipeline' }, | |
| musical_polish: { label: 'Musical Polish' } | |
| }; | |
| const RESPONSE_LENGTH_PRESETS = { | |
| short: { | |
| label: 'Short', | |
| generateTokens: 32, | |
| maxNotes: 8, | |
| maxDurationSec: 4.0 | |
| }, | |
| medium: { | |
| label: 'Medium', | |
| generateTokens: 64, | |
| maxNotes: 14, | |
| maxDurationSec: 6.0 | |
| }, | |
| long: { | |
| label: 'Long', | |
| generateTokens: 96, | |
| maxNotes: 20, | |
| maxDurationSec: 8.0 | |
| }, | |
| extended: { | |
| label: 'Extended', | |
| generateTokens: 128, | |
| maxNotes: 28, | |
| maxDurationSec: 11.0 | |
| } | |
| }; | |
| const RESPONSE_STYLE_PRESETS = { | |
| melodic: { | |
| label: 'Melodic', | |
| maxNotes: 8, | |
| maxDurationSec: 4.0, | |
| smoothLeaps: true, | |
| addMotifEcho: false, | |
| playfulShift: false | |
| }, | |
| motif_echo: { | |
| label: 'Motif Echo', | |
| maxNotes: 10, | |
| maxDurationSec: 4.3, | |
| smoothLeaps: true, | |
| addMotifEcho: true, | |
| playfulShift: false | |
| }, | |
| playful: { | |
| label: 'Playful', | |
| maxNotes: 9, | |
| maxDurationSec: 3.8, | |
| smoothLeaps: true, | |
| addMotifEcho: false, | |
| playfulShift: true | |
| } | |
| }; | |
| const GAME_QUANTIZATION_PRESETS = { | |
| sixteenth: { | |
| label: '16th Notes', | |
| stepBeats: 0.25 | |
| }, | |
| eighth: { | |
| label: '8th Notes', | |
| stepBeats: 0.5 | |
| }, | |
| none: { | |
| label: 'No Quantization', | |
| stepBeats: null | |
| } | |
| }; | |
| // ============================================================================= | |
| // INSTRUMENT FACTORY | |
| // ============================================================================= | |
| function buildInstruments(instrumentConfigs) { | |
| /** | |
| * Build Tone.js synth instances from config | |
| * instrumentConfigs: Object from server with instrument definitions | |
| */ | |
| const instruments = {}; | |
| for (const [key, config] of Object.entries(instrumentConfigs)) { | |
| const baseOptions = { | |
| maxPolyphony: 24, | |
| oscillator: config.oscillator ? { type: config.oscillator } : undefined, | |
| envelope: config.envelope, | |
| }; | |
| // Remove undefined keys | |
| Object.keys(baseOptions).forEach(k => baseOptions[k] === undefined && delete baseOptions[k]); | |
| if (config.type === 'FMSynth') { | |
| baseOptions.harmonicity = config.harmonicity; | |
| baseOptions.modulationIndex = config.modulationIndex; | |
| instruments[key] = () => new Tone.PolySynth(Tone.FMSynth, baseOptions).toDestination(); | |
| } else { | |
| instruments[key] = () => new Tone.PolySynth(Tone.Synth, baseOptions).toDestination(); | |
| } | |
| } | |
| return instruments; | |
| } | |
| let instruments = {}; // Will be populated after config is fetched | |
| function populateEngineSelect(engines) { | |
| if (!engineSelect || !Array.isArray(engines)) return; | |
| // Tooltip map for engine options | |
| const engineTooltips = { | |
| 'parrot': 'Repeats your exact melody', | |
| 'reverse_parrot': 'Plays your melody backward', | |
| 'godzilla_continue': 'MIDI transformer' | |
| }; | |
| engineSelect.innerHTML = ''; | |
| engines.forEach(engine => { | |
| const option = document.createElement('option'); | |
| option.value = engine.id; | |
| option.textContent = engine.name || engine.id; | |
| // Add tooltip attribute for hover display | |
| if (engineTooltips[engine.id]) { | |
| option.setAttribute('data-tooltip', engineTooltips[engine.id]); | |
| } | |
| engineSelect.appendChild(option); | |
| }); | |
| if (engines.length > 0) { | |
| const hasReverseParrot = engines.some(engine => engine.id === 'reverse_parrot'); | |
| selectedEngine = hasReverseParrot ? 'reverse_parrot' : engines[0].id; | |
| engineSelect.value = selectedEngine; | |
| } | |
| } | |
| // ============================================================================= | |
| // INITIALIZATION FROM SERVER CONFIG | |
| // ============================================================================= | |
| async function initializeFromConfig() { | |
| /** | |
| * Fetch configuration from Python server and initialize UI | |
| */ | |
| try { | |
| serverConfig = await callGradioBridge('config', {}); | |
| if (!serverConfig || typeof serverConfig !== 'object') { | |
| throw new Error('Invalid config payload'); | |
| } | |
| // Build instruments from config | |
| instruments = buildInstruments(serverConfig.instruments); | |
| // Build keyboard shortcut maps from server config | |
| window.keyboardShortcutsFromServer = serverConfig.keyboard_shortcuts; | |
| window.keyMapFromServer = {}; | |
| for (const [midiStr, key] of Object.entries(serverConfig.keyboard_shortcuts)) { | |
| window.keyMapFromServer[key.toLowerCase()] = parseInt(midiStr); | |
| } | |
| // Populate engine dropdown from server config | |
| populateEngineSelect(serverConfig.engines); | |
| resetAutoRuntimeResolution(); | |
| // Render keyboard after config is loaded | |
| buildKeyboard(); | |
| } catch (error) { | |
| console.error('Failed to load configuration:', error); | |
| // Fallback: Use hardcoded values for development/debugging | |
| console.warn('Using fallback hardcoded configuration'); | |
| instruments = buildInstruments({ | |
| 'synth': {name: 'Synth', type: 'Synth', oscillator: 'sine', envelope: {attack: 0.005, decay: 0.1, sustain: 0.3, release: 0.2}}, | |
| 'piano': {name: 'Piano', type: 'Synth', oscillator: 'triangle', envelope: {attack: 0.001, decay: 0.2, sustain: 0.1, release: 0.3}}, | |
| 'organ': {name: 'Organ', type: 'Synth', oscillator: 'sine4', envelope: {attack: 0.001, decay: 0, sustain: 1, release: 0.1}}, | |
| 'bass': {name: 'Bass', type: 'Synth', oscillator: 'sawtooth', envelope: {attack: 0.01, decay: 0.1, sustain: 0.4, release: 0.3}}, | |
| 'pluck': {name: 'Pluck', type: 'Synth', oscillator: 'triangle', envelope: {attack: 0.001, decay: 0.3, sustain: 0, release: 0.3}}, | |
| 'fm': {name: 'FM', type: 'FMSynth', harmonicity: 3, modulationIndex: 10, envelope: {attack: 0.01, decay: 0.2, sustain: 0.2, release: 0.2}} | |
| }); | |
| window.keyboardShortcutsFromServer = keyShortcuts; // Use hardcoded as fallback | |
| window.keyMapFromServer = keyMap; // Use hardcoded as fallback | |
| populateEngineSelect([ | |
| { id: 'parrot', name: 'Parrot' }, | |
| { id: 'reverse_parrot', name: 'Reverse Parrot' }, | |
| { id: 'godzilla_continue', name: 'Godzilla' } | |
| ]); | |
| resetAutoRuntimeResolution(); | |
| buildKeyboard(); | |
| } | |
| } | |
| function loadInstrument(type) { | |
| if (synth) { | |
| synth.releaseAll(); | |
| synth.dispose(); | |
| } | |
| synth = instruments[type](); | |
| } | |
| function loadAIInstrument(type) { | |
| if (aiSynth) { | |
| aiSynth.releaseAll(); | |
| aiSynth.dispose(); | |
| } | |
| aiSynth = instruments[type](); | |
| } | |
| // ============================================================================= | |
| // KEYBOARD RENDERING | |
| // ============================================================================= | |
| function buildKeyboard() { | |
| // Clear any existing keys | |
| keyboardEl.innerHTML = ''; | |
| for (let octave = 0; octave < numOctaves; octave++) { | |
| for (let i = 0; i < keys.length; i++) { | |
| const k = keys[i]; | |
| const midiNote = baseMidi + (octave * 12) + k.offset; | |
| const octaveNum = 4 + octave; | |
| const keyEl = document.createElement('div'); | |
| keyEl.className = 'key' + (k.black ? ' black' : ''); | |
| keyEl.dataset.midi = midiNote; | |
| // Use server config shortcuts if available, otherwise fallback to hardcoded | |
| const shortcutsMap = window.keyboardShortcutsFromServer || keyShortcuts; | |
| const shortcut = shortcutsMap[midiNote] || ''; | |
| const shortcutHtml = shortcut ? `<div class="shortcut-hint">${shortcut}</div>` : ''; | |
| keyEl.innerHTML = `<div style="padding-bottom:6px;font-size:11px">${shortcutHtml}${k.name}${octaveNum}</div>`; | |
| keyboardEl.appendChild(keyEl); | |
| } | |
| } | |
| } | |
| // ============================================================================= | |
| // MIDI UTILITIES | |
| // ============================================================================= | |
| const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; | |
| function midiToNoteName(midi) { | |
| const octave = Math.floor(midi / 12) - 1; | |
| const noteName = noteNames[midi % 12]; | |
| return `${noteName}${octave}`; | |
| } | |
| function nowSec() { | |
| return performance.now() / 1000; | |
| } | |
| function getBridgeButton(buttonId) { | |
| return document.getElementById(buttonId) || document.querySelector(`#${buttonId} button`); | |
| } | |
| function getBridgeField(fieldId) { | |
| const root = document.getElementById(fieldId); | |
| if (!root) return null; | |
| if (root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) { | |
| return root; | |
| } | |
| return root.querySelector('textarea, input'); | |
| } | |
| function setFieldValue(field, value) { | |
| const setter = field instanceof HTMLTextAreaElement | |
| ? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set | |
| : Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; | |
| if (setter) { | |
| setter.call(field, value); | |
| } else { | |
| field.value = value; | |
| } | |
| field.dispatchEvent(new Event('input', { bubbles: true })); | |
| field.dispatchEvent(new Event('change', { bubbles: true })); | |
| } | |
| function waitForFieldUpdate(field, previousValue, timeoutMs = 120000) { | |
| return new Promise((resolve, reject) => { | |
| const deadline = Date.now() + timeoutMs; | |
| const check = () => { | |
| const nextValue = field.value || ''; | |
| if (nextValue !== previousValue && nextValue !== '') { | |
| resolve(nextValue); | |
| return; | |
| } | |
| if (Date.now() > deadline) { | |
| reject(new Error('Timed out waiting for Gradio response')); | |
| return; | |
| } | |
| setTimeout(check, 80); | |
| }; | |
| check(); | |
| }); | |
| } | |
| async function waitForBridgeElements(timeoutMs = 20000) { | |
| const required = [ | |
| { kind: 'field', id: 'vk_config_input' }, | |
| { kind: 'field', id: 'vk_config_output' }, | |
| { kind: 'button', id: 'vk_config_btn' }, | |
| { kind: 'field', id: 'vk_save_input' }, | |
| { kind: 'field', id: 'vk_save_output' }, | |
| { kind: 'button', id: 'vk_save_btn' }, | |
| { kind: 'field', id: 'vk_engine_input' }, | |
| { kind: 'field', id: 'vk_engine_cpu_output' }, | |
| { kind: 'button', id: 'vk_engine_cpu_btn' }, | |
| { kind: 'field', id: 'vk_engine_gpu_output' }, | |
| { kind: 'button', id: 'vk_engine_gpu_btn' } | |
| ]; | |
| const optional = [ | |
| { kind: 'field', id: 'vk_engine_stream_cpu_output' }, | |
| { kind: 'button', id: 'vk_engine_stream_cpu_btn' }, | |
| { kind: 'field', id: 'vk_engine_stream_gpu_output' }, | |
| { kind: 'button', id: 'vk_engine_stream_gpu_btn' } | |
| ]; | |
| const isReady = (item) => item.kind === 'button' | |
| ? Boolean(getBridgeButton(item.id)) | |
| : Boolean(getBridgeField(item.id)); | |
| const started = Date.now(); | |
| while (Date.now() - started < timeoutMs) { | |
| if (required.every(isReady)) { | |
| // Required elements ready. Give optional elements a brief window. | |
| const optionalDeadline = Date.now() + 2000; | |
| while (Date.now() < optionalDeadline) { | |
| if (optional.every(isReady)) return; | |
| await new Promise(resolve => setTimeout(resolve, 100)); | |
| } | |
| const missing = optional.filter(item => !isReady(item)).map(item => item.id); | |
| console.warn('Optional bridge elements not found (streaming disabled):', missing); | |
| return; | |
| } | |
| await new Promise(resolve => setTimeout(resolve, 100)); | |
| } | |
| throw new Error('Gradio bridge elements were not ready in time'); | |
| } | |
| function cacheUIElements() { | |
| keyboardEl = document.getElementById('keyboard'); | |
| statusEl = document.getElementById('status'); | |
| recordBtn = document.getElementById('recordBtn'); | |
| stopBtn = document.getElementById('stopBtn'); | |
| playbackBtn = document.getElementById('playbackBtn'); | |
| gameStartBtn = document.getElementById('gameStartBtn'); | |
| gameStopBtn = document.getElementById('gameStopBtn'); | |
| saveBtn = document.getElementById('saveBtn'); | |
| panicBtn = document.getElementById('panicBtn'); | |
| keyboardToggle = document.getElementById('keyboardToggle'); | |
| instrumentSelect = document.getElementById('instrumentSelect'); | |
| aiInstrumentSelect = document.getElementById('aiInstrumentSelect'); | |
| engineSelect = document.getElementById('engineSelect'); | |
| runtimeSelect = document.getElementById('runtimeSelect'); | |
| responseStyleSelect = document.getElementById('responseStyleSelect'); | |
| responseModeSelect = document.getElementById('responseModeSelect'); | |
| responseLengthSelect = document.getElementById('responseLengthSelect'); | |
| quantizationSelect = document.getElementById('quantizationSelect'); | |
| userBarsSelect = document.getElementById('userBarsSelect'); | |
| aiBarsSelect = document.getElementById('aiBarsSelect'); | |
| terminal = document.getElementById('terminal'); | |
| clearTerminal = document.getElementById('clearTerminal'); | |
| countdownOverlay = document.getElementById('countdownOverlay'); | |
| countdownText = document.getElementById('countdownText'); | |
| userGridCanvas = document.getElementById('userGridCanvas'); | |
| aiGridCanvas = document.getElementById('aiGridCanvas'); | |
| userGridMeta = document.getElementById('userGridMeta'); | |
| aiGridMeta = document.getElementById('aiGridMeta'); | |
| gridPhaseBadge = document.getElementById('gridPhaseBadge'); | |
| gameToggleBtn = document.getElementById('gameToggleBtn'); | |
| settingsToggleBtn = document.getElementById('settingsToggleBtn'); | |
| settingsPanel = document.getElementById('settingsPanel'); | |
| settingsCloseBtn = document.getElementById('settingsCloseBtn'); | |
| settingsBackdrop = document.getElementById('settingsBackdrop'); | |
| } | |
| async function waitForKeyboardUIElements(timeoutMs = 20000) { | |
| const requiredIds = [ | |
| 'keyboard', | |
| 'status', | |
| 'recordBtn', | |
| 'stopBtn', | |
| 'playbackBtn', | |
| 'gameStartBtn', | |
| 'gameStopBtn', | |
| 'saveBtn', | |
| 'panicBtn', | |
| 'keyboardToggle', | |
| 'instrumentSelect', | |
| 'engineSelect', | |
| 'runtimeSelect', | |
| 'quantizationSelect', | |
| 'userBarsSelect', | |
| 'aiBarsSelect', | |
| 'terminal', | |
| 'clearTerminal', | |
| 'countdownOverlay', | |
| 'countdownText', | |
| 'userGridCanvas', | |
| 'aiGridCanvas', | |
| 'userGridMeta', | |
| 'aiGridMeta', | |
| 'gridPhaseBadge' | |
| ]; | |
| const started = Date.now(); | |
| while (Date.now() - started < timeoutMs) { | |
| const allReady = requiredIds.every(id => Boolean(document.getElementById(id))); | |
| if (allReady) return; | |
| await new Promise(resolve => setTimeout(resolve, 100)); | |
| } | |
| throw new Error('Keyboard UI elements were not ready in time'); | |
| } | |
| const BRIDGE_ACTIONS = { | |
| config: { | |
| inputId: 'vk_config_input', | |
| outputId: 'vk_config_output', | |
| buttonId: 'vk_config_btn' | |
| }, | |
| save_midi: { | |
| inputId: 'vk_save_input', | |
| outputId: 'vk_save_output', | |
| buttonId: 'vk_save_btn' | |
| }, | |
| process_engine_cpu: { | |
| inputId: 'vk_engine_input', | |
| outputId: 'vk_engine_cpu_output', | |
| buttonId: 'vk_engine_cpu_btn' | |
| }, | |
| process_engine_gpu: { | |
| inputId: 'vk_engine_input', | |
| outputId: 'vk_engine_gpu_output', | |
| buttonId: 'vk_engine_gpu_btn' | |
| }, | |
| stream_engine_cpu: { | |
| inputId: 'vk_engine_input', | |
| outputId: 'vk_engine_stream_cpu_output', | |
| buttonId: 'vk_engine_stream_cpu_btn' | |
| }, | |
| stream_engine_gpu: { | |
| inputId: 'vk_engine_input', | |
| outputId: 'vk_engine_stream_gpu_output', | |
| buttonId: 'vk_engine_stream_gpu_btn' | |
| } | |
| }; | |
| async function callGradioBridge(action, payload) { | |
| const bridge = BRIDGE_ACTIONS[action]; | |
| if (!bridge) { | |
| throw new Error(`Unknown bridge action: ${action}`); | |
| } | |
| const inputField = getBridgeField(bridge.inputId); | |
| const outputField = getBridgeField(bridge.outputId); | |
| const button = getBridgeButton(bridge.buttonId); | |
| if (!inputField || !outputField || !button) { | |
| throw new Error(`Bridge controls missing for action: ${action}`); | |
| } | |
| const requestPayload = payload === undefined ? {} : payload; | |
| setFieldValue(inputField, JSON.stringify(requestPayload)); | |
| const previousOutput = outputField.value || ''; | |
| setFieldValue(outputField, ''); | |
| button.click(); | |
| const outputText = await waitForFieldUpdate(outputField, previousOutput); | |
| try { | |
| return JSON.parse(outputText); | |
| } catch (err) { | |
| throw new Error(`Invalid bridge JSON for ${action}: ${outputText}`); | |
| } | |
| } | |
| /** | |
| * Stream results from a Gradio bridge generator endpoint. | |
| * Polls the output textbox for incremental JSON updates from SSE streaming. | |
| * Calls onUpdate(parsed) for each new intermediate result. | |
| * Resolves with the final result when status === "complete". | |
| */ | |
| let _streamRequestId = 0; | |
| async function callGradioBridgeStreaming(action, payload, onUpdate, timeoutMs = 120000) { | |
| const bridge = BRIDGE_ACTIONS[action]; | |
| if (!bridge) { | |
| throw new Error(`Unknown bridge action: ${action}`); | |
| } | |
| const inputField = getBridgeField(bridge.inputId); | |
| const outputField = getBridgeField(bridge.outputId); | |
| const button = getBridgeButton(bridge.buttonId); | |
| if (!inputField || !outputField || !button) { | |
| throw new Error(`Bridge controls missing for action: ${action}`); | |
| } | |
| // The caller sets _streamRequestId before calling and includes it as | |
| // payload.request_id. The backend echoes it in every response so we | |
| // can distinguish stale yields from a previous generator. | |
| const expectedRequestId = payload.request_id; | |
| const requestPayload = payload === undefined ? {} : payload; | |
| setFieldValue(inputField, JSON.stringify(requestPayload)); | |
| setFieldValue(outputField, ''); | |
| button.click(); | |
| return new Promise((resolve, reject) => { | |
| const deadline = Date.now() + timeoutMs; | |
| let lastValue = ''; | |
| const poll = () => { | |
| // A newer stream has started; abandon this poll loop. | |
| if (expectedRequestId !== _streamRequestId) { | |
| resolve({ status: 'cancelled', events: [] }); | |
| return; | |
| } | |
| const currentValue = outputField.value || ''; | |
| if (currentValue !== '' && currentValue !== lastValue) { | |
| lastValue = currentValue; | |
| try { | |
| const parsed = JSON.parse(currentValue); | |
| // Ignore responses from a previous/stale backend generator. | |
| if (parsed.request_id !== undefined && parsed.request_id !== expectedRequestId) { | |
| setTimeout(poll, 50); | |
| return; | |
| } | |
| if (parsed.status === 'error') { | |
| reject(new Error(parsed.error || 'Streaming error')); | |
| return; | |
| } | |
| if (typeof onUpdate === 'function') { | |
| onUpdate(parsed); | |
| } | |
| if (parsed.status === 'complete') { | |
| resolve(parsed); | |
| return; | |
| } | |
| } catch (err) { | |
| // JSON may be partially written; wait for next poll | |
| } | |
| } | |
| if (Date.now() > deadline) { | |
| reject(new Error('Timed out waiting for streaming response')); | |
| return; | |
| } | |
| setTimeout(poll, 50); | |
| }; | |
| poll(); | |
| }); | |
| } | |
| function sortEventsChronologically(eventsToSort) { | |
| return [...eventsToSort].sort((a, b) => { | |
| const ta = Number(a.time) || 0; | |
| const tb = Number(b.time) || 0; | |
| if (ta !== tb) return ta - tb; | |
| if (a.type === b.type) return 0; | |
| if (a.type === 'note_off') return -1; | |
| if (b.type === 'note_off') return 1; | |
| return 0; | |
| }); | |
| } | |
| function sanitizeEvents(rawEvents) { | |
| if (!Array.isArray(rawEvents) || rawEvents.length === 0) { | |
| return []; | |
| } | |
| const cleaned = rawEvents | |
| .filter(e => e && (e.type === 'note_on' || e.type === 'note_off')) | |
| .map(e => ({ | |
| type: e.type, | |
| note: Number(e.note) || 0, | |
| velocity: Number(e.velocity) || 0, | |
| time: Number(e.time) || 0, | |
| channel: Number(e.channel) || 0 | |
| })); | |
| if (cleaned.length === 0) { | |
| return []; | |
| } | |
| return sortEventsChronologically(cleaned); | |
| } | |
| function normalizeEventsToZero(rawEvents) { | |
| const cleaned = sanitizeEvents(rawEvents); | |
| if (cleaned.length === 0) { | |
| return []; | |
| } | |
| const minTime = Math.min(...cleaned.map(e => e.time)); | |
| return sortEventsChronologically( | |
| cleaned.map(e => ({ | |
| ...e, | |
| time: Math.max(0, e.time - minTime) | |
| })) | |
| ); | |
| } | |
| function clampMidiNote(note) { | |
| const minNote = baseMidi; | |
| const maxNote = baseMidi + (numOctaves * 12) - 1; | |
| return Math.max(minNote, Math.min(maxNote, note)); | |
| } | |
| function eventsToNotePairs(rawEvents) { | |
| const pairs = []; | |
| const activeByNote = new Map(); | |
| const sorted = sortEventsChronologically(rawEvents); | |
| sorted.forEach(event => { | |
| const note = Number(event.note) || 0; | |
| const time = Number(event.time) || 0; | |
| const velocity = Number(event.velocity) || 100; | |
| if (event.type === 'note_on' && velocity > 0) { | |
| if (!activeByNote.has(note)) activeByNote.set(note, []); | |
| activeByNote.get(note).push({ start: time, velocity }); | |
| return; | |
| } | |
| if (event.type === 'note_off' || (event.type === 'note_on' && velocity <= 0)) { | |
| const stack = activeByNote.get(note); | |
| if (!stack || stack.length === 0) return; | |
| const active = stack.shift(); | |
| const end = Math.max(active.start + 0.05, time); | |
| pairs.push({ | |
| note: clampMidiNote(note), | |
| start: active.start, | |
| end, | |
| velocity: Math.max(1, Math.min(127, active.velocity)) | |
| }); | |
| } | |
| }); | |
| return pairs.sort((a, b) => a.start - b.start); | |
| } | |
| function notePairsToEvents(pairs) { | |
| const eventsOut = []; | |
| pairs.forEach(pair => { | |
| const note = clampMidiNote(Math.round(pair.note)); | |
| const start = Math.max(0, Number(pair.start) || 0); | |
| const end = Math.max(start + 0.05, Number(pair.end) || start + 0.2); | |
| const velocity = Math.max(1, Math.min(127, Math.round(Number(pair.velocity) || 100))); | |
| eventsOut.push({ | |
| type: 'note_on', | |
| note, | |
| velocity, | |
| time: start, | |
| channel: 0 | |
| }); | |
| eventsOut.push({ | |
| type: 'note_off', | |
| note, | |
| velocity: 0, | |
| time: end, | |
| channel: 0 | |
| }); | |
| }); | |
| return sortEventsChronologically(eventsOut); | |
| } | |
| function trimNotePairs(pairs, maxNotes, maxDurationSec) { | |
| const out = []; | |
| for (let i = 0; i < pairs.length; i++) { | |
| if (out.length >= maxNotes) break; | |
| if (pairs[i].start > maxDurationSec) break; | |
| const boundedEnd = Math.min(pairs[i].end, maxDurationSec); | |
| out.push({ | |
| ...pairs[i], | |
| end: Math.max(pairs[i].start + 0.05, boundedEnd) | |
| }); | |
| } | |
| return out; | |
| } | |
| function smoothPairLeaps(pairs, maxLeapSemitones = 7) { | |
| if (pairs.length <= 1) return pairs; | |
| const smoothed = [{ ...pairs[0], note: clampMidiNote(pairs[0].note) }]; | |
| for (let i = 1; i < pairs.length; i++) { | |
| const prev = smoothed[i - 1].note; | |
| let current = pairs[i].note; | |
| while (Math.abs(current - prev) > maxLeapSemitones) { | |
| current += current > prev ? -12 : 12; | |
| } | |
| smoothed.push({ | |
| ...pairs[i], | |
| note: clampMidiNote(current) | |
| }); | |
| } | |
| return smoothed; | |
| } | |
| function appendMotifEcho(pairs, callEvents, maxDurationSec) { | |
| const callPitches = normalizeEventsToZero(callEvents) | |
| .filter(e => e.type === 'note_on' && e.velocity > 0) | |
| .map(e => clampMidiNote(Number(e.note) || 0)) | |
| .slice(0, 2); | |
| if (callPitches.length === 0) return pairs; | |
| let nextStart = pairs.length > 0 ? pairs[pairs.length - 1].end + 0.1 : 0.2; | |
| const out = [...pairs]; | |
| callPitches.forEach((pitch, idx) => { | |
| const start = nextStart + (idx * 0.28); | |
| if (start >= maxDurationSec) return; | |
| out.push({ | |
| note: pitch, | |
| start, | |
| end: Math.min(maxDurationSec, start + 0.22), | |
| velocity: 96 | |
| }); | |
| }); | |
| return out; | |
| } | |
| function applyPlayfulShift(pairs) { | |
| return pairs.map((pair, idx) => { | |
| if (idx % 2 === 0) return pair; | |
| const direction = idx % 4 === 1 ? 2 : -2; | |
| return { | |
| ...pair, | |
| note: clampMidiNote(pair.note + direction) | |
| }; | |
| }); | |
| } | |
| // Generic preset getter - consolidates 3 similar functions | |
| function getSelectedPreset(selectElement, presetMap, defaultKey, idKey) { | |
| const id = selectElement ? selectElement.value : defaultKey; | |
| return { | |
| [idKey]: id, | |
| ...(presetMap[id] || presetMap[defaultKey]) | |
| }; | |
| } | |
| function getSelectedStylePreset() { | |
| return getSelectedPreset(responseStyleSelect, RESPONSE_STYLE_PRESETS, 'melodic', 'styleId'); | |
| } | |
| function getSelectedResponseMode() { | |
| return getSelectedPreset(responseModeSelect, RESPONSE_MODES, 'raw_godzilla', 'modeId'); | |
| } | |
| function getSelectedResponseLengthPreset() { | |
| return getSelectedPreset(responseLengthSelect, RESPONSE_LENGTH_PRESETS, 'short', 'lengthId'); | |
| } | |
| function getSelectedGameQuantization() { | |
| const modeId = quantizationSelect ? quantizationSelect.value : 'eighth'; | |
| return { | |
| modeId, | |
| ...(GAME_QUANTIZATION_PRESETS[modeId] || GAME_QUANTIZATION_PRESETS.eighth) | |
| }; | |
| } | |
| function getSelectedGameBars(selectElement, fallback = 2) { | |
| const raw = selectElement ? Number(selectElement.value) : fallback; | |
| if (raw === 1 || raw === 2) return raw; | |
| return fallback; | |
| } | |
| function getSelectedUserBars() { | |
| return getSelectedGameBars(userBarsSelect, 2); | |
| } | |
| function getSelectedAIBars() { | |
| return getSelectedGameBars(aiBarsSelect, 2); | |
| } | |
| function getDecodingOptionsForMode(modeId) { | |
| if (modeId === 'raw_godzilla') { | |
| return { temperature: 1.0, top_p: 0.98, num_candidates: 1 }; | |
| } | |
| if (modeId === 'musical_polish') { | |
| return { temperature: 0.85, top_p: 0.93, num_candidates: 4 }; | |
| } | |
| return { temperature: 0.9, top_p: 0.95, num_candidates: 3 }; | |
| } | |
| function getSelectedDecodingOptions() { | |
| const mode = getSelectedResponseMode(); | |
| return getDecodingOptionsForMode(mode.modeId); | |
| } | |
| function getSelectedRuntime() { | |
| if (!runtimeSelect || !runtimeSelect.value) return 'auto'; | |
| return runtimeSelect.value; | |
| } | |
| function getRuntimeModeLabel(mode) { | |
| if (mode === 'gpu') return 'ZeroGPU'; | |
| if (mode === 'auto') return 'Auto (GPU->CPU)'; | |
| return 'CPU'; | |
| } | |
| function resetAutoRuntimeResolution() { | |
| resolvedAutoRuntimeMode = null; | |
| } | |
| function resolveAutoRuntimeMode(engineId) { | |
| if (engineId !== 'godzilla_continue') { | |
| return 'cpu'; | |
| } | |
| if (resolvedAutoRuntimeMode === 'cpu' || resolvedAutoRuntimeMode === 'gpu') { | |
| return resolvedAutoRuntimeMode; | |
| } | |
| const runtimeInfo = serverConfig && typeof serverConfig === 'object' | |
| ? serverConfig.runtime | |
| : null; | |
| const defaultMode = runtimeInfo && typeof runtimeInfo.default_mode === 'string' | |
| ? runtimeInfo.default_mode | |
| : null; | |
| if (defaultMode === 'cpu' || defaultMode === 'gpu') { | |
| resolvedAutoRuntimeMode = defaultMode; | |
| } else { | |
| resolvedAutoRuntimeMode = 'gpu'; | |
| } | |
| const label = resolvedAutoRuntimeMode === 'gpu' ? 'ZeroGPU' : 'CPU'; | |
| logToTerminal(`Runtime auto resolved to ${label} and will stay fixed this session.`, 'timestamp'); | |
| return resolvedAutoRuntimeMode; | |
| } | |
| async function probeZeroGpuAvailabilityOnInit() { | |
| if (getSelectedRuntime() !== 'auto') return; | |
| // If the backend already told us GPU availability via config, lock immediately | |
| // without a network probe — eliminates the delay on CPU-only machines. | |
| const runtimeInfo = serverConfig && typeof serverConfig === 'object' | |
| ? serverConfig.runtime | |
| : null; | |
| const gpuAvailable = runtimeInfo && runtimeInfo.gpu_available === true; | |
| if (!gpuAvailable) { | |
| resolvedAutoRuntimeMode = 'cpu'; | |
| logToTerminal('Runtime auto: no GPU detected by backend, locked to CPU.', 'timestamp'); | |
| return; | |
| } | |
| if (autoRuntimeProbeInFlight) return; | |
| autoRuntimeProbeInFlight = true; | |
| try { | |
| logToTerminal('Runtime auto: probing ZeroGPU availability...', 'timestamp'); | |
| const probePayload = { | |
| engine_id: 'parrot', | |
| events: [ | |
| { type: 'note_on', note: 60, velocity: 64, time: 0, channel: 0 }, | |
| { type: 'note_off', note: 60, velocity: 0, time: 0.1, channel: 0 } | |
| ], | |
| options: {} | |
| }; | |
| const probeResult = await callGradioBridge('process_engine_gpu', probePayload); | |
| if (probeResult && !probeResult.error && Array.isArray(probeResult.events)) { | |
| resolvedAutoRuntimeMode = 'gpu'; | |
| logToTerminal('Runtime auto probe: ZeroGPU available. Auto locked to ZeroGPU.', 'timestamp'); | |
| } else { | |
| resolvedAutoRuntimeMode = 'cpu'; | |
| const reason = probeResult && probeResult.error ? probeResult.error : 'unavailable'; | |
| logToTerminal(`Runtime auto probe: ZeroGPU unavailable (${reason}). Auto locked to CPU.`, 'timestamp'); | |
| } | |
| } catch (err) { | |
| resolvedAutoRuntimeMode = 'cpu'; | |
| logToTerminal(`Runtime auto probe failed (${err.message}). Auto locked to CPU.`, 'timestamp'); | |
| } finally { | |
| autoRuntimeProbeInFlight = false; | |
| } | |
| } | |
| function beatSec() { | |
| return 60 / GAME_BPM; | |
| } | |
| function barSec() { | |
| return beatSec() * GAME_BEATS_PER_BAR; | |
| } | |
| function barsToSeconds(bars) { | |
| return Math.max(1, Number(bars) || 1) * barSec(); | |
| } | |
| function nowGameSec() { | |
| return nowSec() - gameClockOriginSec; | |
| } | |
| function nextBarAlignedStart(minLeadBeats = GAME_COUNTIN_BEATS) { | |
| const minStart = nowGameSec() + (Math.max(0, minLeadBeats) * beatSec()); | |
| const barLength = barSec(); | |
| return Math.ceil(minStart / barLength) * barLength; | |
| } | |
| function quantizeToStep(value, step) { | |
| if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) { | |
| return value; | |
| } | |
| return Math.round(value / step) * step; | |
| } | |
| function moveByOctaveTowardTarget(note, target) { | |
| let candidate = note; | |
| while (candidate + 12 <= target) { | |
| candidate += 12; | |
| } | |
| while (candidate - 12 >= target) { | |
| candidate -= 12; | |
| } | |
| const up = clampMidiNote(candidate + 12); | |
| const down = clampMidiNote(candidate - 12); | |
| const current = clampMidiNote(candidate); | |
| const best = [current, up, down].reduce((winner, value) => { | |
| return Math.abs(value - target) < Math.abs(winner - target) ? value : winner; | |
| }, current); | |
| return clampMidiNote(best); | |
| } | |
| function getCallProfile(callEvents) { | |
| const normalizedCall = normalizeEventsToZero(callEvents); | |
| const pitches = normalizedCall | |
| .filter(e => e.type === 'note_on' && e.velocity > 0) | |
| .map(e => clampMidiNote(Number(e.note) || baseMidi)); | |
| const velocities = normalizedCall | |
| .filter(e => e.type === 'note_on' && e.velocity > 0) | |
| .map(e => Math.max(1, Math.min(127, Number(e.velocity) || 100))); | |
| const keyboardCenter = baseMidi + Math.floor((numOctaves * 12) / 2); | |
| const center = pitches.length > 0 | |
| ? pitches.reduce((sum, value) => sum + value, 0) / pitches.length | |
| : keyboardCenter; | |
| const finalPitch = pitches.length > 0 ? pitches[pitches.length - 1] : keyboardCenter; | |
| const avgVelocity = velocities.length > 0 | |
| ? velocities.reduce((sum, value) => sum + value, 0) / velocities.length | |
| : 100; | |
| return { pitches, center, finalPitch, avgVelocity }; | |
| } | |
| function applyResponseStyle(rawResponseEvents, callEvents, lengthPreset) { | |
| const preset = getSelectedStylePreset(); | |
| const targetMaxNotes = Math.max(preset.maxNotes, lengthPreset.maxNotes); | |
| const targetMaxDuration = Math.max(preset.maxDurationSec, lengthPreset.maxDurationSec); | |
| let notePairs = eventsToNotePairs(normalizeEventsToZero(rawResponseEvents)); | |
| notePairs = trimNotePairs(notePairs, targetMaxNotes, targetMaxDuration); | |
| if (preset.playfulShift) { | |
| notePairs = applyPlayfulShift(notePairs); | |
| } | |
| if (preset.smoothLeaps) { | |
| notePairs = smoothPairLeaps(notePairs); | |
| } | |
| if (preset.addMotifEcho) { | |
| notePairs = appendMotifEcho(notePairs, callEvents, targetMaxDuration); | |
| notePairs = trimNotePairs(notePairs, targetMaxNotes, targetMaxDuration); | |
| } | |
| return { | |
| styleLabel: preset.label, | |
| events: notePairsToEvents(notePairs) | |
| }; | |
| } | |
| function applyMusicalPolish(rawResponseEvents, callEvents, lengthPreset) { | |
| const stylePreset = getSelectedStylePreset(); | |
| const callProfile = getCallProfile(callEvents); | |
| let notePairs = eventsToNotePairs(normalizeEventsToZero(rawResponseEvents)); | |
| if (notePairs.length === 0) { | |
| const fallbackPitches = callProfile.pitches.slice(0, 4); | |
| if (fallbackPitches.length === 0) { | |
| return []; | |
| } | |
| notePairs = fallbackPitches.map((pitch, idx) => { | |
| const start = idx * 0.28; | |
| return { | |
| note: clampMidiNote(pitch), | |
| start, | |
| end: start + 0.24, | |
| velocity: Math.round(callProfile.avgVelocity) | |
| }; | |
| }); | |
| } | |
| const polished = []; | |
| let previousStart = -1; | |
| for (let i = 0; i < notePairs.length; i++) { | |
| const source = notePairs[i]; | |
| let note = moveByOctaveTowardTarget(source.note, callProfile.center); | |
| if (polished.length > 0) { | |
| const prev = polished[polished.length - 1].note; | |
| while (Math.abs(note - prev) > 7) { | |
| note += note > prev ? -12 : 12; | |
| } | |
| note = clampMidiNote(note); | |
| } | |
| const quantizedStart = Math.max(0, quantizeToStep(source.start, 0.125)); | |
| const start = Math.max(quantizedStart, previousStart + 0.06); | |
| previousStart = start; | |
| const rawDur = Math.max(0.1, source.end - source.start); | |
| const duration = Math.max(0.12, Math.min(0.9, quantizeToStep(rawDur, 0.0625))); | |
| const velocity = Math.round( | |
| (Math.max(1, Math.min(127, source.velocity)) * 0.6) | |
| + (callProfile.avgVelocity * 0.4) | |
| ); | |
| polished.push({ | |
| note, | |
| start, | |
| end: start + duration, | |
| velocity: Math.max(1, Math.min(127, velocity)) | |
| }); | |
| } | |
| if (polished.length > 0) { | |
| polished[polished.length - 1].note = moveByOctaveTowardTarget( | |
| polished[polished.length - 1].note, | |
| callProfile.finalPitch | |
| ); | |
| } | |
| let out = trimNotePairs(polished, lengthPreset.maxNotes, lengthPreset.maxDurationSec); | |
| if (stylePreset.addMotifEcho) { | |
| out = appendMotifEcho(out, callEvents, lengthPreset.maxDurationSec); | |
| } | |
| if (stylePreset.playfulShift) { | |
| out = applyPlayfulShift(out); | |
| } | |
| out = smoothPairLeaps(out, 6); | |
| out = trimNotePairs(out, lengthPreset.maxNotes, lengthPreset.maxDurationSec); | |
| return out; | |
| } | |
| function buildProcessedAIResponse(rawResponseEvents, callEvents) { | |
| const mode = getSelectedResponseMode(); | |
| const lengthPreset = getSelectedResponseLengthPreset(); | |
| if (mode.modeId === 'raw_godzilla') { | |
| return { | |
| label: `${mode.label} (${lengthPreset.label})`, | |
| events: normalizeEventsToZero(rawResponseEvents || []) | |
| }; | |
| } | |
| if (mode.modeId === 'musical_polish') { | |
| return { | |
| label: `${mode.label} (${lengthPreset.label})`, | |
| events: notePairsToEvents(applyMusicalPolish(rawResponseEvents || [], callEvents, lengthPreset)) | |
| }; | |
| } | |
| const styled = applyResponseStyle(rawResponseEvents || [], callEvents, lengthPreset); | |
| return { | |
| label: `${mode.label} / ${styled.styleLabel} (${lengthPreset.label})`, | |
| events: styled.events | |
| }; | |
| } | |
| function buildGameProcessedAIResponse(rawResponseEvents, callEvents, aiBars) { | |
| const mode = getSelectedResponseMode(); | |
| const gameLengthPreset = { | |
| label: `${aiBars} bar${aiBars > 1 ? 's' : ''}`, | |
| maxNotes: aiBars === 1 ? 12 : 24, | |
| maxDurationSec: barsToSeconds(aiBars) | |
| }; | |
| if (mode.modeId === 'raw_godzilla') { | |
| return { | |
| label: `${mode.label} (${gameLengthPreset.label})`, | |
| events: normalizeEventsToZero(rawResponseEvents || []) | |
| }; | |
| } | |
| if (mode.modeId === 'musical_polish') { | |
| return { | |
| label: `${mode.label} (${gameLengthPreset.label})`, | |
| events: notePairsToEvents(applyMusicalPolish(rawResponseEvents || [], callEvents, gameLengthPreset)) | |
| }; | |
| } | |
| const styled = applyResponseStyle(rawResponseEvents || [], callEvents, gameLengthPreset); | |
| return { | |
| label: `${mode.label} / ${styled.styleLabel} (${gameLengthPreset.label})`, | |
| events: styled.events | |
| }; | |
| } | |
| function clampValue(value, minValue, maxValue) { | |
| return Math.max(minValue, Math.min(maxValue, value)); | |
| } | |
| function stretchNotePairsToDuration(pairs, targetDurationSec) { | |
| if (!Array.isArray(pairs) || pairs.length === 0) { | |
| return []; | |
| } | |
| const safeTarget = Math.max(0.1, Number(targetDurationSec) || 0.1); | |
| const sourceEnd = pairs.reduce((maxEnd, pair) => Math.max(maxEnd, Number(pair.end) || 0), 0); | |
| if (sourceEnd <= 0) { | |
| const spacing = safeTarget / Math.max(1, pairs.length); | |
| return pairs.map((pair, idx) => { | |
| const start = idx * spacing; | |
| const end = Math.min(safeTarget, start + Math.max(0.08, spacing * 0.8)); | |
| return { | |
| ...pair, | |
| start, | |
| end: Math.max(start + 0.08, end) | |
| }; | |
| }); | |
| } | |
| const scale = safeTarget / sourceEnd; | |
| return pairs.map((pair) => ({ | |
| ...pair, | |
| start: Math.max(0, (Number(pair.start) || 0) * scale), | |
| end: Math.max(0, (Number(pair.end) || 0) * scale) | |
| })); | |
| } | |
| function quantizeAiResponseForGame(rawEvents, aiBars) { | |
| const maxDurationSec = barsToSeconds(aiBars); | |
| const quantPreset = getSelectedGameQuantization(); | |
| const quantStepSec = Number.isFinite(quantPreset.stepBeats) | |
| ? beatSec() * quantPreset.stepBeats | |
| : null; | |
| const minDurationSec = quantStepSec | |
| ? Math.max(0.08, quantStepSec * 0.5) | |
| : 0.08; | |
| const rawPairs = eventsToNotePairs(normalizeEventsToZero(rawEvents)); | |
| if (rawPairs.length === 0) { | |
| return []; | |
| } | |
| const pairs = stretchNotePairsToDuration(rawPairs, maxDurationSec); | |
| const out = []; | |
| pairs.forEach((pair) => { | |
| const rawStart = quantStepSec ? quantizeToStep(pair.start, quantStepSec) : pair.start; | |
| const quantizedStart = clampValue( | |
| rawStart, | |
| 0, | |
| Math.max(0, maxDurationSec - minDurationSec) | |
| ); | |
| const rawEnd = quantStepSec ? quantizeToStep(pair.end, quantStepSec) : pair.end; | |
| const end = clampValue( | |
| Math.max(quantizedStart + minDurationSec, rawEnd), | |
| quantizedStart + minDurationSec, | |
| maxDurationSec | |
| ); | |
| if (end - quantizedStart < minDurationSec * 0.75) { | |
| return; | |
| } | |
| out.push({ | |
| note: clampMidiNote(Math.round(pair.note)), | |
| start: quantizedStart, | |
| end, | |
| velocity: clampValue(Math.round(pair.velocity || 100), 1, 127) | |
| }); | |
| }); | |
| return notePairsToEvents(out); | |
| } | |
| function getGameGenerateTokens(aiBars) { | |
| // Request enough tokens for up to 128 notes (3 tokens per note). | |
| // Generation stops on the frontend once the bar is filled. | |
| return 384; | |
| } | |
| function getGridPhaseLabel(phase) { | |
| const labels = { | |
| idle: 'Idle', | |
| starting: 'Starting', | |
| user_countdown: 'User Count-In', | |
| user_turn: 'User Turn', | |
| ai_thinking: 'AI Thinking', | |
| ai_countdown: 'AI Count-In', | |
| ai_playback: 'AI Playback' | |
| }; | |
| return labels[phase] || 'Idle'; | |
| } | |
| function prepareCanvasContext(canvas) { | |
| if (!canvas) return null; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return null; | |
| const dpr = window.devicePixelRatio || 1; | |
| const width = Math.max(1, Math.floor(canvas.clientWidth * dpr)); | |
| const height = Math.max(1, Math.floor(canvas.clientHeight * dpr)); | |
| if (canvas.width !== width || canvas.height !== height) { | |
| canvas.width = width; | |
| canvas.height = height; | |
| } | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| return ctx; | |
| } | |
| function getGridPairs(eventsInput, maxDurationSec) { | |
| const pairs = eventsToNotePairs(sanitizeEvents(eventsInput)); | |
| const maxDur = Math.max(0.1, Number(maxDurationSec) || 0.1); | |
| return pairs | |
| .filter(pair => pair.start < maxDur) | |
| .map(pair => ({ | |
| ...pair, | |
| start: Math.max(0, pair.start), | |
| end: Math.max(pair.start + 0.05, Math.min(pair.end, maxDur)) | |
| })); | |
| } | |
| function shouldAnimateGrid() { | |
| return gridPlayheads.user.active || gridPlayheads.ai.active; | |
| } | |
| function stopGridAnimationLoop() { | |
| if (gridAnimationFrameId !== null) { | |
| window.cancelAnimationFrame(gridAnimationFrameId); | |
| gridAnimationFrameId = null; | |
| } | |
| } | |
| function stopGridPlayhead(lane) { | |
| if (!gridPlayheads[lane]) return; | |
| gridPlayheads[lane].active = false; | |
| if (!shouldAnimateGrid()) { | |
| stopGridAnimationLoop(); | |
| } | |
| } | |
| function stopAllGridPlayheads() { | |
| stopGridPlayhead('user'); | |
| stopGridPlayhead('ai'); | |
| } | |
| function readGridPlayheadSec(lane) { | |
| const state = gridPlayheads[lane]; | |
| if (!state || !state.active) return null; | |
| const elapsed = Math.max(0, nowSec() - state.startWallSec); | |
| if (elapsed >= state.durationSec) { | |
| state.active = false; | |
| return state.durationSec; | |
| } | |
| return elapsed; | |
| } | |
| function runGridAnimationFrame() { | |
| if (!shouldAnimateGrid()) { | |
| gridAnimationFrameId = null; | |
| return; | |
| } | |
| renderTurnGrid({ phase: gamePhase }); | |
| gridAnimationFrameId = window.requestAnimationFrame(runGridAnimationFrame); | |
| } | |
| function ensureGridAnimationLoop() { | |
| if (gridAnimationFrameId !== null) return; | |
| gridAnimationFrameId = window.requestAnimationFrame(runGridAnimationFrame); | |
| } | |
| function startGridPlayhead(lane, durationSec) { | |
| if (!gridPlayheads[lane]) return; | |
| gridPlayheads[lane].active = true; | |
| gridPlayheads[lane].startWallSec = nowSec(); | |
| gridPlayheads[lane].durationSec = Math.max(0.1, Number(durationSec) || 0.1); | |
| ensureGridAnimationLoop(); | |
| } | |
| function getEventsDurationSec(eventsInput) { | |
| if (!Array.isArray(eventsInput) || eventsInput.length === 0) { | |
| return 0.1; | |
| } | |
| return Math.max( | |
| 0.1, | |
| ...eventsInput.map(event => Math.max(0, Number(event.time) || 0)) | |
| ); | |
| } | |
| function drawTurnGridLane(canvas, eventsInput, bars, laneType = 'user', playheadSec = null) { | |
| const ctx = prepareCanvasContext(canvas); | |
| if (!ctx) return; | |
| const width = canvas.clientWidth; | |
| const height = canvas.clientHeight; | |
| const padX = 8; | |
| const padY = 8; | |
| const innerW = Math.max(1, width - (padX * 2)); | |
| const innerH = Math.max(1, height - (padY * 2)); | |
| const rows = numOctaves * 12; | |
| const totalBars = Math.max(1, bars); | |
| const totalSixteenths = totalBars * 16; | |
| const maxDurationSec = barsToSeconds(totalBars); | |
| const rowH = innerH / rows; | |
| const bgGrad = ctx.createLinearGradient(0, 0, 0, height); | |
| if (laneType === 'ai') { | |
| bgGrad.addColorStop(0, 'rgba(31, 10, 48, 0.95)'); | |
| bgGrad.addColorStop(1, 'rgba(8, 4, 18, 0.98)'); | |
| } else { | |
| bgGrad.addColorStop(0, 'rgba(8, 16, 42, 0.95)'); | |
| bgGrad.addColorStop(1, 'rgba(5, 5, 18, 0.98)'); | |
| } | |
| ctx.clearRect(0, 0, width, height); | |
| ctx.fillStyle = bgGrad; | |
| ctx.fillRect(0, 0, width, height); | |
| for (let row = 0; row <= rows; row++) { | |
| const y = padY + (row * rowH); | |
| ctx.strokeStyle = row % 12 === 0 | |
| ? 'rgba(62, 244, 255, 0.2)' | |
| : 'rgba(62, 244, 255, 0.07)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(padX, y); | |
| ctx.lineTo(padX + innerW, y); | |
| ctx.stroke(); | |
| } | |
| for (let step = 0; step <= totalSixteenths; step++) { | |
| const x = padX + ((step / totalSixteenths) * innerW); | |
| if (step % 16 === 0) { | |
| ctx.strokeStyle = laneType === 'ai' | |
| ? 'rgba(255, 63, 176, 0.58)' | |
| : 'rgba(62, 244, 255, 0.58)'; | |
| ctx.lineWidth = 1.4; | |
| } else if (step % 4 === 0) { | |
| ctx.strokeStyle = 'rgba(154, 184, 255, 0.34)'; | |
| ctx.lineWidth = 1.1; | |
| } else { | |
| ctx.strokeStyle = 'rgba(154, 184, 255, 0.12)'; | |
| ctx.lineWidth = 1; | |
| } | |
| ctx.beginPath(); | |
| ctx.moveTo(x, padY); | |
| ctx.lineTo(x, padY + innerH); | |
| ctx.stroke(); | |
| } | |
| const minNote = baseMidi; | |
| const maxNote = baseMidi + (numOctaves * 12) - 1; | |
| const noteRange = Math.max(1, maxNote - minNote + 1); | |
| const pairs = getGridPairs(eventsInput, maxDurationSec); | |
| pairs.forEach((pair) => { | |
| const start = Math.max(0, Math.min(maxDurationSec, pair.start)); | |
| const end = Math.max(start + 0.01, Math.min(maxDurationSec, pair.end)); | |
| const x = padX + ((start / maxDurationSec) * innerW); | |
| const w = Math.max(2.5, ((end - start) / maxDurationSec) * innerW); | |
| const clampedNote = clampMidiNote(Math.round(pair.note)); | |
| const noteRow = maxNote - clampedNote; | |
| const y = padY + (noteRow / noteRange) * innerH; | |
| const h = Math.max(3, rowH - 1.5); | |
| const fill = ctx.createLinearGradient(x, y, x + w, y + h); | |
| if (laneType === 'ai') { | |
| fill.addColorStop(0, 'rgba(255, 95, 196, 0.98)'); | |
| fill.addColorStop(1, 'rgba(199, 92, 255, 0.92)'); | |
| ctx.shadowColor = 'rgba(255, 63, 176, 0.55)'; | |
| } else { | |
| fill.addColorStop(0, 'rgba(76, 255, 255, 0.96)'); | |
| fill.addColorStop(1, 'rgba(76, 161, 255, 0.9)'); | |
| ctx.shadowColor = 'rgba(62, 244, 255, 0.6)'; | |
| } | |
| ctx.shadowBlur = 8; | |
| ctx.fillStyle = fill; | |
| ctx.fillRect(x, y + 0.6, w, h); | |
| ctx.shadowBlur = 0; | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.45)'; | |
| ctx.lineWidth = 0.8; | |
| ctx.strokeRect(x, y + 0.6, w, h); | |
| }); | |
| ctx.strokeStyle = 'rgba(198, 216, 255, 0.32)'; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(padX, padY, innerW, innerH); | |
| if (playheadSec !== null) { | |
| const clampedPlayhead = clampValue(playheadSec, 0, maxDurationSec); | |
| const x = padX + ((clampedPlayhead / maxDurationSec) * innerW); | |
| ctx.strokeStyle = laneType === 'ai' | |
| ? 'rgba(255, 95, 196, 0.95)' | |
| : 'rgba(76, 255, 255, 0.95)'; | |
| ctx.lineWidth = 2; | |
| ctx.shadowBlur = 10; | |
| ctx.shadowColor = laneType === 'ai' | |
| ? 'rgba(255, 63, 176, 0.8)' | |
| : 'rgba(62, 244, 255, 0.85)'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, padY); | |
| ctx.lineTo(x, padY + innerH); | |
| ctx.stroke(); | |
| ctx.shadowBlur = 0; | |
| } | |
| } | |
| function renderTurnGrid({ | |
| userEvents = null, | |
| aiEvents = null, | |
| phase = gamePhase | |
| } = {}) { | |
| if (userEvents !== null) { | |
| gameGridUserEvents = sanitizeEvents(userEvents); | |
| } | |
| if (aiEvents !== null) { | |
| gameGridAIEvents = sanitizeEvents(aiEvents); | |
| } | |
| const userBars = getSelectedUserBars(); | |
| const aiBars = getSelectedAIBars(); | |
| const userPlayheadSec = readGridPlayheadSec('user'); | |
| const aiPlayheadSec = readGridPlayheadSec('ai'); | |
| drawTurnGridLane(userGridCanvas, gameGridUserEvents, userBars, 'user', userPlayheadSec); | |
| drawTurnGridLane(aiGridCanvas, gameGridAIEvents, aiBars, 'ai', aiPlayheadSec); | |
| if (userGridMeta) { | |
| userGridMeta.textContent = `${userBars} bar${userBars > 1 ? 's' : ''}`; | |
| } | |
| if (aiGridMeta) { | |
| aiGridMeta.textContent = `${aiBars} bar${aiBars > 1 ? 's' : ''}`; | |
| } | |
| if (gridPhaseBadge) { | |
| gridPhaseBadge.textContent = getGridPhaseLabel(phase); | |
| } | |
| } | |
| // ============================================================================= | |
| // TERMINAL LOGGING | |
| // ============================================================================= | |
| function logToTerminal(message, className = '') { | |
| const line = document.createElement('div'); | |
| line.className = className; | |
| line.textContent = message; | |
| terminal.appendChild(line); | |
| terminal.scrollTop = terminal.scrollHeight; | |
| // Keep terminal from getting too long (max 500 lines) | |
| while (terminal.children.length > 500) { | |
| terminal.removeChild(terminal.firstChild); | |
| } | |
| } | |
| function initTerminal() { | |
| logToTerminal('╔═══════════════════════════════════════════════════════╗', 'timestamp'); | |
| logToTerminal('║ 🎹 MIDI MONITOR INITIALIZED 🎹 ║', 'timestamp'); | |
| logToTerminal('╚═══════════════════════════════════════════════════════╝', 'timestamp'); | |
| logToTerminal('Ready to capture MIDI events...', 'timestamp'); | |
| logToTerminal('', ''); | |
| } | |
| // ============================================================================= | |
| // RECORDING | |
| // ============================================================================= | |
| function beginRecord() { | |
| events = []; | |
| recording = true; | |
| startTime = nowSec(); | |
| statusEl.textContent = 'Recording...'; | |
| recordBtn.disabled = true; | |
| stopBtn.disabled = false; | |
| playbackBtn.disabled = true; | |
| saveBtn.disabled = true; | |
| logToTerminal('', ''); | |
| logToTerminal('▶▶▶ RECORDING STARTED ◀◀◀', 'timestamp'); | |
| logToTerminal('', ''); | |
| } | |
| function stopRecord() { | |
| recording = false; | |
| statusEl.textContent = `Recorded ${events.length} events`; | |
| recordBtn.disabled = false; | |
| stopBtn.disabled = true; | |
| saveBtn.disabled = events.length === 0; | |
| playbackBtn.disabled = events.length === 0; | |
| logToTerminal('', ''); | |
| logToTerminal(`■■■ RECORDING STOPPED (${events.length} events captured) ■■■`, 'timestamp'); | |
| logToTerminal('', ''); | |
| } | |
| // ============================================================================= | |
| // MIDI NOTE HANDLING | |
| // ============================================================================= | |
| function noteOn(midiNote, velocity = 100) { | |
| const freq = Tone.Frequency(midiNote, "midi").toFrequency(); | |
| synth.triggerAttack(freq, undefined, velocity / 127); | |
| const noteName = midiToNoteName(midiNote); | |
| const captureTimestamp = recording | |
| ? (nowSec() - startTime) | |
| : (gameCaptureActive ? (nowSec() - gameCaptureStartWallSec) : null); | |
| const timestamp = captureTimestamp !== null ? captureTimestamp.toFixed(3) : '--'; | |
| logToTerminal( | |
| `[${timestamp}s] NOTE_ON ${noteName} (${midiNote}) vel=${velocity}`, | |
| 'note-on' | |
| ); | |
| if (recording) { | |
| const event = { | |
| type: 'note_on', | |
| note: midiNote, | |
| velocity: Math.max(1, velocity | 0), | |
| time: nowSec() - startTime, | |
| channel: 0 | |
| }; | |
| events.push(event); | |
| } | |
| if (gameCaptureActive) { | |
| gameCaptureActiveNotes.add(midiNote); | |
| gameCapturedEvents.push({ | |
| type: 'note_on', | |
| note: midiNote, | |
| velocity: Math.max(1, velocity | 0), | |
| time: Math.max(0, nowSec() - gameCaptureStartWallSec), | |
| channel: 0 | |
| }); | |
| renderTurnGrid({ | |
| userEvents: getLiveGameCaptureEvents(), | |
| phase: gamePhase | |
| }); | |
| } | |
| } | |
| function noteOff(midiNote) { | |
| const freq = Tone.Frequency(midiNote, "midi").toFrequency(); | |
| synth.triggerRelease(freq); | |
| const noteName = midiToNoteName(midiNote); | |
| const captureTimestamp = recording | |
| ? (nowSec() - startTime) | |
| : (gameCaptureActive ? (nowSec() - gameCaptureStartWallSec) : null); | |
| const timestamp = captureTimestamp !== null ? captureTimestamp.toFixed(3) : '--'; | |
| logToTerminal( | |
| `[${timestamp}s] NOTE_OFF ${noteName} (${midiNote})`, | |
| 'note-off' | |
| ); | |
| if (recording) { | |
| const event = { | |
| type: 'note_off', | |
| note: midiNote, | |
| velocity: 0, | |
| time: nowSec() - startTime, | |
| channel: 0 | |
| }; | |
| events.push(event); | |
| } | |
| if (gameCaptureActive) { | |
| gameCaptureActiveNotes.delete(midiNote); | |
| gameCapturedEvents.push({ | |
| type: 'note_off', | |
| note: midiNote, | |
| velocity: 0, | |
| time: Math.max(0, nowSec() - gameCaptureStartWallSec), | |
| channel: 0 | |
| }); | |
| renderTurnGrid({ | |
| userEvents: getLiveGameCaptureEvents(), | |
| phase: gamePhase | |
| }); | |
| } | |
| } | |
| // ============================================================================= | |
| // COMPUTER KEYBOARD INPUT | |
| // ============================================================================= | |
| function getKeyElement(midiNote) { | |
| return keyboardEl.querySelector(`.key[data-midi="${midiNote}"]`); | |
| } | |
| function bindGlobalKeyboardHandlers() { | |
| document.addEventListener('keydown', async (ev) => { | |
| const key = ev.key.toLowerCase(); | |
| const activeKeyMap = window.keyMapFromServer || keyMap; // Use server config if available, fallback to hardcoded | |
| if (!activeKeyMap[key] || pressedKeys.has(key)) return; | |
| ev.preventDefault(); | |
| pressedKeys.add(key); | |
| await Tone.start(); | |
| const midiNote = activeKeyMap[key]; | |
| const keyEl = getKeyElement(midiNote); | |
| if (keyEl) keyEl.style.filter = 'brightness(0.85)'; | |
| noteOn(midiNote, 100); | |
| }); | |
| document.addEventListener('keyup', (ev) => { | |
| const key = ev.key.toLowerCase(); | |
| const activeKeyMap = window.keyMapFromServer || keyMap; // Use server config if available, fallback to hardcoded | |
| if (!activeKeyMap[key] || !pressedKeys.has(key)) return; | |
| ev.preventDefault(); | |
| pressedKeys.delete(key); | |
| const midiNote = activeKeyMap[key]; | |
| const keyEl = getKeyElement(midiNote); | |
| if (keyEl) keyEl.style.filter = ''; | |
| noteOff(midiNote); | |
| }); | |
| } | |
| // ============================================================================= | |
| // MOUSE/TOUCH INPUT | |
| // ============================================================================= | |
| function attachPointerEvents() { | |
| keyboardEl.querySelectorAll('.key').forEach(k => { | |
| let pressed = false; | |
| k.addEventListener('pointerdown', async (ev) => { | |
| ev.preventDefault(); | |
| k.setPointerCapture(ev.pointerId); | |
| if (!pressed) { | |
| pressed = true; | |
| k.style.filter = 'brightness(0.85)'; | |
| // Ensure Tone.js audio context is started | |
| await Tone.start(); | |
| const midi = parseInt(k.dataset.midi); | |
| const vel = ev.pressure ? Math.round(ev.pressure * 127) : 100; | |
| noteOn(midi, vel); | |
| } | |
| }); | |
| k.addEventListener('pointerup', (ev) => { | |
| ev.preventDefault(); | |
| if (pressed) { | |
| pressed = false; | |
| k.style.filter = ''; | |
| const midi = parseInt(k.dataset.midi); | |
| noteOff(midi); | |
| } | |
| }); | |
| k.addEventListener('pointerleave', (ev) => { | |
| if (pressed) { | |
| pressed = false; | |
| k.style.filter = ''; | |
| const midi = parseInt(k.dataset.midi); | |
| noteOff(midi); | |
| } | |
| }); | |
| }); | |
| } | |
| // ============================================================================= | |
| // MIDI FILE EXPORT | |
| // ============================================================================= | |
| async function saveMIDI() { | |
| if (recording) stopRecord(); | |
| if (events.length === 0) return alert('No events recorded.'); | |
| statusEl.textContent = 'Uploading…'; | |
| saveBtn.disabled = true; | |
| try { | |
| const payload = await callGradioBridge('save_midi', events); | |
| if (!payload || payload.error || !payload.midi_base64) { | |
| throw new Error(payload && payload.error ? payload.error : 'Invalid API response'); | |
| } | |
| const binStr = atob(payload.midi_base64); | |
| const bytes = new Uint8Array(binStr.length); | |
| for (let i = 0; i < binStr.length; i++) { | |
| bytes[i] = binStr.charCodeAt(i); | |
| } | |
| const blob = new Blob([bytes], {type: 'audio/midi'}); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'recording.mid'; | |
| a.click(); | |
| statusEl.textContent = 'Downloaded .mid'; | |
| } catch (err) { | |
| console.error(err); | |
| statusEl.textContent = 'Error saving MIDI'; | |
| alert('Error: ' + err.message); | |
| } finally { | |
| saveBtn.disabled = false; | |
| } | |
| } | |
| function clearTrackedGameTimeouts() { | |
| gameTimeoutIds.forEach((id) => clearTimeout(id)); | |
| gameTimeoutIds.clear(); | |
| } | |
| function scheduleGameTimeout(callback, delayMs) { | |
| const safeDelayMs = Math.max(0, Number(delayMs) || 0); | |
| const timeoutId = setTimeout(() => { | |
| gameTimeoutIds.delete(timeoutId); | |
| callback(); | |
| }, safeDelayMs); | |
| gameTimeoutIds.add(timeoutId); | |
| return timeoutId; | |
| } | |
| function scheduleGameAt(targetGameSec, callback) { | |
| const delayMs = (targetGameSec - nowGameSec()) * 1000; | |
| return scheduleGameTimeout(callback, delayMs); | |
| } | |
| function hideCountdownOverlay() { | |
| if (!countdownOverlay || !countdownText) return; | |
| countdownOverlay.classList.remove('active'); | |
| countdownText.classList.remove('pulse', 'go'); | |
| countdownText.textContent = ''; | |
| } | |
| function showCountdownCue(text, isGo = false) { | |
| if (!countdownOverlay || !countdownText) return; | |
| countdownOverlay.classList.add('active'); | |
| countdownText.textContent = text; | |
| countdownText.classList.remove('pulse', 'go'); | |
| // Force restart animation for each cue. | |
| // eslint-disable-next-line no-unused-expressions | |
| countdownText.offsetWidth; | |
| countdownText.classList.add('pulse'); | |
| if (isGo) { | |
| countdownText.classList.add('go'); | |
| } | |
| } | |
| function scheduleCountdown(targetStartSec, label, sessionId) { | |
| [3, 2, 1].forEach((count) => { | |
| scheduleGameAt(targetStartSec - (count * beatSec()), () => { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| statusEl.textContent = `${label} in ${count}...`; | |
| showCountdownCue(String(count)); | |
| }); | |
| }); | |
| scheduleGameAt(targetStartSec, () => { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| statusEl.textContent = `${label}: GO`; | |
| showCountdownCue('GO', true); | |
| }); | |
| scheduleGameAt(targetStartSec + (beatSec() * 0.7), () => { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| hideCountdownOverlay(); | |
| }); | |
| } | |
| function ensureMetronomeInstruments() { | |
| if (!metronomeKick) { | |
| metronomeKick = new Tone.MembraneSynth({ | |
| pitchDecay: 0.03, | |
| octaves: 4, | |
| envelope: { | |
| attack: 0.001, | |
| decay: 0.22, | |
| sustain: 0 | |
| } | |
| }).toDestination(); | |
| } | |
| if (!metronomeSnare) { | |
| metronomeSnare = new Tone.NoiseSynth({ | |
| noise: { type: 'white' }, | |
| envelope: { | |
| attack: 0.001, | |
| decay: 0.16, | |
| sustain: 0 | |
| } | |
| }).toDestination(); | |
| } | |
| if (!metronomeHat) { | |
| metronomeHat = new Tone.MetalSynth({ | |
| frequency: 270, | |
| envelope: { | |
| attack: 0.001, | |
| decay: 0.08, | |
| release: 0.02 | |
| }, | |
| harmonicity: 4.1, | |
| modulationIndex: 18, | |
| resonance: 1200 | |
| }).toDestination(); | |
| } | |
| } | |
| function playMetronomeBeat(beatIndex) { | |
| const beatInBar = beatIndex % GAME_BEATS_PER_BAR; | |
| if (metronomeHat) { | |
| metronomeHat.triggerAttackRelease('16n', undefined, beatInBar === 0 ? 0.24 : 0.16); | |
| } | |
| if (metronomeKick && (beatInBar === 0 || beatInBar === 2)) { | |
| metronomeKick.triggerAttackRelease('C1', '8n', undefined, beatInBar === 0 ? 0.62 : 0.5); | |
| } | |
| if (metronomeSnare && (beatInBar === 1 || beatInBar === 3)) { | |
| metronomeSnare.triggerAttackRelease('16n', undefined, 0.28); | |
| } | |
| } | |
| function scheduleNextMetronomeBeat(sessionId) { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| const targetBeatIndex = metronomeBeatIndex; | |
| const beatTimeSec = targetBeatIndex * beatSec(); | |
| scheduleGameAt(beatTimeSec, () => { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| playMetronomeBeat(targetBeatIndex); | |
| metronomeBeatIndex = targetBeatIndex + 1; | |
| scheduleNextMetronomeBeat(sessionId); | |
| }); | |
| } | |
| function startGameMetronome(sessionId) { | |
| ensureMetronomeInstruments(); | |
| metronomeBeatIndex = Math.max(0, Math.floor(nowGameSec() / beatSec())); | |
| scheduleNextMetronomeBeat(sessionId); | |
| } | |
| function stopGameMetronome() { | |
| // Beat scheduling is canceled via clearTrackedGameTimeouts(). | |
| // Drum voices are one-shots so no sustained release handling is needed here. | |
| } | |
| async function processEventsThroughEngine(inputEvents, options = {}) { | |
| const selectedEngineId = engineSelect.value; | |
| if (!selectedEngineId || selectedEngineId === 'parrot') { | |
| return { events: inputEvents }; | |
| } | |
| const requestStartedMs = performance.now(); | |
| const requestOptions = { ...options }; | |
| const runtimeMode = getSelectedRuntime(); | |
| const effectiveRuntimeMode = runtimeMode === 'auto' | |
| ? resolveAutoRuntimeMode(selectedEngineId) | |
| : runtimeMode; | |
| if ( | |
| selectedEngineId === 'godzilla_continue' | |
| && typeof requestOptions.generate_tokens !== 'number' | |
| ) { | |
| requestOptions.generate_tokens = RESPONSE_LENGTH_PRESETS.medium.generateTokens; | |
| } | |
| let bridgeAction = 'process_engine_cpu'; | |
| let runtimeUsed = 'cpu'; | |
| if (selectedEngineId === 'godzilla_continue') { | |
| if (effectiveRuntimeMode === 'gpu') { | |
| bridgeAction = 'process_engine_gpu'; | |
| runtimeUsed = 'gpu'; | |
| } | |
| } | |
| const requestPayload = { | |
| engine_id: selectedEngineId, | |
| events: inputEvents, | |
| options: requestOptions | |
| }; | |
| let result; | |
| const primaryAttemptStartMs = performance.now(); | |
| try { | |
| result = await callGradioBridge(bridgeAction, requestPayload); | |
| } catch (err) { | |
| const primaryAttemptMs = performance.now() - primaryAttemptStartMs; | |
| if ( | |
| selectedEngineId === 'godzilla_continue' | |
| && runtimeMode === 'auto' | |
| && bridgeAction === 'process_engine_gpu' | |
| ) { | |
| logToTerminal( | |
| `Runtime auto: ZeroGPU failed after ${Math.round(primaryAttemptMs)}ms (${err.message}), retrying on CPU.`, | |
| 'timestamp' | |
| ); | |
| resolvedAutoRuntimeMode = 'cpu'; | |
| logToTerminal('Runtime auto switched to CPU and will remain on CPU.', 'timestamp'); | |
| const cpuAttemptStartMs = performance.now(); | |
| result = await callGradioBridge('process_engine_cpu', requestPayload); | |
| runtimeUsed = 'cpu'; | |
| const cpuAttemptMs = performance.now() - cpuAttemptStartMs; | |
| const totalMs = performance.now() - requestStartedMs; | |
| logToTerminal( | |
| `Inference fallback success on CPU in ${Math.round(cpuAttemptMs)}ms (total ${Math.round(totalMs)}ms).`, | |
| 'timestamp' | |
| ); | |
| } else { | |
| throw err; | |
| } | |
| } | |
| if ( | |
| result | |
| && result.error | |
| && selectedEngineId === 'godzilla_continue' | |
| && runtimeMode === 'auto' | |
| && bridgeAction === 'process_engine_gpu' | |
| ) { | |
| const primaryAttemptMs = performance.now() - primaryAttemptStartMs; | |
| logToTerminal( | |
| `Runtime auto: ZeroGPU error after ${Math.round(primaryAttemptMs)}ms (${result.error}), retrying on CPU.`, | |
| 'timestamp' | |
| ); | |
| resolvedAutoRuntimeMode = 'cpu'; | |
| logToTerminal('Runtime auto switched to CPU and will remain on CPU.', 'timestamp'); | |
| const cpuAttemptStartMs = performance.now(); | |
| result = await callGradioBridge('process_engine_cpu', requestPayload); | |
| runtimeUsed = 'cpu'; | |
| const cpuAttemptMs = performance.now() - cpuAttemptStartMs; | |
| const totalMs = performance.now() - requestStartedMs; | |
| logToTerminal( | |
| `Inference fallback success on CPU in ${Math.round(cpuAttemptMs)}ms (total ${Math.round(totalMs)}ms).`, | |
| 'timestamp' | |
| ); | |
| } | |
| if (result && result.error) { | |
| throw new Error(result.error); | |
| } | |
| if (!result || !Array.isArray(result.events)) { | |
| throw new Error('Engine returned no events'); | |
| } | |
| if (result.warning) { | |
| logToTerminal(`ENGINE WARNING: ${result.warning}`, 'timestamp'); | |
| } | |
| if (selectedEngineId === 'godzilla_continue') { | |
| const totalMs = performance.now() - requestStartedMs; | |
| const tokens = Number(requestOptions.generate_tokens) || 0; | |
| const inCount = Array.isArray(inputEvents) ? inputEvents.length : 0; | |
| const outCount = Array.isArray(result.events) ? result.events.length : 0; | |
| logToTerminal( | |
| `Inference ${getRuntimeModeLabel(runtimeUsed)}: ${Math.round(totalMs)}ms | in=${inCount} ev | out=${outCount} ev | tokens=${tokens}`, | |
| 'timestamp' | |
| ); | |
| } | |
| return result; | |
| } | |
| function playEvents( | |
| eventsToPlay, | |
| { logSymbols = true, useAISynth = false, shouldAbort = null } = {} | |
| ) { | |
| return new Promise((resolve) => { | |
| if (!Array.isArray(eventsToPlay) || eventsToPlay.length === 0) { | |
| resolve(); | |
| return; | |
| } | |
| const playbackSynth = useAISynth && aiSynth ? aiSynth : synth; | |
| const abortRequested = () => typeof shouldAbort === 'function' && shouldAbort(); | |
| let finished = false; | |
| let eventIndex = 0; | |
| const finishPlayback = () => { | |
| if (finished) return; | |
| finished = true; | |
| if (playbackSynth) playbackSynth.releaseAll(); | |
| keyboardEl.querySelectorAll('.key').forEach(k => { | |
| k.style.filter = ''; | |
| }); | |
| resolve(); | |
| }; | |
| const playEvent = () => { | |
| if (abortRequested()) { | |
| finishPlayback(); | |
| return; | |
| } | |
| if (eventIndex >= eventsToPlay.length) { | |
| finishPlayback(); | |
| return; | |
| } | |
| const event = eventsToPlay[eventIndex]; | |
| const nextTime = eventIndex + 1 < eventsToPlay.length | |
| ? eventsToPlay[eventIndex + 1].time | |
| : event.time; | |
| if (event.type === 'note_on') { | |
| const freq = Tone.Frequency(event.note, "midi").toFrequency(); | |
| if (playbackSynth) { | |
| playbackSynth.triggerAttack(freq, undefined, event.velocity / 127); | |
| } | |
| if (logSymbols) { | |
| const noteName = midiToNoteName(event.note); | |
| logToTerminal( | |
| `[${event.time.toFixed(3)}s] ► ${noteName} (${event.note})`, | |
| 'note-on' | |
| ); | |
| } | |
| const keyEl = getKeyElement(event.note); | |
| if (keyEl) keyEl.style.filter = 'brightness(0.7)'; | |
| } else if (event.type === 'note_off') { | |
| const freq = Tone.Frequency(event.note, "midi").toFrequency(); | |
| if (playbackSynth) { | |
| playbackSynth.triggerRelease(freq); | |
| } | |
| if (logSymbols) { | |
| const noteName = midiToNoteName(event.note); | |
| logToTerminal( | |
| `[${event.time.toFixed(3)}s] ◄ ${noteName}`, | |
| 'note-off' | |
| ); | |
| } | |
| const keyEl = getKeyElement(event.note); | |
| if (keyEl) keyEl.style.filter = ''; | |
| } | |
| eventIndex++; | |
| const deltaTime = Math.max(0, nextTime - event.time); | |
| setTimeout(playEvent, deltaTime * 1000); | |
| }; | |
| playEvent(); | |
| }); | |
| } | |
| async function startGameLoop() { | |
| if (gameActive) return; | |
| await Tone.start(); | |
| if (recording) { | |
| stopRecord(); | |
| } | |
| gameSessionId += 1; | |
| const sessionId = gameSessionId; | |
| gameActive = true; | |
| gamePhase = 'starting'; | |
| gameCaptureActive = false; | |
| gameCapturedEvents = []; | |
| gameCaptureActiveNotes.clear(); | |
| gameGridUserEvents = []; | |
| gameGridAIEvents = []; | |
| stopAllGridPlayheads(); | |
| hideCountdownOverlay(); | |
| clearTrackedGameTimeouts(); | |
| gameClockOriginSec = nowSec(); | |
| gameTurn = 0; | |
| gameStartBtn.disabled = true; | |
| gameStopBtn.disabled = false; | |
| if (gameToggleBtn) { | |
| gameToggleBtn.textContent = 'Stop'; | |
| gameToggleBtn.classList.add('active'); | |
| gameToggleBtn.disabled = false; | |
| } | |
| recordBtn.disabled = true; | |
| stopBtn.disabled = true; | |
| playbackBtn.disabled = true; | |
| saveBtn.disabled = true; | |
| statusEl.textContent = 'Game started'; | |
| logToTerminal('', ''); | |
| logToTerminal('🎮 CALL & RESPONSE GAME STARTED', 'timestamp'); | |
| const quantizationPreset = getSelectedGameQuantization(); | |
| logToTerminal( | |
| `Tempo locked at ${GAME_BPM} BPM, beat grid 4/4, AI quantize: ${quantizationPreset.label}.`, | |
| 'timestamp' | |
| ); | |
| logToTerminal( | |
| `Bars: user=${getSelectedUserBars()} | ai=${getSelectedAIBars()} (adjust anytime).`, | |
| 'timestamp' | |
| ); | |
| const stylePreset = getSelectedStylePreset(); | |
| const modePreset = getSelectedResponseMode(); | |
| const decodingPreset = getSelectedDecodingOptions(); | |
| logToTerminal( | |
| `AI mode: ${modePreset.label} | style: ${stylePreset.label}`, | |
| 'timestamp' | |
| ); | |
| logToTerminal( | |
| `Decoding: temp=${decodingPreset.temperature} top_p=${decodingPreset.top_p} candidates=${decodingPreset.num_candidates}`, | |
| 'timestamp' | |
| ); | |
| logToTerminal('', ''); | |
| renderTurnGrid({ phase: gamePhase }); | |
| startGameMetronome(sessionId); | |
| scheduleUserTurn(sessionId); | |
| } | |
| function stopGameLoop(reason = 'Game stopped') { | |
| gameSessionId += 1; | |
| clearTrackedGameTimeouts(); | |
| hideCountdownOverlay(); | |
| stopGameMetronome(); | |
| stopAllGridPlayheads(); | |
| gamePhase = 'idle'; | |
| gameCaptureActive = false; | |
| gameCapturedEvents = []; | |
| gameCaptureActiveNotes.clear(); | |
| if (recording) { | |
| stopRecord(); | |
| } | |
| gameActive = false; | |
| gameStartBtn.disabled = false; | |
| gameStopBtn.disabled = true; | |
| if (gameToggleBtn) { | |
| gameToggleBtn.textContent = 'Start'; | |
| gameToggleBtn.classList.remove('active'); | |
| gameToggleBtn.disabled = false; | |
| } | |
| recordBtn.disabled = false; | |
| stopBtn.disabled = true; | |
| playbackBtn.disabled = events.length === 0; | |
| saveBtn.disabled = events.length === 0; | |
| statusEl.textContent = reason; | |
| if (synth) synth.releaseAll(); | |
| if (aiSynth) aiSynth.releaseAll(); | |
| keyboardEl.querySelectorAll('.key').forEach(k => { | |
| k.style.filter = ''; | |
| }); | |
| renderTurnGrid({ phase: gamePhase }); | |
| logToTerminal(`🎮 ${reason}`, 'timestamp'); | |
| } | |
| function beginUserCaptureWindow(sessionId, userBars) { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| gamePhase = 'user_turn'; | |
| gameCaptureActive = true; | |
| gameCaptureStartWallSec = nowSec(); | |
| gameCapturedEvents = []; | |
| gameCaptureActiveNotes.clear(); | |
| gameGridUserEvents = []; | |
| startGridPlayhead('user', barsToSeconds(userBars)); | |
| stopGridPlayhead('ai'); | |
| statusEl.textContent = `Turn ${gameTurn}: your call (${userBars} bar${userBars > 1 ? 's' : ''})`; | |
| renderTurnGrid({ | |
| userEvents: [], | |
| phase: gamePhase | |
| }); | |
| logToTerminal(`Turn ${gameTurn}: your call started`, 'timestamp'); | |
| } | |
| function finalizeOpenGameCaptureNotes(captureDurationSec) { | |
| if (gameCaptureActiveNotes.size === 0) return; | |
| const closeTime = Math.max(0, captureDurationSec); | |
| gameCaptureActiveNotes.forEach((note) => { | |
| gameCapturedEvents.push({ | |
| type: 'note_off', | |
| note, | |
| velocity: 0, | |
| time: closeTime, | |
| channel: 0 | |
| }); | |
| }); | |
| gameCaptureActiveNotes.clear(); | |
| } | |
| function getLiveGameCaptureEvents() { | |
| const live = [...gameCapturedEvents]; | |
| if (!gameCaptureActive) { | |
| return sortEventsChronologically(live); | |
| } | |
| const nowCaptureSec = Math.max(0, nowSec() - gameCaptureStartWallSec); | |
| gameCaptureActiveNotes.forEach((note) => { | |
| live.push({ | |
| type: 'note_off', | |
| note, | |
| velocity: 0, | |
| time: nowCaptureSec, | |
| channel: 0 | |
| }); | |
| }); | |
| return sortEventsChronologically(live); | |
| } | |
| function scheduleUserTurn(sessionId) { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| gameTurn += 1; | |
| const userBars = getSelectedUserBars(); | |
| const userStartSec = nextBarAlignedStart(GAME_COUNTIN_BEATS); | |
| const userEndSec = userStartSec + barsToSeconds(userBars); | |
| gamePhase = 'user_countdown'; | |
| gameGridUserEvents = []; | |
| gameGridAIEvents = []; | |
| stopAllGridPlayheads(); | |
| renderTurnGrid({ | |
| userEvents: [], | |
| aiEvents: [], | |
| phase: gamePhase | |
| }); | |
| logToTerminal( | |
| `Turn ${gameTurn}: user countdown (${userBars} bar${userBars > 1 ? 's' : ''})`, | |
| 'timestamp' | |
| ); | |
| scheduleCountdown(userStartSec, `Turn ${gameTurn}: your turn`, sessionId); | |
| scheduleGameAt(userStartSec, () => beginUserCaptureWindow(sessionId, userBars)); | |
| scheduleGameAt(userEndSec, () => { | |
| void finishUserTurn(sessionId); | |
| }); | |
| } | |
| async function finishUserTurn(sessionId) { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| stopGridPlayhead('user'); | |
| const captureDurationSec = Math.max(0, nowSec() - gameCaptureStartWallSec); | |
| finalizeOpenGameCaptureNotes(captureDurationSec); | |
| gameCaptureActive = false; | |
| const userGridEvents = sanitizeEvents(gameCapturedEvents); | |
| const callEvents = normalizeEventsToZero(gameCapturedEvents); | |
| gameCapturedEvents = []; | |
| renderTurnGrid({ | |
| userEvents: userGridEvents, | |
| phase: gamePhase | |
| }); | |
| if (callEvents.length === 0) { | |
| statusEl.textContent = `Turn ${gameTurn}: no notes, try again`; | |
| logToTerminal('No notes captured, restarting your turn...', 'timestamp'); | |
| scheduleGameTimeout(() => { | |
| scheduleUserTurn(sessionId); | |
| }, GAME_RETRY_DELAY_MS); | |
| return; | |
| } | |
| try { | |
| gamePhase = 'ai_thinking'; | |
| renderTurnGrid({ phase: gamePhase }); | |
| statusEl.textContent = `Turn ${gameTurn}: AI thinking...`; | |
| logToTerminal(`Turn ${gameTurn}: AI is thinking...`, 'timestamp'); | |
| const aiBars = getSelectedAIBars(); | |
| const decodingOptions = getSelectedDecodingOptions(); | |
| const selectedEngineId = engineSelect.value; | |
| const useStreaming = selectedEngineId === 'godzilla_continue'; | |
| if (useStreaming) { | |
| await finishUserTurnStreaming(sessionId, callEvents, aiBars, decodingOptions); | |
| } else { | |
| await finishUserTurnBatch(sessionId, callEvents, aiBars, decodingOptions); | |
| } | |
| } catch (err) { | |
| console.error('Game turn error:', err); | |
| logToTerminal(`ENGINE ERROR: ${err.message}`, 'timestamp'); | |
| stopGameLoop(`Game stopped: ${err.message}`); | |
| } | |
| } | |
| async function finishUserTurnBatch(sessionId, callEvents, aiBars, decodingOptions) { | |
| const result = await processEventsThroughEngine(callEvents, { | |
| generate_tokens: getGameGenerateTokens(aiBars), | |
| ...decodingOptions | |
| }); | |
| const processedResponse = buildGameProcessedAIResponse(result.events || [], callEvents, aiBars); | |
| const aiEvents = quantizeAiResponseForGame(processedResponse.events, aiBars); | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| if (aiEvents.length === 0) { | |
| logToTerminal('AI returned no playable events after quantization. Restarting turn.', 'timestamp'); | |
| scheduleUserTurn(sessionId); | |
| return; | |
| } | |
| const aiStartSec = nextBarAlignedStart(GAME_COUNTIN_BEATS); | |
| gamePhase = 'ai_countdown'; | |
| renderTurnGrid({ aiEvents, phase: gamePhase }); | |
| logToTerminal( | |
| `Turn ${gameTurn}: AI countdown (${aiBars} bar${aiBars > 1 ? 's' : ''})`, | |
| 'timestamp' | |
| ); | |
| scheduleCountdown(aiStartSec, `Turn ${gameTurn}: AI`, sessionId); | |
| scheduleGameAt(aiStartSec, async () => { | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| gamePhase = 'ai_playback'; | |
| startGridPlayhead('ai', getEventsDurationSec(aiEvents)); | |
| renderTurnGrid({ phase: gamePhase }); | |
| statusEl.textContent = `Turn ${gameTurn}: AI responds`; | |
| logToTerminal( | |
| `Turn ${gameTurn}: AI response (${aiBars} bar${aiBars > 1 ? 's' : ''})`, | |
| 'timestamp' | |
| ); | |
| await playEvents(aiEvents, { | |
| useAISynth: true, | |
| shouldAbort: () => !gameActive || sessionId !== gameSessionId | |
| }); | |
| stopGridPlayhead('ai'); | |
| if (!gameActive || sessionId !== gameSessionId) return; | |
| scheduleUserTurn(sessionId); | |
| }); | |
| } | |
| async function finishUserTurnStreaming(sessionId, callEvents, aiBars, decodingOptions) { | |
| const runtimeMode = getSelectedRuntime(); | |
| const effectiveRuntime = runtimeMode === 'auto' | |
| ? resolveAutoRuntimeMode('godzilla_continue') | |
| : runtimeMode; | |
| const streamAction = effectiveRuntime === 'gpu' | |
| ? 'stream_engine_gpu' | |
| : 'stream_engine_cpu'; | |
| const streamRequestId = ++_streamRequestId; | |
| const streamPayload = { | |
| engine_id: 'godzilla_continue', | |
| events: callEvents, | |
| request_id: streamRequestId, | |
| options: { | |
| generate_tokens: getGameGenerateTokens(aiBars), | |
| temperature: decodingOptions.temperature, | |
| top_p: decodingOptions.top_p, | |
| num_candidates: 1 | |
| } | |
| }; | |
| const streamStartMs = performance.now(); | |
| let firstNoteMs = null; | |
| let streamedAiEvents = []; | |
| let playbackStarted = false; | |
| let aiStartSec = null; | |
| const playbackSynth = aiSynth || synth; | |
| const abortCheck = () => !gameActive || sessionId !== gameSessionId; | |
| // Schedule playback of newly arrived events relative to aiStartSec. | |
| // No quantization — play raw model times for speed/flow. | |
| // Stop scheduling once notes fill the bar duration. | |
| const barDurationSec = barsToSeconds(aiBars); | |
| let barFilled = false; | |
| function scheduleNewNotes(newEvents, allEvents) { | |
| if (!playbackStarted || !aiStartSec || barFilled) return; | |
| const newPairs = eventsToNotePairs(newEvents); | |
| if (newPairs.length === 0) return; | |
| const now = nowGameSec(); | |
| for (const pair of newPairs) { | |
| const start = Math.max(0, pair.start); | |
| // Clip: skip notes that start beyond the bar. | |
| if (start >= barDurationSec) { | |
| barFilled = true; | |
| return; | |
| } | |
| const end = Math.min(Math.max(start + 0.08, pair.end), barDurationSec); | |
| const note = clampMidiNote(Math.round(pair.note)); | |
| const velocity = clampValue(Math.round(pair.velocity || 100), 1, 127); | |
| const noteOnGameSec = aiStartSec + start; | |
| const noteOffGameSec = aiStartSec + end; | |
| const onDelayMs = Math.max(0, (noteOnGameSec - now) * 1000); | |
| const offDelayMs = Math.max(0, (noteOffGameSec - now) * 1000); | |
| scheduleGameTimeout(() => { | |
| if (abortCheck()) return; | |
| const freq = Tone.Frequency(note, 'midi').toFrequency(); | |
| if (playbackSynth) playbackSynth.triggerAttack(freq, undefined, velocity / 127); | |
| const keyEl = getKeyElement(note); | |
| if (keyEl) keyEl.style.filter = 'brightness(0.7)'; | |
| }, onDelayMs); | |
| scheduleGameTimeout(() => { | |
| if (abortCheck()) return; | |
| const freq = Tone.Frequency(note, 'midi').toFrequency(); | |
| if (playbackSynth) playbackSynth.triggerRelease(freq); | |
| const keyEl = getKeyElement(note); | |
| if (keyEl) keyEl.style.filter = ''; | |
| }, offDelayMs); | |
| } | |
| } | |
| const inputField = getBridgeField('vk_engine_input'); | |
| if (inputField) { | |
| setFieldValue(inputField, JSON.stringify(streamPayload)); | |
| } | |
| let lastEventCount = 0; | |
| const turnId = gameTurn; | |
| // Fire-and-forget: don't await — the scheduled timers handle turn transitions. | |
| // This prevents blocking when the bar ends before streaming completes. | |
| callGradioBridgeStreaming( | |
| streamAction, | |
| streamPayload, | |
| (partial) => { | |
| if (abortCheck() || barFilled) return; | |
| const events = partial.events || []; | |
| if (events.length === 0) return; | |
| if (firstNoteMs === null) { | |
| firstNoteMs = performance.now() - streamStartMs; | |
| logToTerminal( | |
| `Turn ${turnId}: first note in ${Math.round(firstNoteMs)}ms`, | |
| 'timestamp' | |
| ); | |
| } | |
| // Normalize events to start at time 0. | |
| const normalized = normalizeEventsToZero(events); | |
| const newEvents = normalized.slice(lastEventCount); | |
| lastEventCount = normalized.length; | |
| streamedAiEvents = normalized; | |
| // Update grid with accumulated events. | |
| renderTurnGrid({ aiEvents: streamedAiEvents, phase: gamePhase }); | |
| // Start playback once we have enough note-pairs buffered. | |
| const MIN_PAIRS_TO_START = 3; | |
| const bufferedPairs = eventsToNotePairs(streamedAiEvents); | |
| let justStarted = false; | |
| if (!playbackStarted && bufferedPairs.length >= MIN_PAIRS_TO_START) { | |
| playbackStarted = true; | |
| justStarted = true; | |
| aiStartSec = nextBarAlignedStart(GAME_COUNTIN_BEATS); | |
| gamePhase = 'ai_countdown'; | |
| renderTurnGrid({ aiEvents: streamedAiEvents, phase: gamePhase }); | |
| logToTerminal( | |
| `Turn ${turnId}: AI streaming (${aiBars} bar${aiBars > 1 ? 's' : ''})`, | |
| 'timestamp' | |
| ); | |
| scheduleCountdown(aiStartSec, `Turn ${turnId}: AI`, sessionId); | |
| // Schedule all buffered notes that arrived before playback started. | |
| scheduleNewNotes(streamedAiEvents, streamedAiEvents); | |
| scheduleGameAt(aiStartSec, () => { | |
| if (abortCheck()) return; | |
| gamePhase = 'ai_playback'; | |
| startGridPlayhead('ai', barsToSeconds(aiBars)); | |
| renderTurnGrid({ phase: gamePhase }); | |
| statusEl.textContent = `Turn ${turnId}: AI responds (streaming)`; | |
| }); | |
| // Schedule next turn at end of bar — don't wait for streaming to finish. | |
| const aiEndSec = aiStartSec + barDurationSec; | |
| scheduleGameAt(aiEndSec, () => { | |
| barFilled = true; | |
| stopGridPlayhead('ai'); | |
| if (playbackSynth) playbackSynth.releaseAll(); | |
| if (abortCheck()) return; | |
| scheduleUserTurn(sessionId); | |
| }); | |
| } | |
| // Schedule newly arrived notes — skip if we just bulk-scheduled above. | |
| if (!justStarted && newEvents.length > 0) { | |
| scheduleNewNotes(newEvents, streamedAiEvents); | |
| } | |
| } | |
| ).then((finalResult) => { | |
| if (abortCheck() || barFilled) return; | |
| const totalMs = performance.now() - streamStartMs; | |
| logToTerminal( | |
| `Turn ${turnId}: streaming complete in ${Math.round(totalMs)}ms, ${(finalResult.events || []).length} events`, | |
| 'timestamp' | |
| ); | |
| // If streaming finished before we hit the buffer threshold, start now. | |
| if (!playbackStarted && streamedAiEvents.length > 0) { | |
| playbackStarted = true; | |
| aiStartSec = nextBarAlignedStart(GAME_COUNTIN_BEATS); | |
| gamePhase = 'ai_countdown'; | |
| renderTurnGrid({ aiEvents: streamedAiEvents, phase: gamePhase }); | |
| scheduleCountdown(aiStartSec, `Turn ${turnId}: AI`, sessionId); | |
| scheduleNewNotes(streamedAiEvents, streamedAiEvents); | |
| scheduleGameAt(aiStartSec, () => { | |
| if (abortCheck()) return; | |
| gamePhase = 'ai_playback'; | |
| startGridPlayhead('ai', barsToSeconds(aiBars)); | |
| renderTurnGrid({ phase: gamePhase }); | |
| statusEl.textContent = `Turn ${turnId}: AI responds`; | |
| }); | |
| const aiEndSec = aiStartSec + barDurationSec; | |
| scheduleGameAt(aiEndSec, () => { | |
| barFilled = true; | |
| stopGridPlayhead('ai'); | |
| if (playbackSynth) playbackSynth.releaseAll(); | |
| if (abortCheck()) return; | |
| scheduleUserTurn(sessionId); | |
| }); | |
| } | |
| // If no notes were generated at all, restart. | |
| if (aiStartSec === null) { | |
| logToTerminal('AI returned no events via streaming. Restarting turn.', 'timestamp'); | |
| scheduleUserTurn(sessionId); | |
| } | |
| }).catch((err) => { | |
| if (abortCheck() || barFilled) return; | |
| logToTerminal(`Streaming failed (${err.message}), falling back to batch mode.`, 'timestamp'); | |
| finishUserTurnBatch(sessionId, callEvents, aiBars, decodingOptions); | |
| }); | |
| } | |
| // ============================================================================= | |
| // HELPER FUNCTIONS | |
| // ============================================================================= | |
| // Reset all audio synthesis and visual key states | |
| function resetAllNotesAndVisuals() { | |
| if (synth) synth.releaseAll(); | |
| if (aiSynth) aiSynth.releaseAll(); | |
| keyboardEl.querySelectorAll('.key').forEach(k => { | |
| k.style.filter = ''; | |
| }); | |
| } | |
| // ============================================================================= | |
| // EVENT LISTENERS | |
| // ============================================================================= | |
| let listenersBound = false; | |
| function bindUIEventListeners() { | |
| if (listenersBound) return; | |
| listenersBound = true; | |
| bindGlobalKeyboardHandlers(); | |
| if (instrumentSelect) { | |
| instrumentSelect.addEventListener('change', () => { | |
| loadInstrument(instrumentSelect.value); | |
| }); | |
| } | |
| if (aiInstrumentSelect) { | |
| aiInstrumentSelect.addEventListener('change', () => { | |
| loadAIInstrument(aiInstrumentSelect.value); | |
| logToTerminal(`AI voice switched to: ${aiInstrumentSelect.value}`, 'timestamp'); | |
| }); | |
| } | |
| if (clearTerminal) { | |
| clearTerminal.addEventListener('click', () => { | |
| terminal.innerHTML = ''; | |
| logToTerminal('╔═══════════════════════════════════════════════════════╗', 'timestamp'); | |
| logToTerminal('║ 🎹 MIDI MONITOR INITIALIZED 🎹 ║', 'timestamp'); | |
| logToTerminal('╚═══════════════════════════════════════════════════════╝', 'timestamp'); | |
| logToTerminal('Ready to capture MIDI events...', 'timestamp'); | |
| logToTerminal('', ''); | |
| }); | |
| } | |
| if (recordBtn) { | |
| recordBtn.addEventListener('click', async () => { | |
| if (gameActive) return; | |
| await Tone.start(); | |
| beginRecord(); | |
| }); | |
| } | |
| if (stopBtn) { | |
| stopBtn.addEventListener('click', () => { | |
| if (gameActive) return; | |
| stopRecord(); | |
| }); | |
| } | |
| if (engineSelect) { | |
| engineSelect.addEventListener('change', (e) => { | |
| selectedEngine = e.target.value; | |
| logToTerminal(`Engine switched to: ${selectedEngine}`, 'timestamp'); | |
| }); | |
| } | |
| // Consolidated select control listeners | |
| const selectControls = [ | |
| { | |
| element: runtimeSelect, | |
| getter: () => { | |
| const mode = getSelectedRuntime(); | |
| const label = mode === 'gpu' ? 'ZeroGPU' : (mode === 'auto' ? 'Auto (GPU->CPU)' : 'CPU'); | |
| return { label }; | |
| }, | |
| message: (result) => `Runtime switched to: ${result.label}` | |
| }, | |
| { | |
| element: responseStyleSelect, | |
| getter: getSelectedStylePreset, | |
| message: (result) => `AI style switched to: ${result.label}` | |
| }, | |
| { | |
| element: responseModeSelect, | |
| getter: () => { | |
| const mode = getSelectedResponseMode(); | |
| const decode = getSelectedDecodingOptions(); | |
| return { label: `${mode.label} (temp=${decode.temperature}, top_p=${decode.top_p}, candidates=${decode.num_candidates})` }; | |
| }, | |
| message: (result) => `Response mode switched to: ${result.label}` | |
| }, | |
| { | |
| element: responseLengthSelect, | |
| getter: () => { | |
| const preset = getSelectedResponseLengthPreset(); | |
| return { label: `${preset.label} (${preset.generateTokens} tokens)` }; | |
| }, | |
| message: (result) => ( | |
| gameActive | |
| ? `Response length switched to: ${result.label} (game mode uses bar controls)` | |
| : `Response length switched to: ${result.label}` | |
| ) | |
| }, | |
| { | |
| element: quantizationSelect, | |
| getter: getSelectedGameQuantization, | |
| message: (result) => `Game quantization switched to: ${result.label}` | |
| }, | |
| { | |
| element: userBarsSelect, | |
| getter: () => { | |
| const bars = getSelectedUserBars(); | |
| return { label: `${bars} bar${bars > 1 ? 's' : ''}` }; | |
| }, | |
| message: (result) => `Game user bars switched to: ${result.label}` | |
| }, | |
| { | |
| element: aiBarsSelect, | |
| getter: () => { | |
| const bars = getSelectedAIBars(); | |
| return { label: `${bars} bar${bars > 1 ? 's' : ''}` }; | |
| }, | |
| message: (result) => `Game AI bars switched to: ${result.label}` | |
| } | |
| ]; | |
| selectControls.forEach(({ element, getter, message }) => { | |
| if (element) { | |
| element.addEventListener('change', () => { | |
| const result = getter(); | |
| if (element === runtimeSelect && getSelectedRuntime() === 'auto') { | |
| resetAutoRuntimeResolution(); | |
| void probeZeroGpuAvailabilityOnInit(); | |
| } | |
| logToTerminal(message(result), 'timestamp'); | |
| if (element === userBarsSelect || element === aiBarsSelect) { | |
| renderTurnGrid({ phase: gamePhase }); | |
| } | |
| }); | |
| } | |
| }); | |
| if (gameStartBtn) { | |
| gameStartBtn.addEventListener('click', () => { | |
| void startGameLoop(); | |
| }); | |
| } | |
| if (gameStopBtn) { | |
| gameStopBtn.addEventListener('click', () => { | |
| stopGameLoop('Game stopped'); | |
| }); | |
| } | |
| // Settings panel | |
| const openSettings = () => { settingsPanel?.classList.add('open'); settingsBackdrop?.classList.add('open'); }; | |
| const closeSettings = () => { settingsPanel?.classList.remove('open'); settingsBackdrop?.classList.remove('open'); }; | |
| if (settingsToggleBtn) settingsToggleBtn.addEventListener('click', openSettings); | |
| if (settingsCloseBtn) settingsCloseBtn.addEventListener('click', closeSettings); | |
| if (settingsBackdrop) settingsBackdrop.addEventListener('click', closeSettings); | |
| // Single game toggle button | |
| if (gameToggleBtn) { | |
| gameToggleBtn.addEventListener('click', () => { | |
| if (gameActive) stopGameLoop('Game stopped'); | |
| else void startGameLoop(); | |
| }); | |
| } | |
| if (playbackBtn) { | |
| playbackBtn.addEventListener('click', async () => { | |
| if (gameActive) return alert('Stop the game first.'); | |
| if (events.length === 0) return alert('No recording to play back'); | |
| // Ensure all notes are off before starting playback | |
| resetAllNotesAndVisuals(); | |
| statusEl.textContent = 'Playing back...'; | |
| playbackBtn.disabled = true; | |
| recordBtn.disabled = true; | |
| logToTerminal('', ''); | |
| logToTerminal('♫♫♫ PLAYBACK STARTED ♫♫♫', 'timestamp'); | |
| logToTerminal('', ''); | |
| try { | |
| let engineOptions = {}; | |
| if (engineSelect.value === 'godzilla_continue') { | |
| const lengthPreset = getSelectedResponseLengthPreset(); | |
| engineOptions = { | |
| generate_tokens: lengthPreset.generateTokens, | |
| ...getSelectedDecodingOptions() | |
| }; | |
| } | |
| const result = await processEventsThroughEngine(events, engineOptions); | |
| let processedEvents = result.events || []; | |
| if (engineSelect.value === 'godzilla_continue') { | |
| const processedResponse = buildProcessedAIResponse(processedEvents, events); | |
| processedEvents = processedResponse.events; | |
| logToTerminal(`Playback response mode: ${processedResponse.label}`, 'timestamp'); | |
| } | |
| await playEvents(processedEvents, { | |
| useAISynth: engineSelect.value !== 'parrot' | |
| }); | |
| statusEl.textContent = 'Playback complete'; | |
| playbackBtn.disabled = false; | |
| recordBtn.disabled = false; | |
| logToTerminal('', ''); | |
| logToTerminal('♫♫♫ PLAYBACK FINISHED ♫♫♫', 'timestamp'); | |
| logToTerminal('', ''); | |
| } catch (err) { | |
| console.error('Playback error:', err); | |
| statusEl.textContent = 'Playback error: ' + err.message; | |
| logToTerminal(`ENGINE ERROR: ${err.message}`, 'timestamp'); | |
| playbackBtn.disabled = false; | |
| recordBtn.disabled = false; | |
| // Ensure all notes are off on error | |
| resetAllNotesAndVisuals(); | |
| } | |
| }); | |
| } | |
| if (saveBtn) { | |
| saveBtn.addEventListener('click', () => saveMIDI()); | |
| } | |
| if (panicBtn) { | |
| panicBtn.addEventListener('click', () => { | |
| // Stop all notes immediately and reset visuals | |
| resetAllNotesAndVisuals(); | |
| // Clear all pressed keys | |
| pressedKeys.clear(); | |
| logToTerminal('🚨 PANIC - All notes stopped', 'timestamp'); | |
| }); | |
| } | |
| window.addEventListener('resize', () => { | |
| renderTurnGrid({ phase: gamePhase }); | |
| }); | |
| } | |
| // ============================================================================= | |
| // ============================================================================= | |
| // INITIALIZATION | |
| // ============================================================================= | |
| async function init() { | |
| await waitForKeyboardUIElements(); | |
| await waitForBridgeElements(); | |
| cacheUIElements(); | |
| bindUIEventListeners(); | |
| // First, load configuration from server | |
| await initializeFromConfig(); | |
| if (responseStyleSelect && !responseStyleSelect.value) { | |
| responseStyleSelect.value = 'melodic'; | |
| } | |
| if (responseModeSelect && !responseModeSelect.value) { | |
| responseModeSelect.value = 'raw_godzilla'; | |
| } | |
| if (responseLengthSelect && !responseLengthSelect.value) { | |
| responseLengthSelect.value = 'short'; | |
| } | |
| if (quantizationSelect && !quantizationSelect.value) { | |
| quantizationSelect.value = 'none'; | |
| } | |
| if (runtimeSelect && !runtimeSelect.value) { | |
| runtimeSelect.value = 'auto'; | |
| } | |
| if (userBarsSelect && !userBarsSelect.value) { | |
| userBarsSelect.value = '2'; | |
| } | |
| if (aiBarsSelect && !aiBarsSelect.value) { | |
| aiBarsSelect.value = '2'; | |
| } | |
| if (aiInstrumentSelect && !aiInstrumentSelect.value) { | |
| aiInstrumentSelect.value = 'synth'; | |
| } | |
| // Then load default instrument (organ) | |
| loadInstrument('organ'); | |
| loadAIInstrument(aiInstrumentSelect ? aiInstrumentSelect.value : 'synth'); | |
| // Setup keyboard event listeners and UI | |
| keyboardEl.classList.add('shortcuts-visible'); | |
| attachPointerEvents(); | |
| initTerminal(); | |
| renderTurnGrid({ phase: gamePhase }); | |
| const runtimeMode = getSelectedRuntime(); | |
| const runtimeLabel = runtimeMode === 'gpu' ? 'ZeroGPU' : (runtimeMode === 'auto' ? 'Auto (GPU->CPU)' : 'CPU'); | |
| logToTerminal(`Runtime mode: ${runtimeLabel}`, 'timestamp'); | |
| if (runtimeMode === 'auto') { | |
| logToTerminal('Runtime auto probe started in background...', 'timestamp'); | |
| void probeZeroGpuAvailabilityOnInit(); | |
| } | |
| logToTerminal(`Game mode tempo: ${GAME_BPM} BPM (fixed)`, 'timestamp'); | |
| // Set initial button states | |
| recordBtn.disabled = false; | |
| stopBtn.disabled = true; | |
| saveBtn.disabled = true; | |
| playbackBtn.disabled = true; | |
| gameStartBtn.disabled = false; | |
| gameStopBtn.disabled = true; | |
| if (gameToggleBtn) { | |
| gameToggleBtn.textContent = 'Start'; | |
| gameToggleBtn.classList.remove('active'); | |
| gameToggleBtn.disabled = false; | |
| } | |
| } | |
| // Start the application when DOM is ready | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => { | |
| void init(); | |
| }); | |
| } else { | |
| void init(); | |
| } | |