forked from johnnesky/beepbox
-
Notifications
You must be signed in to change notification settings - Fork 36
/
SongRecovery.ts
175 lines (152 loc) · 6.27 KB
/
SongRecovery.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
// Copyright (c) 2012-2022 John Nesky and contributing authors, distributed under the MIT license, see accompanying the LICENSE.md file.
import { Dictionary } from "../synth/SynthConfig";
import { Song } from "../synth/synth";
export interface RecoveredVersion {
uid: string;
time: number;
name: string;
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 ((Math.random() * (-1 >>> 0)) >>> 0).toString(32);
}
export function errorAlert(error: any): void {
console.warn(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.");
}
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: ReturnType<typeof setTimeout>;
private _song: Song = new Song();
public static getAllRecoveredSongs(): RecoveredSong[] {
const songs: RecoveredSong[] = [];
const songsByUid: Dictionary<RecoveredSong> = {};
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, name: string, songData: string): void {
const newName: string = name;
const newTime: number = Math.round(Date.now());
clearTimeout(this._saveVersionTimeoutHandle);
this._saveVersionTimeoutHandle = setTimeout((): void => {
try {
// Ensure that the song is not corrupted.
this._song.fromBase64String(songData);
} catch (error) {
errorAlert(error);
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, name: newName, 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.
}
}