dsh-theme-endfield 1.1.9 → 1.2.3

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/lib/slots.js CHANGED
@@ -1,97 +1,97 @@
1
- 'use strict';
2
- /**
3
- * Sound-slot definitions for the audio-notification feature.
4
- *
5
- * One entry per slot, each documented with the moment it marks so the settings
6
- * page and the docs can describe them without a second source of truth.
7
- *
8
- * Wired to events by the host half: `boot` (the loader plates), `turn-start`,
9
- * `turn-done` and `attention` (approval requests and pending user questions).
10
- * Generated but NOT wired: `turn-fail` — a turn that fails on its own stays
11
- * silent by design (see index.js), so the package already carries the whole
12
- * set and a later version only has to add the trigger.
13
- */
14
-
15
- const SLOTS = {
16
- /**
17
- * 启动加载动画 —— the boot plate starts. A rising three-note figure that lands
18
- * on the same G5 the task-end chime begins from, so the boot sound leads INTO
19
- * that interval instead of competing with it. Shorter and lighter than
20
- * `turn-start`: it marks a page arriving, not work beginning. Fired once per
21
- * page load when the boot plate starts — through the host's preview route,
22
- * which is also what the boot-sound switch in the settings page uses to let
23
- * you hear it before committing.
24
- */
25
- boot: {
26
- duration: 0.95,
27
- peakDbfs: -3,
28
- notes: [
29
- { note: 'G4', start: 0, duration: 0.42, gain: 0.34, timbre: 'blip', decay: 2.2, curve: 2.4 },
30
- { note: 'D5', start: 0.11, duration: 0.5, gain: 0.32, timbre: 'blip', decay: 1.9, curve: 2.2 },
31
- { note: 'G5', start: 0.22, duration: 0.7, gain: 0.32, timbre: 'chime', decay: 1.5, curve: 2.0 },
32
- { note: 'D6', start: 0.34, duration: 0.55, gain: 0.06, timbre: 'chime', decay: 2.0 },
33
- ],
34
- },
35
-
36
- /**
37
- * 任务开始 —— the user submitted a prompt from the composer and the agent is
38
- * picking it up. Short, quiet, informational: it confirms the instruction was
39
- * received without implying anything finished.
40
- */
41
- 'turn-start': {
42
- duration: 0.5,
43
- peakDbfs: -4,
44
- notes: [
45
- { note: 'F4', start: 0, duration: 0.3, gain: 0.42, timbre: 'blip', decay: 3.4, curve: 2.6 },
46
- { note: 'A#4', start: 0.075, duration: 0.4, gain: 0.34, timbre: 'blip', decay: 3.0, curve: 2.4 },
47
- { note: 'C5', start: 0.15, duration: 0.34, gain: 0.22, timbre: 'blip', decay: 3.6, curve: 2.4 },
48
- ],
49
- },
50
-
51
- /**
52
- * 任务结束 —— the agent delivered a final text result. Rising major third
53
- * (C5 -> G5) with an almost inaudible C6 shimmer: closing, not celebratory.
54
- */
55
- 'turn-done': {
56
- duration: 1.15,
57
- peakDbfs: -2,
58
- notes: [
59
- { note: 'C5', start: 0, duration: 0.55, gain: 0.34, timbre: 'chime' },
60
- { note: 'G5', start: 0.16, duration: 0.85, gain: 0.3, timbre: 'chime', decay: 1.0 },
61
- { note: 'C6', start: 0.34, duration: 0.75, gain: 0.07, timbre: 'chime', decay: 1.4 },
62
- ],
63
- },
64
-
65
- /**
66
- * 需要你回应 (reserved) —— human attention required. Descending minor second
67
- * with a detuned partial beats against the fundamental: unresolved on
68
- * purpose, so it does not read as "done".
69
- */
70
- attention: {
71
- duration: 1.0,
72
- peakDbfs: -2,
73
- notes: [
74
- { note: 'A4', start: 0, duration: 0.5, gain: 0.34, timbre: 'chime', decay: 1.8 },
75
- { note: 'G#4', start: 0.22, duration: 0.8, gain: 0.32, timbre: 'chime', decay: 1.5 },
76
- { note: 'A4', start: 0, duration: 0.5, gain: 0.2, timbre: 'chime', detuneCents: 22, decay: 1.8 },
77
- ],
78
- },
79
-
80
- /**
81
- * 出错 (reserved) —— the turn failed. Low, hollow, two hits: reads as a fault
82
- * rather than a completion.
83
- */
84
- 'turn-fail': {
85
- duration: 0.9,
86
- peakDbfs: -2,
87
- notes: [
88
- { note: 'F3', start: 0, duration: 0.28, gain: 0.4, timbre: 'hollow', decay: 2.6, attack: 0.003 },
89
- { note: 'C3', start: 0.2, duration: 0.62, gain: 0.4, timbre: 'hollow', decay: 1.9, attack: 0.003 },
90
- ],
91
- },
92
- };
93
-
94
- /** Slot ids in playback/preview order. */
95
- const SLOT_IDS = ['boot', 'turn-start', 'turn-done', 'attention', 'turn-fail'];
96
-
97
- module.exports = { SLOTS, SLOT_IDS };
1
+ 'use strict';
2
+ /**
3
+ * Sound-slot definitions for the audio-notification feature.
4
+ *
5
+ * One entry per slot, each documented with the moment it marks so the settings
6
+ * page and the docs can describe them without a second source of truth.
7
+ *
8
+ * Wired to events by the host half: `boot` (the loader plates), `turn-start`,
9
+ * `turn-done` and `attention` (approval requests and pending user questions).
10
+ * Generated but NOT wired: `turn-fail` — a turn that fails on its own stays
11
+ * silent by design (see index.js), so the package already carries the whole
12
+ * set and a later version only has to add the trigger.
13
+ */
14
+
15
+ const SLOTS = {
16
+ /**
17
+ * 启动加载动画 —— the boot plate starts. A rising three-note figure that lands
18
+ * on the same G5 the task-end chime begins from, so the boot sound leads INTO
19
+ * that interval instead of competing with it. Shorter and lighter than
20
+ * `turn-start`: it marks a page arriving, not work beginning. Fired once per
21
+ * page load when the boot plate starts — through the host's preview route,
22
+ * which is also what the boot-sound switch in the settings page uses to let
23
+ * you hear it before committing.
24
+ */
25
+ boot: {
26
+ duration: 0.95,
27
+ peakDbfs: -3,
28
+ notes: [
29
+ { note: 'G4', start: 0, duration: 0.42, gain: 0.34, timbre: 'blip', decay: 2.2, curve: 2.4 },
30
+ { note: 'D5', start: 0.11, duration: 0.5, gain: 0.32, timbre: 'blip', decay: 1.9, curve: 2.2 },
31
+ { note: 'G5', start: 0.22, duration: 0.7, gain: 0.32, timbre: 'chime', decay: 1.5, curve: 2.0 },
32
+ { note: 'D6', start: 0.34, duration: 0.55, gain: 0.06, timbre: 'chime', decay: 2.0 },
33
+ ],
34
+ },
35
+
36
+ /**
37
+ * 任务开始 —— the user submitted a prompt from the composer and the agent is
38
+ * picking it up. Short, quiet, informational: it confirms the instruction was
39
+ * received without implying anything finished.
40
+ */
41
+ 'turn-start': {
42
+ duration: 0.5,
43
+ peakDbfs: -4,
44
+ notes: [
45
+ { note: 'F4', start: 0, duration: 0.3, gain: 0.42, timbre: 'blip', decay: 3.4, curve: 2.6 },
46
+ { note: 'A#4', start: 0.075, duration: 0.4, gain: 0.34, timbre: 'blip', decay: 3.0, curve: 2.4 },
47
+ { note: 'C5', start: 0.15, duration: 0.34, gain: 0.22, timbre: 'blip', decay: 3.6, curve: 2.4 },
48
+ ],
49
+ },
50
+
51
+ /**
52
+ * 任务结束 —— the agent delivered a final text result. Rising major third
53
+ * (C5 -> G5) with an almost inaudible C6 shimmer: closing, not celebratory.
54
+ */
55
+ 'turn-done': {
56
+ duration: 1.15,
57
+ peakDbfs: -2,
58
+ notes: [
59
+ { note: 'C5', start: 0, duration: 0.55, gain: 0.34, timbre: 'chime' },
60
+ { note: 'G5', start: 0.16, duration: 0.85, gain: 0.3, timbre: 'chime', decay: 1.0 },
61
+ { note: 'C6', start: 0.34, duration: 0.75, gain: 0.07, timbre: 'chime', decay: 1.4 },
62
+ ],
63
+ },
64
+
65
+ /**
66
+ * 需要你回应 (reserved) —— human attention required. Descending minor second
67
+ * with a detuned partial beats against the fundamental: unresolved on
68
+ * purpose, so it does not read as "done".
69
+ */
70
+ attention: {
71
+ duration: 1.0,
72
+ peakDbfs: -2,
73
+ notes: [
74
+ { note: 'A4', start: 0, duration: 0.5, gain: 0.34, timbre: 'chime', decay: 1.8 },
75
+ { note: 'G#4', start: 0.22, duration: 0.8, gain: 0.32, timbre: 'chime', decay: 1.5 },
76
+ { note: 'A4', start: 0, duration: 0.5, gain: 0.2, timbre: 'chime', detuneCents: 22, decay: 1.8 },
77
+ ],
78
+ },
79
+
80
+ /**
81
+ * 出错 (reserved) —— the turn failed. Low, hollow, two hits: reads as a fault
82
+ * rather than a completion.
83
+ */
84
+ 'turn-fail': {
85
+ duration: 0.9,
86
+ peakDbfs: -2,
87
+ notes: [
88
+ { note: 'F3', start: 0, duration: 0.28, gain: 0.4, timbre: 'hollow', decay: 2.6, attack: 0.003 },
89
+ { note: 'C3', start: 0.2, duration: 0.62, gain: 0.4, timbre: 'hollow', decay: 1.9, attack: 0.003 },
90
+ ],
91
+ },
92
+ };
93
+
94
+ /** Slot ids in playback/preview order. */
95
+ const SLOT_IDS = ['boot', 'turn-start', 'turn-done', 'attention', 'turn-fail'];
96
+
97
+ module.exports = { SLOTS, SLOT_IDS };
package/lib/tone.js CHANGED
@@ -1,216 +1,216 @@
1
- 'use strict';
2
- /**
3
- * Zero-dependency PCM WAV tone synthesizer.
4
- *
5
- * The audio-notification feature needs four short sounds and must NOT ship
6
- * binary audio assets (no licensing question, no repo weight). This module
7
- * computes the samples instead: a note is a fundamental plus a few harmonics
8
- * under an attack/decay envelope, and the mix is normalized so every slot lands
9
- * at the same loudness.
10
- *
11
- * Output format is deliberately the one the whole stack already proved it can
12
- * play (verified end to end with Windows Media.SoundPlayer): RIFF/WAVE,
13
- * 16-bit PCM, mono, 44.1 kHz.
14
- */
15
-
16
- const SAMPLE_RATE = 44100;
17
-
18
- /** Equal-temperament pitch of one note name against A4 = 440 Hz. */
19
- const SEMITONES = { C: -9, 'C#': -8, D: -7, 'D#': -6, E: -5, F: -4, 'F#': -3, G: -2, 'G#': -1, A: 0, 'A#': 1, B: 2 };
20
-
21
- /**
22
- * Resolve a note name such as `C5`, `F#4` or `A4` to hertz.
23
- * @param name - letter, optional `#`, then the octave digit(s).
24
- * @returns frequency in hertz.
25
- */
26
- function noteHz(name) {
27
- const match = /^([A-G]#?)(-?\d+)$/.exec(String(name));
28
- if (match === null) throw new Error(`bad note name: ${name}`);
29
- const semitone = SEMITONES[match[1]];
30
- const octave = Number(match[2]);
31
- return 440 * Math.pow(2, (semitone + (octave - 4) * 12) / 12);
32
- }
33
-
34
- /** Timbre presets: [harmonic multiplier, amplitude] pairs. */
35
- const TIMBRES = {
36
- // Warm chime: fundamental with a soft octave and a faint twelfth.
37
- chime: [[1, 1], [2, 0.3], [3, 0.08]],
38
- // Bright, more present top end — for the short "received" blip.
39
- blip: [[1, 1], [2, 0.42], [3, 0.12]],
40
- // Hollow and darker — used where the sound must read as a warning.
41
- hollow: [[1, 1], [2, 0.12], [3, 0.3]],
42
- };
43
-
44
- /**
45
- * Mix one note into the sample buffer.
46
- *
47
- * @param data - mono sample buffer, added into (not overwritten).
48
- * @param spec - frequency (Hz), start (s), duration (s), gain, timbre, detune
49
- * in cents applied to the whole note, and envelope shape.
50
- */
51
- function addNote(data, spec) {
52
- const {
53
- freq,
54
- start,
55
- duration,
56
- gain,
57
- timbre = 'chime',
58
- detuneCents = 0,
59
- attack = 0.006,
60
- decay = 1.15,
61
- curve = 2.2,
62
- } = spec;
63
- const hz = freq * Math.pow(2, detuneCents / 1200);
64
- const from = Math.floor(start * SAMPLE_RATE);
65
- const length = Math.floor(duration * SAMPLE_RATE);
66
- const attackSamples = Math.max(1, Math.floor(attack * SAMPLE_RATE));
67
- const voices = TIMBRES[timbre];
68
- if (voices === undefined) throw new Error(`unknown timbre: ${timbre}`);
69
- for (let i = 0; i < length; i++) {
70
- const cursor = from + i;
71
- if (cursor >= data.length) break;
72
- const t = i / SAMPLE_RATE;
73
- const envelope =
74
- (i < attackSamples ? i / attackSamples : 1) *
75
- Math.pow(1 - i / length, curve) *
76
- Math.exp(-decay * t);
77
- let sample = 0;
78
- for (const [multiplier, amplitude] of voices) {
79
- sample += amplitude * Math.sin(2 * Math.PI * hz * multiplier * t);
80
- }
81
- data[cursor] += sample * envelope * gain;
82
- }
83
- }
84
-
85
- /**
86
- * Render a slot definition to a complete WAV file.
87
- *
88
- * @param slot - `{ duration, peakDbfs, notes: [...] }` where each note uses the
89
- * `addNote` fields plus a `note` name instead of a raw frequency.
90
- * @returns the file bytes.
91
- */
92
- function renderWav(slot) {
93
- const total = Math.floor(SAMPLE_RATE * slot.duration);
94
- const data = new Float64Array(total);
95
- for (const note of slot.notes) {
96
- addNote(data, Object.assign({}, note, {
97
- freq: note.freq === undefined ? noteHz(note.note) : note.freq,
98
- }));
99
- }
100
- let peak = 0;
101
- for (let i = 0; i < total; i++) peak = Math.max(peak, Math.abs(data[i]));
102
- const target = Math.pow(10, (slot.peakDbfs === undefined ? -2 : slot.peakDbfs) / 20);
103
- const scale = peak > 0 ? target / peak : 1;
104
- const pcm = Buffer.alloc(total * 2);
105
- for (let i = 0; i < total; i++) {
106
- let value = Math.round(data[i] * scale * 32767);
107
- if (value > 32767) value = 32767;
108
- if (value < -32768) value = -32768;
109
- pcm.writeInt16LE(value, i * 2);
110
- }
111
- const header = Buffer.alloc(44);
112
- header.write('RIFF', 0, 'ascii');
113
- header.writeUInt32LE(36 + pcm.length, 4);
114
- header.write('WAVE', 8, 'ascii');
115
- header.write('fmt ', 12, 'ascii');
116
- header.writeUInt32LE(16, 16);
117
- header.writeUInt16LE(1, 20); // PCM
118
- header.writeUInt16LE(1, 22); // mono
119
- header.writeUInt32LE(SAMPLE_RATE, 24);
120
- header.writeUInt32LE(SAMPLE_RATE * 2, 28);
121
- header.writeUInt16LE(2, 32);
122
- header.writeUInt16LE(16, 34);
123
- header.write('data', 36, 'ascii');
124
- header.writeUInt32LE(pcm.length, 40);
125
- return Buffer.concat([header, pcm]);
126
- }
127
-
128
- /** Parse a WAV buffer far enough to locate its PCM data and format. */
129
- function parseWav(buffer) {
130
- if (buffer.length < 44) return undefined;
131
- if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return undefined;
132
- let offset = 12;
133
- let audioFormat = 0;
134
- let bitsPerSample = 0;
135
- let channels = 0;
136
- let sampleRate = 0;
137
- let dataOffset = -1;
138
- let dataSize = 0;
139
- while (offset + 8 <= buffer.length) {
140
- const id = buffer.toString('ascii', offset, offset + 4);
141
- const chunkSize = buffer.readUInt32LE(offset + 4);
142
- const body = offset + 8;
143
- /* chunkSize is file-controlled, so every read below is also bounded by the
144
- real buffer length: a truncated fmt chunk (declared size past the end of
145
- the file) would otherwise throw RangeError out of parseWav — and
146
- scaleWavVolume runs it on user-dropped files whenever volume < 100. */
147
- if (id === 'fmt ' && chunkSize >= 16 && buffer.length >= body + 16) {
148
- audioFormat = buffer.readUInt16LE(body);
149
- channels = buffer.readUInt16LE(body + 2);
150
- sampleRate = buffer.readUInt32LE(body + 4);
151
- bitsPerSample = buffer.readUInt16LE(body + 14);
152
- // WAVE_FORMAT_EXTENSIBLE wraps the real format in the first two bytes of
153
- // the sub-format GUID.
154
- if (audioFormat === 0xfffe && chunkSize >= 40 && buffer.length >= body + 26
155
- && buffer.readUInt16LE(body + 24) === 1) audioFormat = 1;
156
- } else if (id === 'data') {
157
- dataOffset = body;
158
- dataSize = Math.min(chunkSize, buffer.length - body);
159
- }
160
- if (dataOffset >= 0 && bitsPerSample > 0) break;
161
- offset = body + chunkSize + (chunkSize % 2);
162
- }
163
- if (dataOffset < 0 || audioFormat !== 1) return undefined;
164
- if (channels <= 0 || sampleRate <= 0) return undefined;
165
- return { dataOffset, dataSize, bitsPerSample, channels, sampleRate };
166
- }
167
-
168
- /**
169
- * Rescale a PCM WAV's samples by `volume` percent without touching the system
170
- * volume. Returns the original buffer when the format is not rescalable, so a
171
- * caller can always write the result out.
172
- *
173
- * @param buffer - the source WAV bytes.
174
- * @param volume - 0..100.
175
- * @returns scaled WAV bytes (or the input, unchanged, when scaling is a no-op).
176
- */
177
- function scaleWavVolume(buffer, volume) {
178
- const gain = volume / 100;
179
- if (gain === 1) return buffer;
180
- const parsed = parseWav(buffer);
181
- if (parsed === undefined) return buffer;
182
- const { dataOffset, dataSize, bitsPerSample, channels } = parsed;
183
- if (bitsPerSample !== 16 && bitsPerSample !== 8) return buffer;
184
- /* Scale EVERY channel. The first version assumed mono and stepped 2 bytes per
185
- sample, so on stereo input it scaled only the left channel — volume 50 would
186
- have produced a left channel at half level and an untouched right one. The
187
- bundled tones are mono, but a user-supplied file need not be, and the whole
188
- point of this path is honouring a user's own audio. */
189
- const frameBytes = (bitsPerSample / 8) * channels;
190
- const out = Buffer.from(buffer);
191
- const frames = Math.floor(dataSize / frameBytes);
192
- if (bitsPerSample === 16) {
193
- for (let frame = 0; frame < frames; frame++) {
194
- for (let channel = 0; channel < channels; channel++) {
195
- const pos = dataOffset + frame * frameBytes + channel * 2;
196
- let value = Math.round(out.readInt16LE(pos) * gain);
197
- if (value > 32767) value = 32767;
198
- else if (value < -32768) value = -32768;
199
- out.writeInt16LE(value, pos);
200
- }
201
- }
202
- } else {
203
- for (let frame = 0; frame < frames; frame++) {
204
- for (let channel = 0; channel < channels; channel++) {
205
- const pos = dataOffset + frame * frameBytes + channel;
206
- let value = Math.round((out.readUInt8(pos) - 128) * gain + 128);
207
- if (value > 255) value = 255;
208
- else if (value < 0) value = 0;
209
- out.writeUInt8(value, pos);
210
- }
211
- }
212
- }
213
- return out;
214
- }
215
-
216
- module.exports = { SAMPLE_RATE, noteHz, renderWav, scaleWavVolume, parseWav };
1
+ 'use strict';
2
+ /**
3
+ * Zero-dependency PCM WAV tone synthesizer.
4
+ *
5
+ * The audio-notification feature needs four short sounds and must NOT ship
6
+ * binary audio assets (no licensing question, no repo weight). This module
7
+ * computes the samples instead: a note is a fundamental plus a few harmonics
8
+ * under an attack/decay envelope, and the mix is normalized so every slot lands
9
+ * at the same loudness.
10
+ *
11
+ * Output format is deliberately the one the whole stack already proved it can
12
+ * play (verified end to end with Windows Media.SoundPlayer): RIFF/WAVE,
13
+ * 16-bit PCM, mono, 44.1 kHz.
14
+ */
15
+
16
+ const SAMPLE_RATE = 44100;
17
+
18
+ /** Equal-temperament pitch of one note name against A4 = 440 Hz. */
19
+ const SEMITONES = { C: -9, 'C#': -8, D: -7, 'D#': -6, E: -5, F: -4, 'F#': -3, G: -2, 'G#': -1, A: 0, 'A#': 1, B: 2 };
20
+
21
+ /**
22
+ * Resolve a note name such as `C5`, `F#4` or `A4` to hertz.
23
+ * @param name - letter, optional `#`, then the octave digit(s).
24
+ * @returns frequency in hertz.
25
+ */
26
+ function noteHz(name) {
27
+ const match = /^([A-G]#?)(-?\d+)$/.exec(String(name));
28
+ if (match === null) throw new Error(`bad note name: ${name}`);
29
+ const semitone = SEMITONES[match[1]];
30
+ const octave = Number(match[2]);
31
+ return 440 * Math.pow(2, (semitone + (octave - 4) * 12) / 12);
32
+ }
33
+
34
+ /** Timbre presets: [harmonic multiplier, amplitude] pairs. */
35
+ const TIMBRES = {
36
+ // Warm chime: fundamental with a soft octave and a faint twelfth.
37
+ chime: [[1, 1], [2, 0.3], [3, 0.08]],
38
+ // Bright, more present top end — for the short "received" blip.
39
+ blip: [[1, 1], [2, 0.42], [3, 0.12]],
40
+ // Hollow and darker — used where the sound must read as a warning.
41
+ hollow: [[1, 1], [2, 0.12], [3, 0.3]],
42
+ };
43
+
44
+ /**
45
+ * Mix one note into the sample buffer.
46
+ *
47
+ * @param data - mono sample buffer, added into (not overwritten).
48
+ * @param spec - frequency (Hz), start (s), duration (s), gain, timbre, detune
49
+ * in cents applied to the whole note, and envelope shape.
50
+ */
51
+ function addNote(data, spec) {
52
+ const {
53
+ freq,
54
+ start,
55
+ duration,
56
+ gain,
57
+ timbre = 'chime',
58
+ detuneCents = 0,
59
+ attack = 0.006,
60
+ decay = 1.15,
61
+ curve = 2.2,
62
+ } = spec;
63
+ const hz = freq * Math.pow(2, detuneCents / 1200);
64
+ const from = Math.floor(start * SAMPLE_RATE);
65
+ const length = Math.floor(duration * SAMPLE_RATE);
66
+ const attackSamples = Math.max(1, Math.floor(attack * SAMPLE_RATE));
67
+ const voices = TIMBRES[timbre];
68
+ if (voices === undefined) throw new Error(`unknown timbre: ${timbre}`);
69
+ for (let i = 0; i < length; i++) {
70
+ const cursor = from + i;
71
+ if (cursor >= data.length) break;
72
+ const t = i / SAMPLE_RATE;
73
+ const envelope =
74
+ (i < attackSamples ? i / attackSamples : 1) *
75
+ Math.pow(1 - i / length, curve) *
76
+ Math.exp(-decay * t);
77
+ let sample = 0;
78
+ for (const [multiplier, amplitude] of voices) {
79
+ sample += amplitude * Math.sin(2 * Math.PI * hz * multiplier * t);
80
+ }
81
+ data[cursor] += sample * envelope * gain;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Render a slot definition to a complete WAV file.
87
+ *
88
+ * @param slot - `{ duration, peakDbfs, notes: [...] }` where each note uses the
89
+ * `addNote` fields plus a `note` name instead of a raw frequency.
90
+ * @returns the file bytes.
91
+ */
92
+ function renderWav(slot) {
93
+ const total = Math.floor(SAMPLE_RATE * slot.duration);
94
+ const data = new Float64Array(total);
95
+ for (const note of slot.notes) {
96
+ addNote(data, Object.assign({}, note, {
97
+ freq: note.freq === undefined ? noteHz(note.note) : note.freq,
98
+ }));
99
+ }
100
+ let peak = 0;
101
+ for (let i = 0; i < total; i++) peak = Math.max(peak, Math.abs(data[i]));
102
+ const target = Math.pow(10, (slot.peakDbfs === undefined ? -2 : slot.peakDbfs) / 20);
103
+ const scale = peak > 0 ? target / peak : 1;
104
+ const pcm = Buffer.alloc(total * 2);
105
+ for (let i = 0; i < total; i++) {
106
+ let value = Math.round(data[i] * scale * 32767);
107
+ if (value > 32767) value = 32767;
108
+ if (value < -32768) value = -32768;
109
+ pcm.writeInt16LE(value, i * 2);
110
+ }
111
+ const header = Buffer.alloc(44);
112
+ header.write('RIFF', 0, 'ascii');
113
+ header.writeUInt32LE(36 + pcm.length, 4);
114
+ header.write('WAVE', 8, 'ascii');
115
+ header.write('fmt ', 12, 'ascii');
116
+ header.writeUInt32LE(16, 16);
117
+ header.writeUInt16LE(1, 20); // PCM
118
+ header.writeUInt16LE(1, 22); // mono
119
+ header.writeUInt32LE(SAMPLE_RATE, 24);
120
+ header.writeUInt32LE(SAMPLE_RATE * 2, 28);
121
+ header.writeUInt16LE(2, 32);
122
+ header.writeUInt16LE(16, 34);
123
+ header.write('data', 36, 'ascii');
124
+ header.writeUInt32LE(pcm.length, 40);
125
+ return Buffer.concat([header, pcm]);
126
+ }
127
+
128
+ /** Parse a WAV buffer far enough to locate its PCM data and format. */
129
+ function parseWav(buffer) {
130
+ if (buffer.length < 44) return undefined;
131
+ if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return undefined;
132
+ let offset = 12;
133
+ let audioFormat = 0;
134
+ let bitsPerSample = 0;
135
+ let channels = 0;
136
+ let sampleRate = 0;
137
+ let dataOffset = -1;
138
+ let dataSize = 0;
139
+ while (offset + 8 <= buffer.length) {
140
+ const id = buffer.toString('ascii', offset, offset + 4);
141
+ const chunkSize = buffer.readUInt32LE(offset + 4);
142
+ const body = offset + 8;
143
+ /* chunkSize is file-controlled, so every read below is also bounded by the
144
+ real buffer length: a truncated fmt chunk (declared size past the end of
145
+ the file) would otherwise throw RangeError out of parseWav — and
146
+ scaleWavVolume runs it on user-dropped files whenever volume < 100. */
147
+ if (id === 'fmt ' && chunkSize >= 16 && buffer.length >= body + 16) {
148
+ audioFormat = buffer.readUInt16LE(body);
149
+ channels = buffer.readUInt16LE(body + 2);
150
+ sampleRate = buffer.readUInt32LE(body + 4);
151
+ bitsPerSample = buffer.readUInt16LE(body + 14);
152
+ // WAVE_FORMAT_EXTENSIBLE wraps the real format in the first two bytes of
153
+ // the sub-format GUID.
154
+ if (audioFormat === 0xfffe && chunkSize >= 40 && buffer.length >= body + 26
155
+ && buffer.readUInt16LE(body + 24) === 1) audioFormat = 1;
156
+ } else if (id === 'data') {
157
+ dataOffset = body;
158
+ dataSize = Math.min(chunkSize, buffer.length - body);
159
+ }
160
+ if (dataOffset >= 0 && bitsPerSample > 0) break;
161
+ offset = body + chunkSize + (chunkSize % 2);
162
+ }
163
+ if (dataOffset < 0 || audioFormat !== 1) return undefined;
164
+ if (channels <= 0 || sampleRate <= 0) return undefined;
165
+ return { dataOffset, dataSize, bitsPerSample, channels, sampleRate };
166
+ }
167
+
168
+ /**
169
+ * Rescale a PCM WAV's samples by `volume` percent without touching the system
170
+ * volume. Returns the original buffer when the format is not rescalable, so a
171
+ * caller can always write the result out.
172
+ *
173
+ * @param buffer - the source WAV bytes.
174
+ * @param volume - 0..100.
175
+ * @returns scaled WAV bytes (or the input, unchanged, when scaling is a no-op).
176
+ */
177
+ function scaleWavVolume(buffer, volume) {
178
+ const gain = volume / 100;
179
+ if (gain === 1) return buffer;
180
+ const parsed = parseWav(buffer);
181
+ if (parsed === undefined) return buffer;
182
+ const { dataOffset, dataSize, bitsPerSample, channels } = parsed;
183
+ if (bitsPerSample !== 16 && bitsPerSample !== 8) return buffer;
184
+ /* Scale EVERY channel. The first version assumed mono and stepped 2 bytes per
185
+ sample, so on stereo input it scaled only the left channel — volume 50 would
186
+ have produced a left channel at half level and an untouched right one. The
187
+ bundled tones are mono, but a user-supplied file need not be, and the whole
188
+ point of this path is honouring a user's own audio. */
189
+ const frameBytes = (bitsPerSample / 8) * channels;
190
+ const out = Buffer.from(buffer);
191
+ const frames = Math.floor(dataSize / frameBytes);
192
+ if (bitsPerSample === 16) {
193
+ for (let frame = 0; frame < frames; frame++) {
194
+ for (let channel = 0; channel < channels; channel++) {
195
+ const pos = dataOffset + frame * frameBytes + channel * 2;
196
+ let value = Math.round(out.readInt16LE(pos) * gain);
197
+ if (value > 32767) value = 32767;
198
+ else if (value < -32768) value = -32768;
199
+ out.writeInt16LE(value, pos);
200
+ }
201
+ }
202
+ } else {
203
+ for (let frame = 0; frame < frames; frame++) {
204
+ for (let channel = 0; channel < channels; channel++) {
205
+ const pos = dataOffset + frame * frameBytes + channel;
206
+ let value = Math.round((out.readUInt8(pos) - 128) * gain + 128);
207
+ if (value > 255) value = 255;
208
+ else if (value < 0) value = 0;
209
+ out.writeUInt8(value, pos);
210
+ }
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ module.exports = { SAMPLE_RATE, noteHz, renderWav, scaleWavVolume, parseWav };
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "meta": {
3
- "title": "Endfield Theme",
4
- "description": "Arknights: Endfield industrial editorial theme for the Harness web UI — cream paper, ink text, signal accents, zero radius."
5
- }
6
- }
1
+ {
2
+ "meta": {
3
+ "title": "Endfield Theme",
4
+ "description": "Arknights: Endfield industrial editorial theme for the Harness web UI — cream paper, ink text, signal accents, zero radius."
5
+ }
6
+ }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "meta": {
3
- "title": "终末地主题",
4
- "description": "《明日方舟:终末地》工业编辑风主题:奶油纸底、墨黑文字、信号色强调、全直角。"
5
- }
6
- }
1
+ {
2
+ "meta": {
3
+ "title": "终末地主题",
4
+ "description": "《明日方舟:终末地》工业编辑风主题:奶油纸底、墨黑文字、信号色强调、全直角。"
5
+ }
6
+ }