@gmod/cram
Version:
read CRAM files with pure Javascript
438 lines (400 loc) • 12.5 kB
text/typescript
import { CramMalformedError } from '../../errors.ts'
import {
BamFlagsDecoder,
CramFlagsDecoder,
type DecodeOptions,
MateFlagsDecoder,
type MateRecord,
type ReadFeature,
} from '../record.ts'
import { type SliceHeader } from './index.ts'
import { isMappedSliceHeader } from '../sectionParsers.ts'
import { decodeUtf8, readNullTerminatedStringFromBuffer } from '../util.ts'
import type { CramFileBlock } from '../file.ts'
import type CramSlice from './index.ts'
import type { Cursors } from '../codecs/_base.ts'
import type CramContainerCompressionScheme from '../container/compressionScheme.ts'
// Each method returns the next decoded value for that data series, advancing
// the underlying cursor. Built once per slice in slice/index.ts as a fixed-
// shape object literal so call sites get monomorphic property access.
export interface BoundDecoders {
BF(): number
CF(): number
RI(): number
RL(): number
AP(): number
RG(): number
RN(): Uint8Array
MF(): number
NS(): number
NP(): number
TS(): number
NF(): number
TL(): number
FN(): number
FC(): number
FP(): number
DL(): number
BB(): Uint8Array
QQ(): Uint8Array
BS(): number
IN(): Uint8Array
RS(): number
PD(): number
HC(): number
SC(): Uint8Array
MQ(): number
BA(): number
QS(): number
TC(): number
TN(): number
}
// Uses DataView rather than typed arrays because the buffer is a subarray of a
// larger ArrayBuffer. Int32Array(buffer.buffer) would start at byte 0 of the
// parent, ignoring buffer.byteOffset, causing silent data corruption.
function parseTagValueArray(buffer: Uint8Array) {
const arrayType = String.fromCharCode(buffer[0]!)
const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
const length = dv.getUint32(1, true)
const array: number[] = new Array(length)
const dataOffset = 5
if (arrayType === 'c') {
for (let i = 0; i < length; i++) {
array[i] = dv.getInt8(dataOffset + i)
}
} else if (arrayType === 'C') {
for (let i = 0; i < length; i++) {
array[i] = dv.getUint8(dataOffset + i)
}
} else if (arrayType === 's') {
for (let i = 0; i < length; i++) {
array[i] = dv.getInt16(dataOffset + i * 2, true)
}
} else if (arrayType === 'S') {
for (let i = 0; i < length; i++) {
array[i] = dv.getUint16(dataOffset + i * 2, true)
}
} else if (arrayType === 'i') {
for (let i = 0; i < length; i++) {
array[i] = dv.getInt32(dataOffset + i * 4, true)
}
} else if (arrayType === 'I') {
for (let i = 0; i < length; i++) {
array[i] = dv.getUint32(dataOffset + i * 4, true)
}
} else if (arrayType === 'f') {
for (let i = 0; i < length; i++) {
array[i] = dv.getFloat32(dataOffset + i * 4, true)
}
} else {
throw new Error(`unknown type: ${arrayType}`)
}
return array
}
function parseTagData(tagType: string, buffer: Uint8Array) {
if (tagType === 'Z') {
return readNullTerminatedStringFromBuffer(buffer)
}
if (tagType === 'A') {
return String.fromCharCode(buffer[0]!)
}
if (tagType === 'C') {
return buffer[0]!
}
if (tagType === 'c') {
return buffer[0]! > 127 ? buffer[0]! - 256 : buffer[0]!
}
if (tagType === 'B') {
return parseTagValueArray(buffer)
}
if (tagType === 'H') {
return Number.parseInt(
readNullTerminatedStringFromBuffer(buffer).replace(/^0x/, ''),
16,
)
}
const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
if (tagType === 'I') {
return dv.getUint32(0, true)
}
if (tagType === 'i') {
return dv.getInt32(0, true)
}
if (tagType === 's') {
return dv.getInt16(0, true)
}
if (tagType === 'S') {
return dv.getUint16(0, true)
}
if (tagType === 'f') {
return dv.getFloat32(0, true)
}
throw new CramMalformedError(`Unrecognized tag type ${tagType}`)
}
// Read-feature schema: a charCode-indexed array of [code, fn] tuples where
// fn() decodes and transforms the feature's data (character → fromCharCode,
// string → decodeUtf8, numArray → Array.from, number → identity,
// B → [base, qualityScore]). Built once per slice; the inner loop becomes
// a charCode lookup + monomorphic call with no per-feature allocation.
type RFData = string | number | number[] | [string, number]
export type RFEntry = readonly [code: string, fn: () => RFData]
export function buildRFSchema(
bd: BoundDecoders,
majorVersion: number,
): (RFEntry | undefined)[] {
const SC = majorVersion > 1 ? bd.SC : bd.IN
const arr: (RFEntry | undefined)[] = new Array(128)
arr['B'.charCodeAt(0)] = [
'B',
() => [String.fromCharCode(bd.BA()), bd.QS()] as [string, number],
]
arr['X'.charCodeAt(0)] = ['X', () => bd.BS()]
arr['D'.charCodeAt(0)] = ['D', () => bd.DL()]
arr['I'.charCodeAt(0)] = ['I', () => decodeUtf8(bd.IN())]
arr['i'.charCodeAt(0)] = ['i', () => String.fromCharCode(bd.BA())]
arr['b'.charCodeAt(0)] = ['b', () => decodeUtf8(bd.BB())]
arr['q'.charCodeAt(0)] = ['q', () => Array.from(bd.QQ())]
arr['Q'.charCodeAt(0)] = ['Q', () => bd.QS()]
arr['H'.charCodeAt(0)] = ['H', () => bd.HC()]
arr['P'.charCodeAt(0)] = ['P', () => bd.PD()]
arr['N'.charCodeAt(0)] = ['N', () => bd.RS()]
arr['S'.charCodeAt(0)] = ['S', () => decodeUtf8(SC())]
return arr
}
function decodeReadFeatures(
alignmentStart: number,
readFeatureCount: number,
bd: BoundDecoders,
schema: (RFEntry | undefined)[],
): [ReadFeature[], number] {
let readPos = 0
let refDelta = 0
const base = alignmentStart - 1
const readFeatures: ReadFeature[] = new Array(readFeatureCount)
const decodeFC = bd.FC
const decodeFP = bd.FP
for (let i = 0; i < readFeatureCount; i++) {
const codeNum = decodeFC()
readPos += decodeFP()
const entry = schema[codeNum]
if (!entry) {
throw new CramMalformedError(
`invalid read feature code "${String.fromCharCode(codeNum)}"`,
)
}
const code = entry[0]
const data = entry[1]()
readFeatures[i] = {
code,
pos: readPos,
refPos: readPos + base + refDelta,
data,
} as ReadFeature
if (code === 'D' || code === 'N') {
refDelta += data as number
} else if (code === 'I' || code === 'S') {
refDelta -= (data as string).length
} else if (code === 'i') {
refDelta -= 1
}
}
return [readFeatures, refDelta]
}
export type BulkByteRawDecoder = (
dataSeriesName: 'QS' | 'BA',
length: number,
) => Uint8Array | undefined
function decodeQualityScores(
readLength: number,
decodeBulkBytesRaw: BulkByteRawDecoder | undefined,
decodeQS: () => number,
) {
const raw = decodeBulkBytesRaw?.('QS', readLength)
if (raw) {
return raw
}
const out = new Uint8Array(readLength)
for (let i = 0; i < readLength; i++) {
out[i] = decodeQS()
}
return out
}
function decodeReadBases(
readLength: number,
decodeBulkBytesRaw: BulkByteRawDecoder | undefined,
decodeBA: () => number,
) {
const raw = decodeBulkBytesRaw?.('BA', readLength)
if (raw) {
return decodeUtf8(raw)
}
const buf = new Uint8Array(readLength)
for (let i = 0; i < readLength; i++) {
buf[i] = decodeBA()
}
return decodeUtf8(buf)
}
export type BoundTagDecoders = Record<
string,
() => Uint8Array | number | undefined
>
export default function decodeRecord(
slice: CramSlice,
bd: BoundDecoders,
rfSchema: (RFEntry | undefined)[],
boundTagDecoders: BoundTagDecoders,
compressionScheme: CramContainerCompressionScheme,
sliceHeader: SliceHeader,
coreDataBlock: CramFileBlock,
blocksByContentId: Record<number, CramFileBlock>,
cursors: Cursors,
majorVersion: number,
recordNumber: number,
uniqueId: number,
decodeOptions?: Required<DecodeOptions>,
decodeBulkBytesRaw?: BulkByteRawDecoder,
) {
let flags = bd.BF()
// note: the C data type of compressionFlags is byte in cram v1 and int32 in
// cram v2+, but that does not matter for us here in javascript land.
const cramFlags = bd.CF()
if (!isMappedSliceHeader(sliceHeader.parsedContent)) {
throw new Error('slice header not mapped')
}
const sequenceId =
majorVersion > 1 && sliceHeader.parsedContent.refSeqId === -2
? bd.RI()
: sliceHeader.parsedContent.refSeqId
const readLength = bd.RL()
// if APDelta, AP is a delta from the previous record's alignmentStart
let alignmentStart = bd.AP()
if (compressionScheme.APdelta) {
alignmentStart = alignmentStart + cursors.lastAlignmentStart
}
cursors.lastAlignmentStart = alignmentStart
const readGroupId = bd.RG()
let readNameRaw: Uint8Array | undefined
if (compressionScheme.readNamesIncluded) {
readNameRaw = bd.RN()
}
let mate: MateRecord | undefined
let templateSize: number | undefined
let mateRecordNumber: number | undefined
// mate record
if (CramFlagsDecoder.isDetached(cramFlags)) {
// note: the MF is a byte in 1.0, int32 in 2+, but once again this doesn't
// matter for javascript
const mateFlags = bd.MF()
let mateReadName: string | undefined
if (!compressionScheme.readNamesIncluded) {
readNameRaw = bd.RN()
mateReadName = readNullTerminatedStringFromBuffer(readNameRaw)
}
const mateSequenceId = bd.NS()
const mateAlignmentStart = bd.NP()
if (mateFlags || mateSequenceId > -1) {
mate = {
flags: mateFlags,
sequenceId: mateSequenceId,
alignmentStart: mateAlignmentStart,
readName: mateReadName,
}
}
templateSize = bd.TS()
// set mate unmapped if needed
if (MateFlagsDecoder.isUnmapped(mateFlags)) {
flags = BamFlagsDecoder.setMateUnmapped(flags)
}
// set mate reversed if needed
if (MateFlagsDecoder.isOnNegativeStrand(mateFlags)) {
flags = BamFlagsDecoder.setMateReverseComplemented(flags)
}
} else if (CramFlagsDecoder.isWithMateDownstream(cramFlags)) {
mateRecordNumber = bd.NF() + recordNumber + 1
}
// TODO: the aux tag parsing will have to be refactored if we want to support
// cram v1
const TLindex = bd.TL()
if (TLindex < 0) {
/* TODO: check nTL: TLindex >= compressionHeader.tagEncoding.size */
throw new CramMalformedError('invalid TL index')
}
type TagValue = string | number | number[] | undefined
const tags: Record<string, TagValue> = {}
// TN = tag names
const TN = compressionScheme.getTagNames(TLindex)!
const ntags = TN.length
const shouldDecodeTags = decodeOptions?.decodeTags !== false
if (shouldDecodeTags) {
for (let i = 0; i < ntags; i++) {
const tagId = TN[i]!
const tagData = boundTagDecoders[tagId]!()
const tagName = tagId[0]! + tagId[1]!
const tagType = tagId[2]!
tags[tagName] =
tagData === undefined
? undefined
: typeof tagData === 'number'
? tagData
: parseTagData(tagType, tagData)
}
}
let readFeatures: ReadFeature[] | undefined
let lengthOnRef: number | undefined
let mappingQuality: number | undefined
let qualityScores: Uint8Array | undefined | null
let readBases = undefined
if (!BamFlagsDecoder.isSegmentUnmapped(flags)) {
// reading read features
const readFeatureCount = bd.FN()
lengthOnRef = readLength
if (readFeatureCount) {
const [features, refDelta] = decodeReadFeatures(
alignmentStart,
readFeatureCount,
bd,
rfSchema,
)
readFeatures = features
lengthOnRef += refDelta
}
if (Number.isNaN(lengthOnRef)) {
console.warn(
`${sequenceId}:${alignmentStart} record has invalid read features`,
)
lengthOnRef = readLength
}
// mapping quality
mappingQuality = bd.MQ()
if (CramFlagsDecoder.isPreservingQualityScores(cramFlags)) {
qualityScores = decodeQualityScores(readLength, decodeBulkBytesRaw, bd.QS)
}
} else if (CramFlagsDecoder.isDecodeSequenceAsStar(cramFlags)) {
readBases = null
qualityScores = null
} else {
readBases = decodeReadBases(readLength, decodeBulkBytesRaw, bd.BA)
if (CramFlagsDecoder.isPreservingQualityScores(cramFlags)) {
qualityScores = decodeQualityScores(readLength, decodeBulkBytesRaw, bd.QS)
}
}
return {
readLength,
sequenceId,
cramFlags,
flags,
alignmentStart,
readGroupId,
readNameRaw,
mate,
templateSize,
mateRecordNumber,
readFeatures,
lengthOnRef,
mappingQuality,
qualityScores,
readBases,
tags,
uniqueId,
}
}