bemusic/bemuse

View on GitHub
bemuse/src/previewer/NotechartPreview.ts

Summary

Maintainability
D
2 days
Test Coverage
import Notechart, {
  GameLandmine,
  GameNote,
  SoundedEvent,
} from 'bemuse-notechart'
import SamplingMaster, {
  PlayInstance,
  Sample,
  SoundGroup,
} from 'bemuse/sampling-master'
import _ from 'lodash'

export interface NotechartPreview {
  /**
   * Song’s length in seconds.
   */
  duration: number

  name: string
  description: string

  getViewport(currentTime: number, hiSpeed: number): PreviewViewport
  getMeasureInfo(currentTime: number): MeasureInfo
  getCurrentBpm(currentTime: number): number
  getCurrentScroll(currentTime: number): number
  getCurrentSpeed(currentTime: number): number
  play(delegate: NotechartPreviewPlayerDelegate): NotechartPreviewPlayer
  measureToSeconds(measure: number): number
  getMeasureJumpTarget(currentTime: number, direction: number): number
  getComboInfo(currentTime: number): ComboInfo | null
  getMaxCombo(): number
}

export interface PreviewViewport {
  visibleNotes: VisibleNote[]
  visibleBarLines: VisibleBarLine[]
}

export interface MeasureInfo {
  currentBeat: number
  measureNumber: number
  measureStartBeat: number
  measureEndBeat?: number
}

export interface ComboInfo {
  comboTime: number
  comboCount: number
}

export interface NotechartPreviewPlayer {
  stop(): void
}

export interface NotechartPreviewPlayerDelegate {
  startTime: number

  onFinish(): void
  onTimeUpdate(currentTime: number): void
}

export type VisibleNote = {
  y: number
  long?: { endY: number; active: boolean }
  gameEvent: GameNote | GameLandmine
  type: 'note' | 'landmine'
}

export type VisibleBarLine = {
  y: number
  measureNumber: number
}

export function createNullNotechartPreview(): NotechartPreview {
  const warning =
    location.hostname !== 'bemuse.ninja'
      ? ' [NOTE: Do not bookmark this URL because it is a preview and this URL will stop working in the future.]'
      : ''
  return {
    duration: 0.99,
    name: 'No BMS/bmson loaded',
    description:
      'Drop a folder with BMS/bmson files into this window to preview it.' +
      warning,
    getViewport: () => ({
      visibleBarLines: [],
      visibleNotes: [],
    }),
    getMeasureInfo: () => ({
      currentBeat: 0,
      measureNumber: 0,
      measureStartBeat: 0,
    }),
    getCurrentBpm: () => 0,
    getCurrentScroll: () => 0,
    getCurrentSpeed: () => 0,
    play: (delegate) => {
      setTimeout(() => {
        delegate.onFinish()
      })
      return { stop: () => {} }
    },
    measureToSeconds: () => 0,
    getMeasureJumpTarget: () => 0,
    getComboInfo: () => null,
    getMaxCombo: () => 0,
  }
}

export type PreviewSoundSample = {
  filename: string
  sample: Sample | null
}

export function createNotechartPreview(
  notechart: Notechart,
  filename: string,
  samplingMaster: SamplingMaster,
  soundGroup: SoundGroup,
  samples: PreviewSoundSample[]
): NotechartPreview {
  return new BemuseNotechartPreview(
    notechart,
    filename,
    samplingMaster,
    soundGroup,
    samples
  )
}

class BemuseNotechartPreview implements NotechartPreview {
  private _sortedGameNotes: GameNote[]
  private _sortedSoundEvents: SoundedEvent[]
  private _secondsToBeatCache?: { seconds: number; beat: number }
  private _comboPreviewer: ComboPreviewer
  private _sortedGameLandmines: GameNote[]

  constructor(
    private _notechart: Notechart,
    private _filename: string,
    private _samplingMaster: SamplingMaster,
    private _soundGroup: SoundGroup,
    private _samples: PreviewSoundSample[]
  ) {
    this._sortedGameNotes = _.sortBy(this._notechart.notes, (e) => e.position)
    this._sortedGameLandmines = _.sortBy(
      this._notechart.landmines,
      (e) => e.position
    )
    this._sortedSoundEvents = _.sortBy(
      [...this._notechart.notes, ...this._notechart.autos],
      (e) => e.time
    )
    this._comboPreviewer = new ComboPreviewer(this._notechart.notes)
  }

  get duration() {
    return this._notechart.duration
  }

  get name() {
    return this._filename
  }

  get description() {
    return this._notechart.songInfo.title
  }

  getViewport(currentTime: number, hiSpeed: number): PreviewViewport {
    const beat = this._secondsToBeat(currentTime)
    const position = this._notechart.beatToPosition(beat)
    const speed = this._notechart.spacingAtBeat(beat)
    const windowSize = 4 / speed / hiSpeed
    const visibleNotes: VisibleNote[] = []
    const insideView = (gameNote: GameNote) => {
      if (gameNote.end) {
        if (currentTime > gameNote.end.time) return false
        if (gameNote.position > position + windowSize * 1.5) return false
        if (gameNote.end.position < position - windowSize * 0.5) return false
        return true
      } else {
        if (currentTime > gameNote.time) return false
        if (gameNote.position > position + windowSize * 1.5) return false
        if (gameNote.position < position - windowSize * 0.5) return false
        return true
      }
    }
    for (const gameNote of this._sortedGameNotes) {
      if (!insideView(gameNote)) continue
      const delta = gameNote.position - position
      const y = delta / windowSize
      const visibleNote: VisibleNote = {
        gameEvent: gameNote,
        y,
        type: 'note',
      }
      if (gameNote.end) {
        const endDelta = gameNote.end.position - position
        const endY = endDelta / windowSize
        const active =
          currentTime >= gameNote.time && currentTime < gameNote.end.time
        visibleNote.long = { endY, active }
      }
      visibleNotes.push(visibleNote)
    }
    for (const gameLandmine of this._sortedGameLandmines) {
      if (!insideView(gameLandmine)) continue
      const delta = gameLandmine.position - position
      const y = delta / windowSize
      const visibleNote: VisibleNote = {
        gameEvent: gameLandmine,
        y,
        type: 'landmine',
      }
      visibleNotes.push(visibleNote)
    }
    const visibleBarLines: VisibleBarLine[] = []
    for (const [i, barLine] of this._notechart.barLines.entries()) {
      if (barLine.position > position + windowSize * 1.5) continue
      if (barLine.position < position - windowSize * 0.5) continue
      const delta = barLine.position - position
      visibleBarLines.push({ y: delta / windowSize, measureNumber: i })
    }
    return { visibleNotes, visibleBarLines }
  }

  getMeasureInfo(currentTime: number): MeasureInfo {
    const beat = this._secondsToBeat(currentTime)
    let currentMeasure = 0
    let currentMeasureStart = 0
    for (const [i, barLine] of this._notechart.barLines.entries()) {
      if (beat < barLine.beat) {
        break
      }
      currentMeasure = i
      currentMeasureStart = barLine.beat
    }
    return {
      currentBeat: beat,
      measureNumber: currentMeasure,
      measureStartBeat: currentMeasureStart,
      measureEndBeat: this._notechart.barLines[currentMeasure + 1]?.beat,
    }
  }

  getCurrentBpm(currentTime: number): number {
    return this._notechart.bpmAtBeat(this._secondsToBeat(currentTime))
  }

  getCurrentScroll(currentTime: number): number {
    return this._notechart.scrollSpeedAtBeat(this._secondsToBeat(currentTime))
  }

  getCurrentSpeed(currentTime: number): number {
    return this._notechart.spacingAtBeat(this._secondsToBeat(currentTime))
  }

  measureToSeconds(measure: number): number {
    const beat = this._notechart.measureToBeat(measure)
    return this._notechart.beatToSeconds(beat)
  }

  getMeasureJumpTarget(currentTime: number, direction: number): number {
    const beat = this._secondsToBeat(currentTime)
    const closestBarLine = _.minBy(this._notechart.barLines, (b) =>
      Math.abs(b.beat - beat)
    )
    if (!closestBarLine) {
      return currentTime
    }
    const index = this._notechart.barLines.indexOf(closestBarLine)
    const target = this._notechart.barLines[index + direction]
    if (!target) {
      return currentTime
    }
    return target.time
  }

  getComboInfo(currentTime: number): ComboInfo | null {
    return this._comboPreviewer.getComboInfo(currentTime)
  }

  getMaxCombo(): number {
    return this._comboPreviewer.getMaxCombo()
  }

  private _secondsToBeat(currentTime: number) {
    if (
      !this._secondsToBeatCache ||
      this._secondsToBeatCache.seconds !== currentTime
    ) {
      this._secondsToBeatCache = {
        seconds: currentTime,
        beat: this._notechart.secondsToBeat(currentTime),
      }
    }
    return this._secondsToBeatCache.beat
  }

  play(delegate: NotechartPreviewPlayerDelegate) {
    this._samplingMaster.unmute()
    console.log(this._sortedSoundEvents)
    console.log(this._notechart.keysounds)
    const player = new BemuseNotechartPreviewPlayer(
      this._samplingMaster,
      this._soundGroup,
      this._sortedSoundEvents,
      this._notechart.keysounds,
      this._samples,
      delegate
    )
    player.play()
    return player
  }
}

class BemuseNotechartPreviewPlayer implements NotechartPreviewPlayer {
  private _cursor = 0
  private _sampleMap = new Map<string, Sample>()
  private _startAudioTime = 0
  private _startSongTime = 0
  private _stopped = false
  private _playingSamples = new Map<string, PlayInstance>()

  constructor(
    private _samplingMaster: SamplingMaster,
    private _soundGroup: SoundGroup,
    private _sortedSoundEvents: SoundedEvent[],
    private _keysounds: Record<string, string>,
    _samples: PreviewSoundSample[],
    private _delegate: NotechartPreviewPlayerDelegate
  ) {
    for (const { filename, sample } of _samples) {
      if (sample) {
        this._sampleMap.set(filename, sample)
      }
    }
  }

  play() {
    this._startAudioTime = this._samplingMaster.currentTime
    this._startSongTime = this._delegate.startTime
    const frame = () => {
      if (this._stopped) return
      this._advance()
      requestAnimationFrame(frame)
    }
    requestAnimationFrame(frame)
  }

  private _advance() {
    const currentTime =
      this._samplingMaster.currentTime -
      this._startAudioTime +
      this._startSongTime
    for (; this._cursor < this._sortedSoundEvents.length; this._cursor++) {
      const nextEvent = this._sortedSoundEvents[this._cursor]
      if (nextEvent.time > currentTime + 0.1) {
        break
      }

      if (nextEvent.keysoundStart) {
        // bmson’s "continue" notes do not trigger a new sound
        continue
      }

      const keysound = nextEvent.keysound
      const filename = this._keysounds[keysound.toLowerCase()]
      if (!filename) {
        continue
      }

      const sample = this._sampleMap.get(filename)
      if (!sample) {
        continue
      }

      let delay = 0
      let offset = 0
      if (nextEvent.time > currentTime) {
        delay = nextEvent.time - currentTime
      } else if (nextEvent.time < currentTime) {
        offset = currentTime - nextEvent.time
      }

      if (offset >= sample.duration) {
        continue
      }
      this._choke(keysound, delay)
      const instance = sample.play(delay, {
        start: offset,
        group: this._soundGroup,
      })
      this._playingSamples.set(keysound, instance)
    }
    this._delegate.onTimeUpdate(currentTime)
  }

  private _choke(keysound: string, delay: number) {
    const playing = this._playingSamples.get(keysound)
    if (!playing) {
      return
    }
    // TODO: Use Web Audio API instead
    setTimeout(() => {
      playing.stop()
    }, delay * 1000)
  }

  stop() {
    this._stopped = true
    for (const playing of this._playingSamples.values()) {
      playing.destroy()
    }
    this._playingSamples.clear()
  }
}

class ComboPreviewer {
  private _events: number[]

  constructor(notes: GameNote[]) {
    this._events = _.sortBy(
      _.flatMap(notes, (note) => {
        if (note.end) {
          return [note.time, note.end.time]
        } else {
          return [note.time]
        }
      }),
      _.identity
    )
  }

  getComboInfo(time: number): ComboInfo | null {
    const index = _.sortedLastIndex(this._events, time)
    if (index === 0) {
      return null
    }
    const prev = this._events[index - 1]
    return {
      comboCount: index,
      comboTime: prev,
    }
  }

  getMaxCombo(): number {
    return this._events.length
  }
}