jam-cloud/jam-ui/TRACK_CONFIG_IMPLEMENTATION...

18 KiB

Track Configuration API Implementation Plan

Feature: PUT /api/sessions/{id}/tracks - Track Configuration Date: 2026-01-21 Status: Planning Phase


1. Backend API Specification (From Rails Analysis)

Endpoint

PUT /api/sessions/{session_id}/tracks

Purpose

Synchronizes participant's audio track configuration with the server. Updates:

  • Active audio tracks (instrument, sound settings)
  • Backing tracks
  • Metronome open/closed state

Request Payload (Expected Format)

{
  "client_id": "client-uuid",
  "tracks": [
    {
      "instrument_id": "electric guitar",
      "sound": "stereo",
      "client_track_id": "client-track-guid",
      "client_resource_id": "resource-guid"
    }
  ],
  "backing_tracks": [
    {
      "filename": "backing-track.mp3",
      "client_track_id": "backing-track-guid",
      "client_resource_id": "backing-resource-guid"
    }
  ],
  "metronome_open": false
}

Response

{
  "tracks": [...],
  "backing_tracks": [...]
}

Backend Behavior (Rails)

  • Updates Track records in database
  • Updates BackingTrack records
  • Updates Connection.metronome_open flag
  • Updates MusicSessionUserHistory.instruments
  • Sends WebSocket notification to other participants: tracks_changed
  • Returns updated track list

When Called in Legacy

  1. First call: ~1 second after joining session (initial track setup)
  2. Second call: ~400ms later (track refinement)
  3. Third call: ~5 seconds later (final configuration)
  4. Subsequent calls: When user changes instruments, mixer settings, or opens/closes media

2. Current jam-ui State (What Exists)

API Client Already Exists!

File: src/helpers/rest.js:887-897

export const putTrackSyncChange = (options = {}) => {
  const { id, ...rest } = options;
  return new Promise((resolve, reject) => {
    apiFetch(`/sessions/${id}/tracks`, {
      method: 'PUT',
      body: JSON.stringify(rest)
    })
      .then(response => resolve(response))
      .catch(error => reject(error));
  });
}

Status: Already implemented, just not called anywhere!

Redux State Management Ready

Session State: src/store/features/activeSessionSlice.js

  • userTracks - Participant's tracks in session
  • backingTrackData - Backing track state
  • jamTrackData - Jam track state
  • Actions: setUserTracks(), setBackingTrackData(), setJamTrackData()

Mixer State: src/store/features/mixersSlice.js

  • Organized by group_id (MetronomeGroup: 16, BackingTrackGroup, JamTrackGroup, etc.)
  • Actions: setMetronomeTrackMixers(), setBackingTrackMixers(), setJamTrackMixers()
  • Selectors: selectUserTracks, selectBackingTracks, selectJamTracks

Metronome State: Recently integrated (commit 4d141c93c)

  • selectMetronome - Metronome track state
  • selectMetronomeSettings - Configuration

Components That Use Tracks

  1. Session Screen: src/components/client/JKSessionScreen.js

    • Main session UI
    • Manages track display and media players
  2. Track Components:

    • JKSessionMyTrack.js - User's audio track
    • JKSessionRemoteTracks.js - Remote participant tracks
    • JKSessionBackingTrackPlayer.js - Backing track player
    • JKSessionJamTrackPlayer.js - Jam track player
    • JKSessionMetronomePlayer.js - Metronome player
  3. Hooks:

    • useMixerHelper.js - Mixer management and categorization
    • useMediaActions.js - Media control actions
    • useTrackHelpers.js - Track information retrieval

3. What Needs to Be Implemented

Missing: Track Sync Logic

Problem: The API wrapper exists but is never called.

Need to implement:

  1. Track state builder function - Constructs payload from Redux state
  2. Sync trigger logic - Determines when to call API
  3. Redux actions - Update state after API success
  4. Error handling - Handle API failures gracefully

Missing: Integration Points

Where to call the API:

  1. On session join - Initial track configuration
  2. On mixer changes - When user adjusts audio settings
  3. On media open/close - When backing track/jam track/metronome toggled
  4. On instrument change - When user selects different instrument

4. Implementation Strategy

Phase 1: Create Track Sync Service

New file: src/services/trackSyncService.js

Purpose:

  • Build track sync payload from Redux state
  • Call putTrackSyncChange() API
  • Handle success/error responses
  • Dispatch Redux updates

Functions:

// Build payload from current Redux state
export const buildTrackSyncPayload = (state, sessionId) => {
  const userTracks = selectUserTracks(state);
  const backingTracks = selectBackingTracks(state);
  const metronome = selectMetronome(state);
  const clientId = selectClientId(state);

  return {
    client_id: clientId,
    tracks: userTracks.map(track => ({
      instrument_id: track.instrument_id,
      sound: track.sound,
      client_track_id: track.client_track_id,
      client_resource_id: track.client_resource_id
    })),
    backing_tracks: backingTracks.map(bt => ({
      filename: bt.filename,
      client_track_id: bt.client_track_id,
      client_resource_id: bt.client_resource_id
    })),
    metronome_open: metronome?.isOpen || false
  };
};

// Sync tracks to server
export const syncTracksToServer = (sessionId) => async (dispatch, getState) => {
  const state = getState();
  const payload = buildTrackSyncPayload(state, sessionId);

  try {
    const response = await putTrackSyncChange({ id: sessionId, ...payload });

    // Update Redux with server response
    dispatch(setUserTracks(response.tracks));
    dispatch(setBackingTrackData(response.backing_tracks));

    return response;
  } catch (error) {
    console.error('Track sync failed:', error);
    dispatch(showError('Failed to sync tracks'));
    throw error;
  }
};

Phase 2: Add Sync Triggers

Location: src/hooks/useSessionModel.js or create src/hooks/useTrackSync.js

Trigger points:

  1. On session join:
// In useSessionModel or JKSessionScreen
useEffect(() => {
  if (sessionJoined && clientId) {
    // Initial track sync after joining
    setTimeout(() => {
      dispatch(syncTracksToServer(sessionId));
    }, 1000);
  }
}, [sessionJoined, clientId]);
  1. On mixer changes:
// In useMixerHelper or mixer components
const handleMixerChange = (trackId, setting, value) => {
  // Update local mixer state
  updateMixer(trackId, setting, value);

  // Debounced sync to server
  debouncedTrackSync();
};
  1. On media toggle:
// In useMediaActions
export const toggleBackingTrack = (trackId, isOpen) => async (dispatch) => {
  // Update local state
  dispatch(setBackingTrackOpen({ trackId, isOpen }));

  // Sync to server
  await dispatch(syncTracksToServer(sessionId));
};
  1. On metronome toggle:
// In metronome components
const handleMetronomeToggle = () => {
  dispatch(toggleMetronome());
  dispatch(syncTracksToServer(sessionId));
};

Phase 3: Debouncing and Optimization

Problem: Don't want to spam server with requests on every slider move.

Solution: Debounce track sync calls:

// In trackSyncService.js
import { debounce } from 'lodash';

export const createDebouncedTrackSync = (dispatch, sessionId) => {
  return debounce(() => {
    dispatch(syncTracksToServer(sessionId));
  }, 500); // Wait 500ms after last change
};

Usage:

// In component
const debouncedSync = useMemo(
  () => createDebouncedTrackSync(dispatch, sessionId),
  [dispatch, sessionId]
);

const handleMixerChange = (change) => {
  updateLocalState(change);
  debouncedSync(); // Will sync 500ms after last change
};

Phase 4: Initial Sync Sequence (Match Legacy)

Goal: Replicate legacy's 3-call pattern on session join.

// In useSessionModel.js or JKSessionScreen.js
useEffect(() => {
  if (sessionJoined && clientId) {
    // First sync: Initial setup (~1s after join)
    setTimeout(() => {
      dispatch(syncTracksToServer(sessionId));
    }, 1000);

    // Second sync: Refinement (~1.4s after join)
    setTimeout(() => {
      dispatch(syncTracksToServer(sessionId));
    }, 1400);

    // Third sync: Final config (~6s after join)
    setTimeout(() => {
      dispatch(syncTracksToServer(sessionId));
    }, 6000);
  }
}, [sessionJoined, clientId]);

Note: This mimics legacy behavior. May be optimized later to single call once we verify it works.


5. Data Flow Diagram

User Action (e.g., adjust mixer)
        ↓
Component Handler (e.g., handleMixerChange)
        ↓
Update Local Redux State (immediate UI feedback)
        ↓
Debounced Track Sync Trigger
        ↓
buildTrackSyncPayload(Redux state)
        ↓
putTrackSyncChange(sessionId, payload) → Rails API
        ↓
Rails: Update DB + Send WebSocket notification
        ↓
Response: Updated track data
        ↓
Update Redux with server response
        ↓
Components re-render with confirmed state

6. Files to Create/Modify

New Files

  1. src/services/trackSyncService.js (~150 lines)

    • Track sync business logic
    • Payload builder
    • Redux thunk actions
  2. src/hooks/useTrackSync.js (~80 lines)

    • Hook to provide track sync functionality
    • Debouncing logic
    • Effect hooks for auto-sync

Modified Files

  1. src/components/client/JKSessionScreen.js

    • Add initial track sync on session join
    • Import and use useTrackSync hook
  2. src/hooks/useMixerHelper.js

    • Add track sync calls when mixer changes
    • Debounce sync to avoid spam
  3. src/hooks/useMediaActions.js

    • Add track sync after media toggle
    • Sync when backing track/jam track opened/closed
  4. src/components/client/JKSessionMetronomePlayer.js

    • Add track sync when metronome toggled
  5. src/store/features/activeSessionSlice.js (maybe)

    • Add setTrackSyncStatus action (syncing, success, error)
    • Track last sync timestamp

7. Testing Strategy

Unit Tests

Test file: src/services/trackSyncService.test.js

describe('trackSyncService', () => {
  test('buildTrackSyncPayload constructs correct payload', () => {
    const mockState = {
      activeSession: {
        userTracks: [{ instrument_id: 'guitar', sound: 'stereo' }],
        backingTrackData: [],
        metronome: { isOpen: false }
      }
    };

    const payload = buildTrackSyncPayload(mockState, 'session-123');

    expect(payload.tracks).toHaveLength(1);
    expect(payload.tracks[0].instrument_id).toBe('guitar');
    expect(payload.metronome_open).toBe(false);
  });

  test('syncTracksToServer calls API and updates Redux', async () => {
    // Mock API call
    // Mock dispatch
    // Assert correct flow
  });
});

Integration Tests (Playwright)

Test file: test/track-sync/track-configuration.spec.ts

test.describe('Track Configuration API', () => {
  test('syncs tracks when user joins session', async ({ page }) => {
    const apiInterceptor = new APIInterceptor();
    apiInterceptor.intercept(page);

    await loginToJamUI(page);
    await createAndJoinSession(page);

    // Wait for track sync calls
    await page.waitForTimeout(7000);

    const trackSyncCalls = apiInterceptor.getCallsByPath('/api/sessions/*/tracks');
    const putCalls = trackSyncCalls.filter(c => c.method === 'PUT');

    // Should have at least 1 track sync call
    expect(putCalls.length).toBeGreaterThanOrEqual(1);

    // Validate payload structure
    const firstCall = putCalls[0];
    expect(firstCall.requestBody).toHaveProperty('client_id');
    expect(firstCall.requestBody).toHaveProperty('tracks');
    expect(firstCall.requestBody).toHaveProperty('backing_tracks');
    expect(firstCall.requestBody).toHaveProperty('metronome_open');

    // Validate response
    expect(firstCall.responseStatus).toBe(200);
  });

  test('syncs tracks when metronome toggled', async ({ page }) => {
    // Join session
    // Click metronome toggle
    // Assert PUT /tracks called with metronome_open=true
  });

  test('syncs tracks when backing track opened', async ({ page }) => {
    // Join session
    // Open backing track
    // Assert PUT /tracks called with backing_tracks array
  });
});

Manual Testing Checklist

  • Join session → verify 1-3 PUT /tracks calls in DevTools Network tab
  • Toggle metronome → verify PUT /tracks called with metronome_open=true
  • Open backing track → verify PUT /tracks called with backing_tracks populated
  • Adjust mixer → verify debounced PUT /tracks call
  • Change instrument → verify PUT /tracks called with new instrument_id
  • Network failure → verify error handling (toast/alert shown)
  • Verify other participants see track changes via WebSocket

8. Implementation Steps (Order)

  1. Create trackSyncService.js

    • Implement buildTrackSyncPayload()
    • Implement syncTracksToServer() thunk
    • Add error handling
  2. Create useTrackSync.js hook

    • Wrap syncTracksToServer for component use
    • Add debouncing logic
    • Return { syncTracks, isSyncing, lastSyncTime, syncError }
  3. Add initial sync to JKSessionScreen.js

    • Import useTrackSync
    • Add useEffect to sync on session join (3 calls at 1s, 1.4s, 6s)
    • Test with manual join
  4. Add sync to metronome toggle

    • Modify JKSessionMetronomePlayer.js
    • Call syncTracks() when metronome toggled
    • Test metronome toggle
  5. Add sync to media actions

    • Modify useMediaActions.js
    • Add sync after toggleBackingTrack, toggleJamTrack
    • Test opening/closing media
  6. Add sync to mixer changes (optional, can defer)

    • Modify useMixerHelper.js
    • Debounced sync on mixer adjustments
    • Test mixer slider changes
  7. Write Playwright tests

    • Create test/track-sync/track-configuration.spec.ts
    • Test session join sync
    • Test metronome sync
    • Test media sync
  8. Manual testing and refinement

    • Test all flows in browser
    • Verify WebSocket notifications to other participants
    • Check DevTools for correct API calls
    • Optimize timing and debouncing

9. Edge Cases and Error Handling

Edge Cases

  1. Session not yet joined

    • Don't call sync if sessionId is null
    • Wait for sessionJoined flag
  2. Client ID not available

    • Don't call sync if clientId is null
    • Wait for native client initialization
  3. No tracks to sync

    • Still call API with empty arrays (server needs metronome state)
  4. Rapid consecutive changes

    • Debounce to avoid API spam
    • Cancel pending requests if new one triggered
  5. User leaves session mid-sync

    • Cancel pending requests
    • Clear sync timers

Error Handling

  1. Network failure

    • Show user-friendly error toast
    • Don't update local state
    • Retry with exponential backoff?
  2. 401 Unauthorized

    • Session expired, redirect to login
  3. 404 Session not found

    • Session was deleted, show error and redirect
  4. 422 Validation error

    • Log error, show to user
    • Check payload format

10. Success Criteria

Must Have

  • PUT /api/sessions/{id}/tracks called on session join
  • Payload includes client_id, tracks, backing_tracks, metronome_open
  • Response updates Redux state
  • Metronome toggle triggers sync
  • Backing track open/close triggers sync
  • Errors handled gracefully with user feedback
  • Playwright tests pass for all scenarios

Should Have 📋

  • Debouncing prevents API spam
  • Sync status shown in UI (loading spinner?)
  • Automatic retry on network failure
  • Legacy-like 3-call pattern on session join (for compatibility)

Could Have 💡

  • Optimistic updates (update UI before server confirms)
  • Sync queue for offline resilience
  • Analytics tracking for sync success/failure rates
  • Reduce to single call on join (after verifying works)

11. Rollout Plan

Phase 1: Development

  • Implement in feature branch: feature/track-sync-api
  • Local testing with Rails backend
  • Unit tests passing

Phase 2: Integration Testing

  • Playwright tests passing
  • Manual QA with multiple participants
  • Verify WebSocket notifications work

Phase 3: Staging Deployment

  • Deploy to staging environment
  • Monitor error rates and API performance
  • Test with beta users

Phase 4: Production Rollout

  • Deploy to production
  • Monitor API calls (should see 3 PUT /tracks per session join)
  • Monitor error rates
  • Verify no performance degradation

12. Risks and Mitigations

Risk Impact Mitigation
API spam from debouncing bugs High server load Implement rate limiting, test debouncing thoroughly
Wrong payload format breaks Rails 422 errors, broken sync Validate payload against Rails specs, add schema validation
Sync failures silent Users unaware of issues Add error toasts, log to analytics
Race conditions with WebSocket updates Inconsistent state Implement proper Redux action ordering
Breaking changes to legacy app Backward compatibility issues Test with both legacy and jam-ui simultaneously

13. Questions to Resolve Before Implementation

  1. Should we match legacy's 3-call pattern exactly?

    • Pro: Ensures compatibility
    • Con: Seems redundant, could optimize to 1 call
    • Decision: Start with 3 calls, optimize later
  2. What happens if native client not initialized yet?

    • Need to wait for clientId before calling API
    • Add guard: if (!clientId) return;
  3. Should mixer changes sync immediately or debounced?

    • Debounced (500ms) to avoid spam
    • User gets immediate UI feedback from local state
  4. How to handle tracks from native client?

    • Native client provides track list via jamClient API
    • Need to map native client tracks to API format
    • Check if jamClient.getTracks() method exists
  5. What about instrument selection?

    • Need UI for user to select instrument
    • Sync after instrument change
    • Is this already implemented?

Next Steps:

  1. Review this plan with team
  2. Answer open questions
  3. Get approval on approach
  4. Start implementation with Phase 1 (trackSyncService)

Generated: 2026-01-21 Status: Ready for Implementation