Skip to content

Commit b10c49a

Browse files
committed
Fixed prompt style bug by renaming className to class, and added .mp3 file export support.
1 parent 7eeb10c commit b10c49a

16 files changed

+122
-55
lines changed

README.md

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ Beep Box is developed by [John Nesky](http://www.johnnesky.com/).
1919

2020
The source code is available under the MIT license. The code is written in
2121
[TypeScript](https://www.typescriptlang.org/), which requires
22-
[node/npm](https://www.npmjs.com/get-npm), so install that first. Then to build
23-
this project, open the command line and run:
22+
[node & npm](https://www.npmjs.com/get-npm), so install those first. Then to
23+
build this project, open the command line and run:
2424

2525
```
2626
git clone https://github.com/johnnesky/beepbox.git
@@ -33,29 +33,38 @@ npm run build
3333

3434
The code is divided into several folders.
3535

36-
The synth/ folder has just the code you need to be able to play BeepBox songs
37-
out loud, and you could use this code in your own projects, like a web game.
38-
After compiling the synth code, open website/synth_example.html to see a demo
39-
using it. To rebuild just the synth code, run:
36+
The [synth/](synth) folder has just the code you need to be able to play BeepBox
37+
songs out loud, and you could use this code in your own projects, like a web
38+
game. After compiling the synth code, open website/synth_example.html to see a
39+
demo using it. To rebuild just the synth code, run:
4040

4141
```
4242
npm run build-synth
4343
```
4444

45-
The editor/ folder has additional code to display the online song editor
46-
interface. After compiling the editor code, open website/index.html to see the
47-
editor interface. To rebuild just the editor code, run:
45+
The [editor/](editor) folder has additional code to display the online song
46+
editor interface. After compiling the editor code, open website/index.html to
47+
see the editor interface. To rebuild just the editor code, run:
4848

4949
```
5050
npm run build-editor
5151
```
5252

53-
The player/ folder has a miniature song player interface for embedding on other
54-
sites. To rebuild just the player code, run:
53+
The [player/](player) folder has a miniature song player interface for embedding
54+
on other sites. To rebuild just the player code, run:
5555

5656
```
5757
npm run build-player
5858
```
5959

60-
The website/ folder contains index.html files to view the interfaces. The build
61-
process outputs JavaScript files into this folder.
60+
The [website/](website) folder contains index.html files to view the interfaces.
61+
The build process outputs JavaScript files into this folder.
62+
63+
## Dependencies
64+
65+
Most of the dependencies are listed in [package.json](package.json), although
66+
I'd like to note that BeepBox also has an indirect, optional dependency on
67+
[lamejs](https://github.com/zhuker/lamejs) via
68+
[jsdelivr](https://www.jsdelivr.com/) for exporting .mp3 files. If the user
69+
attempts to export an .mp3 file, BeepBox will direct the browser to download
70+
that dependency on demand.

editor/BarScrollBar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {ColorConfig} from "./ColorConfig";
2323
this._rightHighlight,
2424
);
2525

26-
public readonly container: HTMLElement = HTML.div({className: "barScrollBar", style: "width: 512px; height: 20px; overflow: hidden; position: relative;"}, this._svg);
26+
public readonly container: HTMLElement = HTML.div({class: "barScrollBar", style: "width: 512px; height: 20px; overflow: hidden; position: relative;"}, this._svg);
2727

2828
private _mouseX: number = 0;
2929
//private _mouseY: number = 0;

editor/BeatsPerBarPrompt.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ import {ColorConfig} from "./ColorConfig";
1717
option({value: "stretch"}, "Stretch notes to fit in bars."),
1818
option({value: "overflow"}, "Overflow notes across bars."),
1919
);
20-
private readonly _cancelButton: HTMLButtonElement = button({className: "cancelButton"});
21-
private readonly _okayButton: HTMLButtonElement = button({className: "okayButton", style: "width:45%;"}, "Okay");
20+
private readonly _cancelButton: HTMLButtonElement = button({class: "cancelButton"});
21+
private readonly _okayButton: HTMLButtonElement = button({class: "okayButton", style: "width:45%;"}, "Okay");
2222

23-
public readonly container: HTMLDivElement = div({className: "prompt noSelection", style: "width: 250px;"},
23+
public readonly container: HTMLDivElement = div({class: "prompt noSelection", style: "width: 250px;"},
2424
h2("Beats Per Bar"),
2525
div({style: "display: flex; flex-direction: row; align-items: center; height: 2em; justify-content: flex-end;"},
2626
div({style: "text-align: right;"},
@@ -31,7 +31,7 @@ import {ColorConfig} from "./ColorConfig";
3131
this._beatsStepper,
3232
),
3333
div({style: "display: flex; flex-direction: row; align-items: center; height: 2em; justify-content: flex-end;"},
34-
div({className: "selectContainer", style: "width: 100%;"}, this._conversionStrategySelect),
34+
div({class: "selectContainer", style: "width: 100%;"}, this._conversionStrategySelect),
3535
),
3636
div({style: "display: flex; flex-direction: row-reverse; justify-content: space-between;"},
3737
this._okayButton,

editor/ChannelSettingsPrompt.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import {ChangePatternsPerChannel, ChangeInstrumentsPerChannel, ChangeChannelCoun
1515
private readonly _instrumentsStepper: HTMLInputElement = input({style: "width: 3em; margin-left: 1em;", type: "number", step: "1"});
1616
private readonly _pitchChannelStepper: HTMLInputElement = input({style: "width: 3em; margin-left: 1em;", type: "number", step: "1"});
1717
private readonly _drumChannelStepper: HTMLInputElement = input({style: "width: 3em; margin-left: 1em;", type: "number", step: "1"});
18-
private readonly _cancelButton: HTMLButtonElement = button({className: "cancelButton"});
19-
private readonly _okayButton: HTMLButtonElement = button({className: "okayButton", style: "width:45%;"}, "Okay");
18+
private readonly _cancelButton: HTMLButtonElement = button({class: "cancelButton"});
19+
private readonly _okayButton: HTMLButtonElement = button({class: "okayButton", style: "width:45%;"}, "Okay");
2020

21-
public readonly container: HTMLDivElement = div({className: "prompt noSelection", style: "width: 250px;"},
21+
public readonly container: HTMLDivElement = div({class: "prompt noSelection", style: "width: 250px;"},
2222
h2("Channel Settings"),
2323
div({style: "display: flex; flex-direction: row; align-items: center; height: 2em; justify-content: flex-end;"},
2424
"Pitch channels:",

editor/EditorConfig.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {DictionaryArray, BeepBoxOption, InstrumentType, toNameMap} from "../synt
2323
}
2424

2525
export class EditorConfig {
26-
public static readonly version: string = "3.0.9";
26+
public static readonly version: string = "3.0.10";
2727
public static readonly versionDisplayName: string = "BeepBox " + EditorConfig.version;
2828
public static readonly presetCategories: DictionaryArray<PresetCategory> = toNameMap([
2929
{name: "Custom Instruments", presets: <DictionaryArray<Preset>> toNameMap([

editor/ExportPrompt.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
4747
private readonly _enableOutro: HTMLInputElement = input({type: "checkbox"});
4848
private readonly _formatSelect: HTMLSelectElement = select({style: "width: 100%;"},
4949
option({value: "wav"}, "Export to .wav file."),
50+
option({value: "mp3"}, "Export to .mp3 file."),
5051
option({value: "midi"}, "Export to .mid file."),
5152
option({value: "json"}, "Export to .json file."),
5253
);
53-
private readonly _cancelButton: HTMLButtonElement = button({className: "cancelButton"});
54-
private readonly _exportButton: HTMLButtonElement = button({className: "exportButton", style: "width:45%;"}, "Export");
54+
private readonly _cancelButton: HTMLButtonElement = button({class: "cancelButton"});
55+
private readonly _exportButton: HTMLButtonElement = button({class: "exportButton", style: "width:45%;"}, "Export");
5556
private static readonly midiSustainInstruments: number[] = [
5657
0x4A, // rounded -> recorder
5758
0x47, // triangle -> clarinet
@@ -83,7 +84,7 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
8384
0x6A, // spiky -> shamisen
8485
];
8586

86-
public readonly container: HTMLDivElement = div({className: "prompt noSelection", style: "width: 200px;"},
87+
public readonly container: HTMLDivElement = div({class: "prompt noSelection", style: "width: 200px;"},
8788
h2("Export Options"),
8889
div({style: "display: flex; flex-direction: row; align-items: center; justify-content: space-between;"},
8990
"File name:",
@@ -101,7 +102,8 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
101102
div({style: "display: table-cell; vertical-align: middle;"}, this._enableOutro),
102103
),
103104
),
104-
div({className: "selectContainer", style: "width: 100%;"}, this._formatSelect),
105+
div({class: "selectContainer", style: "width: 100%;"}, this._formatSelect),
106+
div({style: "text-align: left;"}, "(Be patient, exporting may take some time...)"),
105107
div({style: "display: flex; flex-direction: row-reverse; justify-content: space-between;"},
106108
this._exportButton,
107109
),
@@ -181,6 +183,9 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
181183
case "wav":
182184
this._exportToWav();
183185
break;
186+
case "mp3":
187+
this._exportToMp3();
188+
break;
184189
case "midi":
185190
this._exportToMidi();
186191
break;
@@ -192,9 +197,9 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
192197
}
193198
}
194199

195-
private _exportToWav(): void {
196-
200+
private _synthesize(sampleRate: number): {recordedSamplesL: Float32Array, recordedSamplesR: Float32Array} {
197201
const synth: Synth = new Synth(this._doc.song);
202+
synth.samplesPerSecond = sampleRate;
198203
synth.loopRepeatCount = Number(this._loopDropDown.value) - 1;
199204
if (!this._enableIntro.checked) {
200205
for (let introIter: number = 0; introIter < this._doc.song.loopStart; introIter++) {
@@ -208,8 +213,15 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
208213
synth.synthesize(recordedSamplesL, recordedSamplesR, sampleFrames);
209214
//console.log("export timer", (performance.now() - timer) / 1000.0);
210215

216+
return {recordedSamplesL, recordedSamplesR};
217+
}
218+
219+
private _exportToWav(): void {
220+
const sampleRate: number = 48000; // Use professional video editing standard sample rate for .wav file export.
221+
const {recordedSamplesL, recordedSamplesR} = this._synthesize(sampleRate);
222+
const sampleFrames: number = recordedSamplesL.length;
223+
211224
const wavChannelCount: number = 2;
212-
const sampleRate: number = 44100;
213225
const bytesPerSample: number = 2;
214226
const bitsPerSample: number = 8 * bytesPerSample;
215227
const sampleCount: number = wavChannelCount * sampleFrames;
@@ -235,9 +247,10 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
235247

236248
if (bytesPerSample > 1) {
237249
// usually samples are signed.
250+
const range: number = (1 << (bitsPerSample - 1)) - 1;
238251
for (let i: number = 0; i < sampleFrames; i++) {
239-
let valL: number = Math.floor(Math.max(-1, Math.min(1, recordedSamplesL[i])) * ((1 << (bitsPerSample - 1)) - 1));
240-
let valR: number = Math.floor(Math.max(-1, Math.min(1, recordedSamplesR[i])) * ((1 << (bitsPerSample - 1)) - 1));
252+
let valL: number = Math.floor(Math.max(-1, Math.min(1, recordedSamplesL[i])) * range);
253+
let valR: number = Math.floor(Math.max(-1, Math.min(1, recordedSamplesR[i])) * range);
241254
if (bytesPerSample == 2) {
242255
data.setInt16(index, valL, true); index += 2;
243256
data.setInt16(index, valR, true); index += 2;
@@ -258,12 +271,55 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
258271
}
259272
}
260273

261-
const blob = new Blob([arrayBuffer], {type: "audio/wav"});
274+
const blob: Blob = new Blob([arrayBuffer], {type: "audio/wav"});
262275
save(blob, this._fileName.value.trim() + ".wav");
263-
264276
this._close();
265277
}
266278

279+
private _exportToMp3(): void {
280+
const whenEncoderIsAvailable = (): void => {
281+
const sampleRate: number = 44100; // Use consumer CD standard sample rate for .mp3 export.
282+
const {recordedSamplesL, recordedSamplesR} = this._synthesize(sampleRate);
283+
284+
const lamejs: any = (<any> window)["lamejs"];
285+
const channelCount: number = 2;
286+
const kbps: number = 192;
287+
const sampleBlockSize: number = 1152;
288+
const mp3encoder: any = new lamejs.Mp3Encoder(channelCount, sampleRate, kbps);
289+
const mp3Data: any[] = [];
290+
291+
const left: Int16Array = new Int16Array(recordedSamplesL.length);
292+
const right: Int16Array = new Int16Array(recordedSamplesR.length);
293+
const range: number = (1 << 15) - 1;
294+
for (let i: number = 0; i < recordedSamplesL.length; i++) {
295+
left[i] = Math.floor(Math.max(-1, Math.min(1, recordedSamplesL[i])) * range);
296+
right[i] = Math.floor(Math.max(-1, Math.min(1, recordedSamplesR[i])) * range);
297+
}
298+
299+
for (let i: number = 0; i < left.length; i += sampleBlockSize) {
300+
const leftChunk: Int16Array = left.subarray(i, i + sampleBlockSize);
301+
const rightChunk: Int16Array = right.subarray(i, i + sampleBlockSize);
302+
const mp3buf: any = mp3encoder.encodeBuffer(leftChunk, rightChunk);
303+
if (mp3buf.length > 0) mp3Data.push(mp3buf);
304+
}
305+
const mp3buf: any = mp3encoder.flush();
306+
if (mp3buf.length > 0) mp3Data.push(mp3buf);
307+
308+
const blob: Blob = new Blob(mp3Data, {type: "audio/mp3"});
309+
save(blob, this._fileName.value.trim() + ".mp3");
310+
this._close();
311+
}
312+
313+
if ("lamejs" in window) {
314+
whenEncoderIsAvailable();
315+
} else {
316+
var script = document.createElement("script");
317+
script.src = "https://cdn.jsdelivr.net/npm/lamejs@1.2.0/lame.min.js";
318+
script.onload = whenEncoderIsAvailable;
319+
document.head.appendChild(script);
320+
}
321+
}
322+
267323
private _exportToMidi(): void {
268324
const song: Song = this._doc.song;
269325
const midiTicksPerBeepBoxTick: number = 2;
@@ -699,7 +755,7 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
699755
writer.rewriteUint32(trackStartIndex, writer.getWriteIndex() - trackStartIndex - 4);
700756
}
701757

702-
const blob = new Blob([writer.toCompactArrayBuffer()], {type: "audio/midi"});
758+
const blob: Blob = new Blob([writer.toCompactArrayBuffer()], {type: "audio/midi"});
703759
save(blob, this._fileName.value.trim() + ".mid");
704760

705761
this._close();
@@ -708,7 +764,7 @@ import {MidiChunkType, MidiFileFormat, MidiControlEventMessage, MidiEventType, M
708764
private _exportToJson(): void {
709765
const jsonObject: Object = this._doc.song.toJsonObject(this._enableIntro.checked, Number(this._loopDropDown.value), this._enableOutro.checked);
710766
const jsonString: string = JSON.stringify(jsonObject, null, '\t');
711-
const blob = new Blob([jsonString], {type: "application/json"});
767+
const blob: Blob = new Blob([jsonString], {type: "application/json"});
712768
save(blob, this._fileName.value.trim() + ".json");
713769
this._close();
714770
}

editor/HarmonicsEditor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {prettyNumber} from "./EditorConfig";
2424
this._lastControlPointContainer,
2525
);
2626

27-
public readonly container: HTMLElement = HTML.div({className: "harmonics", style: "height: 2em;"}, this._svg);
27+
public readonly container: HTMLElement = HTML.div({class: "harmonics", style: "height: 2em;"}, this._svg);
2828

2929
private _mouseX: number = 0;
3030
private _mouseY: number = 0;

editor/ImportPrompt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import {ArrayBufferReader} from "./ArrayBufferReader";
1616

1717
export class ImportPrompt implements Prompt {
1818
private readonly _fileInput: HTMLInputElement = input({type: "file", accept: ".json,application/json,.mid,.midi,audio/midi,audio/x-midi"});
19-
private readonly _cancelButton: HTMLButtonElement = button({className: "cancelButton"});
19+
private readonly _cancelButton: HTMLButtonElement = button({class: "cancelButton"});
2020

21-
public readonly container: HTMLDivElement = div({className: "prompt noSelection", style: "width: 300px;"},
21+
public readonly container: HTMLDivElement = div({class: "prompt noSelection", style: "width: 300px;"},
2222
h2("Import"),
2323
p({style: "text-align: left; margin: 0.5em 0;"},
2424
"BeepBox songs can be exported and re-imported as .json files. You could also use other means to make .json files for BeepBox as long as they follow the same structure.",

editor/MoveNotesSidewaysPrompt.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import {ColorConfig} from "./ColorConfig";
1616
option({value: "overflow"}, "Overflow notes across bars."),
1717
option({value: "wrapAround"}, "Wrap notes around within bars."),
1818
);
19-
private readonly _cancelButton: HTMLButtonElement = button({className: "cancelButton"});
20-
private readonly _okayButton: HTMLButtonElement = button({className: "okayButton", style: "width:45%;"}, "Okay");
19+
private readonly _cancelButton: HTMLButtonElement = button({class: "cancelButton"});
20+
private readonly _okayButton: HTMLButtonElement = button({class: "okayButton", style: "width:45%;"}, "Okay");
2121

22-
public readonly container: HTMLDivElement = div({className: "prompt noSelection", style: "width: 250px;"},
22+
public readonly container: HTMLDivElement = div({class: "prompt noSelection", style: "width: 250px;"},
2323
h2("Move Notes Sideways"),
2424
div({style: "display: flex; flex-direction: row; align-items: center; height: 2em; justify-content: flex-end;"},
2525
div({style: "text-align: right;"},
@@ -30,7 +30,7 @@ import {ColorConfig} from "./ColorConfig";
3030
this._beatsStepper,
3131
),
3232
div({style: "display: flex; flex-direction: row; align-items: center; height: 2em; justify-content: flex-end;"},
33-
div({className: "selectContainer", style: "width: 100%;"}, this._conversionStrategySelect),
33+
div({class: "selectContainer", style: "width: 100%;"}, this._conversionStrategySelect),
3434
),
3535
div({style: "display: flex; flex-direction: row-reverse; justify-content: space-between;"},
3636
this._okayButton,

editor/SongDurationPrompt.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ import {ColorConfig} from "./ColorConfig";
1717
option({value: "end"}, "Apply change at end of song."),
1818
option({value: "beginning"}, "Apply change at beginning of song."),
1919
);
20-
private readonly _cancelButton: HTMLButtonElement = button({className: "cancelButton"});
21-
private readonly _okayButton: HTMLButtonElement = button({className: "okayButton", style: "width:45%;"}, "Okay");
20+
private readonly _cancelButton: HTMLButtonElement = button({class: "cancelButton"});
21+
private readonly _okayButton: HTMLButtonElement = button({class: "okayButton", style: "width:45%;"}, "Okay");
2222

23-
public readonly container: HTMLDivElement = div({className: "prompt noSelection", style: "width: 250px;"},
23+
public readonly container: HTMLDivElement = div({class: "prompt noSelection", style: "width: 250px;"},
2424
h2("Song Length"),
2525
div({style: "display: flex; flex-direction: row; align-items: center; height: 2em; justify-content: flex-end;"},
2626
div({style: "display: inline-block; text-align: right;"},
@@ -31,7 +31,7 @@ import {ColorConfig} from "./ColorConfig";
3131
this._barsStepper,
3232
),
3333
div({style: "display: flex; flex-direction: row; align-items: center; height: 2em; justify-content: flex-end;"},
34-
div({className: "selectContainer", style: "width: 100%;"}, this._positionSelect),
34+
div({class: "selectContainer", style: "width: 100%;"}, this._positionSelect),
3535
),
3636
div({style: "display: flex; flex-direction: row-reverse; justify-content: space-between;"},
3737
this._okayButton,

0 commit comments

Comments
 (0)