Download app.js from Fairus/SUNO: direct link, hf CLI and curl.
- Browser
- Download file 42.4 kB
-
https://huggingface.co/spaces/Fairus/SUNO/resolve/main/app.js
- Command line
-
hf download hf://spaces/Fairus/SUNO/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Fairus/SUNO/resolve/main/app.js
42.4 kB
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 = ` | |
| <span class="ch-name" title="${esc(ch.name)}">${esc(ch.name)}</span> | |
| <span class="ch-badge">${esc(ch.genre || 'β')}</span> | |
| <button class="ch-delete" data-index="${i}" title="Delete channel">β</button> | |
| `; | |
| 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 = '<span class="spinner"></span> 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 = '<div class="history-empty">No generations yet. Forge something!</div>'; | |
| 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 = ` | |
| <div class="ht-channel">${esc(item.channel)}</div> | |
| <div class="ht-genre">${esc(item.genre || item.subgenre || 'β')}</div> | |
| <div class="ht-time">${timeStr}</div> | |
| `; | |
| 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 ? '<span class="model-free">FREE</span>' : ''; | |
| els.modelMeta.innerHTML = `<span class="model-context">Context: ${ctx}</span> ${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 = '<option value="">β³ Fetching models...</option>'; | |
| 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 = '<option value="">Failed to load β use custom slug below</option>'; | |
| // 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 = '<option value="">No free models found</option>'; | |
| 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(); | |
| })(); | |