c64bridge 1.0.2 → 1.0.4
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 +12 -12
- package/dist/.tsbuildinfo +1 -1
- package/dist/audio/record_and_analyze_audio.js +81 -13
- package/dist/c64Client.js +243 -40
- package/dist/device.js +32 -14
- package/dist/tools/audio.js +11 -5
- package/dist/tools/meta/artifacts.js +8 -3
- package/dist/tools/meta/audio.js +25 -28
- package/dist/tools/meta/config.js +12 -4
- package/dist/tools/meta/configInventory.js +4 -1
- package/dist/tools/meta/program.js +25 -12
- package/dist/tools/registry/batch.js +4 -2
- package/dist/tools/registry/config.js +1 -1
- package/dist/tools/registry/disk.js +5 -0
- package/dist/tools/registry/extract.js +3 -0
- package/dist/tools/registry/graphics.js +1 -0
- package/dist/tools/registry/program.js +1 -1
- package/dist/tools/registry/sound.js +4 -4
- package/dist/tools/registry/system.js +3 -3
- package/dist/tools/storage.js +2 -2
- package/dist/tools/streaming.js +11 -2
- package/mcp/schemas/c64_config.schema.json +2 -2
- package/mcp/schemas/c64_program.schema.json +10 -18
- package/mcp/schemas/c64_sound.schema.json +11 -9
- package/mcp/schemas/c64_system.schema.json +3 -3
- package/mcp/server.json +1 -1
- package/mcp/tools.json +55 -42
- 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) };
|
|
@@ -1060,6 +1141,9 @@ export class C64Client {
|
|
|
1060
1141
|
if (facade.type === "vice") {
|
|
1061
1142
|
return this.captureViceFrames(requestedCount);
|
|
1062
1143
|
}
|
|
1144
|
+
if (facade.type !== "c64u") {
|
|
1145
|
+
throw new Error("Video frame capture is only available on C64U/U64 and VICE; U2 has no video stream");
|
|
1146
|
+
}
|
|
1063
1147
|
return this.captureC64uVideoFrames(facade, requestedCount, {
|
|
1064
1148
|
reuseSession: options?.reuseSession === true,
|
|
1065
1149
|
keepAliveMs: Math.max(0, Math.trunc(options?.keepAliveMs ?? 0)),
|
|
@@ -1074,6 +1158,25 @@ export class C64Client {
|
|
|
1074
1158
|
}
|
|
1075
1159
|
return this.captureC64uAudioSamples(facade, requestedPairs);
|
|
1076
1160
|
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Records and analyzes audio. On the C64 Ultimate the PCM comes from the
|
|
1163
|
+
* device audio stream; other backends have no stream, so the host microphone is used.
|
|
1164
|
+
*/
|
|
1165
|
+
async recordAndAnalyzeAudio(params) {
|
|
1166
|
+
const durationSeconds = normalizeAudioDuration(params.durationSeconds);
|
|
1167
|
+
const facade = await this.facadePromise;
|
|
1168
|
+
if (facade.type !== "c64u") {
|
|
1169
|
+
return recordAndAnalyzeAudio(params);
|
|
1170
|
+
}
|
|
1171
|
+
const sampleRateHz = await this.getC64uAudioSampleRate(facade);
|
|
1172
|
+
const samplePairs = Math.ceil(durationSeconds * sampleRateHz);
|
|
1173
|
+
const capture = await this.captureC64uAudioSamples(facade, samplePairs, {
|
|
1174
|
+
timeoutMs: Math.ceil(durationSeconds * 1500) + 2000,
|
|
1175
|
+
sampleRateHz,
|
|
1176
|
+
acceptPartial: true,
|
|
1177
|
+
});
|
|
1178
|
+
return analyzeStereoStream(capture.samples, capture.sampleRateHz, params.expectedSidwave);
|
|
1179
|
+
}
|
|
1077
1180
|
async configsList() {
|
|
1078
1181
|
const facade = await this.facadePromise;
|
|
1079
1182
|
return facade.configsList();
|
|
@@ -1223,7 +1326,7 @@ export class C64Client {
|
|
|
1223
1326
|
try {
|
|
1224
1327
|
await this.bindCaptureSocket(socket, bindAddress);
|
|
1225
1328
|
const target = this.socketEndpoint(socket, bindAddress);
|
|
1226
|
-
await this.ensureStreamSuccess(await
|
|
1329
|
+
await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "video", target), "start video stream");
|
|
1227
1330
|
const frames = await new Promise((resolve, reject) => {
|
|
1228
1331
|
const timeoutMs = Math.max(1500, count * 750);
|
|
1229
1332
|
const timer = setTimeout(() => {
|
|
@@ -1313,7 +1416,7 @@ export class C64Client {
|
|
|
1313
1416
|
this.rejectC64uVideoCaptureWaiters(session, err);
|
|
1314
1417
|
});
|
|
1315
1418
|
try {
|
|
1316
|
-
await this.ensureStreamSuccess(await
|
|
1419
|
+
await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "video", target), "start video stream");
|
|
1317
1420
|
}
|
|
1318
1421
|
catch (error) {
|
|
1319
1422
|
session.closed = true;
|
|
@@ -1447,44 +1550,76 @@ export class C64Client {
|
|
|
1447
1550
|
}
|
|
1448
1551
|
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "VICE display capture failed"));
|
|
1449
1552
|
}
|
|
1450
|
-
async captureC64uAudioSamples(facade, samplePairs) {
|
|
1553
|
+
async captureC64uAudioSamples(facade, samplePairs, options) {
|
|
1451
1554
|
const host = new URL(this.baseUrl).hostname;
|
|
1452
1555
|
const bindAddress = await this.resolveLocalCaptureAddress(host);
|
|
1453
1556
|
const socket = createSocket("udp4");
|
|
1454
1557
|
const chunks = [];
|
|
1455
1558
|
let collectedValues = 0;
|
|
1456
|
-
|
|
1559
|
+
const neededValues = samplePairs * 2;
|
|
1560
|
+
let captureError = null;
|
|
1561
|
+
let captureSettled = false;
|
|
1562
|
+
let notifyCapture = () => { };
|
|
1563
|
+
let socketBound = false;
|
|
1564
|
+
let streamAttempted = false;
|
|
1565
|
+
let failed = false;
|
|
1566
|
+
// The firmware can emit UDP before its REST response reaches us. Listen
|
|
1567
|
+
// before starting, and cap collection even while start/config/stop requests are pending.
|
|
1568
|
+
const onMessage = (msg) => {
|
|
1569
|
+
if (captureSettled || captureError || collectedValues >= neededValues)
|
|
1570
|
+
return;
|
|
1571
|
+
try {
|
|
1572
|
+
const packet = parseAudioPacket(msg);
|
|
1573
|
+
if (packet.samples.length % 2 !== 0)
|
|
1574
|
+
throw new Error("Audio packet contains an incomplete stereo sample pair");
|
|
1575
|
+
const samples = packet.samples.subarray(0, neededValues - collectedValues);
|
|
1576
|
+
chunks.push(samples);
|
|
1577
|
+
collectedValues += samples.length;
|
|
1578
|
+
}
|
|
1579
|
+
catch (error) {
|
|
1580
|
+
captureError = error instanceof Error ? error : new Error(String(error));
|
|
1581
|
+
}
|
|
1582
|
+
notifyCapture();
|
|
1583
|
+
};
|
|
1584
|
+
const onError = (error) => {
|
|
1585
|
+
captureError = error;
|
|
1586
|
+
notifyCapture();
|
|
1587
|
+
};
|
|
1588
|
+
socket.on("message", onMessage);
|
|
1589
|
+
socket.on("error", onError);
|
|
1457
1590
|
try {
|
|
1458
1591
|
await this.bindCaptureSocket(socket, bindAddress);
|
|
1592
|
+
socketBound = true;
|
|
1459
1593
|
const target = this.socketEndpoint(socket, bindAddress);
|
|
1460
|
-
|
|
1461
|
-
|
|
1594
|
+
streamAttempted = true;
|
|
1595
|
+
await this.ensureStreamSuccess(await this.startStreamResolvingHost(facade, "audio", target), "start audio stream");
|
|
1462
1596
|
await new Promise((resolve, reject) => {
|
|
1463
|
-
const timeoutMs = Math.max(1000, Math.ceil(samplePairs / 192) * 500);
|
|
1597
|
+
const timeoutMs = options?.timeoutMs ?? Math.max(1000, Math.ceil(samplePairs / 192) * 500);
|
|
1464
1598
|
const timer = setTimeout(() => {
|
|
1599
|
+
captureSettled = true;
|
|
1600
|
+
// UDP drops packets, so a count-based capture can fall short. Analysis accepts what arrived.
|
|
1601
|
+
if (options?.acceptPartial && collectedValues >= MIN_PARTIAL_AUDIO_VALUES) {
|
|
1602
|
+
resolve();
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1465
1605
|
reject(new Error(`Timed out after ${timeoutMs}ms while capturing ${samplePairs} audio sample pair(s)`));
|
|
1466
1606
|
}, timeoutMs);
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
if (collectedValues >= neededValues) {
|
|
1473
|
-
clearTimeout(timer);
|
|
1474
|
-
resolve();
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
catch (error) {
|
|
1607
|
+
notifyCapture = () => {
|
|
1608
|
+
if (captureSettled)
|
|
1609
|
+
return;
|
|
1610
|
+
if (captureError || collectedValues >= neededValues) {
|
|
1611
|
+
captureSettled = true;
|
|
1478
1612
|
clearTimeout(timer);
|
|
1479
|
-
|
|
1613
|
+
if (captureError)
|
|
1614
|
+
reject(captureError);
|
|
1615
|
+
else
|
|
1616
|
+
resolve();
|
|
1480
1617
|
}
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
clearTimeout(timer);
|
|
1484
|
-
reject(error);
|
|
1485
|
-
});
|
|
1618
|
+
};
|
|
1619
|
+
notifyCapture();
|
|
1486
1620
|
});
|
|
1487
|
-
const
|
|
1621
|
+
const capturedPairs = Math.min(samplePairs, Math.floor(collectedValues / 2));
|
|
1622
|
+
const samples = new Int16Array(capturedPairs * 2);
|
|
1488
1623
|
let offset = 0;
|
|
1489
1624
|
for (const chunk of chunks) {
|
|
1490
1625
|
const remaining = samples.length - offset;
|
|
@@ -1497,32 +1632,37 @@ export class C64Client {
|
|
|
1497
1632
|
return {
|
|
1498
1633
|
backend: "c64u",
|
|
1499
1634
|
channels: 2,
|
|
1500
|
-
sampleRateHz: await this.getC64uAudioSampleRate(facade),
|
|
1501
|
-
samplePairs,
|
|
1635
|
+
sampleRateHz: options?.sampleRateHz ?? await this.getC64uAudioSampleRate(facade),
|
|
1636
|
+
samplePairs: capturedPairs,
|
|
1502
1637
|
samples,
|
|
1503
1638
|
};
|
|
1504
1639
|
}
|
|
1640
|
+
catch (error) {
|
|
1641
|
+
failed = true;
|
|
1642
|
+
throw error;
|
|
1643
|
+
}
|
|
1505
1644
|
finally {
|
|
1645
|
+
captureSettled = true;
|
|
1646
|
+
notifyCapture = () => { };
|
|
1647
|
+
socket.off("message", onMessage);
|
|
1648
|
+
if (socketBound)
|
|
1649
|
+
socket.close();
|
|
1506
1650
|
try {
|
|
1507
|
-
|
|
1651
|
+
if (streamAttempted)
|
|
1652
|
+
await this.ensureStreamSuccess(await facade.streamStop("audio"), "stop audio stream");
|
|
1508
1653
|
}
|
|
1509
1654
|
catch (error) {
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
throw stopError;
|
|
1655
|
+
// Preserve the capture/start error, while still reporting cleanup diagnostics.
|
|
1656
|
+
if (!failed)
|
|
1657
|
+
throw error;
|
|
1658
|
+
writeDiagnosticEvent("audio_capture_stop_failed", { error: this.normaliseError(error) });
|
|
1515
1659
|
}
|
|
1516
1660
|
}
|
|
1517
1661
|
}
|
|
1518
1662
|
async getC64uAudioSampleRate(facade) {
|
|
1519
1663
|
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
|
-
}
|
|
1664
|
+
const response = await facade.configGet(C64U_SYSTEM_MODE_CATEGORY, C64U_SYSTEM_MODE_ITEM);
|
|
1665
|
+
return audioSampleRateForSystemMode(readSystemMode(response));
|
|
1526
1666
|
}
|
|
1527
1667
|
catch {
|
|
1528
1668
|
// Fall back to PAL below.
|
|
@@ -1568,6 +1708,69 @@ export class C64Client {
|
|
|
1568
1708
|
}
|
|
1569
1709
|
return `${bindAddress}:${address.port}`;
|
|
1570
1710
|
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Starts a stream. When the device answers "Network Host Resolve Error", sends
|
|
1713
|
+
* one ICMP echo to the device so it has to address a reply to this host, then
|
|
1714
|
+
* retries the start once.
|
|
1715
|
+
*/
|
|
1716
|
+
async startStreamResolvingHost(facade, stream, target) {
|
|
1717
|
+
let firstResult;
|
|
1718
|
+
let firstError;
|
|
1719
|
+
try {
|
|
1720
|
+
const result = await facade.streamStart(stream, target);
|
|
1721
|
+
if (result.success || !isStreamHostResolveFailure(result.details)) {
|
|
1722
|
+
return result;
|
|
1723
|
+
}
|
|
1724
|
+
firstResult = result;
|
|
1725
|
+
}
|
|
1726
|
+
catch (error) {
|
|
1727
|
+
if (!isStreamHostResolveFailure(error)) {
|
|
1728
|
+
throw error;
|
|
1729
|
+
}
|
|
1730
|
+
firstError = error;
|
|
1731
|
+
}
|
|
1732
|
+
// A ping from this host only helps when the stream target is this host: the device then has to
|
|
1733
|
+
// address the echo reply to it and learns its address. Any other target stays unresolvable.
|
|
1734
|
+
const targetHost = streamTargetHost(target);
|
|
1735
|
+
if (targetHost === null || !isLocalAddress(targetHost)) {
|
|
1736
|
+
if (firstResult) {
|
|
1737
|
+
return firstResult;
|
|
1738
|
+
}
|
|
1739
|
+
throw firstError;
|
|
1740
|
+
}
|
|
1741
|
+
const host = new URL(this.baseUrl).hostname;
|
|
1742
|
+
writeDiagnosticEvent("stream_start_host_resolve_retry", { stream, target, host });
|
|
1743
|
+
await this.primeDeviceNeighborCache(host);
|
|
1744
|
+
try {
|
|
1745
|
+
const retry = await facade.streamStart(stream, target);
|
|
1746
|
+
if (retry.success || !isStreamHostResolveFailure(retry.details)) {
|
|
1747
|
+
return retry;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
catch (error) {
|
|
1751
|
+
if (!isStreamHostResolveFailure(error)) {
|
|
1752
|
+
throw error;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
throw new Error(`The Ultimate at ${host} could not resolve stream target ${target} (${STREAM_HOST_RESOLVE_ERROR}) even after pinging it from this host. ` +
|
|
1756
|
+
"Check that this host is on the same network as the device, or use a multicast target.");
|
|
1757
|
+
}
|
|
1758
|
+
/** Best effort: ping failure (missing binary, blocked ICMP) is ignored and the caller retries anyway. */
|
|
1759
|
+
async primeDeviceNeighborCache(host) {
|
|
1760
|
+
const pingHost = pingableHost(host);
|
|
1761
|
+
if (!pingHost) {
|
|
1762
|
+
writeDiagnosticEvent("stream_start_ping_failed", { host, message: "host is not a plain hostname or address" });
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
await new Promise((resolve) => {
|
|
1766
|
+
execFile("ping", neighborPrimeArgs(pingHost), { timeout: NEIGHBOR_PRIME_TIMEOUT_MS, windowsHide: true }, (error) => {
|
|
1767
|
+
if (error) {
|
|
1768
|
+
writeDiagnosticEvent("stream_start_ping_failed", { host, message: error.message });
|
|
1769
|
+
}
|
|
1770
|
+
resolve();
|
|
1771
|
+
});
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1571
1774
|
async ensureStreamSuccess(result, action) {
|
|
1572
1775
|
if (result.success) {
|
|
1573
1776
|
return;
|
package/dist/device.js
CHANGED
|
@@ -632,10 +632,6 @@ export class ViceBackend {
|
|
|
632
632
|
}
|
|
633
633
|
async powerCycle() { return this.nuclearReset(); }
|
|
634
634
|
async nuclearReset() {
|
|
635
|
-
const poweroffResult = await this.poweroff();
|
|
636
|
-
if (!poweroffResult.success) {
|
|
637
|
-
return poweroffResult;
|
|
638
|
-
}
|
|
639
635
|
if (!this.manageProcess) {
|
|
640
636
|
return {
|
|
641
637
|
success: false,
|
|
@@ -645,6 +641,10 @@ export class ViceBackend {
|
|
|
645
641
|
},
|
|
646
642
|
};
|
|
647
643
|
}
|
|
644
|
+
const poweroffResult = await this.poweroff();
|
|
645
|
+
if (!poweroffResult.success) {
|
|
646
|
+
return poweroffResult;
|
|
647
|
+
}
|
|
648
648
|
try {
|
|
649
649
|
await this.ensureProcess();
|
|
650
650
|
return { success: true };
|
|
@@ -694,7 +694,10 @@ export class ViceBackend {
|
|
|
694
694
|
return drives;
|
|
695
695
|
});
|
|
696
696
|
}
|
|
697
|
-
async driveMount(drive, imagePath) {
|
|
697
|
+
async driveMount(drive, imagePath, options) {
|
|
698
|
+
if (options?.type !== undefined || options?.mode !== undefined) {
|
|
699
|
+
throw unsupported("driveMount type/mode overrides; omit these options on VICE");
|
|
700
|
+
}
|
|
698
701
|
const n = parseDriveNumber(drive);
|
|
699
702
|
await this.withClient(async (client) => {
|
|
700
703
|
await client.resourceSet(`Drive${n}CPUEnabled`, 1);
|
|
@@ -760,8 +763,18 @@ export class ViceBackend {
|
|
|
760
763
|
};
|
|
761
764
|
}
|
|
762
765
|
async configGet(_category, item) {
|
|
763
|
-
if (!item)
|
|
764
|
-
|
|
766
|
+
if (!item) {
|
|
767
|
+
if (_category !== "VICE")
|
|
768
|
+
throw new Error(`Unknown VICE configuration category '${_category}'`);
|
|
769
|
+
const inventory = await this.configsList();
|
|
770
|
+
return this.withClient(async (client) => {
|
|
771
|
+
const values = {};
|
|
772
|
+
for (const name of inventory.categories[0].items) {
|
|
773
|
+
values[name] = (await client.resourceGet(name)).value;
|
|
774
|
+
}
|
|
775
|
+
return values;
|
|
776
|
+
});
|
|
777
|
+
}
|
|
765
778
|
return await this.withClient(async (client) => {
|
|
766
779
|
const res = await client.resourceGet(item);
|
|
767
780
|
return { category: _category, item, value: res.value, type: res.type };
|
|
@@ -771,9 +784,7 @@ export class ViceBackend {
|
|
|
771
784
|
let parsed = value;
|
|
772
785
|
await this.withClient(async (client) => {
|
|
773
786
|
const current = await client.resourceGet(item);
|
|
774
|
-
parsed = current.type
|
|
775
|
-
if (current.type === "int" && !Number.isFinite(parsed))
|
|
776
|
-
throw new Error(`VICE resource '${item}' requires an integer value`);
|
|
787
|
+
parsed = parseViceConfigValue(value, current.type, item);
|
|
777
788
|
await client.resourceSet(item, parsed);
|
|
778
789
|
});
|
|
779
790
|
return { success: true, details: { item, value: parsed } };
|
|
@@ -784,11 +795,8 @@ export class ViceBackend {
|
|
|
784
795
|
for (const [category, items] of Object.entries(payload)) {
|
|
785
796
|
for (const [item, value] of Object.entries(items)) {
|
|
786
797
|
try {
|
|
787
|
-
const str = String(value ?? "");
|
|
788
798
|
const current = await client.resourceGet(item);
|
|
789
|
-
const parsed = current.type
|
|
790
|
-
if (current.type === "int" && !Number.isFinite(parsed))
|
|
791
|
-
throw new Error(`VICE resource '${item}' requires an integer value`);
|
|
799
|
+
const parsed = parseViceConfigValue(value, current.type, item);
|
|
792
800
|
await client.resourceSet(item, parsed);
|
|
793
801
|
results.push({ item: `${category}/${item}`, success: true });
|
|
794
802
|
}
|
|
@@ -814,6 +822,16 @@ export class ViceBackend {
|
|
|
814
822
|
}
|
|
815
823
|
ViceBackend.supervisors = new Map();
|
|
816
824
|
ViceBackend.cleanupRegistered = false;
|
|
825
|
+
function parseViceConfigValue(value, type, item) {
|
|
826
|
+
if (type !== "int")
|
|
827
|
+
return String(value ?? "");
|
|
828
|
+
const parsed = value === true || value === "true" ? 1
|
|
829
|
+
: value === false || value === "false" ? 0 : Number(value);
|
|
830
|
+
if (!Number.isInteger(parsed) || parsed < -0x80000000 || parsed > 0x7fffffff) {
|
|
831
|
+
throw new Error(`VICE resource '${item}' requires a 32-bit integer value`);
|
|
832
|
+
}
|
|
833
|
+
return parsed;
|
|
834
|
+
}
|
|
817
835
|
function unsupported(name) { const err = new Error(`Operation '${name}' is not supported by the VICE backend in phase one`); err.code = "UNSUPPORTED"; return err; }
|
|
818
836
|
/** Ultimate file APIs treat slash as a path separator. Encode each name once,
|
|
819
837
|
* preserving leading and nested separators for firmware routing. */
|