@gmod/bam 7.2.4 → 7.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bamFile.ts CHANGED
@@ -203,14 +203,20 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
203
203
  return this.header
204
204
  }
205
205
 
206
+ // Resolve a reference name to its numeric id, ensuring the header (which
207
+ // populates chrToIndex) has been parsed first.
208
+ private async getSeqId(seqName: string, opts?: BaseOpts) {
209
+ await this.getHeader(opts)
210
+ return this.chrToIndex?.[seqName]
211
+ }
212
+
206
213
  async getRecordsForRange(
207
214
  chr: string,
208
215
  min: number,
209
216
  max: number,
210
217
  opts?: BamOpts,
211
218
  ) {
212
- await this.getHeader(opts)
213
- const chrId = this.chrToIndex?.[chr]
219
+ const chrId = await this.getSeqId(chr, opts)
214
220
  if (chrId === undefined || !this.index) {
215
221
  return []
216
222
  }
@@ -234,9 +240,16 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
234
240
  max: number,
235
241
  opts: BamOpts = {},
236
242
  ) {
237
- const { viewAsPairs, filterBy } = opts
243
+ const { viewAsPairs, filterBy, onProgress } = opts
238
244
  const result: T[] = []
239
245
 
246
+ let totalBytes = 0
247
+ for (let ci = 0, cl = chunks.length; ci < cl; ci++) {
248
+ totalBytes += chunks[ci]!.fetchedSize()
249
+ }
250
+ let downloadedBytes = 0
251
+ onProgress?.(0, totalBytes)
252
+
240
253
  for (let ci = 0, cl = chunks.length; ci < cl; ci++) {
241
254
  const chunk = chunks[ci]!
242
255
  const cacheKey = chunkCacheKey(chunk, filterBy)
@@ -258,6 +271,8 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
258
271
  })
259
272
  }
260
273
 
274
+ downloadedBytes += chunk.fetchedSize()
275
+ onProgress?.(downloadedBytes, totalBytes)
261
276
  appendInRange(records, chrId, min, max, result)
262
277
  }
263
278
 
@@ -335,11 +350,14 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
335
350
  }
336
351
 
337
352
  async _readChunkFeatures(chunk: Chunk, opts: BaseOpts) {
338
- const buf = await this.bam.read(
339
- chunk.fetchedSize(),
340
- chunk.minv.blockPosition,
341
- opts,
342
- )
353
+ // Don't forward onProgress to the inner read: getRecordsForRange already
354
+ // reports progress at chunk granularity (downloadedBytes/totalBytes). If the
355
+ // filehandle's own streaming onProgress also fired this callback it would
356
+ // report a different `total` (this chunk's size, not the whole query),
357
+ // making the determinate bar jump around.
358
+ const buf = await this.bam.read(chunk.fetchedSize(), chunk.minv.blockPosition, {
359
+ signal: opts.signal,
360
+ })
343
361
  const {
344
362
  buffer: data,
345
363
  cpositions,
@@ -397,20 +415,20 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
397
415
  return sink
398
416
  }
399
417
 
400
- async hasRefSeq(seqName: string) {
401
- const seqId = this.chrToIndex?.[seqName]
418
+ async hasRefSeq(seqName: string, opts?: BaseOpts) {
419
+ const seqId = await this.getSeqId(seqName, opts)
402
420
  return !this.index || seqId === undefined
403
421
  ? false
404
422
  : this.index.hasRefSeq(seqId)
405
423
  }
406
424
 
407
- async lineCount(seqName: string) {
408
- const seqId = this.chrToIndex?.[seqName]
425
+ async lineCount(seqName: string, opts?: BaseOpts) {
426
+ const seqId = await this.getSeqId(seqName, opts)
409
427
  return !this.index || seqId === undefined ? 0 : this.index.lineCount(seqId)
410
428
  }
411
429
 
412
430
  async indexCov(seqName: string, start?: number, end?: number) {
413
- const seqId = this.chrToIndex?.[seqName]
431
+ const seqId = await this.getSeqId(seqName)
414
432
  return !this.index || seqId === undefined
415
433
  ? []
416
434
  : this.index.indexCov(seqId, start, end)
@@ -422,7 +440,7 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
422
440
  end: number,
423
441
  opts?: BaseOpts,
424
442
  ) {
425
- const seqId = this.chrToIndex?.[seqName]
443
+ const seqId = await this.getSeqId(seqName, opts)
426
444
  return !this.index || seqId === undefined
427
445
  ? []
428
446
  : this.index.blocksForRange(seqId, start, end, opts)
@@ -440,16 +458,9 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
440
458
  return 0
441
459
  }
442
460
  await this.getHeader(opts)
443
- const chrToIndex = this.chrToIndex
444
- if (!chrToIndex) {
445
- throw new Error('Header not yet parsed')
446
- }
447
461
  const mapped = regions.flatMap(r => {
448
- const refId = chrToIndex[r.refName]
449
- if (refId === undefined) {
450
- return []
451
- }
452
- return [{ refId, start: r.start, end: r.end }]
462
+ const refId = this.chrToIndex?.[r.refName]
463
+ return refId === undefined ? [] : [{ refId, start: r.start, end: r.end }]
453
464
  })
454
465
  return this.index.estimatedBytesForRegions(mapped, opts)
455
466
  }
package/src/htsget.ts CHANGED
@@ -64,6 +64,7 @@ export default class HtsgetFile<
64
64
  max: number,
65
65
  opts?: BamOpts,
66
66
  ) {
67
+ await this.getHeader(opts)
67
68
  const base = `${this.baseUrl}/${this.trackId}`
68
69
  const url = `${base}?referenceName=${chr}&start=${min}&end=${max}&format=BAM`
69
70
  const chrId = this.chrToIndex?.[chr]
package/src/record.ts CHANGED
@@ -29,6 +29,104 @@ export interface Bytes {
29
29
  byteArray: Uint8Array
30
30
  }
31
31
 
32
+ type BArrayValue =
33
+ | Int8Array
34
+ | Uint8Array
35
+ | Int16Array
36
+ | Uint16Array
37
+ | Int32Array
38
+ | Uint32Array
39
+ | Float32Array
40
+ | number[]
41
+
42
+ // Decode a 'B' (array) tag value starting at `p` (the byte after type+subtype+
43
+ // count). When the data is naturally aligned we return a typed-array view over
44
+ // the underlying buffer (zero-copy); otherwise we copy element-by-element since
45
+ // typed-array views require alignment. Shared by getTag and the full-tag parse.
46
+ function decodeBArrayTag(
47
+ ba: Uint8Array,
48
+ dataView: DataView,
49
+ p: number,
50
+ Btype: number,
51
+ limit: number,
52
+ ): BArrayValue | undefined {
53
+ const absOffset = ba.byteOffset + p
54
+ switch (Btype) {
55
+ case 0x69: {
56
+ // 'i'
57
+ if (absOffset % 4 === 0) {
58
+ return new Int32Array(ba.buffer, absOffset, limit)
59
+ }
60
+ const arr = new Array<number>(limit)
61
+ for (let i = 0; i < limit; i++) {
62
+ arr[i] = dataView.getInt32(p + i * 4, true)
63
+ }
64
+ return arr
65
+ }
66
+ case 0x49: {
67
+ // 'I'
68
+ if (absOffset % 4 === 0) {
69
+ return new Uint32Array(ba.buffer, absOffset, limit)
70
+ }
71
+ const arr = new Array<number>(limit)
72
+ for (let i = 0; i < limit; i++) {
73
+ arr[i] = dataView.getUint32(p + i * 4, true)
74
+ }
75
+ return arr
76
+ }
77
+ case 0x73: {
78
+ // 's'
79
+ if (absOffset % 2 === 0) {
80
+ return new Int16Array(ba.buffer, absOffset, limit)
81
+ }
82
+ const arr = new Array<number>(limit)
83
+ for (let i = 0; i < limit; i++) {
84
+ arr[i] = dataView.getInt16(p + i * 2, true)
85
+ }
86
+ return arr
87
+ }
88
+ case 0x53: {
89
+ // 'S'
90
+ if (absOffset % 2 === 0) {
91
+ return new Uint16Array(ba.buffer, absOffset, limit)
92
+ }
93
+ const arr = new Array<number>(limit)
94
+ for (let i = 0; i < limit; i++) {
95
+ arr[i] = dataView.getUint16(p + i * 2, true)
96
+ }
97
+ return arr
98
+ }
99
+ case 0x63: // 'c'
100
+ return new Int8Array(ba.buffer, absOffset, limit)
101
+ case 0x43: // 'C'
102
+ return new Uint8Array(ba.buffer, absOffset, limit)
103
+ case 0x66: {
104
+ // 'f'
105
+ if (absOffset % 4 === 0) {
106
+ return new Float32Array(ba.buffer, absOffset, limit)
107
+ }
108
+ const arr = new Array<number>(limit)
109
+ for (let i = 0; i < limit; i++) {
110
+ arr[i] = dataView.getFloat32(p + i * 4, true)
111
+ }
112
+ return arr
113
+ }
114
+ default:
115
+ return undefined
116
+ }
117
+ }
118
+
119
+ // Byte span of a 'B' tag's element payload, for advancing the cursor past it.
120
+ function bArrayByteLength(Btype: number, limit: number) {
121
+ if (Btype === 0x69 || Btype === 0x49 || Btype === 0x66) {
122
+ return limit << 2
123
+ } else if (Btype === 0x73 || Btype === 0x53) {
124
+ return limit << 1
125
+ } else {
126
+ return limit
127
+ }
128
+ }
129
+
32
130
  export default class BamRecord {
33
131
  public fileOffset: number
34
132
  private _byteArray: Uint8Array
@@ -56,7 +154,10 @@ export default class BamRecord {
56
154
  }
57
155
 
58
156
  get flags() {
59
- return (this._dataView.getInt32(this._start + 16, true) & 0xffff0000) >> 16
157
+ // FLAG is the high 16 bits of flag_nc (byte offset 18). Read it directly as
158
+ // a uint16 — masking flag_nc and arithmetic-shifting would sign-extend any
159
+ // value >= 0x8000.
160
+ return this._dataView.getUint16(this._start + 18, true)
60
161
  }
61
162
 
62
163
  get ref_id() {
@@ -83,11 +184,14 @@ export default class BamRecord {
83
184
  return this.mq
84
185
  }
85
186
 
187
+ // QUAL is present whenever the record has bases — independent of the unmapped
188
+ // flag (unmapped reads routinely carry SEQ/QUAL). A zero-length SEQ means
189
+ // there is no quality to return.
86
190
  get qual() {
87
- if (this.isSegmentUnmapped()) {
191
+ const seqLen = this.seq_length
192
+ if (seqLen === 0) {
88
193
  return null
89
194
  } else {
90
- const seqLen = this.seq_length
91
195
  const p = this.seqStart + ((seqLen + 1) >> 1)
92
196
  return this._byteArray.subarray(p, p + seqLen)
93
197
  }
@@ -236,78 +340,20 @@ export default class BamRecord {
236
340
  }
237
341
  case 0x42: {
238
342
  // 'B'
239
- const Btype = ba[p++]
343
+ const Btype = ba[p++]!
240
344
  const limit = this._dataView.getInt32(p, true)
241
345
  p += 4
242
- const absOffset = ba.byteOffset + p
243
346
  if (isMatch) {
244
- if (Btype === 0x69) {
245
- // 'i'
246
- if (absOffset % 4 === 0) {
247
- return new Int32Array(ba.buffer, absOffset, limit)
248
- }
249
- const arr: number[] = new Array(limit)
250
- for (let i = 0; i < limit; i++) {
251
- arr[i] = this._dataView.getInt32(p + i * 4, true)
252
- }
253
- return arr
254
- } else if (Btype === 0x49) {
255
- // 'I'
256
- if (absOffset % 4 === 0) {
257
- return new Uint32Array(ba.buffer, absOffset, limit)
258
- }
259
- const arr: number[] = new Array(limit)
260
- for (let i = 0; i < limit; i++) {
261
- arr[i] = this._dataView.getUint32(p + i * 4, true)
262
- }
263
- return arr
264
- } else if (Btype === 0x73) {
265
- // 's'
266
- if (absOffset % 2 === 0) {
267
- return new Int16Array(ba.buffer, absOffset, limit)
268
- }
269
- const arr: number[] = new Array(limit)
270
- for (let i = 0; i < limit; i++) {
271
- arr[i] = this._dataView.getInt16(p + i * 2, true)
272
- }
273
- return arr
274
- } else if (Btype === 0x53) {
275
- // 'S'
276
- if (absOffset % 2 === 0) {
277
- return new Uint16Array(ba.buffer, absOffset, limit)
278
- }
279
- const arr: number[] = new Array(limit)
280
- for (let i = 0; i < limit; i++) {
281
- arr[i] = this._dataView.getUint16(p + i * 2, true)
282
- }
283
- return arr
284
- } else if (Btype === 0x63) {
285
- // 'c'
286
- return new Int8Array(ba.buffer, absOffset, limit)
287
- } else if (Btype === 0x43) {
288
- // 'C'
289
- return new Uint8Array(ba.buffer, absOffset, limit)
290
- } else if (Btype === 0x66) {
291
- // 'f'
292
- if (absOffset % 4 === 0) {
293
- return new Float32Array(ba.buffer, absOffset, limit)
294
- }
295
- const arr: number[] = new Array(limit)
296
- for (let i = 0; i < limit; i++) {
297
- arr[i] = this._dataView.getFloat32(p + i * 4, true)
298
- }
299
- return arr
300
- }
301
- }
302
- if (Btype === 0x69 || Btype === 0x49 || Btype === 0x66) {
303
- p += limit << 2
304
- } else if (Btype === 0x73 || Btype === 0x53) {
305
- p += limit << 1
306
- } else if (Btype === 0x63 || Btype === 0x43) {
307
- p += limit
347
+ return decodeBArrayTag(ba, this._dataView, p, Btype, limit)
308
348
  }
349
+ p += bArrayByteLength(Btype, limit)
309
350
  break
310
351
  }
352
+ default:
353
+ if (type !== undefined) {
354
+ console.error('Unknown BAM tag type', type)
355
+ }
356
+ break
311
357
  }
312
358
  }
313
359
  return undefined
@@ -370,79 +416,11 @@ export default class BamRecord {
370
416
  }
371
417
  case 0x42: {
372
418
  // 'B'
373
- const Btype = ba[p++]
419
+ const Btype = ba[p++]!
374
420
  const limit = this._dataView.getInt32(p, true)
375
421
  p += 4
376
- const absOffset = ba.byteOffset + p
377
- if (Btype === 0x69) {
378
- // 'i'
379
- if (absOffset % 4 === 0) {
380
- tags[tag] = new Int32Array(ba.buffer, absOffset, limit)
381
- } else {
382
- const arr: number[] = new Array(limit)
383
- for (let i = 0; i < limit; i++) {
384
- arr[i] = this._dataView.getInt32(p + i * 4, true)
385
- }
386
- tags[tag] = arr
387
- }
388
- p += limit << 2
389
- } else if (Btype === 0x49) {
390
- // 'I'
391
- if (absOffset % 4 === 0) {
392
- tags[tag] = new Uint32Array(ba.buffer, absOffset, limit)
393
- } else {
394
- const arr: number[] = new Array(limit)
395
- for (let i = 0; i < limit; i++) {
396
- arr[i] = this._dataView.getUint32(p + i * 4, true)
397
- }
398
- tags[tag] = arr
399
- }
400
- p += limit << 2
401
- } else if (Btype === 0x73) {
402
- // 's'
403
- if (absOffset % 2 === 0) {
404
- tags[tag] = new Int16Array(ba.buffer, absOffset, limit)
405
- } else {
406
- const arr: number[] = new Array(limit)
407
- for (let i = 0; i < limit; i++) {
408
- arr[i] = this._dataView.getInt16(p + i * 2, true)
409
- }
410
- tags[tag] = arr
411
- }
412
- p += limit << 1
413
- } else if (Btype === 0x53) {
414
- // 'S'
415
- if (absOffset % 2 === 0) {
416
- tags[tag] = new Uint16Array(ba.buffer, absOffset, limit)
417
- } else {
418
- const arr: number[] = new Array(limit)
419
- for (let i = 0; i < limit; i++) {
420
- arr[i] = this._dataView.getUint16(p + i * 2, true)
421
- }
422
- tags[tag] = arr
423
- }
424
- p += limit << 1
425
- } else if (Btype === 0x63) {
426
- // 'c'
427
- tags[tag] = new Int8Array(ba.buffer, absOffset, limit)
428
- p += limit
429
- } else if (Btype === 0x43) {
430
- // 'C'
431
- tags[tag] = new Uint8Array(ba.buffer, absOffset, limit)
432
- p += limit
433
- } else if (Btype === 0x66) {
434
- // 'f'
435
- if (absOffset % 4 === 0) {
436
- tags[tag] = new Float32Array(ba.buffer, absOffset, limit)
437
- } else {
438
- const arr: number[] = new Array(limit)
439
- for (let i = 0; i < limit; i++) {
440
- arr[i] = this._dataView.getFloat32(p + i * 4, true)
441
- }
442
- tags[tag] = arr
443
- }
444
- p += limit << 2
445
- }
422
+ tags[tag] = decodeBArrayTag(ba, this._dataView, p, Btype, limit)
423
+ p += bArrayByteLength(Btype, limit)
446
424
  break
447
425
  }
448
426
  default:
@@ -521,9 +499,14 @@ export default class BamRecord {
521
499
  // Uint32Array view only for large aligned CIGARs.
522
500
 
523
501
  // CG tag pattern: first op is soft-clip consuming entire sequence, second op is N encoding length-on-ref
524
- private _isCGTagPattern(p: number) {
525
- const cigop = this._dataView.getInt32(p, true)
526
- return (cigop & 0xf) === CIGAR_SOFT_CLIP && cigop >> 4 === this.seq_length
502
+ private _isCGTagPattern(p: number, numCigarOps: number) {
503
+ // htslib stores the placeholder as exactly two ops: <seqlen>S<reflen>N.
504
+ if (numCigarOps === 2) {
505
+ const cigop = this._dataView.getInt32(p, true)
506
+ return (cigop & 0xf) === CIGAR_SOFT_CLIP && cigop >> 4 === this.seq_length
507
+ } else {
508
+ return false
509
+ }
527
510
  }
528
511
 
529
512
  private _computeLengthOnRef(): number {
@@ -535,7 +518,7 @@ export default class BamRecord {
535
518
  const numCigarOps = flag_nc & 0xffff
536
519
  const p = this.b0 + this.read_name_length
537
520
 
538
- if (this._isCGTagPattern(p)) {
521
+ if (this._isCGTagPattern(p, numCigarOps)) {
539
522
  const cigop2 = this._dataView.getInt32(p + 4, true)
540
523
  if ((cigop2 & 0xf) !== CIGAR_REF_SKIP) {
541
524
  console.warn('CG tag with no N tag')
@@ -576,7 +559,7 @@ export default class BamRecord {
576
559
  const numCigarOps = flag_nc & 0xffff
577
560
  const p = this.b0 + this.read_name_length
578
561
 
579
- if (this._isCGTagPattern(p)) {
562
+ if (this._isCGTagPattern(p, numCigarOps)) {
580
563
  return (
581
564
  (this.tags.CG as Uint32Array | number[] | undefined) ??
582
565
  new Uint32Array(0)
package/src/util.ts CHANGED
@@ -20,6 +20,14 @@ export interface BamOpts {
20
20
  maxInsertSize?: number
21
21
  signal?: AbortSignal
22
22
  filterBy?: FilterBy
23
+ /**
24
+ * Called as the BGZF blocks covering the query are fetched, with cumulative
25
+ * downloaded bytes and the total to fetch. Reported at block granularity (one
26
+ * tick per chunk, including instant ticks for cache hits) since chunk byte
27
+ * sizes are known up front from the index. Lets callers render a determinate
28
+ * download progress bar.
29
+ */
30
+ onProgress?: (bytesDownloaded: number, totalBytes: number) => void
23
31
  }
24
32
 
25
33
  export interface BaseOpts {
@@ -245,8 +253,9 @@ interface Positioned {
245
253
  }
246
254
 
247
255
  // Append records overlapping [min, max) on `chrId` into `out` (or a fresh
248
- // array if omitted). Records are assumed sorted by start, so we stop scanning
249
- // at the first record past `max`. Returns the populated array.
256
+ // array if omitted). Records are assumed coordinate-sorted (by ref_id, then
257
+ // start), so we stop scanning once we pass `max` within `chrId` or move past
258
+ // `chrId` entirely. Returns the populated array.
250
259
  export function appendInRange<T extends Positioned>(
251
260
  records: T[],
252
261
  chrId: number,
@@ -262,6 +271,8 @@ export function appendInRange<T extends Positioned>(
262
271
  } else if (r.end >= min) {
263
272
  out.push(r)
264
273
  }
274
+ } else if (r.ref_id > chrId) {
275
+ break
265
276
  }
266
277
  }
267
278
  return out