diff --git a/src/components/PlotToolsOverlay.tsx b/src/components/PlotToolsOverlay.tsx index 4157135..2d758ea 100644 --- a/src/components/PlotToolsOverlay.tsx +++ b/src/components/PlotToolsOverlay.tsx @@ -56,12 +56,12 @@ const PlotToolsOverlay = forwardRef(function PlotToolsOve {/* CSV Export Button with Dropdown */}
- + +
)} diff --git a/src/utils/__tests__/chartExport.test.ts b/src/utils/__tests__/chartExport.test.ts index 89a6e00..e33e7c3 100644 --- a/src/utils/__tests__/chartExport.test.ts +++ b/src/utils/__tests__/chartExport.test.ts @@ -70,7 +70,8 @@ describe('chartExport', () => { const result = exportVisibleChartDataAsCsv(mockViewPortData, { scope: 'visible', includeTimestamps: true, - timeFormat: 'iso' + timeFormat: 'iso', + format: 'csv' }) const lines = result.split('\n') @@ -85,7 +86,8 @@ describe('chartExport', () => { const result = exportVisibleChartDataAsCsv(mockViewPortData, { scope: 'visible', includeTimestamps: true, - timeFormat: 'relative' + timeFormat: 'relative', + format: 'csv' }) const lines = result.split('\n') @@ -99,7 +101,8 @@ describe('chartExport', () => { it('should export data without timestamps', () => { const result = exportVisibleChartDataAsCsv(mockViewPortData, { scope: 'visible', - includeTimestamps: false + includeTimestamps: false, + format: 'csv' }) const lines = result.split('\n') @@ -126,7 +129,8 @@ describe('chartExport', () => { const result = exportAllChartDataAsCsv(mockStore, { scope: 'all', includeTimestamps: true, - timeFormat: 'iso' + timeFormat: 'iso', + format: 'csv' }) const lines = result.split('\n') @@ -178,14 +182,14 @@ describe('chartExport', () => { }) it('should export visible data and trigger download', () => { - exportChartData(mockViewPortData, mockStore, { scope: 'visible', includeTimestamps: true }) + exportChartData(mockViewPortData, mockStore, { scope: 'visible', includeTimestamps: true, format: 'csv' }) expect(document.createElement).toHaveBeenCalledWith('a') expect(document.body.appendChild).toHaveBeenCalled() }) it('should export all data and trigger download', () => { - exportChartData(mockViewPortData, mockStore, { scope: 'all', includeTimestamps: true }) + exportChartData(mockViewPortData, mockStore, { scope: 'all', includeTimestamps: true, format: 'csv' }) expect(document.createElement).toHaveBeenCalledWith('a') expect(document.body.appendChild).toHaveBeenCalled() diff --git a/src/utils/chartExport.ts b/src/utils/chartExport.ts index 6993927..209664c 100644 --- a/src/utils/chartExport.ts +++ b/src/utils/chartExport.ts @@ -7,6 +7,7 @@ export interface ChartExportOptions { scope: ChartExportScope includeTimestamps?: boolean timeFormat?: 'iso' | 'relative' | 'timestamp' + format: 'csv' | 'wav' } export function formatChartTimestamp(timestamp: number, format: 'iso' | 'relative' | 'timestamp', baseTime?: number): string { @@ -26,7 +27,7 @@ export function formatChartTimestamp(timestamp: number, format: 'iso' | 'relativ export function exportVisibleChartDataAsCsv( snapshot: ViewPortData, - options: ChartExportOptions = { scope: 'visible', includeTimestamps: true, timeFormat: 'iso' } + options: ChartExportOptions = { scope: 'visible', includeTimestamps: true, timeFormat: 'iso', format: 'csv' } ): string { const { series, getTimes, getSeriesData, firstTimestamp } = snapshot const times = getTimes() @@ -73,7 +74,7 @@ export function exportVisibleChartDataAsCsv( export function exportAllChartDataAsCsv( store: RingStore, - options: ChartExportOptions = { scope: 'all', includeTimestamps: true, timeFormat: 'iso' } + options: ChartExportOptions = { scope: 'all', includeTimestamps: true, timeFormat: 'iso', format: 'csv' } ): string { const series = store.getSeries() @@ -133,6 +134,97 @@ export function exportAllChartDataAsCsv( return csvLines.join('\n') } + +export function exportAllChartDataAsWav( + store: RingStore +): Uint8Array { + const series = store.getSeries() + if (series.length === 0) { + throw new Error('No data available') + } + + const capacity = store.getCapacity() + const writeIndex = store.writeIndex + const totalSamples = Math.min(writeIndex, capacity) + + if (totalSamples === 0) { + throw new Error('No data available') + } + + const numChannels = series.length + const sampleRate = 8000 // TODO: Let the user choose the sample rate in a modal before the file is exported + + // Determine the range of valid data + const startIndex = writeIndex > capacity ? writeIndex - capacity : 0 + const endIndex = writeIndex - 1 + const numFrames = endIndex - startIndex + 1 + + // Prepare interleaved float32 buffer + const interleaved = new Float32Array(numFrames * numChannels) + let ptr = 0 + + for (let i = startIndex; i <= endIndex; i++) { + const ringIndex = i % capacity + for (let ch = 0; ch < numChannels; ch++) { + const value = store.buffers[ch][ringIndex] + interleaved[ptr++] = Number.isFinite(value) ? value : 0 + } + } + + // WAV file construction + const bytesPerSample = 4 + const blockAlign = numChannels * bytesPerSample + const byteRate = sampleRate * blockAlign + const dataSize = interleaved.length * bytesPerSample + const buffer = new ArrayBuffer(44 + dataSize) + const view = new DataView(buffer) + let offset = 0 + + function writeString(str: string) { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset++, str.charCodeAt(i)) + } + } + + function writeUint32(val: number) { + view.setUint32(offset, val, true) + offset += 4 + } + + function writeUint16(val: number) { + view.setUint16(offset, val, true) + offset += 2 + } + + // RIFF header + writeString('RIFF') + writeUint32(36 + dataSize) // file size minus 8 bytes + writeString('WAVE') + + // fmt subchunk + writeString('fmt ') + writeUint32(16) // Subchunk1Size + writeUint16(3) // Audio format 3 = IEEE float + writeUint16(numChannels) + writeUint32(sampleRate) + writeUint32(byteRate) + writeUint16(blockAlign) + writeUint16(bytesPerSample * 8) // bits per sample + + // data subchunk + writeString('data') + writeUint32(dataSize) + + // Write interleaved float32 samples + for (let i = 0; i < interleaved.length; i++) { + view.setFloat32(offset, interleaved[i], true) + offset += 4 + } + + return new Uint8Array(buffer) +} + + export function exportChartData( snapshot: ViewPortData, store: RingStore, @@ -140,15 +232,29 @@ export function exportChartData( ) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) const scopeLabel = options.scope === 'visible' ? 'visible' : 'all' - const filename = `chart-data-${scopeLabel}-${timestamp}.csv` - let csvContent: string - - if (options.scope === 'visible') { - csvContent = exportVisibleChartDataAsCsv(snapshot, options) - } else { - csvContent = exportAllChartDataAsCsv(store, options) + if (options.format == 'csv') { + const filename = `chart-data-${scopeLabel}-${timestamp}.csv` + + let csvContent: string + + if (options.scope === 'visible') { + csvContent = exportVisibleChartDataAsCsv(snapshot, options) + } else { + csvContent = exportAllChartDataAsCsv(store, options) + } + + downloadFile(csvContent, filename, 'text/csv') + } else if (options.format == 'wav') { + const filename = `chart-data-${scopeLabel}-${timestamp}_LOUD.wav` + + let wavContent: Uint8Array + if (options.scope === 'visible') { + throw new Error('`visible` scope is not supported for WAV export'); + } else { + wavContent = exportAllChartDataAsWav(store) + } + + downloadFile(wavContent, filename, 'audio/wav') } - - downloadFile(csvContent, filename, 'text/csv') } \ No newline at end of file diff --git a/src/utils/consoleExport.ts b/src/utils/consoleExport.ts index 0de6d9e..19cd7fd 100644 --- a/src/utils/consoleExport.ts +++ b/src/utils/consoleExport.ts @@ -44,7 +44,7 @@ export function exportMessagesAsJson(messages: ConsoleMessage[]): string { return JSON.stringify(exportData, null, 2) } -export function downloadFile(content: string, filename: string, mimeType: string) { +export function downloadFile(content: string | Uint8Array, filename: string, mimeType: string) { const blob = new Blob([content], { type: mimeType }) const url = URL.createObjectURL(blob)