Skip to content

Support midi clock messages #61

@mickmister

Description

@mickmister

Apps written for recording midi can lack context of tempo without recording of an available midi clock. The task here is to make it easy to record to a midi file including the midi clock information, so the tempo is captured within the midi file. See this consumer code for context:

services/recorder.ts

Details
import {writeMidi, MidiData} from 'midi-file';
import {Buffer} from 'buffer';
import {Subject} from 'rxjs';

import {MidiEventFull} from '@jamtools/core/modules/macro_module/macro_module_types';
import {StateSupervisor} from 'springboard/services/states/shared_state_service';

const sendPushNotification = (data: {title: string, data: {url: string}}) => {

};

// const FIVE_SECONDS = 1000 * 5;
const TICKS_PER_BEAT = 480; // Standard MIDI timing resolution
const BPM = 120; // Default BPM

type LoggedMidiEvent = {
    event: MidiEventFull;
    time: number;
};

type Logger = {
    log: (msg: string) => void;
};

export type FileSaver = {
    writeFile: (fileName: string, buffer: Buffer) => void | Promise<void>;
}

export type RecordingConfig = {
    inactivityTimeLimitSeconds: number;
    uploaderUrl: string;
}

export class MidiRecorderImpl {
    private deviceActivity: {[deviceName: string]: boolean} = {};
    private deviceTimeouts: {[deviceName: string]: NodeJS.Timeout | undefined} = {};
    private recordedEvents: {[deviceName: string]: LoggedMidiEvent[]} = {};
    // private INACTIVITY_LIMIT = FIVE_SECONDS;

    constructor(private onInputEvent: Subject<MidiEventFull>, private logger: Logger, private fileSaver: FileSaver, private recordingConfigState: StateSupervisor<RecordingConfig>) { }

    private formatDeviceName(deviceName: string): string {
        // Truncate long device names and remove common suffixes
        const cleaned = deviceName.replace(/ Air Bluetooth$/, '').replace(/ Bluetooth$/, '');
        return cleaned.length > 20 ? cleaned.substring(0, 17) + '...' : cleaned;
    }

    private formatFilePath(filePath: string): string {
        // Show just the filename without the full path
        const fileName = filePath.split('/').pop() || filePath;
        return fileName.length > 30 ? '...' + fileName.substring(fileName.length - 27) : fileName;
    }

    public initialize = () => {
        this.onInputEvent.subscribe(this.handleMidiEvent);
    };

    private handleMidiEvent = (midiEventFull: MidiEventFull) => {
        const deviceName = midiEventFull.deviceInfo.name;
        const event = midiEventFull.event;
        const time = performance.now();

        this.deviceActivity[deviceName] = true;

        // Store the event in memory
        if (!this.recordedEvents[deviceName]?.length) {
            this.logger.log(`Started recording ${this.formatDeviceName(deviceName)}`);
            this.recordedEvents[deviceName] = [];
            this.notifyUserOfStartRecording();
        }
        this.recordedEvents[deviceName].push({event: midiEventFull, time});

        this.resetDeviceInactivityTimerForDevice(deviceName);
    };

    // Stop recording and save all recorded MIDI events to a file
    private stopRecordingForAllDevices = () => {
        this.logger.log('Stopping recordings due to inactivity');
        Object.keys(this.recordedEvents).forEach((deviceName) => {
            this.saveRecordedMidiToFile(deviceName);

            // Clear events after saving
            this.recordedEvents[deviceName] = [];
        });
    };

    private getInactivityLimit = () => {
        return this.recordingConfigState.getState().inactivityTimeLimitSeconds * 1000;
    }

    private resetDeviceInactivityTimerForDevice = (deviceName: string) => {
        if (this.deviceTimeouts[deviceName]) {
            clearTimeout(this.deviceTimeouts[deviceName]);
        }

        this.deviceTimeouts[deviceName] = setTimeout(() => {
            this.logger.log(`${this.formatDeviceName(deviceName)} inactive`);
            this.deviceActivity[deviceName] = false;

            const allInactive = Object.values(this.deviceActivity).every(isActive => !isActive);
            if (allInactive) {
                this.stopRecordingForAllDevices();
            }
        }, this.getInactivityLimit());
    };

    private generateFilename = (deviceName: string): string => {
        const timestamp = new Date().toISOString();
        const filename = `${deviceName}_${timestamp}_recording.mid`;
        return filename;
    };

    private saveRecordedMidiToFile = (deviceName: string) => {
        const midiEvents = this.recordedEvents[deviceName];
        if (!midiEvents || midiEvents.length === 0) {
            this.logger.log(`No events recorded for device: ${deviceName}`);
            return;
        }

        // Convert the stored events to a MIDI file structure
        const midiData: MidiData = {
            header: {
                format: 1,
                numTracks: 1,
                ticksPerBeat: TICKS_PER_BEAT,
            },
            tracks: [[]],
        };

        let previousTime = midiEvents[0].time; // Set the initial time
        midiEvents.forEach(({event, time}) => {
            const deltaTime = this.calculateDeltaTime(previousTime, time);
            previousTime = time;

            const midiTrackEvent = this.convertMidiEventToMidiFileFormat(event, deltaTime);

            if (midiTrackEvent) {
                midiData.tracks[0].push(midiTrackEvent);
            }
        });

        const midiFilePath = this.generateFilename(deviceName);

        // Write the MIDI file to disk
        try {
            const outputBuffer = Buffer.from(writeMidi(midiData));
            this.fileSaver.writeFile(midiFilePath, outputBuffer);
            this.logger.log(`MIDI saved: ${this.formatFilePath(midiFilePath)}`);
            this.notifyUserOfNewRecordedSession();
        } catch (error) {
            this.logger.log(`Error while saving MIDI file for ${deviceName}: ${(error as Error).message}`);
        }
    };

    private notifyUserOfStartRecording = () => {
        sendPushNotification({
            title: 'Started recording',
            data: {
                url: 'http://jamscribe.local:1337',
            }
        });
    };

    private notifyUserOfNewRecordedSession = () => {
        sendPushNotification({
            title: 'Stopped recording',
            data: {
                url: 'http://jamscribe.local:1337',
            }
        });
    };

    // Convert the event to a format that `midi-file` expects
    private convertMidiEventToMidiFileFormat = (event: MidiEventFull, deltaTime: number): MidiData['tracks'][0][0] | null => {
        if (event.event.type === 'noteon') {
            return {
                deltaTime,
                type: 'noteOn',
                noteNumber: event.event.number,
                velocity: event.event.velocity || 64,
                channel: event.event.channel,
            };
        }
        if (event.event.type === 'noteoff') {
            return {
                deltaTime,
                type: 'noteOff',
                noteNumber: event.event.number,
                velocity: 0,
                channel: event.event.channel,
            };
        }
        if (event.event.type === 'cc') {
            return {
                deltaTime,
                type: 'controller',
                controllerType: event.event.number,
                value: event.event.value!,
                channel: event.event.channel,
            };
        }

        return null;
    };

    private calculateDeltaTime = (previousTime: number, currentTime: number): number => {
        const msPerBeat = (60 / BPM) * 1000;
        const msDifference = currentTime - previousTime;
        return Math.round((msDifference / msPerBeat) * TICKS_PER_BEAT);
    };
}

index.tsx

Details
import React, {useState} from 'react';

import springboard from 'springboard';

import '@jamtools/core/modules/io/io_module';
import 'springboard/modules/files/files_module';

import type {FileSaver, RecordingConfig} from './services/recorder';

// @platform "node"
import {uploadFile, uploadFileFromPath} from './services/upload_service';
// @platform end

let fileSaver: FileSaver | undefined;

// @platform "node"
import fs from 'node:fs';
// fileSaver will be set inside the module after recordingConfig is available
// @platform end

import {MidiRecorderImpl} from './services/recorder';
import {ConfigModal} from './components/ConfigModal';
import {MidiDevices} from './components/MidiDevices';
import './styles.css';

type DraftedFile = {
    name: string;
    buffer: Buffer;
}

type PendingUpload = {
    id: string;
    fileName: string;
    filePath: string;
    contentType: string;
    attempts: number;
    lastAttemptTime: number;
    error?: string;
};

const initialRecordingConfig: RecordingConfig = {
    inactivityTimeLimitSeconds: 60,
    uploaderUrl: '',
};

springboard.registerModule('JamScribe', {}, async (moduleAPI) => {
    if (moduleAPI.deps.core.isMaestro()) {
        await moduleAPI.getModule('io').ensureListening();
    }

    const recordingConfig = await moduleAPI.statesAPI.createPersistentState('recordingConfig', initialRecordingConfig);
    const draftRecordingConfig = await moduleAPI.statesAPI.createSharedState('draftRecordingConfig', recordingConfig.getState());
    const pendingUploads = await moduleAPI.statesAPI.createPersistentState<PendingUpload[]>('pendingUploads', []);

    // @platform "node"
    fileSaver = {
        writeFile: async (fileName, buffer) => {
            if (!fs.existsSync('./midi_files')) {
                fs.mkdirSync('midi_files')
            }

            const filePath = `./midi_files/${fileName}`;
            await fs.promises.writeFile(filePath, buffer);

            try {
                await uploadFile(fileName, 'audio/midi', buffer, recordingConfig.getState().uploaderUrl);
            } catch (error) {
                console.error('Upload failed, queuing for retry:', error);

                // Add to pending uploads queue
                const uploadId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
                pendingUploads.setState(uploads => [
                    ...uploads,
                    {
                        id: uploadId,
                        fileName,
                        filePath,
                        contentType: 'audio/midi',
                        attempts: 1,
                        lastAttemptTime: Date.now(),
                        error: error instanceof Error ? error.message : String(error),
                    },
                ]);
            }
        },
    };

    // Retry mechanism with exponential backoff
    const retryPendingUploads = async () => {
        const uploads = pendingUploads.getState();
        const now = Date.now();
        const MAX_ATTEMPTS = 10;

        for (const upload of uploads) {
            // Calculate exponential backoff: 1min, 2min, 4min, 8min, 16min, etc.
            const backoffMinutes = Math.pow(2, upload.attempts - 1);
            const backoffMs = backoffMinutes * 60 * 1000;
            const nextAttemptTime = upload.lastAttemptTime + backoffMs;

            // Skip if not time yet or max attempts reached
            if (now < nextAttemptTime || upload.attempts >= MAX_ATTEMPTS) {
                continue;
            }

            try {
                const uploaderUrl = recordingConfig.getState().uploaderUrl;
                if (!uploaderUrl) {
                    continue;
                }

                await uploadFileFromPath(upload.fileName, upload.contentType, upload.filePath, uploaderUrl);

                // Success! Remove from pending uploads
                pendingUploads.setState(uploads => uploads.filter(u => u.id !== upload.id));
                console.log(`Successfully uploaded ${upload.fileName} after ${upload.attempts} attempts`);
            } catch (error) {
                // Update attempt count and error
                pendingUploads.setState(uploads =>
                    uploads.map(u =>
                        u.id === upload.id
                            ? {
                                ...u,
                                attempts: u.attempts + 1,
                                lastAttemptTime: now,
                                error: error instanceof Error ? error.message : String(error),
                            }
                            : u
                    )
                );
                console.error(`Upload retry ${upload.attempts + 1} failed for ${upload.fileName}:`, error);

                // Remove from queue if max attempts reached
                if (upload.attempts + 1 >= MAX_ATTEMPTS) {
                    console.error(`Max retry attempts reached for ${upload.fileName}, removing from queue`);
                    pendingUploads.setState(uploads => uploads.filter(u => u.id !== upload.id));
                }
            }
        }
    };

    // Check for pending uploads every minute
    const retryInterval = setInterval(() => {
        retryPendingUploads().catch(err => {
            console.error('Error in retry mechanism:', err);
        });
    }, 60 * 1000);

    // Try to upload any pending uploads from previous sessions on startup
    setTimeout(() => {
        retryPendingUploads().catch(err => {
            console.error('Error in initial retry attempt:', err);
        });
    }, 5000); // Wait 5 seconds after startup
    // @platform end

    const logMessages = await moduleAPI.statesAPI.createSharedState<LogMessage[]>('logMessages', []);
    const draftedFiles = await moduleAPI.statesAPI.createSharedState<DraftedFile[]>('draftedFiles', []);

    const actions = moduleAPI.createActions({
        changeDraftInactivityTimeLimit: async ({limit}: {limit: number}) => {
            draftRecordingConfig.setState(c => ({...c, inactivityTimeLimitSeconds: limit}));
        },
        submitInactivityTimeLimit: async () => {
            recordingConfig.setState(c => ({...c, inactivityTimeLimitSeconds: draftRecordingConfig.getState().inactivityTimeLimitSeconds}));
        },
        changeDraftUploaderUrl: async ({url}: {url: string}) => {
            draftRecordingConfig.setState(c => ({...c, uploaderUrl: url}));
        },
        submitUploaderUrl: async () => {
            recordingConfig.setState(c => ({...c, uploaderUrl: draftRecordingConfig.getState().uploaderUrl}));
        },
    });

    moduleAPI.registerRoute('/', {}, () => (
        <Main
            logs={logMessages.useState()}
            availableFiles={draftedFiles.useState()}
            recordingConfig={recordingConfig.useState()}

            draftInactivityTimeLimit={draftRecordingConfig.useState().inactivityTimeLimitSeconds}
            onDraftInactivityTimeLimitChange={(limit: number) => actions.changeDraftInactivityTimeLimit({limit})}
            submitInactivityTimeLimitChange={() => actions.submitInactivityTimeLimit()}

            draftUploaderUrl={draftRecordingConfig.useState().uploaderUrl}
            onDraftUploaderUrlChange={(url: string) => actions.changeDraftUploaderUrl({url})}
            submitUploaderUrlChange={() => actions.submitUploaderUrl()}
        />
    ));

    // bail out if this is a presentation-only client
    if (!moduleAPI.deps.core.isMaestro()) {
        return;
    }

    // default implementation of file saver
    if (!fileSaver) {
        fileSaver = {
            writeFile: async (fileName, buffer) => {
                const filesModule = moduleAPI.deps.module.moduleRegistry.getModule('Files');
                const file = new File([
                    new Blob([buffer.toString()])
                ], fileName);

                filesModule.uploadFile(file);
            },
        };
    }

    const log = (msg: string) => {
        console.log(msg);
        logMessages.setState(logs => {
            return [...logs, { message: msg, timestamp: new Date(), id: Math.random().toString().slice(2) }]
        });
    }

    const ioModule = moduleAPI.deps.module.moduleRegistry.getModule('io');

    ioModule.midiDeviceStatusSubject.subscribe(device => {
        const msg = `Device '${device.name}' ${device.status}`;
        log(msg);
    });

    const recorder = new MidiRecorderImpl(ioModule.midiInputSubject, {log}, fileSaver, recordingConfig);
    recorder.initialize();
});

type LogMessage = {
    id: string;
    message: string;
    timestamp: Date;
};

type MainProps = {
    logs: LogMessage[];
    availableFiles: DraftedFile[];

    recordingConfig: RecordingConfig;

    draftInactivityTimeLimit: number;
    onDraftInactivityTimeLimitChange: (newLimit: number) => void;
    submitInactivityTimeLimitChange: () => void;

    draftUploaderUrl: string;
    onDraftUploaderUrlChange: (newUrl: string) => void;
    submitUploaderUrlChange: () => void;
}

const Main = ({
    logs,
    availableFiles,
    recordingConfig,
    draftInactivityTimeLimit,
    onDraftInactivityTimeLimitChange,
    submitInactivityTimeLimitChange,
    draftUploaderUrl,
    onDraftUploaderUrlChange,
    submitUploaderUrlChange
}: MainProps) => {
    const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);

    return (
        <div className="app-container">
            <header className="app-header">
                <h1 className="app-title">🎵 JamScribe</h1>
                <button
                    type='button'
                    className='btn-primary'
                    onClick={() => setIsConfigModalOpen(true)}
                >
                    ⚙️ Settings
                </button>
            </header>

            <ConfigModal
                isOpen={isConfigModalOpen}
                onClose={() => setIsConfigModalOpen(false)}
                recordingConfig={recordingConfig}
                draftInactivityTimeLimit={draftInactivityTimeLimit}
                onDraftInactivityTimeLimitChange={onDraftInactivityTimeLimitChange}
                submitInactivityTimeLimitChange={submitInactivityTimeLimitChange}
                draftUploaderUrl={draftUploaderUrl}
                onDraftUploaderUrlChange={onDraftUploaderUrlChange}
                submitUploaderUrlChange={submitUploaderUrlChange}
            />

            <div className="main-grid">
                <div>
                    <MidiDevices />

                    <div className="card">
                        <div className="card-header">
                            <h2 className="card-title">📁 Recorded Files</h2>
                        </div>
                        {availableFiles.length > 0 ? (
                            <div className="files-grid">
                                {availableFiles.map(file => (
                                    <div
                                        key={file.name}
                                        className="file-item fade-in"
                                        onClick={() => {
                                            // Handle file click
                                        }}
                                    >
                                        <div className="file-icon">🎼</div>
                                        <div className="file-name">{file.name}</div>
                                    </div>
                                ))}
                            </div>
                        ) : (
                            <div className="empty-state">
                                <div className="empty-state-icon">📭</div>
                                <p className="text-muted">No recordings yet. Start playing to record MIDI!</p>
                            </div>
                        )}
                    </div>
                </div>

                <div className="card logs-card">
                    <div className="card-header">
                        <h2 className="card-title">📋 Activity Log</h2>
                    </div>
                    <ul className="log-list">
                        {logs.length > 0 ? (
                            [...logs].reverse().map((logEntry) => {
                                const formatTime = (date: Date | string | number) => {
                                    const dateObj = new Date(date);
                                    const now = new Date();
                                    const isToday = dateObj.toDateString() === now.toDateString();
                                    const timeStr = dateObj.toLocaleTimeString('en-US', {
                                        hour: 'numeric',
                                        minute: '2-digit',
                                        hour12: true
                                    });

                                    if (isToday) {
                                        return timeStr;
                                    } else {
                                        const dateStr = dateObj.toLocaleDateString('en-US', {
                                            month: 'short',
                                            day: 'numeric'
                                        });
                                        return `${dateStr} ${timeStr}`;
                                    }
                                };

                                return (
                                    <li key={logEntry.id} className='log-item fade-in'>
                                        <span className="log-timestamp">{formatTime(logEntry.timestamp)}</span>
                                        <span className="log-message">{logEntry.message}</span>
                                    </li>
                                );
                            })
                        ) : (
                            <li className="log-item text-muted">
                                Waiting for activity...
                            </li>
                        )}
                    </ul>
                </div>
            </div>
        </div>
    );
}

@claude Please implement an easy way for apps written with jamtools to write midi files based on input as shown above, and include midi clock analysis to allow for usage in the midi file. Midi clocks communicate 96 pusles/ticks per quarter note. Look at the io_module in this repo for context on midi instrument interaction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions