Skip to main content

slow5lib/
writer.rs

1use std::collections::BTreeSet;
2use std::fs::File;
3use std::io::{self, BufWriter, Write};
4use std::path::Path;
5use std::sync::{Arc, mpsc};
6
7const BUF_SIZE: usize = 512 * 1024;
8
9use crate::Result;
10use crate::error::SlowError;
11use crate::header::{Header, RecordCompression, SignalCompression};
12use crate::record::Record;
13
14/// Primary-field type header line written into every BLOW5 ASCII section.
15const TYPE_HEADER: &str = "#char*\tuint32_t\tdouble\tdouble\tdouble\tdouble\tuint64_t\tint16_t*\n";
16
17/// Primary-field column name header line written into every BLOW5 ASCII section.
18const COL_HEADER: &str = "#read_id\tread_group\tdigitisation\toffset\trange\tsampling_rate\tlen_raw_signal\traw_signal\n";
19
20/// Sequential writer for SLOW5 and BLOW5 files.
21///
22/// Records are written in the order they are passed to `write()`. The format
23/// is detected from the path extension (`.slow5` or `.blow5`).
24///
25/// For high-throughput BLOW5 writing, compress records in parallel with rayon and
26/// call `write()` in sorted order -- the writer itself must remain on one
27/// thread (it owns the file cursor).
28///
29/// ```no_run
30/// # use slow5lib::{Slow5Writer, header::{Header, RecordCompression, SignalCompression, ReadGroup}, aux::AuxMeta};
31/// # fn example() -> slow5lib::Result<()> {
32/// let header = Header {
33///     version: (0, 2, 0),
34///     num_read_groups: 1,
35///     record_compression: RecordCompression::Zstd,
36///     signal_compression: SignalCompression::SvbZd,
37///     read_groups: vec![ReadGroup::default()],
38///     aux_meta: AuxMeta::default(),
39/// };
40/// let mut writer = Slow5Writer::create("out.blow5", header)?;
41/// // writer.write(&record)?;
42/// writer.finish()?;
43/// # Ok(()) }
44/// ```
45pub struct Slow5Writer {
46    state: WriterState,
47}
48
49enum WriterState {
50    Blow5 {
51        inner: BufWriter<File>,
52        record_compression: RecordCompression,
53        signal_compression: SignalCompression,
54        aux_meta: crate::aux::AuxMeta,
55        // Reused across write() calls to avoid per-record ZSTD_CCtx alloc/free.
56        // None when record_compression is not Zstd.
57        zstd_compressor: Option<zstd::bulk::Compressor<'static>>,
58    },
59    Slow5 {
60        inner: BufWriter<File>,
61        aux_meta: crate::aux::AuxMeta,
62    },
63}
64
65impl Slow5Writer {
66    /// Create a new SLOW5 or BLOW5 file and write its header.
67    ///
68    /// The format is detected from the path extension (`.slow5` or `.blow5`).
69    /// For BLOW5 with signal compression, set `header.version` to at least `(0, 2, 0)`.
70    ///
71    /// # Examples
72    ///
73    /// ```no_run
74    /// use slow5lib::{Slow5Writer, header::{Header, RecordCompression, SignalCompression, ReadGroup}};
75    /// use slow5lib::aux::AuxMeta;
76    ///
77    /// # fn example() -> slow5lib::Result<()> {
78    /// let header = Header {
79    ///     version: (0, 2, 0),
80    ///     num_read_groups: 1,
81    ///     record_compression: RecordCompression::Zstd,
82    ///     signal_compression: SignalCompression::SvbZd,
83    ///     read_groups: vec![ReadGroup::default()],
84    ///     aux_meta: AuxMeta::default(),
85    /// };
86    /// let mut writer = Slow5Writer::create("out.blow5", header)?;
87    /// // writer.write(&record)?;
88    /// writer.finish()?;
89    /// # Ok(()) }
90    /// ```
91    pub fn create(path: impl AsRef<Path>, header: Header) -> Result<Self> {
92        let path = path.as_ref();
93
94        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
95        match ext {
96            "blow5" => Self::create_blow5(path, header),
97            "slow5" => Self::create_slow5(path, header),
98            other => Err(SlowError::InvalidFormat(format!(
99                "unrecognised extension '.{other}': expected '.blow5' or '.slow5'"
100            ))),
101        }
102    }
103
104    fn create_blow5(path: &Path, header: Header) -> Result<Self> {
105        let file = File::create(path)?;
106        let mut inner = BufWriter::with_capacity(BUF_SIZE, file);
107
108        let header_bytes = build_blow5_header(&header)?;
109        inner.write_all(&header_bytes)?;
110
111        let zstd_compressor = if matches!(header.record_compression, RecordCompression::Zstd) {
112            let c = zstd::bulk::Compressor::new(1)
113                .map_err(|e| SlowError::Decompression(e.to_string()))?;
114            Some(c)
115        } else {
116            None
117        };
118
119        Ok(Self {
120            state: WriterState::Blow5 {
121                inner,
122                record_compression: header.record_compression,
123                signal_compression: header.signal_compression,
124                aux_meta: header.aux_meta,
125                zstd_compressor,
126            },
127        })
128    }
129
130    fn create_slow5(path: &Path, header: Header) -> Result<Self> {
131        let file = File::create(path)?;
132        let mut inner = BufWriter::with_capacity(BUF_SIZE, file);
133
134        crate::slow5::write_header(&mut inner, &header)?;
135
136        Ok(Self {
137            state: WriterState::Slow5 {
138                inner,
139                aux_meta: header.aux_meta,
140            },
141        })
142    }
143
144    /// Compress and write a single record.
145    ///
146    /// The signal is encoded and compressed according to the compression settings
147    /// specified in the header at construction time.
148    ///
149    /// # Examples
150    ///
151    /// ```no_run
152    /// use slow5lib::{Slow5Writer, record::Record};
153    /// use slow5lib::header::{Header, RecordCompression, SignalCompression, ReadGroup};
154    /// use slow5lib::aux::AuxMeta;
155    /// use std::collections::HashMap;
156    ///
157    /// # fn example() -> slow5lib::Result<()> {
158    /// let header = Header {
159    ///     version: (0, 2, 0),
160    ///     num_read_groups: 1,
161    ///     record_compression: RecordCompression::Zstd,
162    ///     signal_compression: SignalCompression::SvbZd,
163    ///     read_groups: vec![ReadGroup::default()],
164    ///     aux_meta: AuxMeta::default(),
165    /// };
166    /// let mut writer = Slow5Writer::create("out.blow5", header)?;
167    ///
168    /// let record = Record {
169    ///     read_id: "my-read-001".to_string(),
170    ///     read_group: 0,
171    ///     digitisation: 2048.0,
172    ///     offset: -224.0,
173    ///     range: 1463.0,
174    ///     sampling_rate: 4000.0,
175    ///     raw_signal: vec![512i16, 515, 510, 518, 511],
176    ///     aux: HashMap::new(),
177    /// };
178    /// writer.write(&record)?;
179    /// writer.finish()?;
180    /// # Ok(()) }
181    /// ```
182    pub fn write(&mut self, record: &Record) -> Result<()> {
183        match &mut self.state {
184            WriterState::Blow5 {
185                inner,
186                record_compression,
187                signal_compression,
188                aux_meta,
189                zstd_compressor,
190            } => {
191                let record_buf = encode_record(record, *signal_compression, aux_meta)?;
192
193                let (size_bytes, payload) = match record_compression {
194                    RecordCompression::None => {
195                        let size = record_buf.len() as u64;
196                        (size.to_le_bytes(), record_buf)
197                    }
198                    RecordCompression::Zstd => {
199                        let compressor = zstd_compressor.as_mut().expect("zstd compressor present");
200                        let compressed = compressor
201                            .compress(&record_buf)
202                            .map_err(|e| SlowError::Decompression(e.to_string()))?;
203                        let size = compressed.len() as u64;
204                        (size.to_le_bytes(), compressed)
205                    }
206                    RecordCompression::Zlib => {
207                        let compressed = crate::compression::compress_record_zlib(&record_buf)?;
208                        let size = compressed.len() as u64;
209                        (size.to_le_bytes(), compressed)
210                    }
211                };
212
213                inner.write_all(&size_bytes)?;
214                inner.write_all(&payload)?;
215                Ok(())
216            }
217            WriterState::Slow5 { inner, aux_meta } => {
218                crate::slow5::write_record(inner, record, aux_meta)
219            }
220        }
221    }
222
223    /// Compress and write a batch of records in parallel, then flush sequentially.
224    ///
225    /// All records are encoded and compressed concurrently across the rayon thread pool.
226    /// The compressed payloads are then written to disk in order on the calling thread.
227    /// Compression and I/O are not overlapped -- for that, see the manual `records_raw` pattern.
228    ///
229    /// For SLOW5 text files, this falls back to sequential `write()` calls since there
230    /// is no signal compression to parallelize.
231    ///
232    /// Requires the `rayon` feature.
233    ///
234    /// # Examples
235    ///
236    /// ```no_run
237    /// use slow5lib::{Slow5Writer, record::Record};
238    /// use slow5lib::header::{Header, RecordCompression, SignalCompression, ReadGroup};
239    /// use slow5lib::aux::AuxMeta;
240    /// use std::collections::HashMap;
241    ///
242    /// # fn example() -> slow5lib::Result<()> {
243    /// let header = Header {
244    ///     version: (0, 2, 0),
245    ///     num_read_groups: 1,
246    ///     record_compression: RecordCompression::Zstd,
247    ///     signal_compression: SignalCompression::SvbZd,
248    ///     read_groups: vec![ReadGroup::default()],
249    ///     aux_meta: AuxMeta::default(),
250    /// };
251    /// let mut writer = Slow5Writer::create("out.blow5", header)?;
252    ///
253    /// let records: Vec<Record> = Vec::new(); // normally populated from a reader
254    /// writer.write_all_par(&records)?;
255    /// writer.finish()?;
256    /// # Ok(()) }
257    /// ```
258    #[cfg(feature = "rayon")]
259    pub fn write_all_par(&mut self, records: &[Record]) -> Result<()> {
260        use rayon::prelude::*;
261
262        // SLOW5 text -- no record compression, fall back to sequential
263        if matches!(self.state, WriterState::Slow5 { .. }) {
264            for record in records {
265                self.write(record)?;
266            }
267            return Ok(());
268        }
269
270        // Extract parameters; releases the immutable borrow before the mutable write below
271        let (rc, sc, am) = match &self.state {
272            WriterState::Blow5 {
273                record_compression,
274                signal_compression,
275                aux_meta,
276                ..
277            } => (*record_compression, *signal_compression, aux_meta.clone()),
278            WriterState::Slow5 { .. } => unreachable!(),
279        };
280
281        // Parallel: encode + compress
282        let chunks: Vec<Result<(u64, Vec<u8>)>> = records
283            .par_iter()
284            .map(|record| {
285                let record_buf = encode_record(record, sc, &am)?;
286                compress_chunk(&record_buf, rc)
287            })
288            .collect();
289
290        // Sequential: write to file in order
291        let WriterState::Blow5 { inner, .. } = &mut self.state else {
292            unreachable!()
293        };
294        for chunk in chunks {
295            let (size, payload) = chunk?;
296            inner.write_all(&size.to_le_bytes())?;
297            inner.write_all(&payload)?;
298        }
299        Ok(())
300    }
301
302    /// Write the EOF marker (BLOW5) or flush (SLOW5) and close the file.
303    ///
304    /// Consumes `self` so the writer cannot be used after finishing.
305    ///
306    /// # Examples
307    ///
308    /// ```no_run
309    /// use slow5lib::{Slow5Writer, header::{Header, RecordCompression, SignalCompression, ReadGroup}};
310    /// use slow5lib::aux::AuxMeta;
311    ///
312    /// # fn example() -> slow5lib::Result<()> {
313    /// let header = Header {
314    ///     version: (0, 2, 0),
315    ///     num_read_groups: 1,
316    ///     record_compression: RecordCompression::None,
317    ///     signal_compression: SignalCompression::None,
318    ///     read_groups: vec![ReadGroup::default()],
319    ///     aux_meta: AuxMeta::default(),
320    /// };
321    /// let writer = Slow5Writer::create("out.blow5", header)?;
322    /// writer.finish()?; // flushes and writes EOF marker
323    /// # Ok(()) }
324    /// ```
325    pub fn finish(self) -> Result<()> {
326        match self.state {
327            WriterState::Blow5 { mut inner, .. } => {
328                // aux_meta consumed via `..`
329                inner.write_all(crate::blow5::EOF_MARKER)?;
330                inner.flush().map_err(SlowError::Io)
331            }
332            WriterState::Slow5 { mut inner, .. } => inner.flush().map_err(SlowError::Io),
333        }
334    }
335}
336
337// ── ParallelSlow5Writer ───────────────────────────────────────────────────────
338
339/// Cloneable write handle for [`ParallelSlow5Writer`].
340///
341/// Obtain one from [`ParallelSlow5Writer::write_handle`] and clone it for each
342/// rayon worker (or any thread). Calling `write()` compresses on the calling thread
343/// and enqueues the payload for the background I/O thread. Blocks under backpressure.
344///
345/// Drop all handles before calling [`ParallelSlow5Writer::finish`].
346///
347/// # Examples
348///
349/// ```no_run
350/// use slow5lib::{Slow5WriteHandle, writer::ParallelSlow5Writer};
351/// use slow5lib::header::{Header, RecordCompression, SignalCompression, ReadGroup};
352/// use slow5lib::aux::AuxMeta;
353///
354/// # fn example() -> slow5lib::Result<()> {
355/// let header = Header {
356///     version: (0, 2, 0),
357///     num_read_groups: 1,
358///     record_compression: RecordCompression::Zstd,
359///     signal_compression: SignalCompression::SvbZd,
360///     read_groups: vec![ReadGroup::default()],
361///     aux_meta: AuxMeta::default(),
362/// };
363/// let par_writer = ParallelSlow5Writer::create("out.blow5", header, 32)?;
364/// let handle = par_writer.write_handle();
365/// // handle.write(&rec)?;
366/// drop(handle);
367/// par_writer.finish()?;
368/// # Ok(()) }
369/// ```
370#[derive(Clone)]
371pub struct Slow5WriteHandle {
372    tx: mpsc::SyncSender<(u64, Vec<u8>)>,
373    record_compression: RecordCompression,
374    signal_compression: SignalCompression,
375    aux_meta: Arc<crate::aux::AuxMeta>,
376}
377
378impl Slow5WriteHandle {
379    /// Encode and compress `record` on the calling thread, then enqueue for writing.
380    ///
381    /// Blocks when the internal channel is full (backpressure from the I/O thread).
382    /// Returns an error if the background writer thread has exited unexpectedly.
383    pub fn write(&self, record: &crate::record::Record) -> Result<()> {
384        let buf = encode_record(record, self.signal_compression, &self.aux_meta)?;
385        let (size, payload) = compress_chunk(&buf, self.record_compression)?;
386        self.tx.send((size, payload)).map_err(|_| {
387            SlowError::Io(io::Error::new(
388                io::ErrorKind::BrokenPipe,
389                "writer thread has exited",
390            ))
391        })
392    }
393}
394
395/// BLOW5 writer backed by a dedicated background I/O thread.
396///
397/// Rayon workers (or any threads) call `write()` on a cloned [`Slow5WriteHandle`] --
398/// compression happens on the calling thread in parallel. The background thread does
399/// all sequential file I/O, overlapping with compression work.
400///
401/// Records are written in the order they arrive at the background thread, which is
402/// non-deterministic under parallel use. The BLOW5 format is order-independent; the
403/// sidecar index provides random access regardless of record order.
404///
405/// # Usage pattern
406///
407/// ```no_run
408/// use slow5lib::writer::ParallelSlow5Writer;
409/// use slow5lib::header::{Header, RecordCompression, SignalCompression, ReadGroup};
410/// use slow5lib::aux::AuxMeta;
411///
412/// # fn example() -> slow5lib::Result<()> {
413/// let header = Header {
414///     version: (0, 2, 0),
415///     num_read_groups: 1,
416///     record_compression: RecordCompression::Zstd,
417///     signal_compression: SignalCompression::SvbZd,
418///     read_groups: vec![ReadGroup::default()],
419///     aux_meta: AuxMeta::default(),
420/// };
421/// let n_threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
422/// let par_writer = ParallelSlow5Writer::create("out.blow5", header, n_threads * 4)?;
423/// let handle = par_writer.write_handle();
424///
425/// // rayon::prelude::*;
426/// // ids.par_iter().for_each(|id| {
427/// //     handle.write(&reader.get(id).unwrap()).unwrap();
428/// // });
429///
430/// drop(handle);       // signal EOF -- all senders must be dropped before finish()
431/// par_writer.finish() // join background thread, propagate any I/O error
432/// # }
433/// ```
434pub struct ParallelSlow5Writer {
435    tx: Option<mpsc::SyncSender<(u64, Vec<u8>)>>,
436    handle: Option<std::thread::JoinHandle<Result<()>>>,
437    record_compression: RecordCompression,
438    signal_compression: SignalCompression,
439    aux_meta: Arc<crate::aux::AuxMeta>,
440}
441
442impl ParallelSlow5Writer {
443    /// Create a BLOW5 file and spawn the background writer thread.
444    ///
445    /// Only `.blow5` files are supported; returns an error for `.slow5` paths.
446    ///
447    /// `channel_cap` is the maximum number of compressed payloads buffered between
448    /// compression threads and the writer thread. A value of `num_threads * 4` is a
449    /// reasonable default. Larger values consume more memory; smaller values add
450    /// backpressure earlier.
451    pub fn create(path: impl AsRef<Path>, header: Header, channel_cap: usize) -> Result<Self> {
452        let path = path.as_ref();
453        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
454        if ext != "blow5" {
455            return Err(SlowError::InvalidFormat(
456                "ParallelSlow5Writer only supports .blow5 files".into(),
457            ));
458        }
459
460        let file = File::create(path)?;
461        let mut inner = BufWriter::with_capacity(BUF_SIZE, file);
462        let header_bytes = build_blow5_header(&header)?;
463        inner.write_all(&header_bytes)?;
464
465        let record_compression = header.record_compression;
466        let signal_compression = header.signal_compression;
467        let aux_meta = Arc::new(header.aux_meta);
468
469        let (tx, rx) = mpsc::sync_channel::<(u64, Vec<u8>)>(channel_cap);
470
471        let handle = std::thread::spawn(move || -> Result<()> {
472            for (size, payload) in rx {
473                inner
474                    .write_all(&size.to_le_bytes())
475                    .map_err(SlowError::Io)?;
476                inner.write_all(&payload).map_err(SlowError::Io)?;
477            }
478            inner
479                .write_all(crate::blow5::EOF_MARKER)
480                .map_err(SlowError::Io)?;
481            inner.flush().map_err(SlowError::Io)
482        });
483
484        Ok(Self {
485            tx: Some(tx),
486            handle: Some(handle),
487            record_compression,
488            signal_compression,
489            aux_meta,
490        })
491    }
492
493    /// Return a cloneable handle for submitting records from worker threads.
494    ///
495    /// Clone the handle once per rayon worker (or thread). All clones must be
496    /// dropped before calling `finish()`.
497    pub fn write_handle(&self) -> Slow5WriteHandle {
498        Slow5WriteHandle {
499            tx: self.tx.as_ref().expect("writer already finished").clone(),
500            record_compression: self.record_compression,
501            signal_compression: self.signal_compression,
502            aux_meta: Arc::clone(&self.aux_meta),
503        }
504    }
505
506    /// Close the channel and join the background thread, returning any I/O error.
507    ///
508    /// **All `Slow5WriteHandle` clones must be dropped before calling this.**
509    /// If any handle is still live, the background thread will keep waiting for
510    /// records and this call will block indefinitely.
511    pub fn finish(mut self) -> Result<()> {
512        drop(self.tx.take());
513        let h = self.handle.take().expect("writer already finished");
514        match h.join() {
515            Ok(result) => result,
516            Err(_) => Err(SlowError::Io(io::Error::other("writer thread panicked"))),
517        }
518    }
519}
520
521impl Drop for ParallelSlow5Writer {
522    fn drop(&mut self) {
523        drop(self.tx.take());
524        if let Some(h) = self.handle.take() {
525            let _ = h.join();
526        }
527    }
528}
529
530/// Compress one encoded record buffer and return `(compressed_size, payload)`.
531fn compress_chunk(buf: &[u8], rc: RecordCompression) -> Result<(u64, Vec<u8>)> {
532    match rc {
533        RecordCompression::None => Ok((buf.len() as u64, buf.to_vec())),
534        RecordCompression::Zstd => {
535            let c = crate::compression::compress_record_zstd_tl(buf)?;
536            Ok((c.len() as u64, c))
537        }
538        RecordCompression::Zlib => {
539            let c = crate::compression::compress_record_zlib(buf)?;
540            Ok((c.len() as u64, c))
541        }
542    }
543}
544
545/// Build the complete BLOW5 binary header as a byte buffer.
546///
547/// Binary wire layout:
548/// ```text
549/// [magic: 6][major: 1][minor: 1][patch: 1][rec_comp: 1][num_read_groups: 4]
550/// [sig_comp: 1 if version >= 0.2.0][padding: zeros to offset 64]
551/// [header_size: u32 le][ASCII section: header_size bytes]
552/// ```
553///
554/// `header_size` is the byte count of the ASCII section (not including its own
555/// 4-byte field). It is computed after building the ASCII section and patched
556/// back into the buffer at offset 64.
557fn build_blow5_header(header: &Header) -> Result<Vec<u8>> {
558    let mut buf: Vec<u8> = Vec::with_capacity(256);
559
560    // Fixed binary prefix
561    buf.extend_from_slice(crate::blow5::MAGIC);
562    buf.push(header.version.0);
563    buf.push(header.version.1);
564    buf.push(header.version.2);
565    buf.push(header.record_compression.to_byte());
566    buf.extend_from_slice(&header.num_read_groups.to_le_bytes());
567
568    // Signal compression byte present only for version >= 0.2.0
569    if (header.version.0, header.version.1) >= (0, 2) {
570        buf.push(header.signal_compression.to_byte());
571    }
572
573    // Pad with zeros to reach offset 64
574    while buf.len() < 64 {
575        buf.push(0);
576    }
577
578    // Placeholder for header_size (u32 le) at byte 64
579    let header_size_pos = buf.len(); // == 64
580    buf.extend_from_slice(&0u32.to_le_bytes());
581
582    // ASCII section begins at byte 68
583    let ascii_start = buf.len(); // == 68
584
585    // @attribute lines -- collect all unique attribute names sorted for determinism
586    let all_keys: BTreeSet<&str> = header
587        .read_groups
588        .iter()
589        .flat_map(|rg| rg.attributes.keys().map(String::as_str))
590        .collect();
591
592    for key in &all_keys {
593        buf.push(b'@');
594        buf.extend_from_slice(key.as_bytes());
595        for rg in &header.read_groups {
596            buf.push(b'\t');
597            let val = rg.attributes.get(*key).map(String::as_str).unwrap_or("");
598            if val.is_empty() {
599                buf.push(b'.');
600            } else {
601                buf.extend_from_slice(val.as_bytes());
602            }
603        }
604        buf.push(b'\n');
605    }
606
607    // Type header line: primary fields + aux fields
608    buf.extend_from_slice(TYPE_HEADER.trim_end_matches('\n').as_bytes());
609    for typ in &header.aux_meta.types {
610        buf.push(b'\t');
611        buf.extend_from_slice(crate::aux::aux_type_str(typ).as_bytes());
612    }
613    buf.push(b'\n');
614
615    // Column name header line: primary fields + aux fields
616    buf.extend_from_slice(COL_HEADER.trim_end_matches('\n').as_bytes());
617    for name in &header.aux_meta.names {
618        buf.push(b'\t');
619        buf.extend_from_slice(name.as_bytes());
620    }
621    buf.push(b'\n');
622
623    // Patch header_size with the actual ASCII byte count
624    let ascii_len = (buf.len() - ascii_start) as u32;
625    buf[header_size_pos..header_size_pos + 4].copy_from_slice(&ascii_len.to_le_bytes());
626
627    Ok(buf)
628}
629
630/// Encode a `Record` into the uncompressed binary record buffer.
631///
632/// Wire layout (before record compression):
633/// ```text
634/// [read_id_len: u16 le][read_id: N bytes]
635/// [read_group: u32 le]
636/// [digitisation: f64 le][offset: f64 le][range: f64 le][sampling_rate: f64 le]
637/// [len_raw_signal: u64 le][signal bytes]
638/// ```
639///
640/// For `SignalCompression::None`: `len_raw_signal` = sample count; signal bytes = i16 LE samples.
641/// For `SignalCompression::SvbZd`: `len_raw_signal` = byte count of SVB-ZD data; signal bytes = compressed.
642/// For `SignalCompression::ExZd`: `len_raw_signal` = byte count of the ex-zd frame; signal bytes = compressed.
643fn encode_record(
644    record: &Record,
645    signal_compression: SignalCompression,
646    aux_meta: &crate::aux::AuxMeta,
647) -> Result<Vec<u8>> {
648    let read_id_bytes = record.read_id.as_bytes();
649    let read_id_len = u16::try_from(read_id_bytes.len()).map_err(|_| {
650        SlowError::InvalidFormat(format!(
651            "read_id '{}' is too long ({} bytes, max 65535)",
652            record.read_id,
653            read_id_bytes.len()
654        ))
655    })?;
656
657    let mut buf =
658        Vec::with_capacity(2 + read_id_bytes.len() + 4 + 8 * 5 + 2 * record.raw_signal.len());
659
660    buf.extend_from_slice(&read_id_len.to_le_bytes());
661    buf.extend_from_slice(read_id_bytes);
662    buf.extend_from_slice(&record.read_group.to_le_bytes());
663    buf.extend_from_slice(&record.digitisation.to_le_bytes());
664    buf.extend_from_slice(&record.offset.to_le_bytes());
665    buf.extend_from_slice(&record.range.to_le_bytes());
666    buf.extend_from_slice(&record.sampling_rate.to_le_bytes());
667
668    match signal_compression {
669        SignalCompression::None => {
670            let n_samples = record.raw_signal.len() as u64;
671            buf.extend_from_slice(&n_samples.to_le_bytes());
672            for &s in &record.raw_signal {
673                buf.extend_from_slice(&s.to_le_bytes());
674            }
675        }
676        SignalCompression::SvbZd => {
677            let compressed = crate::compression::compress_signal_svb_zd(&record.raw_signal)?;
678            let byte_count = compressed.len() as u64;
679            buf.extend_from_slice(&byte_count.to_le_bytes());
680            buf.extend_from_slice(&compressed);
681        }
682        SignalCompression::ExZd => {
683            let compressed = crate::compression::compress_signal_ex_zd(&record.raw_signal)?;
684            let byte_count = compressed.len() as u64;
685            buf.extend_from_slice(&byte_count.to_le_bytes());
686            buf.extend_from_slice(&compressed);
687        }
688    }
689
690    // Auxiliary fields in schema order
691    for (name, typ) in aux_meta.names.iter().zip(aux_meta.types.iter()) {
692        let val = record
693            .aux
694            .get(name)
695            .unwrap_or(&crate::aux::AuxValue::Missing);
696        crate::aux::encode_binary(val, typ, &mut buf);
697    }
698
699    Ok(buf)
700}