UNPKG

hyper-beats

Version:

Decentralised music using hypercore

268 lines (204 loc) 7.51 kB
const ReadyResource = require('ready-resource') const cloneable = require('cloneable-readable') const b4a = require('b4a') const CodecParser = require('@hdegroote/codec-parser-commonjs') const cenc = require('compact-encoding') // v^8.0.0 no longer supports commonJS, so needs to use the 7.x stream // (https://github.com/Borewit/music-metadata/issues/1783#issuecomment-1620486679) const { selectCover, parseBuffer: parseMetadata } = require('music-metadata') const HyperBeatsStream = require('./lib/stream') const { BeatsMetadataEnc } = require('./lib/encoding') const FLAC_MIMETYPE = 'audio/flac' const MPEG_MIMETYPE = 'audio/mpeg' const MPEG_ALIASES = ['mpeg', 'mp3'] // Hypercore readstreams have poor performance with small // block sizes (in terms of mb/s streamed). // This can be lowered (ideally to 1 frame/block) when this // is fixed in the hypercore replication protocol const DEFAULT_BYTES_PER_BLOCK = 100_000 /* HyperBeats structure: Block-0: hyperbeats metadata (Block0Enc) Block-1: music metadata header (raw bytes) Block-2 -> n-1: metadata.nrFramesPerBlock frames (raw bytes) Block-n: x frames (x <= metadata.nrFramesPerBlock) (raw bytes) */ class Hyperbeats extends ReadyResource { constructor (core) { super() this.core = core } async ready () { await this.core.ready() } async close () { await this.core.close() } async ensureValid () { await this.ready() await this.decodeBlock0() return true } // Throws if the core is not a Hyperbeats // Or if the version is incompatible async decodeBlock0 () { await this.ready() // DEVNOTE: hangs until block0 can be loaded const block0 = await this.core.get(0) return cenc.decode(BeatsMetadataEnc, block0) } async getMimeType () { await this.ready() const { mimeType } = await this.decodeBlock0() return mimeType } async _getSomeFrame () { const mimeType = await this.getMimeType() const parser = getParser(mimeType) const frame = parser.parseAll(await this.core.get(2)) if (!frame[0]) throw new Error('could not parse frame header') return frame[0] } async getFrameHeader () { await this.ready() const frame = await this._getSomeFrame() return frame.header } async getRawMetadata () { await this.ready() // All bytes before the first actual frame return await this.core.get(1) } async getMetadata () { await this.ready() const bytes = await this.getRawMetadata() const mimeType = await this.getMimeType() const { common } = await parseMetadata(bytes, mimeType) return common } async getSecDuration () { const lastBlockBytes = (await this.core.get(this.core.length - 1)).length const framesPerBlock = await this.getFramesPerBlock() const bytesPerFrame = await this.getBytesPerFrame() const lastBlockFrames = lastBlockBytes / bytesPerFrame const frames = (framesPerBlock * (this.core.length - 3)) + lastBlockFrames const duration = Math.round(frames / (await this.getFramesPerSec())) return duration } async getCover () { await this.ready() const metadata = await this.getMetadata() return selectCover(metadata.picture) } async getFramesPerSec () { await this.ready() // Assumes it's the same rate for the entire song const frame = await this._getSomeFrame() const durationMs = frame.duration // Rounding errors should be minimal (no very small values) return (1000 / durationMs) } async getFramesPerBlock () { await this.ready() const { framesPerBlock } = await this.decodeBlock0() return framesPerBlock } async getBytesPerFrame () { const { bytesPerFrame } = await this.decodeBlock0() return bytesPerFrame } async write (stream, mimeType, { bytesPerBlock = DEFAULT_BYTES_PER_BLOCK } = {}) { await this.ready() if (this.core.length !== 0) { throw new Error('Can only write hyperbeats to an empty core') } mimeType = normaliseMimeType(mimeType) const block0 = { mimeType } // TODO: cleaner way to get the header // Context: the parser does not yield the header // (containing metadata, and potentially images) // so a hack is used, accessing the internals of // the parser to extract the header bytes too // so they can be added as the first block stream = cloneable(stream) // TODO: error handling of streams let nrHeaderBytes = null const headerBytes = [] const headerStream = stream.clone() headerStream.on('data', d => headerBytes.push(d)) let framesPerBlock = null let nextBlockContent = null const parser = getParser(mimeType) for await (const entry of stream) { for (const frame of parser.parseChunk(entry)) { if (nrHeaderBytes === null) { nrHeaderBytes = parser._currentReadPosition - frame.data.length headerStream.destroy() await new Promise((resolve, reject) => { headerStream.on('close', resolve) headerStream.on('error', reject) }) const nrBytesPerFrame = frame.data.length framesPerBlock = Math.round(bytesPerBlock / nrBytesPerFrame) block0.framesPerBlock = framesPerBlock block0.bytesPerFrame = nrBytesPerFrame await this.core.append( cenc.encode(BeatsMetadataEnc, block0) ) const header = b4a.concat(headerBytes, nrHeaderBytes) await this.core.append(header) nextBlockContent = [] } if (nextBlockContent.length >= framesPerBlock) { await this.core.append(b4a.concat(nextBlockContent)) nextBlockContent = [] } nextBlockContent.push(frame.data) } } for (const frame of parser.flush()) { if (nextBlockContent.length >= framesPerBlock) { await this.core.append(b4a.concat(nextBlockContent)) nextBlockContent = [] } nextBlockContent.push(frame.data) } // Remaining frames if (nextBlockContent) { await this.core.append(b4a.concat(nextBlockContent)) } } async stream (opts) { // DEPRECATED return this.createReadStream(opts) } createReadStream (opts) { if (!opts) return this.core.createReadStream({ start: 1 }) const startSec = opts.start const endSec = opts.end if (startSec > endSec) throw new Error('Start bigger than end') return new HyperBeatsStream(this, startSec, endSec) } async _secToBlocks (startSec, endSec) { const framesPerSec = await this.getFramesPerSec() const framesPerBlock = await this.getFramesPerBlock() const startFrame = Math.floor(startSec * framesPerSec) const endFrame = endSec != null ? Math.ceil(endSec * framesPerSec) : this.core.length const startBlock = Math.floor(startFrame / framesPerBlock) + 2 // hyperbeats metadata + music-type header const endBlock = Math.min( Math.ceil(endFrame / framesPerBlock) + 2, this.core.length ) if (startBlock >= this.core.length) throw new Error('Start after song ends') return { start: startBlock, end: endBlock } } } function normaliseMimeType (mimeType) { mimeType = mimeType.toLowerCase() if (mimeType === 'flac') return FLAC_MIMETYPE if (MPEG_ALIASES.includes(mimeType)) return MPEG_MIMETYPE throw new Error(`Unsupported mimetype: ${mimeType}`) } function getParser (mimeType) { return new CodecParser(mimeType) } module.exports = Hyperbeats