c64bridge 1.0.2 → 1.0.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/README.md +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/audio/record_and_analyze_audio.js +81 -13
- package/dist/c64Client.js +240 -40
- package/dist/tools/audio.js +4 -3
- package/dist/tools/registry/sound.js +1 -1
- package/dist/tools/streaming.js +11 -2
- package/mcp/schemas/c64_sound.schema.json +3 -3
- package/mcp/server.json +1 -1
- package/mcp/tools.json +3 -3
- package/mcp.json +1 -1
- package/package.json +4 -4
- package/scripts/mockC64Server.mjs +33 -6
- package/scripts/validate-release-artifact.mjs +62 -0
- package/scripts/validate-release.mjs +40 -0
- package/server.json +2 -2
package/mcp/tools.json
CHANGED
|
@@ -1969,7 +1969,7 @@
|
|
|
1969
1969
|
"required": [
|
|
1970
1970
|
"op"
|
|
1971
1971
|
],
|
|
1972
|
-
"description": "Sound operations available via the c64_sound tool.\n\nOperations:\n- capture_samples: Capture raw stereo PCM samples from the C64 Ultimate audio UDP stream. Required inputs: none. Optional inputs: count, encoding.\n- set_volume: Set the SID master volume register at $D418 (0-15). Required inputs: volume.\n- reset: Soft or hard reset of SID registers to clear glitches. Required inputs: none. Optional inputs: hard.\n- note_on: Trigger a SID voice with configurable waveform, ADSR, and pitch. Required inputs: none. Optional inputs: voice, note, frequencyHz, system, waveform, pulseWidth, attack, decay, sustain, release.\n- note_off: Release a SID voice by clearing its gate bit. Required inputs: voice.\n- silence_all: Silence all SID voices with optional audio verification. Required inputs: none. Optional inputs: verify.\n- play_sid_file: Play a SID file stored on the Ultimate filesystem. Required inputs: path. Optional inputs: songnr.\n- play_mod_file: Play a MOD tracker module via the Ultimate SID player. Required inputs: path.\n- generate: Generate a lightweight SID arpeggio playback sequence. Required inputs: none. Optional inputs: root, pattern, steps, tempoMs, waveform, preset.\n- compile_play: Compile SIDWAVE or CPG source and optionally play it immediately. Required inputs: none. Optional inputs: sidwave, cpg, format, output, dryRun.\n- play_preset: Compile and play a built-in SID preset such as Für Elise by Beethoven. Required inputs: none. Optional inputs: preset, platforms, verify, analysisDurationSeconds, waitBeforeCaptureMs, restoreActiveBackend.\n- pipeline: Compile a SIDWAVE score, play it, and analyze the recording. Required inputs: none. Optional inputs: sidwave, cpg, output, waitBeforeCaptureMs, analysisDurationSeconds, expectedSidwave, verifySilenceBefore, verifySilenceAfter, silenceDurationSeconds, silenceRmsThreshold, postSilenceWaitMs, silenceWaitMs.\n- analyze: Automatically analyze SID playback when verification is requested. Required inputs: request. Optional inputs: durationSeconds, expectedSidwave.\n- record_analyze: Record audio for a fixed duration and return SID analysis metrics. Required inputs: durationSeconds. Optional inputs: expectedSidwave.",
|
|
1972
|
+
"description": "Sound operations available via the c64_sound tool.\n\nOperations:\n- capture_samples: Capture raw stereo PCM samples from the C64 Ultimate audio UDP stream. Required inputs: none. Optional inputs: count, encoding.\n- set_volume: Set the SID master volume register at $D418 (0-15). Required inputs: volume.\n- reset: Soft or hard reset of SID registers to clear glitches. Required inputs: none. Optional inputs: hard.\n- note_on: Trigger a SID voice with configurable waveform, ADSR, and pitch. Required inputs: none. Optional inputs: voice, note, frequencyHz, system, waveform, pulseWidth, attack, decay, sustain, release.\n- note_off: Release a SID voice by clearing its gate bit. Required inputs: voice.\n- silence_all: Silence all SID voices with optional audio verification. Required inputs: none. Optional inputs: verify.\n- play_sid_file: Play a SID file stored on the Ultimate filesystem. Required inputs: path. Optional inputs: songnr.\n- play_mod_file: Play a MOD tracker module via the Ultimate SID player. Required inputs: path.\n- generate: Generate a lightweight SID arpeggio playback sequence. Required inputs: none. Optional inputs: root, pattern, steps, tempoMs, waveform, preset.\n- compile_play: Compile SIDWAVE or CPG source and optionally play it immediately. Required inputs: none. Optional inputs: sidwave, cpg, format, output, dryRun.\n- play_preset: Compile and play a built-in SID preset such as Für Elise by Beethoven. Required inputs: none. Optional inputs: preset, platforms, verify, analysisDurationSeconds, waitBeforeCaptureMs, restoreActiveBackend.\n- pipeline: Compile a SIDWAVE score, play it, and analyze the recording. Required inputs: none. Optional inputs: sidwave, cpg, output, waitBeforeCaptureMs, analysisDurationSeconds, expectedSidwave, verifySilenceBefore, verifySilenceAfter, silenceDurationSeconds, silenceRmsThreshold, postSilenceWaitMs, silenceWaitMs.\n- analyze: Automatically analyze SID playback when verification is requested. Required inputs: request. Optional inputs: durationSeconds, expectedSidwave.\n- record_analyze: Record audio for a fixed duration and return SID analysis metrics. Uses the Ultimate audio stream on c64u and the host microphone on other backends. Required inputs: durationSeconds. Optional inputs: expectedSidwave.",
|
|
1973
1973
|
"additionalProperties": false,
|
|
1974
1974
|
"x-c64bridge-operations": [
|
|
1975
1975
|
{
|
|
@@ -2725,7 +2725,7 @@
|
|
|
2725
2725
|
},
|
|
2726
2726
|
{
|
|
2727
2727
|
"op": "record_analyze",
|
|
2728
|
-
"description": "Record audio for a fixed duration and return SID analysis metrics.",
|
|
2728
|
+
"description": "Record audio for a fixed duration and return SID analysis metrics. Uses the Ultimate audio stream on c64u and the host microphone on other backends.",
|
|
2729
2729
|
"required": [
|
|
2730
2730
|
"durationSeconds"
|
|
2731
2731
|
],
|
|
@@ -2734,7 +2734,7 @@
|
|
|
2734
2734
|
],
|
|
2735
2735
|
"inputSchema": {
|
|
2736
2736
|
"type": "object",
|
|
2737
|
-
"description": "Record audio for a fixed duration and return SID analysis metrics.",
|
|
2737
|
+
"description": "Record audio for a fixed duration and return SID analysis metrics. Uses the Ultimate audio stream on c64u and the host microphone on other backends.",
|
|
2738
2738
|
"properties": {
|
|
2739
2739
|
"durationSeconds": {
|
|
2740
2740
|
"type": "number",
|
package/mcp.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c64bridge",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"mcpName": "io.github.chrisgleissner/c64bridge",
|
|
5
5
|
"description": "C64 Bridge MCP server for Commodore 64 Ultimate hardware and VICE emulator control",
|
|
6
6
|
"repository": {
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
"node": ">=24"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
72
|
-
"axios": "^1.
|
|
71
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
72
|
+
"axios": "^1.20.0",
|
|
73
73
|
"date-fns": "^4.4.0",
|
|
74
|
-
"fastify": "^5.
|
|
74
|
+
"fastify": "^5.12.5",
|
|
75
75
|
"jimp": "^1.6.1",
|
|
76
76
|
"meyda": "^5.6.3",
|
|
77
77
|
"octokit": "^5.0.5",
|
|
@@ -44,6 +44,9 @@ function createDefaultConfigs() {
|
|
|
44
44
|
Video: {
|
|
45
45
|
Mode: "PAL",
|
|
46
46
|
},
|
|
47
|
+
"U64 Specific Settings": {
|
|
48
|
+
"System Mode": "PAL",
|
|
49
|
+
},
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -127,6 +130,14 @@ function createInitialState() {
|
|
|
127
130
|
},
|
|
128
131
|
lastStreamAction: null,
|
|
129
132
|
streamActionLog: [],
|
|
133
|
+
// Number of upcoming stream:start requests answered like firmware with an empty ARP entry.
|
|
134
|
+
streamHostResolveFailures: 0,
|
|
135
|
+
// When set, the audio emitter stops after this many packets to imitate UDP packet loss.
|
|
136
|
+
audioPacketLimit: null,
|
|
137
|
+
audioSignal: null,
|
|
138
|
+
streamStartResponseDelayMs: 0,
|
|
139
|
+
// Unlike the one-shot failure counter, this gate stays closed until a test's ping stub primes it.
|
|
140
|
+
streamNeighborReady: true,
|
|
130
141
|
};
|
|
131
142
|
}
|
|
132
143
|
|
|
@@ -329,7 +340,10 @@ export async function startMockC64Server(options = {}) {
|
|
|
329
340
|
}
|
|
330
341
|
|
|
331
342
|
const sendAudioPacket = () => {
|
|
332
|
-
|
|
343
|
+
if (state.audioPacketLimit !== null && state.streams.audio.packetsSent >= state.audioPacketLimit) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const packet = buildMockAudioPacket(runtime.sequence, runtime.sampleIndex, state.audioSignal);
|
|
333
347
|
runtime.sequence = (runtime.sequence + 1) & 0xffff;
|
|
334
348
|
runtime.sampleIndex += 192;
|
|
335
349
|
socket.send(packet, port, host);
|
|
@@ -713,7 +727,7 @@ export async function startMockC64Server(options = {}) {
|
|
|
713
727
|
|
|
714
728
|
if (url.startsWith("/v1/configs/")) {
|
|
715
729
|
const routeUrl = new URL(url, "http://mock.local");
|
|
716
|
-
const segments = routeUrl.pathname.split("/").filter(Boolean).slice(2); // remove v1 + configs
|
|
730
|
+
const segments = routeUrl.pathname.split("/").filter(Boolean).slice(2).map((segment) => decodeURIComponent(segment)); // remove v1 + configs
|
|
717
731
|
|
|
718
732
|
if (segments.length === 1) {
|
|
719
733
|
const [category] = segments;
|
|
@@ -730,7 +744,8 @@ export async function startMockC64Server(options = {}) {
|
|
|
730
744
|
if (method === "GET") {
|
|
731
745
|
const categoryData = state.configs[category] ?? {};
|
|
732
746
|
const value = categoryData[item];
|
|
733
|
-
|
|
747
|
+
// Firmware nests the item as { "<category>": { "<item>": { current } } }; `value` is kept for older callers.
|
|
748
|
+
sendJson(res, { value, [category]: { [item]: { current: value } } });
|
|
734
749
|
return;
|
|
735
750
|
}
|
|
736
751
|
|
|
@@ -762,10 +777,20 @@ export async function startMockC64Server(options = {}) {
|
|
|
762
777
|
if (action === "start" && method === "PUT") {
|
|
763
778
|
const body = await readJson(req);
|
|
764
779
|
const target = routeUrl.searchParams.get("ip") ?? routeUrl.searchParams.get("target") ?? body?.ip ?? body?.target ?? null;
|
|
780
|
+
const neighborReady = typeof state.streamNeighborReady === "function" ? state.streamNeighborReady(target) : state.streamNeighborReady;
|
|
781
|
+
if (!neighborReady || state.streamHostResolveFailures > 0) {
|
|
782
|
+
if (state.streamHostResolveFailures > 0) state.streamHostResolveFailures -= 1;
|
|
783
|
+
state.streamActionLog.push({ action: "start-rejected", stream, target });
|
|
784
|
+
sendJson(res, { errors: ["Network Host Resolve Error"] }, 404);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
765
787
|
state.streams[stream] = { active: true, target, packetsSent: 0 };
|
|
766
788
|
state.lastStreamAction = { action: "start", stream, target };
|
|
767
789
|
state.streamActionLog.push({ action: "start", stream, target });
|
|
768
790
|
startStreamEmitter(stream, target);
|
|
791
|
+
if (state.streamStartResponseDelayMs > 0) {
|
|
792
|
+
await new Promise((resolve) => setTimeout(resolve, state.streamStartResponseDelayMs));
|
|
793
|
+
}
|
|
769
794
|
sendJson(res, { result: "started", stream, target });
|
|
770
795
|
return;
|
|
771
796
|
}
|
|
@@ -994,15 +1019,17 @@ function buildMockVideoFramePackets(frameNumber, sequenceStart) {
|
|
|
994
1019
|
return packets;
|
|
995
1020
|
}
|
|
996
1021
|
|
|
997
|
-
function buildMockAudioPacket(sequence, sampleIndex) {
|
|
1022
|
+
function buildMockAudioPacket(sequence, sampleIndex, signal = null) {
|
|
998
1023
|
const header = Buffer.alloc(2);
|
|
999
1024
|
header.writeUInt16LE(sequence & 0xffff, 0);
|
|
1000
1025
|
|
|
1001
1026
|
const payload = Buffer.alloc(768);
|
|
1002
1027
|
for (let index = 0; index < 192; index += 1) {
|
|
1003
1028
|
const phase = ((sampleIndex + index) % 256) - 128;
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1029
|
+
// Both channels carry the same sawtooth plus a different constant offset, like the real stream.
|
|
1030
|
+
const wave = signal ? Math.sin(2 * Math.PI * signal.frequency * (sampleIndex + index) / signal.sampleRate) : phase * 128;
|
|
1031
|
+
const left = signal ? Math.round(wave * signal.amplitude + signal.leftOffset) : wave + 8000;
|
|
1032
|
+
const right = signal ? Math.round(wave * signal.amplitude * signal.rightGain + signal.rightOffset) : wave + 5000;
|
|
1006
1033
|
payload.writeInt16LE(left, index * 4);
|
|
1007
1034
|
payload.writeInt16LE(right, index * 4 + 2);
|
|
1008
1035
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
8
|
+
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { validateReleaseMetadata } from './validate-release.mjs';
|
|
10
|
+
import { startMockC64Server } from './mockC64Server.mjs';
|
|
11
|
+
|
|
12
|
+
const packageDir = path.resolve(process.argv[2]);
|
|
13
|
+
const version = process.argv[3];
|
|
14
|
+
validateReleaseMetadata(packageDir, version, { packed: true });
|
|
15
|
+
const temp = mkdtempSync(path.join(os.tmpdir(), 'c64bridge-release-smoke-'));
|
|
16
|
+
const mock = await startMockC64Server();
|
|
17
|
+
const config = path.join(temp, 'config.json');
|
|
18
|
+
writeFileSync(config, JSON.stringify({ c64u: { baseUrl: mock.baseUrl } }));
|
|
19
|
+
mock.state.audioSignal = { frequency: 440, sampleRate: 47982.8869047619, amplitude: 4000, leftOffset: 26900, rightOffset: 20000, rightGain: -1 };
|
|
20
|
+
const client = new Client({ name: 'c64bridge-release-validation', version: '1.0.0' }, { capabilities: {} });
|
|
21
|
+
const transport = new StdioClientTransport({
|
|
22
|
+
command: process.execPath,
|
|
23
|
+
args: [path.join(packageDir, 'scripts/cli.js')],
|
|
24
|
+
// Launch from a foreign directory, as an installed npm consumer would.
|
|
25
|
+
cwd: temp,
|
|
26
|
+
env: { ...process.env, C64BRIDGE_CONFIG: config, C64_MODE: 'c64u', C64BRIDGE_DISABLE_DIAGNOSTICS: '1' },
|
|
27
|
+
stderr: 'pipe',
|
|
28
|
+
});
|
|
29
|
+
let stderr = '';
|
|
30
|
+
transport.stderr?.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-16384); });
|
|
31
|
+
try {
|
|
32
|
+
await client.connect(transport);
|
|
33
|
+
assert.equal(client.getServerVersion().version, version, 'MCP runtime version differs from artifact');
|
|
34
|
+
const tools = await client.listTools();
|
|
35
|
+
for (const name of ['c64_program', 'c64_memory', 'c64_sound', 'c64_graphics']) {
|
|
36
|
+
assert.ok(tools.tools.some((tool) => tool.name === name), `Missing packaged tool ${name}`);
|
|
37
|
+
}
|
|
38
|
+
const call = async (name, args) => {
|
|
39
|
+
const result = await client.request({ method: 'tools/call', params: { name, arguments: args } }, CallToolResultSchema);
|
|
40
|
+
assert.notEqual(result.isError, true, JSON.stringify(result));
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
mock.state.streamHostResolveFailures = 1;
|
|
44
|
+
const samples = await call('c64_sound', { op: 'capture_samples', count: 256 });
|
|
45
|
+
assert.equal(samples.metadata.samplePairs, 256);
|
|
46
|
+
await call('c64_graphics', { op: 'capture_frame' });
|
|
47
|
+
const recorded = await call('c64_sound', { op: 'record_analyze', durationSeconds: 0.5 });
|
|
48
|
+
const analysis = JSON.parse(recorded.content.find((entry) => entry.type === 'text').text).analysis;
|
|
49
|
+
assert.equal(analysis.source, 'ultimate-stream');
|
|
50
|
+
assert.ok(analysis.global_metrics.average_rms > 0.08, 'Stereo audio cancelled to silence');
|
|
51
|
+
assert.equal(analysis.voices[0].detected_notes.find((note) => note.note)?.note, 'A4');
|
|
52
|
+
assert.equal(mock.state.streams.audio.active, false);
|
|
53
|
+
assert.equal(mock.state.streams.video.active, false);
|
|
54
|
+
console.log(`Packed c64bridge@${version}: CLI, MCP version/discovery, video, ARP retry, and stereo audio verified.`);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(stderr);
|
|
57
|
+
throw error;
|
|
58
|
+
} finally {
|
|
59
|
+
await client.close();
|
|
60
|
+
await mock.close();
|
|
61
|
+
rmSync(temp, { recursive: true, force: true });
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export function validateReleaseMetadata(root, version, { packed = false } = {}) {
|
|
8
|
+
assert.match(version, /^\d+\.\d+\.\d+$/, 'Expected a stable release version');
|
|
9
|
+
const read = (file) => JSON.parse(readFileSync(path.join(root, file), 'utf8'));
|
|
10
|
+
const pkg = read('package.json');
|
|
11
|
+
assert.equal(pkg.name, 'c64bridge', 'Wrong npm package');
|
|
12
|
+
assert.equal(pkg.version, version, 'package.json version does not match release tag');
|
|
13
|
+
assert.equal(pkg.private, undefined, 'Release package must be public');
|
|
14
|
+
assert.equal(pkg.main, 'dist/index.js', 'Wrong compiled entry point');
|
|
15
|
+
assert.equal(pkg.bin.c64bridge, 'scripts/cli.js', 'Wrong CLI entry point');
|
|
16
|
+
assert.equal(read('mcp.json').version, version, 'mcp.json version mismatch');
|
|
17
|
+
assert.equal(read('mcp/server.json').version, version, 'Generated MCP metadata version mismatch');
|
|
18
|
+
const server = read('server.json');
|
|
19
|
+
assert.equal(server.name, pkg.mcpName, 'MCP Registry server name mismatch');
|
|
20
|
+
assert.equal(server.version, version, 'MCP Registry version mismatch');
|
|
21
|
+
assert.equal(server.packages[0].identifier, pkg.name, 'MCP Registry npm name mismatch');
|
|
22
|
+
assert.equal(server.packages[0].version, version, 'MCP Registry npm version mismatch');
|
|
23
|
+
if (!packed) {
|
|
24
|
+
const lock = read('package-lock.json');
|
|
25
|
+
assert.equal(lock.name, pkg.name, 'Lockfile package name mismatch');
|
|
26
|
+
assert.equal(lock.version, version, 'Lockfile version mismatch');
|
|
27
|
+
assert.equal(lock.packages[''].version, version, 'Lockfile root version mismatch');
|
|
28
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
29
|
+
assert.deepEqual(lock.packages[''][section], pkg[section], `Lockfile ${section} differs from package.json`);
|
|
30
|
+
}
|
|
31
|
+
const changelog = readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8');
|
|
32
|
+
assert.match(changelog, new RegExp(`^## ${version.replaceAll('.', '\\.')} - \\d{4}-\\d{2}-\\d{2}$`, 'm'), 'Missing release changelog entry');
|
|
33
|
+
}
|
|
34
|
+
return pkg;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
38
|
+
validateReleaseMetadata(process.cwd(), process.argv[2]);
|
|
39
|
+
console.log(`Release metadata agrees on c64bridge@${process.argv[2]}.`);
|
|
40
|
+
}
|
package/server.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
10
|
"websiteUrl": "https://github.com/chrisgleissner/c64bridge",
|
|
11
|
-
"version": "1.0.
|
|
11
|
+
"version": "1.0.3",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "c64bridge",
|
|
16
|
-
"version": "1.0.
|
|
16
|
+
"version": "1.0.3",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
},
|