@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/LICENSE +661 -0
- package/NOTICE +48 -0
- package/README.md +57 -0
- package/_framework/M0LTE.Dsp.wasm +0 -0
- package/_framework/M0LTE.Fec.wasm +0 -0
- package/_framework/M0LTE.FecLdpc.wasm +0 -0
- package/_framework/M0LTE.Il2p.wasm +0 -0
- package/_framework/M0LTE.Ofdm.wasm +0 -0
- package/_framework/Packet.SoundModem.wasm +0 -0
- package/_framework/System.Linq.wasm +0 -0
- package/_framework/System.Private.CoreLib.wasm +0 -0
- package/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
- package/_framework/System.Runtime.Numerics.wasm +0 -0
- package/_framework/dotnet.boot.js +117 -0
- package/_framework/dotnet.js +4 -0
- package/_framework/dotnet.native.js +5497 -0
- package/_framework/dotnet.native.wasm +0 -0
- package/_framework/dotnet.runtime.js +4 -0
- package/_framework/pdn-modem-wasm.wasm +0 -0
- package/package.json +61 -0
- package/src/index.js +7 -0
- package/src/modem.js +224 -0
- package/src/rx-worklet.js +32 -0
- package/src/transport.js +85 -0
- package/types/index.d.ts +2 -0
- package/types/modem.d.ts +116 -0
- package/types/rx-worklet.d.ts +7 -0
- package/types/transport.d.ts +39 -0
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)
|
package/src/transport.js
ADDED
|
@@ -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
|
+
}
|
package/types/index.d.ts
ADDED
package/types/modem.d.ts
ADDED
|
@@ -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,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
|
+
}
|