funoteka 0.1.0
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/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long an MPEG audio stream plays.
|
|
3
|
+
*
|
|
4
|
+
* **Sources, and which parts have one.** The frame header — where each field
|
|
5
|
+
* sits, the bitrate tables, the sample rates, the slot a padding bit adds — is
|
|
6
|
+
* checked against the MP3'Tech page on it (`mp3-tech.org/programmer/
|
|
7
|
+
* frame_header.html`, saved as `2026-09-12-mpeg-audio-frame-header---mp3-tech.md`).
|
|
8
|
+
* That page is **secondary**: ISO/IEC 11172-3, the standard itself, is not
|
|
9
|
+
* openly available, so nothing here is verified against the standard, and
|
|
10
|
+
* saying "checked against the standard" would be false.
|
|
11
|
+
*
|
|
12
|
+
* Three things in this file that page does not cover, and no other source in
|
|
13
|
+
* hand does either — the samples a frame carries, the frame-length formula, and
|
|
14
|
+
* the Xing/Info and VBRI header layouts with the side-info sizes that lead to
|
|
15
|
+
* them. Those are held up by measurement instead: on 700 mp3 files here the
|
|
16
|
+
* length this reader reports is identical to the frame count ffprobe arrives at
|
|
17
|
+
* when it reads every frame (`-count_frames`), which is an independent
|
|
18
|
+
* instrument reaching the same number by a different route. 528 of those files
|
|
19
|
+
* take the Xing/Info path, so its offsets are exercised rather than assumed.
|
|
20
|
+
* **VBRI is exercised by nothing**: no file in the collection carries one, and
|
|
21
|
+
* it remains the one path here that rests on recollection.
|
|
22
|
+
*
|
|
23
|
+
* mp3 has no field that states this outright the way FLAC's STREAMINFO does,
|
|
24
|
+
* which is not the same as saying it is unknown — every player knows it exactly,
|
|
25
|
+
* and it knows it from the file alone. There are two ways and both are here:
|
|
26
|
+
*
|
|
27
|
+
* - a **Xing/Info** header (LAME writes one; Fraunhofer writes `VBRI`), which
|
|
28
|
+
* states the frame count and costs a multiplication; or
|
|
29
|
+
* - **walking the frames**, which needs no assumption about the stream and is
|
|
30
|
+
* exact for constant and variable bitrate alike.
|
|
31
|
+
*
|
|
32
|
+
* There is deliberately no "assume CBR and divide the file size by the bitrate"
|
|
33
|
+
* shortcut, though it is the next thing anyone reaches for. It is wrong on any
|
|
34
|
+
* file with a trailing ID3v1 or APEv2 tag, wrong on a free-format stream, and
|
|
35
|
+
* wrong on the variable bitrate rips where it is most tempting — and the frames
|
|
36
|
+
* are already in memory, so walking them costs a jump per frame and no
|
|
37
|
+
* assumption at all.
|
|
38
|
+
*
|
|
39
|
+
* Counting is also what makes it possible to say the length is *not* known,
|
|
40
|
+
* which is the other half of this module. A walk that runs out of frames with
|
|
41
|
+
* most of the file still ahead of it has lost the stream, and the number it
|
|
42
|
+
* would otherwise report is short by however much it skipped — plausible,
|
|
43
|
+
* wrong, and indistinguishable downstream from a correct one. That case is
|
|
44
|
+
* refused: the caller is told to ask something else, and ffprobe answers. The
|
|
45
|
+
* same refusal is what stops a file that merely *looks* like a stream — a
|
|
46
|
+
* couple of headers matching by chance inside an mp4 — from being claimed as
|
|
47
|
+
* one; see `MIN_STREAM_FRAMES` and `REFUSAL_MIN_GAP`.
|
|
48
|
+
*
|
|
49
|
+
* The tables are the part that is easy to get subtly wrong, and every one of
|
|
50
|
+
* them differs between MPEG versions:
|
|
51
|
+
*
|
|
52
|
+
* - samples per frame: Layer III is 1152 at MPEG-1 and **576** at MPEG-2/2.5.
|
|
53
|
+
* Using 1152 everywhere reports a low-bitrate rip at double its length.
|
|
54
|
+
* - the bitrate table: MPEG-2/2.5 Layer III starts at 8 kbps and stops at
|
|
55
|
+
* 160; MPEG-1 runs 32 to 320. Reading one with the other's table gives a
|
|
56
|
+
* plausible, wrong number.
|
|
57
|
+
* - the sample rate: 44.1/48/32 kHz at MPEG-1, half that at MPEG-2, half
|
|
58
|
+
* again at MPEG-2.5.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* Bitrate in kbps by table index, keyed by the same layer field as everything
|
|
62
|
+
* else — **1 is Layer III**. Index 0 is "free" and 15 is invalid.
|
|
63
|
+
*
|
|
64
|
+
* Every value here is the MP3'Tech table, transcribed column for column: V1/L1
|
|
65
|
+
* runs 32 to 448, V1/L2 32 to 384, V1/L3 32 to 320, V2/L1 32 to 256, and
|
|
66
|
+
* V2/L2 with L3 8 to 160 — the last being the one that catches people, since
|
|
67
|
+
* low-bitrate MPEG-2 audio is exactly where a wrong table is least obvious.
|
|
68
|
+
*
|
|
69
|
+
* Getting a layer's key wrong here is the quietest failure in the file: read
|
|
70
|
+
* Layer III's index 9 against Layer I's table and a 128 kbps stream reports as
|
|
71
|
+
* 288, which is a real bitrate at a real index and produces a frame length of
|
|
72
|
+
* 864 instead of 384 — no error, no exception, just a track three times too
|
|
73
|
+
* long.
|
|
74
|
+
*/
|
|
75
|
+
const BITRATES_V1 = {
|
|
76
|
+
1: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0], // Layer III
|
|
77
|
+
2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0], // Layer II
|
|
78
|
+
3: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0], // Layer I
|
|
79
|
+
};
|
|
80
|
+
const BITRATES_V2 = {
|
|
81
|
+
1: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], // Layer III
|
|
82
|
+
2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0], // Layer II
|
|
83
|
+
3: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0], // Layer I
|
|
84
|
+
};
|
|
85
|
+
/** Indexed by the two-bit version field: 0 = MPEG-2.5, 2 = MPEG-2, 3 = MPEG-1. */
|
|
86
|
+
const SAMPLE_RATES = {
|
|
87
|
+
0: [11025, 12000, 8000],
|
|
88
|
+
2: [22050, 24000, 16000],
|
|
89
|
+
3: [44100, 48000, 32000],
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Samples per frame, indexed by version field and then by the two-bit layer
|
|
93
|
+
* field — where **1 is Layer III**, 2 is Layer II and 3 is Layer I.
|
|
94
|
+
*
|
|
95
|
+
* **No source in hand states these numbers.** The MP3'Tech page describes the
|
|
96
|
+
* 32-bit header and stops; ISO/IEC 11172-3 has them and is paywalled. So this
|
|
97
|
+
* table, and the frame-length formula that is built on it, are the recollection
|
|
98
|
+
* the rest of this file is checked against text — held up by measurement
|
|
99
|
+
* instead: over 700 mp3 files the length derived from them matches the frame
|
|
100
|
+
* count ffprobe gets by reading every frame, exactly, to the millisecond. A
|
|
101
|
+
* wrong figure here cannot hide inside that.
|
|
102
|
+
*
|
|
103
|
+
* Getting this table's keys the wrong way round is worth spelling out, because
|
|
104
|
+
* it is silent: Layer I carries 384 samples and Layer III 1152, so swapping
|
|
105
|
+
* them reports a three-second track for a nine-second one and nothing looks
|
|
106
|
+
* malformed.
|
|
107
|
+
*/
|
|
108
|
+
const SAMPLES_PER_FRAME = {
|
|
109
|
+
3: { 1: 1152, 2: 1152, 3: 384 }, // MPEG-1
|
|
110
|
+
2: { 1: 576, 2: 1152, 3: 384 }, // MPEG-2
|
|
111
|
+
0: { 1: 576, 2: 1152, 3: 384 }, // MPEG-2.5
|
|
112
|
+
};
|
|
113
|
+
/** The codec a layer field names, as ffprobe would name it. */
|
|
114
|
+
export function codecOfLayer(layerField) {
|
|
115
|
+
return layerField === 3 ? 'mp1' : layerField === 2 ? 'mp2' : 'mp3';
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Read the frame whose header starts at `at`, or null if nothing valid does.
|
|
119
|
+
*
|
|
120
|
+
* The 32-bit header is the MP3'Tech one field for field — `AAAAAAAA AAABBCCD
|
|
121
|
+
* EEEEFFGH IIJJKLMM`, where B is the version, C the layer, E the bitrate index,
|
|
122
|
+
* F the sample rate, G padding, I the channel mode. The shifts below are that
|
|
123
|
+
* layout read as big-endian bits, and the three values each field reserves —
|
|
124
|
+
* version 1, layer 0, bitrate 15, sample rate index 3 — are refused rather than
|
|
125
|
+
* guessed at, because a frame built on one of them is not a frame.
|
|
126
|
+
*/
|
|
127
|
+
export function readFrame(bytes, at) {
|
|
128
|
+
if (at + 4 > bytes.length)
|
|
129
|
+
return null;
|
|
130
|
+
if (bytes[at] !== 0xff || ((bytes[at + 1] ?? 0) & 0xe0) !== 0xe0)
|
|
131
|
+
return null;
|
|
132
|
+
const versionField = ((bytes[at + 1] ?? 0) >> 3) & 0x03;
|
|
133
|
+
const layerField = ((bytes[at + 1] ?? 0) >> 1) & 0x03;
|
|
134
|
+
const bitrateIndex = ((bytes[at + 2] ?? 0) >> 4) & 0x0f;
|
|
135
|
+
const rateIndex = ((bytes[at + 2] ?? 0) >> 2) & 0x03;
|
|
136
|
+
const padding = ((bytes[at + 2] ?? 0) >> 1) & 0x01;
|
|
137
|
+
// 1 is reserved as a version, 0 as a layer, and both 0 and 15 as bitrates.
|
|
138
|
+
if (versionField === 1 || layerField === 0)
|
|
139
|
+
return null;
|
|
140
|
+
if (bitrateIndex === 0 || bitrateIndex === 15 || rateIndex === 3)
|
|
141
|
+
return null;
|
|
142
|
+
const table = versionField === 3 ? BITRATES_V1 : BITRATES_V2;
|
|
143
|
+
const bitrateKbps = table[layerField][bitrateIndex] ?? 0;
|
|
144
|
+
const sampleRate = SAMPLE_RATES[versionField][rateIndex] ?? 0;
|
|
145
|
+
const samplesPerFrame = SAMPLES_PER_FRAME[versionField][layerField];
|
|
146
|
+
if (bitrateKbps === 0 || sampleRate === 0)
|
|
147
|
+
return null;
|
|
148
|
+
const mono = (((bytes[at + 3] ?? 0) >> 6) & 0x03) === 3;
|
|
149
|
+
// A Layer I frame counts in four-byte slots; Layers II and III do not.
|
|
150
|
+
const size = layerField === 3
|
|
151
|
+
? (Math.floor((12 * bitrateKbps * 1000) / sampleRate) + padding) * 4
|
|
152
|
+
: Math.floor((samplesPerFrame / 8) * ((bitrateKbps * 1000) / sampleRate)) + padding;
|
|
153
|
+
// Side info sits between the header and any Xing/Info header.
|
|
154
|
+
const sideInfo = versionField === 3 ? (mono ? 17 : 32) : mono ? 9 : 17;
|
|
155
|
+
return {
|
|
156
|
+
at,
|
|
157
|
+
size,
|
|
158
|
+
bitrateKbps,
|
|
159
|
+
sampleRate,
|
|
160
|
+
samplesPerFrame,
|
|
161
|
+
mono,
|
|
162
|
+
sideInfoAt: at + 4 + sideInfo,
|
|
163
|
+
versionField,
|
|
164
|
+
layerField,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The first frame at or after `from`, resynchronising past leading garbage.
|
|
169
|
+
*
|
|
170
|
+
* Two frames, not one. A plausible-looking header occurs by chance inside
|
|
171
|
+
* padding and artwork constantly, and believing the first one costs the whole
|
|
172
|
+
* length: everything walked from a false start is noise counted as frames. On
|
|
173
|
+
* a real file in the collection that read as 98 seconds against a true 24, and
|
|
174
|
+
* the rate at which a lone coincidence is followed by another at exactly the
|
|
175
|
+
* right offset is low enough to make this the whole of the test.
|
|
176
|
+
*/
|
|
177
|
+
export function firstFrame(bytes, from) {
|
|
178
|
+
for (let at = from; at + 4 <= bytes.length; at += 1) {
|
|
179
|
+
const frame = readFrame(bytes, at);
|
|
180
|
+
if (frame === null)
|
|
181
|
+
continue;
|
|
182
|
+
if (readFrame(bytes, at + frame.size) !== null)
|
|
183
|
+
return frame;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
function uint32be(bytes, at) {
|
|
188
|
+
return ((((bytes[at] ?? 0) << 24) |
|
|
189
|
+
((bytes[at + 1] ?? 0) << 16) |
|
|
190
|
+
((bytes[at + 2] ?? 0) << 8) |
|
|
191
|
+
(bytes[at + 3] ?? 0)) >>>
|
|
192
|
+
0);
|
|
193
|
+
}
|
|
194
|
+
function magicAt(bytes, at, magic) {
|
|
195
|
+
if (at + magic.length > bytes.length)
|
|
196
|
+
return false;
|
|
197
|
+
for (let i = 0; i < magic.length; i += 1) {
|
|
198
|
+
if (bytes[at + i] !== magic.charCodeAt(i))
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Frames stated by the header, or null when there is no usable one.
|
|
205
|
+
*
|
|
206
|
+
* `Xing` marks a variable-bitrate stream and `Info` a constant one, but both
|
|
207
|
+
* carry the same count and neither is trusted blindly — the count is only used
|
|
208
|
+
* when the flag saying it is present is set.
|
|
209
|
+
*
|
|
210
|
+
* The two layouts are **not from a specification in hand** — no primary text
|
|
211
|
+
* covers either, and MP3'Tech does not describe them. Xing and Info are the
|
|
212
|
+
* same nine bytes of header: the magic, a flags word, then the frame count.
|
|
213
|
+
* VBRI, Fraunhofer's, is longer and puts the count fourteen bytes in. On the
|
|
214
|
+
* collection, 528 files carry Xing or Info and their stated counts agree with
|
|
215
|
+
* the frames that are actually there.
|
|
216
|
+
*
|
|
217
|
+
* **VBRI is carried by no file in the collection**, so the offsets above it were
|
|
218
|
+
* settled by construction instead: a stream was built with a VBRI header and
|
|
219
|
+
* handed to ffmpeg, which read 522 ms from a twenty-frame stream and 26 ms when
|
|
220
|
+
* the count was moved ten bytes earlier — the same two answers this reader
|
|
221
|
+
* gives, on the right layout and on the deliberately wrong one. Two parsers
|
|
222
|
+
* written independently agreeing on both is what stands in for the text neither
|
|
223
|
+
* of them had, and the fixture in `test/helpers/bytes.ts` writes the same bytes
|
|
224
|
+
* so the branch is exercised rather than assumed.
|
|
225
|
+
*/
|
|
226
|
+
function framesFromHeader(bytes, frame) {
|
|
227
|
+
const at = frame.sideInfoAt;
|
|
228
|
+
if (magicAt(bytes, at, 'Xing') || magicAt(bytes, at, 'Info')) {
|
|
229
|
+
const flags = uint32be(bytes, at + 4);
|
|
230
|
+
if ((flags & 0x01) === 0)
|
|
231
|
+
return null;
|
|
232
|
+
const frames = uint32be(bytes, at + 8);
|
|
233
|
+
return frames > 0 ? frames : null;
|
|
234
|
+
}
|
|
235
|
+
if (magicAt(bytes, at, 'VBRI')) {
|
|
236
|
+
const frames = uint32be(bytes, at + 14);
|
|
237
|
+
return frames > 0 ? frames : null;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Whether this frame is the encoder's own header rather than audio.
|
|
243
|
+
*
|
|
244
|
+
* LAME writes `Xing`/`Info` there and Fraunhofer `VBRI`, and whatever the flags
|
|
245
|
+
* word says afterwards, the frame itself carries no audio a decoder plays. A
|
|
246
|
+
* stream cut out of the middle of a file has to leave it out — not only because
|
|
247
|
+
* it states the *whole* file's frame count and would misreport the segment's
|
|
248
|
+
* length, but because the walk above does not count it either, so keeping it
|
|
249
|
+
* would put the served bytes one frame out of step with the times they were
|
|
250
|
+
* chosen by.
|
|
251
|
+
*/
|
|
252
|
+
export function isEncoderFrame(bytes, frame) {
|
|
253
|
+
const at = frame.sideInfoAt;
|
|
254
|
+
return magicAt(bytes, at, 'Xing') || magicAt(bytes, at, 'Info') || magicAt(bytes, at, 'VBRI');
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* A gap between where the walk stopped and the end of the bytes has to be both
|
|
258
|
+
* absolutely and relatively large before it means a lost stream.
|
|
259
|
+
*
|
|
260
|
+
* Both tests earn their place. The absolute one clears the tags that sit after
|
|
261
|
+
* the audio in an ordinary rip — an ID3v1 block is 128 bytes and an APEv2 one
|
|
262
|
+
* carrying artwork can be a hundred kilobytes, and neither is a lost frame. The
|
|
263
|
+
* relative one keeps a fixed byte threshold from calling a long track damaged
|
|
264
|
+
* because its artwork is large: what makes a gap a loss is a stream continuing
|
|
265
|
+
* past it, and a gap that is a tenth of the file cannot be the tail of it.
|
|
266
|
+
*
|
|
267
|
+
* Erring towards refusing is deliberate and cheap. A needless refusal costs one
|
|
268
|
+
* ffprobe spawn and returns the same number; a missed one stores a short length
|
|
269
|
+
* that nothing downstream can tell from a correct one.
|
|
270
|
+
*/
|
|
271
|
+
const REFUSAL_MIN_GAP = 4096;
|
|
272
|
+
const REFUSAL_GAP_SHARE = 8;
|
|
273
|
+
/**
|
|
274
|
+
* How many frames in a row it takes to call these bytes an MPEG stream at all.
|
|
275
|
+
*
|
|
276
|
+
* Without this, a file that is not an MPEG stream can still be claimed as one:
|
|
277
|
+
* `firstFrame` accepts a header that is followed by another at exactly the right
|
|
278
|
+
* offset, and inside unrelated data that happens. Measured over 229 real `.m4a`
|
|
279
|
+
* files — which are MP4, share no structure with MPEG audio, and are simply not
|
|
280
|
+
* understood here — every such coincidence ran to **exactly two frames**, the
|
|
281
|
+
* minimum `firstFrame` will accept, and no further. The genuine damaged stream
|
|
282
|
+
* this reader has to hand over (`02. 218 Tracks.mp3`) ran to **657**.
|
|
283
|
+
*
|
|
284
|
+
* So the populations do not overlap and the line goes in the space between
|
|
285
|
+
* them. Eight frames is a fifth of a second — far below any track a music
|
|
286
|
+
* collection holds, and four times the most a coincidence has ever produced.
|
|
287
|
+
* The margin does not need to be wider than that: two is not where coincidence
|
|
288
|
+
* happens to stop, it is where `firstFrame` stops looking, and a third frame
|
|
289
|
+
* arriving at exactly the computed offset is already the unlikely part.
|
|
290
|
+
*
|
|
291
|
+
* Below the line the honest answer is "no stream here", which leaves the file
|
|
292
|
+
* reported as a format this reader does not understand. That is a worse answer
|
|
293
|
+
* than a duration, but it is a true one — and claiming an m4a is an mpeg
|
|
294
|
+
* container is not. It cost the collection a correct count of what is
|
|
295
|
+
* unreadable, and it did so for 14 files out of 229 while the other 215 were
|
|
296
|
+
* still being described accurately.
|
|
297
|
+
*/
|
|
298
|
+
const MIN_STREAM_FRAMES = 8;
|
|
299
|
+
/**
|
|
300
|
+
* How long the MPEG stream starting at `from` plays, and whether that is known.
|
|
301
|
+
*
|
|
302
|
+
* Null when no frame can be found at all, which is the honest answer for
|
|
303
|
+
* something that is not a stream — and this never throws, because the caller is
|
|
304
|
+
* a scan over a whole collection and one damaged file must not stop it.
|
|
305
|
+
*/
|
|
306
|
+
export function mpegLength(bytes, from) {
|
|
307
|
+
const first = firstFrame(bytes, from);
|
|
308
|
+
if (first === null)
|
|
309
|
+
return null;
|
|
310
|
+
// A frame count written by the encoder is a statement, not a measurement —
|
|
311
|
+
// nothing was walked, so there is nothing to have lost.
|
|
312
|
+
const stated = framesFromHeader(bytes, first);
|
|
313
|
+
if (stated !== null) {
|
|
314
|
+
return {
|
|
315
|
+
durationMs: Math.round((stated * first.samplesPerFrame * 1000) / first.sampleRate),
|
|
316
|
+
codec: codecOfLayer(first.layerField),
|
|
317
|
+
sampleRate: first.sampleRate,
|
|
318
|
+
channels: first.mono ? 1 : 2,
|
|
319
|
+
refused: false,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// Walk the stream. Each frame states its own length, so this is a jump per
|
|
323
|
+
// frame rather than a pass over the bytes.
|
|
324
|
+
//
|
|
325
|
+
// Contiguous, and it stops at the first position that is not a frame. There
|
|
326
|
+
// was a resync here that looked for the next plausible header after a break,
|
|
327
|
+
// and it was worse than useless: MPEG frames run back to back, so a break
|
|
328
|
+
// means the stream ended, and every "recovered" position was a coincidence in
|
|
329
|
+
// trailing data counted as audio. What replaced it is not a better resync but
|
|
330
|
+
// the refusal below — a walk that loses the stream no longer has to guess at
|
|
331
|
+
// the rest, because it can decline to answer.
|
|
332
|
+
let at = first.at;
|
|
333
|
+
let samples = 0;
|
|
334
|
+
let frames = 0;
|
|
335
|
+
for (let guard = 0; guard < 1_000_000; guard += 1) {
|
|
336
|
+
const frame = readFrame(bytes, at);
|
|
337
|
+
if (frame === null)
|
|
338
|
+
break;
|
|
339
|
+
samples += frame.samplesPerFrame;
|
|
340
|
+
frames += 1;
|
|
341
|
+
at += frame.size;
|
|
342
|
+
}
|
|
343
|
+
// Too few frames to have found a stream — see `MIN_STREAM_FRAMES`. Not a
|
|
344
|
+
// refusal: there is nothing here to hand over, because there is nothing here.
|
|
345
|
+
if (frames < MIN_STREAM_FRAMES)
|
|
346
|
+
return null;
|
|
347
|
+
const gap = bytes.length - at;
|
|
348
|
+
return {
|
|
349
|
+
durationMs: Math.round((samples * 1000) / first.sampleRate),
|
|
350
|
+
codec: codecOfLayer(first.layerField),
|
|
351
|
+
sampleRate: first.sampleRate,
|
|
352
|
+
channels: first.mono ? 1 : 2,
|
|
353
|
+
refused: gap > REFUSAL_MIN_GAP && gap * REFUSAL_GAP_SHARE > bytes.length - from,
|
|
354
|
+
};
|
|
355
|
+
}
|