diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 9995668..e6caccf 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -3056,6 +3056,9 @@ const App = () => { isRunning: false, }); const [aiPrompt, setAiPrompt] = useState(''); + const [promptHistory, setPromptHistory] = useState([]); + const [promptHistIdx, setPromptHistIdx] = useState(-1); + const promptHistRef = useRef([]); const [aiProvider, setAiProvider] = useState('OpenAI'); const [aiModel, setAiModel] = useState('GPT-4o'); const [aiActionLog, setAiActionLog] = useState([]); @@ -6993,10 +6996,13 @@ const App = () => { dawContext, tools: window.AIGateway.DEFAULT_TOOLS }); - if (result.textResponse) { - setAiActionLog(prev => [...prev, { type: 'status', text: ` AI: ${result.textResponse}`, time: Date.now() }]); + const hasText = !!result.textResponse; + const hasCalls = result.functionCalls && result.functionCalls.length > 0; + if (hasText) { + setAiActionLog(prev => [...prev, { type: 'status', text: ` AI: ${result.textResponse.slice(0, 500)}`, time: Date.now() }]); } - if (result.functionCalls && result.functionCalls.length > 0) { + if (hasCalls) { + setAiActionLog(prev => [...prev, { type: 'info', text: ` Gọi ${result.functionCalls.length} lệnh...`, time: Date.now() }]); for (const fc of result.functionCalls) { setAiActionLog(prev => [...prev, { type: 'info', text: ` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`, time: Date.now() }]); const cmdName = fc.name.toUpperCase(); @@ -7007,9 +7013,14 @@ const App = () => { } catch (cmdErr) { setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: ${cmdErr.message}`, time: Date.now() }]); } + } else { + setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: DAWCommandDispatcher not available`, time: Date.now() }]); } } } + if (!hasText && !hasCalls) { + setAiActionLog(prev => [...prev, { type: 'error', text: ` AI không trả về lệnh hoặc text. Kiểm tra provider/model có hỗ trợ function calling.`, time: Date.now() }]); + } setAiActionLog(prev => [...prev, { type: 'status', text: ` Hoàn tất.`, time: Date.now() }]); setAiPrompt(''); setTimeout(() => lucide.createIcons(), 200); @@ -7179,12 +7190,19 @@ const App = () => { }, addClip: (args) => { const trackId = args.track_id || selectedTrackId; - const startTime = args.start_time || args.start_bar ? (args.start_bar * (60 / parseInt(bpm || 120)) * 4) : currentTime; + const barDur = 60 / parseInt(bpm || 120) * 4; + let startTime; + if (args.start_time !== undefined && args.start_time !== null) startTime = args.start_time; + else if (args.start_bar !== undefined && args.start_bar !== null) startTime = args.start_bar * barDur; + else startTime = currentTime; const track = tracks.find(t => t.id === trackId); if (!track) return { success: false, error: 'Track not found' }; const ctx = getAudioContext(); const sr = 44100; - const duration = args.duration_seconds || args.length_bars ? (args.length_bars * (60 / parseInt(bpm || 120)) * 4) : 2; + let duration; + if (args.duration_seconds !== undefined && args.duration_seconds !== null) duration = args.duration_seconds; + else if (args.length_bars !== undefined && args.length_bars !== null) duration = args.length_bars * barDur; + else duration = 2; const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = 0; @@ -7286,15 +7304,83 @@ const App = () => { } return { success: false, error: `Unknown action: ${action}` }; }, + renameTrack: (args) => { + const tid = args.track_id || selectedTrackId; + const name = args.name; + if (!tid) return { success: false, error: 'No track_id provided' }; + if (!name) return { success: false, error: 'No name provided' }; + updateTrackName(tid, name); + return { success: true, trackId: tid, name }; + }, + setSelection: (args) => { + const barDur = 60 / parseInt(bpm || 120) * 4; + let start, end; + if (args.start_time !== undefined && args.start_time !== null) start = args.start_time; + else if (args.start_bar !== undefined && args.start_bar !== null) start = args.start_bar * barDur; + else start = currentTime; + if (args.end_time !== undefined && args.end_time !== null) end = args.end_time; + else if (args.length_bars !== undefined && args.length_bars !== null) end = start + args.length_bars * barDur; + else if (args.end_bar !== undefined && args.end_bar !== null) end = args.end_bar * barDur; + else end = start + barDur; + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(start); + setSelectionEnd(end); + return { success: true, start: parseFloat(start.toFixed(3)), end: parseFloat(end.toFixed(3)), length: parseFloat((end - start).toFixed(3)) }; + }, + scanTrack: (args) => { + const tid = args.track_id || selectedTrackId; + const track = tracks.find(t => t.id === tid); + if (!track) return { success: false, error: 'Track not found' }; + if (!track.buffer) return { success: false, error: 'Track has no audio buffer. Load audio first.' }; + const buffer = track.buffer; + const data = buffer.getChannelData(0); + const sr = buffer.sampleRate; + const channels = buffer.numberOfChannels; + const duration = buffer.duration; + const totalSamples = buffer.length; + const windowSize = Math.min(sr * 3, data.length); + let detectedBPM = 0; + if (windowSize > sr) { + let maxCorr = 0; + for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { + let corr = 0; + const step = 4; + for (let i = 0; i < windowSize && i + lag < data.length; i += step) corr += data[i] * data[i + lag]; + corr /= windowSize / step; + if (corr > maxCorr) { maxCorr = corr; detectedBPM = 60 / (lag / sr); } + } + } + detectedBPM = Math.round(Math.min(300, Math.max(30, detectedBPM))); + const bitDepth = 16; + const bitrate = Math.round(sr * channels * bitDepth / 1000); + return { + success: true, + trackId: tid, + trackName: track.name, + bpm: detectedBPM, + sampleRate: sr, + channels, + duration: parseFloat(duration.toFixed(3)), + totalSamples, + bitDepth, + bitrateKbps: bitrate, + hasAudio: true + }; + }, setBpm: (args) => { const bpmVal = args.bpm || args.tempo || 120; setBpm(String(bpmVal)); return { success: true, bpm: bpmVal }; }, setPlayhead: (args) => { - const time = args.time ?? args.position ?? 0; + const barDur = 60 / parseInt(bpm || 120) * 4; + let time; + if (args.time !== undefined && args.time !== null) time = args.time; + else if (args.bar !== undefined && args.bar !== null) time = args.bar * barDur; + else time = 0; handlePlayheadSet(time); - return { success: true, time }; + return { success: true, time: parseFloat(time.toFixed(3)) }; }, addMarker: (args) => { const trackId = args.track_id || selectedTrackId; diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index f07f238..9d7039e 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -7004,14 +7004,21 @@ const App = () => { dawContext, tools: window.AIGateway.DEFAULT_TOOLS }); - if (result.textResponse) { + const hasText = !!result.textResponse; + const hasCalls = result.functionCalls && result.functionCalls.length > 0; + if (hasText) { setAiActionLog(prev => [...prev, { type: 'status', - text: ` AI: ${result.textResponse}`, + text: ` AI: ${result.textResponse.slice(0, 500)}`, time: Date.now() }]); } - if (result.functionCalls && result.functionCalls.length > 0) { + if (hasCalls) { + setAiActionLog(prev => [...prev, { + type: 'info', + text: ` Gọi ${result.functionCalls.length} lệnh...`, + time: Date.now() + }]); for (const fc of result.functionCalls) { setAiActionLog(prev => [...prev, { type: 'info', @@ -7034,9 +7041,22 @@ const App = () => { time: Date.now() }]); } + } else { + setAiActionLog(prev => [...prev, { + type: 'error', + text: ` ❌ ${fc.name}: DAWCommandDispatcher not available`, + time: Date.now() + }]); } } } + if (!hasText && !hasCalls) { + setAiActionLog(prev => [...prev, { + type: 'error', + text: ` AI không trả về lệnh hoặc text. Kiểm tra provider/model có hỗ trợ function calling.`, + time: Date.now() + }]); + } setAiActionLog(prev => [...prev, { type: 'status', text: ` Hoàn tất.`, @@ -7224,7 +7244,9 @@ const App = () => { }, addClip: args => { const trackId = args.track_id || selectedTrackId; - const startTime = args.start_time || args.start_bar ? args.start_bar * (60 / parseInt(bpm || 120)) * 4 : currentTime; + const barDur = 60 / parseInt(bpm || 120) * 4; + let startTime; + if (args.start_time !== undefined && args.start_time !== null) startTime = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) startTime = args.start_bar * barDur;else startTime = currentTime; const track = tracks.find(t => t.id === trackId); if (!track) return { success: false, @@ -7232,7 +7254,8 @@ const App = () => { }; const ctx = getAudioContext(); const sr = 44100; - const duration = args.duration_seconds || args.length_bars ? args.length_bars * (60 / parseInt(bpm || 120)) * 4 : 2; + let duration; + if (args.duration_seconds !== undefined && args.duration_seconds !== null) duration = args.duration_seconds;else if (args.length_bars !== undefined && args.length_bars !== null) duration = args.length_bars * barDur;else duration = 2; const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = 0; @@ -7395,6 +7418,89 @@ const App = () => { error: `Unknown action: ${action}` }; }, + renameTrack: args => { + const tid = args.track_id || selectedTrackId; + const name = args.name; + if (!tid) return { + success: false, + error: 'No track_id provided' + }; + if (!name) return { + success: false, + error: 'No name provided' + }; + updateTrackName(tid, name); + return { + success: true, + trackId: tid, + name + }; + }, + setSelection: args => { + const barDur = 60 / parseInt(bpm || 120) * 4; + let start, end; + if (args.start_time !== undefined && args.start_time !== null) start = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) start = args.start_bar * barDur;else start = currentTime; + if (args.end_time !== undefined && args.end_time !== null) end = args.end_time;else if (args.length_bars !== undefined && args.length_bars !== null) end = start + args.length_bars * barDur;else if (args.end_bar !== undefined && args.end_bar !== null) end = args.end_bar * barDur;else end = start + barDur; + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(start); + setSelectionEnd(end); + return { + success: true, + start: parseFloat(start.toFixed(3)), + end: parseFloat(end.toFixed(3)), + length: parseFloat((end - start).toFixed(3)) + }; + }, + scanTrack: args => { + const tid = args.track_id || selectedTrackId; + const track = tracks.find(t => t.id === tid); + if (!track) return { + success: false, + error: 'Track not found' + }; + if (!track.buffer) return { + success: false, + error: 'Track has no audio buffer. Load audio first.' + }; + const buffer = track.buffer; + const data = buffer.getChannelData(0); + const sr = buffer.sampleRate; + const channels = buffer.numberOfChannels; + const duration = buffer.duration; + const totalSamples = buffer.length; + const windowSize = Math.min(sr * 3, data.length); + let detectedBPM = 0; + if (windowSize > sr) { + let maxCorr = 0; + for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { + let corr = 0; + const step = 4; + for (let i = 0; i < windowSize && i + lag < data.length; i += step) corr += data[i] * data[i + lag]; + corr /= windowSize / step; + if (corr > maxCorr) { + maxCorr = corr; + detectedBPM = 60 / (lag / sr); + } + } + } + detectedBPM = Math.round(Math.min(300, Math.max(30, detectedBPM))); + const bitDepth = 16; + const bitrate = Math.round(sr * channels * bitDepth / 1000); + return { + success: true, + trackId: tid, + trackName: track.name, + bpm: detectedBPM, + sampleRate: sr, + channels, + duration: parseFloat(duration.toFixed(3)), + totalSamples, + bitDepth, + bitrateKbps: bitrate, + hasAudio: true + }; + }, setBpm: args => { const bpmVal = args.bpm || args.tempo || 120; setBpm(String(bpmVal)); @@ -7404,11 +7510,13 @@ const App = () => { }; }, setPlayhead: args => { - const time = args.time ?? args.position ?? 0; + const barDur = 60 / parseInt(bpm || 120) * 4; + let time; + if (args.time !== undefined && args.time !== null) time = args.time;else if (args.bar !== undefined && args.bar !== null) time = args.bar * barDur;else time = 0; handlePlayheadSet(time); return { success: true, - time + time: parseFloat(time.toFixed(3)) }; }, addMarker: args => { diff --git a/app/static/js/services/aiGateway.js b/app/static/js/services/aiGateway.js index 7f1d993..1fdae70 100644 --- a/app/static/js/services/aiGateway.js +++ b/app/static/js/services/aiGateway.js @@ -4,17 +4,145 @@ const AIGateway = (function() { const DEFAULT_TOOLS = [{ name: 'create_track', - description: 'Tạo một track mới trong dự án', + description: 'Tạo một track âm thanh mới trong dự án', parameters: { type: 'object', properties: { - name: { type: 'string', description: 'Tên track' }, + name: { type: 'string', description: 'Tên cho track mới (VD: Beat, Vocal, Guitar)' }, type: { type: 'string', enum: ['audio', 'midi'], description: 'Loại track' } }, - required: ['name', 'type'] + required: ['name'] } }, { - name: 'add_midi_item', + name: 'delete_track', + description: 'Xóa một track khỏi dự án', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track cần xóa. Nếu không có thì xóa track đang chọn.' } + } + } + }, { + name: 'add_clip', + description: 'Thêm một clip âm thanh rỗng vào track', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track đích' }, + start_time: { type: 'number', description: 'Vị trí bắt đầu (giây)' }, + duration_seconds: { type: 'number', description: 'Độ dài clip (giây)' }, + start_bar: { type: 'number', description: 'Vị trí bắt đầu (bar). Bar 0 = bar đầu tiên. Dùng thay cho start_time.' }, + length_bars: { type: 'number', description: 'Độ dài (bar). Dùng thay cho duration_seconds.' }, + name: { type: 'string', description: 'Tên clip' } + } + } + }, { + name: 'remove_clip', + description: 'Xóa một clip khỏi track', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track' }, + clip_id: { type: 'string', description: 'ID của clip cần xóa' } + }, + required: ['clip_id'] + } + }, { + name: 'set_track_volume', + description: 'Điều chỉnh âm lượng của track (dB)', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track' }, + volume_db: { type: 'number', description: 'Âm lượng tính bằng dB (VD: -6, 0, +3)' } + }, + required: ['volume_db'] + } + }, { + name: 'set_track_pan', + description: 'Điều chỉnh cân bằng trái/phải (pan) của track', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track' }, + pan: { type: 'integer', description: 'Pan value: -100 (trái), 0 (trung tâm), 100 (phải)' } + }, + required: ['pan'] + } + }, { + name: 'toggle_mute', + description: 'Bật/tắt mute (tắt tiếng) của track', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track cần mute/unmute' } + } + } + }, { + name: 'toggle_solo', + description: 'Bật/tắt solo (chỉ nghe track này) của track', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track cần solo/unsolo' } + } + } + }, { + name: 'rename_track', + description: 'Đổi tên của một track', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track cần đổi tên' }, + name: { type: 'string', description: 'Tên mới cho track' } + }, + required: ['track_id', 'name'] + } + }, { + name: 'set_bpm', + description: 'Thay đổi tempo (BPM) của dự án', + parameters: { + type: 'object', + properties: { + bpm: { type: 'number', description: 'Tempo mới tính bằng BPM (VD: 120, 128, 140)' } + }, + required: ['bpm'] + } + }, { + name: 'set_playhead', + description: 'Di chuyển playhead (con trỏ phát) đến vị trí chỉ định', + parameters: { + type: 'object', + properties: { + time: { type: 'number', description: 'Vị trí thời gian tính bằng giây' }, + bar: { type: 'number', description: 'Vị trí bar (0 = bar đầu tiên). Dùng thay cho time.' } + } + } + }, { + name: 'add_marker', + description: 'Thêm một marker (đánh dấu) vào track tại vị trí chỉ định', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track' }, + time: { type: 'number', description: 'Vị trí thời gian (giây). Mặc định là vị trí playhead hiện tại.' }, + label: { type: 'string', description: 'Nhãn cho marker' } + } + } + }, { + name: 'process_audio_dsp', + description: 'Xử lý hiệu ứng âm thanh DSP cho track (chuẩn hóa, đảo phase, gain, pitch shift)', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track âm thanh' }, + action: { type: 'string', enum: ['normalize', 'invert_phase', 'gain', 'pitch_shift'], description: 'Loại xử lý DSP' }, + params: { type: 'object', description: 'Tham số bổ sung: gain_db cho gain, semitones cho pitch_shift' } + }, + required: ['track_id', 'action'] + } + }, { + name: 'create_midi_item', description: 'Thêm một MIDI item/clip vào track', parameters: { type: 'object', @@ -25,6 +153,28 @@ const AIGateway = (function() { }, required: ['track_id', 'start_bar', 'length_bars'] } + }, { + name: 'set_selection', + description: 'Chọn một vùng trên timeline (selection range). Dùng để xác định khoảng thời gian trước khi gọi các lệnh khác.', + parameters: { + type: 'object', + properties: { + start_bar: { type: 'number', description: 'Bar bắt đầu (0 = bar đầu tiên của project)' }, + end_bar: { type: 'number', description: 'Bar kết thúc' }, + start_time: { type: 'number', description: 'Thời gian bắt đầu (giây). Dùng thay cho start_bar.' }, + end_time: { type: 'number', description: 'Thời gian kết thúc (giây). Dùng thay cho end_bar.' }, + length_bars: { type: 'number', description: 'Độ dài vùng chọn (bar). Dùng cùng start_bar thay cho end_bar.' } + } + } + }, { + name: 'scan_track', + description: 'Quét và phân tích track âm thanh: phát hiện BPM (tempo), sample rate, số kênh (mono/stereo), bit depth, duration', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track cần quét. Nếu không có thì quét track đang chọn.' } + } + } }, { name: 'modify_midi_notes', description: 'Thêm, chỉnh sửa hoặc xóa các note MIDI trong item', @@ -49,18 +199,6 @@ const AIGateway = (function() { }, required: ['item_id', 'notes'] } - }, { - name: 'process_audio_dsp', - description: 'Gửi yêu cầu chỉnh sửa âm thanh sang Python DSP Backend', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track âm thanh' }, - action: { type: 'string', enum: ['normalize', 'invert_phase', 'gain', 'pitch_shift'], description: 'Loại xử lý DSP' }, - params: { type: 'object', description: 'Tham số bổ sung cho hành động' } - }, - required: ['track_id', 'action'] - } }]; function parseOrigin(urlStr) { @@ -91,6 +229,7 @@ const AIGateway = (function() { const body = { model, messages, + stream: false, ...(tools && tools.length > 0 ? { tools: tools.map(t => ({ type: 'function', function: t })) } : {}), ...(toolChoice ? { tool_choice: toolChoice } : {}) }; @@ -155,29 +294,33 @@ const AIGateway = (function() { function buildUserMessage(prompt, context) { const contextStr = JSON.stringify(context, null, 2); + const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n'); return [ - { role: 'system', content: 'Bạn là trợ lý AI cho DAW (SonicForge Studio). Hãy phân tích yêu cầu người dùng và phản hồi BẰNG DẠNG FUNCTION CALLS phù hợp. Luôn trả về function call khi có thể thực hiện hành động.' }, + { role: 'system', content: `Bạn là trợ lý AI cho DAW (SonicForge Studio). Các lệnh DAW có sẵn:\n${toolNames}\n\nQUAN TRỌNG: Bar được đánh số từ 0 (bar 0 = bar đầu tiên). VD: bar 0-3 = 4 bar đầu tiên.\nPhân tích yêu cầu và trả về FUNCTION CALLS. Có thể gọi nhiều function cùng lúc.` }, { role: 'user', content: `Ngữ cảnh DAW hiện tại:\n${contextStr}\n\nYêu cầu người dùng: ${prompt}` } ]; } function buildAIPromptContext(dawState) { - const tracks = (dawState.tracks || []).map(t => ({ - id: t.id, - name: t.name, - type: t.buffer ? 'audio' : 'empty', - hasBuffer: !!t.buffer, - clipsCount: t.clips ? t.clips.length : (t.buffer ? 1 : 0), - muted: t.muted, - solo: t.solo, - volumeDb: t.volumeDb ?? 0, - pan: t.pan ?? 0 - })); + const tracks = (dawState.tracks || []).map(t => { + const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ id: 'default_' + t.id, name: t.name, startTime: t.startTime || 0, duration: t.buffer.duration }] : []); + return { + id: t.id, + name: t.name, + type: t.buffer ? 'audio' : 'empty', + hasBuffer: !!t.buffer, + muted: t.muted, + solo: t.solo, + volumeDb: t.volumeDb ?? 0, + pan: t.pan ?? 0, + clips: clips.map(c => ({ id: c.id, name: c.name, startTime: parseFloat((c.startTime || 0).toFixed(3)), duration: parseFloat((c.buffer ? c.buffer.duration : 0).toFixed(3)) })) + }; + }); return { - tempo: dawState.bpm || 120, + tempo: parseInt(dawState.bpm || '120'), timeSignature: '4/4', selectedTrackId: dawState.selectedTrackId || null, - playheadPosition: dawState.currentTime || 0, + playheadPosition: parseFloat((dawState.currentTime || 0).toFixed(3)), selection: (dawState.selLeft !== null && dawState.selRight !== null && dawState.selRight > dawState.selLeft) ? { start: parseFloat(dawState.selLeft.toFixed(3)), end: parseFloat(dawState.selRight.toFixed(3)), diff --git a/app/static/js/services/dawCommandDispatcher.js b/app/static/js/services/dawCommandDispatcher.js index 200294d..719d2f4 100644 --- a/app/static/js/services/dawCommandDispatcher.js +++ b/app/static/js/services/dawCommandDispatcher.js @@ -57,6 +57,9 @@ const DAWCommandDispatcher = (function() { register('TOGGLE_MUTE', (args) => api.toggleMute(args)); register('TOGGLE_SOLO', (args) => api.toggleSolo(args)); register('PROCESS_AUDIO_DSP', (args) => api.processAudioDsp(args)); + register('RENAME_TRACK', (args) => api.renameTrack(args)); + register('SCAN_TRACK', (args) => api.scanTrack(args)); + register('SET_SELECTION', (args) => api.setSelection(args)); register('SET_BPM', (args) => api.setBpm(args)); register('SET_PLAYHEAD', (args) => api.setPlayhead(args)); register('ADD_MARKER', (args) => api.addMarker(args)); diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index d2ae6ec..9e89621 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ diff --git a/app/templates/index.html b/app/templates/index.html index a0c7adc..1d7ab32 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -13,6 +13,7 @@ +