• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

atomic14 / web-serial-plotter / 19186043244

08 Nov 2025 01:44AM UTC coverage: 59.981% (-1.2%) from 61.184%
19186043244

Pull #23

github

web-flow
Merge bf67d01a4 into ae73642b7
Pull Request #23: Feature: Multi-channel WAV export

421 of 538 branches covered (78.25%)

Branch coverage included in aggregate %.

16 of 101 new or added lines in 3 files covered. (15.84%)

1 existing line in 1 file now uncovered.

2052 of 3585 relevant lines covered (57.24%)

33.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.9
/src/utils/chartExport.ts
1
import type { ViewPortData, RingStore } from '../store/RingStore'
2
import { downloadFile } from './consoleExport'
1✔
3

4
export type ChartExportScope = 'visible' | 'all'
5

6
export interface ChartExportOptions {
7
  scope: ChartExportScope
8
  includeTimestamps?: boolean
9
  timeFormat?: 'iso' | 'relative' | 'timestamp'
10
  format: 'csv' | 'wav'
11
}
12

13
export function formatChartTimestamp(timestamp: number, format: 'iso' | 'relative' | 'timestamp', baseTime?: number): string {
1✔
14
  switch (format) {
24✔
15
    case 'iso':
24✔
16
      return new Date(timestamp).toISOString()
17✔
17
    case 'relative': {
24✔
18
      const relativeMs = baseTime ? timestamp - baseTime : timestamp
6✔
19
      return (relativeMs / 1000).toFixed(3) // Convert to seconds with 3 decimal places
6✔
20
    }
6✔
21
    case 'timestamp':
24✔
22
      return timestamp.toString()
1✔
23
    default:
24!
24
      return timestamp.toString()
×
25
  }
24✔
26
}
24✔
27

28
export function exportVisibleChartDataAsCsv(
1✔
29
  snapshot: ViewPortData, 
5✔
30
  options: ChartExportOptions = { scope: 'visible', includeTimestamps: true, timeFormat: 'iso', format: 'csv' }
5✔
31
): string {
5✔
32
  const { series, getTimes, getSeriesData, firstTimestamp } = snapshot
5✔
33
  const times = getTimes()
5✔
34
  
35
  if (series.length === 0 || times.length === 0) {
5✔
36
    return 'No data available'
1✔
37
  }
1✔
38

39
  // Build header
40
  const headers = []
4✔
41
  if (options.includeTimestamps) {
5✔
42
    headers.push('Timestamp')
3✔
43
  }
3✔
44
  headers.push(...series.map(s => s.name))
4✔
45
  
46
  const csvLines = [headers.join(',')]
4✔
47
  
48
  // Base time for relative timestamps (first timestamp ever received)
49
  const baseTime = options.timeFormat === 'relative' ? (firstTimestamp ?? undefined) : undefined
5!
50
  
51
  // Export each data point
52
  for (let i = 0; i < times.length; i++) {
5✔
53
    const row = []
16✔
54
    
55
    // Add timestamp if requested
56
    if (options.includeTimestamps && Number.isFinite(times[i])) {
16✔
57
      row.push(formatChartTimestamp(times[i], options.timeFormat || 'iso', baseTime))
12✔
58
    } else if (options.includeTimestamps) {
16!
59
      row.push('') // Empty timestamp for NaN values
×
60
    }
×
61
    
62
    // Add data for each series
63
    for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex++) {
16✔
64
      const seriesData = getSeriesData(seriesIndex)
32✔
65
      const value = i < seriesData.length ? seriesData[i] : NaN
32!
66
      row.push(Number.isFinite(value) ? value.toString() : '')
32✔
67
    }
32✔
68
    
69
    csvLines.push(row.join(','))
16✔
70
  }
16✔
71
  
72
  return csvLines.join('\n')
4✔
73
}
4✔
74

75
export function exportAllChartDataAsCsv(
1✔
76
  store: RingStore,
4✔
77
  options: ChartExportOptions = { scope: 'all', includeTimestamps: true, timeFormat: 'iso', format: 'csv' }
4✔
78
): string {
4✔
79
  const series = store.getSeries()
4✔
80
  
81
  if (series.length === 0) {
4✔
82
    return 'No data available'
1✔
83
  }
1✔
84
  
85
  // Build header
86
  const headers = []
3✔
87
  if (options.includeTimestamps) {
3✔
88
    headers.push('Timestamp')
3✔
89
  }
3✔
90
  headers.push(...series.map((s) => s.name))
3✔
91
  
92
  const csvLines = [headers.join(',')]
3✔
93
  
94
  // Get all data from the store
95
  const capacity = store.getCapacity()
3✔
96
  const writeIndex = store.writeIndex
3✔
97
  const totalSamples = Math.min(writeIndex, capacity)
3✔
98
  
99
  if (totalSamples === 0) {
4✔
100
    return csvLines.join('\n') // Just header
1✔
101
  }
1✔
102
  
103
  // Determine the range of valid data
104
  const startIndex = writeIndex > capacity ? writeIndex - capacity : 0
4!
105
  const endIndex = writeIndex - 1
4✔
106
  
107
  // Base time for relative timestamps (use first timestamp ever received)
108
  const baseTime = options.timeFormat === 'relative' ? (store.firstTimestamp ?? undefined) : undefined
4!
109
  
110
  // Export each data point in chronological order
111
  for (let i = startIndex; i <= endIndex; i++) {
4✔
112
    const ringIndex = i % capacity
8✔
113
    const row = []
8✔
114
    
115
    // Add timestamp if requested
116
    if (options.includeTimestamps) {
8✔
117
      const timestamp = store.times[ringIndex]
8✔
118
      if (Number.isFinite(timestamp)) {
8✔
119
        row.push(formatChartTimestamp(timestamp, options.timeFormat || 'iso', baseTime))
8✔
120
      } else {
8!
121
        row.push('') // Empty timestamp for NaN values
×
122
      }
×
123
    }
8✔
124
    
125
    // Add data for each series
126
    for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex++) {
8✔
127
      const value = store.buffers[seriesIndex][ringIndex]
16✔
128
      row.push(Number.isFinite(value) ? value.toString() : '')
16!
129
    }
16✔
130
    
131
    csvLines.push(row.join(','))
8✔
132
  }
8✔
133
  
134
  return csvLines.join('\n')
2✔
135
}
2✔
136

137

138
export function exportAllChartDataAsWav(
1✔
NEW
139
  store: RingStore
×
NEW
140
): Uint8Array {
×
NEW
141
  const series = store.getSeries()
×
NEW
142
  if (series.length === 0) {
×
NEW
143
    throw new Error('No data available')
×
NEW
144
  }
×
145

NEW
146
  const capacity = store.getCapacity()
×
NEW
147
  const writeIndex = store.writeIndex
×
NEW
148
  const totalSamples = Math.min(writeIndex, capacity)
×
149

NEW
150
  if (totalSamples === 0) {
×
NEW
151
    throw new Error('No data available')
×
NEW
152
  }
×
153

NEW
154
  const numChannels = series.length
×
NEW
155
  const sampleRate = 8000 // TODO: Let the user choose the sample rate in a modal before the file is exported
×
156

157
  // Determine the range of valid data
NEW
158
  const startIndex = writeIndex > capacity ? writeIndex - capacity : 0
×
NEW
159
  const endIndex = writeIndex - 1
×
NEW
160
  const numFrames = endIndex - startIndex + 1
×
161

162
  // Prepare interleaved float32 buffer
NEW
163
  const interleaved = new Float32Array(numFrames * numChannels)
×
NEW
164
  let ptr = 0
×
165

NEW
166
  for (let i = startIndex; i <= endIndex; i++) {
×
NEW
167
    const ringIndex = i % capacity
×
NEW
168
    for (let ch = 0; ch < numChannels; ch++) {
×
NEW
169
      const value = store.buffers[ch][ringIndex]
×
NEW
170
      interleaved[ptr++] = Number.isFinite(value) ? value : 0
×
NEW
171
    }
×
NEW
172
  }
×
173

174
  // WAV file construction
NEW
175
  const bytesPerSample = 4
×
NEW
176
  const blockAlign = numChannels * bytesPerSample
×
NEW
177
  const byteRate = sampleRate * blockAlign
×
NEW
178
  const dataSize = interleaved.length * bytesPerSample
×
NEW
179
  const buffer = new ArrayBuffer(44 + dataSize)
×
NEW
180
  const view = new DataView(buffer)
×
NEW
181
  let offset = 0
×
182

NEW
183
  function writeString(str: string) {
×
NEW
184
    for (let i = 0; i < str.length; i++) {
×
NEW
185
      view.setUint8(offset++, str.charCodeAt(i))
×
NEW
186
    }
×
NEW
187
  }
×
188

NEW
189
  function writeUint32(val: number) {
×
NEW
190
    view.setUint32(offset, val, true)
×
NEW
191
    offset += 4
×
NEW
192
  }
×
193

NEW
194
  function writeUint16(val: number) {
×
NEW
195
    view.setUint16(offset, val, true)
×
NEW
196
    offset += 2
×
NEW
197
  }
×
198

199
  // RIFF header
NEW
200
  writeString('RIFF')
×
NEW
201
  writeUint32(36 + dataSize) // file size minus 8 bytes
×
NEW
202
  writeString('WAVE')
×
203

204
  // fmt subchunk
NEW
205
  writeString('fmt ')
×
NEW
206
  writeUint32(16) // Subchunk1Size
×
NEW
207
  writeUint16(3) // Audio format 3 = IEEE float
×
NEW
208
  writeUint16(numChannels)
×
NEW
209
  writeUint32(sampleRate)
×
NEW
210
  writeUint32(byteRate)
×
NEW
211
  writeUint16(blockAlign)
×
NEW
212
  writeUint16(bytesPerSample * 8) // bits per sample
×
213

214
  // data subchunk
NEW
215
  writeString('data')
×
NEW
216
  writeUint32(dataSize)
×
217

218
  // Write interleaved float32 samples
NEW
219
  for (let i = 0; i < interleaved.length; i++) {
×
NEW
220
    view.setFloat32(offset, interleaved[i], true)
×
NEW
221
    offset += 4
×
NEW
222
  }
×
223

NEW
224
  return new Uint8Array(buffer)
×
NEW
225
}
×
226

227

228
export function exportChartData(
1✔
229
  snapshot: ViewPortData,
2✔
230
  store: RingStore,
2✔
231
  options: ChartExportOptions
2✔
232
) {
2✔
233
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
2✔
234
  const scopeLabel = options.scope === 'visible' ? 'visible' : 'all'
2✔
235
  
236
  if (options.format == 'csv') {
2✔
237
    const filename = `chart-data-${scopeLabel}-${timestamp}.csv`
2✔
238

239
    let csvContent: string
2✔
240
    
241
    if (options.scope === 'visible') {
2✔
242
      csvContent = exportVisibleChartDataAsCsv(snapshot, options)
1✔
243
    } else {
1✔
244
      csvContent = exportAllChartDataAsCsv(store, options)
1✔
245
    }
1✔
246
    
247
    downloadFile(csvContent, filename, 'text/csv')
2✔
248
  } else if (options.format == 'wav') {
2!
NEW
249
    const filename = `chart-data-${scopeLabel}-${timestamp}_LOUD.wav`
×
250

NEW
251
    let wavContent: Uint8Array
×
NEW
252
    if (options.scope === 'visible') {
×
NEW
253
      throw new Error('`visible` scope is not supported for WAV export');
×
NEW
254
    } else {
×
NEW
255
      wavContent = exportAllChartDataAsWav(store)
×
NEW
256
    }
×
257
    
NEW
258
    downloadFile(wavContent, filename, 'audio/wav')
×
UNCOV
259
  }
×
260
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc