picopilot 0.2.1 → 0.3.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/dist/commands/audio.d.ts +23 -2
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +228 -23
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/music.d.ts +2 -1
- package/dist/commands/music.d.ts.map +1 -1
- package/dist/commands/music.js +209 -2
- package/dist/commands/music.js.map +1 -1
- package/dist/commands/sfx.d.ts.map +1 -1
- package/dist/commands/sfx.js +99 -11
- package/dist/commands/sfx.js.map +1 -1
- package/dist/engine/audio/analyze.d.ts +164 -0
- package/dist/engine/audio/analyze.d.ts.map +1 -0
- package/dist/engine/audio/analyze.js +512 -0
- package/dist/engine/audio/analyze.js.map +1 -0
- package/dist/engine/audio/index.d.ts +4 -2
- package/dist/engine/audio/index.d.ts.map +1 -1
- package/dist/engine/audio/index.js +4 -2
- package/dist/engine/audio/index.js.map +1 -1
- package/dist/engine/audio/score.d.ts +137 -0
- package/dist/engine/audio/score.d.ts.map +1 -0
- package/dist/engine/audio/score.js +420 -0
- package/dist/engine/audio/score.js.map +1 -0
- package/dist/engine/audio/section.d.ts +7 -0
- package/dist/engine/audio/section.d.ts.map +1 -1
- package/dist/engine/audio/section.js +17 -0
- package/dist/engine/audio/section.js.map +1 -1
- package/dist/engine/audio/sfx.d.ts +114 -12
- package/dist/engine/audio/sfx.d.ts.map +1 -1
- package/dist/engine/audio/sfx.js +236 -33
- package/dist/engine/audio/sfx.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +11 -5
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +24 -10
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -1
- package/dist/engine/scaffold/scaffold.js +5 -1
- package/dist/engine/scaffold/scaffold.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/audio.test.ts +170 -1
- package/src/commands/audio.ts +284 -27
- package/src/commands/doctor.test.ts +1 -0
- package/src/commands/doctor.ts +1 -0
- package/src/commands/music.test.ts +119 -0
- package/src/commands/music.ts +251 -1
- package/src/commands/sfx.test.ts +55 -0
- package/src/commands/sfx.ts +118 -11
- package/src/engine/audio/analyze.test.ts +239 -0
- package/src/engine/audio/analyze.ts +664 -0
- package/src/engine/audio/index.ts +46 -1
- package/src/engine/audio/score.test.ts +402 -0
- package/src/engine/audio/score.ts +630 -0
- package/src/engine/audio/section.ts +19 -0
- package/src/engine/audio/sfx.test.ts +219 -4
- package/src/engine/audio/sfx.ts +326 -40
- package/src/engine/pico8/harness.test.ts +19 -0
- package/src/engine/pico8/harness.ts +29 -11
- package/src/engine/scaffold/scaffold.ts +5 -1
- package/src/skills/picopilot-audio/SKILL.md +74 -184
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
analyzeAudio,
|
|
5
|
+
AudioAnalyzeError,
|
|
6
|
+
decodeWav,
|
|
7
|
+
type DecodedWav,
|
|
8
|
+
encodeWav16,
|
|
9
|
+
percentile,
|
|
10
|
+
sparkline,
|
|
11
|
+
} from './analyze.js';
|
|
12
|
+
|
|
13
|
+
const RATE = 22050;
|
|
14
|
+
|
|
15
|
+
/** Builds a mono signal from per-second generator `f(t)`. */
|
|
16
|
+
function signal(seconds: number, f: (t: number) => number): DecodedWav {
|
|
17
|
+
const samples = new Float32Array(Math.round(seconds * RATE));
|
|
18
|
+
for (let i = 0; i < samples.length; i++) samples[i] = f(i / RATE);
|
|
19
|
+
return {sampleRate: RATE, channels: 1, samples};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sine = (hz: number, amp: number) => (t: number) =>
|
|
23
|
+
amp * Math.sin(2 * Math.PI * hz * t);
|
|
24
|
+
|
|
25
|
+
/** A deterministic "melody": a new pitch every `noteSeconds`, from a seeded sequence. */
|
|
26
|
+
function melody(seed: number, noteSeconds: number) {
|
|
27
|
+
const pitches = [220, 247, 262, 294, 330, 349, 392, 440];
|
|
28
|
+
return (t: number) => {
|
|
29
|
+
const n = Math.floor(t / noteSeconds);
|
|
30
|
+
const x = Math.sin((n + 1) * 12.9898 * seed) * 43758.5453;
|
|
31
|
+
const hz = pitches[Math.floor((x - Math.floor(x)) * pitches.length)]!;
|
|
32
|
+
return 0.1 * Math.sin(2 * Math.PI * hz * t);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('decodeWav / encodeWav16', () => {
|
|
37
|
+
it('round-trips 16-bit mono PCM', () => {
|
|
38
|
+
const wav = signal(0.5, sine(440, 0.5));
|
|
39
|
+
const back = decodeWav(encodeWav16(wav.samples, RATE));
|
|
40
|
+
expect(back.sampleRate).toBe(RATE);
|
|
41
|
+
expect(back.channels).toBe(1);
|
|
42
|
+
expect(back.samples).toHaveLength(wav.samples.length);
|
|
43
|
+
expect(back.samples[100]).toBeCloseTo(wav.samples[100]!, 3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('downmixes stereo and reads 8-bit', () => {
|
|
47
|
+
// Hand-build a 2-channel 8-bit WAV with 2 frames: (255,1) and (128,128).
|
|
48
|
+
const data = [255, 1, 128, 128];
|
|
49
|
+
const buf = new Uint8Array(44 + data.length);
|
|
50
|
+
const v = new DataView(buf.buffer);
|
|
51
|
+
buf.set(
|
|
52
|
+
[...'RIFF'].map((c) => c.charCodeAt(0)),
|
|
53
|
+
0,
|
|
54
|
+
);
|
|
55
|
+
v.setUint32(4, 36 + data.length, true);
|
|
56
|
+
buf.set(
|
|
57
|
+
[...'WAVEfmt '].map((c) => c.charCodeAt(0)),
|
|
58
|
+
8,
|
|
59
|
+
);
|
|
60
|
+
v.setUint32(16, 16, true);
|
|
61
|
+
v.setUint16(20, 1, true);
|
|
62
|
+
v.setUint16(22, 2, true);
|
|
63
|
+
v.setUint32(24, 8000, true);
|
|
64
|
+
v.setUint32(28, 16000, true);
|
|
65
|
+
v.setUint16(32, 2, true);
|
|
66
|
+
v.setUint16(34, 8, true);
|
|
67
|
+
buf.set(
|
|
68
|
+
[...'data'].map((c) => c.charCodeAt(0)),
|
|
69
|
+
36,
|
|
70
|
+
);
|
|
71
|
+
v.setUint32(40, data.length, true);
|
|
72
|
+
buf.set(data, 44);
|
|
73
|
+
const wav = decodeWav(buf);
|
|
74
|
+
expect(wav.channels).toBe(2);
|
|
75
|
+
expect(wav.samples).toHaveLength(2);
|
|
76
|
+
expect(wav.samples[0]).toBeCloseTo(0, 1); // (+0.99 + -0.99) / 2
|
|
77
|
+
expect(wav.samples[1]).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('refuses something that is not a WAV', () => {
|
|
81
|
+
expect(() =>
|
|
82
|
+
decodeWav(new TextEncoder().encode('hello world, not a wav')),
|
|
83
|
+
).toThrow(AudioAnalyzeError);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('analyzeAudio: loudness, brightness, dynamics', () => {
|
|
88
|
+
it('a steady 440 Hz sine: centroid ~440 Hz, no high energy, flat, and its RMS', () => {
|
|
89
|
+
const a = analyzeAudio(signal(6, sine(440, 0.1)));
|
|
90
|
+
expect(a.skippedSeconds).toBe(1);
|
|
91
|
+
expect(a.analyzedSeconds).toBeCloseTo(5, 1);
|
|
92
|
+
expect(a.loudness.meanRms).toBeCloseTo(0.1 / Math.SQRT2, 2);
|
|
93
|
+
expect(Math.abs(a.brightness.centroidHz - 440)).toBeLessThan(30);
|
|
94
|
+
expect(a.brightness.above2kHzPercent).toBeLessThan(0.1);
|
|
95
|
+
expect(a.dynamicRangeDb).toBeLessThan(0.5);
|
|
96
|
+
const codes = a.warnings.map((w) => w.code);
|
|
97
|
+
expect(codes).toContain('monotonous');
|
|
98
|
+
expect(codes).not.toContain('loud');
|
|
99
|
+
expect(codes).not.toContain('shrill');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('a loud, bright square wave is loud and shrill', () => {
|
|
103
|
+
const a = analyzeAudio(
|
|
104
|
+
signal(4, (t) => (Math.sin(2 * Math.PI * 1500 * t) >= 0 ? 0.3 : -0.3)),
|
|
105
|
+
);
|
|
106
|
+
const codes = a.warnings.map((w) => w.code);
|
|
107
|
+
expect(codes).toContain('loud');
|
|
108
|
+
expect(codes).toContain('shrill');
|
|
109
|
+
expect(a.brightness.above2kHzPercent).toBeGreaterThan(8);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('the first second (PICO-8 startup) is not measured', () => {
|
|
113
|
+
const a = analyzeAudio(
|
|
114
|
+
signal(4, (t) => (t < 1 ? sine(440, 0.9)(t) : sine(440, 0.05)(t))),
|
|
115
|
+
);
|
|
116
|
+
expect(a.loudness.peakFrameRms).toBeLessThan(0.05);
|
|
117
|
+
expect(
|
|
118
|
+
analyzeAudio(signal(4, sine(440, 0.05)), {skipSeconds: 0}).skippedSeconds,
|
|
119
|
+
).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('a short burst over a quiet bed is a spike, located in time', () => {
|
|
123
|
+
const a = analyzeAudio(
|
|
124
|
+
signal(6, (t) =>
|
|
125
|
+
t > 3 && t < 3.3 ? sine(330, 0.5)(t) : sine(330, 0.05)(t),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
expect(a.warnings.map((w) => w.code)).toContain('spikes');
|
|
129
|
+
expect(a.loudness.peakFrameAtSeconds).toBeGreaterThan(2.9);
|
|
130
|
+
expect(a.loudness.peakFrameAtSeconds).toBeLessThan(3.3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('quiet and loud passages give a dynamic range; silence is excluded from it', () => {
|
|
134
|
+
const a = analyzeAudio(
|
|
135
|
+
signal(9, (t) => {
|
|
136
|
+
const phase = Math.floor(t) % 3;
|
|
137
|
+
return phase === 0 ? 0 : sine(330, phase === 1 ? 0.02 : 0.1)(t);
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
expect(a.dynamicRangeDb).toBeGreaterThan(10);
|
|
141
|
+
expect(a.dynamicRangeDb).toBeLessThan(16);
|
|
142
|
+
expect(a.loudness.silentPercent).toBeGreaterThan(20);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('near-silence is reported as such (and nothing else)', () => {
|
|
146
|
+
const a = analyzeAudio(signal(3, () => 0));
|
|
147
|
+
expect(a.warnings.map((w) => w.code)).toEqual(['near-silent']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('a loudness profile per half second, with a sparkline', () => {
|
|
151
|
+
const a = analyzeAudio(signal(5, (t) => sine(440, t < 3 ? 0.02 : 0.2)(t)));
|
|
152
|
+
expect(a.profile.stepSeconds).toBe(0.5);
|
|
153
|
+
expect(a.profile.rms).toHaveLength(8);
|
|
154
|
+
expect(a.profile.sparkline).toHaveLength(8);
|
|
155
|
+
expect(a.profile.sparkline.at(-1)).toBe('█');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('too short for one frame is a structured error', () => {
|
|
159
|
+
expect(() => analyzeAudio(signal(0.05, sine(440, 0.1)))).toThrow(
|
|
160
|
+
AudioAnalyzeError,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('analyzeAudio: repetition (loop) length', () => {
|
|
166
|
+
it('finds a 5 s loop repeated three times, and calls it repetitive', () => {
|
|
167
|
+
const loop = melody(7, 0.25);
|
|
168
|
+
const a = analyzeAudio(
|
|
169
|
+
signal(16, (t) => loop(t % 5)),
|
|
170
|
+
{skipSeconds: 0},
|
|
171
|
+
);
|
|
172
|
+
expect(a.loop.seconds).not.toBeNull();
|
|
173
|
+
expect(Math.abs(a.loop.seconds! - 5)).toBeLessThan(0.15);
|
|
174
|
+
expect(a.warnings.map((w) => w.code)).toContain('repetitive');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('picks the loop, not a multiple of it', () => {
|
|
178
|
+
const loop = melody(3, 0.2);
|
|
179
|
+
const a = analyzeAudio(
|
|
180
|
+
signal(25, (t) => loop(t % 4)),
|
|
181
|
+
{skipSeconds: 0},
|
|
182
|
+
);
|
|
183
|
+
expect(Math.abs(a.loop.seconds! - 4)).toBeLessThan(0.15);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('says why when no repetition is visible', () => {
|
|
187
|
+
const a = analyzeAudio(signal(12, melody(11, 0.25)), {skipSeconds: 0});
|
|
188
|
+
expect(a.loop.seconds).toBeNull();
|
|
189
|
+
expect((a.loop as {reason: string}).reason).toMatch(/at least 2x/);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('analyzeAudio: against a reference SFX', () => {
|
|
194
|
+
it('reports the music level relative to the SFX and warns when it is not under it', () => {
|
|
195
|
+
const sfx = signal(1, sine(600, 0.2));
|
|
196
|
+
const quiet = analyzeAudio(signal(4, sine(330, 0.05)), {against: sfx});
|
|
197
|
+
expect(quiet.against!.musicOverReferenceDb).toBeLessThan(-10);
|
|
198
|
+
expect(quiet.warnings.map((w) => w.code)).not.toContain('music-over-sfx');
|
|
199
|
+
const loud = analyzeAudio(signal(4, sine(330, 0.2)), {against: sfx});
|
|
200
|
+
expect(loud.warnings.map((w) => w.code)).toContain('music-over-sfx');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('helpers', () => {
|
|
205
|
+
it('percentile interpolates', () => {
|
|
206
|
+
expect(percentile([1, 2, 3, 4, 5], 50)).toBe(3);
|
|
207
|
+
expect(percentile([0, 10], 90)).toBe(9);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('sparkline scales to the loudest value and blanks silence', () => {
|
|
211
|
+
expect(sparkline([0, 0.5, 1])).toBe(' ▅█');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('analyzeAudio: review fixes', () => {
|
|
216
|
+
it('a silent --against reference is refused, not a false warning', () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
analyzeAudio(signal(4, sine(330, 0.05)), {against: signal(1, () => 0)}),
|
|
219
|
+
).toThrow(AudioAnalyzeError);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('kind sfx keeps only the checks that apply to a one-shot', () => {
|
|
223
|
+
// A short, loud blip with a silent tail: music rules would flag it.
|
|
224
|
+
const blip = signal(2, (t) => (t < 0.3 ? sine(1500, 0.6)(t) : 0));
|
|
225
|
+
const music = analyzeAudio(blip, {skipSeconds: 0}).warnings.map(
|
|
226
|
+
(w) => w.code,
|
|
227
|
+
);
|
|
228
|
+
expect(music.length).toBeGreaterThan(0);
|
|
229
|
+
const sfx = analyzeAudio(blip, {skipSeconds: 0, kind: 'sfx'}).warnings;
|
|
230
|
+
expect(
|
|
231
|
+
sfx.every((w) => w.code === 'near-silent' || w.code === 'near-clipping'),
|
|
232
|
+
).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('a truncated fmt chunk is a structured bad-wav error', () => {
|
|
236
|
+
const ok = encodeWav16(new Float32Array(10), RATE);
|
|
237
|
+
expect(() => decodeWav(ok.slice(0, 24))).toThrow(AudioAnalyzeError);
|
|
238
|
+
});
|
|
239
|
+
});
|