# User Story 007: Clean Record API Implementation ## Story **As a** robotics developer building teleoperation recording systems **I want** to record robot motor positions and control data using a clean `record()` function API **So that** I can capture teleoperation sessions for training AI models, analysis, and replay without dealing with complex class-based APIs or mixed concerns ## Background A community contributor has provided a recording implementation in this PR branch, which includes a comprehensive `LeRobotDatasetRecorder` class with video recording, data export, and LeRobot dataset format support. However, the current implementation violates several of our core conventions and doesn't match the clean API patterns established by `calibrate()`, `teleoperate()`, and `findPort()`. ### Current Implementation Problems The existing `LeRobotDatasetRecorder` implementation has several architectural issues: - **Missing Standard Library Pattern**: Uses class instantiation instead of simple function call like our other APIs - **Library vs Demo Separation Violation**: Mixes hardware recording (library concern) with video streams, export formats, and UI (demo concerns) - **Teleoperator Integration Issues**: Recording logic deeply embedded in `BaseWebTeleoperator` with complex state management - **Complex Constructor Anti-Pattern**: Requires pre-configured teleoperators and video streams, violating our "direct library usage" principle - **Export API Complexity**: ZIP, HuggingFace, and S3 upload belong in demo code, not standard library - **No Clean Process API**: Doesn't follow our consistent `start()/stop()/result` pattern - **Redundant Event System**: Uses `dispatchMotorPositionChanged` events that aren't consumed and duplicate callback functionality - **Artificial Polling**: 100ms polling in teleoperate instead of immediate callbacks when motors change ### Convention Alignment Needed Our established patterns from `calibrate()`, `teleoperate()`, and `findPort()` follow these principles: - **Simple Function API**: `const process = await record(config)` - **Clean Process Objects**: Consistent `start()`, `stop()`, `getState()`, `result` interface - **Hardware-Only Library**: Standard library handles only robotics hardware, not UI/storage/export - **Demo Handles UI**: Examples handle video, export formats, browser storage, file downloads - **Immediate Callbacks**: Real-time updates via callbacks, not polling or unused events - **Direct Usage**: End users call library functions directly without complex setup ## Acceptance Criteria ### Core Functionality - [ ] **Standard Library API**: Clean `record(config)` function matching our established patterns - [ ] **Process Object Interface**: Consistent `RecordProcess` with `start()`, `stop()`, `getState()`, `result` methods - [ ] **Hardware-Only Recording**: Library captures only robot motor positions and teleoperation data - [ ] **Real-Time Callbacks**: Immediate `onDataUpdate` and `onStateUpdate` callbacks, no polling - [ ] **Device-Agnostic**: Works with any robot type through configuration, not hardcoded values - [ ] **Clean Teleoperator Integration**: Recording subscribes to teleoperation changes without embedding in teleoperator classes ### User Experience - [ ] **Simple Integration**: Easy to add recording to existing teleoperation workflows - [ ] **Consistent API**: Same patterns as `calibrate()` and `teleoperate()` for familiar developer experience - [ ] **Immediate Feedback**: Real-time recording state and data updates for responsive UI - [ ] **Error Handling**: Clear error messages for recording failures or invalid configurations - [ ] **Resource Management**: Proper cleanup of recording resources on stop/disconnect ### Technical Requirements - [ ] **Library/Demo Separation**: Move video, export, and storage logic to examples/demo layer - [ ] **Remove Event System**: Eliminate unused `dispatchMotorPositionChanged` events, use callbacks only - [ ] **Extract from Teleoperators**: Remove recording state and logic from `BaseWebTeleoperator` - [ ] **TypeScript**: Fully typed with proper interfaces for recording configuration and data - [ ] **No Code Duplication**: Reuse existing teleoperation and motor communication infrastructure - [ ] **Performance**: Immediate callbacks when data changes, no unnecessary polling ## Expected User Flow ### Basic Robot Recording ```typescript import { record } from "@lerobot/web"; // Clean API matching our conventions const recordProcess = await record({ robot: connectedRobot, options: { fps: 30, onDataUpdate: (data) => { // Real-time recording data for UI feedback console.log(`Recorded ${data.frameCount} frames`); updateRecordingUI(data); }, onStateUpdate: (state) => { // Recording state changes console.log(`Recording: ${state.isActive}`); updateRecordingStatus(state); }, }, }); // Consistent process interface recordProcess.start(); // Recording runs automatically while teleoperation is active setTimeout(() => { recordProcess.stop(); }, 30000); // Get pure robot recording data const robotData = await recordProcess.result; console.log("Episodes:", robotData.episodes); console.log("Metadata:", robotData.metadata); ``` ### Recording with Teleoperation ```typescript import { teleoperate, record } from "@lerobot/web"; // Start teleoperation const teleoperationProcess = await teleoperate({ robot: connectedRobot, teleop: { type: "keyboard" }, calibrationData: calibrationData, onStateUpdate: (state) => { updateTeleoperationUI(state); }, }); // Add recording to existing teleoperation const recordProcess = await record({ robot: connectedRobot, options: { onDataUpdate: (data) => { console.log(`Recording frame ${data.frameCount}`); }, }, }); // Both run independently teleoperationProcess.start(); recordProcess.start(); // Control independently setTimeout(() => { recordProcess.stop(); // Stop recording, keep teleoperation }, 60000); setTimeout(() => { teleoperationProcess.stop(); // Stop teleoperation }, 120000); ``` ### Demo-Layer Dataset Export ```typescript // In examples/demo - NOT in standard library import { record } from "@lerobot/web"; import { DatasetExporter } from "./dataset-exporter"; // Demo code const recordProcess = await record({ robot, options }); recordProcess.start(); // ... recording session ... recordProcess.stop(); const robotData = await recordProcess.result; // Demo handles complex export logic const exporter = new DatasetExporter({ robotData, videoStreams: cameraStreams, // Demo manages video taskDescription: "Pick and place task", }); // Export options handled by demo await exporter.downloadZip(); await exporter.uploadToHuggingFace({ apiKey, repoName }); await exporter.uploadToS3({ credentials }); ``` ### Component Integration ```typescript // React component - direct library usage like calibration const [recordingState, setRecordingState] = useState(); const [recordingData, setRecordingData] = useState(); const recordProcessRef = useRef(null); useEffect(() => { const initRecording = async () => { const process = await record({ robot, options: { onStateUpdate: setRecordingState, onDataUpdate: setRecordingData, }, }); recordProcessRef.current = process; }; initRecording(); }, [robot]); const handleStartRecording = () => { recordProcessRef.current?.start(); }; const handleStopRecording = async () => { recordProcessRef.current?.stop(); const data = await recordProcessRef.current?.result; // Handle recorded data }; ``` ## Implementation Details ### File Structure Refactoring ``` packages/web/src/ ├── record.ts # NEW: Clean record() function ├── types/ │ └── recording.ts # NEW: Recording-specific types ├── utils/ │ └── recording-manager.ts # NEW: Internal recording logic ├── teleoperators/ │ └── base-teleoperator.ts # UPDATED: Remove recording logic └── [MOVED TO EXAMPLES] ├── LeRobotDatasetRecorder.ts # Complex export logic ├── dataset-exporter.ts # Video + export functionality └── upload-handlers.ts # HuggingFace, S3 upload logic ``` ### Key Dependencies #### No New Dependencies for Standard Library - **Existing**: Reuse all current dependencies (motor communication, teleoperation integration) - **Architecture Only**: Pure refactoring to clean up existing functionality #### Demo Dependencies (Moved) - **Video/Export**: `parquet-wasm`, `apache-arrow`, `jszip` - moved to examples - **Upload**: `@huggingface/hub`, AWS SDK - moved to examples ### Core Functions to Implement #### Clean Record API ```typescript // record.ts - New clean API interface RecordConfig { robot: RobotConnection; options?: { fps?: number; // Default: 30 onDataUpdate?: (data: RecordingData) => void; onStateUpdate?: (state: RecordingState) => void; }; } interface RecordProcess { start(): void; stop(): void; getState(): RecordingState; result: Promise; } interface RecordingState { isActive: boolean; frameCount: number; episodeCount: number; duration: number; // milliseconds lastUpdate: number; } interface RecordingData { frameCount: number; currentEpisode: number; recentFrames: MotorPositionFrame[]; // Last few frames for UI } interface RobotRecordingData { episodes: MotorPositionFrame[][]; // Pure motor data only metadata: { fps: number; robotType: string; startTime: number; endTime: number; totalFrames: number; totalEpisodes: number; }; } // Main function - matches our conventions export async function record(config: RecordConfig): Promise; ``` #### Recording Manager (Internal) ```typescript // utils/recording-manager.ts - Internal implementation class RecordingManager { private robot: RobotConnection; private isActive: boolean = false; private episodes: MotorPositionFrame[][] = []; private currentEpisode: MotorPositionFrame[] = []; private startTime: number = 0; private frameCount: number = 0; constructor( robot: RobotConnection, private options: RecordOptions, private onDataUpdate?: (data: RecordingData) => void, private onStateUpdate?: (state: RecordingState) => void ) { this.robot = robot; } start(): void { if (this.isActive) return; this.isActive = true; this.startTime = Date.now(); // Subscribe to teleoperation changes (NO events, just callbacks) this.subscribeToRobotChanges(); this.notifyStateUpdate(); } stop(): void { if (!this.isActive) return; this.isActive = false; this.finishCurrentEpisode(); this.unsubscribeFromRobotChanges(); this.notifyStateUpdate(); } private subscribeToRobotChanges(): void { // Listen to existing teleoperation callbacks - no new events needed // This integrates with the existing onStateUpdate mechanism } private recordFrame(motorConfigs: MotorConfig[]): void { const frame: MotorPositionFrame = { timestamp: Date.now() - this.startTime, motorPositions: motorConfigs.map((config) => ({ id: config.id, name: config.name, position: config.currentPosition, })), frameIndex: this.frameCount++, }; this.currentEpisode.push(frame); if (this.onDataUpdate) { this.onDataUpdate({ frameCount: this.frameCount, currentEpisode: this.episodes.length, recentFrames: this.currentEpisode.slice(-10), // Last 10 frames }); } } getState(): RecordingState { return { isActive: this.isActive, frameCount: this.frameCount, episodeCount: this.episodes.length, duration: this.isActive ? Date.now() - this.startTime : 0, lastUpdate: Date.now(), }; } async getResult(): Promise { return { episodes: [...this.episodes], metadata: { fps: this.options.fps || 30, robotType: this.robot.robotType || "unknown", startTime: this.startTime, endTime: Date.now(), totalFrames: this.frameCount, totalEpisodes: this.episodes.length, }, }; } } ``` #### Updated Teleoperate Integration ```typescript // teleoperate.ts - Remove 100ms polling, add immediate callbacks export async function teleoperate( config: TeleoperateConfig ): Promise { const teleoperator = await createTeleoperatorProcess(config); return { start: () => { teleoperator.start(); // NO MORE 100ms polling! Use immediate callbacks if (config.onStateUpdate) { teleoperator.setStateUpdateCallback(config.onStateUpdate); } }, // ... rest of interface }; } ``` #### Clean Teleoperator Base ```typescript // teleoperators/base-teleoperator.ts - Remove recording logic export abstract class BaseWebTeleoperator extends WebTeleoperator { protected port: MotorCommunicationPort; public motorConfigs: MotorConfig[] = []; protected isActive: boolean = false; // REMOVED: All recording-related properties // REMOVED: dispatchMotorPositionChanged events // REMOVED: recordedMotorPositions, episodeIndex, etc. private stateUpdateCallback?: (state: TeleoperationState) => void; setStateUpdateCallback(callback: (state: TeleoperationState) => void): void { this.stateUpdateCallback = callback; } protected motorPositionsChanged(): void { // Call immediately when motors change - no events, no 100ms delay if (this.stateUpdateCallback) { const state = this.buildCurrentState(); this.stateUpdateCallback(state); } } // Clean implementation without recording concerns } ``` ### Technical Considerations #### Migration Strategy **Preserve Existing Functionality:** 1. **Move Complex Logic**: `LeRobotDatasetRecorder` moves to `examples/` as demo code 2. **Extract Clean Core**: Create new `record()` function for standard library 3. **Update Examples**: Cyberpunk demo uses new API with demo-layer export functionality 4. **Remove Event System**: Clean up unused `dispatchMotorPositionChanged` events 5. **Fix Polling**: Replace 100ms polling with immediate callbacks #### Performance Improvements - **Remove Polling**: Eliminate artificial 100ms delays in favor of immediate callbacks - **Event-Driven**: Only fire callbacks when robot state actually changes - **Memory Efficiency**: No unused event listeners or redundant data structures - **Responsive UI**: Immediate feedback for recording status and data updates #### Future Extensibility The clean architecture supports advanced recording features as demo enhancements: ```typescript // Future: Advanced demo features (NOT in standard library) class AdvancedDatasetExporter extends DatasetExporter { // Video synchronization, multi-camera support // Cloud storage, data preprocessing // Visualization, playback, analysis tools } ``` ## Definition of Done - [ ] **Clean Record API**: `record(config)` function implemented matching our established patterns - [ ] **Process Interface**: `RecordProcess` with consistent `start()`, `stop()`, `getState()`, `result` methods - [ ] **Hardware-Only Library**: Standard library captures only robot motor data, no video/export complexity - [ ] **Demo Separation**: Video recording, export formats, and UI logic moved to examples layer - [ ] **Remove Events**: `dispatchMotorPositionChanged` events eliminated, callbacks used exclusively - [ ] **Fix Polling**: 100ms artificial polling replaced with immediate callbacks when motors change - [ ] **Clean Teleoperators**: Recording logic extracted from `BaseWebTeleoperator` and teleoperator classes - [ ] **TypeScript Coverage**: Full type safety with proper interfaces for all recording functionality - [ ] **Performance**: Immediate, event-driven updates with no unnecessary polling or unused listeners - [ ] **Integration**: Easy integration with existing teleoperation workflows using familiar patterns - [ ] **Example Updates**: Cyberpunk demo updated to use new clean API with demo-layer export features - [ ] **No Regression**: All existing recording functionality preserved through demo layer - [ ] **Documentation**: Clear examples showing standard library vs demo separation