SUNO / app.js
Tamal321's picture
Upload folder using huggingface_hub
d0f37f6 verified
Raw History Blame Contribute Delete
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();
})();