c64bridge 1.0.1 → 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.
@@ -1,9 +1,13 @@
1
1
  import { parseSidwave, midiToHz } from "../sidwave.js";
2
- export async function recordAndAnalyzeAudio(params) {
3
- const duration = Math.max(0.5, Math.min(120, Number(params.durationSeconds || 0)));
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
- const win = signal.subarray(i, i + windowSize);
91
- const rms = computeRms(win, Meyda, sampleRate, windowSize);
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
- const f = yin(win);
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: "microphone",
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
- const features = Meyda.extract(["rms"], win, { sampleRate, bufferSize });
290
- const val = Number(features?.rms ?? 0);
291
- if (Number.isFinite(val))
292
- return val;
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 facade.streamStart(stream, ip);
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 facade.streamStart("video", target), "start video stream");
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 facade.streamStart("video", target), "start video stream");
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
- let stopError = null;
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
- await this.ensureStreamSuccess(await facade.streamStart("audio", target), "start audio stream");
1461
- const neededValues = samplePairs * 2;
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
- socket.on("message", (msg) => {
1468
- try {
1469
- const packet = parseAudioPacket(Buffer.from(msg));
1470
- chunks.push(packet.samples);
1471
- collectedValues += packet.samples.length;
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
- reject(error);
1610
+ if (captureError)
1611
+ reject(captureError);
1612
+ else
1613
+ resolve();
1480
1614
  }
1481
- });
1482
- socket.once("error", (error) => {
1483
- clearTimeout(timer);
1484
- reject(error);
1485
- });
1615
+ };
1616
+ notifyCapture();
1486
1617
  });
1487
- const samples = new Int16Array(samplePairs * 2);
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
- await this.ensureStreamSuccess(await facade.streamStop("audio"), "stop audio stream");
1648
+ if (streamAttempted)
1649
+ await this.ensureStreamSuccess(await facade.streamStop("audio"), "stop audio stream");
1508
1650
  }
1509
1651
  catch (error) {
1510
- stopError = error instanceof Error ? error : new Error(String(error));
1511
- }
1512
- socket.close();
1513
- if (stopError) {
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("Video", "Mode");
1521
- const raw = response?.value ?? response;
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;
@@ -256,7 +256,7 @@ const musicCompileArgsSchema = objectSchema({
256
256
  additionalProperties: false,
257
257
  });
258
258
  const recordAndAnalyzeArgsSchema = objectSchema({
259
- description: "Record audio from the default input and analyze SID playback characteristics.",
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 default input device and analyze SID playback characteristics.",
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 result = await recordAndAnalyzeAudio({
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
  ];
@@ -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",
package/mcp/server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c64bridge",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Stdio-based Model Context Protocol (MCP) server that bridges Commodore 64 Ultimate hardware with AI agents.",
5
5
  "transports": [
6
6
  "stdio"