Skip to content

Commit 381c018

Browse files
committed
Add progress bar to export. Made selection state be stored in undo history (similar to how it worked before the selection rework).
Fixed a bug where instrument copy paste didn't work for certain types.
1 parent ca2172f commit 381c018

File tree

3 files changed

+144
-109
lines changed

3 files changed

+144
-109
lines changed

editor/ExportPrompt.ts

Lines changed: 120 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { InstrumentType, EnvelopeType, Config, getArpeggioPitchIndex } from "../synth/SynthConfig";
44
import { Instrument, Pattern, Note, Song, Synth } from "../synth/synth";
5-
//import {ColorConfig} from "./ColorConfig";
5+
import {ColorConfig} from "./ColorConfig";
66
import { Preset, EditorConfig } from "./EditorConfig";
77
import { SongDocument } from "./SongDocument";
88
import { Prompt } from "./Prompt";
@@ -41,6 +41,14 @@ function save(blob: Blob, name: string): void {
4141
}
4242

4343
export class ExportPrompt implements Prompt {
44+
private synth: Synth;
45+
private thenExportTo: string;
46+
private recordedSamplesL: Float32Array;
47+
private recordedSamplesR: Float32Array;
48+
private sampleFrames: number;
49+
private totalChunks: number;
50+
private currentChunk: number;
51+
private outputStarted: boolean = false;
4452
private readonly _fileName: HTMLInputElement = input({ type: "text", style: "width: 10em;", value: "BeepBox-Song", maxlength: 250, "autofocus": "autofocus" });
4553
private readonly _computedSamplesLabel: HTMLDivElement = div({ style: "width: 10em;" }, new Text("0:00"));
4654
private readonly _enableIntro: HTMLInputElement = input({ type: "checkbox" });
@@ -54,6 +62,12 @@ export class ExportPrompt implements Prompt {
5462
);
5563
private readonly _cancelButton: HTMLButtonElement = button({ class: "cancelButton" });
5664
private readonly _exportButton: HTMLButtonElement = button({ class: "exportButton", style: "width:45%;" }, "Export");
65+
private readonly _outputProgressBar: HTMLDivElement = div({ style: `width: 0%; background: ${ColorConfig.loopAccent}; height: 100%; position: absolute; z-index: 2;` });
66+
private readonly _outputProgressLabel: HTMLDivElement = div({ style: `position: relative; top: -1px; z-index: 3;` }, "0%");
67+
private readonly _outputProgressContainer: HTMLDivElement = div({ style: `height: 12px; background: ${ColorConfig.uiWidgetBackground}; display: block; position: relative; z-index: 1;` },
68+
this._outputProgressBar,
69+
this._outputProgressLabel,
70+
);
5771
private static readonly midiSustainInstruments: number[] = [
5872
0x4A, // rounded -> recorder
5973
0x47, // triangle -> clarinet
@@ -108,7 +122,8 @@ export class ExportPrompt implements Prompt {
108122
),
109123
),
110124
div({ class: "selectContainer", style: "width: 100%;" }, this._formatSelect),
111-
div({ style: "text-align: left;" }, "(Be patient, exporting may take some time...)"),
125+
div({ style: "text-align: left;" }, "Exporting can be slow. Reloading the page or clicking the X will cancel it. Please be patient."),
126+
this._outputProgressContainer,
112127
div({ style: "display: flex; flex-direction: row-reverse; justify-content: space-between;" },
113128
this._exportButton,
114129
),
@@ -165,6 +180,7 @@ export class ExportPrompt implements Prompt {
165180
}
166181

167182
private _close = (): void => {
183+
this.outputStarted = false;
168184
this._doc.undo();
169185
}
170186

@@ -212,49 +228,120 @@ export class ExportPrompt implements Prompt {
212228
}
213229

214230
private _export = (): void => {
231+
if (this.outputStarted == true)
232+
return;
215233
window.localStorage.setItem("exportFormat", this._formatSelect.value);
216234
switch (this._formatSelect.value) {
217235
case "wav":
218-
this._exportToWav();
236+
this.outputStarted = true;
237+
this._exportTo("wav");
219238
break;
220239
case "mp3":
221-
this._exportToMp3();
240+
this.outputStarted = true;
241+
this._exportTo("mp3");
222242
break;
223243
case "midi":
244+
this.outputStarted = true;
224245
this._exportToMidi();
225246
break;
226247
case "json":
248+
this.outputStarted = true;
227249
this._exportToJson();
228250
break;
229251
default:
230252
throw new Error("Unhandled file export type.");
231253
}
232254
}
233255

234-
private _synthesize(sampleRate: number): { recordedSamplesL: Float32Array, recordedSamplesR: Float32Array } {
235-
const synth: Synth = new Synth(this._doc.song);
236-
synth.samplesPerSecond = sampleRate;
237-
synth.loopRepeatCount = Number(this._loopDropDown.value) - 1;
256+
private _synthesize(): void {
257+
//const timer: number = performance.now();
258+
259+
// If output was stopped e.g. user clicked the close button, abort.
260+
if (this.outputStarted == false) {
261+
return;
262+
}
263+
264+
// Update progress bar UI once per 5 sec of exported data
265+
const samplesPerChunk: number = this.synth.samplesPerSecond * 5; //e.g. 44100 * 5
266+
const currentFrame: number = this.currentChunk * samplesPerChunk;
267+
268+
const samplesInChunk: number = Math.min(samplesPerChunk, this.sampleFrames - currentFrame);
269+
const tempSamplesL = new Float32Array(samplesInChunk);
270+
const tempSamplesR = new Float32Array(samplesInChunk);
271+
272+
this.synth.synthesize(tempSamplesL, tempSamplesR, samplesInChunk);
273+
274+
// Concatenate chunk data into final array
275+
this.recordedSamplesL.set(tempSamplesL, currentFrame);
276+
this.recordedSamplesR.set(tempSamplesR, currentFrame);
277+
278+
// Update UI
279+
this._outputProgressBar.style.setProperty("width", Math.round((this.currentChunk + 1) / this.totalChunks * 100.0) + "%");
280+
this._outputProgressLabel.innerText = Math.round((this.currentChunk + 1) / this.totalChunks * 100.0) + "%";
281+
282+
// Next call, synthesize the next chunk.
283+
this.currentChunk++;
284+
285+
if (this.currentChunk >= this.totalChunks) {
286+
// Done, call final function
287+
this._outputProgressLabel.innerText = "Encoding...";
288+
if (this.thenExportTo == "wav") {
289+
this._exportToWavFinish();
290+
}
291+
else if (this.thenExportTo == "mp3") {
292+
this._exportToMp3Finish();
293+
}
294+
else {
295+
throw new Error("Unrecognized file export type chosen!");
296+
}
297+
}
298+
else {
299+
// Continue batch export
300+
setTimeout(() => { this._synthesize(); });
301+
}
302+
303+
//console.log("export timer", (performance.now() - timer) / 1000.0);
304+
}
305+
306+
private _exportTo(type: string): void {
307+
// Batch the export operation
308+
this.thenExportTo = type;
309+
this.currentChunk = 0;
310+
this.synth = new Synth(this._doc.song);
311+
if (type == "wav") {
312+
this.synth.samplesPerSecond = 48000; // Use professional video editing standard sample rate for .wav file export.
313+
}
314+
else if (type == "mp3") {
315+
this.synth.samplesPerSecond = 44100; // Use consumer CD standard sample rate for .mp3 export.
316+
}
317+
else {
318+
throw new Error("Unrecognized file export type chosen!");
319+
}
320+
321+
this._outputProgressBar.style.setProperty("width", "0%");
322+
this._outputProgressLabel.innerText = "0%";
323+
324+
this.synth.loopRepeatCount = Number(this._loopDropDown.value) - 1;
238325
if (!this._enableIntro.checked) {
239326
for (let introIter: number = 0; introIter < this._doc.song.loopStart; introIter++) {
240-
synth.nextBar();
327+
this.synth.nextBar();
241328
}
242329
}
243-
synth.computeLatestModValues();
244-
const sampleFrames: number = synth.getTotalSamples(this._enableIntro.checked, this._enableOutro.checked, synth.loopRepeatCount);
245-
const recordedSamplesL: Float32Array = new Float32Array(sampleFrames);
246-
const recordedSamplesR: Float32Array = new Float32Array(sampleFrames);
247-
//const timer: number = performance.now();
248-
synth.synthesize(recordedSamplesL, recordedSamplesR, sampleFrames);
249-
//console.log("export timer", (performance.now() - timer) / 1000.0);
330+
this.synth.computeLatestModValues();
331+
332+
this.sampleFrames = this.synth.getTotalSamples(this._enableIntro.checked, this._enableOutro.checked, this.synth.loopRepeatCount);
333+
// Compute how many UI updates will need to run to determine how many
334+
this.totalChunks = Math.ceil(this.sampleFrames / (this.synth.samplesPerSecond * 5));
335+
this.recordedSamplesL = new Float32Array(this.sampleFrames);
336+
this.recordedSamplesR = new Float32Array(this.sampleFrames);
250337

251-
return { recordedSamplesL, recordedSamplesR };
338+
// Batch the actual export
339+
setTimeout(() => { this._synthesize(); });
252340
}
253341

254-
private _exportToWav(): void {
255-
const sampleRate: number = 48000; // Use professional video editing standard sample rate for .wav file export.
256-
const { recordedSamplesL, recordedSamplesR } = this._synthesize(sampleRate);
257-
const sampleFrames: number = recordedSamplesL.length;
342+
private _exportToWavFinish(): void {
343+
const sampleFrames: number = this.recordedSamplesL.length;
344+
const sampleRate: number = this.synth.samplesPerSecond;
258345

259346
const wavChannelCount: number = 2;
260347
const bytesPerSample: number = 2;
@@ -284,8 +371,8 @@ export class ExportPrompt implements Prompt {
284371
// usually samples are signed.
285372
const range: number = (1 << (bitsPerSample - 1)) - 1;
286373
for (let i: number = 0; i < sampleFrames; i++) {
287-
let valL: number = Math.floor(Math.max(-1, Math.min(1, recordedSamplesL[i])) * range);
288-
let valR: number = Math.floor(Math.max(-1, Math.min(1, recordedSamplesR[i])) * range);
374+
let valL: number = Math.floor(Math.max(-1, Math.min(1, this.recordedSamplesL[i])) * range);
375+
let valR: number = Math.floor(Math.max(-1, Math.min(1, this.recordedSamplesR[i])) * range);
289376
if (bytesPerSample == 2) {
290377
data.setInt16(index, valL, true); index += 2;
291378
data.setInt16(index, valR, true); index += 2;
@@ -299,8 +386,8 @@ export class ExportPrompt implements Prompt {
299386
} else {
300387
// 8 bit samples are a special case: they are unsigned.
301388
for (let i: number = 0; i < sampleFrames; i++) {
302-
let valL: number = Math.floor(Math.max(-1, Math.min(1, recordedSamplesL[i])) * 127 + 128);
303-
let valR: number = Math.floor(Math.max(-1, Math.min(1, recordedSamplesR[i])) * 127 + 128);
389+
let valL: number = Math.floor(Math.max(-1, Math.min(1, this.recordedSamplesL[i])) * 127 + 128);
390+
let valR: number = Math.floor(Math.max(-1, Math.min(1, this.recordedSamplesR[i])) * 127 + 128);
304391
data.setUint8(index, valL > 255 ? 255 : (valL < 0 ? 0 : valL)); index++;
305392
data.setUint8(index, valR > 255 ? 255 : (valR < 0 ? 0 : valR)); index++;
306393
}
@@ -312,24 +399,21 @@ export class ExportPrompt implements Prompt {
312399
this._close();
313400
}
314401

315-
private _exportToMp3(): void {
402+
private _exportToMp3Finish( ): void {
316403
const whenEncoderIsAvailable = (): void => {
317-
const sampleRate: number = 44100; // Use consumer CD standard sample rate for .mp3 export.
318-
const { recordedSamplesL, recordedSamplesR } = this._synthesize(sampleRate);
319-
320404
const lamejs: any = (<any>window)["lamejs"];
321405
const channelCount: number = 2;
322406
const kbps: number = 192;
323407
const sampleBlockSize: number = 1152;
324-
const mp3encoder: any = new lamejs.Mp3Encoder(channelCount, sampleRate, kbps);
408+
const mp3encoder: any = new lamejs.Mp3Encoder(channelCount, this.synth.samplesPerSecond, kbps);
325409
const mp3Data: any[] = [];
326410

327-
const left: Int16Array = new Int16Array(recordedSamplesL.length);
328-
const right: Int16Array = new Int16Array(recordedSamplesR.length);
411+
const left: Int16Array = new Int16Array(this.recordedSamplesL.length);
412+
const right: Int16Array = new Int16Array(this.recordedSamplesR.length);
329413
const range: number = (1 << 15) - 1;
330-
for (let i: number = 0; i < recordedSamplesL.length; i++) {
331-
left[i] = Math.floor(Math.max(-1, Math.min(1, recordedSamplesL[i])) * range);
332-
right[i] = Math.floor(Math.max(-1, Math.min(1, recordedSamplesR[i])) * range);
414+
for (let i: number = 0; i < this.recordedSamplesL.length; i++) {
415+
left[i] = Math.floor(Math.max(-1, Math.min(1, this.recordedSamplesL[i])) * range);
416+
right[i] = Math.floor(Math.max(-1, Math.min(1, this.recordedSamplesR[i])) * range);
333417
}
334418

335419
for (let i: number = 0; i < left.length; i += sampleBlockSize) {
@@ -338,6 +422,7 @@ export class ExportPrompt implements Prompt {
338422
const mp3buf: any = mp3encoder.encodeBuffer(leftChunk, rightChunk);
339423
if (mp3buf.length > 0) mp3Data.push(mp3buf);
340424
}
425+
341426
const mp3buf: any = mp3encoder.flush();
342427
if (mp3buf.length > 0) mp3Data.push(mp3buf);
343428

editor/Selection.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class Selection {
3535
public patternSelectionActive: boolean = false;
3636

3737
private _changeTranspose: ChangeGroup | null = null;
38-
//private _changeTrack: ChangeGroup | null = null;
38+
private _changeTrack: ChangeGroup | null = null;
3939

4040
constructor(private _doc: SongDocument) { }
4141

@@ -87,16 +87,11 @@ export class Selection {
8787
}
8888

8989
public setChannelBar(channel: number, bar: number): void {
90-
/* BeepBox counts clicking around as a change.
91-
const canReplaceLastChange: boolean = this._doc.lastChangeWas(this._changeTrack);
90+
const canReplaceLastChange: boolean = true;//this._doc.lastChangeWas(this._changeTrack);
9291
this._changeTrack = new ChangeGroup();
9392
this._changeTrack.append(new ChangeChannelBar(this._doc, channel, bar));
9493
this._doc.record(this._changeTrack, canReplaceLastChange);
9594
this.selectionUpdated();
96-
*/
97-
// JummBox does not count this as a change
98-
new ChangeChannelBar(this._doc, channel, bar);
99-
this.selectionUpdated();
10095
}
10196

10297
public setPattern(pattern: number): void {
@@ -592,12 +587,10 @@ export class Selection {
592587
}
593588

594589
public setTrackSelection(newX0: number, newX1: number, newY0: number, newY1: number): void {
595-
//const canReplaceLastChange: boolean = this._doc.lastChangeWas(this._changeTrack);
596-
//this._changeTrack = new ChangeGroup();
597-
//this._changeTrack.append(new ChangeTrackSelection(this._doc, newX0, newX1, newY0, newY1));
598-
// this._doc.record(this._changeTrack, canReplaceLastChange);
599-
new ChangeTrackSelection(this._doc, newX0, newX1, newY0, newY1);
600-
// In JummBox selections don't cause a change.
590+
const canReplaceLastChange: boolean = true;//this._doc.lastChangeWas(this._changeTrack);
591+
this._changeTrack = new ChangeGroup();
592+
this._changeTrack.append(new ChangeTrackSelection(this._doc, newX0, newX1, newY0, newY1));
593+
this._doc.record(this._changeTrack, canReplaceLastChange);
601594
}
602595

603596
public transpose(upward: boolean, octave: boolean): void {

0 commit comments

Comments
 (0)