# Radio Room Architecture

## Overview

Radio Rooms are a specialized room type in Suno Spaces that enable continuous, AI-generated music playback based on a text prompt. Unlike standard rooms with manual playback control, Radio Rooms maintain a self-sustaining playlist that automatically generates new songs matching a user-defined theme.

## Core Concepts

### Radio Room Prompt

The heart of a Radio Room is a plaintext prompt that describes the type of music to generate. This prompt can optionally reference atoms for context, enabling features like creating radio stations that play covers or variations of existing songs.

**Example prompts:**
- `"groovy neosoul jazz songs with chill vibes"`
- `"ambient electronic music with nature sounds"`
- `"indie rock with lyrics about space exploration"`
- `"songs like [Referenced Atom] but in a jazz style"` - Creates covers in different genres
- `"upbeat versions of [Referenced Atom]"` - Creates variations based on referenced song

**Atom Reference Support:**
When creating or updating a radio prompt, users can reference existing song atoms. The system uses the OpenAI assistant (similar to @suno mentions in chat) to:
- Parse the natural language prompt
- Extract genre/style tags
- Detect cover/remix requests when atoms are referenced
- Generate appropriate parameters for the Suno API

This enables powerful use cases like:
- Radio stations that play covers of a specific song in different styles
- Stations that generate variations on a theme from referenced songs
- Genre-bending experiments (e.g., "metal versions of classic pop songs")

The prompt is stored and displayed prominently in the room UI, allowing users to understand the room's musical theme at a glance.

### Server-Driven Playback

Radio Rooms use **server-controlled playback state** to ensure all listeners hear the same song at the same time:

- The room state stores:
  - `currentTrackId` - The atom currently playing
  - `startedAt` - Server timestamp when the track started playing
  - `duration` - Expected duration of the track

- The server schedules automatic track transitions based on duration
- As songs transition from streaming URLs to CDN URLs, the server seamlessly updates without interrupting playback
- Clients synchronize their local audio players to the server state using timestamp-based calculation

### Lookahead Queue

Radio Rooms maintain a buffer of upcoming songs to ensure uninterrupted playback:

**Queue Management:**
- When a song generation completes via the Suno API (which returns 2 songs), both are added to the queue
- The queue target is 3-5 songs matching the current prompt
- When queue length drops below threshold (e.g., 2 songs), the server proactively triggers new generation
- Generation happens asynchronously, so playback continues uninterrupted

**Prompt Changes:**
- When the user updates the radio prompt, existing songs continue playing
- New songs matching the updated prompt are generated in the background
- A brief "Loading new songs..." indicator shows while generation is in progress
- Once new songs are ready, they enter the queue and play when reached naturally

### Presence-Based Playback

Radio Rooms intelligently pause when empty to conserve resources:

**Behavior:**
- When the last user leaves a Radio Room, playback pauses and generation stops
- The current track position is preserved
- When any user enters the room:
  - If the room was playing when evacuated, playback resumes
  - Queue generation resumes if needed
  - Position synchronization ensures seamless continuation

**Implementation:**
- Uses the existing `userPresence` table with `by_room` index
- A scheduled Convex cron job checks for empty rooms and pauses them
- Room entry triggers a mutation that checks and updates playback state

## Data Model

### Schema Changes

Radio Rooms extend the existing `rooms` schema with new fields:

```typescript
rooms: defineTable({
  // ... existing fields
  type: v.string(), // "chat", "canvas", "radio", etc.

  // Radio-specific configuration
  radioConfig: v.optional(
    v.object({
      prompt: v.string(), // The radio prompt
      atomReferences: v.optional(v.array(v.id("atoms"))), // Optional atom refs
      autoGenerate: v.boolean(), // Whether to auto-generate new songs
      queueTargetLength: v.number(), // Desired queue length (default: 4)
    })
  ),

  // Enhanced playback state for server-driven playback
  playbackState: v.optional(
    v.object({
      currentTrackId: v.optional(v.id("atoms")),
      isPlaying: v.boolean(),
      position: v.number(),
      timestamp: v.number(),
      queue: v.array(v.id("atoms")),
      queueIndex: v.number(),
      volume: v.number(),
      repeat: v.union(v.literal("none"), v.literal("one"), v.literal("all")),
      shuffle: v.boolean(),
      radioMode: v.union(v.literal("OFF"), v.literal("PROMPT"), v.literal("AUTO")),
      radioPrompt: v.optional(v.string()),

      // NEW: Radio-specific fields
      startedAt: v.optional(v.number()), // When current track started (server time)
      trackDuration: v.optional(v.number()), // Expected duration in ms
      generatingNewSongs: v.optional(v.boolean()), // In-progress generation flag
    })
  ),
})
```

### Radio Generation State

Track the state of ongoing song generation to prevent duplicate requests:

```typescript
radioGenerationJobs: defineTable({
  roomId: v.id("rooms"),
  prompt: v.string(),
  status: v.union(
    v.literal("pending"),
    v.literal("generating"),
    v.literal("completed"),
    v.literal("failed")
  ),
  generatedAtomIds: v.optional(v.array(v.id("atoms"))),
  createdAt: v.number(),
  completedAt: v.optional(v.number()),
  error: v.optional(v.string()),
})
  .index("by_room", ["roomId"])
  .index("by_status", ["status"])
  .index("by_room_and_status", ["roomId", "status"])
```

## Backend Implementation

### Convex Functions

**Radio Room Management:**

```typescript
// convex/radioRooms.ts

// Create a new radio room
export const createRadioRoom = mutation({
  args: {
    spaceId: v.id("spaces"),
    name: v.string(),
    prompt: v.string(),
    atomReferences: v.optional(v.array(v.id("atoms"))),
  },
  handler: async (ctx, args) => {
    // Create room with type "radio"
    // Initialize radioConfig with prompt
    // Trigger initial song generation
  }
})

// Update radio prompt
export const updateRadioPrompt = mutation({
  args: {
    roomId: v.id("rooms"),
    prompt: v.string(),
    atomReferences: v.optional(v.array(v.id("atoms"))),
  },
  handler: async (ctx, args) => {
    // Update radioConfig.prompt
    // Mark that new songs are needed
    // Trigger background generation
  }
})

// Get radio room state (includes queue and generation status)
export const getRadioRoomState = query({
  args: { roomId: v.id("rooms") },
  handler: async (ctx, args) => {
    // Return room + populated queue atoms + generation status
  }
})
```

**Playback Scheduling:**

```typescript
// convex/radioPlayback.ts

// Server-side track transition
export const advanceToNextTrack = mutation({
  args: { roomId: v.id("rooms") },
  handler: async (ctx, args) => {
    // Move to next track in queue
    // Update startedAt timestamp
    // Update trackDuration from atom metadata
    // Check queue length and trigger generation if needed
  }
})

// Scheduled function to check for track transitions
export const checkRadioRoomTransitions = internalMutation({
  handler: async (ctx) => {
    // Query all radio rooms with active playback
    // For each room, check if currentTime >= startedAt + duration
    // If so, call advanceToNextTrack
  }
})
```

**Queue Management:**

```typescript
// Add generated songs to radio queue
export const addToRadioQueue = internalMutation({
  args: {
    roomId: v.id("rooms"),
    atomIds: v.array(v.id("atoms")),
  },
  handler: async (ctx, args) => {
    // Append atoms to playbackState.queue
    // If queue was empty and room has users, start playback
  }
})

// Check if queue needs refilling
export const checkAndGenerateRadioSongs = internalMutation({
  args: { roomId: v.id("rooms") },
  handler: async (ctx, args) => {
    // Check current queue length
    // If below threshold and no active generation job:
    //   - Create radioGenerationJob
    //   - Schedule internal action to generate
  }
})
```

**Presence Integration:**

```typescript
// Handle user entering/leaving radio room
export const updateRadioRoomPresence = mutation({
  args: {
    roomId: v.id("rooms"),
    isEntering: v.boolean(),
  },
  handler: async (ctx, args) => {
    // Count users in room via userPresence
    // If entering and count was 0, resume playback
    // If leaving and count becomes 0, pause playback and stop generation
  }
})
```

**Song Generation:**

```typescript
// Internal action to generate songs via Suno API
export const generateRadioSongs = internalAction({
  args: {
    jobId: v.id("radioGenerationJobs"),
    roomId: v.id("rooms"),
    prompt: v.string(),
  },
  handler: async (ctx, args) => {
    // Call Suno API with prompt
    // Poll for completion
    // Create atom records
    // Update job status
    // Add to queue via addToRadioQueue
  }
})
```

### Scheduled Jobs

```typescript
// convex/cron.ts

export default {
  // Check for track transitions every 5 seconds
  radioTransitions: {
    schedule: "*/5 * * * * *", // Every 5 seconds
    handler: internal.radioPlayback.checkRadioRoomTransitions,
  },

  // Clean up old generation jobs every hour
  cleanupGenerationJobs: {
    schedule: "0 * * * *",
    handler: internal.radioPlayback.cleanupOldJobs,
  },
}
```

## Frontend Implementation

### Radio Room UI Components

**RadioRoomView.tsx** - Main room view

```typescript
// components/RadioRoomView.tsx

export function RadioRoomView({ roomId }: { roomId: Id<"rooms"> }) {
  const room = useQuery(api.rooms.get, { roomId })
  const radioState = useQuery(api.radioRooms.getRadioRoomState, { roomId })

  return (
    <div className="flex flex-col h-full">
      {/* Radio prompt display/editor */}
      <RadioPromptSection
        prompt={room?.radioConfig?.prompt}
        roomId={roomId}
      />

      {/* Chat messages */}
      <div className="flex-1 overflow-y-auto">
        <Messages roomId={roomId} />
      </div>

      {/* Chat input */}
      <ChatInput roomId={roomId} />

      {/* Playbar (shows current track + queue) */}
      <RadioPlayBar
        roomId={roomId}
        radioState={radioState}
      />
    </div>
  )
}
```

**RadioPromptSection.tsx** - Prompt display and editing

```typescript
// components/RadioPromptSection.tsx

export function RadioPromptSection({
  prompt,
  roomId
}: {
  prompt?: string
  roomId: Id<"rooms">
}) {
  const [isEditing, setIsEditing] = useState(false)
  const [editedPrompt, setEditedPrompt] = useState(prompt || "")
  const updatePrompt = useMutation(api.radioRooms.updateRadioPrompt)

  // Empty state when no prompt set
  if (!prompt && !isEditing) {
    return (
      <div className="p-8 border-b border-gray-200 text-center">
        <h3 className="text-lg font-semibold mb-2">
          Set your radio station's vibe
        </h3>
        <p className="text-gray-600 mb-4">
          Describe the type of music you want to hear
        </p>
        <button
          onClick={() => setIsEditing(true)}
          className="btn-primary"
        >
          Create Radio Prompt
        </button>
      </div>
    )
  }

  // Display prompt with edit button
  return (
    <div className="p-4 border-b border-gray-200 bg-blue-50">
      {isEditing ? (
        <div>
          <textarea
            value={editedPrompt}
            onChange={(e) => setEditedPrompt(e.target.value)}
            className="w-full p-2 border rounded"
            rows={3}
            placeholder="e.g., groovy neosoul jazz with lyrics about AI"
          />
          <div className="flex gap-2 mt-2">
            <button onClick={handleSave}>Save</button>
            <button onClick={() => setIsEditing(false)}>Cancel</button>
          </div>
        </div>
      ) : (
        <div className="flex items-start justify-between">
          <div>
            <div className="text-xs text-gray-500 uppercase mb-1">
              Radio Prompt
            </div>
            <div className="text-base">{prompt}</div>
          </div>
          <button
            onClick={() => setIsEditing(true)}
            className="text-sm text-blue-600"
          >
            Edit
          </button>
        </div>
      )}
    </div>
  )
}
```

**RadioPlayBar.tsx** - Playbar for radio rooms

Extends the existing PlayBar component with radio-specific features:

```typescript
// components/RadioPlayBar.tsx

export function RadioPlayBar({
  roomId,
  radioState
}: {
  roomId: Id<"rooms">
  radioState: RadioRoomState
}) {
  const { currentTrack, queue, generatingNewSongs } = radioState

  return (
    <div className="border-t border-gray-200 bg-white">
      {/* Current track info */}
      <div className="flex items-center gap-4 p-4">
        {currentTrack && (
          <>
            <img
              src={currentTrack.metadata.image_url}
              className="w-16 h-16 rounded"
            />
            <div className="flex-1">
              <div className="font-semibold">{currentTrack.metadata.title}</div>
              <div className="text-sm text-gray-600">Radio Station</div>
            </div>
          </>
        )}

        {/* Server-synced progress bar */}
        <RadioProgressBar
          startedAt={radioState.startedAt}
          duration={radioState.trackDuration}
        />

        {/* Volume control */}
        <VolumeControl />
      </div>

      {/* Queue preview */}
      <RadioQueuePreview
        queue={queue}
        generatingNewSongs={generatingNewSongs}
      />
    </div>
  )
}
```

**RadioProgressBar.tsx** - Server-synced progress indicator

```typescript
// components/RadioProgressBar.tsx

export function RadioProgressBar({
  startedAt,
  duration
}: {
  startedAt?: number
  duration?: number
}) {
  const [progress, setProgress] = useState(0)

  useEffect(() => {
    if (!startedAt || !duration) return

    const interval = setInterval(() => {
      const elapsed = Date.now() - startedAt
      const progressPercent = Math.min((elapsed / duration) * 100, 100)
      setProgress(progressPercent)
    }, 100)

    return () => clearInterval(interval)
  }, [startedAt, duration])

  if (!duration) return null

  return (
    <div className="flex-1 max-w-md">
      <div className="h-1 bg-gray-200 rounded-full overflow-hidden">
        <div
          className="h-full bg-blue-500 transition-all duration-100"
          style={{ width: `${progress}%` }}
        />
      </div>
      <div className="flex justify-between text-xs text-gray-500 mt-1">
        <span>{formatTime(Date.now() - startedAt!)}</span>
        <span>{formatTime(duration)}</span>
      </div>
    </div>
  )
}
```

### Playback Synchronization Context

Extend the existing PlaybackContext to support radio rooms:

```typescript
// contexts/PlaybackContext.tsx

// Add radio-specific state
const [isRadioMode, setIsRadioMode] = useState(false)
const [serverPlaybackState, setServerPlaybackState] = useState<ServerPlaybackState | null>(null)

// Subscribe to server-driven playback for radio rooms
useEffect(() => {
  if (!currentRoom || currentRoom.type !== "radio") {
    setIsRadioMode(false)
    return
  }

  setIsRadioMode(true)

  // In radio mode, follow server playback state
  // Don't allow local play/pause controls
  // Sync audio position to server timestamps
}, [currentRoom])

// Sync audio element to server state
useEffect(() => {
  if (!isRadioMode || !serverPlaybackState || !audioRef.current) return

  const { currentTrackId, startedAt, isPlaying, duration } = serverPlaybackState

  // Calculate expected position
  const expectedPosition = Date.now() - startedAt
  const currentPosition = audioRef.current.currentTime * 1000

  // If position drift is > 1 second, resync
  if (Math.abs(expectedPosition - currentPosition) > 1000) {
    audioRef.current.currentTime = expectedPosition / 1000
  }

  // Update playing state
  if (isPlaying && audioRef.current.paused) {
    audioRef.current.play()
  } else if (!isPlaying && !audioRef.current.paused) {
    audioRef.current.pause()
  }
}, [isRadioMode, serverPlaybackState])
```

## User Flows

### Creating a Radio Room

1. User clicks "Create Room" in a space
2. Selects "Radio Station" as room type
3. Enters room name and initial radio prompt
4. Room is created with `type: "radio"` and `radioConfig` populated
5. Server triggers initial song generation (2 songs from Suno API)
6. User enters room and sees empty state with "Generating your first songs..." message
7. Once generation completes, playback automatically starts

### Listening to a Radio Room

1. User enters an existing radio room
2. If room is empty, playback resumes and queue generation restarts
3. User sees:
   - Radio prompt at the top
   - Chat messages in the middle
   - Chat input
   - Play bar showing current track and upcoming queue
4. Audio automatically plays, synced to server state
5. User can't manually skip or seek (server-controlled)
6. Progress bar shows real-time position based on server timestamps

### Updating Radio Prompt

1. User clicks "Edit" on the radio prompt section
2. Edits the prompt text
3. Clicks "Save"
4. Old songs continue playing
5. Loading indicator appears: "Generating new songs..."
6. New songs are generated and added to queue
7. When current song ends, new songs matching updated prompt play
8. Loading indicator disappears

### Empty Room Behavior

1. Last user leaves radio room
2. Server detects empty room via presence check (cron job or on-leave trigger)
3. Playback pauses, position is saved
4. Generation stops, any pending jobs are cancelled
5. When first user enters:
   - Playback resumes from saved position
   - Queue generation resumes if queue is low
   - Room continues as if never paused

## Technical Considerations

### Seamless URL Transitions

Songs from Suno API initially have streaming URLs, which later become CDN URLs:

- Server stores both URLs in atom metadata
- When CDN URL becomes available, server updates atom
- Client audio element already playing continues without interruption
- HTML5 audio handles URL transition gracefully if using blob URLs

**Implementation:**
```typescript
// When polling Suno API and CDN URL becomes available
await ctx.runMutation(internal.atoms.updateInternal, {
  id: atomId,
  metadata: {
    ...existingMetadata,
    audio_url: cdnUrl, // Updated from streaming URL
  }
})

// Client automatically picks up new URL via subscription
// No playback interruption if audio element is already playing
```

### Handling Generation Failures

If song generation fails:

1. Mark generation job as failed with error message
2. Retry automatically (up to 3 attempts)
3. If all retries fail, show non-intrusive notification in UI
4. Keep playing existing songs in queue
5. User can manually trigger re-generation via "Retry" button

### Handling Atom State Transitions

Songs go through states: `pending` → `processing` → `streaming` → `completed`

- Radio queue can contain songs in any state
- When a song reaches the front of the queue:
  - If `streaming` or `completed`: Play immediately
  - If `pending` or `processing`: Show loading indicator, poll for completion
- Server monitors atom status and triggers playback when ready

### Rate Limiting and Cost Management

To prevent excessive API usage:

- Track generation requests per room per hour
- Limit to reasonable threshold (e.g., 20 songs/hour)
- Show warning to users if limit is approaching
- Cache similar prompts to reuse generations (future enhancement)

### Mobile Considerations

- Radio progress bar is touch-friendly but read-only (no scrubbing)
- Background audio continues when app is backgrounded
- Lock screen shows current track metadata
- Minimal battery drain by avoiding continuous position polling

## Future Enhancements

### Smart Prompt Refinement

- Orphy can be tagged to refine the prompt: `@orphy make it more upbeat`
- Orphy analyzes current prompt and updates it contextually
- Maintains conversation history to understand refinements

### Collaborative Prompt Building

- Multiple users can suggest prompt modifications
- Voting system to accept/reject changes
- Prompt history and ability to revert

### Atom Reference in Prompts

- Allow dragging song atoms into the prompt
- Example: "Songs like [Atom: 'Sunset Dreams'] but more energetic"
- Server extracts features from referenced atoms to guide generation

### Scheduled Radio Programs

- Set up time-based prompt changes
- Example: "Chill morning vibes until noon, then upbeat afternoon tunes"
- Automated DJ mode with smooth transitions

### Radio Station Templates

- Pre-built prompts for common genres/moods
- One-click to start a "Lo-fi Study Session" or "Indie Rock Roadtrip" radio

### Analytics and Insights

- Track which prompts generate the most engagement
- Song skip rates and listener retention metrics
- Suggest prompt improvements based on data

## Implementation Priority

### Phase 1: Core Radio Room ✅ (This PR)

1. Schema updates for radio rooms
2. Radio room creation with prompt
3. Server-driven playback with timestamp sync
4. Basic queue management
5. Song generation integration
6. Empty room pause/resume
7. Frontend: RadioPromptSection component
8. Frontend: RadioPlayBar with server sync

### Phase 2: Polish and Reliability

1. Graceful generation failure handling
2. Smooth CDN URL transitions
3. Rate limiting and cost controls
4. Loading states and progress indicators
5. Mobile optimization

### Phase 3: Advanced Features

1. Orphy integration for prompt refinement
2. Atom references in prompts
3. Collaborative prompt editing
4. Radio station templates
5. Analytics dashboard
