Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/components/PlotToolsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ const PlotToolsOverlay = forwardRef<HTMLDivElement, Props>(function PlotToolsOve

{/* CSV Export Button with Dropdown */}
<div className="relative" ref={exportMenuRef}>
<Tooltip label="Export CSV">
<Tooltip label="Export">
<Button
id="tour-tool-export"
size="sm"
variant="neutral"
aria-label="Export CSV"
aria-label="Export"
disabled={!hasData}
onClick={() => setShowExportMenu(!showExportMenu)}
>
Expand All @@ -74,7 +74,7 @@ const PlotToolsOverlay = forwardRef<HTMLDivElement, Props>(function PlotToolsOve
<div className="p-2 space-y-1">
<button
onClick={() => {
onExportCsv({ scope: 'visible', includeTimestamps: true, timeFormat: 'iso' })
onExportCsv({ scope: 'visible', includeTimestamps: true, timeFormat: 'iso', format: 'csv' })
setShowExportMenu(false)
}}
className="w-full text-left px-2 py-1 text-sm text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 rounded"
Expand All @@ -83,7 +83,7 @@ const PlotToolsOverlay = forwardRef<HTMLDivElement, Props>(function PlotToolsOve
</button>
<button
onClick={() => {
onExportCsv({ scope: 'all', includeTimestamps: true, timeFormat: 'iso' })
onExportCsv({ scope: 'all', includeTimestamps: true, timeFormat: 'iso', format: 'csv' })
setShowExportMenu(false)
}}
className="w-full text-left px-2 py-1 text-sm text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 rounded"
Expand All @@ -93,7 +93,7 @@ const PlotToolsOverlay = forwardRef<HTMLDivElement, Props>(function PlotToolsOve
<div className="border-t border-gray-200 dark:border-neutral-700 my-1" />
<button
onClick={() => {
onExportCsv({ scope: 'visible', includeTimestamps: true, timeFormat: 'relative' })
onExportCsv({ scope: 'visible', includeTimestamps: true, timeFormat: 'relative', format: 'csv' })
setShowExportMenu(false)
}}
className="w-full text-left px-2 py-1 text-sm text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 rounded"
Expand All @@ -102,13 +102,22 @@ const PlotToolsOverlay = forwardRef<HTMLDivElement, Props>(function PlotToolsOve
</button>
<button
onClick={() => {
onExportCsv({ scope: 'all', includeTimestamps: true, timeFormat: 'relative' })
onExportCsv({ scope: 'all', includeTimestamps: true, timeFormat: 'relative', format: 'csv' })
setShowExportMenu(false)
}}
className="w-full text-left px-2 py-1 text-sm text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 rounded"
>
⏱️ All Data (Relative Time)
</button>
<button
onClick={() => {
onExportCsv({ scope: 'all', format: 'wav'})
setShowExportMenu(false)
}}
className="w-full text-left px-2 py-1 text-sm text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 rounded"
>
🔊 Export as multi-channel WAV (⚠️ LOUD)
</button>
</div>
</div>
)}
Expand Down
16 changes: 10 additions & 6 deletions src/utils/__tests__/chartExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand All @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -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()
Expand Down
128 changes: 117 additions & 11 deletions src/utils/chartExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -133,22 +134,127 @@ 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,
options: ChartExportOptions
) {
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')
}
2 changes: 1 addition & 1 deletion src/utils/consoleExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down