bemusic/bemuse

View on GitHub
packages/bemuse-indexer/src/index.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import _ from 'lodash'
import assign from 'object-assign'
import invariant from 'invariant'
import pMap from 'p-map'
import {
  BMSChart,
  BMSNote,
  Compiler,
  Notes,
  Reader,
  SongInfo,
  Timing,
} from 'bms'
import {
  hasScratch as bmsonHasScratch,
  keysForBmson,
  musicalScoreForBmson,
  songInfoForBmson,
} from 'bmson'
import { createHash } from 'crypto'
import { extname } from 'path'

import {
  BGAInfo,
  IndexingInputFile,
  Keys,
  OutputChart,
  OutputFileInfo,
  OutputSongInfo,
  OutputSongInfoVideo,
} from './types'
import { getBmsBga } from './bms-bga'
import { getBmsonBga } from './bmson-bga'
import { getBpmInfo } from './bpm-info'
import { getDuration } from './duration'
import { getKeys } from './keys'
import { lcs } from './lcs'

interface InputMeta {
  name: string
  md5?: string
}

interface FileIndexBasis {
  info: SongInfo
  notes: Notes
  timing: Timing
  scratch: boolean
  keys: Keys
  bga?: BGAInfo
}

interface ExtensionHandler {
  (source: Buffer, meta: InputMeta): Promise<FileIndexBasis>
}
interface ExtensionMap {
  [extname: string]: ExtensionHandler
}
const _extensions: ExtensionMap = {}
export { _extensions as extensions }

_extensions['.bms'] = async function (source, meta) {
  const options = Reader.getReaderOptionsFromFilename(meta.name)
  const str = await Reader.readAsync(source, options)
  const chart = Compiler.compile(str).chart
  const info = SongInfo.fromBMSChart(chart)
  const notes = Notes.fromBMSChart(chart)
  const timing = Timing.fromBMSChart(chart)
  return {
    info: info,
    notes: notes,
    timing: timing,
    scratch: hasScratch(chart),
    keys: getKeys(chart),
    bga: getBmsBga(chart, { timing }),
  }
}

_extensions['.bmson'] = async function (source) {
  const string = Buffer.from(source).toString('utf8')
  const object = JSON.parse(string)
  const info = songInfoForBmson(object)
  const ms = musicalScoreForBmson(object)
  const notes = ms.notes
  const timing = ms.timing
  const bga = getBmsonBga(object, { timing: timing })
  return {
    info: info,
    notes: notes,
    timing: timing,
    scratch: bmsonHasScratch(object),
    keys: keysForBmson(object),
    bga: bga,
  }
}

async function getFileInfo(
  data: Buffer,
  meta: InputMeta,
  options?: { extensions?: ExtensionMap }
): Promise<OutputFileInfo> {
  options = options || {}
  invariant(typeof meta.name === 'string', 'meta.name must be a string')

  const extensions = options.extensions || _extensions
  const extension =
    extensions[extname(meta.name).toLowerCase()] || extensions['.bms']

  const md5 =
    meta.md5 ||
    (function () {
      const hash = createHash('md5')
      hash.update(data)
      return hash.digest('hex')
    })()

  const basis = await extension(data, meta)
  invariant(basis.info, 'basis.info must be a BMS.SongInfo')
  invariant(basis.notes, 'basis.notes must be a BMS.Notes')
  invariant(basis.timing, 'basis.timing must be a BMS.Timing')
  invariant(
    typeof basis.scratch === 'boolean',
    'basis.scratch must be a boolean'
  )
  invariant(typeof basis.keys === 'string', 'basis.keys must be a string')
  const info = basis.info
  const notes = basis.notes
  const timing = basis.timing
  const count = notes.all().filter(noteIsPlayable).length
  return {
    md5: md5,
    info: info,
    noteCount: count,
    bpm: getBpmInfo(notes, timing),
    duration: getDuration(notes, timing),
    scratch: basis.scratch,
    keys: basis.keys,
    bga: basis.bga,
  }
}

const _getFileInfo = getFileInfo
export { _getFileInfo as getFileInfo }

async function getSongInfo(
  files: IndexingInputFile[],
  options?: {
    cache?: {
      get(md5: string): OutputFileInfo
      put(md5: string, info: OutputFileInfo): void
    }
    extra?: any
    onProgress?: any
    onError?: (error: Error, name: string) => void
    getFileInfo?: typeof getFileInfo
  }
): Promise<OutputSongInfo> {
  options = options || {}
  const warnings: string[] = []
  const cache = options.cache || undefined
  const extra = options.extra || {}
  const report = options.onProgress || function () {}
  const onError =
    options.onError ||
    function (e, name) {
      if (global.console && console.error) {
        console.error('Error while parsing ' + name, e)
      }
    }
  let processed = 0
  const doGetFileInfo = options.getFileInfo || getFileInfo
  const results = await pMap(
    files,
    async function (file): Promise<OutputChart[]> {
      const name = file.name
      const fileData = file.data
      const hash = createHash('md5')
      hash.update(fileData)
      const md5Hash = hash.digest('hex')
      try {
        const cached = await Promise.resolve(cache && cache.get(md5Hash))
        if (cached) {
          return [{ ...cached, file: name }]
        } else {
          const meta = { name: name, md5: md5Hash }
          const info = await Promise.resolve(doGetFileInfo(fileData, meta))
          if (cache) cache.put(md5Hash, info)
          return [{ ...info, file: name }]
        }
      } catch (e) {
        onError(e as Error, name)
        warnings.push('Unable to parse ' + name + ': ' + e)
        return []
      } finally {
        processed += 1
        report(processed, files.length, name)
      }
    },
    { concurrency: 2 }
  )
  const charts = _.flatten(results)
  if (charts.length === 0) {
    warnings.push('No usable charts found!')
  }
  const song: Partial<OutputSongInfo> = {
    title: common(charts, _.property('info.title')),
    artist: common(charts, _.property('info.artist')),
    genre: common(charts, _.property('info.genre')),
    bpm: median(charts, _.property('bpm.median')),
  }
  assign(song, getSongVideoFromCharts(charts))
  assign(song, extra)
  song.charts = charts
  song.warnings = warnings
  return song as OutputSongInfo
}

const _getSongInfo = getSongInfo
export { _getSongInfo as getSongInfo }

function getSongVideoFromCharts(charts: OutputFileInfo[]): OutputSongInfoVideo {
  const result: OutputSongInfoVideo = {}
  const chart = _.find(charts, 'bga')
  if (chart) {
    result.video_file = chart.bga!.file
    result.video_offset = chart.bga!.offset
  }
  return result
}

export const _getSongVideoFromCharts = getSongVideoFromCharts

function noteIsPlayable(note: BMSNote) {
  return note.column !== undefined
}

function hasScratch(chart: BMSChart) {
  const objects = chart.objects.all()
  for (let i = 0; i < objects.length; i++) {
    const object = objects[i]
    let channel = +object.channel
    if (channel >= 50 && channel <= 69) channel -= 20
    if (channel === 16 || channel === 26) return true
  }
  return false
}

function common<T>(array: T[], f: (item: T) => string) {
  const longest = array.map(f).reduce(lcs, '')
  return String(longest || f(array[0])).trim()
}

function median<T>(array: T[], f: (item: T) => number) {
  const arr = _(array).map(f).sortBy().value()
  return arr[Math.floor(arr.length / 2)]
}