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
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { parseSidwave, midiToHz } from "../sidwave.js";
|
|
2
|
-
export
|
|
3
|
-
const duration =
|
|
2
|
+
export function normalizeAudioDuration(durationSeconds) {
|
|
3
|
+
const duration = Number(durationSeconds);
|
|
4
4
|
if (!Number.isFinite(duration)) {
|
|
5
5
|
throw new Error("durationSeconds must be a number");
|
|
6
6
|
}
|
|
7
|
+
return Math.max(0.5, Math.min(120, duration));
|
|
8
|
+
}
|
|
9
|
+
export async function recordAndAnalyzeAudio(params) {
|
|
10
|
+
const duration = normalizeAudioDuration(params.durationSeconds);
|
|
7
11
|
// Capture PCM from default microphone using naudiodon (PortAudio)
|
|
8
12
|
const sampleRate = 44100;
|
|
9
13
|
const channelCount = 1;
|
|
@@ -52,9 +56,49 @@ export async function recordAndAnalyzeAudio(params) {
|
|
|
52
56
|
});
|
|
53
57
|
const pcm16 = Buffer.concat(chunks);
|
|
54
58
|
const float32 = convertInt16ToFloat32(pcm16);
|
|
55
|
-
const analysis = await analyzePcm(float32, sampleRate, params.expectedSidwave);
|
|
59
|
+
const analysis = await analyzePcm(float32, sampleRate, "microphone", params.expectedSidwave);
|
|
56
60
|
return analysis;
|
|
57
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Analyzes interleaved 16-bit stereo PCM captured from the Ultimate audio stream.
|
|
64
|
+
*
|
|
65
|
+
* The stream carries a large constant offset per channel even when silent
|
|
66
|
+
* (for example left 26900, right 20000). The mean of each channel is removed
|
|
67
|
+
* before analysis; otherwise the RMS measures the offset instead of the audio level.
|
|
68
|
+
* Analyze channel energy separately, since a mono mix can cancel opposite-phase
|
|
69
|
+
* audio and falsely verify silence. Pitch and level follow the louder channel in each window.
|
|
70
|
+
*/
|
|
71
|
+
export async function analyzeStereoStream(interleaved, sampleRate, expectedSidwave) {
|
|
72
|
+
const [left, right] = stereoToCenteredChannels(interleaved);
|
|
73
|
+
return analyzePcm(left, sampleRate, "ultimate-stream", expectedSidwave, right);
|
|
74
|
+
}
|
|
75
|
+
function stereoToCenteredChannels(interleaved) {
|
|
76
|
+
const frames = Math.floor(interleaved.length / 2);
|
|
77
|
+
const left = new Float32Array(frames);
|
|
78
|
+
const right = new Float32Array(frames);
|
|
79
|
+
if (frames === 0) {
|
|
80
|
+
return [left, right];
|
|
81
|
+
}
|
|
82
|
+
let leftSum = 0;
|
|
83
|
+
let rightSum = 0;
|
|
84
|
+
for (let i = 0; i < frames; i += 1) {
|
|
85
|
+
leftSum += interleaved[i * 2];
|
|
86
|
+
rightSum += interleaved[i * 2 + 1];
|
|
87
|
+
}
|
|
88
|
+
const leftMean = leftSum / frames;
|
|
89
|
+
const rightMean = rightSum / frames;
|
|
90
|
+
for (let i = 0; i < frames; i += 1) {
|
|
91
|
+
left[i] = (interleaved[i * 2] - leftMean) / 32768;
|
|
92
|
+
right[i] = (interleaved[i * 2 + 1] - rightMean) / 32768;
|
|
93
|
+
}
|
|
94
|
+
return [left, right];
|
|
95
|
+
}
|
|
96
|
+
export function stereoToCenteredMono(interleaved) {
|
|
97
|
+
const [left, right] = stereoToCenteredChannels(interleaved);
|
|
98
|
+
for (let i = 0; i < left.length; i += 1)
|
|
99
|
+
left[i] = (left[i] + right[i]) / 2;
|
|
100
|
+
return left;
|
|
101
|
+
}
|
|
58
102
|
function convertInt16ToFloat32(buf) {
|
|
59
103
|
const len = Math.floor(buf.length / 2);
|
|
60
104
|
const out = new Float32Array(len);
|
|
@@ -64,7 +108,7 @@ function convertInt16ToFloat32(buf) {
|
|
|
64
108
|
}
|
|
65
109
|
return out;
|
|
66
110
|
}
|
|
67
|
-
async function analyzePcm(signal, sampleRate, expectedSidwave) {
|
|
111
|
+
async function analyzePcm(signal, sampleRate, source, expectedSidwave, secondChannel) {
|
|
68
112
|
let Pitchfinder;
|
|
69
113
|
let Meyda = null;
|
|
70
114
|
try {
|
|
@@ -87,13 +131,28 @@ async function analyzePcm(signal, sampleRate, expectedSidwave) {
|
|
|
87
131
|
const yin = Pitchfinder.YIN({ sampleRate });
|
|
88
132
|
const frames = [];
|
|
89
133
|
for (let i = 0; i + windowSize <= signal.length; i += hopSize) {
|
|
90
|
-
|
|
91
|
-
|
|
134
|
+
let win = signal.subarray(i, i + windowSize);
|
|
135
|
+
let rms = computeRms(win, Meyda, sampleRate, windowSize);
|
|
136
|
+
if (secondChannel) {
|
|
137
|
+
const otherWin = secondChannel.subarray(i, i + windowSize);
|
|
138
|
+
const otherRms = computeRms(otherWin, Meyda, sampleRate, windowSize);
|
|
139
|
+
if (otherRms > rms) {
|
|
140
|
+
rms = otherRms;
|
|
141
|
+
win = otherWin;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
92
144
|
const minRms = 0.01; // simple noise gate
|
|
93
145
|
let freq = null;
|
|
94
146
|
if (rms >= minRms) {
|
|
95
147
|
try {
|
|
96
|
-
|
|
148
|
+
// pitchfinder's YIN seeds its difference sum with 1, making low-level
|
|
149
|
+
// normalized PCM produce spurious ultrasonic pitches. Normalize only
|
|
150
|
+
// the detector input; RMS and the noise gate retain the original level.
|
|
151
|
+
let peak = 0;
|
|
152
|
+
for (const value of win)
|
|
153
|
+
peak = Math.max(peak, Math.abs(value));
|
|
154
|
+
const pitchInput = peak > 0 ? Float32Array.from(win, (value) => value / peak) : win;
|
|
155
|
+
const f = yin(pitchInput);
|
|
97
156
|
if (Number.isFinite(f) && f > 20 && f < 8000)
|
|
98
157
|
freq = f;
|
|
99
158
|
}
|
|
@@ -175,7 +234,7 @@ async function analyzePcm(signal, sampleRate, expectedSidwave) {
|
|
|
175
234
|
return {
|
|
176
235
|
sidwave: 1.0,
|
|
177
236
|
analysis: {
|
|
178
|
-
source
|
|
237
|
+
source,
|
|
179
238
|
durationSeconds: signal.length / sampleRate,
|
|
180
239
|
voices,
|
|
181
240
|
global_metrics: {
|
|
@@ -189,7 +248,7 @@ async function analyzePcm(signal, sampleRate, expectedSidwave) {
|
|
|
189
248
|
}
|
|
190
249
|
// Test-only helper: bypasses microphone capture and analyzes provided PCM buffer.
|
|
191
250
|
export async function analyzePcmForTest(signal, sampleRate, expectedSidwave) {
|
|
192
|
-
return analyzePcm(signal, sampleRate, expectedSidwave);
|
|
251
|
+
return analyzePcm(signal, sampleRate, "microphone", expectedSidwave);
|
|
193
252
|
}
|
|
194
253
|
function groupSegments(frames, _sampleRate) {
|
|
195
254
|
const segments = [];
|
|
@@ -286,10 +345,19 @@ function noteNameToMidiSafe(note) {
|
|
|
286
345
|
function computeRms(win, Meyda, sampleRate, bufferSize) {
|
|
287
346
|
try {
|
|
288
347
|
if (Meyda && typeof Meyda.extract === "function") {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
348
|
+
// Rectangular windows give plain sample RMS. Restore Meyda's shared setting
|
|
349
|
+
// synchronously so other consumers retain their window configuration.
|
|
350
|
+
const previousWindow = Meyda.windowingFunction;
|
|
351
|
+
try {
|
|
352
|
+
Meyda.windowingFunction = "rect";
|
|
353
|
+
const features = Meyda.extract(["rms"], win, { sampleRate, bufferSize });
|
|
354
|
+
const val = Number(features?.rms ?? 0);
|
|
355
|
+
if (Number.isFinite(val))
|
|
356
|
+
return val;
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
Meyda.windowingFunction = previousWindow;
|
|
360
|
+
}
|
|
293
361
|
}
|
|
294
362
|
}
|
|
295
363
|
catch { }
|
package/dist/c64Client.js
CHANGED
|
@@ -6,7 +6,9 @@ Licensed under the GNU General Public License v2.0 or later.
|
|
|
6
6
|
See <https://www.gnu.org/licenses/> for details.
|
|
7
7
|
*/
|
|
8
8
|
import { Buffer } from "node:buffer";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
9
10
|
import { createSocket } from "node:dgram";
|
|
11
|
+
import { networkInterfaces } from "node:os";
|
|
10
12
|
import axios from "axios";
|
|
11
13
|
import { basicToPrg } from "./tools/translation/basicTokenizer.js";
|
|
12
14
|
import { assemblyToPrg } from "./tools/translation/assembler.js";
|
|
@@ -16,6 +18,7 @@ import { createAllFacades, createFacade, } from "./device.js";
|
|
|
16
18
|
import { Api } from "../generated/c64/index.js";
|
|
17
19
|
import { createLoggingHttpClient } from "./loggingHttpClient.js";
|
|
18
20
|
import { withDiagnosticSpan, writeDiagnosticEvent } from "./diagnostics.js";
|
|
21
|
+
import { analyzeStereoStream, normalizeAudioDuration, recordAndAnalyzeAudio, } from "./audio/record_and_analyze_audio.js";
|
|
19
22
|
import { C64U_NTSC_AUDIO_SAMPLE_RATE, C64U_PAL_AUDIO_SAMPLE_RATE, collectCompleteVideoFrames, parseAudioPacket, parseVideoPacket, } from "./streamCapture.js";
|
|
20
23
|
import { buildVicBitmapRegisters, resolveVicBitmapMemoryLayout, } from "./vicBitmap.js";
|
|
21
24
|
/** Raised when the KERNAL keyboard buffer did not drain before another chunk
|
|
@@ -29,6 +32,84 @@ export class KeyboardQueueDrainTimeoutError extends Error {
|
|
|
29
32
|
this.timeoutMs = timeoutMs;
|
|
30
33
|
}
|
|
31
34
|
}
|
|
35
|
+
const C64U_SYSTEM_MODE_CATEGORY = "U64 Specific Settings";
|
|
36
|
+
const C64U_SYSTEM_MODE_ITEM = "System Mode";
|
|
37
|
+
// System Mode values that run the machine clock at the NTSC frequency
|
|
38
|
+
// (VIDEO_FMT_NTSC_FREQ in the firmware colour timings); the other values use the PAL clock.
|
|
39
|
+
const NTSC_CLOCK_SYSTEM_MODES = new Set(["NTSC", "PAL-60", "PAL-60/L"]);
|
|
40
|
+
/**
|
|
41
|
+
* Reads the current System Mode from a config response. The firmware answers
|
|
42
|
+
* `{ "<category>": { "<item>": { "current": "PAL", ... } } }`; a bare `{ value }` is also accepted.
|
|
43
|
+
*/
|
|
44
|
+
export function readSystemMode(response) {
|
|
45
|
+
const record = (value) => value && typeof value === "object" ? value : null;
|
|
46
|
+
const category = record(record(response)?.[C64U_SYSTEM_MODE_CATEGORY]);
|
|
47
|
+
const node = category?.[C64U_SYSTEM_MODE_ITEM] ?? response;
|
|
48
|
+
const raw = typeof node === "string" ? node : record(node)?.current ?? record(node)?.value;
|
|
49
|
+
return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
|
|
50
|
+
}
|
|
51
|
+
export function audioSampleRateForSystemMode(mode) {
|
|
52
|
+
return mode !== null && NTSC_CLOCK_SYSTEM_MODES.has(mode.toUpperCase())
|
|
53
|
+
? C64U_NTSC_AUDIO_SAMPLE_RATE
|
|
54
|
+
: C64U_PAL_AUDIO_SAMPLE_RATE;
|
|
55
|
+
}
|
|
56
|
+
// A partial capture must still hold one analysis window (2048 stereo frames).
|
|
57
|
+
const MIN_PARTIAL_AUDIO_VALUES = 2048 * 2;
|
|
58
|
+
const STREAM_HOST_RESOLVE_ERROR = "Network Host Resolve Error";
|
|
59
|
+
const NEIGHBOR_PRIME_TIMEOUT_MS = 4000;
|
|
60
|
+
/**
|
|
61
|
+
* The Ultimate firmware resolves a unicast stream target through its ARP cache
|
|
62
|
+
* and answers 404 "Network Host Resolve Error" immediately when the client is
|
|
63
|
+
* not cached. Accepts both a thrown HTTP error and an unsuccessful result.
|
|
64
|
+
*/
|
|
65
|
+
function isStreamHostResolveFailure(failure) {
|
|
66
|
+
const body = axios.isAxiosError(failure) ? failure.response?.data : failure;
|
|
67
|
+
if (body === undefined || body === null) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return JSON.stringify(body).includes(STREAM_HOST_RESOLVE_ERROR);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Hostnames come from configuration; accept only plain host or address characters so they cannot act as ping options. */
|
|
78
|
+
export function pingableHost(hostname) {
|
|
79
|
+
const host = hostname.replace(/^\[(.*)\]$/, "$1");
|
|
80
|
+
return /^[A-Za-z0-9][A-Za-z0-9._:%-]*$/.test(host) ? host : null;
|
|
81
|
+
}
|
|
82
|
+
/** True when `host` is an address of one of this machine's network interfaces. */
|
|
83
|
+
export function isLocalAddress(host) {
|
|
84
|
+
const wanted = host.replace(/^\[(.*)\]$/, "$1").toLowerCase();
|
|
85
|
+
return Object.values(networkInterfaces()).some((entries) => (entries ?? []).some((entry) => entry.address.toLowerCase() === wanted));
|
|
86
|
+
}
|
|
87
|
+
/** Host part of a `host:port` stream target, or null when it cannot be split. */
|
|
88
|
+
export function streamTargetHost(target) {
|
|
89
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(target);
|
|
90
|
+
if (bracketed) {
|
|
91
|
+
return bracketed[1];
|
|
92
|
+
}
|
|
93
|
+
const colon = target.indexOf(":");
|
|
94
|
+
if (colon === -1) {
|
|
95
|
+
return target.length > 0 ? target : null;
|
|
96
|
+
}
|
|
97
|
+
// Exactly one colon separates host and port; several colons are a bare IPv6 address.
|
|
98
|
+
if (colon !== target.lastIndexOf(":")) {
|
|
99
|
+
return target;
|
|
100
|
+
}
|
|
101
|
+
return colon > 0 ? target.slice(0, colon) : null;
|
|
102
|
+
}
|
|
103
|
+
function neighborPrimeArgs(host) {
|
|
104
|
+
switch (process.platform) {
|
|
105
|
+
case "win32":
|
|
106
|
+
return ["-n", "1", "-w", "2000", host];
|
|
107
|
+
case "darwin":
|
|
108
|
+
return ["-c", "1", "-t", "2", host];
|
|
109
|
+
default:
|
|
110
|
+
return ["-c", "1", "-W", "2", host];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
32
113
|
function menuMatrixIncludes(matrix, expected) {
|
|
33
114
|
// Firmware supplies the character matrix followed by its colour matrix. The
|
|
34
115
|
// documented wire format is raw bytes, so only inspect the first 40×25
|
|
@@ -1026,7 +1107,7 @@ export class C64Client {
|
|
|
1026
1107
|
async streamStart(stream, ip) {
|
|
1027
1108
|
try {
|
|
1028
1109
|
const facade = await this.facadePromise;
|
|
1029
|
-
return await
|
|
1110
|
+
return await this.startStreamResolvingHost(facade, stream, ip);
|
|
1030
1111
|
}
|
|
1031
1112
|
catch (error) {
|
|
1032
1113
|
return { success: false, details: this.normaliseError(error) };
|
|
@@ -1074,6 +1155,25 @@ export class C64Client {
|
|
|
1074
1155
|
}
|
|
1075
1156
|
return this.captureC64uAudioSamples(facade, requestedPairs);
|
|
1076
1157
|
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Records and analyzes audio. On the C64 Ultimate the PCM comes from the
|
|
1160
|
+
* device audio stream; other backends have no stream, so the host microphone is used.
|
|
1161
|
+
*/
|
|
1162
|
+
async recordAndAnalyzeAudio(params) {
|
|
1163
|
+
const durationSeconds = normalizeAudioDuration(params.durationSeconds);
|
|
1164
|
+
const facade = await this.facadePromise;
|
|
1165
|
+
if (facade.type !== "c64u") {
|
|
1166
|
+
return recordAndAnalyzeAudio(params);
|
|
1167
|
+
}
|
|
1168
|
+
const sampleRateHz = await this.getC64uAudioSampleRate(facade);
|
|
1169
|
+
const samplePairs = Math.ceil(durationSeconds * sampleRateHz);
|
|
1170
|
+
const capture = await this.captureC64uAudioSamples(facade, samplePairs, {
|
|
1171
|
+
timeoutMs: Math.ceil(durationSeconds * 1500) + 2000,
|
|
1172
|
+
sampleRateHz,
|
|
1173
|
+
acceptPartial: true,
|
|
1174
|
+
});
|
|
1175
|
+
return analyzeStereoStream(capture.samples, capture.sampleRateHz, params.expectedSidwave);
|
|
1176
|
+
}
|
|
1077
1177
|
async configsList() {
|
|
1078
1178
|
const facade = await this.facadePromise;
|
|
1079
1179
|
return facade.configsList();
|
|
@@ -1223,7 +1323,7 @@ export class C64Client {
|
|
|
1223
1323
|
try {
|
|
1224
1324
|
await this.bindCaptureSocket(socket, bindAddress);
|
|
1225
1325
|
const target = this.socketEndpoint(socket, bindAddress);
|
|
1226
|
-
await this.ensureStreamSuccess(await
|
|
1326
|
+
await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "video", target), "start video stream");
|
|
1227
1327
|
const frames = await new Promise((resolve, reject) => {
|
|
1228
1328
|
const timeoutMs = Math.max(1500, count * 750);
|
|
1229
1329
|
const timer = setTimeout(() => {
|
|
@@ -1313,7 +1413,7 @@ export class C64Client {
|
|
|
1313
1413
|
this.rejectC64uVideoCaptureWaiters(session, err);
|
|
1314
1414
|
});
|
|
1315
1415
|
try {
|
|
1316
|
-
await this.ensureStreamSuccess(await
|
|
1416
|
+
await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "video", target), "start video stream");
|
|
1317
1417
|
}
|
|
1318
1418
|
catch (error) {
|
|
1319
1419
|
session.closed = true;
|
|
@@ -1447,44 +1547,76 @@ export class C64Client {
|
|
|
1447
1547
|
}
|
|
1448
1548
|
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "VICE display capture failed"));
|
|
1449
1549
|
}
|
|
1450
|
-
async captureC64uAudioSamples(facade, samplePairs) {
|
|
1550
|
+
async captureC64uAudioSamples(facade, samplePairs, options) {
|
|
1451
1551
|
const host = new URL(this.baseUrl).hostname;
|
|
1452
1552
|
const bindAddress = await this.resolveLocalCaptureAddress(host);
|
|
1453
1553
|
const socket = createSocket("udp4");
|
|
1454
1554
|
const chunks = [];
|
|
1455
1555
|
let collectedValues = 0;
|
|
1456
|
-
|
|
1556
|
+
const neededValues = samplePairs * 2;
|
|
1557
|
+
let captureError = null;
|
|
1558
|
+
let captureSettled = false;
|
|
1559
|
+
let notifyCapture = () => { };
|
|
1560
|
+
let socketBound = false;
|
|
1561
|
+
let streamAttempted = false;
|
|
1562
|
+
let failed = false;
|
|
1563
|
+
// The firmware can emit UDP before its REST response reaches us. Listen
|
|
1564
|
+
// before starting, and cap collection even while start/config/stop requests are pending.
|
|
1565
|
+
const onMessage = (msg) => {
|
|
1566
|
+
if (captureSettled || captureError || collectedValues >= neededValues)
|
|
1567
|
+
return;
|
|
1568
|
+
try {
|
|
1569
|
+
const packet = parseAudioPacket(msg);
|
|
1570
|
+
if (packet.samples.length % 2 !== 0)
|
|
1571
|
+
throw new Error("Audio packet contains an incomplete stereo sample pair");
|
|
1572
|
+
const samples = packet.samples.subarray(0, neededValues - collectedValues);
|
|
1573
|
+
chunks.push(samples);
|
|
1574
|
+
collectedValues += samples.length;
|
|
1575
|
+
}
|
|
1576
|
+
catch (error) {
|
|
1577
|
+
captureError = error instanceof Error ? error : new Error(String(error));
|
|
1578
|
+
}
|
|
1579
|
+
notifyCapture();
|
|
1580
|
+
};
|
|
1581
|
+
const onError = (error) => {
|
|
1582
|
+
captureError = error;
|
|
1583
|
+
notifyCapture();
|
|
1584
|
+
};
|
|
1585
|
+
socket.on("message", onMessage);
|
|
1586
|
+
socket.on("error", onError);
|
|
1457
1587
|
try {
|
|
1458
1588
|
await this.bindCaptureSocket(socket, bindAddress);
|
|
1589
|
+
socketBound = true;
|
|
1459
1590
|
const target = this.socketEndpoint(socket, bindAddress);
|
|
1460
|
-
|
|
1461
|
-
|
|
1591
|
+
streamAttempted = true;
|
|
1592
|
+
await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "audio", target), "start audio stream");
|
|
1462
1593
|
await new Promise((resolve, reject) => {
|
|
1463
|
-
const timeoutMs = Math.max(1000, Math.ceil(samplePairs / 192) * 500);
|
|
1594
|
+
const timeoutMs = options?.timeoutMs ?? Math.max(1000, Math.ceil(samplePairs / 192) * 500);
|
|
1464
1595
|
const timer = setTimeout(() => {
|
|
1596
|
+
captureSettled = true;
|
|
1597
|
+
// UDP drops packets, so a count-based capture can fall short. Analysis accepts what arrived.
|
|
1598
|
+
if (options?.acceptPartial && collectedValues >= MIN_PARTIAL_AUDIO_VALUES) {
|
|
1599
|
+
resolve();
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1465
1602
|
reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${samplePairs} audio sample pair(s)`));
|
|
1466
1603
|
}, timeoutMs);
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
if (collectedValues >= neededValues) {
|
|
1473
|
-
clearTimeout(timer);
|
|
1474
|
-
resolve();
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
catch (error) {
|
|
1604
|
+
notifyCapture = () => {
|
|
1605
|
+
if (captureSettled)
|
|
1606
|
+
return;
|
|
1607
|
+
if (captureError || collectedValues >= neededValues) {
|
|
1608
|
+
captureSettled = true;
|
|
1478
1609
|
clearTimeout(timer);
|
|
1479
|
-
|
|
1610
|
+
if (captureError)
|
|
1611
|
+
reject(captureError);
|
|
1612
|
+
else
|
|
1613
|
+
resolve();
|
|
1480
1614
|
}
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
clearTimeout(timer);
|
|
1484
|
-
reject(error);
|
|
1485
|
-
});
|
|
1615
|
+
};
|
|
1616
|
+
notifyCapture();
|
|
1486
1617
|
});
|
|
1487
|
-
const
|
|
1618
|
+
const capturedPairs = Math.min(samplePairs, Math.floor(collectedValues / 2));
|
|
1619
|
+
const samples = new Int16Array(capturedPairs * 2);
|
|
1488
1620
|
let offset = 0;
|
|
1489
1621
|
for (const chunk of chunks) {
|
|
1490
1622
|
const remaining = samples.length - offset;
|
|
@@ -1497,32 +1629,37 @@ export class C64Client {
|
|
|
1497
1629
|
return {
|
|
1498
1630
|
backend: "c64u",
|
|
1499
1631
|
channels: 2,
|
|
1500
|
-
sampleRateHz: await this.getC64uAudioSampleRate(facade),
|
|
1501
|
-
samplePairs,
|
|
1632
|
+
sampleRateHz: options?.sampleRateHz ?? await this.getC64uAudioSampleRate(facade),
|
|
1633
|
+
samplePairs: capturedPairs,
|
|
1502
1634
|
samples,
|
|
1503
1635
|
};
|
|
1504
1636
|
}
|
|
1637
|
+
catch (error) {
|
|
1638
|
+
failed = true;
|
|
1639
|
+
throw error;
|
|
1640
|
+
}
|
|
1505
1641
|
finally {
|
|
1642
|
+
captureSettled = true;
|
|
1643
|
+
notifyCapture = () => { };
|
|
1644
|
+
socket.off("message", onMessage);
|
|
1645
|
+
if (socketBound)
|
|
1646
|
+
socket.close();
|
|
1506
1647
|
try {
|
|
1507
|
-
|
|
1648
|
+
if (streamAttempted)
|
|
1649
|
+
await this.ensureStreamSuccess(await facade.streamStop("audio"), "stop audio stream");
|
|
1508
1650
|
}
|
|
1509
1651
|
catch (error) {
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
throw stopError;
|
|
1652
|
+
// Preserve the capture/start error, while still reporting cleanup diagnostics.
|
|
1653
|
+
if (!failed)
|
|
1654
|
+
throw error;
|
|
1655
|
+
writeDiagnosticEvent("audio_capture_stop_failed", { error: this.normaliseError(error) });
|
|
1515
1656
|
}
|
|
1516
1657
|
}
|
|
1517
1658
|
}
|
|
1518
1659
|
async getC64uAudioSampleRate(facade) {
|
|
1519
1660
|
try {
|
|
1520
|
-
const response = await facade.configGet(
|
|
1521
|
-
|
|
1522
|
-
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
|
1523
|
-
if (mode.includes("NTSC")) {
|
|
1524
|
-
return C64U_NTSC_AUDIO_SAMPLE_RATE;
|
|
1525
|
-
}
|
|
1661
|
+
const response = await facade.configGet(C64U_SYSTEM_MODE_CATEGORY, C64U_SYSTEM_MODE_ITEM);
|
|
1662
|
+
return audioSampleRateForSystemMode(readSystemMode(response));
|
|
1526
1663
|
}
|
|
1527
1664
|
catch {
|
|
1528
1665
|
// Fall back to PAL below.
|
|
@@ -1568,6 +1705,69 @@ export class C64Client {
|
|
|
1568
1705
|
}
|
|
1569
1706
|
return `${bindAddress}:${address.port}`;
|
|
1570
1707
|
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Starts a stream. When the device answers "Network Host Resolve Error", sends
|
|
1710
|
+
* one ICMP echo to the device so it has to address a reply to this host, then
|
|
1711
|
+
* retries the start once.
|
|
1712
|
+
*/
|
|
1713
|
+
async startStreamResolvingHost(facade, stream, target) {
|
|
1714
|
+
let firstResult;
|
|
1715
|
+
let firstError;
|
|
1716
|
+
try {
|
|
1717
|
+
const result = await facade.streamStart(stream, target);
|
|
1718
|
+
if (result.success || !isStreamHostResolveFailure(result.details)) {
|
|
1719
|
+
return result;
|
|
1720
|
+
}
|
|
1721
|
+
firstResult = result;
|
|
1722
|
+
}
|
|
1723
|
+
catch (error) {
|
|
1724
|
+
if (!isStreamHostResolveFailure(error)) {
|
|
1725
|
+
throw error;
|
|
1726
|
+
}
|
|
1727
|
+
firstError = error;
|
|
1728
|
+
}
|
|
1729
|
+
// A ping from this host only helps when the stream target is this host: the device then has to
|
|
1730
|
+
// address the echo reply to it and learns its address. Any other target stays unresolvable.
|
|
1731
|
+
const targetHost = streamTargetHost(target);
|
|
1732
|
+
if (targetHost === null || !isLocalAddress(targetHost)) {
|
|
1733
|
+
if (firstResult) {
|
|
1734
|
+
return firstResult;
|
|
1735
|
+
}
|
|
1736
|
+
throw firstError;
|
|
1737
|
+
}
|
|
1738
|
+
const host = new URL(this.baseUrl).hostname;
|
|
1739
|
+
writeDiagnosticEvent("stream_start_host_resolve_retry", { stream, target, host });
|
|
1740
|
+
await this.primeDeviceNeighborCache(host);
|
|
1741
|
+
try {
|
|
1742
|
+
const retry = await facade.streamStart(stream, target);
|
|
1743
|
+
if (retry.success || !isStreamHostResolveFailure(retry.details)) {
|
|
1744
|
+
return retry;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
catch (error) {
|
|
1748
|
+
if (!isStreamHostResolveFailure(error)) {
|
|
1749
|
+
throw error;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
throw new Error(`The Ultimate at ${host} could not resolve stream target ${target} (${STREAM_HOST_RESOLVE_ERROR}) even after pinging it from this host. ` +
|
|
1753
|
+
"Check that this host is on the same network as the device, or use a multicast target.");
|
|
1754
|
+
}
|
|
1755
|
+
/** Best effort: ping failure (missing binary, blocked ICMP) is ignored and the caller retries anyway. */
|
|
1756
|
+
async primeDeviceNeighborCache(host) {
|
|
1757
|
+
const pingHost = pingableHost(host);
|
|
1758
|
+
if (!pingHost) {
|
|
1759
|
+
writeDiagnosticEvent("stream_start_ping_failed", { host, message: "host is not a plain hostname or address" });
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
await new Promise((resolve) => {
|
|
1763
|
+
execFile("ping", neighborPrimeArgs(pingHost), { timeout: NEIGHBOR_PRIME_TIMEOUT_MS, windowsHide: true }, (error) => {
|
|
1764
|
+
if (error) {
|
|
1765
|
+
writeDiagnosticEvent("stream_start_ping_failed", { host, message: error.message });
|
|
1766
|
+
}
|
|
1767
|
+
resolve();
|
|
1768
|
+
});
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1571
1771
|
async ensureStreamSuccess(result, action) {
|
|
1572
1772
|
if (result.success) {
|
|
1573
1773
|
return;
|
package/dist/tools/audio.js
CHANGED
|
@@ -256,7 +256,7 @@ const musicCompileArgsSchema = objectSchema({
|
|
|
256
256
|
additionalProperties: false,
|
|
257
257
|
});
|
|
258
258
|
const recordAndAnalyzeArgsSchema = objectSchema({
|
|
259
|
-
description: "Record audio from the
|
|
259
|
+
description: "Record audio and analyze SID playback characteristics. On the c64u backend the audio comes from the Ultimate audio stream; on other backends it is recorded from the host default input device (microphone).",
|
|
260
260
|
properties: {
|
|
261
261
|
durationSeconds: numberSchema({
|
|
262
262
|
description: "Capture duration in seconds (0.5 - 30).",
|
|
@@ -963,7 +963,7 @@ export const audioModule = defineToolModule({
|
|
|
963
963
|
},
|
|
964
964
|
{
|
|
965
965
|
name: "record_and_analyze_audio",
|
|
966
|
-
description: "Record audio from the
|
|
966
|
+
description: "Record audio and analyze SID playback characteristics. On the c64u backend the audio comes from the Ultimate audio stream; on other backends it is recorded from the host default input device (microphone).",
|
|
967
967
|
summary: "Captures PCM data, extracts notes, tempo, and deviation metrics for SID verification workflows.",
|
|
968
968
|
inputSchema: recordAndAnalyzeArgsSchema.jsonSchema,
|
|
969
969
|
relatedResources: ["c64://sound/sid/spec", "c64://sound/sid/file-format"],
|
|
@@ -981,7 +981,8 @@ export const audioModule = defineToolModule({
|
|
|
981
981
|
try {
|
|
982
982
|
const parsed = recordAndAnalyzeArgsSchema.parse(args ?? {});
|
|
983
983
|
ctx.logger.info("Recording audio for analysis", { durationSeconds: parsed.durationSeconds });
|
|
984
|
-
const
|
|
984
|
+
const analyzer = resolveAnalyzer(ctx);
|
|
985
|
+
const result = await analyzer({
|
|
985
986
|
durationSeconds: parsed.durationSeconds,
|
|
986
987
|
expectedSidwave: normaliseSidwaveInput(parsed.expectedSidwave),
|
|
987
988
|
});
|
|
@@ -131,7 +131,7 @@ const soundOperations = [
|
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
op: "record_analyze",
|
|
134
|
-
schema: extendSchemaWithOp("record_analyze", ensureDescriptor(audioDescriptorIndex, "record_and_analyze_audio").inputSchema, { description: "Record audio for a fixed duration and return SID analysis metrics." }),
|
|
134
|
+
schema: extendSchemaWithOp("record_analyze", ensureDescriptor(audioDescriptorIndex, "record_and_analyze_audio").inputSchema, { 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." }),
|
|
135
135
|
handler: async (rawArgs, ctx) => invokeModuleTool(audioModule, "record_and_analyze_audio", rawArgs, ctx),
|
|
136
136
|
},
|
|
137
137
|
];
|
package/dist/tools/streaming.js
CHANGED
|
@@ -32,6 +32,15 @@ function toRecord(details) {
|
|
|
32
32
|
}
|
|
33
33
|
return undefined;
|
|
34
34
|
}
|
|
35
|
+
function withFailureReason(message, details) {
|
|
36
|
+
const record = toRecord(details);
|
|
37
|
+
const body = toRecord(record?.data) ?? toRecord(record?.response) ?? record;
|
|
38
|
+
const errors = body?.errors;
|
|
39
|
+
const reason = Array.isArray(errors) && errors.length > 0
|
|
40
|
+
? errors.map(String).join("; ")
|
|
41
|
+
: record?.message ?? record?.reason ?? (typeof details === "string" ? details : undefined);
|
|
42
|
+
return typeof reason === "string" && reason.length > 0 ? `${message}: ${reason}` : message;
|
|
43
|
+
}
|
|
35
44
|
export const streamingModule = defineToolModule({
|
|
36
45
|
domain: "streaming",
|
|
37
46
|
summary: "Long-running or streaming workflows such as audio capture or SID playback monitoring.",
|
|
@@ -71,7 +80,7 @@ export const streamingModule = defineToolModule({
|
|
|
71
80
|
});
|
|
72
81
|
const result = await ctx.client.streamStart(parsed.stream, parsed.target);
|
|
73
82
|
if (!result.success) {
|
|
74
|
-
throw new ToolExecutionError("C64 firmware reported failure while starting stream", {
|
|
83
|
+
throw new ToolExecutionError(withFailureReason("C64 firmware reported failure while starting stream", result.details), {
|
|
75
84
|
details: toRecord(result.details),
|
|
76
85
|
});
|
|
77
86
|
}
|
|
@@ -111,7 +120,7 @@ export const streamingModule = defineToolModule({
|
|
|
111
120
|
});
|
|
112
121
|
const result = await ctx.client.streamStop(parsed.stream);
|
|
113
122
|
if (!result.success) {
|
|
114
|
-
throw new ToolExecutionError("C64 firmware reported failure while stopping stream", {
|
|
123
|
+
throw new ToolExecutionError(withFailureReason("C64 firmware reported failure while stopping stream", result.details), {
|
|
115
124
|
details: toRecord(result.details),
|
|
116
125
|
});
|
|
117
126
|
}
|
|
@@ -363,7 +363,7 @@
|
|
|
363
363
|
"required": [
|
|
364
364
|
"op"
|
|
365
365
|
],
|
|
366
|
-
"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.",
|
|
366
|
+
"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.",
|
|
367
367
|
"additionalProperties": false,
|
|
368
368
|
"x-c64bridge-operations": [
|
|
369
369
|
{
|
|
@@ -1119,7 +1119,7 @@
|
|
|
1119
1119
|
},
|
|
1120
1120
|
{
|
|
1121
1121
|
"op": "record_analyze",
|
|
1122
|
-
"description": "Record audio for a fixed duration and return SID analysis metrics.",
|
|
1122
|
+
"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.",
|
|
1123
1123
|
"required": [
|
|
1124
1124
|
"durationSeconds"
|
|
1125
1125
|
],
|
|
@@ -1128,7 +1128,7 @@
|
|
|
1128
1128
|
],
|
|
1129
1129
|
"inputSchema": {
|
|
1130
1130
|
"type": "object",
|
|
1131
|
-
"description": "Record audio for a fixed duration and return SID analysis metrics.",
|
|
1131
|
+
"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.",
|
|
1132
1132
|
"properties": {
|
|
1133
1133
|
"durationSeconds": {
|
|
1134
1134
|
"type": "number",
|