// 核心生成 API // POST /api/generate { audioUrl, style, lyrics, author, title, instr, voice } const SUNO_KEY = '84e3182b1bbeb540164ff543d7749cef'; const BASE = 'https://api.sunoapi.org/api/v1'; // 乐器名 → Suno 英文关键词 const INSTR_EN = { '钢琴':'piano', '古筝':'guzheng', '琵琶':'pipa', '效果器':'effects', '提琴':'violin', '竖笛':'recorder', '吉他':'guitar', '架子鼓':'drums', '电子鼓':'electronic drums', '二胡':'erhu', '弦乐':'strings', '贝斯':'bass', '竹笛':'bamboo flute', '南箫':'nanxiao', '口琴':'harmonica', '萨克斯':'saxophone', '合成器':'synthesizer' }; // 各风格的 Suno 提示词 const STYLE_PROMPTS = { '国风': 'Chinese pentatonic scale, traditional Chinese instruments blended with modern pop', '流行': 'modern K-pop influenced Chinese pop, piano guitar, polished catchy melodic', '民谣': 'Chinese indie folk ballad, acoustic guitar fingerpicking, storytelling vocal', '摇滚': 'Chinese rock, distorted electric guitar, powerful drums, high energy', '电子': 'Chinese EDM, synth leads, electronic drum beats, build-up drop, futuristic', 'R&B': 'Chinese R&B soul, smooth vocal runs, electric piano, groovy drum patterns', '爵士': 'modern Chinese jazz, piano saxophone, walking bass, swing rhythm' }; async function pollTask(taskId, env) { // 快速检查3次(每次3秒),不超过30秒 for (let i = 0; i < 3; i++) { await new Promise(r => setTimeout(r, 3000)); try { const cbData = await env.AUDIO_KV.get('suno:task:' + taskId, { type: 'text' }); if (cbData) { const s = JSON.parse(cbData); return { id: s.id, title: s.title, audio_url: s.audio_url, image_url: s.image_url, duration: s.duration }; } } catch(e) {} const resp = await fetch(BASE + '/generate/record-info?taskId=' + taskId, { headers: { 'Authorization': 'Bearer ' + SUNO_KEY } }); const data = await resp.json(); const songs = data.data?.response?.sunoData || data.data?.sunoData; if (data.code === 200 && songs?.length > 0) { const s = songs[0]; const audioUrl = s.audioUrl || s.sourceAudioUrl || s.audio_url; if (audioUrl) return { id: s.id, title: s.title, audio_url: audioUrl, image_url: s.image_url || s.sourceImageUrl, duration: s.duration }; } } return null; } export async function onRequest(context) { const { request, env } = context; if (request.method === 'OPTIONS') { return new Response(null, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type' } }); } try { const { audioUrl, style, lyrics: userLyrics, author, title, instr, voice } = await request.json(); if (!audioUrl) return new Response(JSON.stringify({ ok: false, error: 'no audioUrl' }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); const SENSITIVE = ['色情','赌博','毒品','暴力','枪支','血腥','反动','分裂','恐怖','邪教']; function isSafe(t) { for (const w of SENSITIVE) { if (t.includes(w)) return false; } return true; } let lyrics = (userLyrics || '').trim(); if (lyrics && !isSafe(lyrics)) return new Response(JSON.stringify({ ok: false, error: '歌词不合适' }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); if (lyrics.length > 800) lyrics = lyrics.slice(0, 800); if (!lyrics) return new Response(JSON.stringify({ ok: false, error: 'no lyrics' }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); // 解析用户选择的乐器 → 英文关键词 var instrEnList = []; if (instr) { try { var instrSel = typeof instr === 'string' ? JSON.parse(instr) : instr; ['intro','verse','bridge','chorus','outro'].forEach(function(k) { var v = instrSel[k] || ''; if (v) { v.split(',').forEach(function(item) { var trimmed = item.trim(); var en = INSTR_EN[trimmed] || trimmed; if (en && instrEnList.indexOf(en) < 0) instrEnList.push(en); }); } }); } catch(e) {} } // 构建 style(详细乐器和风格) var stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS['流行']; var vocalText = voice === '女声' ? 'female vocal' : 'male vocal'; var instrText = instrEnList.length > 0 ? instrEnList.join(', ') : ''; var sunoStyle = [stylePrompt, instrText, vocalText].filter(Boolean).join(', '); var sunoStyleText = sunoStyle.slice(0, 1000); // 清洗歌词:去掉 [xxx前奏] [xxx主歌] 等乐器标注,保留标准 [Verse], [Chorus] 格式 var cleanLyrics = lyrics .replace(/\[[^\]]*?前奏[^\]]*\]/g, '[Intro]') .replace(/\[[^\]]*?主歌[^\]]*\]/g, '[Verse]') .replace(/\[[^\]]*?间奏[^\]]*\]/g, '[Interlude]') .replace(/\[[^\]]*?副歌[^\]]*\]/g, '[Chorus]') .replace(/\[[^\]]*?结尾[^\]]*\]/g, '[Outro]') .replace(/\[[^\]]*?桥段[^\]]*\]/g, '[Bridge]') .replace(/\[[^\]]*?过渡[^\]]*\]/g, '[Interlude]') .replace(/(纯音乐)\s*/g, '').trim(); var sunoLyrics = cleanLyrics.slice(0, 5000); if (instrEnList.length > 0) sunoLyrics += '\n[Instrument: ' + instrEnList.join(', ') + ']'; var songTitle = (title || (author ? author + '的灵感创作' : '灵感创作')).slice(0, 100); const sunoPayload = { uploadUrl: audioUrl, customMode: true, instrumental: false, prompt: sunoLyrics, style: sunoStyleText, title: songTitle, model: 'V5', callBackUrl: 'https://inspiration-rnp.pages.dev/api/callback' }; const sunoResp = await fetch(BASE + '/generate/upload-cover', { method: 'POST', headers: { 'Authorization': 'Bearer ' + SUNO_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(sunoPayload) }); const sunoData = await sunoResp.json(); if (sunoData.code === 200 && sunoData.data?.taskId) { const result = await pollTask(sunoData.data.taskId, env); if (result) { try { const songId = 'song_' + Date.now().toString(36) + Math.random().toString(36).substring(2,6); await env.AUDIO_KV.put('song:' + songId, JSON.stringify({ id: songId, title: result.title || title, audio_url: result.audio_url, image_url: result.image_url || '', nickname: author ? '词曲:' + author : '匿名', occasion: style, lyrics, created_at: new Date().toISOString(), likes: 0, plays: 0 })); var listStr = await env.AUDIO_KV.get('songs:recent') || '[]'; var ids = JSON.parse(listStr); ids.unshift(songId); if (ids.length > 20) ids.length = 20; await env.AUDIO_KV.put('songs:recent', JSON.stringify(ids)); } catch(e) { console.log('save error:', e); } return new Response(JSON.stringify({ ok: true, data: { ...result, lyrics } }), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); } } if (sunoData.code === 200 && sunoData.data?.taskId) { return new Response(JSON.stringify({ ok: true, taskId: sunoData.data.taskId, data: { taskId: sunoData.data.taskId, lyrics } }), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); } return new Response(JSON.stringify({ ok: false, error: 'Suno failed', data: sunoData }), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); } catch(e) { return new Response(JSON.stringify({ ok: false, error: e.message }), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }); } }