This commit is contained in:
Flatlogic Bot 2026-04-12 17:45:29 +00:00
parent fe111dfa46
commit bd12d12528
10 changed files with 1770 additions and 156 deletions

View File

@ -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 }),

View File

@ -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;

View File

@ -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;
}
}
};

View File

@ -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';

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
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 (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('SA AI Music Studio')}</title>
<meta
name="description"
content="A South African-first AI music production studio for beat generation, vocal workflows, mixing, mastering, and export."
/>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your SA AI Music Studio app!"/>
<div className="space-y-3">
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center '>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className="min-h-screen bg-[#07070C] text-white">
<div className="mx-auto max-w-7xl px-6 pb-16 pt-6 sm:px-8 lg:px-10">
<header className="flex flex-wrap items-center justify-between gap-4 rounded-full border border-white/10 bg-white/5 px-5 py-4 backdrop-blur">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-violet-300">SA AI Music Studio</div>
<div className="mt-1 text-lg font-semibold text-white">Browser-based studio workflow</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
<nav className="flex flex-wrap items-center gap-3">
<Link
href="/login"
className="inline-flex items-center rounded-full border border-white/15 px-4 py-2 text-sm font-medium text-white transition hover:border-violet-300/40 hover:bg-white/5"
>
<BaseIcon path={mdiLogin} size={16} className="mr-2" />
Login
</Link>
<Link
href="/dashboard"
className="inline-flex items-center rounded-full bg-violet-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-violet-400"
>
<BaseIcon path={mdiShieldAccount} size={16} className="mr-2" />
Admin interface
</Link>
</nav>
</header>
<main className="mt-10 grid gap-8 xl:grid-cols-[1.4fr_0.9fr] xl:items-center">
<section>
<div className="inline-flex items-center rounded-full border border-violet-400/20 bg-violet-500/10 px-4 py-2 text-xs uppercase tracking-[0.24em] text-violet-200">
South African genres · Vocal workflows · Mix & master
</div>
<h1 className="mt-6 max-w-4xl text-5xl font-semibold leading-tight sm:text-6xl">
Build modern South African records with an AI-powered studio workflow.
</h1>
<p className="mt-6 max-w-3xl text-lg leading-8 text-slate-300">
Generate genre-aware beats, stage multilingual vocal sessions, queue mixing and mastering, and organize exports for release all from one dark, studio-first workspace.
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Link
href="/studio"
className="inline-flex items-center rounded-full bg-white px-6 py-3 text-sm font-semibold text-slate-950 transition hover:bg-violet-100"
>
Open Studio Launchpad
<BaseIcon path={mdiChevronRight} size={18} className="ml-2" />
</Link>
<Link
href="/login"
className="inline-flex items-center rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:border-violet-300/40 hover:bg-white/5"
>
Sign in to continue
</Link>
</div>
<div className="mt-10 flex flex-wrap gap-3">
{genreHighlights.map((genre) => (
<span key={genre} className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200">
{genre}
</span>
))}
</div>
</section>
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-gradient-to-br from-violet-600/20 via-slate-900 to-slate-950 p-6 shadow-2xl shadow-violet-950/20">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(168,85,247,0.22),_transparent_45%),radial-gradient(circle_at_bottom_left,_rgba(34,211,238,0.16),_transparent_40%)]" />
<div className="relative">
<div className="flex items-center justify-between rounded-3xl border border-white/10 bg-white/5 px-4 py-3">
<div>
<div className="text-xs uppercase tracking-[0.22em] text-violet-200">Launch-ready slice</div>
<div className="mt-1 text-lg font-semibold">Generate Vocal prep Master</div>
</div>
<div className="rounded-full border border-emerald-400/30 bg-emerald-500/10 px-3 py-1 text-xs uppercase tracking-[0.16em] text-emerald-200">
live MVP
</div>
</div>
<div className="mt-6 space-y-4">
{workflowPillars.map((pillar) => (
<div key={pillar.title} className="rounded-3xl border border-white/10 bg-black/30 p-5 backdrop-blur">
<div className="flex items-start gap-4">
<div className="rounded-2xl border border-white/10 bg-white/10 p-3 text-violet-200">
<BaseIcon path={pillar.icon} size={22} />
</div>
<div>
<div className="text-lg font-medium text-white">{pillar.title}</div>
<p className="mt-2 text-sm leading-7 text-slate-300">{pillar.body}</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
</main>
<section className="mt-14 grid gap-6 lg:grid-cols-3">
<div className="rounded-[1.75rem] border border-white/10 bg-white/5 p-6">
<div className="text-xs uppercase tracking-[0.22em] text-violet-300">Genre engine</div>
<h2 className="mt-3 text-2xl font-semibold">SA-first sound design</h2>
<p className="mt-4 text-sm leading-7 text-slate-300">
Start sessions for Amapiano, Kwaito, Gqom, Maskandi, Gospel, Cape Jazz, Lekompo, and more with BPM-aware workflow presets.
</p>
</div>
<div className="rounded-[1.75rem] border border-white/10 bg-white/5 p-6">
<div className="text-xs uppercase tracking-[0.22em] text-violet-300">Vocal path</div>
<h2 className="mt-3 text-2xl font-semibold">Artists move faster</h2>
<p className="mt-4 text-sm leading-7 text-slate-300">
Stage vocal uploads or recording-ready sessions, keep language context attached, and prepare the mix chain without leaving the browser.
</p>
</div>
<div className="rounded-[1.75rem] border border-white/10 bg-white/5 p-6">
<div className="text-xs uppercase tracking-[0.22em] text-violet-300">Release prep</div>
<h2 className="mt-3 text-2xl font-semibold">From idea to export</h2>
<p className="mt-4 text-sm leading-7 text-slate-300">
Queue mastering, metadata, artwork, and exports as part of the same workflow so the first version already feels like a product.
</p>
</div>
</section>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
export default HomePage;

View File

@ -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';

View File

@ -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<FormState>(initialForm);
const [genres, setGenres] = useState<GenreOption[]>([]);
const [languages, setLanguages] = useState<LanguageOption[]>([]);
const [masteringPresets, setMasteringPresets] = useState<MasteringPresetOption[]>([]);
const [aiModels, setAiModels] = useState<AiModelOption[]>([]);
const [recentSessions, setRecentSessions] = useState<RecentSession[]>([]);
const [createdSession, setCreatedSession] = useState<CreatedSession | null>(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<LaunchpadResponse>('/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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
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<HTMLFormElement>) => {
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 (
<>
<Head>
<title>{getPageTitle('Studio Launchpad')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Studio Launchpad" main>
{''}
</SectionTitleLineWithButton>
<div className="mb-6 grid gap-6 xl:grid-cols-[1.8fr_1fr]">
<CardBox className="overflow-hidden border border-violet-500/20 bg-gradient-to-br from-slate-950 via-slate-900 to-violet-950 text-white shadow-2xl shadow-violet-950/30">
<div className="grid gap-6 lg:grid-cols-[1.5fr_1fr]">
<div>
<div className="mb-4 inline-flex items-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.24em] text-violet-200">
South African AI music workflow
</div>
<h1 className="max-w-2xl text-3xl font-semibold leading-tight md:text-4xl">
Turn a beat idea into a staged studio session in one launch.
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 md:text-base">
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.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/projects/projects-list"
className="inline-flex items-center rounded-full border border-white/10 bg-white/10 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/15"
>
View all projects
<BaseIcon path={mdiChevronRight} size={16} className="ml-1" />
</Link>
<Link
href="/generation_requests/generation_requests-list"
className="inline-flex items-center rounded-full border border-white/10 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-violet-300/40 hover:text-white"
>
AI queue
<BaseIcon path={mdiChevronRight} size={16} className="ml-1" />
</Link>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
{quickStats.map((item) => (
<div key={item.label} className="rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur">
<div className="flex items-center justify-between">
<span className="text-xs uppercase tracking-[0.18em] text-slate-400">{item.label}</span>
<BaseIcon path={item.icon} size={22} className="text-violet-200" />
</div>
<div className="mt-3 text-3xl font-semibold text-white">{item.value}</div>
</div>
))}
</div>
</div>
</CardBox>
<CardBox className="border border-slate-800 bg-slate-950 text-white">
<div className="mb-5 flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">Active AI chain</p>
<h2 className="mt-2 text-xl font-semibold">What gets staged automatically</h2>
</div>
<BaseIcon path={mdiWaveform} size={26} className="text-violet-300" />
</div>
<div className="space-y-4">
{stageCards.map((card) => (
<div key={card.label} className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="flex items-start gap-3">
<div className="rounded-xl border border-white/10 bg-white/10 p-2 text-violet-200">
<BaseIcon path={card.icon} size={20} />
</div>
<div>
<div className="font-medium text-white">{card.label}</div>
<p className="mt-1 text-sm leading-6 text-slate-300">{card.detail}</p>
</div>
</div>
</div>
))}
</div>
</CardBox>
</div>
{errorMessage ? (
<NotificationBar color="danger" icon={mdiClockOutline}>
{errorMessage}
</NotificationBar>
) : null}
{successMessage ? (
<NotificationBar color="success" icon={mdiCheckCircleOutline}>
{successMessage}
</NotificationBar>
) : null}
{!canCreateProjects ? (
<NotificationBar color="warning" icon={mdiClockOutline}>
Your role can review studio sessions, but it cannot launch new ones yet. Ask an admin for project creation access.
</NotificationBar>
) : null}
<div className="grid gap-6 2xl:grid-cols-[1.55fr_1fr]">
<CardBox className="border border-slate-800 bg-slate-950 text-white">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">First delivery workflow</p>
<h2 className="mt-2 text-2xl font-semibold">Create a launch-ready studio session</h2>
</div>
<div className="rounded-full border border-violet-500/30 bg-violet-500/10 px-4 py-2 text-xs uppercase tracking-[0.2em] text-violet-200">
Project Song Mix Master Export
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 md:grid-cols-2">
<FormField label="Project title" help="Use a release-ready working title for the session.">
<input
name="title"
placeholder="Midnight Log Drum Prayer"
value={form.title}
onChange={handleChange('title')}
disabled={!canCreateProjects || isSubmitting}
/>
</FormField>
<FormField label="Main vocal language" help="Used to stage vocal processing and metadata context.">
<select value={form.languageId} onChange={handleChange('languageId')} disabled={!canCreateProjects || isSubmitting}>
<option value="">Select language</option>
{languages.map((language) => (
<option key={language.id} value={language.id}>
{language.name}
{language.iso_code ? ` (${language.iso_code})` : ''}
</option>
))}
</select>
</FormField>
</div>
<div className="grid gap-6 md:grid-cols-3">
<FormField label="South African genre" help="Choose the dominant production style for the generated beat.">
<select value={form.genreId} onChange={handleChange('genreId')} disabled={!canCreateProjects || isSubmitting}>
<option value="">Select genre</option>
{genres.map((genre) => (
<option key={genre.id} value={genre.id}>
{genre.name}
</option>
))}
</select>
</FormField>
<FormField label="Mood" help="Controls arrangement, energy, and mastering direction.">
<select value={form.mood} onChange={handleChange('mood')} disabled={!canCreateProjects || isSubmitting}>
{moodOptions.map((mood) => (
<option key={mood} value={mood}>
{prettyStatus(mood)}
</option>
))}
</select>
</FormField>
<FormField label="Vocal workflow" help="Choose whether this starts from vocals, a planned recording, or an instrumental draft.">
<select value={form.vocalMode} onChange={handleChange('vocalMode')} disabled={!canCreateProjects || isSubmitting}>
{vocalModes.map((mode) => (
<option key={mode.value} value={mode.value}>
{mode.title}
</option>
))}
</select>
</FormField>
</div>
<FormField label="Beat prompt" help="Describe the groove, emotion, instrumentation, and arrangement vibe.">
<textarea
name="promptText"
placeholder="Soulful Amapiano with warm log drum movement, emotional piano chords, and a radio-ready vocal pocket for Xitsonga hooks."
value={form.promptText}
onChange={handleChange('promptText')}
disabled={!canCreateProjects || isSubmitting}
/>
</FormField>
<div className="grid gap-6 md:grid-cols-2">
<FormField label="Creative notes" help="Optional notes for mix balance, vocal texture, or arrangement details.">
<textarea
name="notes"
placeholder="Keep the intro DJ-friendly, push the bass in the chorus, and leave space for call-and-response adlibs."
value={form.notes}
onChange={handleChange('notes')}
disabled={!canCreateProjects || isSubmitting}
/>
</FormField>
<div className="rounded-3xl border border-white/10 bg-white/5 p-5">
<div className="grid gap-5 md:grid-cols-2">
<FormField label="Target BPM" help="You can refine the genre default.">
<input
name="targetBpm"
type="number"
min={60}
max={180}
value={form.targetBpm}
onChange={handleChange('targetBpm')}
disabled={!canCreateProjects || isSubmitting}
/>
</FormField>
<FormField label="Target key" help="Optional if you already know the vocal key center.">
<input
name="targetKey"
placeholder="F# minor"
value={form.targetKey}
onChange={handleChange('targetKey')}
disabled={!canCreateProjects || isSubmitting}
/>
</FormField>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField label="Mastering preset" help="This stages the master destination and loudness profile.">
<select
value={form.masteringPresetId}
onChange={handleChange('masteringPresetId')}
disabled={!canCreateProjects || isSubmitting}
>
<option value="">Select mastering preset</option>
{masteringPresets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</select>
</FormField>
<FormField label="Export target" help="Choose the initial delivery format to queue.">
<select value={form.exportFormat} onChange={handleChange('exportFormat')} disabled={!canCreateProjects || isSubmitting}>
<option value="wav">WAV · studio quality</option>
<option value="mp3">MP3 · quick share</option>
<option value="stems_zip">Stems ZIP · distribution prep</option>
</select>
</FormField>
</div>
<label className="mt-2 flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-900/60 px-4 py-3 text-sm text-slate-200">
<input
type="checkbox"
checked={form.includeStems}
onChange={handleChange('includeStems')}
disabled={!canCreateProjects || isSubmitting}
className="h-4 w-4 rounded border-slate-600 bg-slate-950 text-violet-400 focus:ring-violet-500"
/>
Include stems export in the first session package.
</label>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<BaseButton
type="submit"
color="info"
label={isSubmitting ? 'Launching session…' : 'Launch studio session'}
icon={mdiRobot}
disabled={!canCreateProjects || isSubmitting || isLoading}
className="!rounded-full !px-6"
/>
<BaseButton
color="white"
outline
label="Open songs"
href="/songs/songs-list"
icon={mdiAlbum}
className="!rounded-full !px-6 !text-slate-200"
/>
</div>
</form>
</CardBox>
<div className="space-y-6">
<CardBox className="border border-slate-800 bg-slate-950 text-white">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">Session preview</p>
<h3 className="mt-2 text-xl font-semibold">Current setup</h3>
</div>
<BaseIcon path={mdiExportVariant} size={24} className="text-violet-300" />
</div>
<div className="mt-5 space-y-4 text-sm text-slate-300">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Genre DNA</div>
<div className="mt-2 text-lg font-medium text-white">{selectedGenre?.name || 'Choose a genre'}</div>
<p className="mt-2 leading-6 text-slate-300">{selectedGenre?.description || 'South African groove details will appear here once you pick a genre.'}</p>
{selectedGenre?.typical_bpm_min || selectedGenre?.typical_bpm_max ? (
<div className="mt-3 text-xs uppercase tracking-[0.18em] text-violet-200">
Typical BPM {selectedGenre?.typical_bpm_min || '—'}{selectedGenre?.typical_bpm_max || '—'}
</div>
) : null}
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Mastering target</div>
<div className="mt-2 text-lg font-medium text-white">{selectedPreset?.name || 'Select a preset'}</div>
<p className="mt-2 leading-6 text-slate-300">
{selectedPreset
? `${prettyStatus(selectedPreset.target)} master${selectedPreset.lufs_target ? ` · LUFS ${selectedPreset.lufs_target}` : ''}${selectedPreset.true_peak_db ? ` · Peak ${selectedPreset.true_peak_db} dB` : ''}`
: 'Choose how the first export should be prepared for streaming, radio, or club playback.'}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Vocal route</div>
<div className="mt-2 text-lg font-medium text-white">
{vocalModes.find((mode) => mode.value === form.vocalMode)?.title || 'Upload vocal'}
</div>
<p className="mt-2 leading-6 text-slate-300">
{vocalModes.find((mode) => mode.value === form.vocalMode)?.description}
</p>
</div>
</div>
</CardBox>
<CardBox className="border border-slate-800 bg-slate-950 text-white">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">AI engines</p>
<h3 className="mt-2 text-xl font-semibold">Available models</h3>
</div>
<BaseIcon path={mdiRobot} size={24} className="text-violet-300" />
</div>
<div className="mt-5 space-y-3">
{aiModels.length ? (
aiModels.map((model) => (
<div key={model.id} className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-white">{model.name}</div>
<p className="text-sm text-slate-300">
{prettyStatus(model.model_type)} · {model.provider || 'Internal'}
{model.version ? ` · ${model.version}` : ''}
</p>
</div>
<BaseIcon path={mdiCheckCircleOutline} size={20} className="text-emerald-300" />
</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/5 p-4 text-sm text-slate-300">
No active AI models are visible for this workspace yet.
</div>
)}
</div>
</CardBox>
</div>
</div>
{createdSession ? (
<CardBox className="mt-6 border border-emerald-500/20 bg-gradient-to-br from-emerald-950/70 via-slate-950 to-slate-950 text-white">
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-emerald-300">Session created</p>
<h2 className="mt-2 text-2xl font-semibold">Everything is linked and ready for review</h2>
</div>
<span className="rounded-full border border-emerald-400/30 bg-emerald-500/10 px-4 py-2 text-xs uppercase tracking-[0.2em] text-emerald-200">
End-to-end MVP slice complete
</span>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{[
{ label: 'Project', item: createdSession.project, icon: mdiMusic },
{ label: 'Song', item: createdSession.song, icon: mdiAlbum },
{ label: 'Generation', item: createdSession.generationRequest, icon: mdiRobot },
{ label: 'Recording', item: createdSession.recordingSession, icon: mdiMicrophone },
{ label: 'Mix', item: createdSession.mixSession, icon: mdiWaveform },
{ label: 'Master', item: createdSession.masteringSession, icon: mdiTuneVariant },
{ label: 'Export', item: createdSession.exportJob, icon: mdiExportVariant },
{ label: 'Metadata', item: createdSession.songMetadata, icon: mdiCheckCircleOutline },
{ label: 'Artwork', item: createdSession.coverArtwork, icon: mdiOpenInNew },
].map((entry) =>
entry.item ? (
<Link
key={entry.label}
href={entry.item.href}
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:border-emerald-300/40 hover:bg-white/10"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-xl border border-white/10 bg-white/10 p-2 text-emerald-200">
<BaseIcon path={entry.icon} size={18} />
</div>
<div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">{entry.label}</div>
<div className="mt-1 text-base font-medium text-white">
{entry.item.title || entry.item.name || prettyStatus(entry.item.format || entry.item.status)}
</div>
</div>
</div>
<BaseIcon path={mdiChevronRight} size={18} className="text-slate-400" />
</div>
</Link>
) : null,
)}
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Arrangement builder</div>
<div className="mt-4 space-y-3">
{createdSession.arrangementSections.map((section) => (
<div key={section.id} className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3">
<div>
<div className="font-medium text-white">{section.label}</div>
<div className="text-sm text-slate-400">{prettyStatus(section.section_type)}</div>
</div>
<div className="text-right text-sm text-slate-300">
<div>Bar {section.start_bar}</div>
<div>{section.bar_length} bars</div>
</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Track blueprint</div>
<div className="mt-4 space-y-3">
{createdSession.tracks.map((track) => (
<div key={track.id} className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3">
<div>
<div className="font-medium text-white">{track.name}</div>
<div className="text-sm text-slate-400">{prettyStatus(track.track_type)}</div>
</div>
<div className="rounded-full border border-violet-400/30 bg-violet-500/10 px-3 py-1 text-xs uppercase tracking-[0.16em] text-violet-200">
{prettyStatus(track.instrument)}
</div>
</div>
))}
</div>
</div>
</div>
</CardBox>
) : null}
<CardBox className="mt-6 border border-slate-800 bg-slate-950 text-white">
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-violet-300">Recent work</p>
<h2 className="mt-2 text-2xl font-semibold">Recent studio sessions</h2>
</div>
<Link
href="/projects/projects-list"
className="inline-flex items-center rounded-full border border-white/10 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-violet-300/40 hover:text-white"
>
Full project library
<BaseIcon path={mdiOpenInNew} size={16} className="ml-2" />
</Link>
</div>
{isLoading ? (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-slate-300">
Loading the latest studio sessions
</div>
) : recentSessions.length ? (
<div className="grid gap-4 xl:grid-cols-2">
{recentSessions.map((session) => (
<div key={session.id} className="rounded-3xl border border-white/10 bg-white/5 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">
{session.genre?.name || 'Studio session'}
</div>
<h3 className="mt-2 text-xl font-semibold text-white">{session.name}</h3>
</div>
<div className={`rounded-full border px-3 py-1 text-xs uppercase tracking-[0.16em] ${getStatusClasses(session.status)}`}>
{prettyStatus(session.status)}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-300">
{session.target_bpm ? <span className="rounded-full border border-white/10 px-3 py-1">{session.target_bpm} BPM</span> : null}
{session.target_key ? <span className="rounded-full border border-white/10 px-3 py-1">{session.target_key}</span> : null}
{session.mood ? <span className="rounded-full border border-white/10 px-3 py-1">{prettyStatus(session.mood)}</span> : null}
<span className="rounded-full border border-white/10 px-3 py-1">Updated {formatRelativeDate(session.updatedAt)}</span>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{[
session.song ? { label: 'Song', href: session.song.href, status: session.song.title } : null,
session.generationRequest
? {
label: 'Generation',
href: session.generationRequest.href,
status: prettyStatus(session.generationRequest.status),
}
: null,
session.mixSession
? { label: 'Mix', href: session.mixSession.href, status: prettyStatus(session.mixSession.status) }
: null,
session.masteringSession
? {
label: 'Master',
href: session.masteringSession.href,
status: prettyStatus(session.masteringSession.status),
}
: null,
session.exportJob
? {
label: 'Export',
href: session.exportJob.href,
status: `${prettyStatus(session.exportJob.status)} · ${session.exportJob.format.toUpperCase()}`,
}
: null,
]
.filter(Boolean)
.map((item) => (
<Link
key={`${session.id}-${item?.label}`}
href={item?.href || session.projectHref}
className="rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 transition hover:border-violet-300/30 hover:bg-slate-900"
>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{item?.label}</div>
<div className="mt-1 text-sm font-medium text-white">{item?.status}</div>
</Link>
))}
</div>
<div className="mt-5 flex items-center justify-between border-t border-white/10 pt-4">
<Link href={session.projectHref} className="inline-flex items-center text-sm font-medium text-violet-200 transition hover:text-white">
Open project
<BaseIcon path={mdiChevronRight} size={16} className="ml-1" />
</Link>
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">Studio pipeline live</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/5 p-8 text-center text-sm text-slate-300">
No studio sessions yet. Launch your first beat-to-master workflow above and it will appear here instantly.
</div>
)}
</CardBox>
</SectionMain>
</>
);
};
StudioPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission={'READ_PROJECTS'}>{page}</LayoutAuthenticated>;
};
export default StudioPage;