diff --git a/editor/BeatsPerBarPrompt.ts b/editor/BeatsPerBarPrompt.ts index 4f7a9131..ef7c1e8a 100644 --- a/editor/BeatsPerBarPrompt.ts +++ b/editor/BeatsPerBarPrompt.ts @@ -97,7 +97,7 @@ namespace beepbox { private _saveChanges = (): void => { window.localStorage.setItem("beatCountStrategy", this._conversionStrategySelect.value); this._doc.prompt = null; - this._doc.record(new ChangeBeatsPerBar(this._doc, BeatsPerBarPrompt._validate(this._beatsStepper), this._conversionStrategySelect.value), "replace"); + this._doc.record(new ChangeBeatsPerBar(this._doc, BeatsPerBarPrompt._validate(this._beatsStepper), this._conversionStrategySelect.value), StateChangeType.replace); } } } diff --git a/editor/ChannelSettingsPrompt.ts b/editor/ChannelSettingsPrompt.ts index 63e2560f..9d43b5f9 100644 --- a/editor/ChannelSettingsPrompt.ts +++ b/editor/ChannelSettingsPrompt.ts @@ -109,7 +109,7 @@ namespace beepbox { private static _validateNumber(event: Event): void { const input: HTMLInputElement = event.target; - input.value = String(this._validate(input)); + input.value = String(ChannelSettingsPrompt._validate(input)); } private static _validate(input: HTMLInputElement): number { @@ -122,7 +122,7 @@ namespace beepbox { group.append(new ChangeInstrumentsPerChannel(this._doc, ChannelSettingsPrompt._validate(this._instrumentsStepper))); group.append(new ChangeChannelCount(this._doc, ChannelSettingsPrompt._validate(this._pitchChannelStepper), ChannelSettingsPrompt._validate(this._drumChannelStepper))); this._doc.prompt = null; - this._doc.record(group, "replace"); + this._doc.record(group, StateChangeType.replace); } } } diff --git a/editor/EditorConfig.ts b/editor/EditorConfig.ts index 7fcf097e..cc6e1ac6 100644 --- a/editor/EditorConfig.ts +++ b/editor/EditorConfig.ts @@ -19,7 +19,7 @@ namespace beepbox { export const isMobile: boolean = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|android|ipad|playbook|silk/i.test(navigator.userAgent); export class EditorConfig { - public static readonly version: string = "3.0.8"; + public static readonly version: string = "3.0.9"; public static readonly versionDisplayName: string = "BeepBox " + EditorConfig.version; public static readonly presetCategories: DictionaryArray = toNameMap([ {name: "Custom Instruments", presets: > toNameMap([ diff --git a/editor/ImportPrompt.ts b/editor/ImportPrompt.ts index 05f18325..49759d19 100644 --- a/editor/ImportPrompt.ts +++ b/editor/ImportPrompt.ts @@ -55,7 +55,7 @@ namespace beepbox { reader.addEventListener("load", (event: Event): void => { this._doc.prompt = null; this._doc.goBackToStart(); - this._doc.record(new ChangeSong(this._doc, reader.result), "replace"); + this._doc.record(new ChangeSong(this._doc, reader.result), StateChangeType.replace, true); }); reader.readAsText(file); } else if (extension == "midi" || extension == "mid") { @@ -858,7 +858,7 @@ namespace beepbox { this._doc.goBackToStart(); for (const channel of this._doc.song.channels) channel.muted = false; this._doc.prompt = null; - this._doc.record(new ChangeImportMidi(this._doc), "replace"); + this._doc.record(new ChangeImportMidi(this._doc), StateChangeType.replace, true); } } } diff --git a/editor/MoveNotesSidewaysPrompt.ts b/editor/MoveNotesSidewaysPrompt.ts index fc8640c2..bae60a6e 100644 --- a/editor/MoveNotesSidewaysPrompt.ts +++ b/editor/MoveNotesSidewaysPrompt.ts @@ -83,7 +83,7 @@ namespace beepbox { private _saveChanges = (): void => { window.localStorage.setItem("moveNotesSidewaysStrategy", this._conversionStrategySelect.value); this._doc.prompt = null; - this._doc.record(new ChangeMoveNotesSideways(this._doc, +this._beatsStepper.value, this._conversionStrategySelect.value), "replace"); + this._doc.record(new ChangeMoveNotesSideways(this._doc, +this._beatsStepper.value, this._conversionStrategySelect.value), StateChangeType.replace); } } } diff --git a/editor/OctaveScrollBar.ts b/editor/OctaveScrollBar.ts index 27b2cf91..61aa76d6 100644 --- a/editor/OctaveScrollBar.ts +++ b/editor/OctaveScrollBar.ts @@ -174,12 +174,12 @@ namespace beepbox { if (this._mouseY < this._barBottom - this._barHeight * 0.5) { if (currentOctave < Config.scrollableOctaves) { this._change = new ChangeOctave(this._doc, oldValue, currentOctave + 1); - this._doc.record(this._change, canReplaceLastChange ? "replace" : "push"); + this._doc.record(this._change, canReplaceLastChange ? StateChangeType.replace : StateChangeType.push); } } else { if (currentOctave > 0) { this._change = new ChangeOctave(this._doc, oldValue, currentOctave - 1); - this._doc.record(this._change, canReplaceLastChange ? "replace" : "push"); + this._doc.record(this._change, canReplaceLastChange ? StateChangeType.replace : StateChangeType.push); } } } diff --git a/editor/SongDocument.ts b/editor/SongDocument.ts index ced5e88f..817f452b 100644 --- a/editor/SongDocument.ts +++ b/editor/SongDocument.ts @@ -1,6 +1,7 @@ // Copyright (C) 2020 John Nesky, distributed under the MIT license. /// +/// /// /// @@ -10,10 +11,16 @@ namespace beepbox { sequenceNumber: number; bar: number; channel: number; + recoveryUid: string; prompt: string | null; } - type StateChangeType = "replace" | "push" | "jump"; + export const enum StateChangeType { + replace = 0, + push, + jump, + length, + } export class SongDocument { public song: Song; @@ -39,9 +46,12 @@ namespace beepbox { public prompt: string | null = null; private static readonly _maximumUndoHistory: number = 100; + private _recovery: SongRecovery = new SongRecovery(); + private _recoveryUid: string; private _recentChange: Change | null = null; private _sequenceNumber: number = 0; - private _stateChangeType: StateChangeType = "replace"; + private _stateChangeType: StateChangeType = StateChangeType.replace; + private _recordedNewSong: boolean = false; private _barFromCurrentState: number = 0; private _channelFromCurrentState: number = 0; private _waitingToUpdateState: boolean = false; @@ -86,16 +96,18 @@ namespace beepbox { let state: HistoryState | null = this._getHistoryState(); if (state == null) { // When the page is first loaded, indicate that undo is NOT possible. - state = {canUndo: false, sequenceNumber: 0, bar: 0, channel: 0, prompt: null}; + state = {canUndo: false, sequenceNumber: 0, bar: 0, channel: 0, recoveryUid: generateUid(), prompt: null}; } + if (state.recoveryUid == undefined) state.recoveryUid = generateUid(); this._replaceState(state, songString); - window.addEventListener("hashchange", this._whenUrlHashChanged); + window.addEventListener("hashchange", this._whenHistoryStateChanged); window.addEventListener("popstate", this._whenHistoryStateChanged); this.bar = state.bar; this.channel = state.channel; this._barFromCurrentState = state.bar; this._channelFromCurrentState = state.channel; + this._recoveryUid = state.recoveryUid; this.barScrollPos = Math.max(0, this.bar - (this.trackVisibleBars - 6)); this.prompt = state.prompt; @@ -187,12 +199,14 @@ namespace beepbox { } } - private _whenUrlHashChanged = (): void => { + private _whenHistoryStateChanged = (): void => { if (window.history.state == null && window.location.hash != "") { // The user changed the hash directly. this._sequenceNumber++; - const state: HistoryState = {canUndo: true, sequenceNumber: this._sequenceNumber, bar: this.bar, channel: this.channel, prompt: this.prompt}; + this._resetSongRecoveryUid(); + const state: HistoryState = {canUndo: true, sequenceNumber: this._sequenceNumber, bar: this.bar, channel: this.channel, recoveryUid: this._recoveryUid, prompt: null}; new ChangeSong(this, window.location.hash); + this.prompt = state.prompt; if (this.displayBrowserUrl) { this._replaceState(state, this.song.toBase64String()); } else { @@ -200,14 +214,12 @@ namespace beepbox { } this.forgetLastChange(); this.notifier.notifyWatchers(); + return; } - } - - private _whenHistoryStateChanged = (): void => { + const state: HistoryState | null = this._getHistoryState(); if (state == null) throw new Error("History state is null."); - // We're listening for both hashchanged and popstate, which often fire together. // Abort if we've already handled the current state. if (state.sequenceNumber == this._sequenceNumber) return; @@ -226,6 +238,7 @@ namespace beepbox { this._barFromCurrentState = state.bar; this._channelFromCurrentState = state.channel; + this._recoveryUid = state.recoveryUid; //this.barScrollPos = Math.min(this.bar, Math.max(this.bar - (this.trackVisibleBars - 1), this.barScrollPos)); @@ -240,48 +253,58 @@ namespace beepbox { private _updateHistoryState = (): void => { this._waitingToUpdateState = false; const hash: string = this.song.toBase64String(); - if (this._stateChangeType == "push") { + if (this._stateChangeType == StateChangeType.push) { this._sequenceNumber++; - } else if (this._stateChangeType == "jump") { + } else if (this._stateChangeType >= StateChangeType.jump) { this._sequenceNumber += 2; } - let state: HistoryState = {canUndo: true, sequenceNumber: this._sequenceNumber, bar: this.bar, channel: this.channel, prompt: this.prompt}; - if (this._stateChangeType == "push" || this._stateChangeType == "jump") { - this._pushState(state, hash); + if (this._recordedNewSong) { + this._resetSongRecoveryUid(); } else { + this._recovery.saveVersion(this._recoveryUid, hash); + } + let state: HistoryState = {canUndo: true, sequenceNumber: this._sequenceNumber, bar: this.bar, channel: this.channel, recoveryUid: this._recoveryUid, prompt: this.prompt}; + if (this._stateChangeType == StateChangeType.replace) { this._replaceState(state, hash); + } else { + this._pushState(state, hash); } this._barFromCurrentState = state.bar; this._channelFromCurrentState = state.channel; - this._stateChangeType = "replace"; + this._stateChangeType = StateChangeType.replace; + this._recordedNewSong = false; } - public record(change: Change, stateChangeType: StateChangeType = "push"): void { + public record(change: Change, stateChangeType: StateChangeType = StateChangeType.push, newSong: boolean = false): void { if (change.isNoop()) { this._recentChange = null; - if (stateChangeType == "replace") { + if (stateChangeType == StateChangeType.replace) { this._back(); } } else { change.commit(); this._recentChange = change; - if (stateChangeType == "push" && this._stateChangeType == "replace") { - this._stateChangeType = stateChangeType; - } else if (stateChangeType == "jump") { - this._stateChangeType = stateChangeType; - } + if (this._stateChangeType < stateChangeType) this._stateChangeType = stateChangeType; + this._recordedNewSong = this._recordedNewSong || newSong; if (!this._waitingToUpdateState) { + // Defer updating the url/history until all sequenced changes have + // committed and the interface has rendered the latest changes to + // improve perceived responsiveness. window.requestAnimationFrame(this._updateHistoryState); this._waitingToUpdateState = true; } } } + private _resetSongRecoveryUid(): void { + this._recoveryUid = generateUid(); + } + public openPrompt(prompt: string): void { this.prompt = prompt; const hash: string = this.song.toBase64String(); this._sequenceNumber++; - const state = {canUndo: true, sequenceNumber: this._sequenceNumber, bar: this.bar, channel: this.channel, prompt: this.prompt}; + const state = {canUndo: true, sequenceNumber: this._sequenceNumber, bar: this.bar, channel: this.channel, recoveryUid: this._recoveryUid, prompt: this.prompt}; this._pushState(state, hash); } diff --git a/editor/SongDurationPrompt.ts b/editor/SongDurationPrompt.ts index 1b6a7626..0879beb7 100644 --- a/editor/SongDurationPrompt.ts +++ b/editor/SongDurationPrompt.ts @@ -98,7 +98,7 @@ namespace beepbox { const group: ChangeGroup = new ChangeGroup(); group.append(new ChangeBarCount(this._doc, SongDurationPrompt._validate(this._barsStepper), this._positionSelect.value == "beginning")); this._doc.prompt = null; - this._doc.record(group, "replace"); + this._doc.record(group, StateChangeType.replace); } } } diff --git a/editor/SongEditor.ts b/editor/SongEditor.ts index e3d29fdc..e9d99592 100644 --- a/editor/SongEditor.ts +++ b/editor/SongEditor.ts @@ -25,6 +25,7 @@ /// /// /// +/// namespace beepbox { const {button, div, input, select, span, optgroup, option} = HTML; @@ -134,6 +135,7 @@ namespace beepbox { option({value: "shareUrl"}, "⤳ Share Song URL"), option({value: "viewPlayer"}, "▶ View in Song Player"), option({value: "copyEmbed"}, "⎘ Copy HTML Embed Code"), + option({value: "songRecovery"}, "⚠ Recover Recent Song..."), ); private readonly _editMenu: HTMLSelectElement = select({style: "width: 100%;"}, option({selected: true, disabled: true, hidden: false}, "Edit"), // todo: "hidden" should be true but looks wrong on mac chrome, adds checkmark next to first visible option. :( @@ -526,6 +528,9 @@ namespace beepbox { case "import": this.prompt = new ImportPrompt(this._doc); break; + case "songRecovery": + this.prompt = new SongRecoveryPrompt(this._doc); + break; case "barCount": this.prompt = new SongDurationPrompt(this._doc); break; @@ -1170,7 +1175,7 @@ namespace beepbox { case "new": this._doc.goBackToStart(); for (const channel of this._doc.song.channels) channel.muted = false; - this._doc.record(new ChangeSong(this._doc, "")); + this._doc.record(new ChangeSong(this._doc, ""), StateChangeType.push, true); break; case "export": this._openPrompt("export"); @@ -1178,9 +1183,9 @@ namespace beepbox { case "import": this._openPrompt("import"); break; - case "copyUrl": { + case "copyUrl": this._copyTextToClipboard(new URL("#" + this._doc.song.toBase64String(), location.href).href); - } break; + break; case "shareUrl": (navigator).share({ url: new URL("#" + this._doc.song.toBase64String(), location.href).href }); break; @@ -1190,6 +1195,9 @@ namespace beepbox { case "copyEmbed": this._copyTextToClipboard(``); break; + case "songRecovery": + this._openPrompt("songRecovery"); + break; } this._fileMenu.selectedIndex = 0; } diff --git a/editor/SongRecovery.ts b/editor/SongRecovery.ts new file mode 100644 index 00000000..719f48b5 --- /dev/null +++ b/editor/SongRecovery.ts @@ -0,0 +1,168 @@ +// Copyright (C) 2020 John Nesky, distributed under the MIT license. + +/// + +namespace beepbox { + + export interface RecoveredVersion { + uid: string; + time: number; + work: number; + } + + export interface RecoveredSong { + versions: RecoveredVersion[]; + } + + const versionPrefix = "songVersion: "; + const maximumSongCount = 8; + const maximumWorkPerVersion = 3 * 60 * 1000; // 3 minutes + const minimumWorkPerSpan = 1 * 60 * 1000; // 1 minute + + function keyIsVersion(key: string): boolean { + return key.indexOf(versionPrefix) == 0; + } + + function keyToVersion(key: string): RecoveredVersion { + return JSON.parse(key.substring(versionPrefix.length)); + } + + export function versionToKey(version: RecoveredVersion): string { + return versionPrefix + JSON.stringify(version); + } + + export function generateUid(): string { + // Not especially robust, but simple and effective! + return ("00000"+(Math.random()*(-1>>>0)>>>0).toString(32)).slice(-6); + } + + function compareSongs(a: RecoveredSong, b: RecoveredSong): number { + return b.versions[0].time - a.versions[0].time; + } + + function compareVersions(a: RecoveredVersion, b: RecoveredVersion): number { + return b.time - a.time; + } + + export class SongRecovery { + private _saveVersionTimeoutHandle: number = 0; + + private _song: Song = new Song(); + + public static getAllRecoveredSongs(): RecoveredSong[] { + const songs: RecoveredSong[] = []; + const songsByUid: Dictionary = {}; + for (let i = 0; i < localStorage.length; i++) { + const itemKey: string = localStorage.key(i)!; + if (keyIsVersion(itemKey)) { + const version: RecoveredVersion = keyToVersion(itemKey); + let song: RecoveredSong | undefined = songsByUid[version.uid]; + if (song == undefined) { + song = {versions: []}; + songsByUid[version.uid] = song; + songs.push(song); + } + song.versions.push(version); + } + } + for (const song of songs) { + song.versions.sort(compareVersions); + } + songs.sort(compareSongs); + return songs; + } + + public saveVersion(uid: string, songData: string): void { + const newTime: number = Math.round(Date.now()); + + clearTimeout(this._saveVersionTimeoutHandle); + this._saveVersionTimeoutHandle = setTimeout((): void => { + try { + // Ensure that the song is not corrupted before saving it. + this._song.fromBase64String(songData); + } catch (error) { + window.alert("Whoops, the song data appears to have been corrupted! Please try to recover the last working version of the song from the \"Recover Recent Song...\" option in BeepBox's \"File\" menu."); + return; + } + + const songs: RecoveredSong[] = SongRecovery.getAllRecoveredSongs(); + let currentSong: RecoveredSong | null = null; + for (const song of songs) { + if (song.versions[0].uid == uid) { + currentSong = song; + } + } + if (currentSong == null) { + currentSong = {versions: []}; + songs.unshift(currentSong); + } + let versions: RecoveredVersion[] = currentSong.versions; + + let newWork: number = 1000; // default to 1 second of work for the first change. + if (versions.length > 0) { + const mostRecentTime: number = versions[0].time; + const mostRecentWork: number = versions[0].work; + newWork = mostRecentWork + Math.min(maximumWorkPerVersion, newTime - mostRecentTime); + } + + const newVersion: RecoveredVersion = {uid: uid, time: newTime, work: newWork}; + const newKey: string = versionToKey(newVersion); + versions.unshift(newVersion); + localStorage.setItem(newKey, songData); + + // Consider deleting an old version to free up space. + let minSpan: number = minimumWorkPerSpan; // start out with a gap between versions. + const spanMult: number = Math.pow(2, 1 / 2); // Double the span every 2 versions back. + for (var i: number = 1; i < versions.length; i++) { + const currentWork: number = versions[i].work; + const olderWork: number = (i == versions.length - 1) ? 0.0 : versions[i + 1].work; + // If not enough work happened between two versions, discard one of them. + if (currentWork - olderWork < minSpan) { + let indexToDiscard: number = i; + if (i < versions.length - 1) { + const currentTime: number = versions[i].time; + const newerTime: number = versions[i - 1].time; + const olderTime: number = versions[i + 1].time; + // Weird heuristic: Between the three adjacent versions, prefer to keep + // the newest and the oldest, discarding the middle one (i), unless + // there is a large gap of time between the newest and middle one, in + // which case the middle one represents the end of a span of work and is + // thus more memorable. + if ((currentTime - olderTime) < 0.5 * (newerTime - currentTime)) { + indexToDiscard = i + 1; + } + } + localStorage.removeItem(versionToKey(versions[indexToDiscard])); + break; + } + minSpan *= spanMult; + } + + // If there are too many songs, discard the least important ones. + // Songs that are older, or have less work, are less important. + while (songs.length > maximumSongCount) { + let leastImportantSong: RecoveredSong | null = null; + let leastImportance: number = Number.POSITIVE_INFINITY; + for (let i: number = Math.round(maximumSongCount / 2); i < songs.length; i++) { + const song: RecoveredSong = songs[i]; + const timePassed: number = newTime - song.versions[0].time; + // Convert the time into a factor of 12 hours, add one, then divide by the result. + // This creates a curve that starts at 1, and then gradually drops off. + const timeScale: number = 1.0 / ((timePassed / (12 * 60 * 60 * 1000)) + 1.0); + // Add 5 minutes of work, to balance out simple songs a little bit. + const adjustedWork: number = song.versions[0].work + 5 * 60 * 1000; + const weight: number = adjustedWork * timeScale; + if (leastImportance > weight) { + leastImportance = weight; + leastImportantSong = song; + } + } + for (const version of leastImportantSong!.versions) { + localStorage.removeItem(versionToKey(version)); + } + songs.splice(songs.indexOf(leastImportantSong!), 1); + } + }, 750); // Wait 3/4 of a second before saving a version. + } + } +} \ No newline at end of file diff --git a/editor/SongRecoveryPrompt.ts b/editor/SongRecoveryPrompt.ts new file mode 100644 index 00000000..ef0920d1 --- /dev/null +++ b/editor/SongRecoveryPrompt.ts @@ -0,0 +1,63 @@ +// Copyright (C) 2020 John Nesky, distributed under the MIT license. + +/// +/// +/// +/// +/// + +namespace beepbox { + const {button, div, h2, p, select, option, iframe} = HTML; + + export class SongRecoveryPrompt implements Prompt { + private readonly _songContainer: HTMLDivElement = div(); + private readonly _cancelButton: HTMLButtonElement = button({className: "cancelButton"}); + + public readonly container: HTMLDivElement = div({className: "prompt", style: "width: 300px;"}, + h2("Song Recovery"), + div({style: "max-height: 385px; overflow-y: auto;"}, + p("This is a TEMPORARY list of songs you have recently modified. Please keep your own backups of songs you care about!"), + this._songContainer, + p("(If \"Display Song Data in URL\" is enabled in your preferences, then you may also be able to find song versions in your browser history. However, song recovery won't work if you were browsing in private/incognito mode.)"), + ), + this._cancelButton, + ); + + constructor(private _doc: SongDocument) { + this._cancelButton.addEventListener("click", this._close); + + const songs: RecoveredSong[] = SongRecovery.getAllRecoveredSongs(); + + if (songs.length == 0) { + this._songContainer.appendChild(p("There are no recovered songs available yet. Try making a song!")); + } + + for (const song of songs) { + const versionMenu: HTMLSelectElement = select({style: "width: 100%;"}); + + for (const version of song.versions) { + versionMenu.appendChild(option({value: version.time}, new Date(version.time).toLocaleString())); + } + + const player: HTMLIFrameElement = iframe({style: "width: 100%; height: 60px; border: none; display: block;"}); + player.src = "player/#song=" + window.localStorage.getItem(versionToKey(song.versions[0])); + const container: HTMLDivElement = div({style: "margin: 4px 0;"}, div({className: "selectContainer", style: "width: 100%; margin: 2px 0;"}, versionMenu), player); + this._songContainer.appendChild(container); + + versionMenu.addEventListener("change", () => { + const version: RecoveredVersion = song.versions[versionMenu.selectedIndex]; + player.contentWindow!.location.replace("player/#song=" + window.localStorage.getItem(versionToKey(version))); + player.contentWindow!.dispatchEvent(new Event("hashchange")); + }); + } + } + + private _close = (): void => { + this._doc.undo(); + } + + public cleanUp = (): void => { + this._cancelButton.removeEventListener("click", this._close); + } + } +} diff --git a/editor/TrackEditor.ts b/editor/TrackEditor.ts index f8d834ea..f1a4b1e2 100644 --- a/editor/TrackEditor.ts +++ b/editor/TrackEditor.ts @@ -293,14 +293,14 @@ namespace beepbox { } public insertBars(): void { - this._doc.record(new ChangeInsertBars(this._doc, this._boxSelectionBar + this._boxSelectionWidth, this._boxSelectionWidth), "jump"); + this._doc.record(new ChangeInsertBars(this._doc, this._boxSelectionBar + this._boxSelectionWidth, this._boxSelectionWidth), StateChangeType.jump); const width: number = this._boxSelectionWidth; this._boxSelectionX0 += width; this._boxSelectionX1 += width; } public deleteBars(): void { - this._doc.record(new ChangeDeleteBars(this._doc, this._boxSelectionBar, this._boxSelectionWidth), "jump"); + this._doc.record(new ChangeDeleteBars(this._doc, this._boxSelectionBar, this._boxSelectionWidth), StateChangeType.jump); const width: number = this._boxSelectionWidth; this._boxSelectionX0 = Math.max(0, this._boxSelectionX0 - width); this._boxSelectionX1 = Math.max(0, this._boxSelectionX1 - width); @@ -691,7 +691,7 @@ namespace beepbox { } } - this._doc.record(group, canReplaceLastChange ? "replace" : "push"); + this._doc.record(group, canReplaceLastChange ? StateChangeType.replace : StateChangeType.push); } public setInstrument(instrument: number): void { diff --git a/synth/synth.ts b/synth/synth.ts index cbb2ceeb..570e2857 100644 --- a/synth/synth.ts +++ b/synth/synth.ts @@ -292,6 +292,11 @@ namespace beepbox { } } + function validateRange(min: number, max: number, val: number): number { + if (min <= val && val <= max) return val; + throw new Error(`Value ${val} not in range [${min}, ${max}]`); + } + export class Note { public pitches: number[]; public pins: NotePin[]; @@ -1382,41 +1387,44 @@ namespace beepbox { let instrumentChannelIterator: number = 0; let instrumentIndexIterator: number = -1; - - while (charIndex < compressed.length) { - const command: number = compressed.charCodeAt(charIndex++); - let channel: number; - if (command == SongTagCode.channelCount) { + let command: SongTagCode; + while (charIndex < compressed.length) switch(command = compressed.charCodeAt(charIndex++)) { + case SongTagCode.channelCount: { this.pitchChannelCount = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; this.noiseChannelCount = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; - this.pitchChannelCount = clamp(Config.pitchChannelCountMin, Config.pitchChannelCountMax + 1, this.pitchChannelCount); - this.noiseChannelCount = clamp(Config.noiseChannelCountMin, Config.noiseChannelCountMax + 1, this.noiseChannelCount); + this.pitchChannelCount = validateRange(Config.pitchChannelCountMin, Config.pitchChannelCountMax, this.pitchChannelCount); + this.noiseChannelCount = validateRange(Config.noiseChannelCountMin, Config.noiseChannelCountMax, this.noiseChannelCount); for (let channelIndex = this.channels.length; channelIndex < this.getChannelCount(); channelIndex++) { this.channels[channelIndex] = new Channel(); } this.channels.length = this.getChannelCount(); - } else if (command == SongTagCode.scale) { + } break; + case SongTagCode.scale: { this.scale = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; if (beforeThree && this.scale == 10) this.scale = 11; - } else if (command == SongTagCode.key) { + } break; + case SongTagCode.key: { if (beforeSeven) { this.key = clamp(0, Config.keys.length, 11 - base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } else { this.key = clamp(0, Config.keys.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.loopStart) { + } break; + case SongTagCode.loopStart: { if (beforeFive) { this.loopStart = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; } else { this.loopStart = (base64CharCodeToInt[compressed.charCodeAt(charIndex++)] << 6) + base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; } - } else if (command == SongTagCode.loopEnd) { + } break; + case SongTagCode.loopEnd: { if (beforeFive) { this.loopLength = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; } else { this.loopLength = (base64CharCodeToInt[compressed.charCodeAt(charIndex++)] << 6) + base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; } - } else if (command == SongTagCode.tempo) { + } break; + case SongTagCode.tempo: { if (beforeFour) { this.tempo = [95, 120, 151, 190][base64CharCodeToInt[compressed.charCodeAt(charIndex++)]]; } else if (beforeSeven) { @@ -1425,41 +1433,47 @@ namespace beepbox { this.tempo = (base64CharCodeToInt[compressed.charCodeAt(charIndex++)] << 6) | (base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } this.tempo = clamp(Config.tempoMin, Config.tempoMax + 1, this.tempo); - } else if (command == SongTagCode.reverb) { + } break; + case SongTagCode.reverb: { this.reverb = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; this.reverb = clamp(0, Config.reverbRange, this.reverb); - } else if (command == SongTagCode.beatCount) { + } break; + case SongTagCode.beatCount: { if (beforeThree) { this.beatsPerBar = [6, 7, 8, 9, 10][base64CharCodeToInt[compressed.charCodeAt(charIndex++)]]; } else { this.beatsPerBar = base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; } this.beatsPerBar = Math.max(Config.beatsPerBarMin, Math.min(Config.beatsPerBarMax, this.beatsPerBar)); - } else if (command == SongTagCode.barCount) { - this.barCount = (base64CharCodeToInt[compressed.charCodeAt(charIndex++)] << 6) + base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; - this.barCount = Math.max(Config.barCountMin, Math.min(Config.barCountMax, this.barCount)); + } break; + case SongTagCode.barCount: { + const barCount: number = (base64CharCodeToInt[compressed.charCodeAt(charIndex++)] << 6) + base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; + this.barCount = validateRange(Config.barCountMin, Config.barCountMax, barCount); for (let channel = 0; channel < this.getChannelCount(); channel++) { for (let bar = this.channels[channel].bars.length; bar < this.barCount; bar++) { this.channels[channel].bars[bar] = 1; } this.channels[channel].bars.length = this.barCount; } - } else if (command == SongTagCode.patternCount) { + } break; + case SongTagCode.patternCount: { + let patternsPerChannel: number; if (beforeEight) { - this.patternsPerChannel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; + patternsPerChannel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; } else { - this.patternsPerChannel = (base64CharCodeToInt[compressed.charCodeAt(charIndex++)] << 6) + base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; + patternsPerChannel = (base64CharCodeToInt[compressed.charCodeAt(charIndex++)] << 6) + base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; } - this.patternsPerChannel = Math.max(1, Math.min(Config.barCountMax, this.patternsPerChannel)); + this.patternsPerChannel = validateRange(1, Config.barCountMax, patternsPerChannel); for (let channel = 0; channel < this.getChannelCount(); channel++) { for (let pattern = this.channels[channel].patterns.length; pattern < this.patternsPerChannel; pattern++) { this.channels[channel].patterns[pattern] = new Pattern(); } this.channels[channel].patterns.length = this.patternsPerChannel; } - } else if (command == SongTagCode.instrumentCount) { - this.instrumentsPerChannel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; - this.instrumentsPerChannel = Math.max(Config.instrumentsPerChannelMin, Math.min(Config.instrumentsPerChannelMax, this.instrumentsPerChannel)); + } break; + case SongTagCode.instrumentCount: { + const instrumentsPerChannel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1; + this.instrumentsPerChannel = validateRange(Config.instrumentsPerChannelMin, Config.instrumentsPerChannelMax, instrumentsPerChannel); for (let channel = 0; channel < this.getChannelCount(); channel++) { const isNoiseChannel: boolean = channel >= this.pitchChannelCount; for (let instrumentIndex = this.channels[channel].instruments.length; instrumentIndex < this.instrumentsPerChannel; instrumentIndex++) { @@ -1472,37 +1486,43 @@ namespace beepbox { } } } - } else if (command == SongTagCode.rhythm) { + } break; + case SongTagCode.rhythm: { this.rhythm = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; - } else if (command == SongTagCode.channelOctave) { + } break; + case SongTagCode.channelOctave: { if (beforeThree) { - channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; + const channel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; this.channels[channel].octave = clamp(0, Config.scrollableOctaves + 1, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } else { - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { this.channels[channel].octave = clamp(0, Config.scrollableOctaves + 1, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } } - } else if (command == SongTagCode.startInstrument) { + } break; + case SongTagCode.startInstrument: { instrumentIndexIterator++; if (instrumentIndexIterator >= this.instrumentsPerChannel) { instrumentChannelIterator++; instrumentIndexIterator = 0; } + validateRange(0, this.channels.length - 1, instrumentChannelIterator); const instrument: Instrument = this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator]; - const instrumentType: number = clamp(0, InstrumentType.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); + const instrumentType: number = validateRange(0, InstrumentType.length - 1, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); instrument.setTypeAndReset(instrumentType, instrumentChannelIterator >= this.pitchChannelCount); - } else if (command == SongTagCode.preset) { + } break; + case SongTagCode.preset: { const presetValue: number = (base64CharCodeToInt[compressed.charCodeAt(charIndex++)] << 6) | (base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].preset = presetValue; - } else if (command == SongTagCode.wave) { + } break; + case SongTagCode.wave: { if (beforeThree) { const legacyWaves: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 0]; - channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; + const channel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; this.channels[channel].instruments[0].chipWave = clamp(0, Config.chipWaves.length, legacyWaves[base64CharCodeToInt[compressed.charCodeAt(charIndex++)]] | 0); } else if (beforeSix) { const legacyWaves: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 0]; - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { for (let i: number = 0; i < this.instrumentsPerChannel; i++) { if (channel >= this.pitchChannelCount) { this.channels[channel].instruments[i].chipNoise = clamp(0, Config.chipNoises.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); @@ -1525,25 +1545,26 @@ namespace beepbox { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].chipWave = clamp(0, Config.chipWaves.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } } - } else if (command == SongTagCode.filterCutoff) { + } break; + case SongTagCode.filterCutoff: { if (beforeSeven) { const legacyToCutoff: number[] = [10, 6, 3, 0, 8, 5, 2]; const legacyToEnvelope: number[] = [1, 1, 1, 1, 18, 19, 20]; const filterNames: string[] = ["none", "bright", "medium", "soft", "decay bright", "decay medium", "decay soft"]; if (beforeThree) { - channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; + const channel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; const instrument: Instrument = this.channels[channel].instruments[0]; const legacyFilter: number = [1, 3, 4, 5][clamp(0, filterNames.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)])]; instrument.filterCutoff = legacyToCutoff[legacyFilter]; instrument.filterEnvelope = legacyToEnvelope[legacyFilter]; instrument.filterResonance = 0; } else if (beforeSix) { - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { for (let i: number = 0; i < this.instrumentsPerChannel; i++) { const instrument: Instrument = this.channels[channel].instruments[i]; + const legacyFilter: number = clamp(0, filterNames.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1); if (channel < this.pitchChannelCount) { - const legacyFilter: number = clamp(0, filterNames.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)] + 1); instrument.filterCutoff = legacyToCutoff[legacyFilter]; instrument.filterEnvelope = legacyToEnvelope[legacyFilter]; instrument.filterResonance = 0; @@ -1565,9 +1586,11 @@ namespace beepbox { const instrument: Instrument = this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator]; instrument.filterCutoff = clamp(0, Config.filterCutoffRange, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.filterResonance) { + } break; + case SongTagCode.filterResonance: { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].filterResonance = clamp(0, Config.filterResonanceRange, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.filterEnvelope) { + } break; + case SongTagCode.filterEnvelope: { const instrument: Instrument = this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator]; if (instrument.type == InstrumentType.drumset) { for (let i: number = 0; i < Config.drumCount; i++) { @@ -1576,16 +1599,18 @@ namespace beepbox { } else { instrument.filterEnvelope = clamp(0, Config.envelopes.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.pulseWidth) { + } break; + case SongTagCode.pulseWidth: { const instrument: Instrument = this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator]; instrument.pulseWidth = clamp(0, Config.pulseWidthRange, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); instrument.pulseEnvelope = clamp(0, Config.envelopes.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.transition) { + } break; + case SongTagCode.transition: { if (beforeThree) { - channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; + const channel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; this.channels[channel].instruments[0].transition = clamp(0, Config.transitions.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } else if (beforeSix) { - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { for (let i: number = 0; i < this.instrumentsPerChannel; i++) { this.channels[channel].instruments[i].transition = clamp(0, Config.transitions.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } @@ -1593,11 +1618,12 @@ namespace beepbox { } else { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].transition = clamp(0, Config.transitions.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.vibrato) { + } break; + case SongTagCode.vibrato: { if (beforeThree) { const legacyEffects: number[] = [0, 3, 2, 0]; const legacyEnvelopes: number[] = [1, 1, 1, 13]; - channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; + const channel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; const effect: number = clamp(0, legacyEffects.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); const instrument: Instrument = this.channels[channel].instruments[0]; instrument.vibrato = legacyEffects[effect]; @@ -1607,7 +1633,7 @@ namespace beepbox { } else if (beforeSix) { const legacyEffects: number[] = [0, 1, 2, 3, 0, 0]; const legacyEnvelopes: number[] = [1, 1, 1, 1, 16, 13]; - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { for (let i: number = 0; i < this.instrumentsPerChannel; i++) { const effect: number = clamp(0, legacyEffects.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); const instrument: Instrument = this.channels[channel].instruments[i]; @@ -1630,12 +1656,13 @@ namespace beepbox { const vibrato: number = clamp(0, Config.vibratos.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].vibrato = vibrato; } - } else if (command == SongTagCode.interval) { + } break; + case SongTagCode.interval: { if (beforeThree) { - channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; + const channel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; this.channels[channel].instruments[0].interval = clamp(0, Config.intervals.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } else if (beforeSix) { - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { for (let i: number = 0; i < this.instrumentsPerChannel; i++) { const originalValue: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; let interval: number = clamp(0, Config.intervals.length, originalValue); @@ -1659,19 +1686,22 @@ namespace beepbox { } else { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].interval = clamp(0, Config.intervals.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.chord) { + } break; + case SongTagCode.chord: { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].chord = clamp(0, Config.chords.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.effects) { + } break; + case SongTagCode.effects: { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].effects = clamp(0, Config.effectsNames.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.volume) { + } break; + case SongTagCode.volume: { if (beforeThree) { - channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; + const channel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; const instrument: Instrument = this.channels[channel].instruments[0]; instrument.volume = clamp(0, Config.volumeRange, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); // legacy mute value: if (instrument.volume == 5) instrument.volume = Config.volumeRange - 1; } else if (beforeSix) { - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { for (let i: number = 0; i < this.instrumentsPerChannel; i++) { const instrument: Instrument = this.channels[channel].instruments[i]; instrument.volume = clamp(0, Config.volumeRange, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); @@ -1688,30 +1718,39 @@ namespace beepbox { const instrument: Instrument = this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator]; instrument.volume = clamp(0, Config.volumeRange, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.pan) { + } break; + case SongTagCode.pan: { const instrument: Instrument = this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator]; instrument.pan = clamp(0, Config.panMax + 1, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.algorithm) { + } break; + case SongTagCode.algorithm: { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].algorithm = clamp(0, Config.algorithms.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.feedbackType) { + } break; + case SongTagCode.feedbackType: { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].feedbackType = clamp(0, Config.feedbacks.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.feedbackAmplitude) { + } break; + case SongTagCode.feedbackAmplitude: { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].feedbackAmplitude = clamp(0, Config.operatorAmplitudeMax + 1, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.feedbackEnvelope) { + } break; + case SongTagCode.feedbackEnvelope: { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].feedbackEnvelope = clamp(0, Config.envelopes.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); - } else if (command == SongTagCode.operatorFrequencies) { + } break; + case SongTagCode.operatorFrequencies: { for (let o: number = 0; o < Config.operatorCount; o++) { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].operators[o].frequency = clamp(0, Config.operatorFrequencies.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.operatorAmplitudes) { + } break; + case SongTagCode.operatorAmplitudes: { for (let o: number = 0; o < Config.operatorCount; o++) { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].operators[o].amplitude = clamp(0, Config.operatorAmplitudeMax + 1, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.operatorEnvelopes) { + } break; + case SongTagCode.operatorEnvelopes: { for (let o: number = 0; o < Config.operatorCount; o++) { this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator].operators[o].envelope = clamp(0, Config.envelopes.length, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); } - } else if (command == SongTagCode.spectrum) { + } break; + case SongTagCode.spectrum: { const instrument: Instrument = this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator]; if (instrument.type == InstrumentType.spectrum) { const byteCount: number = Math.ceil(Config.spectrumControlPoints * Config.spectrumControlPointBits / 6) @@ -1734,7 +1773,8 @@ namespace beepbox { } else { throw new Error("Unhandled instrument type for spectrum song tag code."); } - } else if (command == SongTagCode.harmonics) { + } break; + case SongTagCode.harmonics: { const instrument: Instrument = this.channels[instrumentChannelIterator].instruments[instrumentIndexIterator]; const byteCount: number = Math.ceil(Config.harmonicsControlPoints * Config.harmonicsControlPointBits / 6) const bits: BitFieldReader = new BitFieldReader(compressed, charIndex, charIndex + byteCount); @@ -1743,10 +1783,11 @@ namespace beepbox { } instrument.harmonicsWave.markCustomWaveDirty(); charIndex += byteCount; - } else if (command == SongTagCode.bars) { + } break; + case SongTagCode.bars: { let subStringLength: number; if (beforeThree) { - channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; + const channel: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; const barCount: number = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; subStringLength = Math.ceil(barCount * 0.5); const bits: BitFieldReader = new BitFieldReader(compressed, charIndex, charIndex + subStringLength); @@ -1758,7 +1799,7 @@ namespace beepbox { while ((1 << neededBits) < this.patternsPerChannel) neededBits++; subStringLength = Math.ceil(this.getChannelCount() * this.barCount * neededBits / 6); const bits: BitFieldReader = new BitFieldReader(compressed, charIndex, charIndex + subStringLength); - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { for (let i: number = 0; i < this.barCount; i++) { this.channels[channel].bars[i] = bits.read(neededBits) + 1; } @@ -1768,15 +1809,17 @@ namespace beepbox { while ((1 << neededBits) < this.patternsPerChannel + 1) neededBits++; subStringLength = Math.ceil(this.getChannelCount() * this.barCount * neededBits / 6); const bits: BitFieldReader = new BitFieldReader(compressed, charIndex, charIndex + subStringLength); - for (channel = 0; channel < this.getChannelCount(); channel++) { + for (let channel: number = 0; channel < this.getChannelCount(); channel++) { for (let i: number = 0; i < this.barCount; i++) { this.channels[channel].bars[i] = bits.read(neededBits); } } } charIndex += subStringLength; - } else if (command == SongTagCode.patterns) { + } break; + case SongTagCode.patterns: { let bitStringLength: number = 0; + let channel: number; if (beforeThree) { channel = base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; @@ -1788,7 +1831,7 @@ namespace beepbox { bitStringLength += base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; } else { channel = 0; - let bitStringLengthLength: number = Math.min(4, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); + let bitStringLengthLength: number = validateRange(1, 4, base64CharCodeToInt[compressed.charCodeAt(charIndex++)]); while (bitStringLengthLength > 0) { bitStringLength = bitStringLength << 6; bitStringLength += base64CharCodeToInt[compressed.charCodeAt(charIndex++)]; @@ -1828,7 +1871,7 @@ namespace beepbox { let newNote: boolean = false; let shapeIndex: number = 0; if (useOldShape) { - shapeIndex = bits.readLongTail(0, 0); + shapeIndex = validateRange(0, recentShapes.length - 1, bits.readLongTail(0, 0)); } else { newNote = bits.read(1) == 1; } @@ -1893,7 +1936,7 @@ namespace beepbox { intervalIter++; } } else { - const pitchIndex: number = bits.read(3); + const pitchIndex: number = validateRange(0, recentPitches.length - 1, bits.read(3)); pitch = recentPitches[pitchIndex]; recentPitches.splice(pitchIndex, 1); } @@ -1921,7 +1964,7 @@ namespace beepbox { pin = makeNotePin(pitchBends[0] - note.pitches[0], pinObj.time, pinObj.volume); note.pins.push(pin); } - curPart = note.end; + curPart = validateRange(0, this.beatsPerBar * Config.partsPerBeat, note.end); newNotes.push(note); } } @@ -1934,7 +1977,10 @@ namespace beepbox { if (channel >= this.getChannelCount()) break; } } // while (true) - } + } break; + default: { + throw new Error("Unrecognized song tag code " + String.fromCharCode(command) + " at index " + (charIndex - 1)); + } break; } } diff --git a/tasks.txt b/tasks.txt index 411dfa15..ba11ef19 100644 --- a/tasks.txt +++ b/tasks.txt @@ -352,3 +352,4 @@ √ Added "realName" to scales, hover over scale select menu to see it. √ Allow playing piano while song is paused, added preference to preview added notes while paused. √ Added preference to control whether to display song data in url. +√ Added song recovery feature to file menu. diff --git a/website/synth_example.html b/website/synth_example.html index c03ed846..faeb4dd2 100644 --- a/website/synth_example.html +++ b/website/synth_example.html @@ -13,10 +13,10 @@ var synth = new beepbox.Synth("5sbk4l00e0ftaa7g0fj7i0r1w1100f0000d1110c0000h0000v2200o3320b4z8Ql6hkpUsiczhkp5hDxN8Od5hAl6u74z8Ql6hkpUsp24ZFzzQ1E39kxIceEtoV8s66138l1S0L1u2139l1H39McyaeOgKA0TxAU213jj0NM4x8i0o0c86ywz7keUtVxQk1E3hi6OEcB8Atl0q0Qmm6eCexg6wd50oczkhO8VcsEeAc26gG3E1q2U406hG3i6jw94ksf8i5Uo0dZY26kHHzxp2gAgM0o4d516ej7uegceGwd0q84czm6yj8Xa0Q1EIIctcvq0Q1EE3ihE8W1OgV8s46Icxk7o24110w0OdgqMOk392OEWhS1ANQQ4toUctBpzRxx1M0WNSk1I3ANMEXwS3I79xSzJ7q6QtEXgw0"); function togglePlay() { - if (synth.paused) { - synth.play(); - } else { + if (synth.isPlayingSong) { synth.pause(); + } else { + synth.play(); } }