Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
da194c0a8f Autosave: 20260412-184445 2026-04-12 18:44:46 +00:00
Flatlogic Bot
bd12d12528 MANZI 2026-04-12 17:45:29 +00:00
11 changed files with 3080 additions and 156 deletions

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config'); const config = require('./config');
const swaggerUI = require('swagger-ui-express'); const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc'); 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_listingsRoutes = require('./routes/marketplace_listings');
const marketplace_ordersRoutes = require('./routes/marketplace_orders'); const marketplace_ordersRoutes = require('./routes/marketplace_orders');
const studioRoutes = require('./routes/studio');
const getBaseUrl = (url) => { 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/marketplace_orders', passport.authenticate('jwt', {session: false}), marketplace_ordersRoutes);
app.use(
'/api/studio',
passport.authenticate('jwt', { session: false }),
studioRoutes,
);
app.use( app.use(
'/api/openai', '/api/openai',
passport.authenticate('jwt', { session: false }), 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,687 @@
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 AssetsDBApi = require('../db/api/assets');
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 sanitizeUploadedAudioFile(rawFile) {
if (!rawFile || typeof rawFile !== 'object') {
return null;
}
const name = `${rawFile.name || ''}`.trim();
const privateUrl = `${rawFile.privateUrl || ''}`.trim();
const publicUrl = `${rawFile.publicUrl || ''}`.trim();
if (!name || !privateUrl || !publicUrl || !rawFile.new) {
return null;
}
if (!/\.(mp3|wav)$/i.test(name) && !/\.(mp3|wav)$/i.test(privateUrl)) {
throw badRequest('Only MP3 and WAV vocal uploads are supported right now.');
}
const sizeInBytes = Number(rawFile.sizeInBytes);
return {
id: rawFile.id || undefined,
name,
sizeInBytes: Number.isFinite(sizeInBytes) && sizeInBytes > 0 ? sizeInBytes : null,
privateUrl,
publicUrl,
new: true,
};
}
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, vocalAsset, vocalUpload) {
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,
vocalAsset: vocalAsset
? {
id: vocalAsset.id,
name: vocalAsset.name,
fileName: vocalUpload?.name || vocalAsset.name,
publicUrl: vocalUpload?.publicUrl || null,
href: `/assets/${vocalAsset.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 uploadedVocal = sanitizeUploadedAudioFile(data.vocalUpload);
if (data.vocalMode === 'upload' && !uploadedVocal) {
throw badRequest('Upload an MP3 or WAV vocal take before launching the session.');
}
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 vocalAsset = uploadedVocal
? await AssetsDBApi.create(
{
asset_type: 'audio',
audio_role: 'vocal_raw',
name: uploadedVocal.name,
uploaded_user: currentUser.id,
project: project.id,
song: song.id,
is_stereo: false,
organizations: organizationId,
file_blobs: [uploadedVocal],
},
{ currentUser, transaction },
)
: null;
const generationRequest = await GenerationRequestsDBApi.create(
{
project: project.id,
song: song.id,
requested_user: currentUser.id,
model: musicModel?.id || null,
input_asset: vocalAsset?.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,
source_asset: track.track_type === 'vocal' && vocalAsset ? vocalAsset.id : null,
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,
vocalAsset,
uploadedVocal,
),
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 BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList' import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks' import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link'; import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios'; 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 Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -0,0 +1,139 @@
import React, { useEffect, useMemo, useState } from 'react';
type Props = {
file?: File | null;
audioUrl?: string;
title?: string;
subtitle?: string;
emptyMessage?: string;
isLoading?: boolean;
};
const BAR_COUNT = 56;
function createWaveformBars(channelData: Float32Array) {
const blockSize = Math.max(1, Math.floor(channelData.length / BAR_COUNT));
const bars: number[] = [];
for (let index = 0; index < BAR_COUNT; index += 1) {
const start = index * blockSize;
const end = Math.min(channelData.length, start + blockSize);
let sum = 0;
for (let sampleIndex = start; sampleIndex < end; sampleIndex += 1) {
sum += Math.abs(channelData[sampleIndex]);
}
const average = end > start ? sum / (end - start) : 0;
bars.push(Math.min(100, Math.max(8, Math.round(average * 280))));
}
return bars;
}
const AudioWaveformPreview = ({
file,
audioUrl,
title = 'Waveform preview',
subtitle,
emptyMessage = 'Add an audio file to preview its waveform.',
isLoading = false,
}: Props) => {
const [bars, setBars] = useState<number[]>([]);
const [errorMessage, setErrorMessage] = useState('');
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : audioUrl || ''), [audioUrl, file]);
useEffect(() => {
return () => {
if (file && previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [file, previewUrl]);
useEffect(() => {
let isActive = true;
let audioContext: AudioContext | null = null;
const buildWaveform = async () => {
if (!file && !audioUrl) {
setBars([]);
setErrorMessage('');
return;
}
try {
setErrorMessage('');
const audioBuffer = file ? await file.arrayBuffer() : await fetch(audioUrl as string).then((response) => response.arrayBuffer());
if (typeof window === 'undefined' || !window.AudioContext) {
throw new Error('AudioContext is unavailable in this browser.');
}
audioContext = new window.AudioContext();
const decoded = await audioContext.decodeAudioData(audioBuffer.slice(0));
if (!isActive) {
return;
}
setBars(createWaveformBars(decoded.getChannelData(0)));
} catch (error) {
console.error('Failed to render waveform preview:', error);
if (isActive) {
setBars([]);
setErrorMessage('Waveform preview is unavailable for this file, but the audio upload is still attached.');
}
} finally {
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => null);
}
}
};
buildWaveform();
return () => {
isActive = false;
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => null);
}
};
}, [audioUrl, file]);
return (
<div className="rounded-2xl border border-white/10 bg-slate-950/70 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{title}</div>
<div className="mt-2 text-sm font-medium text-white">{subtitle || file?.name || 'Awaiting audio upload'}</div>
</div>
<div className="rounded-full border border-white/10 px-3 py-1 text-[11px] uppercase tracking-[0.16em] text-slate-400">
{isLoading ? 'Rendering…' : previewUrl ? 'Ready to review' : 'No audio'}
</div>
</div>
<div className="mt-4 flex h-24 items-end gap-1 overflow-hidden rounded-2xl border border-white/10 bg-slate-900/70 px-3 py-2">
{bars.length ? (
bars.map((barHeight, index) => (
<div
key={`${index}-${barHeight}`}
className="min-w-[4px] flex-1 rounded-full bg-gradient-to-t from-violet-500 via-violet-300 to-emerald-300 opacity-90"
style={{ height: `${barHeight}%` }}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-center text-sm text-slate-500">{emptyMessage}</div>
)}
</div>
{previewUrl ? <audio controls className="mt-4 w-full" src={previewUrl} preload="metadata" /> : null}
{errorMessage ? <p className="mt-3 text-xs leading-5 text-amber-200">{errorMessage}</p> : null}
</div>
);
};
export default AudioWaveformPreview;

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -8,6 +8,13 @@ const menuAside: MenuAsideItem[] = [
label: 'Dashboard', 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', href: '/users/users-list',
label: 'Users', label: 'Users',

View File

@ -1,166 +1,179 @@
import { mdiAlbum, mdiChevronRight, mdiLogin, mdiMusic, mdiRobot, mdiShieldAccount } from '@mdi/js';
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import BaseButton from '../components/BaseButton'; import React, { ReactElement } from 'react';
import CardBox from '../components/CardBox'; import BaseIcon from '../components/BaseIcon';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config'; 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 workflowPillars = [
const [illustrationImage, setIllustrationImage] = useState({ {
src: undefined, title: 'Create the beat',
photographer: undefined, body: 'Start from a text prompt and shape BPM, key, vibe, and South African genre DNA in seconds.',
photographer_url: undefined, icon: mdiRobot,
}) },
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) {
const [contentType, setContentType] = useState('image'); title: 'Bring in vocals',
const [contentPosition, setContentPosition] = useState('background'); body: 'Plan for uploads or recording sessions, with language-aware vocal workflows for multilingual artists.',
const textColor = useAppSelector((state) => state.style.linkColor); icon: mdiMusic,
},
const title = 'SA AI Music Studio' {
title: 'Ship release-ready audio',
// Fetch Pexels image/video body: 'Queue mix, mastering, metadata, artwork, and export for streaming, radio, or club delivery.',
useEffect(() => { icon: mdiAlbum,
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>)
}
};
function HomePage() {
return ( 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> <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> </Head>
<SectionFullScreen bg='violet'> <div className="min-h-screen bg-[#07070C] text-white">
<div <div className="mx-auto max-w-7xl px-6 pb-16 pt-6 sm:px-8 lg:px-10">
className={`flex ${ <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">
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <div>
} min-h-screen w-full`} <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>
{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> </div>
<BaseButtons> <nav className="flex flex-wrap items-center gap-3">
<BaseButton <Link
href='/login' href="/login"
label='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"
color='info' >
className='w-full' <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>
</BaseButtons> <main className="mt-10 grid gap-8 xl:grid-cols-[1.4fr_0.9fr] xl:items-center">
</CardBox> <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>
</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>; return <LayoutGuest>{page}</LayoutGuest>;
}; };
export default HomePage;

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';

File diff suppressed because it is too large Load Diff