/* ══════════════════════════════════════════════════════════ SUNO FORGE — app.js All logic: channels, API calls, history, CSV export ══════════════════════════════════════════════════════════ */ (function () { 'use strict'; // ── Constants ── const LS_KEY_API = 'sunoforge_apikey'; const LS_KEY_MODEL = 'sunoforge_model'; const LS_KEY_CHANNELS = 'sunoforge_channels'; const LS_KEY_HISTORY = 'sunoforge_history'; const LS_KEY_MODELS_CACHE = 'sunoforge_models_cache'; const LS_KEY_SB_URL = 'sunoforge_sb_url'; const LS_KEY_SB_KEY = 'sunoforge_sb_key'; const DEFAULT_MODEL = 'google/gemini-flash-1.5'; const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'; const MAX_HISTORY = 50; const MODELS_CACHE_TTL = 1000 * 60 * 60; // 1 hour cache const SYSTEM_PROMPT = `You are an expert AI music composer and prompt engineer specializing in Suno AI. Your job is to generate ultra-detailed Suno AI music prompts. You deeply understand music theory, production techniques, and genre conventions for music channels. Return ONLY valid JSON with these exact keys — no markdown, no explanation, no backticks: { "style_prompt": "The full detailed Suno AI prompt (1000 characters max, highly specific, rich instrumentation, production details, subgenres, acoustic elements, vocal characteristics, and atmospheric descriptors).", "exclude": "Comma-separated list of things Suno should avoid. Under 200 chars.", "lyrics": "If requested: structured lyric scaffold with [Verse 1], [Chorus], [Bridge] markers. Under 3000 chars. Empty string if not requested." }`; const GENRES = ['Metal', 'Synthwave', 'Lo-fi', 'Post-Rock', 'Folk', 'EDM', 'Ambient']; const MOODS = ['Dark', 'Euphoric', 'Melancholic', 'Aggressive', 'Dreamy', 'Devotional', 'Chill', 'Apocalyptic', 'Nostalgic', 'Ethereal', 'Hype', 'Tense']; const INSTRUMENTS = ['Distorted Electric Guitar', 'Analog Synthesizer', 'Heavy 808 Bass', 'Acoustic Cello', 'Grand Piano', 'Saxophone', 'Tribal Percussion', 'Violin Ensemble', 'Vintage Drums', 'Sub Bass & Pad']; const TEMPOS = ['Slow', 'Mid', 'Fast', 'Variable']; const VOCALS = ['Instrumental', 'Male', 'Female', 'Choir', 'Harsh', 'Soft']; // ── State ── let channels = []; let history = []; let activeChannelIndex = -1; let lastGeneration = null; let freeModels = []; // cached model objects let selectedModelPricing = null; // pricing for current model let supabaseClient = null; // ── DOM refs ── const $ = (id) => document.getElementById(id); const els = { settingsToggle: $('settingsToggle'), settingsPanel: $('settingsPanel'), apiKeyInput: $('apiKeyInput'), apiKeyToggle: $('apiKeyToggle'), modelSelect: $('modelSelect'), modelMeta: $('modelMeta'), btnRefreshModels: $('btnRefreshModels'), modelInput: $('modelInput'), btnClearHistory: $('btnClearHistory'), btnExportAll: $('btnExportAll'), importDataInput: $('importDataInput'), channelList: $('channelList'), btnNewChannel: $('btnNewChannel'), newChannelForm: $('newChannelForm'), ncName: $('ncName'), ncGenre: $('ncGenre'), ncMood: $('ncMood'), ncLanguage: $('ncLanguage'), ncExclude: $('ncExclude'), btnSaveChannel: $('btnSaveChannel'), btnCancelChannel: $('btnCancelChannel'), activeChannelLabel: $('activeChannelLabel'), bGenre: $('bGenre'), bSubgenre: $('bSubgenre'), bMood: $('bMood'), bInstruments: $('bInstruments'), bScale: $('bScale'), bTempo: $('bTempo'), bVocal: $('bVocal'), bLanguage: $('bLanguage'), bExclude: $('bExclude'), bConcept: $('bConcept'), bLyrics: $('bLyrics'), btnForge: $('btnForge'), btnSurprise: $('btnSurprise'), errorMessage: $('errorMessage'), outStyle: $('outStyle'), outExclude: $('outExclude'), outLyrics: $('outLyrics'), styleCounter: $('styleCounter'), excludeCounter: $('excludeCounter'), lyricsCounter: $('lyricsCounter'), lyricsSection: $('lyricsSection'), tokenUsage: $('tokenUsage'), btnSaveHistory: $('btnSaveHistory'), btnCopyStyle: $('btnCopyStyle'), btnCopyExclude: $('btnCopyExclude'), btnCopyLyrics: $('btnCopyLyrics'), historyScroll: $('historyScroll'), historyEmpty: $('historyEmpty'), btnExportCsv: $('btnExportCsv'), sbUrlInput: $('sbUrlInput'), sbKeyInput: $('sbKeyInput'), sbKeyToggle: $('sbKeyToggle'), btnConnectSb: $('btnConnectSb'), sbStatus: $('sbStatus'), btnShowSqlModal: $('btnShowSqlModal'), sqlModal: $('sqlModal'), btnCloseSqlModal: $('btnCloseSqlModal'), btnCloseSqlModal2: $('btnCloseSqlModal2'), btnCopySql: $('btnCopySql'), sqlSchemaText: $('sqlSchemaText'), }; // ══════════════════════════════════════ // LOCAL STORAGE HELPERS // ══════════════════════════════════════ function loadData() { try { const apiKey = localStorage.getItem(LS_KEY_API) || ''; const model = localStorage.getItem(LS_KEY_MODEL) || ''; const sbUrl = localStorage.getItem(LS_KEY_SB_URL) || ''; const sbKey = localStorage.getItem(LS_KEY_SB_KEY) || ''; channels = JSON.parse(localStorage.getItem(LS_KEY_CHANNELS) || '[]'); history = JSON.parse(localStorage.getItem(LS_KEY_HISTORY) || '[]'); els.apiKeyInput.value = apiKey; els.modelInput.value = model; if (els.sbUrlInput) els.sbUrlInput.value = sbUrl; if (els.sbKeyInput) els.sbKeyInput.value = sbKey; } catch (e) { console.error('Failed to load data:', e); channels = []; history = []; } } function saveChannels() { localStorage.setItem(LS_KEY_CHANNELS, JSON.stringify(channels)); } function saveHistoryLocally() { if (history.length > MAX_HISTORY) { history = history.slice(-MAX_HISTORY); } localStorage.setItem(LS_KEY_HISTORY, JSON.stringify(history)); } function saveHistory() { saveHistoryLocally(); if (lastGeneration && supabaseClient) { saveToSupabase(lastGeneration); } } function saveApiKey() { localStorage.setItem(LS_KEY_API, els.apiKeyInput.value.trim()); } function saveModel() { const custom = els.modelInput.value.trim(); const selected = els.modelSelect.value; localStorage.setItem(LS_KEY_MODEL, custom || selected || DEFAULT_MODEL); } // ══════════════════════════════════════ // SETTINGS PANEL // ══════════════════════════════════════ els.settingsToggle.addEventListener('click', (e) => { e.stopPropagation(); els.settingsPanel.classList.toggle('active'); }); document.addEventListener('click', (e) => { if (!els.settingsPanel.contains(e.target) && e.target !== els.settingsToggle) { els.settingsPanel.classList.remove('active'); } }); els.apiKeyInput.addEventListener('change', saveApiKey); els.modelInput.addEventListener('change', saveModel); els.modelSelect.addEventListener('change', () => { saveModel(); updateModelMeta(); }); els.btnRefreshModels.addEventListener('click', () => fetchFreeModels(true)); // Password toggle els.apiKeyToggle.addEventListener('click', () => { const inp = els.apiKeyInput; if (inp.type === 'password') { inp.type = 'text'; els.apiKeyToggle.textContent = '🔒'; } else { inp.type = 'password'; els.apiKeyToggle.textContent = '👁'; } }); // Supabase password toggle & input listeners if (els.sbKeyToggle) { els.sbKeyToggle.addEventListener('click', () => { const inp = els.sbKeyInput; if (inp.type === 'password') { inp.type = 'text'; els.sbKeyToggle.textContent = '🔒'; } else { inp.type = 'password'; els.sbKeyToggle.textContent = '👁'; } }); } if (els.sbUrlInput) { els.sbUrlInput.addEventListener('change', () => { localStorage.setItem(LS_KEY_SB_URL, els.sbUrlInput.value.trim()); }); } if (els.sbKeyInput) { els.sbKeyInput.addEventListener('change', () => { localStorage.setItem(LS_KEY_SB_KEY, els.sbKeyInput.value.trim()); }); } if (els.btnConnectSb) { els.btnConnectSb.addEventListener('click', () => { const url = els.sbUrlInput.value.trim(); const key = els.sbKeyInput.value.trim(); localStorage.setItem(LS_KEY_SB_URL, url); localStorage.setItem(LS_KEY_SB_KEY, key); initSupabase(url, key, true); }); } // SQL Schema Modal Listeners if (els.btnShowSqlModal) { els.btnShowSqlModal.addEventListener('click', () => { els.sqlModal.style.display = 'flex'; }); } if (els.btnCloseSqlModal) { els.btnCloseSqlModal.addEventListener('click', () => { els.sqlModal.style.display = 'none'; }); } if (els.btnCloseSqlModal2) { els.btnCloseSqlModal2.addEventListener('click', () => { els.sqlModal.style.display = 'none'; }); } if (els.btnCopySql) { els.btnCopySql.addEventListener('click', () => { navigator.clipboard.writeText(els.sqlSchemaText.value).then(() => { els.btnCopySql.textContent = 'COPIED!'; setTimeout(() => { els.btnCopySql.textContent = 'COPY SQL SCHEMA'; }, 1500); }); }); } // Clear history els.btnClearHistory.addEventListener('click', () => { if (confirm('Clear all history? This cannot be undone.')) { history = []; saveHistory(); renderHistory(); } }); // Export all data els.btnExportAll.addEventListener('click', () => { const data = { sunoforge_apikey: localStorage.getItem(LS_KEY_API) || '', sunoforge_model: localStorage.getItem(LS_KEY_MODEL) || DEFAULT_MODEL, sunoforge_channels: channels, sunoforge_history: history, }; downloadJSON(data, 'sunoforge_backup_' + dateStamp() + '.json'); }); // Import data els.importDataInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function (ev) { try { const data = JSON.parse(ev.target.result); if (data.sunoforge_apikey !== undefined) { localStorage.setItem(LS_KEY_API, data.sunoforge_apikey); els.apiKeyInput.value = data.sunoforge_apikey; } if (data.sunoforge_model !== undefined) { localStorage.setItem(LS_KEY_MODEL, data.sunoforge_model); els.modelInput.value = data.sunoforge_model; } if (Array.isArray(data.sunoforge_channels)) { channels = data.sunoforge_channels; saveChannels(); renderChannels(); } if (Array.isArray(data.sunoforge_history)) { history = data.sunoforge_history; saveHistory(); renderHistory(); } alert('Data imported successfully!'); } catch (err) { alert('Invalid JSON file.'); } }; reader.readAsText(file); e.target.value = ''; }); // ══════════════════════════════════════ // CHANNEL MANAGEMENT // ══════════════════════════════════════ function renderChannels() { els.channelList.innerHTML = ''; channels.forEach((ch, i) => { const row = document.createElement('div'); row.className = 'channel-item' + (i === activeChannelIndex ? ' active' : ''); row.innerHTML = ` ${esc(ch.name)} ${esc(ch.genre || '—')} `; row.addEventListener('click', (e) => { if (e.target.classList.contains('ch-delete')) return; selectChannel(i); }); row.querySelector('.ch-delete').addEventListener('click', (e) => { e.stopPropagation(); deleteChannel(i); }); els.channelList.appendChild(row); }); } function selectChannel(index) { activeChannelIndex = index; const ch = channels[index]; if (!ch) return; els.activeChannelLabel.textContent = '▸ ' + ch.name; els.bGenre.value = ch.genre || ''; els.bMood.value = ch.mood || ''; els.bLanguage.value = ch.language || ''; els.bExclude.value = ch.exclude || ''; els.bSubgenre.value = ''; els.bConcept.value = ''; renderChannels(); } function deleteChannel(index) { if (!confirm('Delete channel "' + channels[index].name + '"?')) return; channels.splice(index, 1); if (activeChannelIndex === index) { activeChannelIndex = -1; els.activeChannelLabel.textContent = 'No channel selected'; } else if (activeChannelIndex > index) { activeChannelIndex--; } saveChannels(); renderChannels(); } // New Channel Form els.btnNewChannel.addEventListener('click', () => { els.newChannelForm.classList.add('active'); els.ncName.value = ''; els.ncGenre.value = ''; els.ncMood.value = ''; els.ncLanguage.value = ''; els.ncExclude.value = ''; els.ncName.focus(); }); els.btnCancelChannel.addEventListener('click', () => { els.newChannelForm.classList.remove('active'); }); els.btnSaveChannel.addEventListener('click', () => { const name = els.ncName.value.trim(); if (!name) { els.ncName.focus(); return; } channels.push({ name: name, genre: els.ncGenre.value.trim(), mood: els.ncMood.value.trim(), language: els.ncLanguage.value.trim(), exclude: els.ncExclude.value.trim(), }); saveChannels(); renderChannels(); els.newChannelForm.classList.remove('active'); // Auto-select the new channel selectChannel(channels.length - 1); }); // ══════════════════════════════════════ // LYRICS TOGGLE // ══════════════════════════════════════ els.bLyrics.addEventListener('change', () => { els.lyricsSection.classList.toggle('active', els.bLyrics.checked); }); // ══════════════════════════════════════ // CHAR COUNTERS // ══════════════════════════════════════ function updateCharCounter(textarea, counterEl, limit) { const len = textarea.value.length; counterEl.textContent = len + ' / ' + limit; counterEl.className = 'char-counter'; if (limit === 1000) { if (len <= 800) counterEl.classList.add('green'); else if (len <= 1000) counterEl.classList.add('amber'); else counterEl.classList.add('red'); } else { if (len <= limit * 0.8) counterEl.classList.add('green'); else if (len <= limit) counterEl.classList.add('amber'); else counterEl.classList.add('red'); } } els.outStyle.addEventListener('input', () => updateCharCounter(els.outStyle, els.styleCounter, 1000)); els.outExclude.addEventListener('input', () => updateCharCounter(els.outExclude, els.excludeCounter, 200)); els.outLyrics.addEventListener('input', () => updateCharCounter(els.outLyrics, els.lyricsCounter, 3000)); // ══════════════════════════════════════ // COPY BUTTONS // ══════════════════════════════════════ function setupCopy(btn, targetId) { btn.addEventListener('click', () => { const text = $(targetId).value; if (!text) return; navigator.clipboard.writeText(text).then(() => { btn.textContent = 'COPIED!'; btn.classList.add('copied'); setTimeout(() => { btn.textContent = 'COPY'; btn.classList.remove('copied'); }, 1500); }).catch(() => { // Fallback const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); btn.textContent = 'COPIED!'; btn.classList.add('copied'); setTimeout(() => { btn.textContent = 'COPY'; btn.classList.remove('copied'); }, 1500); }); }); } setupCopy(els.btnCopyStyle, 'outStyle'); setupCopy(els.btnCopyExclude, 'outExclude'); setupCopy(els.btnCopyLyrics, 'outLyrics'); // ══════════════════════════════════════ // FORGE PROMPT (API CALL) // ══════════════════════════════════════ els.btnForge.addEventListener('click', () => forgePrompt()); async function forgePrompt() { const apiKey = (localStorage.getItem(LS_KEY_API) || '').trim(); if (!apiKey) { showError('No API key set. Open Settings (⚙) and paste your OpenRouter API key.'); return; } const genre = els.bGenre.value; const subgenre = els.bSubgenre.value.trim(); const mood = els.bMood.value.trim(); const instruments = els.bInstruments ? els.bInstruments.value.trim() : ''; const scale = els.bScale ? els.bScale.value.trim() : ''; const tempo = els.bTempo.value; const vocal = els.bVocal.value; const language = els.bLanguage.value; const exclude = els.bExclude.value.trim(); const concept = els.bConcept.value.trim(); const wantLyrics = els.bLyrics.checked; if (!genre && !subgenre && !concept && !instruments) { showError('Provide at least a genre, subgenre, instruments, or song concept.'); return; } hideError(); setLoading(true); // Build user prompt let userMsg = 'Generate a Suno AI prompt with these parameters:\n'; if (genre) userMsg += `- Genre: ${genre}\n`; if (subgenre) userMsg += `- Subgenre: ${subgenre}\n`; if (mood) userMsg += `- Mood: ${mood}\n`; if (instruments) userMsg += `- Instruments: ${instruments}\n`; if (scale) userMsg += `- Scale/Key: ${scale}\n`; if (tempo) userMsg += `- Tempo: ${tempo}\n`; if (vocal) userMsg += `- Vocal Style: ${vocal}\n`; if (language) userMsg += `- Language: ${language}\n`; if (exclude) userMsg += `- Exclude: ${exclude}\n`; if (concept) userMsg += `- Song Concept: ${concept}\n`; userMsg += `- Include lyrics scaffold: ${wantLyrics ? 'YES' : 'NO'}\n`; const model = getActiveModel(); const body = { model: model, messages: [ { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: userMsg }, ], temperature: 0.8, max_tokens: 2000, }; try { const response = await fetch(OPENROUTER_URL, { method: 'POST', headers: { 'Authorization': 'Bearer ' + apiKey, 'HTTP-Referer': 'https://huggingface.co/spaces/', 'X-Title': 'SUNO FORGE', 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!response.ok) { const errData = await response.json().catch(() => ({})); throw new Error(errData.error?.message || `API error: ${response.status} ${response.statusText}`); } const data = await response.json(); // Extract content const content = data.choices?.[0]?.message?.content || ''; const parsed = parseAIResponse(content); if (!parsed) { throw new Error('Failed to parse AI response. Raw: ' + content.substring(0, 200)); } // Populate output els.outStyle.value = parsed.style_prompt || ''; els.outExclude.value = parsed.exclude || ''; els.outLyrics.value = parsed.lyrics || ''; // Update char counters updateCharCounter(els.outStyle, els.styleCounter, 1000); updateCharCounter(els.outExclude, els.excludeCounter, 200); updateCharCounter(els.outLyrics, els.lyricsCounter, 3000); // Show lyrics section if there are lyrics if (parsed.lyrics && wantLyrics) { els.lyricsSection.classList.add('active'); els.bLyrics.checked = true; } // Token usage — use actual pricing from model data if available const usage = data.usage || {}; const totalTokens = usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0) || 0; const pricing = getModelPricing(model); const promptCost = (usage.prompt_tokens || 0) * pricing.prompt; const completionCost = (usage.completion_tokens || 0) * pricing.completion; const estCost = promptCost + completionCost; const costLabel = estCost === 0 ? 'FREE' : '$' + estCost.toFixed(6); els.tokenUsage.textContent = `Model: ${model} | Tokens: ${totalTokens} | Cost: ${costLabel}`; // Store last generation const channelName = activeChannelIndex >= 0 ? channels[activeChannelIndex].name : 'No Channel'; lastGeneration = { timestamp: new Date().toISOString(), channel: channelName, genre: genre, mood: mood, instruments: instruments, scale: scale, tempo: tempo, vocal: vocal, language: language, concept: concept, subgenre: subgenre, exclude: exclude, style_prompt: parsed.style_prompt || '', exclude_output: parsed.exclude || '', lyrics: parsed.lyrics || '', }; } catch (err) { showError(err.message); } finally { setLoading(false); } } function parseAIResponse(raw) { // Strip markdown fences if present let cleaned = raw.trim(); cleaned = cleaned.replace(/^```json\s*/i, ''); cleaned = cleaned.replace(/^```\s*/i, ''); cleaned = cleaned.replace(/\s*```$/i, ''); cleaned = cleaned.trim(); try { const obj = JSON.parse(cleaned); return { style_prompt: String(obj.style_prompt || ''), exclude: String(obj.exclude || ''), lyrics: String(obj.lyrics || ''), }; } catch (e) { // Try to extract JSON from the response const jsonMatch = cleaned.match(/\{[\s\S]*\}/); if (jsonMatch) { try { const obj = JSON.parse(jsonMatch[0]); return { style_prompt: String(obj.style_prompt || ''), exclude: String(obj.exclude || ''), lyrics: String(obj.lyrics || ''), }; } catch (e2) { return null; } } return null; } } function setLoading(on) { if (on) { els.btnForge.classList.add('loading'); els.btnForge.innerHTML = ' FORGING...'; els.btnSurprise.disabled = true; } else { els.btnForge.classList.remove('loading'); els.btnForge.innerHTML = '⚡ FORGE PROMPT'; els.btnSurprise.disabled = false; } } function showError(msg) { els.errorMessage.textContent = '⚠ ' + msg; els.errorMessage.classList.add('active'); } function hideError() { els.errorMessage.classList.remove('active'); els.errorMessage.textContent = ''; } // ══════════════════════════════════════ // SURPRISE ME // ══════════════════════════════════════ els.btnSurprise.addEventListener('click', () => { els.bGenre.value = pick(GENRES); els.bMood.value = pick(MOODS); if (els.bInstruments) els.bInstruments.value = pick(INSTRUMENTS); els.bTempo.value = pick(TEMPOS); els.bVocal.value = pick(VOCALS); els.bSubgenre.value = ''; els.bConcept.value = ''; forgePrompt(); }); function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } // ══════════════════════════════════════ // SAVE TO HISTORY // ══════════════════════════════════════ els.btnSaveHistory.addEventListener('click', () => { if (!lastGeneration) { showError('Nothing to save. Forge a prompt first.'); return; } history.push(lastGeneration); saveHistory(); renderHistory(); // Brief visual feedback els.btnSaveHistory.textContent = '✓ SAVED!'; setTimeout(() => { els.btnSaveHistory.textContent = '💾 SAVE TO HISTORY'; }, 1500); }); // ══════════════════════════════════════ // HISTORY RENDERING // ══════════════════════════════════════ function renderHistory() { const last10 = history.slice(-10).reverse(); if (last10.length === 0) { els.historyScroll.innerHTML = '