picopilot 0.2.0 → 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/cli.d.ts.map +1 -1
- package/dist/cli.js +129 -36
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +23 -2
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +239 -26
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +8 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +11 -5
- package/dist/commands/gfx.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/pico8-env.d.ts +39 -3
- package/dist/commands/pico8-env.d.ts.map +1 -1
- package/dist/commands/pico8-env.js +51 -4
- package/dist/commands/pico8-env.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +8 -2
- package/dist/commands/run.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 +2 -2
- package/src/cli.test.ts +42 -1
- package/src/cli.ts +143 -37
- package/src/commands/audio.test.ts +170 -1
- package/src/commands/audio.ts +294 -30
- package/src/commands/doctor.test.ts +1 -0
- package/src/commands/doctor.ts +8 -3
- package/src/commands/gfx.ts +12 -9
- package/src/commands/music.test.ts +119 -0
- package/src/commands/music.ts +251 -1
- package/src/commands/pico8-env.ts +71 -3
- package/src/commands/run.ts +8 -2
- 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/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +74 -184
- package/src/skills/picopilot-overview/SKILL.md +1 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {type ChildProcess, spawn} from 'node:child_process';
|
|
2
|
+
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {createRequire} from 'node:module';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {join} from 'node:path';
|
|
6
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* picopilot as an MCP server (`picopilot --mcp`), end to end over stdio. incur's
|
|
10
|
+
* MCP transport passes no global flags to a tool call and loads no config file,
|
|
11
|
+
* so these prove picopilot closes those gaps itself: the server's own
|
|
12
|
+
* `--display` / `--config`, picopilot.json in the server's cwd, and a per-call
|
|
13
|
+
* `display` argument (ADR-0017).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const tsx = createRequire(import.meta.url).resolve('tsx');
|
|
17
|
+
const bin = new URL('./bin.ts', import.meta.url).pathname;
|
|
18
|
+
|
|
19
|
+
let dir: string;
|
|
20
|
+
let child: ChildProcess | undefined;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
dir = mkdtempSync(join(tmpdir(), 'picopilot-mcp-'));
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
child?.kill('SIGKILL');
|
|
27
|
+
child = undefined;
|
|
28
|
+
rmSync(dir, {recursive: true, force: true});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Starts `picopilot --mcp ...` in `dir` and returns a JSON-RPC caller. */
|
|
32
|
+
async function mcp(argv: string[] = [], env: NodeJS.ProcessEnv = {}) {
|
|
33
|
+
const proc = spawn(
|
|
34
|
+
process.execPath,
|
|
35
|
+
['--import', tsx, bin, '--mcp', ...argv],
|
|
36
|
+
{
|
|
37
|
+
cwd: dir,
|
|
38
|
+
// No PICO-8 / shrinko on PATH: doctor's probes fail fast.
|
|
39
|
+
env: {PATH: '/nonexistent', HOME: dir, ...env},
|
|
40
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
child = proc;
|
|
44
|
+
const waiting = new Map<number, (msg: any) => void>();
|
|
45
|
+
let buf = '';
|
|
46
|
+
proc.stdout!.setEncoding('utf8');
|
|
47
|
+
proc.stdout!.on('data', (chunk: string) => {
|
|
48
|
+
buf += chunk;
|
|
49
|
+
let nl: number;
|
|
50
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
51
|
+
const line = buf.slice(0, nl).trim();
|
|
52
|
+
buf = buf.slice(nl + 1);
|
|
53
|
+
if (line === '') continue;
|
|
54
|
+
const msg = JSON.parse(line);
|
|
55
|
+
if (typeof msg.id === 'number') waiting.get(msg.id)?.(msg);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
let nextId = 1;
|
|
59
|
+
const request = (method: string, params: unknown) =>
|
|
60
|
+
new Promise<any>((resolve) => {
|
|
61
|
+
const id = nextId++;
|
|
62
|
+
waiting.set(id, resolve);
|
|
63
|
+
proc.stdin!.write(
|
|
64
|
+
`${JSON.stringify({jsonrpc: '2.0', id, method, params})}\n`,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
await request('initialize', {
|
|
68
|
+
protocolVersion: '2025-06-18',
|
|
69
|
+
capabilities: {},
|
|
70
|
+
clientInfo: {name: 'test', version: '0'},
|
|
71
|
+
});
|
|
72
|
+
proc.stdin!.write(
|
|
73
|
+
`${JSON.stringify({jsonrpc: '2.0', method: 'notifications/initialized'})}\n`,
|
|
74
|
+
);
|
|
75
|
+
const call = async (name: string, args: Record<string, unknown> = {}) => {
|
|
76
|
+
const res = await request('tools/call', {name, arguments: args});
|
|
77
|
+
const text = res.result?.content?.[0]?.text ?? '';
|
|
78
|
+
return {isError: res.result?.isError === true, text};
|
|
79
|
+
};
|
|
80
|
+
return {request, call};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function doctorMode(
|
|
84
|
+
session: Awaited<ReturnType<typeof mcp>>,
|
|
85
|
+
args: Record<string, unknown> = {},
|
|
86
|
+
): Promise<string> {
|
|
87
|
+
const {text, isError} = await session.call('doctor', args);
|
|
88
|
+
expect(isError).toBe(false);
|
|
89
|
+
return JSON.parse(text).display.mode;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('picopilot --mcp: the display mode reaches tool calls', () => {
|
|
93
|
+
it('defaults to auto', async () => {
|
|
94
|
+
expect(await doctorMode(await mcp())).toBe('auto');
|
|
95
|
+
}, 30_000);
|
|
96
|
+
|
|
97
|
+
it('the server flag `--mcp --display xvfb` applies to every call', async () => {
|
|
98
|
+
expect(await doctorMode(await mcp(['--display', 'xvfb']))).toBe('xvfb');
|
|
99
|
+
}, 30_000);
|
|
100
|
+
|
|
101
|
+
it('picopilot.json "display" in the server cwd applies (incur loads no config under --mcp)', async () => {
|
|
102
|
+
writeFileSync(
|
|
103
|
+
join(dir, 'picopilot.json'),
|
|
104
|
+
JSON.stringify({display: 'native'}),
|
|
105
|
+
);
|
|
106
|
+
expect(await doctorMode(await mcp())).toBe('native');
|
|
107
|
+
}, 30_000);
|
|
108
|
+
|
|
109
|
+
it('the server flag beats PICOPILOT_DISPLAY, which beats picopilot.json', async () => {
|
|
110
|
+
writeFileSync(
|
|
111
|
+
join(dir, 'picopilot.json'),
|
|
112
|
+
JSON.stringify({display: 'native'}),
|
|
113
|
+
);
|
|
114
|
+
expect(await doctorMode(await mcp([], {PICOPILOT_DISPLAY: 'xvfb'}))).toBe(
|
|
115
|
+
'xvfb',
|
|
116
|
+
);
|
|
117
|
+
child?.kill('SIGKILL');
|
|
118
|
+
expect(
|
|
119
|
+
await doctorMode(
|
|
120
|
+
await mcp(['--display', 'auto'], {PICOPILOT_DISPLAY: 'xvfb'}),
|
|
121
|
+
),
|
|
122
|
+
).toBe('auto');
|
|
123
|
+
}, 30_000);
|
|
124
|
+
|
|
125
|
+
it('--no-config on the server ignores picopilot.json', async () => {
|
|
126
|
+
writeFileSync(
|
|
127
|
+
join(dir, 'picopilot.json'),
|
|
128
|
+
JSON.stringify({display: 'native'}),
|
|
129
|
+
);
|
|
130
|
+
expect(await doctorMode(await mcp(['--no-config']))).toBe('auto');
|
|
131
|
+
}, 30_000);
|
|
132
|
+
|
|
133
|
+
it('--config resolves like incur: `~` is the home dir, the last config flag wins', async () => {
|
|
134
|
+
writeFileSync(
|
|
135
|
+
join(dir, 'custom.json'),
|
|
136
|
+
JSON.stringify({display: 'native'}),
|
|
137
|
+
);
|
|
138
|
+
expect(await doctorMode(await mcp(['--config', '~/custom.json']))).toBe(
|
|
139
|
+
'native',
|
|
140
|
+
);
|
|
141
|
+
child?.kill('SIGKILL');
|
|
142
|
+
expect(
|
|
143
|
+
await doctorMode(await mcp(['--no-config', '--config=custom.json'])),
|
|
144
|
+
).toBe('native');
|
|
145
|
+
}, 30_000);
|
|
146
|
+
|
|
147
|
+
it('a broken config stops the server visibly instead of serving without it', async () => {
|
|
148
|
+
writeFileSync(join(dir, 'picopilot.json'), '{not json');
|
|
149
|
+
const proc = spawn(process.execPath, ['--import', tsx, bin, '--mcp'], {
|
|
150
|
+
cwd: dir,
|
|
151
|
+
env: {PATH: '/nonexistent', HOME: dir},
|
|
152
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
153
|
+
});
|
|
154
|
+
child = proc;
|
|
155
|
+
let stderr = '';
|
|
156
|
+
proc.stderr!.setEncoding('utf8');
|
|
157
|
+
proc.stderr!.on('data', (s: string) => {
|
|
158
|
+
stderr += s;
|
|
159
|
+
});
|
|
160
|
+
const code = await new Promise<number | null>((resolve) =>
|
|
161
|
+
proc.once('exit', resolve),
|
|
162
|
+
);
|
|
163
|
+
expect(code).toBe(1);
|
|
164
|
+
expect(stderr).toContain('Invalid JSON config file');
|
|
165
|
+
}, 30_000);
|
|
166
|
+
|
|
167
|
+
it('a per-call `display` argument beats the server flag', async () => {
|
|
168
|
+
const session = await mcp(['--display', 'xvfb']);
|
|
169
|
+
expect(await doctorMode(session, {display: 'native'})).toBe('native');
|
|
170
|
+
}, 30_000);
|
|
171
|
+
|
|
172
|
+
it('the recording tools expose `display` as an input', async () => {
|
|
173
|
+
const session = await mcp();
|
|
174
|
+
const list = await session.request('tools/list', {});
|
|
175
|
+
for (const name of ['audio_record', 'audio_render', 'run', 'doctor']) {
|
|
176
|
+
const tool = list.result.tools.find(
|
|
177
|
+
(t: {name: string}) => t.name === name,
|
|
178
|
+
);
|
|
179
|
+
expect(tool?.inputSchema?.properties?.display, name).toBeDefined();
|
|
180
|
+
}
|
|
181
|
+
}, 30_000);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('picopilot --mcp: per-command picopilot.json options still apply', () => {
|
|
185
|
+
/** A cart whose sprite 130 (shared map region) already holds a pixel. */
|
|
186
|
+
function cartWithSprite130Data(): string {
|
|
187
|
+
const rows = Array.from({length: 128}, () => '0'.repeat(128));
|
|
188
|
+
rows[64] = `${'0'.repeat(16)}7${'0'.repeat(111)}`;
|
|
189
|
+
const path = join(dir, 'main.p8');
|
|
190
|
+
writeFileSync(
|
|
191
|
+
path,
|
|
192
|
+
`pico-8 cartridge // http://www.pico-8.com\nversion 42\n__lua__\n__gfx__\n${rows.join('\n')}\n`,
|
|
193
|
+
);
|
|
194
|
+
return path;
|
|
195
|
+
}
|
|
196
|
+
const grid = Array.from({length: 8}, () => '33333333').join('\n');
|
|
197
|
+
|
|
198
|
+
it('gfx set is refused without authorisation, allowed by allowMapOverlap in picopilot.json', async () => {
|
|
199
|
+
const cart = cartWithSprite130Data();
|
|
200
|
+
const refused = await (
|
|
201
|
+
await mcp()
|
|
202
|
+
).call('gfx_set', {sprite: 130, grid, cart});
|
|
203
|
+
expect(refused.isError).toBe(true);
|
|
204
|
+
expect(refused.text).toContain('refusing');
|
|
205
|
+
child?.kill('SIGKILL');
|
|
206
|
+
|
|
207
|
+
writeFileSync(
|
|
208
|
+
join(dir, 'picopilot.json'),
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
commands: {gfx: {commands: {set: {options: {allowMapOverlap: true}}}}},
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
const allowed = await (
|
|
214
|
+
await mcp()
|
|
215
|
+
).call('gfx_set', {sprite: 130, grid, cart});
|
|
216
|
+
expect(allowed.isError).toBe(false);
|
|
217
|
+
expect(JSON.parse(allowed.text).overwroteSharedData).toBe(true);
|
|
218
|
+
}, 30_000);
|
|
219
|
+
});
|
|
@@ -1,190 +1,80 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: picopilot-audio
|
|
3
|
-
description: Compose PICO-8 sound as text with picopilot
|
|
3
|
+
description: Compose PICO-8 sound and music as text with picopilot, and judge it without ears. Covers tracker-row picopilot-MML (held notes, volume envelopes, the 5 SFX filters), whole songs with `music from-score`, the render then analyze loop, and the taste rules that keep game music soft, varied and under the SFX. Use when authoring __sfx__ / __music__.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# picopilot audio
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
`
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
##
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
held
|
|
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
|
-
effect, so an ABC-authored SFX is timbre-less ("blind audio in a nicer font").
|
|
82
|
-
|
|
83
|
-
### The picopilot-MML grammar in brief
|
|
84
|
-
|
|
85
|
-
Modal state (sticky until changed): `@<0-7>` waveform (`@8`-`@f` = SFX 0-7 as a
|
|
86
|
-
custom instrument), `v<0-7>` volume, `e<0-7>` effect (0 none, 1 slide, 2 vibrato,
|
|
87
|
-
3 drop, 4 fade in, 5 fade out, 6 arpeggio fast, 7 arpeggio slow), `o<0-5>` octave
|
|
88
|
-
(default `o2`), `l<N>` default length in rows, `>`/`<` octave shift. A bare note
|
|
89
|
-
(`c d e f g a b`, `+`/`#` sharp, `-` flat) emits one row in the current state.
|
|
90
|
-
`r<N>` = N silent rows; `^<N>` extends the previous note; `s<N>` = SFX speed
|
|
91
|
-
(default `s16`); `{`/`}` = loop markers (a LONE `{` marks the pattern LEN, the
|
|
92
|
-
finding's LEN special case). SFX-level FILTERS (per-SFX, order-free): `!dampen`/
|
|
93
|
-
`!dampen2`, `!reverb`, `!noiz`, `!buzz`, `!detune1`/`!detune2` (see the filters
|
|
94
|
-
section below). Whitespace + `|` bars are cosmetic. See finding Part B / A.7 for
|
|
95
|
-
the full tables.
|
|
96
|
-
|
|
97
|
-
Note `e` is BOTH a note letter AND the effect prefix: `e0`-`e7` is the EFFECT; a
|
|
98
|
-
bare `e` is note E (hold it with `l4 e`, not `e4`). Effects have NO letter
|
|
99
|
-
mnemonics (the finding's `sl`/`vb`/... sugar did NOT ship: each collides with a
|
|
100
|
-
real note-plus-length sequence); use the numeric `e0`-`e7`.
|
|
101
|
-
|
|
102
|
-
Example: `picopilot sfx from-mml 0 "s8 @3 v6 e2 c e g > c"` is a square-wave
|
|
103
|
-
(`@3`) vibrato (`e2`) arpeggio at speed 8.
|
|
104
|
-
|
|
105
|
-
### SFX FILTERS: how to reach DESIGNED sounds (explosion, engine, pad)
|
|
106
|
-
|
|
107
|
-
Pitch + waveform + volume + effect get you notes; the 5 per-SFX FILTERS are what
|
|
108
|
-
make a sound DESIGNED. They are `!`-prefixed SFX-level directives (per-SFX like
|
|
109
|
-
`s<N>`, place them anywhere in the string, order-free): they set the SFX's first
|
|
110
|
-
header byte and change nothing per-note. Use them when a boom reads as a
|
|
111
|
-
machine-gun rattle, or an engine as a buzz: no amount of pitch/volume MML can
|
|
112
|
-
synthesize the low BODY or resonant TAIL, only these filters can.
|
|
113
|
-
|
|
114
|
-
- **`!dampen` / `!dampen2`** — a low-pass at 2 levels. This is the BODY/weight:
|
|
115
|
-
turns raw `@6` noise from a hiss/rattle into a "boom". `!dampen2` is heavier.
|
|
116
|
-
The single biggest lever for an explosion.
|
|
117
|
-
- **`!reverb`** — an echo/tail. The resonant TAIL that makes a boom ring out (and
|
|
118
|
-
a pad bloom). On/off (a single switch, see the caveat below).
|
|
119
|
-
- **`!noiz`** — pure white-noise texture; applies ONLY to instrument 6 (`@6`), a
|
|
120
|
-
no-op on any other waveform. The noise character under an explosion.
|
|
121
|
-
- **`!buzz`** — a buzzier alteration (all waveforms). With `@6` + `!buzz` and NOIZ
|
|
122
|
-
off you get brown noise.
|
|
123
|
-
- **`!detune1`** (flange, second detuned voice) / **`!detune2`** (a voice tuned up
|
|
124
|
-
~an octave) — thickness for a lead or pad. `!detune` needs an explicit 1 or 2.
|
|
125
|
-
|
|
126
|
-
The canonical DESIGNED explosion: `!dampen2 !reverb @6` over a descending pitch
|
|
127
|
-
sweep + volume decay, e.g.
|
|
128
|
-
`picopilot sfx from-mml 3 "s6 !dampen2 !reverb @6 o3 v7 g o1 c o0 g v6 e v5 c v4 g v3 c v2 g v1 c"`.
|
|
129
|
-
Strip the two filters and the SAME notes read as a descending zap / rattle: the
|
|
130
|
-
filters are the tool gap the audio dogfood surfaced (they set header byte `e0`).
|
|
131
|
-
|
|
132
|
-
CAVEAT (finding A.7): the filter byte layout was decoded byte-for-byte on PICO-8
|
|
133
|
-
v0.2.7, but the byte has no non-colliding room for a REVERB level 2 under the
|
|
134
|
-
verified layout, so picopilot exposes `!reverb` as a single on/off switch (DAMPEN
|
|
135
|
-
keeps its 2 levels). A bad level (`!dampen3`, `!noiz2`, `!reverb2`, `!detune`
|
|
136
|
-
without 1/2) is a loud `audio-mml-filter-level-out-of-range` refusal, not a clamp.
|
|
137
|
-
|
|
138
|
-
## `music from-patterns`: structural, NOT a notation
|
|
139
|
-
|
|
140
|
-
`__music__` holds NO melody: melody lives entirely in the referenced SFX. A song
|
|
141
|
-
is an ORDERED LIST of up-to-4 SFX-channel references per pattern.
|
|
142
|
-
`picopilot music from-patterns "<json>"` writes `__music__` (only `__music__`
|
|
143
|
-
changes; `__sfx__` and every other section stay byte-identical). So author each
|
|
144
|
-
voice as an SFX with `sfx from-mml`, THEN arrange the references here; you never
|
|
145
|
-
write music in a melodic notation. Cart is `--cart` (default `main.p8`); longer
|
|
146
|
-
songs via `--file`.
|
|
147
|
-
|
|
148
|
-
### The pattern-list JSON
|
|
149
|
-
|
|
150
|
-
An array of patterns, each
|
|
151
|
-
`{"channels":[c0,c1,c2,c3], "loopStart"?, "loopBack"?, "stop"?}`. Pattern order =
|
|
152
|
-
song order. A channel is an SFX index `0-63`, OR `null` = OFF (silent this
|
|
153
|
-
pattern). **`null` is NOT `sfx 0`**: `null` sets the channel's off bit; `0` is a
|
|
154
|
-
real, playing SFX. Keep them distinct. The 3 flow flags (optional, default
|
|
155
|
-
false): `loopStart`, `loopBack` (jump back to the loop-start pattern at this
|
|
156
|
-
pattern's end), `stop` (music stops after this pattern); they combine. A
|
|
157
|
-
`loopBack` with NO `loopStart` anywhere is allowed but WARNS (PICO-8 falls back
|
|
158
|
-
to pattern 0).
|
|
159
|
-
|
|
160
|
-
Example: `[{"channels":[0,1,null,3],"loopStart":true},{"channels":[0,1,null,3],"loopBack":true}]`
|
|
161
|
-
is a 2-pattern loop whose channel 2 is silent.
|
|
162
|
-
|
|
163
|
-
**Pattern length is INHERITED from the SFX, not stored here.** PICO-8 ends a
|
|
164
|
-
pattern when its LEFT-MOST non-looping channel's SFX finishes, so pattern timing
|
|
165
|
-
comes from the referenced SFX (its `speed` + LEN). To change how long a pattern
|
|
166
|
-
plays, change the referenced SFX, not the music.
|
|
167
|
-
|
|
168
|
-
## The two refusals: loud, never clamped
|
|
169
|
-
|
|
170
|
-
Both stop you with a structured error + nonzero exit (no bytes change on a
|
|
171
|
-
refusal) so a refusal is ACTIONABLE, not a dead end:
|
|
172
|
-
|
|
173
|
-
- **`audio-mml-pitch-out-of-range`** (`sfx from-mml`): a note fell below C0 or
|
|
174
|
-
above D#5 (`0-63`). PICO-8 has no notes outside that range. Fix: TRANSPOSE, pick
|
|
175
|
-
an octave (`o0`-`o5`) that keeps every note in range, rather than transposing
|
|
176
|
-
off the keyboard.
|
|
177
|
-
- **`audio-mml-sfx-overflow`** (`sfx from-mml`): more than 32 rows. Fix: SPLIT the
|
|
178
|
-
melody across multiple SFX slots yourself, then arrange them with
|
|
179
|
-
`music from-patterns`.
|
|
180
|
-
- **`audio-mml-filter-level-out-of-range`** (`sfx from-mml`): a filter level PICO-8
|
|
181
|
-
does not have (`!dampen3`, `!reverb2`, `!noiz2`, or `!detune` without a mode).
|
|
182
|
-
Fix: `!dampen` has levels 1-2 (`!dampen`/`!dampen2`); `!reverb`/`!noiz`/`!buzz`
|
|
183
|
-
are on/off; `!detune` needs `!detune1` (flange) or `!detune2` (octave).
|
|
184
|
-
|
|
185
|
-
Related: `music from-patterns` refuses an out-of-range SFX index with
|
|
186
|
-
`audio-music-sfx-out-of-range` (to silence a channel use `null`, not an
|
|
187
|
-
out-of-range number); a malformed JSON payload is `audio-music-parse-error`. And
|
|
188
|
-
`audio render`/`audio record` require PICO-8, so they return a structured
|
|
189
|
-
`pico8-not-found` (remedy `set PICO8_PATH or install PICO-8`) when absent, never
|
|
190
|
-
a crash. The text authoring + transpile steps need neither PICO-8 nor shrinko.
|
|
8
|
+
You cannot hear, so you compose sound as TEXT (picopilot-MML), let picopilot write `__sfx__` / `__music__`, and judge the result with NUMBERS from `audio analyze`. Never hand-write the audio hex. The byte layout and full grammar live in `work/notes/findings/pico8-audio-sfx-music-layout-and-mml-subset.md`; the measurements behind the taste rules in `work/notes/findings/pico8-audio-music-taste-and-analysis.md`.
|
|
9
|
+
|
|
10
|
+
## The loop: compose, render, analyze, iterate
|
|
11
|
+
|
|
12
|
+
1. **Compose.** Game SFX: `picopilot sfx from-mml <slot> "<mml>"` (one sound, up to 32 rows). Music: `picopilot music from-score --file score.json --slots 16-63` (whole songs, any length).
|
|
13
|
+
2. **Render.** `picopilot audio render --pattern <startPattern> --seconds <n> --analyze`. Record more than twice the song's `seconds` (from-score reports it, and its next-step hint does this) so the loop length is measurable. `--sfx <slot> --analyze` analyzes a one-shot with only the checks that apply to it (near-silent, near-clipping).
|
|
14
|
+
3. **Analyze.** Read `analysis.warnings` and the numbers. Check music against an SFX with `picopilot audio analyze music.wav --against sfx.wav`.
|
|
15
|
+
4. **Iterate** until the music analysis has no warnings and the score follows the taste rules below. A clean analysis is the done bar; "it compiled" is not.
|
|
16
|
+
|
|
17
|
+
## Tracker rows, not sheet music (ADR-0008)
|
|
18
|
+
|
|
19
|
+
PICO-8 has no tempo and no per-note duration. An SFX is up to 32 ROWS at one `speed` (`s<N>` ticks per row, 1/120 s each). A length is a ROW COUNT: `c4` is C held for 4 rows. A tempo token is refused.
|
|
20
|
+
|
|
21
|
+
**A held note is ONE note (ADR-0019).** PICO-8 plays consecutive same-pitch rows as one continuous tone, so picopilot shapes a held note across its rows: fade in (`e4`) on its first row, fade out (`e5`) as a volume ramp ending on its last row, drop (`e3`) as a one-row hit then silence; vibrato, slide and arpeggio continue. Separate one-row notes (`e5 c c c c`) still re-trigger, which is how you write repeated plucks. Shape held notes with:
|
|
22
|
+
|
|
23
|
+
- **`v[...]` volume envelope**, one digit per row, last value held, the part after `/` is the release on each note's last rows: `v[3 2 2 2 1/1 0] a12`. Modal; `v<N>` clears it.
|
|
24
|
+
- **`~N` delayed vibrato**: rows from N on get vibrato, so a long note starts plain and blooms: `~6`. `~0` turns it off.
|
|
25
|
+
|
|
26
|
+
The `e` trap: `e0`-`e7` is an EFFECT, so note E with a length is `l8 e`, never `e8` (refused) or `e4` (silently effect 4).
|
|
27
|
+
|
|
28
|
+
## Taste: what makes game music pleasant (measured)
|
|
29
|
+
|
|
30
|
+
These rules come from a soundtrack a listener called shrill and grating, a revision called "far too violent", and the accepted versions (the taste finding has the numbers).
|
|
31
|
+
|
|
32
|
+
- **Register.** Lead at `o3` or below (`o2 a` is 440 Hz), bass around `o1`, pads `o1`-`o2`. A lead and arpeggio at `o4` on square/pulse is what made the first track shrill.
|
|
33
|
+
- **Timbre.** Soft: triangle `@0` and a quiet dampened organ `@5`. Harsh at volume: square `@3`, pulse `@4`, saw `@2`. Put `!dampen` on music voices. Noise drums and drop-kicks (`e3`) read as aggressive at default volumes; leave drums out of ambient music, or keep them at `v1`-`v2`.
|
|
34
|
+
- **Density.** Rests are music. Write held notes with envelopes and gaps, not wall-to-wall 16th-note arpeggios at one volume.
|
|
35
|
+
- **Form.** Title and in-game are DIFFERENT songs. In-game loops run about a minute, with sections (A/B), and a melody that plays only in some bars (a silent channel in a pattern costs nothing: it becomes OFF).
|
|
36
|
+
- **Volume headroom.** Music mostly `v1`-`v2`, `v3` only on attacks; SFX `v4`-`v7` sit on top.
|
|
37
|
+
- **Leave a channel for SFX.** Write music on channels 0-2 and play it with a mask: `music(n, fade, 7)` (channels 0-2), so `sfx()` always has channel 3.
|
|
38
|
+
|
|
39
|
+
The analyzer's thresholds, for reading its output: mean RMS above 0.12 is loud (accepted music 0.07-0.10); centroid above 900 Hz or more than 8 % of energy above 2 kHz is shrill (accepted: under 500 Hz, about 0.2 %); dynamic range under 4 dB is monotonous (accepted 7-8 dB); a loop under 20 s is repetitive; with `--against`, music should sit at least 3 dB under the SFX.
|
|
40
|
+
|
|
41
|
+
## `music from-score`: whole songs (ADR-0020)
|
|
42
|
+
|
|
43
|
+
The score is JSON: songs, each up to 4 channels of picopilot-MML of ANY length.
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{"songs": [
|
|
47
|
+
{"name": "title", "speed": 16, "channels": [
|
|
48
|
+
{"filters": "!dampen", "mml": "@0 ~6 v[3 2 2 2 1/1 0] o2 a12 > d4 f8 l8 e | d16 c8 < a+8"},
|
|
49
|
+
{"filters": "!dampen", "mml": "@0 v[2 2 2 1 1 1/0] o1 d16 a16 | < a+16 > f16"},
|
|
50
|
+
null]},
|
|
51
|
+
{"name": "game", "speed": 20, "loopFrom": 0, "channels": ["...", "...", "..."]}
|
|
52
|
+
]}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
picopilot cuts every channel at the same 32-row boundaries (`patternRows` for other bar lengths), turns a channel silent for a whole pattern into OFF, shares identical SFX, writes `__sfx__` + `__music__`, and reports each song's `startPattern` (play it with `music(startPattern, 0, 7)`), its `seconds`, and which slot holds what. Per song: `speed` (shared by its channels), `loop` (default true; false stops at the end), `loopFrom`. `|` bars are cosmetic: use them to check every channel's bars add up to the same rows.
|
|
56
|
+
|
|
57
|
+
Slots: pass `--slots 16-63` (a range the music OWNS: overwritten and cleared on every run, so re-runs are clean). Without it, only empty slots are used, from 63 down, and nothing with notes is touched. `picopilot sfx clear <slots>` empties slots (`5`, `16-63`, `3,5,9`).
|
|
58
|
+
|
|
59
|
+
`music from-patterns '<json>'` still exists for arranging hand-made SFX: a list of `{"channels":[c0,c1,c2,c3], loopStart?, loopBack?, stop?}` where a channel is an SFX index or `null` (OFF, which is different from SFX 0). Music is structural either way: melody lives in SFX, `__music__` only references them.
|
|
60
|
+
|
|
61
|
+
## `sfx from-mml`: one sound
|
|
62
|
+
|
|
63
|
+
Modal (sticky) state: `@0`-`@7` waveform (0 triangle, 1 tilted saw, 2 saw, 3 square, 4 pulse, 5 organ, 6 noise, 7 phaser; `@8`-`@f` = SFX 0-7 as an instrument), `v0`-`v7` or `v[...]`, `e0`-`e7` effect (1 slide, 2 vibrato, 3 drop, 4 fade in, 5 fade out, 6/7 arpeggio fast/slow), `~N`, `o0`-`o5` octave (default `o2`), `l<N>` default length, `>`/`<` octave shift. Notes `c d e f g a b` with `+`/`#`/`-`; `r<N>` rest; `^<N>` extends the previous note; `s<N>` speed; `{`/`}` loop (a lone `{` sets the LEN). picopilot-MML, not ABC, because ABC cannot name a per-note waveform, volume or effect.
|
|
64
|
+
|
|
65
|
+
**Filters make DESIGNED sounds** (per SFX, anywhere in the string): `!dampen`/`!dampen2` low-pass body, `!reverb` echo tail, `!noiz` (only on `@6`), `!buzz`, `!detune1` (flange) / `!detune2` (octave). An explosion is `!dampen2 !reverb @6` over a falling pitch and volume: `s6 !dampen2 !reverb @6 o3 v7 g o1 c o0 g v6 e v5 c v4 g v3 c v2 g v1 c`.
|
|
66
|
+
|
|
67
|
+
## `audio render` / `record` / `analyze`
|
|
68
|
+
|
|
69
|
+
PICO-8 has no working offline export of audio, so `render` and `record` are REAL-TIME recordings of a PICO-8 session (ADR-0009), not an offline export. They work headless (picopilot starts a virtual display when there is none) and require PICO-8. `render` plays a target you pick (`--sfx`, `--pattern`, or the whole song); `record <cart>` captures the cart's own playback. `--seconds` sets the window and the safety backstop follows it. The WAV goes to an isolated temp dir (`--wav-dir`). When `captured` is false, read `noAudioReason` and `pico8LogTail`, then run `picopilot verify`.
|
|
70
|
+
|
|
71
|
+
`audio analyze <wav>` needs neither PICO-8 nor Python. It skips the first second (as the calibration did; `--skip 0` for an SFX, plus `--kind sfx`) and reports loudness (mean, loudest frame and when), brightness (centroid, share above 2 kHz), dynamic range, a per-half-second loudness `sparkline`, the loop length, and `warnings` with the fix for each.
|
|
72
|
+
|
|
73
|
+
## Refusals (no bytes change)
|
|
74
|
+
|
|
75
|
+
- `audio-mml-pitch-out-of-range`: a note outside C0..D#5. Pick an octave in range (in a score, the message names song and channel; watch accumulated `<`/`>`).
|
|
76
|
+
- `audio-mml-sfx-overflow`: over 32 rows in `sfx from-mml`. Use `music from-score`.
|
|
77
|
+
- `audio-mml-tie-state-change`: a `v`/`e`/`@`/`~`/`v[...]` between a note and its `^`. Use an envelope or a new note.
|
|
78
|
+
- `audio-mml-filter-level-out-of-range`: `!dampen3`, `!reverb2`, `!noiz2`, `!detune` without 1/2.
|
|
79
|
+
- `audio-score-*`: speed mismatch between channels, `{`/`}` in a score, over 64 patterns, not enough free slots (pass `--slots`).
|
|
80
|
+
- `audio-backstop-too-small`: an explicit `--backstop-ms` that cannot fit the window. Omit it.
|
|
@@ -19,7 +19,7 @@ Run `picopilot doctor` before anything else on a machine you have not used yet.
|
|
|
19
19
|
|
|
20
20
|
Every picopilot command works on a machine with no display and no sound card (a server, CI, an agent sandbox). Screenshots, `playtest` and `export` run PICO-8 headless. Audio recording (`audio render`, `audio record`, `run --record-audio`) needs a real PICO-8 session with a window, and picopilot provides one by itself: with the default display mode `auto`, when there is no display it starts a private virtual display for that recording and removes it afterwards. You do not need to set anything up. Only if `doctor` lists the audio commands as unavailable with `missing: display` (or a recording fails with `display-unavailable`) does the machine lack Xvfb; the error names the install command to suggest to the human.
|
|
21
21
|
|
|
22
|
-
The display mode can be forced with `--display xvfb` (always virtual) or `--display native` (use the current display as-is), also settable as `PICOPILOT_DISPLAY` or `"display"` in `picopilot.json
|
|
22
|
+
The display mode can be forced with `--display xvfb` (always virtual) or `--display native` (use the current display as-is), also settable as `PICOPILOT_DISPLAY` or `"display"` in `picopilot.json`. Through MCP, the same `display` is an argument of the `run`, `audio_record`, `audio_render` and `doctor` tools. Leave it at `auto` unless a human asks otherwise.
|
|
23
23
|
|
|
24
24
|
## The #include discipline (how every picopilot cart is laid out)
|
|
25
25
|
|