/* ══════════════════════════════════════════════════════════ 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 = '
No generations yet. Forge something!
'; return; } els.historyScroll.innerHTML = ''; last10.forEach((item, i) => { const tile = document.createElement('div'); tile.className = 'history-tile fade-in'; const date = new Date(item.timestamp); const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); tile.innerHTML = `
${esc(item.channel)}
${esc(item.genre || item.subgenre || '—')}
${timeStr}
`; tile.addEventListener('click', () => restoreFromHistory(item)); els.historyScroll.appendChild(tile); }); } function restoreFromHistory(item) { els.bGenre.value = item.genre || ''; els.bSubgenre.value = item.subgenre || ''; els.bMood.value = item.mood || ''; if (els.bInstruments) els.bInstruments.value = item.instruments || ''; if (els.bScale) els.bScale.value = item.scale || ''; els.bTempo.value = item.tempo || ''; els.bVocal.value = item.vocal || ''; els.bLanguage.value = item.language || ''; els.bExclude.value = item.exclude || ''; els.bConcept.value = item.concept || ''; els.outStyle.value = item.style_prompt || ''; els.outExclude.value = item.exclude_output || ''; els.outLyrics.value = item.lyrics || ''; updateCharCounter(els.outStyle, els.styleCounter, 1000); updateCharCounter(els.outExclude, els.excludeCounter, 200); updateCharCounter(els.outLyrics, els.lyricsCounter, 3000); if (item.lyrics) { els.lyricsSection.classList.add('active'); els.bLyrics.checked = true; } lastGeneration = item; // Scroll to builder els.bGenre.scrollIntoView({ behavior: 'smooth', block: 'start' }); } // ══════════════════════════════════════ // CSV EXPORT // ══════════════════════════════════════ els.btnExportCsv.addEventListener('click', () => { if (history.length === 0) { showError('No history to export.'); return; } const headers = ['timestamp', 'channel', 'genre', 'mood', 'instruments', 'scale', 'tempo', 'vocal', 'language', 'concept', 'style_prompt', 'exclude', 'lyrics']; const rows = [headers.join(',')]; history.forEach((item) => { const row = headers.map((h) => { let val = ''; if (h === 'exclude') val = item.exclude_output || ''; else val = item[h] || ''; // Escape CSV val = String(val).replace(/"/g, '""'); if (val.includes(',') || val.includes('"') || val.includes('\n')) { val = '"' + val + '"'; } return val; }); rows.push(row.join(',')); }); const csv = rows.join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'sunoforge_history_' + dateStamp() + '.csv'; a.click(); URL.revokeObjectURL(url); }); // ══════════════════════════════════════ // UTILITIES // ══════════════════════════════════════ function esc(str) { const div = document.createElement('div'); div.textContent = str || ''; return div.innerHTML; } function dateStamp() { return new Date().toISOString().slice(0, 10).replace(/-/g, ''); } function downloadJSON(data, filename) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } // ══════════════════════════════════════ // MODEL FETCHING (OpenRouter /api/v1/models) // ══════════════════════════════════════ function getActiveModel() { const custom = els.modelInput.value.trim(); if (custom) return custom; const selected = els.modelSelect.value; if (selected) return selected; return DEFAULT_MODEL; } function getModelPricing(modelId) { const m = freeModels.find((x) => x.id === modelId); if (m && m.pricing) { return { prompt: parseFloat(m.pricing.prompt) || 0, completion: parseFloat(m.pricing.completion) || 0, }; } // Fallback rough estimate return { prompt: 0.000000075, completion: 0.0000003 }; } function updateModelMeta() { const modelId = els.modelSelect.value; const m = freeModels.find((x) => x.id === modelId); if (!m) { els.modelMeta.innerHTML = ''; return; } const ctx = m.context_length ? (m.context_length / 1000).toFixed(0) + 'k' : '?'; const isFree = m.pricing && parseFloat(m.pricing.prompt) === 0 && parseFloat(m.pricing.completion) === 0; const freeTag = isFree ? 'FREE' : ''; els.modelMeta.innerHTML = `Context: ${ctx} ${freeTag}`; } async function fetchFreeModels(forceRefresh = false) { // Try cache first if (!forceRefresh) { try { const cached = JSON.parse(localStorage.getItem(LS_KEY_MODELS_CACHE) || '{}'); if (cached.data && cached.ts && (Date.now() - cached.ts < MODELS_CACHE_TTL)) { freeModels = cached.data; populateModelDropdown(); return; } } catch (e) { /* ignore cache errors */ } } els.modelSelect.innerHTML = ''; els.btnRefreshModels.disabled = true; try { const response = await fetch(OPENROUTER_MODELS_URL); if (!response.ok) throw new Error('Failed to fetch models'); const json = await response.json(); const allModels = json.data || []; // Filter: free models (prompt and completion pricing both "0") freeModels = allModels.filter((m) => { if (!m.pricing) return false; return parseFloat(m.pricing.prompt) === 0 && parseFloat(m.pricing.completion) === 0; }); // Sort alphabetically by name freeModels.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)); // Cache try { localStorage.setItem(LS_KEY_MODELS_CACHE, JSON.stringify({ data: freeModels, ts: Date.now(), })); } catch (e) { /* storage full, ignore */ } populateModelDropdown(); } catch (err) { console.error('Model fetch error:', err); els.modelSelect.innerHTML = ''; // Add default as fallback const opt = document.createElement('option'); opt.value = DEFAULT_MODEL; opt.textContent = 'google/gemini-flash-1.5 (default)'; els.modelSelect.appendChild(opt); } finally { els.btnRefreshModels.disabled = false; } } function populateModelDropdown() { els.modelSelect.innerHTML = ''; if (freeModels.length === 0) { els.modelSelect.innerHTML = ''; return; } // Group by provider const groups = {}; freeModels.forEach((m) => { const provider = m.id.split('/')[0] || 'other'; if (!groups[provider]) groups[provider] = []; groups[provider].push(m); }); // Sort provider names const sortedProviders = Object.keys(groups).sort(); // Add a default option const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = `— Select from ${freeModels.length} free models —`; els.modelSelect.appendChild(defaultOpt); sortedProviders.forEach((provider) => { const optgroup = document.createElement('optgroup'); optgroup.label = provider.toUpperCase(); groups[provider].forEach((m) => { const opt = document.createElement('option'); opt.value = m.id; const ctx = m.context_length ? ` [${(m.context_length / 1000).toFixed(0)}k]` : ''; opt.textContent = (m.name || m.id) + ctx; optgroup.appendChild(opt); }); els.modelSelect.appendChild(optgroup); }); // Restore saved selection const saved = localStorage.getItem(LS_KEY_MODEL) || ''; if (saved && !els.modelInput.value.trim()) { // Try to select it in dropdown const exists = freeModels.some((m) => m.id === saved); if (exists) { els.modelSelect.value = saved; } } updateModelMeta(); } // ══════════════════════════════════════ // IFRAME DETECTION & STORAGE ACCESS // ══════════════════════════════════════ function isInIframe() { try { return window.self !== window.top; } catch (e) { return true; // cross-origin iframe blocks access to window.top } } function getDirectUrl() { // When on HF Spaces, the iframe src is like https://username-spacename.hf.space // The parent page is https://huggingface.co/spaces/username/spacename // We want to link to the direct .hf.space URL const loc = window.location; if (loc.hostname.endsWith('.hf.space')) { return loc.href; } // Fallback: try to construct from current URL return loc.href; } async function requestStorageAccess() { // Try the Storage Access API to get unpartitioned storage in iframe if (document.hasStorageAccess) { try { const hasAccess = await document.hasStorageAccess(); if (!hasAccess && document.requestStorageAccess) { await document.requestStorageAccess(); // Reload data after getting access loadData(); renderChannels(); renderHistory(); } } catch (e) { console.log('Storage Access API not available or denied:', e); } } } function setupIframeBanner() { if (!isInIframe()) return; const banner = document.getElementById('iframeBanner'); const notice = document.getElementById('iframeNotice'); const btnOpen = document.getElementById('btnOpenFull'); const btnOpenSettings = document.getElementById('settingsOpenFull'); const btnDismiss = document.getElementById('btnDismissBanner'); // Check if user previously dismissed (session-only) const dismissed = sessionStorage.getItem('sunoforge_banner_dismissed'); const directUrl = getDirectUrl(); btnOpen.href = directUrl; if (btnOpenSettings) btnOpenSettings.href = directUrl; if (!dismissed) { banner.style.display = 'flex'; } if (notice) { notice.style.display = 'block'; } btnDismiss.addEventListener('click', () => { banner.style.display = 'none'; sessionStorage.setItem('sunoforge_banner_dismissed', '1'); }); // Try to request storage access requestStorageAccess(); } // ══════════════════════════════════════ // SUPABASE CLOUD HISTORY SYNC // ══════════════════════════════════════ function initSupabase(url, key, isUserAction = false) { if (!url || !key) { if (els.sbStatus) { els.sbStatus.textContent = 'Status: Local storage only'; els.sbStatus.style.color = 'var(--dim)'; } supabaseClient = null; return; } if (typeof window.supabase === 'undefined' || !window.supabase.createClient) { if (els.sbStatus) { els.sbStatus.textContent = 'Status: Supabase SDK loading...'; els.sbStatus.style.color = 'var(--amber)'; } setTimeout(() => initSupabase(url, key, isUserAction), 500); return; } try { supabaseClient = window.supabase.createClient(url, key); syncHistoryFromSupabase(isUserAction); } catch (e) { console.error('Supabase init error:', e); if (els.sbStatus) { els.sbStatus.textContent = 'Status: Invalid URL/Key format'; els.sbStatus.style.color = 'var(--red)'; } } } async function syncHistoryFromSupabase(isUserAction = false) { if (!supabaseClient) return; if (els.sbStatus) { els.sbStatus.textContent = 'Status: Connecting...'; els.sbStatus.style.color = 'var(--amber)'; } try { const { data, error } = await supabaseClient .from('suno_history') .select('*') .order('created_at', { ascending: false }) .limit(50); if (error) { console.warn('Supabase query error:', error); if (els.sbStatus) { els.sbStatus.textContent = `Status: Error (${error.message || 'Check RLS/Table'})`; els.sbStatus.style.color = 'var(--red)'; } return; } if (Array.isArray(data)) { if (els.sbStatus) { els.sbStatus.textContent = `Status: Connected ✓ (${data.length} cloud records)`; els.sbStatus.style.color = 'var(--green)'; } if (data.length > 0) { const cloudItems = data.map((row) => ({ timestamp: row.created_at || new Date().toISOString(), channel: row.channel || '', genre: row.genre || '', subgenre: row.subgenre || '', mood: row.mood || '', instruments: row.instruments || '', scale: row.scale || '', tempo: row.tempo || '', vocal: row.vocal || '', language: row.language || '', exclude: row.exclude || '', concept: row.concept || '', style_prompt: row.style_prompt || '', exclude_output: row.exclude_output || '', lyrics: row.lyrics || '', })).reverse(); history = cloudItems; saveHistoryLocally(); renderHistory(); } if (isUserAction) { alert(`Successfully connected to Supabase! Syncing ${data.length} records.`); } } } catch (err) { console.error('Supabase sync error:', err); if (els.sbStatus) { els.sbStatus.textContent = 'Status: Connection failed'; els.sbStatus.style.color = 'var(--red)'; } } } async function saveToSupabase(genItem) { if (!supabaseClient) return; try { const { error } = await supabaseClient .from('suno_history') .insert([{ channel: genItem.channel || '', genre: genItem.genre || '', subgenre: genItem.subgenre || '', mood: genItem.mood || '', instruments: genItem.instruments || '', scale: genItem.scale || '', tempo: genItem.tempo || '', vocal: genItem.vocal || '', language: genItem.language || '', exclude: genItem.exclude || '', concept: genItem.concept || '', style_prompt: genItem.style_prompt || '', exclude_output: genItem.exclude_output || '', lyrics: genItem.lyrics || '', created_at: genItem.timestamp || new Date().toISOString() }]); if (error) { console.warn('Supabase insert error:', error); } else { if (els.sbStatus) { els.sbStatus.textContent = 'Status: Connected ✓ (Saved row)'; els.sbStatus.style.color = 'var(--green)'; } } } catch (e) { console.error('Failed to insert into Supabase:', e); } } // ══════════════════════════════════════ // INIT // ══════════════════════════════════════ function init() { loadData(); renderChannels(); renderHistory(); fetchFreeModels(); setupIframeBanner(); // Auto-connect Supabase if credentials are in localStorage const sbUrl = localStorage.getItem(LS_KEY_SB_URL); const sbKey = localStorage.getItem(LS_KEY_SB_KEY); if (sbUrl && sbKey) { initSupabase(sbUrl, sbKey, false); } } init(); })();