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) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
+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
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
Session preview
+
Current setup
+
+
+
+
+
+
+
Genre DNA
+
{selectedGenre?.name || 'Choose a genre'}
+
{selectedGenre?.description || 'South African groove details will appear here once you pick a genre.'}
+ {selectedGenre?.typical_bpm_min || selectedGenre?.typical_bpm_max ? (
+
+ Typical BPM {selectedGenre?.typical_bpm_min || '—'}–{selectedGenre?.typical_bpm_max || '—'}
+
+ ) : null}
+
+
+
+
Mastering target
+
{selectedPreset?.name || 'Select a preset'}
+
+ {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.'}
+
+
+
+
+
Vocal route
+
+ {vocalModes.find((mode) => mode.value === form.vocalMode)?.title || 'Upload vocal'}
+
+
+ {vocalModes.find((mode) => mode.value === form.vocalMode)?.description}
+
+
+
+
+
+
+
+
+
AI engines
+
Available models
+
+
+
+
+ {aiModels.length ? (
+ aiModels.map((model) => (
+
+
+
+
{model.name}
+
+ {prettyStatus(model.model_type)} · {model.provider || 'Internal'}
+ {model.version ? ` · ${model.version}` : ''}
+
+
+
+
+
+ ))
+ ) : (
+
+ No active AI models are visible for this workspace yet.
+
+ )}
+
+
+
+
+
+ {createdSession ? (
+
+
+
+
Session created
+
Everything is linked and ready for review
+
+
+ End-to-end MVP slice complete
+
+
+
+
+ {[
+ { 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 ? (
+
+
+
+
+
+
+
+
{entry.label}
+
+ {entry.item.title || entry.item.name || prettyStatus(entry.item.format || entry.item.status)}
+
+
+
+
+
+
+ ) : null,
+ )}
+
+
+
+
+
Arrangement builder
+
+ {createdSession.arrangementSections.map((section) => (
+
+
+
{section.label}
+
{prettyStatus(section.section_type)}
+
+
+
Bar {section.start_bar}
+
{section.bar_length} bars
+
+
+ ))}
+
+
+
+
+
Track blueprint
+
+ {createdSession.tracks.map((track) => (
+
+
+
{track.name}
+
{prettyStatus(track.track_type)}
+
+
+ {prettyStatus(track.instrument)}
+
+
+ ))}
+
+
+
+
+ ) : null}
+
+
+
+
+
Recent work
+
Recent studio sessions
+
+
+ Full project library
+
+
+
+
+ {isLoading ? (
+
+ Loading the latest studio sessions…
+
+ ) : recentSessions.length ? (
+
+ {recentSessions.map((session) => (
+
+
+
+
+ {session.genre?.name || 'Studio session'}
+
+
{session.name}
+
+
+ {prettyStatus(session.status)}
+
+
+
+
+ {session.target_bpm ? {session.target_bpm} BPM : null}
+ {session.target_key ? {session.target_key} : null}
+ {session.mood ? {prettyStatus(session.mood)} : null}
+ Updated {formatRelativeDate(session.updatedAt)}
+
+
+
+ {[
+ 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) => (
+
+
{item?.label}
+
{item?.status}
+
+ ))}
+
+
+
+
+ Open project
+
+
+
Studio pipeline live
+
+
+ ))}
+
+ ) : (
+
+ No studio sessions yet. Launch your first beat-to-master workflow above and it will appear here instantly.
+
+ )}
+
+
+ >
+ );
+};
+
+StudioPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default StudioPage;