Skip to content

Commit 5378bdd

Browse files
committed
added suno generate
1 parent 96d0d3e commit 5378bdd

4 files changed

Lines changed: 648 additions & 7 deletions

File tree

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@ PORT=3000
55
PACKAGE_NAME=org.yourname.appname
66

77
# API key - Your MentraOS developer API key from the MentraOS Developer Console (never commit the key to source control)
8-
MENTRAOS_API_KEY=your_api_key_here
8+
MENTRAOS_API_KEY=your_api_key_here
9+
10+
# Suno API key - Your Suno HackMIT API key for music generation (optional - only needed for song generation feature)
11+
SUNO_API_KEY=your_suno_api_key_here

CLAUDE.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,17 @@ Copy `.env.example` to `.env` and configure these values before running the app.
4141
- **Data Management**: In-memory storage system with Maps tracking:
4242
- `photoGalleries`: User photo collections (up to 50 photos per user)
4343
- `transcriptionHistory`: User speech history (up to 100 entries per user)
44+
- `songGalleries`: Generated song collections (up to 25 songs per user)
45+
- `activeGenerations`: Tracks ongoing Suno API generations
4446
- `isStreamingPhotos`: Streaming mode state per user
4547
- `nextPhotoTime`: Next photo capture time for streaming
4648

4749
- **Enhanced Web Interface** (`views/enhanced-interface.ejs`): Multi-tab interface featuring:
4850
- Photo gallery with selection capabilities
4951
- Transcription history with timestamps and activation indicators
52+
- Song gallery with playback, favorites, and management
5053
- Music studio for AI song generation
51-
- Real-time status updates and audio playback
54+
- Real-time status updates and streaming audio support
5255
- Responsive design optimized for various screen sizes
5356

5457
### API Endpoints
@@ -66,9 +69,12 @@ Copy `.env.example` to `.env` and configure these values before running the app.
6669
- `GET /api/transcriptions`: Returns user's transcription history
6770
- `POST /api/transcriptions/select`: Toggle transcription selection status
6871

69-
**Music Generation:**
72+
**Music Generation & Gallery:**
7073
- `POST /api/generate-song`: Generate song using selected photos and transcriptions
7174
- `GET /api/song-status/:clipId`: Check Suno generation status and get audio URL
75+
- `GET /api/songs`: Get user's complete song gallery with metadata
76+
- `POST /api/songs/favorite`: Toggle song favorite status
77+
- `DELETE /api/songs/:songId`: Delete a song from the gallery
7278

7379
### Interaction Model
7480

@@ -170,12 +176,14 @@ Requires a `.env.local` file with:
170176
## Development Notes
171177

172178
- All data is stored in memory only - consider implementing persistent storage for production use
173-
- Photos are limited to 50 per user, transcriptions to 100 per user (automatic cleanup)
179+
- Storage limits: 50 photos, 100 transcriptions, 25 songs per user (automatic cleanup)
174180
- The streaming interval is set to check every 1 second with 30-second fallback timeouts
175181
- All operations are user-scoped and require MentraOS authentication
176182
- Voice transcription only processes final speech results, ignoring interim transcriptions
177183
- Multiple activation phrases are supported for natural voice interaction
178184
- Suno integration requires a separate API key and builds prompts from selected content
179-
- The webview interface includes auto-refresh for real-time updates
180-
- Song generation uses polling every 5 seconds to check Suno API status
185+
- The webview interface includes auto-refresh every 10 seconds for real-time updates
186+
- Song generation uses individual polling every 5 seconds per active generation
187+
- Streaming audio is available ~30-60 seconds after generation starts
188+
- Song gallery supports favorites, deletion, and real-time status updates
181189
- The separate Suno starter app operates independently and uses yarn instead of bun

src/index.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ interface SongGenerationRequest {
3939
tags?: string;
4040
}
4141

42+
/**
43+
* Interface for stored generated songs
44+
*/
45+
interface GeneratedSong {
46+
id: string;
47+
clipId: string;
48+
title: string;
49+
status: string;
50+
audioUrl: string | null;
51+
imageUrl: string | null;
52+
createdAt: Date;
53+
completedAt: Date | null;
54+
userId: string;
55+
prompt: string;
56+
tags: string;
57+
selectedPhotosCount: number;
58+
selectedTranscriptionsCount: number;
59+
metadata: any;
60+
favorite?: boolean;
61+
}
62+
4263
const PACKAGE_NAME = process.env.PACKAGE_NAME ?? (() => { throw new Error('PACKAGE_NAME is not set in .env file'); })();
4364
const MENTRAOS_API_KEY = process.env.MENTRAOS_API_KEY ?? (() => { throw new Error('MENTRAOS_API_KEY is not set in .env file'); })();
4465
const PORT = parseInt(process.env.PORT || '3000');
@@ -66,6 +87,10 @@ class ExampleMentraOSApp extends AppServer {
6687
private photoGalleries: Map<string, StoredPhoto[]> = new Map();
6788
// Transcription history: userId -> array of transcriptions
6889
private transcriptionHistory: Map<string, TranscriptionEntry[]> = new Map();
90+
// Generated songs gallery: userId -> array of songs
91+
private songGalleries: Map<string, GeneratedSong[]> = new Map();
92+
// Active song generations: clipId -> userId (for tracking ongoing generations)
93+
private activeGenerations: Map<string, string> = new Map();
6994
// Streaming state
7095
private isStreamingPhotos: Map<string, boolean> = new Map();
7196
private nextPhotoTime: Map<string, number> = new Map();
@@ -244,6 +269,81 @@ class ExampleMentraOSApp extends AppServer {
244269
return gallery && gallery.length > 0 ? gallery[gallery.length - 1] : undefined;
245270
}
246271

272+
/**
273+
* Add a generated song to the user's gallery
274+
*/
275+
private addSongToGallery(userId: string, clipId: string, prompt: string, tags: string, selectedPhotosCount: number, selectedTranscriptionsCount: number): GeneratedSong {
276+
if (!this.songGalleries.has(userId)) {
277+
this.songGalleries.set(userId, []);
278+
}
279+
280+
const song: GeneratedSong = {
281+
id: `song-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
282+
clipId,
283+
title: '', // Will be updated when available
284+
status: 'submitted',
285+
audioUrl: null,
286+
imageUrl: null,
287+
createdAt: new Date(),
288+
completedAt: null,
289+
userId,
290+
prompt,
291+
tags,
292+
selectedPhotosCount,
293+
selectedTranscriptionsCount,
294+
metadata: {},
295+
favorite: false
296+
};
297+
298+
const gallery = this.songGalleries.get(userId)!;
299+
gallery.push(song);
300+
301+
// Keep only the last 25 songs per user
302+
if (gallery.length > 25) {
303+
gallery.shift();
304+
}
305+
306+
// Track active generation
307+
this.activeGenerations.set(clipId, userId);
308+
309+
this.logger.info(`Added song to gallery for user ${userId}, clipId: ${clipId}`);
310+
return song;
311+
}
312+
313+
/**
314+
* Update song status and metadata
315+
*/
316+
private updateSongInGallery(clipId: string, status: string, title?: string, audioUrl?: string, imageUrl?: string, metadata?: any): void {
317+
const userId = this.activeGenerations.get(clipId);
318+
if (!userId) return;
319+
320+
const gallery = this.songGalleries.get(userId);
321+
if (!gallery) return;
322+
323+
const song = gallery.find(s => s.clipId === clipId);
324+
if (!song) return;
325+
326+
song.status = status;
327+
if (title) song.title = title;
328+
if (audioUrl) song.audioUrl = audioUrl;
329+
if (imageUrl) song.imageUrl = imageUrl;
330+
if (metadata) song.metadata = metadata;
331+
332+
if (status === 'complete') {
333+
song.completedAt = new Date();
334+
this.activeGenerations.delete(clipId); // Clean up tracking
335+
}
336+
337+
this.logger.debug(`Updated song ${song.id} for user ${userId}, status: ${status}`);
338+
}
339+
340+
/**
341+
* Get user's song gallery
342+
*/
343+
private getSongGallery(userId: string): GeneratedSong[] {
344+
return this.songGalleries.get(userId) || [];
345+
}
346+
247347

248348

249349
/**
@@ -460,9 +560,20 @@ class ExampleMentraOSApp extends AppServer {
460560
const result = await response.json();
461561
this.logger.info(`Song generation started for user ${userId}, clip ID: ${result.id}`);
462562

563+
// Save to song gallery
564+
const song = this.addSongToGallery(
565+
userId,
566+
result.id,
567+
songPrompt.slice(0, 2500),
568+
tags || 'ambient, atmospheric, reflective',
569+
selectedPhotos.length,
570+
selectedTranscriptions.length
571+
);
572+
463573
res.json({
464574
success: true,
465575
clipId: result.id,
576+
songId: song.id,
466577
status: result.status,
467578
prompt: songPrompt.slice(0, 2500),
468579
selectedPhotos: selectedPhotos.length,
@@ -510,6 +621,16 @@ class ExampleMentraOSApp extends AppServer {
510621
const clips = await response.json();
511622
const clip = clips[0];
512623

624+
// Update song in gallery
625+
this.updateSongInGallery(
626+
clipId,
627+
clip.status,
628+
clip.title,
629+
clip.audio_url,
630+
clip.image_url,
631+
clip.metadata
632+
);
633+
513634
res.json({
514635
id: clip.id,
515636
status: clip.status,
@@ -526,6 +647,90 @@ class ExampleMentraOSApp extends AppServer {
526647
}
527648
});
528649

650+
// API endpoint to get user's song gallery
651+
app.get('/api/songs', (req: any, res: any) => {
652+
const userId = (req as AuthenticatedRequest).authUserId;
653+
654+
if (!userId) {
655+
res.status(401).json({ error: 'Not authenticated' });
656+
return;
657+
}
658+
659+
const songs = this.getSongGallery(userId);
660+
661+
res.json({
662+
songs: songs.map(song => ({
663+
id: song.id,
664+
clipId: song.clipId,
665+
title: song.title || 'Untitled',
666+
status: song.status,
667+
audioUrl: song.audioUrl,
668+
imageUrl: song.imageUrl,
669+
createdAt: song.createdAt.getTime(),
670+
completedAt: song.completedAt ? song.completedAt.getTime() : null,
671+
prompt: song.prompt,
672+
tags: song.tags,
673+
selectedPhotosCount: song.selectedPhotosCount,
674+
selectedTranscriptionsCount: song.selectedTranscriptionsCount,
675+
favorite: song.favorite || false,
676+
metadata: song.metadata
677+
}))
678+
});
679+
});
680+
681+
// API endpoint to toggle song favorite status
682+
app.post('/api/songs/favorite', (req: any, res: any) => {
683+
const userId = (req as AuthenticatedRequest).authUserId;
684+
const { songId, favorite } = req.body;
685+
686+
if (!userId) {
687+
res.status(401).json({ error: 'Not authenticated' });
688+
return;
689+
}
690+
691+
const songs = this.getSongGallery(userId);
692+
const song = songs.find(s => s.id === songId);
693+
694+
if (!song) {
695+
res.status(404).json({ error: 'Song not found' });
696+
return;
697+
}
698+
699+
song.favorite = favorite;
700+
res.json({ success: true, favorite: song.favorite });
701+
});
702+
703+
// API endpoint to delete a song
704+
app.delete('/api/songs/:songId', (req: any, res: any) => {
705+
const userId = (req as AuthenticatedRequest).authUserId;
706+
const songId = req.params.songId;
707+
708+
if (!userId) {
709+
res.status(401).json({ error: 'Not authenticated' });
710+
return;
711+
}
712+
713+
const songs = this.getSongGallery(userId);
714+
const songIndex = songs.findIndex(s => s.id === songId);
715+
716+
if (songIndex === -1) {
717+
res.status(404).json({ error: 'Song not found' });
718+
return;
719+
}
720+
721+
const song = songs[songIndex];
722+
723+
// Remove from active generations if still generating
724+
if (this.activeGenerations.has(song.clipId)) {
725+
this.activeGenerations.delete(song.clipId);
726+
}
727+
728+
// Remove from gallery
729+
songs.splice(songIndex, 1);
730+
731+
res.json({ success: true });
732+
});
733+
529734
// Main webview route - displays the enhanced interface
530735
app.get('/webview', async (req: any, res: any) => {
531736
const userId = (req as AuthenticatedRequest).authUserId;

0 commit comments

Comments
 (0)