From bd12d12528cea936a0b2b84ec7a9c1b9db7351c5 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 12 Apr 2026 17:45:29 +0000 Subject: [PATCH] MANZI --- backend/src/index.js | 8 +- backend/src/routes/studio.js | 26 + backend/src/services/studio.js | 621 ++++++++++++++ frontend/src/components/AsideMenuLayer.tsx | 4 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 7 + frontend/src/pages/index.tsx | 303 +++---- frontend/src/pages/search.tsx | 4 +- frontend/src/pages/studio.tsx | 947 +++++++++++++++++++++ 10 files changed, 1770 insertions(+), 156 deletions(-) create mode 100644 backend/src/routes/studio.js create mode 100644 backend/src/services/studio.js create mode 100644 frontend/src/pages/studio.tsx diff --git a/backend/src/index.js b/backend/src/index.js index 8a902dd..e6e3637 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,6 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -74,6 +73,7 @@ const cover_artworksRoutes = require('./routes/cover_artworks'); const marketplace_listingsRoutes = require('./routes/marketplace_listings'); const marketplace_ordersRoutes = require('./routes/marketplace_orders'); +const studioRoutes = require('./routes/studio'); const getBaseUrl = (url) => { @@ -185,6 +185,12 @@ app.use('/api/marketplace_listings', passport.authenticate('jwt', {session: fals app.use('/api/marketplace_orders', passport.authenticate('jwt', {session: false}), marketplace_ordersRoutes); +app.use( + '/api/studio', + passport.authenticate('jwt', { session: false }), + studioRoutes, +); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/studio.js b/backend/src/routes/studio.js new file mode 100644 index 0000000..0279769 --- /dev/null +++ b/backend/src/routes/studio.js @@ -0,0 +1,26 @@ +const express = require('express'); +const StudioService = require('../services/studio'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.get( + '/launchpad', + checkPermissions('READ_PROJECTS'), + wrapAsync(async (req, res) => { + const payload = await StudioService.getLaunchpad(req.currentUser); + res.status(200).send(payload); + }), +); + +router.post( + '/launchpad', + checkPermissions('CREATE_PROJECTS'), + wrapAsync(async (req, res) => { + const payload = await StudioService.createLaunchpadSession(req.body || {}, req.currentUser); + res.status(200).send(payload); + }), +); + +module.exports = router; diff --git a/backend/src/services/studio.js b/backend/src/services/studio.js new file mode 100644 index 0000000..fbc197c --- /dev/null +++ b/backend/src/services/studio.js @@ -0,0 +1,621 @@ +const db = require('../db/models'); +const ProjectsDBApi = require('../db/api/projects'); +const SongsDBApi = require('../db/api/songs'); +const GenerationRequestsDBApi = require('../db/api/generation_requests'); +const RecordingSessionsDBApi = require('../db/api/recording_sessions'); +const TracksDBApi = require('../db/api/tracks'); +const ArrangementSectionsDBApi = require('../db/api/arrangement_sections'); +const MixSessionsDBApi = require('../db/api/mix_sessions'); +const MasteringSessionsDBApi = require('../db/api/mastering_sessions'); +const ExportsDBApi = require('../db/api/exports'); +const SongMetadataDBApi = require('../db/api/song_metadata'); +const CoverArtworksDBApi = require('../db/api/cover_artworks'); + +const { Op } = db.Sequelize; + +const moods = ['happy', 'sad', 'spiritual', 'energetic', 'romantic', 'chill', 'aggressive']; +const vocalModes = ['instrumental', 'upload', 'record']; +const exportFormats = ['wav', 'mp3', 'stems_zip']; + +const arrangementBlueprint = [ + { section_type: 'intro', label: 'Atmospheric Intro', start_bar: 1, bar_length: 8, order_index: 1 }, + { section_type: 'verse', label: 'Verse Groove', start_bar: 9, bar_length: 16, order_index: 2 }, + { section_type: 'chorus', label: 'Main Hook', start_bar: 25, bar_length: 16, order_index: 3 }, + { section_type: 'bridge', label: 'Lift & Transition', start_bar: 41, bar_length: 8, order_index: 4 }, + { section_type: 'outro', label: 'DJ Friendly Outro', start_bar: 49, bar_length: 8, order_index: 5 }, +]; + +function badRequest(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function getOrganizationId(currentUser) { + return currentUser?.organizationsId || currentUser?.organizations?.id || null; +} + +function getArtistDisplay(currentUser) { + const name = [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ').trim(); + if (name) { + return name; + } + + return currentUser?.email || 'Independent Artist'; +} + +function buildTrackBlueprint(genreName, vocalMode) { + const name = `${genreName || ''}`.toLowerCase(); + + let blueprint; + + if (name.includes('amapiano') || name.includes('lekompo') || name.includes('bolobedu')) { + blueprint = [ + { name: 'Groove Drums', track_type: 'drums', instrument: 'traditional_drums', volume_db: -4 }, + { name: 'Log Drum Bass', track_type: 'bass', instrument: 'log_drum', volume_db: -3 }, + { name: 'Soul Chords', track_type: 'instrument', instrument: 'piano', volume_db: -5 }, + { name: 'Texture Synth', track_type: 'instrument', instrument: 'pads', volume_db: -8 }, + ]; + } else if (name.includes('gospel') || name.includes('jazz')) { + blueprint = [ + { name: 'Drum Pocket', track_type: 'drums', instrument: 'perc', volume_db: -5 }, + { name: 'Piano Lead', track_type: 'instrument', instrument: 'piano', volume_db: -4 }, + { name: 'Warm Bass', track_type: 'bass', instrument: 'other', volume_db: -6 }, + { name: 'Choir Pad', track_type: 'instrument', instrument: 'strings', volume_db: -9 }, + ]; + } else if (name.includes('maskandi')) { + blueprint = [ + { name: 'Maskandi Guitar', track_type: 'instrument', instrument: 'guitar', volume_db: -4 }, + { name: 'Rhythm Drums', track_type: 'drums', instrument: 'traditional_drums', volume_db: -6 }, + { name: 'Supporting Bass', track_type: 'bass', instrument: 'other', volume_db: -6 }, + ]; + } else { + blueprint = [ + { name: 'Main Drums', track_type: 'drums', instrument: 'perc', volume_db: -5 }, + { name: 'Bass Foundation', track_type: 'bass', instrument: 'other', volume_db: -5 }, + { name: 'Lead Synth', track_type: 'instrument', instrument: 'synth', volume_db: -6 }, + ]; + } + + if (vocalMode !== 'instrumental') { + blueprint.unshift({ name: 'Lead Vocal', track_type: 'vocal', instrument: 'voice', volume_db: -3 }); + } + + blueprint.push({ name: 'Master Bus', track_type: 'master', instrument: 'other', volume_db: -1 }); + + return blueprint; +} + +function buildDescription({ genreName, languageName, promptText, vocalMode, targetLabel }) { + return [ + `${genreName} studio workflow`, + languageName ? `for ${languageName} vocals` : null, + vocalMode !== 'instrumental' ? `with ${vocalMode === 'upload' ? 'uploaded' : 'planned live'} vocal capture` : 'as an instrumental-first draft', + targetLabel ? `aimed at ${targetLabel.toLowerCase().replace(/_/g, ' ')}` : null, + promptText ? `Seed prompt: ${promptText}` : null, + ] + .filter(Boolean) + .join('. '); +} + +function generateIsrc() { + const year = new Date().getFullYear().toString().slice(-2); + const random = Math.random().toString().slice(2, 7); + return `ZA-AIM-${year}-${random}`; +} + +function mapSessionSummary(project, song, generationRequest, mixSession, masteringSession, exportJob, recordingSession, songMetadata, coverArtwork) { + return { + project: { + id: project.id, + name: project.name, + status: project.status, + href: `/projects/${project.id}`, + }, + song: { + id: song.id, + title: song.title, + bpm: song.bpm, + key_signature: song.key_signature, + mood: song.mood, + href: `/songs/${song.id}`, + }, + generationRequest: { + id: generationRequest.id, + status: generationRequest.status, + request_type: generationRequest.request_type, + href: `/generation_requests/${generationRequest.id}`, + }, + recordingSession: recordingSession + ? { + id: recordingSession.id, + status: recordingSession.status, + href: `/recording_sessions/${recordingSession.id}`, + } + : null, + mixSession: { + id: mixSession.id, + status: mixSession.status, + href: `/mix_sessions/${mixSession.id}`, + }, + masteringSession: { + id: masteringSession.id, + status: masteringSession.status, + href: `/mastering_sessions/${masteringSession.id}`, + }, + exportJob: { + id: exportJob.id, + status: exportJob.status, + format: exportJob.format, + href: `/exports/${exportJob.id}`, + }, + songMetadata: { + id: songMetadata.id, + distribution_status: songMetadata.distribution_status, + isrc: songMetadata.isrc, + href: `/song_metadata/${songMetadata.id}`, + }, + coverArtwork: { + id: coverArtwork.id, + status: coverArtwork.status, + href: `/cover_artworks/${coverArtwork.id}`, + }, + }; +} + +module.exports = class StudioService { + static async getLaunchpad(currentUser) { + const organizationId = getOrganizationId(currentUser); + const scopedWhere = organizationId ? { organizationsId: organizationId } : {}; + + const [genres, languages, masteringPresets, aiModels, recentProjects] = await Promise.all([ + db.genres.findAll({ + attributes: ['id', 'name', 'description', 'typical_bpm_min', 'typical_bpm_max'], + where: { + ...scopedWhere, + origin: 'south_africa', + }, + order: [['name', 'ASC']], + limit: 16, + }), + db.languages.findAll({ + attributes: ['id', 'name', 'iso_code', 'is_supported'], + where: { + ...scopedWhere, + is_supported: true, + }, + order: [['name', 'ASC']], + limit: 12, + }), + db.mastering_presets.findAll({ + attributes: ['id', 'name', 'target', 'lufs_target', 'true_peak_db'], + where: scopedWhere, + order: [['is_default', 'DESC'], ['name', 'ASC']], + limit: 8, + }), + db.ai_models.findAll({ + attributes: ['id', 'name', 'model_type', 'provider', 'version'], + where: { + ...scopedWhere, + is_active: true, + model_type: { + [Op.in]: ['music_generation', 'mixing', 'mastering', 'metadata'], + }, + }, + order: [['model_type', 'ASC'], ['name', 'ASC']], + limit: 8, + }), + db.projects.findAll({ + attributes: ['id', 'name', 'status', 'target_bpm', 'target_key', 'mood', 'updatedAt'], + where: { + ...scopedWhere, + createdById: currentUser.id, + }, + include: [ + { + model: db.genres, + as: 'primary_genre', + attributes: ['id', 'name'], + }, + ], + order: [['updatedAt', 'DESC']], + limit: 6, + }), + ]); + + const recentSessions = await Promise.all( + recentProjects.map(async (project) => { + const song = await db.songs.findOne({ + attributes: ['id', 'title', 'bpm', 'key_signature', 'mood', 'createdAt'], + where: { + ...scopedWhere, + projectId: project.id, + }, + order: [['createdAt', 'DESC']], + }); + + const [generationRequest, mixSession, masteringSession, exportJob] = song + ? await Promise.all([ + db.generation_requests.findOne({ + attributes: ['id', 'status', 'request_type', 'createdAt'], + where: { + ...scopedWhere, + songId: song.id, + }, + order: [['createdAt', 'DESC']], + }), + db.mix_sessions.findOne({ + attributes: ['id', 'status', 'mix_type', 'createdAt'], + where: { + ...scopedWhere, + songId: song.id, + }, + order: [['createdAt', 'DESC']], + }), + db.mastering_sessions.findOne({ + attributes: ['id', 'status', 'createdAt'], + where: { + ...scopedWhere, + songId: song.id, + }, + order: [['createdAt', 'DESC']], + }), + db.exports.findOne({ + attributes: ['id', 'status', 'format', 'createdAt'], + where: { + ...scopedWhere, + songId: song.id, + }, + order: [['createdAt', 'DESC']], + }), + ]) + : [null, null, null, null]; + + return { + id: project.id, + name: project.name, + status: project.status, + mood: project.mood, + target_bpm: project.target_bpm, + target_key: project.target_key, + updatedAt: project.updatedAt, + genre: project.primary_genre ? { id: project.primary_genre.id, name: project.primary_genre.name } : null, + projectHref: `/projects/${project.id}`, + song: song + ? { + id: song.id, + title: song.title, + href: `/songs/${song.id}`, + } + : null, + generationRequest: generationRequest + ? { + id: generationRequest.id, + status: generationRequest.status, + request_type: generationRequest.request_type, + href: `/generation_requests/${generationRequest.id}`, + } + : null, + mixSession: mixSession + ? { + id: mixSession.id, + status: mixSession.status, + href: `/mix_sessions/${mixSession.id}`, + } + : null, + masteringSession: masteringSession + ? { + id: masteringSession.id, + status: masteringSession.status, + href: `/mastering_sessions/${masteringSession.id}`, + } + : null, + exportJob: exportJob + ? { + id: exportJob.id, + status: exportJob.status, + format: exportJob.format, + href: `/exports/${exportJob.id}`, + } + : null, + }; + }), + ); + + return { + genres: genres.map((genre) => genre.toJSON()), + languages: languages.map((language) => language.toJSON()), + masteringPresets: masteringPresets.map((preset) => preset.toJSON()), + aiModels: aiModels.map((model) => model.toJSON()), + recentSessions, + }; + } + + static async createLaunchpadSession(data, currentUser) { + const title = `${data.title || ''}`.trim(); + const promptText = `${data.promptText || ''}`.trim(); + const organizationId = getOrganizationId(currentUser); + + if (!title || title.length < 3) { + throw badRequest('Please enter a project title with at least 3 characters.'); + } + + if (!data.genreId) { + throw badRequest('Please choose a South African genre to shape the session.'); + } + + if (!data.languageId) { + throw badRequest('Please choose the main vocal language for this session.'); + } + + if (!promptText || promptText.length < 12) { + throw badRequest('Please provide a more descriptive beat prompt so the session has direction.'); + } + + if (!moods.includes(data.mood)) { + throw badRequest('Please choose a valid mood.'); + } + + if (!vocalModes.includes(data.vocalMode)) { + throw badRequest('Please choose a valid vocal workflow.'); + } + + if (!exportFormats.includes(data.exportFormat)) { + throw badRequest('Please choose a valid export format.'); + } + + const targetBpm = data.targetBpm ? Number(data.targetBpm) : null; + if (targetBpm && (Number.isNaN(targetBpm) || targetBpm < 60 || targetBpm > 180)) { + throw badRequest('Target BPM must be between 60 and 180.'); + } + + const scopedWhere = organizationId ? { organizationsId: organizationId } : {}; + const [genre, language, selectedPreset, musicModel] = await Promise.all([ + db.genres.findOne({ where: { id: data.genreId, ...scopedWhere } }), + db.languages.findOne({ where: { id: data.languageId, ...scopedWhere } }), + data.masteringPresetId + ? db.mastering_presets.findOne({ where: { id: data.masteringPresetId, ...scopedWhere } }) + : db.mastering_presets.findOne({ + where: scopedWhere, + order: [['is_default', 'DESC'], ['name', 'ASC']], + }), + db.ai_models.findOne({ + where: { + ...scopedWhere, + is_active: true, + model_type: 'music_generation', + }, + order: [['name', 'ASC']], + }), + ]); + + if (!genre) { + throw badRequest('The selected genre could not be found.'); + } + + if (!language) { + throw badRequest('The selected language could not be found.'); + } + + const projectStatus = data.vocalMode === 'instrumental' ? 'in_progress' : 'ready_for_mix'; + const now = new Date(); + const transaction = await db.sequelize.transaction(); + + try { + const project = await ProjectsDBApi.create( + { + name: title, + description: buildDescription({ + genreName: genre.name, + languageName: language.name, + promptText, + vocalMode: data.vocalMode, + targetLabel: selectedPreset?.target, + }), + status: projectStatus, + target_bpm: targetBpm || genre.typical_bpm_min || null, + target_key: data.targetKey || null, + mood: data.mood, + target_duration_seconds: 240, + collaboration_enabled: false, + last_opened_at: now, + created_user: currentUser.id, + primary_genre: genre.id, + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + const song = await SongsDBApi.create( + { + project: project.id, + title, + artist_display: getArtistDisplay(currentUser), + genre: genre.id, + bpm: targetBpm || genre.typical_bpm_min || null, + key_signature: data.targetKey || null, + mood: data.mood, + duration_seconds: 240, + vocal_language: language.id, + explicit_content: false, + lyrics: data.notes ? `${promptText}\n\nCreative notes:\n${data.notes}` : promptText, + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + const generationRequest = await GenerationRequestsDBApi.create( + { + project: project.id, + song: song.id, + requested_user: currentUser.id, + model: musicModel?.id || null, + request_type: data.vocalMode === 'upload' ? 'generate_beat_from_vocals' : 'generate_beat_from_text', + prompt_text: promptText, + target_genre: genre.id, + target_bpm: song.bpm, + target_key: song.key_signature, + mood: data.mood, + status: 'queued', + queued_at: now, + estimated_cost: data.vocalMode === 'upload' ? 2.8 : 1.6, + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + const recordingSession = + data.vocalMode === 'instrumental' + ? null + : await RecordingSessionsDBApi.create( + { + project: project.id, + song: song.id, + started_user: currentUser.id, + status: 'planned', + input_channels: 1, + sample_rate_hz: 48000, + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + const tracks = await Promise.all( + buildTrackBlueprint(genre.name, data.vocalMode).map((track, index) => + TracksDBApi.create( + { + song: song.id, + name: track.name, + track_type: track.track_type, + instrument: track.instrument, + volume_db: track.volume_db, + pan: index % 2 === 0 ? -5 : 5, + organizations: organizationId, + }, + { currentUser, transaction }, + ), + ), + ); + + const arrangementSections = await Promise.all( + arrangementBlueprint.map((section) => + ArrangementSectionsDBApi.create( + { + song: song.id, + section_type: section.section_type, + label: section.label, + start_bar: section.start_bar, + bar_length: section.bar_length, + order_index: section.order_index, + organizations: organizationId, + }, + { currentUser, transaction }, + ), + ), + ); + + const mixSession = await MixSessionsDBApi.create( + { + song: song.id, + requested_user: currentUser.id, + mix_type: 'auto_mix', + status: 'queued', + stereo_widening_amount: genre.name.toLowerCase().includes('gospel') ? 0.4 : 0.6, + compression_amount: 0.45, + eq_amount: 0.5, + started_at: null, + finished_at: null, + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + const masteringSession = await MasteringSessionsDBApi.create( + { + song: song.id, + requested_user: currentUser.id, + preset: selectedPreset?.id || null, + status: 'queued', + started_at: null, + finished_at: null, + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + const exportJob = await ExportsDBApi.create( + { + song: song.id, + requested_user: currentUser.id, + format: data.exportFormat, + quality: data.exportFormat === 'wav' ? 'studio' : 'standard', + include_stems: Boolean(data.includeStems) || data.exportFormat === 'stems_zip', + status: 'queued', + requested_at: now, + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + const songMetadata = await SongMetadataDBApi.create( + { + song: song.id, + isrc: generateIsrc(), + release_title: title, + release_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), + label_name: 'Independent Release', + publisher: getArtistDisplay(currentUser), + copyright_notice: `© ${new Date().getFullYear()} ${getArtistDisplay(currentUser)}`, + distribution_status: 'ready', + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + const coverArtwork = await CoverArtworksDBApi.create( + { + song: song.id, + prompt: `${genre.name} cover artwork, South African nightlife palette, ${data.mood} energy, premium music release design`, + status: 'generated', + organizations: organizationId, + }, + { currentUser, transaction }, + ); + + await transaction.commit(); + + return { + message: `${title} is now staged for beat generation, vocal prep, mix, mastering, and export.`, + session: { + ...mapSessionSummary( + project, + song, + generationRequest, + mixSession, + masteringSession, + exportJob, + recordingSession, + songMetadata, + coverArtwork, + ), + arrangementSections: arrangementSections.map((section) => ({ + id: section.id, + label: section.label, + section_type: section.section_type, + start_bar: section.start_bar, + bar_length: section.bar_length, + })), + tracks: tracks.map((track) => ({ + id: track.id, + name: track.name, + track_type: track.track_type, + instrument: track.instrument, + })), + }, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index f6320a4..64c65e8 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 5417bf1..3fd2507 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -8,6 +8,13 @@ const menuAside: MenuAsideItem[] = [ label: 'Dashboard', }, + { + href: '/studio', + label: 'Studio Launchpad', + icon: 'mdiMusicBoxMultiple' in icon ? icon['mdiMusicBoxMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_PROJECTS' + }, + { href: '/users/users-list', label: 'Users', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 32dd948..1281e78 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,179 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { mdiAlbum, mdiChevronRight, mdiLogin, mdiMusic, mdiRobot, mdiShieldAccount } from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; +import React, { ReactElement } from 'react'; +import BaseIcon from '../components/BaseIcon'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const genreHighlights = [ + 'Amapiano', + 'Gqom', + 'Maskandi', + 'Kwaito', + 'Afro House', + 'SA Gospel', + 'Motswako', + 'Lekompo', + 'Cape Jazz', + 'Afro Soul', +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('background'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'SA AI Music Studio' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +const workflowPillars = [ + { + title: 'Create the beat', + body: 'Start from a text prompt and shape BPM, key, vibe, and South African genre DNA in seconds.', + icon: mdiRobot, + }, + { + title: 'Bring in vocals', + body: 'Plan for uploads or recording sessions, with language-aware vocal workflows for multilingual artists.', + icon: mdiMusic, + }, + { + title: 'Ship release-ready audio', + body: 'Queue mix, mastering, metadata, artwork, and export for streaming, radio, or club delivery.', + icon: mdiAlbum, + }, +]; +function HomePage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('SA AI Music Studio')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+
SA AI Music Studio
+
Browser-based studio workflow
- - - - - + +
+ +
+
+
+ South African genres · Vocal workflows · Mix & master +
+

+ Build modern South African records with an AI-powered studio workflow. +

+

+ Generate genre-aware beats, stage multilingual vocal sessions, queue mixing and mastering, and organize exports for release — all from one dark, studio-first workspace. +

+ +
+ + Open Studio Launchpad + + + + Sign in to continue + +
+ +
+ {genreHighlights.map((genre) => ( + + {genre} + + ))} +
+
+ +
+
+
+
+
+
Launch-ready slice
+
Generate → Vocal prep → Master
+
+
+ live MVP +
+
+ +
+ {workflowPillars.map((pillar) => ( +
+
+
+ +
+
+
{pillar.title}
+

{pillar.body}

+
+
+
+ ))} +
+
+
+
+ +
+
+
Genre engine
+

SA-first sound design

+

+ Start sessions for Amapiano, Kwaito, Gqom, Maskandi, Gospel, Cape Jazz, Lekompo, and more with BPM-aware workflow presets. +

+
+
+
Vocal path
+

Artists move faster

+

+ Stage vocal uploads or recording-ready sessions, keep language context attached, and prepare the mix chain without leaving the browser. +

+
+
+
Release prep
+

From idea to export

+

+ Queue mastering, metadata, artwork, and exports as part of the same workflow so the first version already feels like a product. +

+
+
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
+ ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; }; +export default HomePage; diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index 00f5168..005eb07 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -1,9 +1,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import Head from 'next/head'; import 'react-datepicker/dist/react-datepicker.css'; -import { useAppDispatch } from '../stores/hooks'; - -import { useAppSelector } from '../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; import LayoutAuthenticated from '../layouts/Authenticated'; diff --git a/frontend/src/pages/studio.tsx b/frontend/src/pages/studio.tsx new file mode 100644 index 0000000..ac7b8b1 --- /dev/null +++ b/frontend/src/pages/studio.tsx @@ -0,0 +1,947 @@ +import { + mdiAlbum, + mdiChartTimelineVariant, + mdiCheckCircleOutline, + mdiChevronRight, + mdiClockOutline, + mdiExportVariant, + mdiMicrophone, + mdiMusic, + mdiOpenInNew, + mdiRobot, + mdiTuneVariant, + mdiWaveform, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type GenreOption = { + id: string; + name: string; + description?: string; + typical_bpm_min?: number; + typical_bpm_max?: number; +}; + +type LanguageOption = { + id: string; + name: string; + iso_code?: string; +}; + +type MasteringPresetOption = { + id: string; + name: string; + target: string; + lufs_target?: number; + true_peak_db?: number; +}; + +type AiModelOption = { + id: string; + name: string; + model_type: string; + provider?: string; + version?: string; +}; + +type RecentSession = { + id: string; + name: string; + status: string; + mood?: string; + target_bpm?: number; + target_key?: string; + updatedAt: string; + genre?: { + id: string; + name: string; + } | null; + projectHref: string; + song?: { + id: string; + title: string; + href: string; + } | null; + generationRequest?: { + id: string; + status: string; + request_type: string; + href: string; + } | null; + mixSession?: { + id: string; + status: string; + href: string; + } | null; + masteringSession?: { + id: string; + status: string; + href: string; + } | null; + exportJob?: { + id: string; + status: string; + format: string; + href: string; + } | null; +}; + +type CreatedLink = { + id: string; + href: string; + status?: string; + format?: string; + title?: string; + bpm?: number; + key_signature?: string; + mood?: string; + request_type?: string; + distribution_status?: string; + isrc?: string; +}; + +type CreatedSession = { + project: CreatedLink & { name?: string }; + song: CreatedLink; + generationRequest: CreatedLink; + recordingSession?: CreatedLink | null; + mixSession: CreatedLink; + masteringSession: CreatedLink; + exportJob: CreatedLink; + songMetadata: CreatedLink; + coverArtwork: CreatedLink; + arrangementSections: Array<{ + id: string; + label: string; + section_type: string; + start_bar: number; + bar_length: number; + }>; + tracks: Array<{ + id: string; + name: string; + track_type: string; + instrument: string; + }>; +}; + +type LaunchpadResponse = { + genres: GenreOption[]; + languages: LanguageOption[]; + masteringPresets: MasteringPresetOption[]; + aiModels: AiModelOption[]; + recentSessions: RecentSession[]; +}; + +type FormState = { + title: string; + genreId: string; + languageId: string; + mood: string; + targetBpm: string; + targetKey: string; + vocalMode: 'instrumental' | 'upload' | 'record'; + promptText: string; + notes: string; + masteringPresetId: string; + exportFormat: 'wav' | 'mp3' | 'stems_zip'; + includeStems: boolean; +}; + +const initialForm: FormState = { + title: '', + genreId: '', + languageId: '', + mood: 'energetic', + targetBpm: '', + targetKey: '', + vocalMode: 'upload', + promptText: '', + notes: '', + masteringPresetId: '', + exportFormat: 'wav', + includeStems: true, +}; + +const moodOptions = ['happy', 'sad', 'spiritual', 'energetic', 'romantic', 'chill', 'aggressive']; +const vocalModes: Array<{ value: FormState['vocalMode']; title: string; description: string }> = [ + { + value: 'upload', + title: 'Upload vocal', + description: 'Prepare a beat around an imported vocal take and queue vocal treatment.', + }, + { + value: 'record', + title: 'Record later', + description: 'Start with an instrumental draft and a planned recording session.', + }, + { + value: 'instrumental', + title: 'Instrumental only', + description: 'Build a beat-first session without a vocal chain yet.', + }, +]; + +const stageCards = [ + { + label: 'Beat generation', + detail: 'Queue a South African genre-aware instrumental brief.', + icon: mdiRobot, + }, + { + label: 'Vocal workflow', + detail: 'Prepare upload or recording steps with language-aware context.', + icon: mdiMicrophone, + }, + { + label: 'Auto mix & master', + detail: 'Stage mix, mastering, metadata, artwork, and export in one go.', + icon: mdiTuneVariant, + }, +]; + +function getStatusClasses(status?: string) { + switch (status) { + case 'completed': + case 'succeeded': + case 'generated': + case 'ready': + return 'border-emerald-400/40 bg-emerald-500/10 text-emerald-200'; + case 'running': + case 'recording': + case 'in_progress': + case 'ready_for_mix': + case 'ready_for_master': + return 'border-sky-400/40 bg-sky-500/10 text-sky-200'; + case 'failed': + return 'border-rose-400/40 bg-rose-500/10 text-rose-200'; + default: + return 'border-violet-400/40 bg-violet-500/10 text-violet-200'; + } +} + +function prettyStatus(value?: string) { + return (value || 'queued').replace(/_/g, ' '); +} + +function formatRelativeDate(value: string) { + const date = new Date(value); + const diffMs = Date.now() - date.getTime(); + const diffHours = Math.max(1, Math.round(diffMs / (1000 * 60 * 60))); + + if (diffHours < 24) { + return `${diffHours}h ago`; + } + + const diffDays = Math.round(diffHours / 24); + return `${diffDays}d ago`; +} + +const StudioPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const canCreateProjects = hasPermission(currentUser, 'CREATE_PROJECTS'); + + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [form, setForm] = useState(initialForm); + const [genres, setGenres] = useState([]); + const [languages, setLanguages] = useState([]); + const [masteringPresets, setMasteringPresets] = useState([]); + const [aiModels, setAiModels] = useState([]); + const [recentSessions, setRecentSessions] = useState([]); + const [createdSession, setCreatedSession] = useState(null); + + const selectedGenre = useMemo( + () => genres.find((genre) => genre.id === form.genreId) || null, + [form.genreId, genres], + ); + + const selectedPreset = useMemo( + () => masteringPresets.find((preset) => preset.id === form.masteringPresetId) || null, + [form.masteringPresetId, masteringPresets], + ); + + const loadLaunchpad = async () => { + const { data } = await axios.get('/studio/launchpad'); + + setGenres(data.genres || []); + setLanguages(data.languages || []); + setMasteringPresets(data.masteringPresets || []); + setAiModels(data.aiModels || []); + setRecentSessions(data.recentSessions || []); + setForm((current) => ({ + ...current, + genreId: current.genreId || data.genres?.[0]?.id || '', + languageId: current.languageId || data.languages?.[0]?.id || '', + masteringPresetId: current.masteringPresetId || data.masteringPresets?.[0]?.id || '', + targetBpm: + current.targetBpm || + (data.genres?.[0]?.typical_bpm_min ? String(data.genres[0].typical_bpm_min) : ''), + })); + }; + + useEffect(() => { + const run = async () => { + try { + setIsLoading(true); + setErrorMessage(''); + await loadLaunchpad(); + } catch (error) { + console.error('Failed to load studio launchpad:', error); + setErrorMessage('We could not load the studio launchpad right now. Please refresh and try again.'); + } finally { + setIsLoading(false); + } + }; + + run(); + }, []); + + useEffect(() => { + if (!selectedGenre) { + return; + } + + setForm((current) => { + if (current.targetBpm) { + return current; + } + + return { + ...current, + targetBpm: selectedGenre.typical_bpm_min ? String(selectedGenre.typical_bpm_min) : '', + }; + }); + }, [selectedGenre]); + + const handleChange = + (field: keyof FormState) => + (event: React.ChangeEvent) => { + const target = event.target; + const value = target instanceof HTMLInputElement && target.type === 'checkbox' ? target.checked : target.value; + + setForm((current) => ({ + ...current, + [field]: value, + } as FormState)); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + try { + setIsSubmitting(true); + setErrorMessage(''); + setSuccessMessage(''); + + const { data } = await axios.post<{ message: string; session: CreatedSession }>('/studio/launchpad', form); + setCreatedSession(data.session); + setSuccessMessage(data.message); + setForm((current) => ({ + ...initialForm, + genreId: current.genreId, + languageId: current.languageId, + masteringPresetId: current.masteringPresetId, + targetBpm: current.targetBpm, + })); + await loadLaunchpad(); + } catch (error) { + console.error('Failed to create studio session:', error); + setErrorMessage( + axios.isAxiosError(error) + ? error.response?.data || 'We could not create the session. Please review the form and try again.' + : 'We could not create the session. Please review the form and try again.', + ); + } finally { + setIsSubmitting(false); + } + }; + + const quickStats = [ + { + label: 'SA genres ready', + value: genres.length || '—', + icon: mdiMusic, + }, + { + label: 'Languages supported', + value: languages.length || '—', + icon: mdiMicrophone, + }, + { + label: 'AI engines online', + value: aiModels.length || '—', + icon: mdiRobot, + }, + { + label: 'Recent sessions', + value: recentSessions.length || '0', + icon: mdiAlbum, + }, + ]; + + return ( + <> + + {getPageTitle('Studio Launchpad')} + + + + {''} + + +
+ +
+
+
+ South African AI music workflow +
+

+ Turn a beat idea into a staged studio session in one launch. +

+

+ Pick your genre, vocal workflow, language, mastering target, and export format. The launchpad instantly creates a linked project, song, AI generation request, arrangement, mix, mastering, metadata, artwork, and export job. +

+
+ + View all projects + + + + AI queue + + +
+
+ +
+ {quickStats.map((item) => ( +
+
+ {item.label} + +
+
{item.value}
+
+ ))} +
+
+
+ + +
+
+

Active AI chain

+

What gets staged automatically

+
+ +
+
+ {stageCards.map((card) => ( +
+
+
+ +
+
+
{card.label}
+

{card.detail}

+
+
+
+ ))} +
+
+
+ + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {successMessage ? ( + + {successMessage} + + ) : null} + + {!canCreateProjects ? ( + + Your role can review studio sessions, but it cannot launch new ones yet. Ask an admin for project creation access. + + ) : null} + +
+ +
+
+

First delivery workflow

+

Create a launch-ready studio session

+
+
+ Project → Song → Mix → Master → Export +
+
+ +
+
+ + + + + + + +
+ +
+ + + + + + + + + + + +
+ + +