tserato 0.1.0 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +53 -0
- package/dist/builder.d.ts +2 -1
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +56 -55
- package/dist/encoders/v2/v2Mp3Encoder.d.ts +2 -0
- package/dist/encoders/v2/v2Mp3Encoder.d.ts.map +1 -1
- package/dist/encoders/v2/v2Mp3Encoder.js +12 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/model/crate.d.ts +2 -4
- package/dist/model/crate.d.ts.map +1 -1
- package/dist/model/crate.js +7 -37
- package/dist/model/track.d.ts +4 -0
- package/dist/model/track.d.ts.map +1 -1
- package/dist/model/track.js +7 -1
- package/dist/model/trackMeta.d.ts +7 -0
- package/dist/model/trackMeta.d.ts.map +1 -0
- package/dist/model/trackMeta.js +2 -0
- package/dist/util.d.ts +1 -1
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +10 -28
- package/eslint.config.js +50 -0
- package/package.json +8 -4
- package/src/builder.ts +197 -0
- package/src/encoders/baseEncoder.ts +8 -0
- package/src/encoders/serato_tags.ts +4 -0
- package/src/encoders/utils.ts +21 -0
- package/src/encoders/v2/v2Mp3Encoder.ts +245 -0
- package/src/index.ts +7 -0
- package/src/model/crate.ts +55 -0
- package/src/model/hotCue.ts +206 -0
- package/src/model/hotCueType.ts +6 -0
- package/src/model/seratoColor.ts +20 -0
- package/src/model/tempo.ts +4 -0
- package/src/model/track.ts +81 -0
- package/src/model/trackMeta.ts +6 -0
- package/src/util.ts +79 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { HotCueType } from './hotCueType';
|
|
2
|
+
import { SeratoColor } from './seratoColor';
|
|
3
|
+
import { Buffer } from 'buffer';
|
|
4
|
+
|
|
5
|
+
function writeNullTerminatedString(str: string): Uint8Array {
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const strBytes = encoder.encode(str);
|
|
8
|
+
const result = new Uint8Array(strBytes.length + 1); // +1 for null terminator
|
|
9
|
+
result.set(strBytes, 0);
|
|
10
|
+
result[strBytes.length] = 0;
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
|
15
|
+
let totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
|
16
|
+
let result = new Uint8Array(totalLength);
|
|
17
|
+
let offset = 0;
|
|
18
|
+
arrays.forEach(arr => {
|
|
19
|
+
result.set(arr, offset);
|
|
20
|
+
offset += arr.length;
|
|
21
|
+
});
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function encodeElement(name: string, data: Uint8Array): Uint8Array {
|
|
26
|
+
const nameBytes = writeNullTerminatedString(name);
|
|
27
|
+
const lengthBytes = new Uint8Array(4);
|
|
28
|
+
new DataView(lengthBytes.buffer).setUint32(0, data.length, false); // big endian
|
|
29
|
+
return concatUint8Arrays([nameBytes, lengthBytes, data]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HotCueData {
|
|
33
|
+
name: string;
|
|
34
|
+
type: HotCueType;
|
|
35
|
+
start: number;
|
|
36
|
+
index: number;
|
|
37
|
+
end?: number | null;
|
|
38
|
+
isLocked?: boolean;
|
|
39
|
+
color?: SeratoColor;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class HotCue implements HotCueData {
|
|
43
|
+
name: string;
|
|
44
|
+
type: HotCueType;
|
|
45
|
+
start: number;
|
|
46
|
+
index: number;
|
|
47
|
+
end?: number | null;
|
|
48
|
+
isLocked?: boolean;
|
|
49
|
+
color: SeratoColor;
|
|
50
|
+
|
|
51
|
+
constructor(data: HotCueData) {
|
|
52
|
+
this.name = data.name;
|
|
53
|
+
this.type = data.type;
|
|
54
|
+
this.start = data.start;
|
|
55
|
+
this.index = data.index;
|
|
56
|
+
this.end = data.end ?? null;
|
|
57
|
+
this.isLocked = data.isLocked ?? false;
|
|
58
|
+
this.color = data.color ?? SeratoColor.RED;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
toString(): string {
|
|
62
|
+
const startStr = `${this.start}ms`;
|
|
63
|
+
const endStr = this.end != null ? ` | End: ${this.end}ms` : '';
|
|
64
|
+
return `Start: ${startStr}${endStr} | Index: ${String(this.index).padStart(2)} | Name: ${this.name} | Color: ${this.color}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toV2Bytes(): Buffer {
|
|
68
|
+
if (this.type === HotCueType.CUE) return this.cueToV2Bytes();
|
|
69
|
+
if (this.type === HotCueType.LOOP) return this.loopToV2Bytes();
|
|
70
|
+
throw new Error(`unsupported hotcue type ${this.type}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private loopToV2Bytes(): Buffer {
|
|
74
|
+
let nameBytes = writeNullTerminatedString(this.name);
|
|
75
|
+
let buf = new Uint8Array(0x14 + nameBytes.length);
|
|
76
|
+
let dv = new DataView(buf.buffer);
|
|
77
|
+
|
|
78
|
+
buf[0x0] = 0x00; // flags? (unused in parse)
|
|
79
|
+
buf[0x1] = this.index;
|
|
80
|
+
dv.setUint32(0x02, this.start, false);
|
|
81
|
+
dv.setUint32(0x06, this.end!, false);
|
|
82
|
+
buf[0x0e] = 0;
|
|
83
|
+
buf[0x0f] = 0;
|
|
84
|
+
buf[0x10] = 255;
|
|
85
|
+
buf[0x11] = 255;
|
|
86
|
+
buf[0x13] = 1;
|
|
87
|
+
buf.set(nameBytes, 0x14);
|
|
88
|
+
|
|
89
|
+
return Buffer.from(encodeElement("LOOP", buf));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private cueToV2Bytes(): Buffer {
|
|
93
|
+
const parts: Buffer[] = [];
|
|
94
|
+
|
|
95
|
+
// >B (unsigned char, big-endian)
|
|
96
|
+
parts.push(Buffer.from([0]));
|
|
97
|
+
|
|
98
|
+
// >B self.index
|
|
99
|
+
parts.push(Buffer.from([this.index]));
|
|
100
|
+
|
|
101
|
+
// >I self.start (4-byte big-endian unsigned int)
|
|
102
|
+
const startBuf = Buffer.alloc(4);
|
|
103
|
+
startBuf.writeUInt32BE(this.start, 0);
|
|
104
|
+
parts.push(startBuf);
|
|
105
|
+
|
|
106
|
+
// >B 0
|
|
107
|
+
parts.push(Buffer.from([0]));
|
|
108
|
+
|
|
109
|
+
// >3s color (3 raw bytes from hex string)
|
|
110
|
+
const colorBuf = Buffer.from(this.color, "hex");
|
|
111
|
+
if (colorBuf.length !== 3) {
|
|
112
|
+
throw new Error(`Color must be 3 bytes (e.g. "ff0000"), got ${this.color}`);
|
|
113
|
+
}
|
|
114
|
+
parts.push(colorBuf);
|
|
115
|
+
|
|
116
|
+
// >B 0
|
|
117
|
+
parts.push(Buffer.from([0]));
|
|
118
|
+
|
|
119
|
+
parts.push(Buffer.from([1]));
|
|
120
|
+
|
|
121
|
+
parts.push(Buffer.from(this.name, "utf8"));
|
|
122
|
+
|
|
123
|
+
// >B 0 (null terminator after name)
|
|
124
|
+
parts.push(Buffer.from([0]));
|
|
125
|
+
|
|
126
|
+
const data = Buffer.concat(parts);
|
|
127
|
+
|
|
128
|
+
const lenBuf = Buffer.alloc(4);
|
|
129
|
+
lenBuf.writeUInt32BE(data.length, 0);
|
|
130
|
+
|
|
131
|
+
const payload = Buffer.concat([
|
|
132
|
+
Buffer.from("CUE", "ascii"),
|
|
133
|
+
Buffer.from([0]),
|
|
134
|
+
lenBuf,
|
|
135
|
+
data,
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
return payload;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static fromBytes(data: Buffer, hotcueType: HotCueType): HotCue {
|
|
142
|
+
let offset = 1; // skip first null
|
|
143
|
+
if (hotcueType === HotCueType.CUE) {
|
|
144
|
+
const index = data.readUInt8(offset);
|
|
145
|
+
offset += 1;
|
|
146
|
+
const start = data.readUInt32BE(offset);
|
|
147
|
+
offset += 4;
|
|
148
|
+
offset += 1; // skip null / position end
|
|
149
|
+
offset += 0; // skip placeholder
|
|
150
|
+
const colorBytes = data.slice(offset, offset + 3);
|
|
151
|
+
offset += 3;
|
|
152
|
+
offset += 1; // skip null
|
|
153
|
+
const locked = data.readUInt8(offset) !== 0;
|
|
154
|
+
offset += 1;
|
|
155
|
+
const nameBuf = data.slice(offset).subarray(0, data.slice(offset).indexOf(0));
|
|
156
|
+
const name = nameBuf.toString("utf-8");
|
|
157
|
+
|
|
158
|
+
const colorHex = colorBytes.toString("hex").toUpperCase();
|
|
159
|
+
const color = (Object.values(SeratoColor).includes(colorHex as SeratoColor)
|
|
160
|
+
? (colorHex as SeratoColor)
|
|
161
|
+
: SeratoColor.RED);
|
|
162
|
+
|
|
163
|
+
return new HotCue({
|
|
164
|
+
name,
|
|
165
|
+
type: HotCueType.CUE,
|
|
166
|
+
index,
|
|
167
|
+
start,
|
|
168
|
+
color,
|
|
169
|
+
isLocked: locked,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
else if (hotcueType === HotCueType.LOOP) {
|
|
173
|
+
const index = data.readUInt8(offset); offset += 1;
|
|
174
|
+
const start = data.readUInt32BE(offset); offset += 4;
|
|
175
|
+
const end = data.readUInt32BE(offset); offset += 4;
|
|
176
|
+
|
|
177
|
+
// Skip to offset 0x0E
|
|
178
|
+
offset = 0x0E;
|
|
179
|
+
|
|
180
|
+
// skip color placeholder bytes (0x0E–0x11)
|
|
181
|
+
offset += 2; // 0x0E, 0x0F
|
|
182
|
+
offset += 2; // 0x10, 0x11
|
|
183
|
+
offset += 1; // 0x12 (padding)
|
|
184
|
+
|
|
185
|
+
const isLocked = Boolean(data.readUInt8(offset)); offset += 1;
|
|
186
|
+
|
|
187
|
+
// read null-terminated name
|
|
188
|
+
const nameEnd = data.indexOf(0x00, offset);
|
|
189
|
+
const name = data.toString("utf8", offset, nameEnd === -1 ? data.length : nameEnd);
|
|
190
|
+
return new HotCue({
|
|
191
|
+
name,
|
|
192
|
+
type: HotCueType.CUE,
|
|
193
|
+
index,
|
|
194
|
+
start,
|
|
195
|
+
end: end,
|
|
196
|
+
color: SeratoColor.RED,
|
|
197
|
+
isLocked: isLocked,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
else {
|
|
202
|
+
throw new Error(`Unknown hotcue type: ${hotcueType}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export enum SeratoColor {
|
|
2
|
+
RED = 'CC0000',
|
|
3
|
+
ORANGE = 'CC4400',
|
|
4
|
+
AMBER = 'CC8800',
|
|
5
|
+
YELLOW = 'CCCC00',
|
|
6
|
+
LIME_GREEN = '88CC00',
|
|
7
|
+
GREEN_YELLOW = '44CC00',
|
|
8
|
+
GREEN = '00CC00',
|
|
9
|
+
MINT_GREEN = '00CC44',
|
|
10
|
+
TEAL_GREEN = '00CC88',
|
|
11
|
+
TEAL = '00CCCC',
|
|
12
|
+
SKY_BLUE = '0088CC',
|
|
13
|
+
BLUE = '0044CC',
|
|
14
|
+
DARK_BLUE = '0000CC',
|
|
15
|
+
PURPLE = '4400CC',
|
|
16
|
+
VIOLET = '8800CC',
|
|
17
|
+
MAGENTA = 'CC00CC',
|
|
18
|
+
PINK = 'CC0088',
|
|
19
|
+
RED_PINK = 'CC0044'
|
|
20
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { HotCue } from './hotCue';
|
|
3
|
+
import { HotCueType } from './hotCueType';
|
|
4
|
+
import { Tempo } from './tempo';
|
|
5
|
+
import { TrackMeta } from './trackMeta';
|
|
6
|
+
|
|
7
|
+
export class Track {
|
|
8
|
+
path: string;
|
|
9
|
+
trackId: string;
|
|
10
|
+
averageBpm: number;
|
|
11
|
+
dateAdded: string;
|
|
12
|
+
playCount: string;
|
|
13
|
+
tonality: string;
|
|
14
|
+
totalTime: number;
|
|
15
|
+
|
|
16
|
+
beatgrid: Tempo[];
|
|
17
|
+
hotCues: HotCue[];
|
|
18
|
+
cueLoops: HotCue[];
|
|
19
|
+
trackMeta: TrackMeta | null;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
trackPath: string,
|
|
23
|
+
params: {
|
|
24
|
+
trackId?: string;
|
|
25
|
+
averageBpm?: number;
|
|
26
|
+
dateAdded?: string;
|
|
27
|
+
playCount?: string;
|
|
28
|
+
tonality?: string;
|
|
29
|
+
totalTime?: number;
|
|
30
|
+
beatgrid?: Tempo[];
|
|
31
|
+
hotCues?: HotCue[];
|
|
32
|
+
cueLoops?: HotCue[];
|
|
33
|
+
trackMeta?: TrackMeta;
|
|
34
|
+
} = {}
|
|
35
|
+
) {
|
|
36
|
+
this.path = trackPath;
|
|
37
|
+
this.trackId = params.trackId ?? "";
|
|
38
|
+
this.averageBpm = params.averageBpm ?? 0.0;
|
|
39
|
+
this.dateAdded = params.dateAdded ?? "";
|
|
40
|
+
this.playCount = params.playCount ?? "";
|
|
41
|
+
this.tonality = params.tonality ?? "";
|
|
42
|
+
this.totalTime = params.totalTime ?? 0.0;
|
|
43
|
+
|
|
44
|
+
this.beatgrid = params.beatgrid ?? [];
|
|
45
|
+
this.hotCues = params.hotCues ?? [];
|
|
46
|
+
this.cueLoops = params.cueLoops ?? [];
|
|
47
|
+
this.trackMeta = params.trackMeta ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static fromPath(trackPath: string, userRoot?: string): Track {
|
|
51
|
+
const resolved =
|
|
52
|
+
userRoot != null
|
|
53
|
+
? path.resolve(userRoot, trackPath)
|
|
54
|
+
: path.resolve(trackPath);
|
|
55
|
+
return new Track(resolved);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
addBeatgridMarker(tempo: Tempo): void {
|
|
59
|
+
this.beatgrid.push(tempo);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
addHotCue(hotCue: HotCue): void {
|
|
63
|
+
if (this.hotCues.length >= 8) {
|
|
64
|
+
throw new Error("cannot have more than 8 hot cues on a track");
|
|
65
|
+
}
|
|
66
|
+
if (this.cueLoops.length >= 4) {
|
|
67
|
+
throw new Error("cannot have more than 4 loops on a track");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const atIndex = hotCue.index;
|
|
71
|
+
if (hotCue.type === HotCueType.LOOP) {
|
|
72
|
+
this.cueLoops.splice(atIndex, 0, hotCue);
|
|
73
|
+
} else {
|
|
74
|
+
this.hotCues.splice(atIndex, 0, hotCue);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
addTrackMeta(trackMeta: TrackMeta): void {
|
|
79
|
+
this.trackMeta = trackMeta;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
|
|
3
|
+
const INVALID_CHARACTERS_REGEX = /[^A-Za-z0-9_ ]/i;
|
|
4
|
+
|
|
5
|
+
export function splitString(input: Buffer, after: number = 72, delimiter: Buffer = Buffer.from('\n')): Buffer {
|
|
6
|
+
const pieces: Buffer[] = [];
|
|
7
|
+
let buf = Buffer.from(input);
|
|
8
|
+
while (buf.length > 0) {
|
|
9
|
+
pieces.push(buf.slice(0, after));
|
|
10
|
+
buf = buf.slice(after);
|
|
11
|
+
}
|
|
12
|
+
return Buffer.concat(pieces.reduce<Buffer[]>((acc, p, i) => {
|
|
13
|
+
if (i) acc.push(delimiter);
|
|
14
|
+
acc.push(p);
|
|
15
|
+
return acc;
|
|
16
|
+
}, []));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export function seratoDecode(buffer: Buffer): string {
|
|
21
|
+
let result = "";
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i + 1 < buffer.length; i += 2) {
|
|
24
|
+
// Take 2 bytes
|
|
25
|
+
const chunk = buffer.slice(i, i + 2);
|
|
26
|
+
|
|
27
|
+
// Reverse bytes (Python: chunk[::-1])
|
|
28
|
+
const reversed = Buffer.from([chunk[1], chunk[0]]);
|
|
29
|
+
|
|
30
|
+
// Decode as UTF-16LE (Node uses LE explicitly)
|
|
31
|
+
result += reversed.toString("utf16le");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
export function concatBytes(arrays: Uint8Array[]): Uint8Array {
|
|
39
|
+
const totalLength = arrays.reduce((sum, a) => sum + a.length, 0);
|
|
40
|
+
const result = new Uint8Array(totalLength);
|
|
41
|
+
let offset = 0;
|
|
42
|
+
for (const a of arrays) {
|
|
43
|
+
result.set(a, offset);
|
|
44
|
+
offset += a.length;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function latin1Encode(str: string): Uint8Array {
|
|
50
|
+
return Uint8Array.from(Buffer.from(str, "latin1"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function intToBytes(value: number, length: number): Uint8Array {
|
|
54
|
+
const bytes = new Uint8Array(length);
|
|
55
|
+
for (let i = 0; i < length; i++) {
|
|
56
|
+
// big-endian
|
|
57
|
+
bytes[length - 1 - i] = (value >> (8 * i)) & 0xff;
|
|
58
|
+
}
|
|
59
|
+
return bytes;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function seratoEncode(s: string): Uint8Array {
|
|
63
|
+
const result: number[] = [];
|
|
64
|
+
|
|
65
|
+
for (const c of s) {
|
|
66
|
+
const codePoint = c.charCodeAt(0); // UTF-16 code unit
|
|
67
|
+
// Big-endian: high byte first, then low byte
|
|
68
|
+
result.push((codePoint >> 8) & 0xff);
|
|
69
|
+
result.push(codePoint & 0xff);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return Uint8Array.from(result);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sanitizeFilename(filename: string): string {
|
|
76
|
+
return filename.replace(INVALID_CHARACTERS_REGEX, '-');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class DuplicateTrackError extends Error {}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"declaration": true,
|
|
4
|
+
"declarationMap": true,
|
|
5
|
+
"emitDeclarationOnly": false,
|
|
6
|
+
"target": "ES2020",
|
|
7
|
+
"module": "CommonJS",
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": [
|
|
15
|
+
"src/**/*"
|
|
16
|
+
],
|
|
17
|
+
"exclude": ["node_modules", "tests", "dist"]
|
|
18
|
+
}
|