Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion bemuse/src/game/audio/wave-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ export class WaveFactory {

// Plays a note
_play({ note, delay, exclusive }) {
// `note.keysound` and the keys of `this._map` are both canonical keysound
// IDs produced by the `bms` package (lowercased in base-36, case-preserved
// in base-62), so they can be matched directly without re-normalizing.
const keysound = note.keysound
if (exclusive) this._stopOldExclusiveSound(keysound, delay)
const filename = this._map[keysound.toLowerCase()]
const filename = this._map[keysound]
if (!filename) return null
const sample = this._samples[filename]
if (!sample) return null
Expand Down
5 changes: 4 additions & 1 deletion bemuse/src/previewer/NotechartPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,11 @@ class BemuseNotechartPreviewPlayer implements NotechartPreviewPlayer {
continue
}

// Canonical keysound IDs from the `bms` package: the event's keysound
// and the keys of `_keysounds` already agree (lowercased in base-36,
// case-preserved in base-62), so no re-normalization is needed.
const keysound = nextEvent.keysound
const filename = this._keysounds[keysound.toLowerCase()]
const filename = this._keysounds[keysound]
if (!filename) {
continue
}
Expand Down
8 changes: 8 additions & 0 deletions changelog/pr-861.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
author: dtinth
category: feature
type: minor
pr: 861
---

Added support for base62 keysound IDs in BMS charts (`#BASE 62`).
24 changes: 24 additions & 0 deletions examples/base62/base62-demo.bms
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
This is a minimal base62 demo chart (discussion #860).

Lines that do not begin with "#" are ignored by the BMS parser, so this
paragraph is just documentation.

Because of "#BASE 62", keysound IDs are CASE-SENSITIVE: "Aa", "AA", and "aA"
are three different sounds. On a base-36 chart they would all collapse to the
same sound. Drop this folder into the Bemuse custom-chart / preview area
(alongside the referenced .wav/.ogg files) to hear three distinct keysounds.

#PLAYER 1
#GENRE Demo
#TITLE base62 keysound demo
#ARTIST bemuse
#BASE 62
#BPM 120
#PLAYLEVEL 1
#RANK 3

#WAVAa kick.wav
#WAVAA snare.wav
#WAVaA hat.wav

#00111:AaAAaA00
34 changes: 34 additions & 0 deletions packages/bemuse-notechart/src/loader/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,40 @@ describe('NotechartLoader', function () {
const notechart = await loader.load(buffer, { name: 'wow.bms' }, options)
expect(notechart.landmines.length).to.equal(3)
})

it('folds keysound-ID case for base-36 charts (default)', async function () {
const loader = new NotechartLoader()
const buffer = Buffer.from(`#WAVAA cat.wav\n#00111:AA`)

const notechart = await loader.load(buffer, { name: 'wow.bms' }, options)
expect(notechart.keysounds).to.deep.equal({ aa: 'cat.wav' })
// note.keysound is canonical, so a direct map lookup resolves it.
const note = notechart.notes[0]
expect(note.keysound).to.equal('aa')
expect(notechart.keysounds[note.keysound]).to.equal('cat.wav')
})

it('keeps base-62 keysound IDs case-sensitive end-to-end', async function () {
const loader = new NotechartLoader()
const buffer = Buffer.from(
`#BASE 62\n#WAVAa lower.wav\n#WAVAA upper.wav\n#00111:AaAA`
)

const notechart = await loader.load(buffer, { name: 'wow.bms' }, options)
expect(notechart.keysounds).to.deep.equal({
Aa: 'lower.wav',
AA: 'upper.wav',
})
expect(notechart.samples.slice().sort()).to.deep.equal([
'lower.wav',
'upper.wav',
])
// Each note's canonical keysound resolves to the correct (case-sensitive)
// file via a direct map lookup — the same lookup the audio layer performs.
const sounds = notechart.notes.map((n) => notechart.keysounds[n.keysound])
expect(sounds).to.include('lower.wav')
expect(sounds).to.include('upper.wav')
})
})

describe('with bmson file', function () {
Expand Down
36 changes: 36 additions & 0 deletions packages/bms/spec/base62_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const { Reader, Compiler, Keysounds, Notes } = require('../lib')
const fs = require('fs')
const path = require('path')
const { expect } = require('chai')

function fixture(name) {
return fs.readFileSync(path.resolve(__dirname, 'fixtures/' + name))
}

describe('base62 keysound IDs (#BASE 62)', function () {
function loadFixture() {
const source = Reader.read(fixture('base62.bms'))
return Compiler.compile(source).chart
}

it('should expose the chart base', function () {
expect(loadFixture().base).to.equal(62)
expect(Compiler.compile('#TITLE hi').chart.base).to.equal(36)
})

it('should keep differently-cased WAV ids distinct end-to-end', function () {
const keysounds = Keysounds.fromBMSChart(loadFixture())
expect(keysounds.get('Aa')).to.equal('lower.wav')
expect(keysounds.get('AA')).to.equal('upper.wav')
expect(keysounds.get('aA')).to.equal('mixed.wav')
})

it('should preserve object value case so notes reference distinct sounds', function () {
const chart = loadFixture()
const notes = Notes.fromBMSChart(chart)
const keysounds = Keysounds.fromBMSChart(chart)
// Channel data `AaAAaA` => three notes referencing Aa, AA, aA.
const filenames = notes.all().map((note) => keysounds.get(note.keysound))
expect(filenames).to.deep.equal(['lower.wav', 'upper.wav', 'mixed.wav'])
})
})
10 changes: 10 additions & 0 deletions packages/bms/spec/fixtures/base62.bms
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#TITLE base62 sample
#ARTIST bemuse
#BASE 62
#BPM 120

#WAVAa lower.wav
#WAVAA upper.wav
#WAVaA mixed.wav

#00111:AaAAaA
52 changes: 51 additions & 1 deletion packages/bms/spec/keysounds_spec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,57 @@
const { Keysounds } = require('../lib')
const { Keysounds, Compiler } = require('../lib')
const { expect } = require('chai')

describe('Keysounds', function () {
describe('.fromBMSChart', function () {
it('should map keysound IDs to filenames', function () {
const chart = Compiler.compile('#WAVAA cat.wav\n#WAVBB dog.wav').chart
const keysounds = Keysounds.fromBMSChart(chart)
expect(keysounds.get('aa')).to.equal('cat.wav')
expect(keysounds.get('bb')).to.equal('dog.wav')
})

it('should fold case in base-36 charts (default)', function () {
const chart = Compiler.compile('#WAVAA cat.wav').chart
const keysounds = Keysounds.fromBMSChart(chart)
expect(keysounds.get('aa')).to.equal('cat.wav')
expect(keysounds.get('AA')).to.equal('cat.wav')
expect(keysounds.get('Aa')).to.equal('cat.wav')
})

it('should let a later definition override an earlier one (base 36)', function () {
const chart = Compiler.compile('#WAVAA cat.wav\n#WAVaa dog.wav').chart
const keysounds = Keysounds.fromBMSChart(chart)
expect(keysounds.get('aa')).to.equal('dog.wav')
})

describe('with #BASE 62', function () {
it('should treat differently-cased IDs as distinct keysounds', function () {
const chart = Compiler.compile(
'#BASE 62\n#WAVAa lower.wav\n#WAVAA upper.wav\n#WAVaA mixed.wav'
).chart
const keysounds = Keysounds.fromBMSChart(chart)
expect(keysounds.get('Aa')).to.equal('lower.wav')
expect(keysounds.get('AA')).to.equal('upper.wav')
expect(keysounds.get('aA')).to.equal('mixed.wav')
})

it('should be case-sensitive on lookup', function () {
const chart = Compiler.compile('#BASE 62\n#WAVAa cat.wav').chart
const keysounds = Keysounds.fromBMSChart(chart)
expect(keysounds.get('Aa')).to.equal('cat.wav')
expect(keysounds.get('AA')).to.equal(undefined)
expect(keysounds.get('aa')).to.equal(undefined)
})

it('should work regardless of where #BASE appears', function () {
const chart = Compiler.compile('#WAVAa cat.wav\n#BASE 62').chart
const keysounds = Keysounds.fromBMSChart(chart)
expect(keysounds.get('Aa')).to.equal('cat.wav')
expect(keysounds.get('AA')).to.equal(undefined)
})
})
})

describe('#files', function () {
it('should get list of all files', function () {
expect(
Expand Down
26 changes: 26 additions & 0 deletions packages/bms/spec/notes_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,32 @@ describe('Notes', function () {
expect(notes.all()).to.have.length(3)
})

describe('LNOBJ', function () {
it('should fold case in base-36 charts (default)', function () {
// The trailing `aa` matches `#LNOBJ AA` case-insensitively, so it
// terminates the `0B` note instead of creating a new one.
const chart = Compiler.compile('#LNOBJ AA\n#00111:0Baa').chart
const notes = Notes.fromBMSChart(chart)
expect(notes.all()).to.have.length(1)
expect(notes.all()[0].endBeat).to.not.equal(undefined)
})

describe('with #BASE 62', function () {
it('should terminate a note only on an exact-case match', function () {
const chart = Compiler.compile('#BASE 62\n#LNOBJ AA\n#00111:0BAA').chart
const notes = Notes.fromBMSChart(chart)
expect(notes.all()).to.have.length(1)
expect(notes.all()[0].endBeat).to.not.equal(undefined)
})

it('should not terminate a note on a differently-cased ID', function () {
const chart = Compiler.compile('#BASE 62\n#LNOBJ AA\n#00111:0Baa').chart
const notes = Notes.fromBMSChart(chart)
expect(notes.all()).to.have.length(2)
})
})
})

describe('custom mapping', function () {
it('allows custom mapping', function () {
const chart = Compiler.compile('#00112:01').chart
Expand Down
33 changes: 33 additions & 0 deletions packages/bms/spec/timing_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,38 @@ describe('Timing', function () {
const t = Timing.fromBMSChart(chart)
expect(t.bpmAtBeat(5)).to.equal(123.45)
})

it('should fold #EXBPM id case in base-36 charts (default)', function () {
const chart = Compiler.compile('#BPMAA 123.45\n#00108:aa').chart
const t = Timing.fromBMSChart(chart)
expect(t.bpmAtBeat(5)).to.equal(123.45)
})

describe('with #BASE 62', function () {
it('should resolve #EXBPM ids case-sensitively', function () {
const chart = Compiler.compile(
'#BASE 62\n#BPMAa 200\n#BPMAA 100\n#00108:Aa'
).chart
const t = Timing.fromBMSChart(chart)
expect(t.bpmAtBeat(5)).to.equal(200)
})

it('should not resolve an #EXBPM id of the wrong case', function () {
const chart = Compiler.compile('#BASE 62\n#BPMAa 200\n#00108:AA').chart
const t = Timing.fromBMSChart(chart)
// `AA` is undefined in base-62, so no BPM change happens.
expect(t.bpmAtBeat(5)).to.equal(60)
})

it('should resolve #STOP ids case-sensitively', function () {
const chart = Compiler.compile('#BASE 62\n#STOPAa 48\n#00109:Aa').chart
const t = Timing.fromBMSChart(chart)
// The stop object lands on measure 1 (beat 4). #STOPAa is 48 ticks =
// one beat, and the default BPM is 60, so it delays everything past
// beat 4 by one second: beat 5 shifts from 5s to 6s.
expect(t.getEventBeats()).to.deep.equal([4])
expect(t.beatToSeconds(5)).to.be.closeTo(6, 1e-2)
})
})
})
})
9 changes: 9 additions & 0 deletions packages/bms/src/bms/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export class BMSChart {
this.timeSignatures = new TimeSignatures()
}

/**
* The numeric base used for keysound/event IDs in this chart, as declared
* by the `#BASE` header. Defaults to `36` (case-insensitive IDs); a value of
* `62` makes IDs case-sensitive.
*/
get base(): number {
return this.headers.base
}

/**
* Public: Converts measure number and fraction into beat.
* A single beat is equivalent to a quarter note in common time signature.
Expand Down
45 changes: 42 additions & 3 deletions packages/bms/src/bms/headers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ID_INDEXED_COMMAND, normalizeIdSuffix } from '../util/id'

/**
* A BMSHeader holds the header information in a BMS file, such as
* `#TITLE`, `#ARTIST`, or `#BPM`.
Expand All @@ -18,12 +20,49 @@
export class BMSHeaders {
private _data: { [field: string]: string }
private _dataAll: { [field: string]: string[] }
private _base = 36

constructor() {
this._data = {}
this._dataAll = {}
}

/**
* The numeric base used for keysound/event IDs in this chart.
* Defaults to `36` (case-insensitive IDs); `62` enables case-sensitive IDs.
*/
get base(): number {
return this._base
}

/**
* Sets the numeric base for keysound/event IDs.
*
* This must be set (by the {Compiler}, from the `#BASE` header) before any
* ID-indexed headers are stored, so that their keys are normalized
* consistently with later lookups.
* @param base the numeric base (`36` or `62`)
*/
setBase(base: number) {
this._base = base
}

/**
* Normalizes a header field name into its storage key.
*
* The command prefix is always folded to lowercase (case-insensitive),
* while for ID-indexed commands (`#WAVxx`, `#BMPxx`, `#BPMxx`, `#STOPxx`)
* the 2-character ID suffix is normalized according to the chart’s base:
* lowercased in base-36, but case-preserved in base-62.
*/
private _normalizeName(name: string): string {
const match = name.match(ID_INDEXED_COMMAND)
if (match) {
return match[1].toLowerCase() + normalizeIdSuffix(match[2], this._base)
}
return name.toLowerCase()
}

/**
* Iterates through each header field using a callback function.
* @param callback will be called for each header field
Expand All @@ -40,7 +79,7 @@ export class BMSHeaders {
* @return the field’s latest value
*/
get(name: string): string | undefined {
return this._data[name.toLowerCase()]
return this._data[this._normalizeName(name)]
}

/**
Expand All @@ -49,7 +88,7 @@ export class BMSHeaders {
* @param name field’s name
*/
getAll(name: string): string[] | undefined {
return this._dataAll[name.toLowerCase()]
return this._dataAll[this._normalizeName(name)]
}

/**
Expand All @@ -58,7 +97,7 @@ export class BMSHeaders {
* @param value field’s value
*/
set(name: string, value: string) {
const key = name.toLowerCase()
const key = this._normalizeName(name)
this._data[key] = value
;(this._dataAll[key] || (this._dataAll[key] = [])).push(value)
}
Expand Down
7 changes: 7 additions & 0 deletions packages/bms/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export function compile(text: string, options?: Partial<BMSCompileOptions>) {

const chart = new BMSChart()

// The `#BASE` header controls whether keysound/event IDs are case-sensitive.
// It is detected up-front (before the main pass) so that ID-indexed headers
// are stored with the correct case normalization regardless of where `#BASE`
// appears in the file.
const baseMatch = text.match(/^\s*#BASE\s+(\d+)/im)
if (baseMatch) chart.headers.setBase(+baseMatch[1])

const rng =
options.rng ||
function (max) {
Expand Down
Loading
Loading