summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--LICENSE14
-rw-r--r--Makefile49
-rw-r--r--README61
-rw-r--r--cwsnd.1144
-rw-r--r--cwsnd.c480
-rw-r--r--kochgen.1101
-rw-r--r--kochgen.c148
7 files changed, 997 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ab96bcf
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,14 @@
+BSD Zero Clause License
+
+Copyright (c) 2023 Samuel Wirajaya
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0fa4298
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,49 @@
+CC?= cc
+CFLAGS?=
+PREFIX?=/usr/local
+
+all: cwsnd kochgen
+
+install: install-cwsnd install-kochgen
+
+installdoc: installdoc-cwsnd installdoc-kochgen
+
+uninstall: uninstall-cwsnd uninstall-kochgen
+
+cwsnd: cwsnd.c
+ $(CC) -Wall -Werror -O2 $(CFLAGS) \
+ -o cwsnd cwsnd.c -lm -lsndio
+
+kochgen: kochgen.c
+ $(CC) -Wall -Werror -O2 $(CFLAGS) \
+ -o kochgen kochgen.c
+
+install-cwsnd: cwsnd
+ install -s cwsnd $(PREFIX)/bin/cwsnd
+
+installdoc-cwsnd:
+ install -d $(PREFIX)/man/man1
+ install -m 0644 cwsnd.1 $(PREFIX)/man/man1/cwsnd.1
+
+install-kochgen: kochgen
+ install -s kochgen $(PREFIX)/bin/kochgen
+
+installdoc-kochgen:
+ install -d $(PREFIX)/man/man1
+ install -m 0644 kochgen.1 $(PREFIX)/man/man1/kochgen.1
+
+uninstall-cwsnd:
+ -rm $(PREFIX)/bin/cwsnd
+ -rm $(PREFIX)/man/man1/cwsnd.1
+
+uninstall-kochgen:
+ -rm $(PREFIX)/bin/kochgen
+ -rm $(PREFIX)/man/man1/kochgen.1
+
+clean:
+ -rm cwsnd
+ -rm kochgen
+
+.PHONY: all clean install install-cwsnd install-kochgen \
+ installdoc-cwsnd installdoc-kochgen \
+ uninstall uninstall-cwsnd uninstall-kochgen
diff --git a/README b/README
new file mode 100644
index 0000000..bdd2aa1
--- /dev/null
+++ b/README
@@ -0,0 +1,61 @@
+cwsnd & kochgen
+version 0.9.0
+
+CW Training Suite for OpenBSD, following the UNIX philosophy.
+
+
+ABOUD CWSND
+-----------
+cwsnd is a CW/Morse code sounder written for OpenBSD using the sndio
+API. The primary use case for this program is for CW ham study
+purposes, although it can be used for computer keying with suitable
+hardware, similar to FLDigi Tx using sound card audio output.
+
+More information can be found in the manpage (man ./cwsnd.1)
+
+(Currently only OpenBSD is supported. At the present moment, the
+author does not want to deal with the Linux audio hellscape.)
+
+
+ABOUT KOCHGEN
+-------------
+kochgen is a simple utility to generate a word list based on a
+character set. It can be used for Koch or Koch--Farnsworth training
+when piped into cwsnd.
+
+More information can be found in the manpage (man ./kochgen.1)
+
+
+INSTALLATION
+------------
+To compile:
+
+ $ make
+
+To install the binaries as root:
+
+ # make install
+
+Use the PREFIX variable to install somewhere other than /usr/local:
+
+ $ PREFIX=/home/me/.local make install
+
+Run this as root to install the manpages:
+
+ # make installdoc
+
+Other install targets: install-cwsnd install-kochgen installdoc-cwsnd
+installdoc-kochgen uninstall uninstall-cwsnd uninstall-kochgen
+
+
+AUTHOR
+------
+Samuel Wirajaya
+
+
+DISCLAIMER
+----------
+This software is provided "as is" with no warranty from the author.
+If you do use this program for actual radio transmission, bear in mind
+that you are ultimately responsible for whatever is coming out of your
+antenna. See the LICENSE file for details.
diff --git a/cwsnd.1 b/cwsnd.1
new file mode 100644
index 0000000..5258931
--- /dev/null
+++ b/cwsnd.1
@@ -0,0 +1,144 @@
+.Dd CWSND 1
+.Os
+.Sh NAME
+.Nm cwsnd
+.Nd cw sounder
+.Sh SYNOPSIS
+.Nm cwsnd
+.Op Fl h
+.Op Fl E
+.Op Fl P
+.Op Fl f Ar effwpm
+.Op Fl s Ar suffix
+.Op Fl t Ar tonefrq
+.Op Fl w Ar wpm
+.Op Ar file
+.Sh DESCRIPTION
+.Nm
+is a CW/Morse code sounder written for OpenBSD using the sndio API.
+The primary use case for this program is for CW ham study purposes,
+although it can be used for computer keying with suitable hardware,
+similar to FLDigi Tx using sound card audio output.
+.Pp
+Input is taken from the standard input by default, unless specified
+in the
+.Ar file
+argument.
+Prosigns are prefixed with the dollar sign ($), for example $AR, $SK.
+.Pp
+The input is translated into code, and then into sinusoidal
+audio-frequency tones outputted by the sound card,
+which can be used as-is for ear training,
+or further modulated by external hardware for transmission.
+The output device can be selected with the
+.Ev AUDIODEVICE
+environment variable (see
+.Xr sndio 7
+for details).
+.Pp
+There are three modes of operation: Default,
+Echo (see the
+.Fl E
+option) and Prompt (see the
+.Fl P
+option).
+In the Default mode, lines of text to be transmitted can be entered
+into the standard input.
+Lines are processed in a line-by-line basis, i.e. nothing gets sent
+unless Enter/Return is inputted.
+There will be no outputs on the standard output,
+except one single newline upon the input EOF.
+.Pp
+If running interactively in a terminal,
+Ctrl+D (end-of-file) ends the session.
+.Pp
+The options are:
+.Bl -tag -width Ds
+.It Fl h
+Show help message.
+.It Fl E
+Starts a session in the Echo mode.
+In this mode, the input will be obtained from the standard input (or
+.Ar file
+if supplied).
+Characters are echoed into the standard output as they are sent.
+Useful for making a transcript of what is actually sent.
+Not recommended for interactive use.
+.It Fl P
+Starts a session in the Prompt mode.
+The user types into an asterisk prompt before sending the entire line
+by inputting Enter/Return.
+The sent characters will be echoed into the standard output,
+and the prompt will appear again after the transmission is finished.
+.It Fl f Ar effwpm
+Sets the Farnsworth speed (effective wpm) to
+.Ar effwpm
+words per minute.
+When not specified,
+.Ar effwpm
+is assumed to be equal to the actual
+.Ar wpm .
+.It Fl s Ar suffix
+To send
+.Ar suffix
+when end-of-file is reached.
+Default empty.
+.It Fl t Ar tonefrq
+Sets the output tone frequency to
+.Ar tonefrq
+cycles per second.
+Defaults to 600 cycles/sec.
+.It Fl w Ar wpm
+Sets the character speed (wpm) to
+.Ar wpm
+words per minute, using the PARIS standard word.
+Defaults to 20 words/min.
+.Sh ENVIRONMENT
+.Bl -tag -width "AUDIODEVICEXXX" -compact
+.It Ev AUDIODEVICE
+Name of audio device to use.
+.Sh EXAMPLES
+Start a Prompt session.
+.Pp
+.Dl "$ cwsnd -P"
+.Dl "* TEST TEST"
+.Dl "TEST TEST"
+.Pp
+Press Ctrl+D to end the session.
+.Pp
+The Default mode works better for real-time conversations
+since it allows the operator to buffer the input
+while transmitting at the same time.
+.Pp
+.Dl "$ cwsnd"
+.Dl "s2yz de s1abc"
+.Dl "gm dr om [ these lines can be typed while ]"
+.Dl "ur rst 5nn 5nn = [ the prev. lines are still sending ]"
+.Dl "op is john john ="
+.Dl "qth andalusia ="
+.Dl "hw?"
+.Dl "s2yz de s1abc"
+.Dl "$kn"
+.Pp
+Sending an automatic report in a text file at 30 words per minute.
+The transmission ends with the specified suffix.
+.Pp
+.Dl "$ cwsnd -w 30 -s '= 73 de s2yz $sk' report.txt"
+.Pp
+Random
+.Xr fortune 1
+cookie with transcript via Echo mode.
+.Pp
+.Dl "$ fortune -s | cwsnd -E"
+.Pp
+Koch--Farnsworth traning practice, 25 wpm code
+spaced out to 15 effective words per minute (requires
+.Xr kochgen 1 )
+.Pp
+.Dl "$ kochgen -n 30 KMRSUAPTLO | cwsnd -w 25 -f 15"
+.Sh SEE ALSO
+.Xr kochgen 1 ,
+.Xr morse 6 ,
+.Xr sndio 7
+.Sh AUTHORS
+.An Samuel Wirajaya
diff --git a/cwsnd.c b/cwsnd.c
new file mode 100644
index 0000000..7a91243
--- /dev/null
+++ b/cwsnd.c
@@ -0,0 +1,480 @@
+/*
+
+BSD Zero Clause License
+
+Copyright (c) 2023 Samuel Wirajaya
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
+
+================================================================================
+
+ C W S N D
+
+================================================================================
+
+*/
+
+#include <ctype.h> /* toupper */
+#include <limits.h> /* strtoul */
+#include <math.h> /* sin */
+#include <stdint.h> /* int16_t */
+#include <sndio.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#define VERSION "0.9.0"
+
+typedef int16_t sample_t;
+#define SAMPLE_MAX INT16_MAX
+
+struct sio_hdl *hdl;
+struct sio_par par;
+
+/* options */
+char *progname;
+char *suffix;
+int eflg;
+int pflg;
+int tonefrq;
+int effwpm;
+int wpm;
+
+/* audio data buffer layout:
+ [~-~~~-------]
+ ^dot ^space
+ ^dash
+ */
+sample_t *buf;
+size_t dotsz;
+size_t cspsz;
+size_t wspsz;
+sample_t *buf_dot;
+sample_t *buf_dash;
+sample_t *buf_space;
+
+/* buffer for stuff */
+#define TXBUFSZ 1024
+char txbuf[TXBUFSZ];
+
+
+
+
+
+
+void
+calcsz(void) {
+ /* To calculate size (in # of elements) of the dot sound.
+
+ "PARIS " -> 50 dot-durations
+ [. --- --- . . --- . --- . . . . . . ]
+ 12345678901234567890123456789012345678901234567890
+
+ dot duration in seconds:
+ dotdur = (60 s/min) / (50 dots/word * wpm in words/min)
+
+ Let s = rate * pchan
+
+ dot size in # of elements:
+ dotsz = s * dotdur
+ = s * 6 / (5 * wpm)
+ */
+
+ size_t s = par.rate * par.pchan;
+ dotsz = (s * 6) / (5 * wpm);
+
+ /* To calculate size (# of elements) of the spaces.
+
+ "$PARIS" as "prosign" without space -> 35 dot-durations
+ [. --- --- . . --- . --- . . . . . .]
+ 12345678901234567890123456789012345
+
+ 50 - 35 = 15
+ -> 15 dot-duration units (spdotdur) that can be stretched to
+ match Farnsworth speed
+
+ The string "PARIS PARIS PARIS ..." (repeated effwpm times
+ with trailing space) should take 60 seconds:
+ effwpm * (15 * spdotdur + 35 * dotdur) == 60 seconds
+
+ spacing-dot duration solved from above eq:
+ (60 / effwpm) - (35 * dotdur)
+ spdotdur = -----------------------------
+ 15
+
+ spacing-dot size from spdotdur and simplifying:
+ spdotsz = s * spdotdur
+ (s * 12 / effwpm) - (7 * dotsz)
+ = -------------------------------
+ 3
+
+ character space size cspsz = spdotsz * 2
+ word space size wspsz = spdotsz * 4
+ */
+
+ cspsz = (
+ (s * 24 / effwpm) - (14 * dotsz)
+ /*--------------------------------*/
+ ) / 3;
+ wspsz = (
+ (s * 48 / effwpm) - (28 * dotsz)
+ /*--------------------------------*/
+ ) / 3;
+}
+
+void
+mksin(sample_t *ptr, size_t n) {
+ unsigned int i = 0, j;
+ double fi = 0., val;
+ /* TODO try windowing */
+ while (1) {
+ val = sin(6.283185307 * tonefrq * (fi / par.rate));
+ for (j = 0; j < par.pchan; ++j) {
+ ptr[i] = (sample_t)round(SAMPLE_MAX * val);
+ if (++i == n) {
+ return;
+ }
+ }
+ fi += 1.;
+ }
+}
+
+int
+mkbuf(void) {
+ /* audio data buffer layout:
+ [~-~~~-------]
+ ^dot ^space
+ ^dash
+ */
+ buf = (sample_t *)calloc(dotsz * (2 + 4) + wspsz, sizeof(sample_t));
+ if (!buf) {
+ perror("can't allocate buffer");
+ return 0;
+ }
+
+ mksin(buf_dot = buf, dotsz);
+ mksin(buf_dash = &buf[dotsz * 2], dotsz * 3);
+ buf_space = &buf[dotsz * 6];
+ return 1;
+}
+
+size_t
+dot(void) {
+ return sio_write(hdl, buf_dot,
+ /* tone blank */
+ sizeof(sample_t) * dotsz * 2);
+}
+
+size_t
+dash(void) {
+ return sio_write(hdl, buf_dash,
+ /* tone tone tone blank */
+ sizeof(sample_t) * dotsz * 4);
+}
+
+size_t
+csp(void) {
+ return sio_write(hdl, buf_space, sizeof(sample_t) * cspsz);
+}
+
+size_t
+wsp(void) {
+ return sio_write(hdl, buf_space, sizeof(sample_t) * wspsz);
+}
+
+void
+usage(void) {
+ printf(
+ "CW Sounder for OpenBSD (version " VERSION ")\n"
+ "usage: %s [-EPh] [-f EFFWPM] [-w WPM] [-s SUFFIX] [-t TONEFRQ] "
+ "[file]\n"
+ " -E: echo mode\n"
+ " -P: prompt mode\n"
+ " -h: displays this message\n"
+ " -f: sets the Farnsworth (effective) speed in words per minute\n"
+ " -w: sets the speed in words (=\"PARIS \") per minute [%d]\n"
+ " -t: sets the output tone frequency in cycles per second [%d]\n"
+ , progname, wpm, tonefrq);
+}
+
+
+
+/* ----------------------------------------------------------------------------
+ * T H E M A I N F U N C T I O N
+ * ----------------------------------------------------------------------------
+ */
+
+
+
+int
+main(int argc, char **argv) {
+ FILE *f;
+ char *p;
+ int opt, noprint, prosgn, contd;
+ int retval = 1;
+
+#define _CLEAN_EXIT(label, exitcode) retval=(exitcode);goto label;
+
+ /* desired sndio params */
+ struct sio_par req = {
+ .bits = 16,
+ .bps = 2,
+ .le = SIO_LE_NATIVE,
+ .sig = 1,
+ .pchan = 1,
+ .rate = 44100,
+ };
+
+ /* parse options */
+ progname = argv[0];
+ suffix = NULL;
+ eflg = 0;
+ pflg = 0;
+ tonefrq = 600;
+ effwpm = -1;
+ wpm = 20;
+ f = stdin;
+
+ while ((opt = getopt(argc, argv, "EPf:hs:t:w:")) != -1) {
+ switch (opt) {
+ case 'E':
+ eflg = 1;
+ break;
+ case 'P':
+ pflg = 1;
+ eflg = 1;
+ break;
+ case 'h':
+ usage();
+ _CLEAN_EXIT(end, 0);
+ case 'f':
+ effwpm = (int)strtoul(optarg, NULL, 10);
+ break;
+ case 's':
+ suffix = optarg;
+ break;
+ case 't':
+ tonefrq = (int)strtoul(optarg, NULL, 10);
+ break;
+ case 'w':
+ wpm = (int)strtoul(optarg, NULL, 10);
+ break;
+ case '?':
+ default:
+ usage();
+ _CLEAN_EXIT(end, 1);
+ }
+ }
+ argc -= optind;
+ argv += optind;
+ if (argc == 1) {
+ f = fopen(argv[0], "r");
+ } else if (argc > 1) {
+ usage();
+ _CLEAN_EXIT(end, 1);
+ }
+
+ /* validate options */
+ if (!f) {
+ perror("error opening file");
+ _CLEAN_EXIT(end, 1);
+ }
+ if ((tonefrq < 20) || (tonefrq > 22000)) {
+ fprintf(stderr, "error: tonefrq out of range (20--22000)\n");
+ usage();
+ _CLEAN_EXIT(after_fopen, 1);
+ }
+ if ((wpm < 5) || (wpm > tonefrq / 4)) {
+ fprintf(stderr, "error: wpm out of range (5--%d)\n", tonefrq / 4);
+ usage();
+ _CLEAN_EXIT(after_fopen, 1);
+ }
+ if (effwpm == -1) {
+ effwpm = wpm;
+ }
+ if ((effwpm < 1) || (effwpm > wpm)) {
+ fprintf(stderr, "error: Farnsworth speed out of range (1--%d)\n", wpm);
+ usage();
+ _CLEAN_EXIT(after_fopen, 1);
+ }
+
+ /* get sndio handle for audio, with desired params if possible */
+ hdl = sio_open(SIO_DEVANY, SIO_PLAY, 0);
+ if (!hdl) {
+ fprintf(stderr, "sio_open\n");
+ _CLEAN_EXIT(after_fopen, 1);
+ }
+
+ sio_initpar(&par);
+ par.bits = req.bits;
+ par.bps = req.bps;
+ par.le = req.le;
+ par.sig = req.sig;
+ par.pchan = req.pchan;
+ par.rate = req.rate;
+ if (!sio_getpar(hdl, &par)) {
+ fprintf(stderr, "failed to get sio parameters\n");
+ _CLEAN_EXIT(after_sio_open, 1);
+ }
+#define _CHECK_PAR(p) \
+ if (par.p != req.p) { \
+ fprintf(stderr, \
+ "sio_getpar unexpected " #p " (requested %u, got %u)\n", \
+ req.p, par.p); \
+ _CLEAN_EXIT(after_sio_open, 1); \
+ }
+ _CHECK_PAR(bits) _CHECK_PAR(bps) _CHECK_PAR(le) _CHECK_PAR(sig)
+
+ /* calculate size and prepare audio data buffer */
+ calcsz();
+ if (!mkbuf()) {
+ _CLEAN_EXIT(after_sio_open, 1);
+ }
+
+#define _PLAYBACK_ERROR(fmt, ...) \
+ { \
+ fprintf(stderr, "error playing back: " fmt "\n" __VA_ARGS__); \
+ _CLEAN_EXIT(after_mkbuf, 1); \
+ }
+#define _DOT if (!dot()) _PLAYBACK_ERROR("sio_write dot");
+#define _DASH if (!dash()) _PLAYBACK_ERROR("sio_write dash");
+#define _CSP if (!csp()) _PLAYBACK_ERROR("sio_write csp");
+#define _WSP if (!wsp()) _PLAYBACK_ERROR("sio_write wsp");
+
+ if (!sio_start(hdl))
+ _PLAYBACK_ERROR("sio_start");
+
+ /* main loop */
+ contd = 1;
+ while (contd) {
+ /* prompt */
+ if (pflg) {
+ printf("* ");
+ }
+ if (!fgets(txbuf, TXBUFSZ, f)) {
+ /* ending, send space then suffix (if any) */
+ if (suffix) {
+ contd = 0;
+ _CSP;
+ _WSP;
+ p = suffix;
+ } else {
+ break;
+ }
+ } else {
+ /* business as usual */
+ p = txbuf;
+ }
+ /* not the most code-efficient way to deal with Morse
+ encoding, but it works. */
+ prosgn = 0;
+ for (; *p; ++p) {
+ *p = toupper(*p);
+ noprint = 0;
+ switch (*p) {
+#define _BRK if (!prosgn) { _CSP } break;
+ case 'A': _DOT _DASH _BRK
+ case 'B': _DASH _DOT _DOT _DOT _BRK
+ case 'C': _DASH _DOT _DASH _DOT _BRK
+ case 'D': _DASH _DOT _DOT _BRK
+ case 'E': _DOT _BRK
+ case 'F': _DOT _DOT _DASH _DOT _BRK
+ case 'G': _DASH _DASH _DOT _BRK
+ case 'H': _DOT _DOT _DOT _DOT _BRK
+ case 'I': _DOT _DOT _BRK
+ case 'J': _DOT _DASH _DASH _DASH _BRK
+ case 'K': _DASH _DOT _DASH _BRK
+ case 'L': _DOT _DASH _DOT _DOT _BRK
+ case 'M': _DASH _DASH _BRK
+ case 'N': _DASH _DOT _BRK
+ case 'O': _DASH _DASH _DASH _BRK
+ case 'P': _DOT _DASH _DASH _DOT _BRK
+ case 'Q': _DASH _DASH _DOT _DASH _BRK
+ case 'R': _DOT _DASH _DOT _BRK
+ case 'S': _DOT _DOT _DOT _BRK
+ case 'T': _DASH _BRK
+ case 'U': _DOT _DOT _DASH _BRK
+ case 'V': _DOT _DOT _DOT _DASH _BRK
+ case 'W': _DOT _DASH _DASH _BRK
+ case 'X': _DASH _DOT _DOT _DASH _BRK
+ case 'Y': _DASH _DOT _DASH _DASH _BRK
+ case 'Z': _DASH _DASH _DOT _DOT _BRK
+ case '1': _DOT _DASH _DASH _DASH _DASH _BRK
+ case '2': _DOT _DOT _DASH _DASH _DASH _BRK
+ case '3': _DOT _DOT _DOT _DASH _DASH _BRK
+ case '4': _DOT _DOT _DOT _DOT _DASH _BRK
+ case '5': _DOT _DOT _DOT _DOT _DOT _BRK
+ case '6': _DASH _DOT _DOT _DOT _DOT _BRK
+ case '7': _DASH _DASH _DOT _DOT _DOT _BRK
+ case '8': _DASH _DASH _DASH _DOT _DOT _BRK
+ case '9': _DASH _DASH _DASH _DASH _DOT _BRK
+ case '0': _DASH _DASH _DASH _DASH _DASH _BRK
+
+ case '?':
+ prosgn = 0; /* punctuation marks break prosigns */
+ _DOT _DOT _DASH _DASH _DOT _DOT _BRK
+ case '=':
+ prosgn = 0;
+ _DASH _DOT _DOT _DOT _DASH _BRK
+ case '/':
+ prosgn = 0;
+ _DASH _DOT _DOT _DASH _DOT _BRK
+ case ',':
+ prosgn = 0;
+ _DASH _DASH _DOT _DOT _DASH _DASH _BRK
+ case '.':
+ prosgn = 0;
+ _DOT _DASH _DOT _DASH _DOT _DASH _BRK
+
+ case '$':
+ prosgn = 1;
+ break;
+
+ case ' ':
+ case '\n':
+ prosgn = 0;
+ _WSP;
+ break;
+
+ default:
+ noprint = 1;
+ }
+ /* echo */
+ if (eflg && !noprint) {
+ fputc(*p, stdout);
+ fflush(stdout);
+ }
+ }
+ }
+
+ if (!sio_stop(hdl))
+ _PLAYBACK_ERROR("sio_stop");
+
+ printf("\n");
+ sleep(1);
+
+ retval = 0;
+
+after_mkbuf:
+ free(buf);
+
+after_sio_open:
+ sio_close(hdl);
+
+after_fopen:
+ if (f && f != stdin) {
+ fclose(f);
+ }
+
+end:
+ return retval;
+}
diff --git a/kochgen.1 b/kochgen.1
new file mode 100644
index 0000000..7cafaec
--- /dev/null
+++ b/kochgen.1
@@ -0,0 +1,101 @@
+.Dd KOCHGEN 1
+.Os
+.Sh NAME
+.Nm kochgen
+.Nd Koch training generator
+.Sh SYNOPSIS
+.Nm
+.Op Fl h
+.Op Fl M Ar wrlenmax
+.Op Fl m Ar wrlenmin
+.Op Fl n Ar nwrs
+.Op Fl o Ar ofile
+.Ar charset
+.Sh DESCRIPTION
+.Nm
+generates a list of words suitable for CW operator training using
+the Koch method.
+The intended use case of this program is for its output to be piped
+to
+.Xr cwsnd 1
+for Koch method practice.
+.Pp
+A number of words will be generated with the supplied
+.Ar charset .
+The list will be separated by newlines.
+.Pp
+.Ar charset
+is a string parameter consisting of all characters in the desired
+character set. For example, for the traditional first lesson in Koch
+training,
+.Ar charset
+can be set to 'km'. Characters can be given more weight by
+duplicating the letter in the
+.Ar charset
+string: for example, 'kmrss' would favor the letter 's' in the word
+generation.
+.Pp
+The
+.Ar charset
+parameter is mandatory and must be the last parameter supplied in the
+command line.
+.Pp
+The available options are:
+.Pp
+.Bl -tag -width Ds
+.It Fl h
+Show help message.
+.It Fl M Ar wrlenmax
+Sets the maximum number of letters in a word to
+.Ar wrlenmax .
+Defaults to 5.
+.It Fl m Ar wrlenmin
+Sets the minimum number of letters in a word to
+.Ar wrlenmin .
+Defaults to 5.
+.It Fl n Ar nwrs
+Sets the number of words to be generated. Defaults to 50.
+(To roughly set the duration of practice,
+set it to the duration in minutes times the effective/Farnsworth wpm.)
+.It Fl o Ar ofile
+Writes a copy of the output to
+.Ar ofile .
+.Sh EXAMPLES
+First lesson: student to copy 50 five-letter words with only
+K's and M's.
+.Pp
+.Dl "$ kochgen km | cwsnd"
+.Pp
+Second lesson: to add 'r' to the character set.
+Also generates the answer key.
+.Pp
+.Dl "$ kochgen -o anskey.txt kmr | cwsnd"
+.Pp
+Variant of the first lesson: vary the word lengths between 2 and 6
+characters.
+.Pp
+.Dl "$ kochgen -m 2 -M 6 km | cwsnd"
+.Sh ABOUT KOCH METHOD
+Many CW operators swear by Koch method; that is, if they can relearn
+code from scratch. Apparently it works well in developing neural
+pathways required for decoding code at speeds above 15 words per
+minute.
+.Pp
+Koch training works by exposing CW trainees to a subset of the
+standard Morse character set, played at full speed right from the
+start of the training. The training starts with only two characters
+(traditionally 'M' and 'K') in the character set. Words are generated
+from the characters in the set, then played at full speed. When the
+student can reliably copy at 90 percent accuracy or above, one
+character is added to the set. The training progresses until the
+student learns the entire character set.
+.Pp
+For self-paced study, especially when aiming to become conversational
+in Morse code, mental copying (i.e. no typing/writing) is also a skill
+worth practicing. In this case, the accuracy assessment must be done
+on the basis of honesty.
+.Sh SEE ALSO
+.Xr morse 6 ,
+.Xr cwsnd 1
+.Sh AUTHORS
+.An Samuel Wirajaya
diff --git a/kochgen.c b/kochgen.c
new file mode 100644
index 0000000..2f823ec
--- /dev/null
+++ b/kochgen.c
@@ -0,0 +1,148 @@
+/*
+
+BSD Zero Clause License
+
+Copyright (c) 2023 Samuel Wirajaya
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
+
+================================================================================
+
+ K O C H G E N
+
+================================================================================
+
+*/
+
+#include <ctype.h> /* toupper */
+#include <limits.h> /* strtoul */
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h> /* strnlen */
+#include <time.h>
+#include <unistd.h>
+
+#define VERSION "0.9.0"
+
+char *progname;
+int wrlenmax;
+int wrlenmin;
+int nwrs;
+char *charset;
+FILE *ofile;
+
+void
+usage(void) {
+ printf(
+ "Koch Training Generator (version " VERSION ")\n"
+ "usage: %s [-h] [-M WRLENMAX] [-m WRLENMIN] [-n NWRS] [-o OFILE] "
+ "CHARSET\n"
+ "CHARSET is a string containing all letters to be trained on\n"
+ "e.g. 'km' for the first lesson, 'kmr' for the second lesson,\n"
+ "etc.\n"
+ " -h: displays this message\n"
+ " -M: sets the maximum n. of letters in a word [%d]\n"
+ " -m: sets the minimum n. of letters in a word [%d]\n"
+ " -n: sets the number of words to be generated [%d]\n"
+ " -o: writes a copy of the output to OFILE\n"
+ , progname, wrlenmax, wrlenmin, nwrs);
+}
+
+void
+teeputc(int c) {
+ fputc(c, stdout);
+ if (ofile) {
+ fputc(c, ofile);
+ }
+}
+
+int
+main(int argc, char **argv) {
+ int i, j, wrlen;
+ int k, setsz;
+ int opt;
+
+ srand(time(NULL));
+
+ progname = argv[0];
+ wrlenmax = 5;
+ wrlenmin = 5;
+ nwrs = 50;
+ ofile = NULL;
+
+ while ((opt = getopt(argc, argv, "M:hm:n:o:")) != -1) {
+ switch (opt) {
+ case 'M':
+ wrlenmax = (int)strtoul(optarg, NULL, 10);
+ break;
+ case 'h':
+ usage();
+ return 0;
+ case 'm':
+ wrlenmin = (int)strtoul(optarg, NULL, 10);
+ break;
+ case 'n':
+ nwrs = (int)strtoul(optarg, NULL, 10);
+ break;
+ case 'o':
+ ofile = fopen(optarg, "w");
+ if (!ofile) {
+ perror("warning: cannot open output file");
+ }
+ break;
+ case '?':
+ default:
+ usage();
+ return 1;
+ }
+ }
+ argc -= optind;
+ argv += optind;
+ if (argc == 1) {
+ charset = argv[0];
+ } else {
+ fprintf(stderr, "error: one charset must be supplied\n");
+ usage();
+ return 1;
+ }
+
+ if ((wrlenmax < 1) || (wrlenmax > 1023)) {
+ fprintf(stderr, "error: wrlenmax out of range (1--1023)\n");
+ usage();
+ return 1;
+ }
+ if ((wrlenmin < 1) || (wrlenmin > wrlenmax)) {
+ fprintf(stderr, "error: wrlenmin out of range (1--wrlenmax)\n");
+ usage();
+ return 1;
+ }
+
+ setsz = (int)strnlen(charset, 127);
+ for (k = 0; k < setsz; ++k) {
+ charset[k] = toupper(charset[k]);
+ }
+
+ for (i = 0; i < nwrs; ++i) {
+ if (wrlenmin == wrlenmax) {
+ wrlen = wrlenmin;
+ } else {
+ wrlen = wrlenmin + rand() % (1 + wrlenmax - wrlenmin);
+ }
+ for (j = 0; j < wrlen; ++j) {
+ k = rand() % setsz;
+ teeputc(charset[k]);
+ }
+ teeputc('\n');
+ }
+
+ return 0;
+}