hyper-beats
Version:
Decentralised music using hypercore
268 lines (204 loc) • 7.51 kB
JavaScript
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