@packet-net/soundmodem 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/modem.js ADDED
@@ -0,0 +1,224 @@
1
+ // pdn-soundmodem in a browser tab: the C# modem core compiled to WebAssembly, a Web Audio
2
+ // graph either side of it, and a PTT line over Web Serial. This is the sound-card answer to
3
+ // the question a KISS TNC on a serial port answers, and it sits in the same place: raw AX.25
4
+ // frames in, raw AX.25 frames out, and a carrier-sense reading. No waterfall, no config API,
5
+ // no KISS framing - there is no serial link here to frame anything for.
6
+ //
7
+ // The audio contract with the wasm module is one rate, the AudioContext's: the module
8
+ // decimates into the mode's DSP rate and upsamples back out with the same anti-aliased
9
+ // filters the daemon uses on a real sound card, so nothing here resamples and the browser's
10
+ // own resampler never touches modem audio.
11
+
12
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
13
+
14
+ /** PTT on a serial control line - the RTS/DTR keying every packet interface has done for decades. */
15
+ export class SerialPtt {
16
+ /**
17
+ * Prompts for a port (must be called from a user gesture) and opens it.
18
+ * @param {{ signal?: 'rts' | 'dtr', baudRate?: number }} [options]
19
+ * @returns {Promise<SerialPtt>}
20
+ */
21
+ static async request({ signal = 'rts', baudRate = 9600 } = {}) {
22
+ if (!navigator.serial) throw new Error('this browser has no Web Serial (Chrome or Edge required)')
23
+ const port = await navigator.serial.requestPort()
24
+ await port.open({ baudRate })
25
+ const ptt = new SerialPtt(port, signal)
26
+ await ptt.unkey()
27
+ return ptt
28
+ }
29
+
30
+ /**
31
+ * @param {SerialPort} port an open Web Serial port
32
+ * @param {'rts' | 'dtr'} [signal] which control line keys the radio
33
+ */
34
+ constructor(port, signal = 'rts') {
35
+ this.port = port
36
+ this.signal = signal
37
+ }
38
+
39
+ #signals(on) {
40
+ return this.signal === 'dtr' ? { dataTerminalReady: on } : { requestToSend: on }
41
+ }
42
+
43
+ key() { return this.port.setSignals(this.#signals(true)) }
44
+ unkey() { return this.port.setSignals(this.#signals(false)) }
45
+ async close() { await this.unkey(); await this.port.close() }
46
+ }
47
+
48
+ /** PTT that keys nothing - for a VOX interface, or for listening only. */
49
+ export const NoPtt = { key: async () => {}, unkey: async () => {}, close: async () => {} }
50
+
51
+ export class SoundModem {
52
+ #exports
53
+ #handle = 0
54
+ #context
55
+ #stream
56
+ #worklet
57
+ #frameListeners = new Set()
58
+ #transmitting = false
59
+
60
+ /**
61
+ * Loads the WebAssembly runtime and the modem core. Do this once per page; opening and
62
+ * closing modes afterwards is cheap.
63
+ */
64
+ /**
65
+ * @param {string} [frameworkUrl] where the WebAssembly bundle lives; the default is the
66
+ * copy shipped alongside this module, which is right for a CDN and for self-hosting.
67
+ * @returns {Promise<SoundModem>}
68
+ */
69
+ static async load(frameworkUrl = new URL('../_framework/dotnet.js', import.meta.url).href) {
70
+ const { dotnet } = await import(frameworkUrl)
71
+ const runtime = await dotnet.withDiagnosticTracing(false).create()
72
+ const exports = await runtime.getAssemblyExports(runtime.getConfig().mainAssemblyName)
73
+ return new SoundModem(exports.Modem)
74
+ }
75
+
76
+ constructor(exports) {
77
+ this.#exports = exports
78
+ this.txDelayMs = 300
79
+ this.txTailMs = 20
80
+ this.pttLeadMs = 50
81
+ }
82
+
83
+ /**
84
+ * Every mode the catalogue can build, e.g. afsk1200, bpsk300, qpsk2400, fsk9600-il2p.
85
+ * @returns {string[]}
86
+ */
87
+ get modes() { return this.#exports.Modes() }
88
+
89
+ /** The audio rate the graph is running at, once open. @returns {number} */
90
+ get sampleRate() { return this.#context?.sampleRate ?? 0 }
91
+
92
+ /** True while this station is keyed. @returns {boolean} */
93
+ get transmitting() { return this.#transmitting }
94
+
95
+ /**
96
+ * Opens the radio: microphone (or USB codec input) in, speaker (or USB codec output) out,
97
+ * a mode running between them.
98
+ *
99
+ * The three audio-processing constraints are not optional. A browser will happily hand a
100
+ * modem echo-cancelled, noise-suppressed, automatically-gained audio, and every one of
101
+ * those is a defect generator a demodulator cannot see around.
102
+ */
103
+ /**
104
+ * @param {{ mode?: string, inputDeviceId?: string, outputDeviceId?: string,
105
+ * ptt?: { key(): Promise<void>, unkey(): Promise<void>, close?(): Promise<void> },
106
+ * sampleRate?: number }} [options]
107
+ * @returns {Promise<number>} the rate the DSP chain ended up running at
108
+ */
109
+ async open({ mode = 'afsk1200', inputDeviceId, outputDeviceId, ptt = NoPtt, sampleRate = 48000 } = {}) {
110
+ this.ptt = ptt
111
+ this.#stream = await navigator.mediaDevices.getUserMedia({
112
+ audio: {
113
+ deviceId: inputDeviceId ? { exact: inputDeviceId } : undefined,
114
+ channelCount: 1,
115
+ echoCancellation: false,
116
+ noiseSuppression: false,
117
+ autoGainControl: false,
118
+ },
119
+ })
120
+
121
+ this.#context = new AudioContext({ sampleRate, latencyHint: 'playback' })
122
+ if (outputDeviceId && this.#context.setSinkId) await this.#context.setSinkId(outputDeviceId)
123
+ await this.#context.audioWorklet.addModule(new URL('./rx-worklet.js', import.meta.url))
124
+
125
+ this.mode = mode
126
+ this.#handle = this.#exports.Open(mode, this.#context.sampleRate)
127
+ this.dspRate = this.#exports.DspRateOf(this.#handle)
128
+
129
+ const source = this.#context.createMediaStreamSource(this.#stream)
130
+ this.#worklet = new AudioWorkletNode(this.#context, 'sound-modem-rx', {
131
+ numberOfInputs: 1,
132
+ numberOfOutputs: 0,
133
+ processorOptions: { blockSize: 1024 },
134
+ })
135
+ this.#worklet.port.onmessage = (event) => this.#receive(event.data)
136
+ source.connect(this.#worklet)
137
+ await this.#context.resume()
138
+ return this.dspRate
139
+ }
140
+
141
+ /**
142
+ * Subscribes to decoded frames - raw AX.25 bytes, no flags and no FCS. Returns the
143
+ * unsubscribe. There can be several: a soundcard modem hears the whole channel, and the
144
+ * session layer wanting a frame must not stop a monitor pane seeing it.
145
+ */
146
+ /**
147
+ * @param {(frame: Uint8Array) => void} callback
148
+ * @returns {() => void} the unsubscribe
149
+ */
150
+ onFrame(callback) {
151
+ this.#frameListeners.add(callback)
152
+ return () => { this.#frameListeners.delete(callback) }
153
+ }
154
+
155
+ /** True while the demodulator sees a coherent packet signal (the DCD lamp). @returns {boolean} */
156
+ get carrierDetect() { return !this.#transmitting && this.#exports.CarrierDetect(this.#handle) }
157
+
158
+ /**
159
+ * Carrier sense: busy while anything is on channel, ourselves included.
160
+ * @returns {boolean}
161
+ */
162
+ channelBusy() {
163
+ if (this.#transmitting) return true
164
+ return this.#exports.ChannelBusy(this.#handle)
165
+ }
166
+
167
+ /** @param {Float32Array} samples */
168
+ #receive(samples) {
169
+ // Our own transmission is not traffic to decode, and hearing it would only teach the
170
+ // busy detector that the channel is occupied by the station that is talking.
171
+ if (this.#transmitting) return
172
+ this.#exports.Feed(this.#handle, new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength))
173
+ const packed = this.#exports.TakeFrames(this.#handle)
174
+ for (let at = 0; at < packed.length;) {
175
+ const length = packed[at] | (packed[at + 1] << 8)
176
+ const frame = packed.subarray(at + 2, at + 2 + length)
177
+ for (const listener of this.#frameListeners) listener(frame)
178
+ at += 2 + length
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Keys the radio and sends one AX.25 frame. TXDELAY is inside the modulated audio, so the
184
+ * only waits here are the radio's own: a lead before the audio starts, and a tail after it
185
+ * ends - and the tail has to include the output latency, because the last sample leaves the
186
+ * graph well before it leaves the sound card.
187
+ */
188
+ /**
189
+ * @param {Uint8Array} ax25Frame one AX.25 frame, no flags and no FCS
190
+ * @param {number} [txDelayMs]
191
+ * @returns {Promise<void>}
192
+ */
193
+ async transmit(ax25Frame, txDelayMs = this.txDelayMs) {
194
+ const pcm = new Float32Array(this.#exports.Modulate(this.#handle, ax25Frame, txDelayMs).buffer)
195
+ const buffer = this.#context.createBuffer(1, pcm.length, this.#context.sampleRate)
196
+ buffer.copyToChannel(pcm, 0)
197
+
198
+ this.#transmitting = true
199
+ try {
200
+ await this.ptt.key()
201
+ await sleep(this.pttLeadMs)
202
+ const node = this.#context.createBufferSource()
203
+ node.buffer = buffer
204
+ node.connect(this.#context.destination)
205
+ const ended = new Promise((resolve) => { node.onended = resolve })
206
+ node.start()
207
+ await ended
208
+ await sleep(this.txTailMs + Math.round((this.#context.outputLatency ?? 0) * 1000))
209
+ } finally {
210
+ await this.ptt.unkey()
211
+ this.#transmitting = false
212
+ this.#exports.ResetCarrierState(this.#handle)
213
+ }
214
+ }
215
+
216
+ async close() {
217
+ if (this.#handle) this.#exports.Close(this.#handle)
218
+ this.#handle = 0
219
+ this.#worklet?.port.close()
220
+ this.#stream?.getTracks().forEach((t) => t.stop())
221
+ await this.#context?.close()
222
+ await this.ptt?.close?.()
223
+ }
224
+ }
@@ -0,0 +1,32 @@
1
+ // The receive end of the audio graph. This runs on the audio render thread, where the .NET
2
+ // runtime cannot go (an AudioWorklet has no module loader and no threads of its own), so all
3
+ // it does is gather render quanta into blocks big enough to be worth a message and hand them
4
+ // over. The DSP happens where the runtime is.
5
+ //
6
+ // A block is one allocation per ~21 ms at 48 kHz. That is the price of not having
7
+ // SharedArrayBuffer: a shared ring would need COOP/COEP headers on the page, which static
8
+ // hosting often will not set, and a packet modem does not need the microseconds it would buy.
9
+ class SoundModemRx extends AudioWorkletProcessor {
10
+ constructor(options) {
11
+ super()
12
+ this.blockSize = options?.processorOptions?.blockSize ?? 1024
13
+ this.block = new Float32Array(this.blockSize)
14
+ this.at = 0
15
+ }
16
+
17
+ process(inputs) {
18
+ const channel = inputs[0]?.[0]
19
+ if (!channel) return true
20
+ for (let i = 0; i < channel.length; i++) {
21
+ this.block[this.at++] = channel[i]
22
+ if (this.at === this.blockSize) {
23
+ this.port.postMessage(this.block, [this.block.buffer])
24
+ this.block = new Float32Array(this.blockSize)
25
+ this.at = 0
26
+ }
27
+ }
28
+ return true
29
+ }
30
+ }
31
+
32
+ registerProcessor('sound-modem-rx', SoundModemRx)
@@ -0,0 +1,85 @@
1
+ // The modem as a transport: the slot a KISS TNC on a serial port occupies, filled by a sound
2
+ // card instead.
3
+ //
4
+ // This file imports nothing. It satisfies a transport contract by shape - `start`, `send`,
5
+ // `stop`, and a `channelBusy()` for carrier sense - so any link layer that wants raw AX.25
6
+ // frames from somewhere can take them from here without either side knowing about the other.
7
+ // A station picks its modem the way it always has: a TNC on a lead, or the sound card.
8
+
9
+ /**
10
+ * Classic p-persistent CSMA, AX.25 §6.4.2 - the same loop the C# SoundModemChannel runs:
11
+ * while the channel is busy, wait a slot; when it is clear, roll p, and on a failed roll wait
12
+ * a slot and try again. It belongs to the modem rather than to a link layer because it is a
13
+ * property of a shared half-duplex radio channel: whoever owns the PTT owns the contention.
14
+ */
15
+ async function contend({ channelBusy, persistence, slotTimeMs, signal }) {
16
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
17
+ for (;;) {
18
+ if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
19
+ if (channelBusy()) { await sleep(slotTimeMs); continue }
20
+ if (Math.floor(Math.random() * 256) <= persistence) return
21
+ await sleep(slotTimeMs)
22
+ }
23
+ }
24
+
25
+ /** A transport, and a carrier-sense source, backed by a {@link SoundModem}. */
26
+ export class SoundModemTransport {
27
+ /**
28
+ * @param {import('./modem.js').SoundModem} modem an open SoundModem
29
+ * @param options KISS channel-access parameters, in KISS units: persistence 0-255 where
30
+ * p = (value + 1) / 256, slot time and TXDELAY in milliseconds. The defaults are the
31
+ * daemon's.
32
+ */
33
+ constructor(modem, { txDelayMs = 300, persistence = 63, slotTimeMs = 100 } = {}) {
34
+ this.modem = modem
35
+ /** @type {(() => void) | undefined} */
36
+ this.unsubscribe = undefined
37
+ this.txDelayMs = txDelayMs
38
+ this.persistence = persistence
39
+ this.slotTimeMs = slotTimeMs
40
+ this.running = false
41
+ /** Transmissions are serialised: one radio, one keyup at a time. */
42
+ this.queue = Promise.resolve()
43
+ }
44
+
45
+ /**
46
+ * @param {(frame: Uint8Array) => void} onFrame
47
+ * @returns {Promise<void>}
48
+ */
49
+ async start(onFrame) {
50
+ this.unsubscribe?.()
51
+ this.unsubscribe = this.modem.onFrame(onFrame)
52
+ this.running = true
53
+ }
54
+
55
+ /**
56
+ * @param {Uint8Array} axBytes one AX.25 frame, no flags and no FCS
57
+ * @returns {Promise<void>}
58
+ */
59
+ async send(axBytes) {
60
+ if (!this.running) throw new Error('transport not started')
61
+ const send = this.queue.then(async () => {
62
+ await contend({
63
+ channelBusy: () => this.modem.channelBusy(),
64
+ persistence: this.persistence,
65
+ slotTimeMs: this.slotTimeMs,
66
+ })
67
+ await this.modem.transmit(axBytes, this.txDelayMs)
68
+ })
69
+ // Keep the chain alive after a failed send so one error does not wedge the radio.
70
+ this.queue = send.catch(() => {})
71
+ return send
72
+ }
73
+
74
+ async stop() {
75
+ this.running = false
76
+ this.unsubscribe?.()
77
+ this.unsubscribe = undefined
78
+ }
79
+
80
+ /**
81
+ * Carrier sense: a null would mean "cannot tell", and this modem always can.
82
+ * @returns {boolean}
83
+ */
84
+ channelBusy() { return this.modem.channelBusy() }
85
+ }
@@ -0,0 +1,2 @@
1
+ export { SoundModemTransport } from "./transport.js";
2
+ export { SoundModem, SerialPtt, NoPtt } from "./modem.js";
@@ -0,0 +1,116 @@
1
+ /** PTT on a serial control line - the RTS/DTR keying every packet interface has done for decades. */
2
+ export class SerialPtt {
3
+ /**
4
+ * Prompts for a port (must be called from a user gesture) and opens it.
5
+ * @param {{ signal?: 'rts' | 'dtr', baudRate?: number }} [options]
6
+ * @returns {Promise<SerialPtt>}
7
+ */
8
+ static request({ signal, baudRate }?: {
9
+ signal?: "rts" | "dtr";
10
+ baudRate?: number;
11
+ }): Promise<SerialPtt>;
12
+ /**
13
+ * @param {SerialPort} port an open Web Serial port
14
+ * @param {'rts' | 'dtr'} [signal] which control line keys the radio
15
+ */
16
+ constructor(port: SerialPort, signal?: "rts" | "dtr");
17
+ port: SerialPort;
18
+ signal: "rts" | "dtr";
19
+ key(): any;
20
+ unkey(): any;
21
+ close(): Promise<void>;
22
+ #private;
23
+ }
24
+ export namespace NoPtt {
25
+ function key(): Promise<void>;
26
+ function unkey(): Promise<void>;
27
+ function close(): Promise<void>;
28
+ }
29
+ export class SoundModem {
30
+ /**
31
+ * Loads the WebAssembly runtime and the modem core. Do this once per page; opening and
32
+ * closing modes afterwards is cheap.
33
+ */
34
+ /**
35
+ * @param {string} [frameworkUrl] where the WebAssembly bundle lives; the default is the
36
+ * copy shipped alongside this module, which is right for a CDN and for self-hosting.
37
+ * @returns {Promise<SoundModem>}
38
+ */
39
+ static load(frameworkUrl?: string): Promise<SoundModem>;
40
+ constructor(exports: any);
41
+ txDelayMs: number;
42
+ txTailMs: number;
43
+ pttLeadMs: number;
44
+ /**
45
+ * Every mode the catalogue can build, e.g. afsk1200, bpsk300, qpsk2400, fsk9600-il2p.
46
+ * @returns {string[]}
47
+ */
48
+ get modes(): string[];
49
+ /** The audio rate the graph is running at, once open. @returns {number} */
50
+ get sampleRate(): number;
51
+ /** True while this station is keyed. @returns {boolean} */
52
+ get transmitting(): boolean;
53
+ /**
54
+ * Opens the radio: microphone (or USB codec input) in, speaker (or USB codec output) out,
55
+ * a mode running between them.
56
+ *
57
+ * The three audio-processing constraints are not optional. A browser will happily hand a
58
+ * modem echo-cancelled, noise-suppressed, automatically-gained audio, and every one of
59
+ * those is a defect generator a demodulator cannot see around.
60
+ */
61
+ /**
62
+ * @param {{ mode?: string, inputDeviceId?: string, outputDeviceId?: string,
63
+ * ptt?: { key(): Promise<void>, unkey(): Promise<void>, close?(): Promise<void> },
64
+ * sampleRate?: number }} [options]
65
+ * @returns {Promise<number>} the rate the DSP chain ended up running at
66
+ */
67
+ open({ mode, inputDeviceId, outputDeviceId, ptt, sampleRate }?: {
68
+ mode?: string;
69
+ inputDeviceId?: string;
70
+ outputDeviceId?: string;
71
+ ptt?: {
72
+ key(): Promise<void>;
73
+ unkey(): Promise<void>;
74
+ close?(): Promise<void>;
75
+ };
76
+ sampleRate?: number;
77
+ }): Promise<number>;
78
+ ptt: {
79
+ key(): Promise<void>;
80
+ unkey(): Promise<void>;
81
+ close?(): Promise<void>;
82
+ } | undefined;
83
+ mode: string | undefined;
84
+ dspRate: any;
85
+ /**
86
+ * Subscribes to decoded frames - raw AX.25 bytes, no flags and no FCS. Returns the
87
+ * unsubscribe. There can be several: a soundcard modem hears the whole channel, and the
88
+ * session layer wanting a frame must not stop a monitor pane seeing it.
89
+ */
90
+ /**
91
+ * @param {(frame: Uint8Array) => void} callback
92
+ * @returns {() => void} the unsubscribe
93
+ */
94
+ onFrame(callback: (frame: Uint8Array) => void): () => void;
95
+ /** True while the demodulator sees a coherent packet signal (the DCD lamp). @returns {boolean} */
96
+ get carrierDetect(): boolean;
97
+ /**
98
+ * Carrier sense: busy while anything is on channel, ourselves included.
99
+ * @returns {boolean}
100
+ */
101
+ channelBusy(): boolean;
102
+ /**
103
+ * Keys the radio and sends one AX.25 frame. TXDELAY is inside the modulated audio, so the
104
+ * only waits here are the radio's own: a lead before the audio starts, and a tail after it
105
+ * ends - and the tail has to include the output latency, because the last sample leaves the
106
+ * graph well before it leaves the sound card.
107
+ */
108
+ /**
109
+ * @param {Uint8Array} ax25Frame one AX.25 frame, no flags and no FCS
110
+ * @param {number} [txDelayMs]
111
+ * @returns {Promise<void>}
112
+ */
113
+ transmit(ax25Frame: Uint8Array, txDelayMs?: number): Promise<void>;
114
+ close(): Promise<void>;
115
+ #private;
116
+ }
@@ -0,0 +1,7 @@
1
+ declare class SoundModemRx {
2
+ constructor(options: any);
3
+ blockSize: any;
4
+ block: Float32Array<any>;
5
+ at: number;
6
+ process(inputs: any): boolean;
7
+ }
@@ -0,0 +1,39 @@
1
+ /** A transport, and a carrier-sense source, backed by a {@link SoundModem}. */
2
+ export class SoundModemTransport {
3
+ /**
4
+ * @param {import('./modem.js').SoundModem} modem an open SoundModem
5
+ * @param options KISS channel-access parameters, in KISS units: persistence 0-255 where
6
+ * p = (value + 1) / 256, slot time and TXDELAY in milliseconds. The defaults are the
7
+ * daemon's.
8
+ */
9
+ constructor(modem: import("./modem.js").SoundModem, { txDelayMs, persistence, slotTimeMs }?: {
10
+ txDelayMs?: number | undefined;
11
+ persistence?: number | undefined;
12
+ slotTimeMs?: number | undefined;
13
+ });
14
+ modem: import("./modem.js").SoundModem;
15
+ /** @type {(() => void) | undefined} */
16
+ unsubscribe: (() => void) | undefined;
17
+ txDelayMs: number;
18
+ persistence: number;
19
+ slotTimeMs: number;
20
+ running: boolean;
21
+ /** Transmissions are serialised: one radio, one keyup at a time. */
22
+ queue: Promise<void>;
23
+ /**
24
+ * @param {(frame: Uint8Array) => void} onFrame
25
+ * @returns {Promise<void>}
26
+ */
27
+ start(onFrame: (frame: Uint8Array) => void): Promise<void>;
28
+ /**
29
+ * @param {Uint8Array} axBytes one AX.25 frame, no flags and no FCS
30
+ * @returns {Promise<void>}
31
+ */
32
+ send(axBytes: Uint8Array): Promise<void>;
33
+ stop(): Promise<void>;
34
+ /**
35
+ * Carrier sense: a null would mean "cannot tell", and this modem always can.
36
+ * @returns {boolean}
37
+ */
38
+ channelBusy(): boolean;
39
+ }