npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@packet-net/soundmodem

v0.78.0

Published

A sound card instead of a TNC: the pdn-soundmodem DSP core in WebAssembly, with a Web Audio graph and serial or CM108 GPIO PTT, producing and consuming raw AX.25 frames in the browser.

Readme

@packet-net/soundmodem

A sound card instead of a TNC. The pdn-soundmodem DSP core compiled to WebAssembly, with a Web Audio graph and a PTT line either side of it, so a browser tab with a USB audio interface is a packet modem.

It sits where a KISS TNC on a serial port sits: raw AX.25 frames in, raw AX.25 frames out, plus a carrier-sense reading. It depends on nothing, and it knows nothing about any particular AX.25 implementation.

import { SoundModem, SerialPtt, SoundModemTransport } from '@packet-net/soundmodem'

const modem = await SoundModem.load()
await modem.open({ mode: 'afsk1200', ptt: await SerialPtt.request() })

modem.onFrame((ax25) => console.log(ax25.length, 'bytes'))

const transport = new SoundModemTransport(modem)   // start / send / stop + channelBusy()

SoundModemTransport satisfies a transport contract by shape, so a link layer can take its frames without either side importing the other. With @packet-net/ax25 that reads:

const listener = new Ax25Listener(transport, { myCall: 'M0LTE-1', carrierSense: transport })

which is the same call a serial KISS TNC gets, with a different transport passed in.

Modes

Every mode the modem catalogue builds, including the NinoTNC set: afsk1200, afsk300, bpsk300, bpsk1200, qpsk600, qpsk2400, qpsk3600, fsk4800, fsk9600, c4fsk9600, c4fsk19200, their IL2P and IL2P+CRC variants, and the MIL-STD-188-110D and FreeDV families. modem.modes lists them.

The decodes are bit for bit what the native build produces - the same demodulators, carrying the same measured tuning constants, checked frame by frame against the native build on every release.

Levels

A browser station has no sound card mixer to reach, so the two levels a station trims on the card are trimmed in the graph instead: one gain either side of the modem, both in dB, both unity at 0.

modem.rxGainDb = 6      // 0 dB is the device's audio untouched
modem.txGainDb = -12    // 0 dB is the modulator's own output, which peaks at 0.8
modem.inputLevel        // { peak, rms, clip } in dBFS, from the most recent block

Receive gain is applied before the demodulator, and inputLevel is measured on the same samples the demodulator is given - so the reading moves when the gain does, which is the one thing a meter beside a gain control exists to show. Transmit gain is applied to everything the modem sends, frames and test tones alike. Either can be set before open(); the modem keeps the dB and applies it when the graph is built. A level outside -60 to +30 dB is refused rather than clamped.

The transmitter test

The station page's TX test, on a browser station: the classic two-tone pair for a linearity check, or one tone for a carrier level or an FM deviation check by Bessel null. It goes out through the ordinary transmit path - the same PTT line, the same transmit gain - which is the point of it. A test that took a different route to the air would measure that route.

modem.twoTonePairHz          // [700, 1900]
modem.besselNullPresets      // [{ toneHz: 500, deviationHz: 1202.5 }, ...]

// Contends for the channel first, like anything else that keys, then keys and sends.
const done = await transport.testTone({ twoTone: true, seconds: 5 })
// { text: 'two-tone 700+1900 Hz, 5.0 s, peak level 0.80 - done, 5.0 s on air', onAir, stopped }

transport.stopTestTone()     // withdraws it if queued; fades it out if it is on the air

modem.describeTestTone(options) reads a request without sending it, which is how a page shows what is about to go out - and how a tone it will not send gets refused before anything is keyed. The tones and the presets come from the core's own TestTone, so nothing here is a second copy of a measurement setting. A single tone must be at least 50 Hz and below Nyquist, and is refused rather than moved: the deviation read off a null is wrong by exactly as much as a tone that was quietly nudged. A test is capped at 30 seconds whatever is asked for, because a test transmission is a transmission.

Raise the transmit gain until the carrier disappears on a spectrum display and the level at that point is the deviation the preset names.

What it needs

Chrome, Edge or Opera, on desktop. Web Serial and WebHID, which carry the PTT line between them, exist in neither Firefox nor Safari and are switched off in mobile Chromium builds; the modem itself runs anywhere, so receive-only or VOX use does not need either. A secure context (https, or http://localhost) for the microphone and for the keying device.

PTT is RTS or DTR on a serial port, or the GPIO pin of a CM108-family dongle over WebHID:

import { Cm108Ptt } from '@packet-net/soundmodem'

await modem.open({ mode: 'afsk1200', ptt: await Cm108Ptt.request({ debug: true }) })

which is worth having because it makes the whole station one USB lead: the dongle carries receive audio, transmit audio and the keying, so inputDeviceId, outputDeviceId and the PTT are all the same piece of hardware. gpio defaults to 3, which is what every interface we have seen wires PTT to, and filters defaults to C-Media's vendor ID plus the AIOC (1209:7388), which emulates the same report - pass filters: [] to see every HID device on the machine for a clone that reports somebody else's.

On Linux the browser needs permission on the hidraw node, and it runs as you rather than as a service account, so uaccess is the rule to write rather than a group:

KERNEL=="hidraw*", ATTRS{idVendor}=="0d8c", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="7388", TAG+="uaccess"

One thing to know before you key a radio from a tab: the chip latches the pin, so PTT survives the page going away. Cm108Ptt releases on pagehide and on close(), which covers a navigation or a closed tab, but nothing in the browser can cover a crash. An interface with a hardware transmit timeout is the belt to that braces.

Licence

AGPL-3.0-or-later. See NOTICE for what is in the bundle and under which licence: the modem core and the IL2P codec are GPL-3.0-or-later inside an AGPL-3.0-or-later combination, which is what GPLv3 section 13 provides for. Anything that imports this package forms a combined work under those terms.