diff --git a/.gitignore b/.gitignore index 8a64134..e879464 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ slowrx +slowrxd rx rx-lum + +*.d +*.o +*.a diff --git a/Makefile b/Makefile index dc38a36..be0a471 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,69 @@ -CC = gcc +CC ?= gcc +AR ?= ar +RANLIB ?= ranlib -CFLAGS = -Wall -Wextra -std=gnu99 -pedantic -g -DGDK_VERSION_MIN_REQUIRED=GDK_VERSION_3_4 -GTKCFLAGS = `pkg-config --cflags gtk+-3.0` -GTKLIBS = `pkg-config --libs gtk+-3.0` +CFLAGS = -Wall -Wextra -std=gnu99 -pedantic -g + +GTKCFLAGS = $(shell pkg-config --cflags gtk+-3.0) +GTKLIBS = $(shell pkg-config --libs gtk+-3.0) + +GDCFLAGS = $(shell pkg-config --cflags gdlib) +GDLIBS = $(shell pkg-config --libs gdlib) OFLAGS = -O3 -OBJECTS = common.o modespec.o gui.o video.o vis.o sync.o pcm.o fsk.o slowrx.o +GUI_BIN = slowrx +DAEMON_BIN = slowrxd +COMMON_LIB = libslowrx.a + +TARGETS ?= $(GUI_BIN) $(DAEMON_BIN) + +COMMON_CFLAGS = $(CFLAGS) $(OFLAGS) +COMMON_LDFLAGS = -lfftw3 -lasound -lm -lpthread + +LIB_SOURCES = common.c fft.c fsk.c listen.c modespec.c sync.c pic.c pcm.c vis.c video.c +LIB_OBJECTS = $(patsubst %.c,%.o,$(LIB_SOURCES)) +LIB_DEPENDS = $(patsubst %.c,%.d,$(LIB_SOURCES)) +LIB_CFLAGS = $(COMMON_CFLAGS) + +GUI_SOURCES = config.c gui.c slowrx.c +GUI_OBJECTS = $(patsubst %.c,%.o,$(GUI_SOURCES)) +GUI_DEPENDS = $(patsubst %.c,%.d,$(GUI_SOURCES)) +GUI_CFLAGS = $(COMMON_CFLAGS) $(LIB_CFLAGS) $(GTKCFLAGS) -DGDK_VERSION_MIN_REQUIRED=GDK_VERSION_3_4 +GUI_LDFLAGS = $(COMMON_LDFLAGS) -lgthread-2.0 $(GTKLIBS) + +DAEMON_SOURCES = slowrxd.c +DAEMON_OBJECTS = $(patsubst %.c,%.o,$(DAEMON_SOURCES)) +DAEMON_DEPENDS = $(patsubst %.c,%.d,$(DAEMON_SOURCES)) +DAEMON_CFLAGS = $(COMMON_CFLAGS) $(LIB_CFLAGS) $(GDCFLAGS) +DAEMON_LDFLAGS = $(COMMON_LDFLAGS) $(GDLIBS) -all: slowrx +OBJECTS = $(GUI_OBJECTS) $(LIB_OBJECTS) $(DAEMON_OBJECTS) +DEPENDS = $(GUI_DEPENDS) $(LIB_DEPENDS) $(DAEMON_DEPENDS) -slowrx: $(OBJECTS) - $(CC) $(CFLAGS) -o $@ $(OBJECTS) $(GTKLIBS) -lfftw3 -lgthread-2.0 -lasound -lm -lpthread +all: $(TARGETS) -%.o: %.c common.h - $(CC) $(CFLAGS) $(GTKCFLAGS) $(OFLAGS) -c -o $@ $< +$(GUI_BIN): $(COMMON_LIB) $(GUI_OBJECTS) + $(CC) $(GUI_CFLAGS) -o $@ -Wl,--as-needed -Wl,--start-group $^ $(GUI_LDFLAGS) -Wl,--end-group + +$(DAEMON_BIN): $(COMMON_LIB) $(DAEMON_OBJECTS) + $(CC) $(DAEMON_CFLAGS) -o $@ -Wl,--as-needed -Wl,--start-group $^ $(DAEMON_LDFLAGS) -Wl,--end-group + +$(COMMON_LIB): $(LIB_OBJECTS) + $(AR) cr $@ $^ + $(RANLIB) $@ + +%.o: %.c + $(CC) -MM -MF $(*F).d $(OBJ_CFLAGS) $< + $(CC) $(OBJ_CFLAGS) -c -o $@ $< + +$(GUI_OBJECTS): OBJ_CFLAGS=$(GUI_CFLAGS) +$(LIB_OBJECTS): OBJ_CFLAGS=$(LIB_CFLAGS) +$(DAEMON_OBJECTS): OBJ_CFLAGS=$(DAEMON_CFLAGS) clean: - rm -f slowrx $(OBJECTS) + rm -f $(TARGETS) $(COMMON_LIB) $(OBJECTS) $(DEPENDS) + +-include $(DEPENDS) + +gui.c: aboutdialog.ui slowrx.ui diff --git a/README.md b/README.md index e1bd285..1c2e4c3 100644 --- a/README.md +++ b/README.md @@ -21,23 +21,269 @@ Features Requirements ------------ +### Common requirements + * Linux * Alsa (`libasound2-dev`) -* Gtk+ 3.4 (`libgtk-3-dev`) * FFTW 3 (`libfftw3-dev`) And, obviously: * shortwave radio with SSB -* computer with sound card +* computer with sound card capable of at least 6kHz sample rate (>24kHz + strongly recommended) * means of getting sound from radio to sound card +### For the GUI: + +* gtk+ 3.4 (`libgtk-3-dev`) + +### For the daemon: + +* libgd + Compiling --------- `make` +### GUI only + +``` +make slowrx +``` + +### Daemon only + +The daemon is experimental. + +``` +make slowrxd +``` + Running ------- +### GUI + `./slowrx` + +### Daemon + +`./slowrxd [arguments]` + +The daemon runs in the foreground, the intention is to run it beneath a daemon +service like `supervisord` or `systemd`. An example unit file for `systemd` +might be (see the +[`systemd-unit` manpage](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html) +for correct syntax): + +``` +[Unit] +Description=slowrxd + +[Service] +User=slowrxuser +ExecStart=/path/to/slowrxd [arguments] + +[Install] +WantedBy=multi-user.target +``` + +#### Daemon arguments + +These are listed with the `-h` argument: + +``` +$ ./slowrxd -h +Usage: ./slowrxd [-h] [-F] [-S] [-A inprogress.au] + [-I inprogress.png] [-L inprogress.ndjson] [-a latest.au] + [-c channel] [-d directory] [-i latest.png] + [-l latest.ndjson] [-p pcmdevice] [-r samplerate] + [-x script] + +where: + -F : disable FSK ID detection + -S : disable slant correction + -h : display this help and exit + -d : set the directory where images are kept + -A : set the in-progress audio dump path (- to disable) + -I : set the in-progress image path (- to disable) + -L : set the in-progress receive log path (- to disable) + -a : set the latest audio dump path (- to disable) + -c : set the audio channel to use, left, right or mono + -i : set the latest image path (- to disable) + -l : set the latest receive log path (- to disable) + -r : set the ALSA PCM sample rate + -p : set the ALSA PCM capture device + -x : specify a script to run on receive events +``` + +##### Paths + +`-d directory` sets the destination for files to `directory`, the path must +already exist. Default is the current working directory. All output files are +assumed to reside in the same directory (and **must** reside on the same +filesystem volume). As the image is received, it is periodically written to +the _in progress_ image path (see `-I`) as a PNG image. + +Alongside this file are two other files (unless disabled): + + * `inprogress.ndjson`: a [NDJSON](https://github.com/ndjson/ndjson-spec) log + file storing the events as the file was received. + * `inprogress.au`: a [Sun Audio](https://en.wikipedia.org/wiki/Au_file_format) + audio file containing the recording of the transmission. + +`-A`, `-I` and `-L` set the path to the audio dump, image and receive log of +the transmission currently being received. Relative to the output directory +(see `-d`) unless they start with a `/`. `-A` and `-L` may be disabled by +specifying `-` as the path. `-I` may not be disabled. + +`-a`, `-i` and `-l`: same as the upper-case counterparts, but these are for the +symbolic link to the _latest_ received image. Again, these are relative to the +output directory unless they start with a `/`. Pass `-` as the argument to +disable the corresponding symbolic link being generated. + +(**NOTE** the code that figures out what the _target_ of the symlink is, is +especially dumb and does **NOT** handle the symlink residing in a different +place to the output file.) + +When a SSTV image is fully received (after the FSK ID), the file names of the +three (or less) files currently named `inprogress` will be renamed to one of +the following forms: + + * `YYYY-mm-ddTHH-MMZ-MODE-FSKID` if the FSK ID was decoded (non-alphanumeric + characters in the FSK ID are replaced with `-`). + * `YYYY-mm-ddTHH-MMZ-MODE` if no FSK ID was detected (or it was disabled). + +The "latest" image symbolic links (`-a`, `-i` and `-l`) will then be re-created +to point to these new files. + +##### PCM input options + +`slowrxd` uses ALSA device names (see `arecord -L`). You can specify a +different PCM capture device with `-p name`. e.g. Pipewire users may want +`-p pipewire` or `-p pulse`. + +The sample rate can be adjusted with `-r`. The default is 44100 Hz for +historical reasons, however some users may want to set this to 48000 if they +have hardware that requires it (some modern sound cards do not natively support +44.1kHz) or they are using a userspace subsystem like PulseAudio, PipeWire or +JACK running at this (or any other) sample rate. + +Bare minimum sample rate required is 6kHz, and will work but deliver poor +results ([see this Mastodon thread](https://mastodon.longlandclan.id.au/@stuartl/112771433903628425) +for an example of how poor). + +`-c` selects which channel is used. By default, the left channel is used, but +if your interface requires it, you may choose the right channel, or have both +summed together (mono). Only the first letter (`l`, `m` or `r`) is used, and +case does not matter. + +##### SSTV decoder features + +By default, FSK ID detection and slant correction are enabled, you can disable +these with `-F` and `-S` respectively. + +##### Running scripts + +The daemon can run a script on events. The path to the script must include the +relative or full path to the script if it is not in `${PATH}` and must be +executable by the process running `slowrxd`. + +It will be called with 4 arguments: + + * the event being triggered (see log file format) + * the path to the image file (either the in-progress or final image) + * the path to the log file for the image + * the path to the audio file for the image + +This script **MUST** do its thing then return back to the process as soon as +possible, because it will block the SSTV receiver otherwise! + +A recommended solution to this is to write a simple shell script that launches +the real workhorse as a forked process in the background using +[`nohup`](https://en.wikipedia.org/wiki/Nohup): + +```bash +#!/bin/sh + +nohup $( dirname $( realpath $0 ) )/real-upload.sh "$@" > upload.log 2>&1 & +``` + +This script can do whatever you want: + + * upload the file to a web server for a SSTV cam + * notify another program + * post the image to social media + +…etc… + +#### The log file format + +NDJSON was used since JSON itself is relatively easy to generate from C code, +and this allows for separate JSON "packets" to be logged one per line, to the +file and still maintain valid JSON at all times. + +Each line of the NDJSON file is a JSON object storing a log record. + + * `timestamp` (required): The timestamp of the event, in milliseconds since + the 1st January 1970 (UTC). + * `type` (required): The type of log record being emitted. + * `msg` (optional): Message text string, if applicable. + +Each record type may have its own special parameters. + +##### `RECEIVE_START` records + +This indicates the start of a transmission. No special log records here. + +##### `VIS_DETECT` records + +This indicates the VIS header has been detected and decoded. + + * `code`: Raw VIS code as an integer + * `mode`: Human+Machine-readable "short" descriptor of the mode. See the + `ShortName` fields in `modespec.c`. + * `desc`: Human-readable description of the mode. See the `Name` field in + `modespec.c`. + +##### `SIG_STRENGTH` records + +This is the FFT power calculation. Zero-valued FFT buckets before and after +the signal are omitted. + + * `win`: The Hann window index being used + * `num`: the number of FFT buckets computed (usually 1024) + * `first`: the FFT bucket number of the first bucket storing non-zero data, + this will correspond to the first element of `fft`. + * `last`: the FFT bucket number of the last bucket storing non-zero data, this + will correspond to the last element of `fft`. + * `fft`: the FFT buckets between `first` and `last` (inclusive). + +All omitted buckets may be assumed to contain zeroes (`0.000000`). + +##### `IMAGE_REFRESHED` records + +These do not actually appear in the log, but instead are used exclusively with +the receive script. This event tells the receive script that the image has +been re-drawn with new image data. + +##### `FSK_DETECT` records + +Indication that the actual SSTV image has been received in full (and no slant +correction yet applied). + +##### `STATUS` records + +These indicate the status bar text that would be seen in the GTK+ UI. + +##### `FSK_RECEIVED` records + +These are emitted if a FSK ID is successfully decoded. + + * `id` is the FSK ID decoded. + +##### `RECEIVE_END` records + +Indicate that reception of this particular image has finished. diff --git a/common.c b/common.c index be4a97b..a234fff 100644 --- a/common.c +++ b/common.c @@ -1,55 +1,33 @@ -#include -#include #include +#include #include -#include - -#include -#include - -#include #include "common.h" +#include "modespec.h" +#include "pcm.h" -gboolean Abort = FALSE; -gboolean Adaptive = TRUE; -gboolean *HasSync = NULL; -gshort HedrShift = 0; -gboolean ManualActivated = FALSE; -gboolean ManualResync = FALSE; -guchar *StoredLum = NULL; - -pthread_t thread1; - -FFTStuff fft; -GuiObjs gui; -PicMeta CurrentPic; -PcmData pcm; - -GdkPixbuf *pixbuf_rx = NULL; -GdkPixbuf *pixbuf_disp = NULL; -GdkPixbuf *pixbuf_PWR = NULL; -GdkPixbuf *pixbuf_SNR = NULL; - -GtkListStore *savedstore = NULL; - -GKeyFile *config = NULL; +_Bool Abort = false; +_Bool Adaptive = true; +_Bool *HasSync = NULL; +_Bool ManualActivated = false; +_Bool ManualResync = false; +uint8_t *StoredLum = NULL; // Return the FFT bin index matching the given frequency -guint GetBin (double Freq, guint FFTLen) { - return (Freq / 44100 * FFTLen); +uint32_t GetBin (double Freq, uint32_t FFTLen) { + return (Freq / pcm.SampleRate * FFTLen); } // Sinusoid power from complex DFT coefficients -double power (fftw_complex coeff) { - return pow(coeff[0],2) + pow(coeff[1],2); +double power (double complex coeff) { + return pow(creal(coeff),2) + pow(cimag(coeff),2); } // Clip to [0..255] -guchar clip (double a) { +uint8_t clip (double a) { if (a < 0) return 0; else if (a > 255) return 255; - return (guchar)round(a); + return (uint8_t)round(a); } // Convert degrees -> radians @@ -73,143 +51,3 @@ void ensure_dir_exists(const char *dir) { } } } - -// Save current picture as PNG -void saveCurrentPic() { - GdkPixbuf *scaledpb; - GString *pngfilename; - - pngfilename = g_string_new(g_key_file_get_string(config,"slowrx","rxdir",NULL)); - g_string_append_printf(pngfilename, "/%s_%s.png", CurrentPic.timestr, ModeSpec[CurrentPic.Mode].ShortName); - printf(" Saving to %s\n", pngfilename->str); - - scaledpb = gdk_pixbuf_scale_simple (pixbuf_rx, ModeSpec[CurrentPic.Mode].ImgWidth, - ModeSpec[CurrentPic.Mode].NumLines * ModeSpec[CurrentPic.Mode].LineHeight, GDK_INTERP_HYPER); - - ensure_dir_exists(g_key_file_get_string(config,"slowrx","rxdir",NULL)); - gdk_pixbuf_savev(scaledpb, pngfilename->str, "png", NULL, NULL, NULL); - g_object_unref(scaledpb); - g_string_free(pngfilename, TRUE); -} - - -/*** Gtk+ event handlers ***/ - - -// Quit -void evt_deletewindow() { - gtk_main_quit (); -} - -// Transform the NoiseAdapt toggle state into a variable -void evt_GetAdaptive() { - Adaptive = gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON(gui.tog_adapt)); -} - -// Manual Start clicked -void evt_ManualStart() { - ManualActivated = TRUE; -} - -// Abort clicked during rx -void evt_AbortRx() { - Abort = TRUE; -} - -// Another device selected from list -void evt_changeDevices() { - - int status; - - pcm.BufferDrop = FALSE; - Abort = TRUE; - - static int init; - if (init) - pthread_join(thread1, NULL); - init = 1; - - if (pcm.handle != NULL) snd_pcm_close(pcm.handle); - - status = initPcmDevice(gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(gui.combo_card))); - - - switch(status) { - case 0: - gtk_image_set_from_stock(GTK_IMAGE(gui.image_devstatus),GTK_STOCK_YES,GTK_ICON_SIZE_SMALL_TOOLBAR); - gtk_widget_set_tooltip_text(gui.image_devstatus, "Device successfully opened"); - break; - case -1: - gtk_image_set_from_stock(GTK_IMAGE(gui.image_devstatus),GTK_STOCK_DIALOG_WARNING,GTK_ICON_SIZE_SMALL_TOOLBAR); - gtk_widget_set_tooltip_text(gui.image_devstatus, "Device was opened, but doesn't support 44100 Hz"); - break; - case -2: - gtk_image_set_from_stock(GTK_IMAGE(gui.image_devstatus),GTK_STOCK_DIALOG_ERROR,GTK_ICON_SIZE_SMALL_TOOLBAR); - gtk_widget_set_tooltip_text(gui.image_devstatus, "Failed to open device"); - break; - } - - g_key_file_set_string(config,"slowrx","device",gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(gui.combo_card))); - - pthread_create (&thread1, NULL, Listen, NULL); - -} - -// Clear received picture & metadata -void evt_clearPix() { - gdk_pixbuf_fill (pixbuf_disp, 0); - gtk_image_set_from_pixbuf(GTK_IMAGE(gui.image_rx), pixbuf_disp); - gtk_label_set_markup (GTK_LABEL(gui.label_fskid), ""); - gtk_label_set_markup (GTK_LABEL(gui.label_utc), ""); - gtk_label_set_markup (GTK_LABEL(gui.label_lastmode), ""); -} - -// Manual slant adjust -void evt_clickimg(GtkWidget *widget, GdkEventButton* event, GdkWindowEdge edge) { - static double prevx=0,prevy=0,newrate; - static gboolean secondpress=FALSE; - double x,y,dx,dy,xic; - - (void)widget; - (void)edge; - - if (event->type == GDK_BUTTON_PRESS && event->button == 1 && gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON(gui.tog_setedge))) { - - x = event->x * (ModeSpec[CurrentPic.Mode].ImgWidth / 500.0); - y = event->y * (ModeSpec[CurrentPic.Mode].ImgWidth / 500.0) / ModeSpec[CurrentPic.Mode].LineHeight; - - if (secondpress) { - secondpress=FALSE; - - dx = x - prevx; - dy = y - prevy; - - gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON(gui.tog_setedge),FALSE); - - // Adjust sample rate, if in sensible limits - newrate = CurrentPic.Rate + CurrentPic.Rate * (dx * ModeSpec[CurrentPic.Mode].PixelTime) / (dy * ModeSpec[CurrentPic.Mode].LineHeight * ModeSpec[CurrentPic.Mode].LineTime); - if (newrate > 32000 && newrate < 56000) { - CurrentPic.Rate = newrate; - - // Find x-intercept and adjust skip - xic = fmod( (x - (y / (dy/dx))), ModeSpec[CurrentPic.Mode].ImgWidth); - if (xic < 0) xic = ModeSpec[CurrentPic.Mode].ImgWidth - xic; - CurrentPic.Skip = fmod(CurrentPic.Skip + xic * ModeSpec[CurrentPic.Mode].PixelTime * CurrentPic.Rate, - ModeSpec[CurrentPic.Mode].LineTime * CurrentPic.Rate); - if (CurrentPic.Skip > ModeSpec[CurrentPic.Mode].LineTime * CurrentPic.Rate / 2.0) - CurrentPic.Skip -= ModeSpec[CurrentPic.Mode].LineTime * CurrentPic.Rate; - - // Signal the listener to exit from GetVIS() and re-process the pic - ManualResync = TRUE; - } - - } else { - secondpress = TRUE; - prevx = x; - prevy = y; - } - } else { - secondpress=FALSE; - gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON(gui.tog_setedge), FALSE); - } -} diff --git a/common.h b/common.h index 471c170..cc95687 100644 --- a/common.h +++ b/common.h @@ -3,149 +3,30 @@ #define MINSLANT 30 #define MAXSLANT 150 -#define BUFLEN 4096 +#define BUFLEN 5120 #define SYNCPIXLEN 1.5e-3 -extern gboolean Abort; -extern gboolean Adaptive; -extern gboolean *HasSync; -extern gboolean ManualActivated; -extern gboolean ManualResync; -extern guchar *StoredLum; -extern pthread_t thread1; -extern guchar VISmap[]; +#include +#include +#include -typedef struct _FFTStuff FFTStuff; -struct _FFTStuff { - double *in; - fftw_complex *out; - fftw_plan Plan1024; - fftw_plan Plan2048; -}; -extern FFTStuff fft; +extern _Bool Abort; +extern _Bool Adaptive; +extern _Bool *HasSync; +extern _Bool ManualActivated; +extern _Bool ManualResync; +extern uint8_t *StoredLum; -typedef struct _PcmData PcmData; -struct _PcmData { - snd_pcm_t *handle; - gint16 *Buffer; - int WindowPtr; - gboolean BufferDrop; -}; -extern PcmData pcm; +// Various callback function types for various events. +typedef void (*EventCallback)(void); +typedef void (*TextStatusCallback)(const char* status); +typedef void (*UpdateVUCallback)(double* Power, int FFTLen, int WinIdx); -typedef struct _GuiObjs GuiObjs; -struct _GuiObjs { - GtkWidget *button_abort; - GtkWidget *button_browse; - GtkWidget *button_clear; - GtkWidget *button_start; - GtkWidget *combo_card; - GtkWidget *combo_mode; - GtkWidget *entry_picdir; - GtkWidget *eventbox_img; - GtkWidget *frame_manual; - GtkWidget *frame_slant; - GtkWidget *grid_vu; - GtkWidget *iconview; - GtkWidget *image_devstatus; - GtkWidget *image_pwr; - GtkWidget *image_rx; - GtkWidget *image_snr; - GtkWidget *label_fskid; - GtkWidget *label_lastmode; - GtkWidget *label_utc; - GtkWidget *menuitem_about; - GtkWidget *menuitem_quit; - GtkWidget *spin_shift; - GtkWidget *statusbar; - GtkWidget *tog_adapt; - GtkWidget *tog_fsk; - GtkWidget *tog_rx; - GtkWidget *tog_save; - GtkWidget *tog_setedge; - GtkWidget *tog_slant; - GtkWidget *window_about; - GtkWidget *window_main; -}; -extern GuiObjs gui; +double power (double complex coeff); +uint8_t clip (double a); +double deg2rad (double Deg); +uint32_t GetBin (double Freq, uint32_t FFTLen); -extern GdkPixbuf *pixbuf_PWR; -extern GdkPixbuf *pixbuf_SNR; -extern GdkPixbuf *pixbuf_rx; -extern GdkPixbuf *pixbuf_disp; - -extern GtkListStore *savedstore; - -extern GKeyFile *config; - - -typedef struct _PicMeta PicMeta; -struct _PicMeta { - gshort HedrShift; - guchar Mode; - double Rate; - int Skip; - GdkPixbuf *thumbbuf; - char timestr[40]; -}; -extern PicMeta CurrentPic; - -// SSTV modes -enum { - UNKNOWN=0, - M1, M2, M3, M4, - S1, S2, SDX, - R72, R36, R24, R24BW, R12BW, R8BW, - PD50, PD90, PD120, PD160, PD180, PD240, PD290, - P3, P5, P7, - W2120, W2180 -}; - -// Color encodings -enum { - GBR, RGB, YUV, BW -}; - -typedef struct ModeSpec { - char *Name; - char *ShortName; - double SyncTime; - double PorchTime; - double SeptrTime; - double PixelTime; - double LineTime; - gushort ImgWidth; - gushort NumLines; - guchar LineHeight; - guchar ColorEnc; -} _ModeSpec; - -extern _ModeSpec ModeSpec[]; - -double power (fftw_complex coeff); -guchar clip (double a); -void createGUI (); -double deg2rad (double Deg); -double FindSync (guchar Mode, double Rate, int *Skip); -void GetFSK (char *dest); -gboolean GetVideo (guchar Mode, double Rate, int Skip, gboolean Redraw); -guchar GetVIS (); -guint GetBin (double Freq, guint FFTLen); -int initPcmDevice (); -void *Listen (); -void populateDeviceList (); -void readPcm (gint numsamples); -void saveCurrentPic(); -void setVU (double *Power, int FFTLen, int WinIdx, gboolean ShowWin); - -void evt_AbortRx (); -void evt_changeDevices (); -void evt_chooseDir (); -void evt_clearPix (); -void evt_clickimg (); -void evt_deletewindow (); -void evt_GetAdaptive (); -void evt_ManualStart (); -void evt_show_about (); +void ensure_dir_exists(const char *dir); #endif diff --git a/config.c b/config.c new file mode 100644 index 0000000..80ad962 --- /dev/null +++ b/config.c @@ -0,0 +1,40 @@ +#include +#include +#include "config.h" + +GKeyFile *config = NULL; + +void load_config_settings(GString **confpath) { + const gchar *confdir; + + // Load config + confdir = g_get_user_config_dir(); + *confpath = g_string_new(confdir); + g_string_append(*confpath, "/slowrx.ini"); + + config = g_key_file_new(); + if (g_key_file_load_from_file(config, (*confpath)->str, G_KEY_FILE_KEEP_COMMENTS, NULL)) { + + } else { + printf("No valid config file found\n"); + g_key_file_load_from_data(config, "[slowrx]\ndevice=default", -1, G_KEY_FILE_NONE, NULL); + } +} + +void save_config_settings(GString *confpath) { + FILE *ConfFile; + gchar *confdata; + gsize *keylen=NULL; + + // Save config on exit + ConfFile = fopen(confpath->str,"w"); + if (ConfFile == NULL) { + perror("Unable to open config file for writing"); + } else { + confdata = g_key_file_to_data(config,keylen,NULL); + fprintf(ConfFile,"%s",confdata); + fwrite(confdata,1,(size_t)keylen,ConfFile); + fclose(ConfFile); + } + +} diff --git a/config.h b/config.h new file mode 100644 index 0000000..8ece7ef --- /dev/null +++ b/config.h @@ -0,0 +1,12 @@ +#ifndef _CONFIG_H_ +#define _CONFIG_H_ + +#include +#include + +extern GKeyFile *config; + +void load_config_settings(GString **confpath); +void save_config_settings(GString *confpath); + +#endif diff --git a/contrib/sstv-cam-script/README.md b/contrib/sstv-cam-script/README.md new file mode 100644 index 0000000..94ac9d0 --- /dev/null +++ b/contrib/sstv-cam-script/README.md @@ -0,0 +1,30 @@ +Example SSTV CAM page script +============================ + +Requirements +------------ + +- `jq`: for decoding the NDJSON files to extract the FSK ID and mode +- `file` for figuring out the dimensions of the PNG image +- `lame` for MP3-encoding the SSTV signals +- `sox` for generating spectrograms of the SSTV signals +- `imagemagick` for generating the timestamp/mode/FSK ID data embedded in the + image (vertical text running up the left/right sides of the image). +- `netpbm` for assembling the final uploaded image +- `xz` for archiving the NDJSON files after summarisation. +- `rsync` for uploading the result + +Usage +----- + +Drop these files into your image directory and adjust to taste. Point +`slowrxd -x` at the `upload.sh` script. + +W3C validator images can be pulled from the W3C site, *if* your page actually +validates and you care to show others that it does. These two commands will +fetch the images you need. + +``` +wget -O valid-xhtml11.png https://www.w3.org/Icons/valid-xhtml11 +wget -O valid-css3.png https://jigsaw.w3.org/css-validator/images/vcss +``` diff --git a/contrib/sstv-cam-script/gallery.js b/contrib/sstv-cam-script/gallery.js new file mode 100644 index 0000000..2b084b6 --- /dev/null +++ b/contrib/sstv-cam-script/gallery.js @@ -0,0 +1,68 @@ +function main() { + var cards = document.getElementsByClassName("imgcard"); + for (var i = 0; i < cards.length; i++) { + var card = cards[i]; + var expandbtn = document.createElement("button"); + expandbtn.textContent = "zoom"; + expandbtn.addEventListener("click", expand.bind(null, card)); + card.appendChild(expandbtn); + + var img = card.getElementsByTagName("img")[0]; + if (window.location.hash == ("#" + basename(img.src))) { + expand(card); + } + } +} + +function expand(card) { + var img = card.getElementsByTagName("img")[0]; + window.location.hash = "#" + basename(img.src); + + var overlaydiv = document.createElement("div"); + var overlayimg = img.cloneNode(false); + + var overlayclose = document.createElement("button"); + overlayclose.href="#"; + overlayclose.addEventListener("click", close.bind(null, overlaydiv)); + overlayclose.appendChild(document.createTextNode("close")); + + var imgwidth = overlayimg.width; + var imgheight = overlayimg.height; + var viewportwidth = document.documentElement.clientWidth; + var viewportheight = document.documentElement.clientHeight; + + /* Try fit width */ + var h = Math.round((viewportwidth * imgheight) / imgwidth); + var w = Math.round((h * imgwidth) / imgheight); + + if ((w > viewportwidth) || (h > viewportheight)) { + /* Fit height instead */ + w = Math.round((viewportheight * imgwidth) / imgheight); + h = Math.round((w * imgheight) / imgwidth); + } + + overlayimg.width = w; + overlayimg.height = h; + + overlaydiv.classList.add("overlay"); + overlaydiv.addEventListener("click", close.bind(null, overlaydiv)); + overlaydiv.appendChild(overlayclose); + overlaydiv.appendChild(overlayimg); + card.parentElement.insertBefore(overlaydiv, card); +} + +function close(overlaydiv) { + overlaydiv.parentElement.removeChild(overlaydiv); + window.location.hash = ""; +} + +function basename(uri) { + var lastslash = uri.lastIndexOf("/"); + if (lastslash < 0) { + return uri; + } else { + return uri.substring(lastslash + 1); + } +} + +window.addEventListener("DOMContentLoaded", main); diff --git a/contrib/sstv-cam-script/gengallery.sh b/contrib/sstv-cam-script/gengallery.sh new file mode 100755 index 0000000..d567d59 --- /dev/null +++ b/contrib/sstv-cam-script/gengallery.sh @@ -0,0 +1,354 @@ +#!/bin/bash + +# gengallery: Generate a SSTV image gallery then upload it to a remote server. +# +# Requirements: +# - jq +# - file +# - lame +# - rsync +# - sox +# - imagemagick +# - netpbm +# - xz +# +# The HTML fragments for each image are generated, then the resulting fragments +# assembled into a single HTML file for upload. + +IMAGE_DIR=$( dirname $( realpath ${0} ) ) + +# Capture arguments provided by slowrxd. +SSTV_EVENT=${1} # SSTV event triggering this script +SSTV_IMAGE="$( realpath "${2}" )" # SSTV image file +SSTV_LOG="$( realpath "${3}" )" # SSTV log file (NDJSON or JSON) +SSTV_AUDIO="$( realpath "${4}" )" # SSTV audio file (Sun audio or MP3) + +# Make a note of the base name +SSTV_BASE="${SSTV_IMAGE%.png}" + +# Derive generated file names +SSTV_STRENGTH="${SSTV_BASE}.strength" +SSTV_FREQ="${SSTV_BASE}.freq" +SSTV_SPEC="${SSTV_BASE}-spec.png" +SSTV_ORIG="${SSTV_BASE}-orig.png" + +# Derive names of the latest and in-progress image files. +SSTV_IP_IMAGE="${IMAGE_DIR}/inprogress.png" +SSTV_IP_LOG="${IMAGE_DIR}/inprogress.ndjson" +SSTV_IP_AUDIO="${IMAGE_DIR}/inprogress.au" +SSTV_IP_FREQ="${IMAGE_DIR}/inprogress.freq" +SSTV_IP_STRENGTH="${IMAGE_DIR}/inprogress.strength" + +SSTV_LATEST_IMAGE="${IMAGE_DIR}/latest.png" +SSTV_LATEST_LOG="${IMAGE_DIR}/latest.ndjson" +SSTV_LATEST_AUDIO="${IMAGE_DIR}/latest.mp3" +SSTV_LATEST_FREQ="${IMAGE_DIR}/latest.freq" +SSTV_LATEST_STRENGTH="${IMAGE_DIR}/latest.strength" + +. ${IMAGE_DIR}/lib.sh + +set -x + +# Dump a HTML fragment displaying the image +# Arguments: +# 1: image alt text +# 2: image file name +# 3: optional style +# +# styles supported: +# - featured: use FEATURED_WIDTH for the image width instead of STANDARD_WIDTH +# +# Emits raw HTML +showimg() { + alt_text="${1}" + img_src="$( basename "${2}" )" + name="img${img_src%.png}" + img_file="$( realpath "${2}" )" + style="${3}" + + base="${img_file%.png}" + log_file="$( firstexistsof "${base}.json" "${base}.ndjson" )" + orig_file="${base}-orig.png" + spec_file="${base}-spec.png" + freq_file="${base}.freq" + audio_file="${base}.mp3" + + if [ ! -f "${orig_file}" ]; then + # Image may in fact be in-progress + orig_file="${img_file}" + fi + + if [ -f "${log_file}" ]; then + mode="$( logfetch "${log_file}" "mode" )" + start_ts="$( logfetch "${log_file}" "start_ts" )" + end_ts="$( logfetch "${log_file}" "end_ts" )" + fsk_id="$( logfetch "${log_file}" "fsk_id" )" + else + mode="Unknown Mode" + start_ts="" + end_ts="" + fsk_id="" + fi + + if [ -f "${freq_file}" ]; then + freq="$( cat "${freq_file}" )" + else + freq="" + fi + + eval $( imgdims "${img_file}" ) + w=${orig_w} + h=${orig_h} + + if [ -n "${w}" ] && [ -n "${h}" ]; then + if [ "${style}" = featured ]; then + # Scale the image up + out_w=${FEATURED_WIDTH} + else + # Shrink it down + out_w=${STANDARD_WIDTH} + fi + h=$(( (${orig_h} * ${out_w}) / ${orig_w} )) + w=${out_w} + fi + + if [ -n "${w}" ]; then + w_attr="width=\"${w}\"" + fi + + map="" + usemap="" + if [ -n "${h}" ]; then + if [ -f "${spec_file}" ]; then + spec_h=$(( ${h} - ((${SPECTROGRAM_HEIGHT} * ${out_w}) / ${orig_w}) )) + + map="" + map="${map}\"Original" + map="${map}\"Spectrogram\""" + map="${map}" + usemap="usemap=\"#${name}-map\"" + fi + h_attr="height=\"${h}\"" + fi + + cat < + ${map} + ${alt_text} +
    +
  • SSTV Mode: ${mode}
  • +EOF + if [ -n "${freq}" ]; then + cat <Frequency: $( formatfreq ${freq} ) +EOF + fi + cat <Started: ${start_ts} +EOF + if [ -n "${end_ts}" ]; then + cat <Finished: ${end_ts} +EOF + fi + + if [ -n "${fsk_id}" ]; then + cat <Callsign: ${fsk_id} +EOF + fi + + if [ -n "${audio_file}" ] && [ -f "${audio_file}" ]; then + cat <Transmission Audio +EOF + fi + cat < + +EOF +} + +# Is this an incoming image transmission? +if [ "${SSTV_IMAGE}" = "${SSTV_IP_IMAGE}" ]; then + # Are we transmitting? + if [ "$( getptt )" = 0 ]; then + # Capture the signal strength + str_now=$( getstrength ) + if [ -f "${SSTV_STRENGTH}" ]; then + str_prev=$( cat "${SSTV_STRENGTH}" ) + if [ ${str_now} > ${str_prev} ]; then + echo "${str_now}" > "${SSTV_STRENGTH}" + fi + else + echo "${str_now}" > "${SSTV_STRENGTH}" + fi + fi +fi + +# Are we at the end of a transmission? +if [ "${SSTV_EVENT}" = RECEIVE_END ]; then + # Sanity check, this is the latest file. + if [ "${SSTV_IMAGE}" = "$( realpath "${SSTV_LATEST_IMAGE}" )" ]; then + # It is, rename the signal strength file if present. + if [ -f "${SSTV_IP_STRENGTH}" ]; then + mv "${SSTV_IP_STRENGTH}" "${SSTV_STRENGTH}" + rm -f "${SSTV_LATEST_STRENGTH}" + ln -sv "$( basename "${SSTV_STRENGTH}" )" \ + "${SSTV_LATEST_STRENGTH}" + fi + fi + + # Capture the frequency if not yet done, this can disrupt + # reception due to quirks in rigctl, so we can't do this during + # reception. + if [ ! -f "${SSTV_FREQ}" ]; then + getfreq > "${SSTV_FREQ}" + fi + + # Generate a spectrogram of the audio if we haven't done it already. + if [ ! -f "${SSTV_SPEC}" ]; then + genspectrogram \ + "${SSTV_IMAGE}" \ + "${SSTV_AUDIO}" \ + "${SSTV_SPEC}" + fi + + # Encode the audio as MP3 if not already done + SSTV_AUDIO="$( audiotomp3 "${SSTV_AUDIO}" )" + + # Summarise the NDJSON log + SSTV_LOG="$( summarisendjsonlog "${SSTV_LOG}" )" + + # Generate the composite output image + generateoutimg "${SSTV_BASE}" +fi + +if [ "${SSTV_IMAGE}" = "${SSTV_IP_IMAGE}" ]; then + # We're receiving an image, check back in 5 seconds + refresh_in=5 +else + # No image being received, check back in 5 minutes + refresh_in=300 +fi + +# Emit the start of the web page. +# Adjust this to taste, e.g. you may or may not want to share your station +# details, that is up to you. + +cat > ${IMAGE_DIR}/index.html < + + + + $( echo "${STATION_IDENT}" | htmlescape ) + + + + + +

    $( echo "${STATION_IDENT}" | htmlescape )

    +

    + This is an experimental SSTV receiver station using slowrxd. + Please note I do not control what people transmit on SSTV + frequencies, and this service runs unsupervised. If unsavory images + are posted, the responsibility rests with the station transmitting them. +

    +
      +
    • Location: Your Location XY12ab
    • +
    • Antenna: Your antenna type
    • +
    • Radio: Your radio +
    • Radio Interface: Your radio interface +
    • Computer: Your computer
    • +
    • OS: Your OS
    • +
    • SSTV Decoder: slowrx + daemon addition
    • +
    + +

    This page will refresh in ${refresh_in} seconds.

    +
    +EOF + +if [ "${SSTV_IMAGE}" = "${SSTV_IP_IMAGE}" ]; then + cat >> ${IMAGE_DIR}/index.html < +

    Currently in progress

    + $( showimg "In progress SSTV image" \ + ${IMAGE_DIR}/inprogress.png \ + featured ) + +
    +EOF +fi + +if [ -f "${IMAGE_DIR}/latest.png" ]; then + cat >> ${IMAGE_DIR}/index.html < +

    Last received

    + $( showimg "Last received SSTV image" \ + ${IMAGE_DIR}/latest.png \ + featured ) + +
    +EOF +fi + +cat >> ${IMAGE_DIR}/index.html < +

    Previously received

    +EOF + +latest_img="$( realpath ${IMAGE_DIR}/latest.png )" +for img in $( ls -1r ${IMAGE_DIR}/20*-orig.png ${IMAGE_DIR}/20*-orig.jpg ); do + ext="${img##*.}" + imgbase="${img%-orig.${ext}}" + if [ "${imgbase}.png" != "${latest_img}" ]; then + if [ ! -f "${imgbase}.htmlpart" ]; then + showimg "Previous image $( basename ${img} )" \ + "${imgbase}.${ext}" \ + > "${imgbase}.htmlpart" + fi + cat "${imgbase}.htmlpart" >> ${IMAGE_DIR}/index.html + fi +done +cat >> ${IMAGE_DIR}/index.html < +
    + + + +EOF + +rsync -zaHS --partial --delete-before \ + --exclude .\*.swp \ + --exclude \*.sh \ + --exclude \*.au \ + --exclude \*.ndjson \ + --exclude \*.htmlpart \ + --exclude \*.log \ + --exclude \*.freq \ + --exclude \*.strength \ + --exclude \*.xz \ + --exclude \*.pam \ + --exclude archive/ \ + you@yourserver.example.com:/var/www/sites/sstv.example.com/htdocs/ diff --git a/contrib/sstv-cam-script/lib.sh b/contrib/sstv-cam-script/lib.sh new file mode 100644 index 0000000..60400e5 --- /dev/null +++ b/contrib/sstv-cam-script/lib.sh @@ -0,0 +1,419 @@ +#!/bin/bash + +# Widths of standard and featured images +FEATURED_WIDTH=720 +STANDARD_WIDTH=320 + +# Station locator, you can use a script like +# https://gist.github.com/sjlongland/2fc720b1cfab77deddd646dced1f5418 +# run from cron to write the current location to a file, or just manually +# create it yourself. +if [ -f "${IMAGE_DIR}/locator.txt" ]; then + STATION_LOC="$( cat "${IMAGE_DIR}/locator.txt" )" +else + # Or hard-code it here. + STATION_LOC="XY12ab" +fi + +# Station identity +STATION_IDENT="N0CALL SSTV RX @ ${STATION_LOC}" + +# Information text font -- use any TTF font you like, but the full path +# must be known here. See generateoutimg for how this is used, you may +# need to tweak things there too if you don't use the font I used here. +INFO_IMG_FONT=/usr/share/fonts/truetype/terminus/TerminusTTF-4.46.0.ttf + +# Spectrogram graph will be a fixed height, and scaled width-wise to match the +# width of the output image. +SPECTROGRAM_GRAPH_HEIGHT=80 + +# SoX seems to add this size to the dimension you give it for legends, +# axis labels, etc. +SPECTROGRAM_WIDTH_PAD=144 +SPECTROGRAM_HEIGHT_PAD=78 + +# Given this information, we can work out exactly how high a spectrogram will +# be including all padding. +SPECTROGRAM_HEIGHT=$(( \ + ${SPECTROGRAM_GRAPH_HEIGHT} + ${SPECTROGRAM_HEIGHT_PAD} \ +)) + +# Capture the frequency from rigctl. Tweak this for your set-up, or if you +# only ever receive on one frequency, replace this with an `echo ${FREQ}`. +# The frequency is given in Hz. +getfreq() { + rigctl -m 2 -r localhost:4532 --vfo f VFOA +} + +# Parse a time-stamp into ISO-8601 format +# Input is a timestamp in milliseconds since 1st Jan 1970. +# Returns empty string if the input is empty. +parsets() { + if [ -n "$1" ]; then + TZ=UTC date --date @$(( $1 / 1000 )) -Isec + fi +} + +# HTML-escape a string +htmlescape() { + sed -e 's/&/\&/; s//\>/;' +} + +# Format a frequency in Hz as kHz. +# Arguments: +# 1: frequency in Hz +# 2: text to use if $1 is empty +formatfreq() { + if [ -n "${1}" ]; then + freq_khz=$(( ${1} / 1000 )) + freq_hz=$(( ${1} % 1000 )) + printf "%6d.%03d kHz" ${freq_khz} ${freq_hz} + else + echo "${2}" + fi +} + +# Retrieve the dimensions of an image. Use with eval, +# defines orig_w and orig_h. +imgdims() { + file "$( realpath "$1" )" | cut -f 2 -d, \ + | sed -e 's/^ \+/orig_w=/; s/ x / orig_h=/; s/[^0-9]\+$//g;' +} + +# Fetch a data field from a log, either a NDJSON full log, or a summarised +# JSON log. +# Arguments: +# 1: log file (NDJSON or JSON file) +# 2: field requested +# +# Fields supported: +# +# - mode: the mode description +# - start_ts: ISO-8601 start of transmission +# - end_ts: ISO-8601 end of transmission, empty string if in progress +# - fsk_id: FSK ID from station, empty if not detected +logfetch() { + case "${1}" in + *.ndjson) + case "${2}" in + mode) + head -n 1 "${1}" | jq -r .desc + ;; + start_ts) + parsets $( head -n 1 "${1}" | jq -r .timestamp ) + ;; + end_ts) + parsets $( grep RECEIVE_END "${1}" | jq -r .timestamp ) + ;; + fsk_id) + grep FSK_RECEIVED "${1}" | jq -r .id + ;; + esac + ;; + *.json) + case "${2}" in + mode) + jq -r .VIS_DETECT.desc "${1}" + ;; + start_ts) + parsets $( jq -r .VIS_DETECT.timestamp "${1}" ) + ;; + end_ts) + parsets $( jq -r '.RECEIVE_END.timestamp // ""' "${1}" ) + ;; + fsk_id) + jq -r '.FSK_RECEIVED.id // ""' "${1}" \ + | tr -d '\n\t' | tr -C '[:alnum:]_/-' '?' + ;; + esac + ;; + esac +} + +# Summarise a log file. This picks out the critical fields needed for +# rendering a gallery out of a NDJSON image and generates a JSON file from it. +# The input NDJSON is then compressed with XZ for archival purposes. +# +# If the file already is a plain JSON, nothing is done. +# +# Symbolic links are not modified. +# +# Arguments: +# - input log file +# +# Returns +# - output log file +summarisendjsonlog() { + input="${1}" + ext="${input##*.}" + + if [ -f "${input}" ] && [ ! -L "${input}" ] \ + && [ "${ext}" = ndjson ] + then + output="${input%${ext}}json" + sed -e '1 s/^/[/ ; 2,$ s/^/,/; $ s/$/]/' "${input}" \ + | jq '[ .[] | select(.type == "VIS_DETECT" or .type == "FSK_RECEIVED" or .type == "RECEIVE_END") | {"key":.type, "value":.} ] | from_entries' \ + > "${output}" + xz -9 "${input}" + + # Update the latest symlink + if [ -L "${IMAGE_DIR}/latest.ndjson" ] \ + && [ "$( readlink "${IMAGE_DIR}/latest.ndjson" )" \ + = "$( basename "${input}" )" ] + then + rm "${IMAGE_DIR}/latest.json" \ + "${IMAGE_DIR}/latest.ndjson" + ln -s "$( basename "${output}" )" \ + "${IMAGE_DIR}/latest.json" + fi + + echo "${output}" + else + echo "${input}" + fi +} + +# Get the VFO +getvfo() { + rigctl -m 2 -r localhost:4532 --skipinit v | grep -v ^rigctld +} + +# Capture the frequency from rigctl +getfreq() { + rigctl -m 2 -r localhost:4532 --skipinit --vfo f $( getvfo ) | grep -v ^rigctld +} + +# Capture the PTT state +getptt() { + rigctl -m 2 -r localhost:4532 --skipinit --vfo t $( getvfo ) | grep -v ^rigctld +} + +# Capture the signal strength from rigctl +getstrength() { + rigctl -m 2 -r localhost:4532 --skipinit --vfo l RAWSTR VFOCUR | grep -v ^rigctld +} + +# Decode the signal strength from rigctl +parsestrength() { + case "${1}" in + [0123456789]) echo "S${1}" + ;; + 10) echo "S9+10dB" + ;; + 1[12345]) echo "S9+20dB" + ;; + *) + echo "${1}?" + esac +} + +# Generate a spectrogram of the audio recording +# 1: image file from the transmission +# 2: audio file of the transmission +# 3: spectrogram file name +genspectrogram() { + orig_img="${1}" + audio_dump="${2}" + out_img="${3}" + + eval $( imgdims "${orig_img}" ) + + # Maintain the aspect ratio of the original image. + # output image will be 158px higher (96 + 62px for + # headers/labels) + out_h=$(( ${orig_h} + ${SPECTROGRAM_HEIGHT} )) + out_w=$(( (${orig_w} * ${out_h}) / ${orig_h} )) + + # Generate the spectrogram + spec_w=$(( ${out_w} - ${SPECTROGRAM_WIDTH_PAD} )) + sox "${audio_dump}" -n rate 6k spectrogram \ + -x ${spec_w} -y 80 -z 48 -Z -16 -o "${out_img}" +} + +# Return the first file given that exists. +firstexistsof() { + for f in "$@"; do + if [ -f "${f}" ]; then + echo "${f}" + break + fi + done +} + +# Generate the output image that embeds the original transmitted image +# along with the spectrogram and some data of when the file was received, +# the frequency, and the output mode. +# +# Arguments: +# 1: base name of the image being generated without extension. +# - input image will be ${1}-orig.png +# - spectrogram will be ${1}-spec.png +# - log will be ${1}.json or ${1}.ndjson +# - frequency will be kept in ${1}.freq (plain text with frequency in Hz) +# - output image will be ${1}.png +generateoutimg() { + input_img="${1}-orig.png" + spec_img="${1}-spec.png" + freq_file="${1}.freq" + strength_file="${1}.strength" + output_img="${1}.png" + left_img="${1}-left.png" + right_img="${1}-right.png" + log_file="$( firstexistsof "${1}.json" "${1}.ndjson" )" + + # If we have an output file, but no original, then assume + # that *is* the original and rename it so we don't clobber it! + if [ -f "${output_img}" ] && [ ! -f "${input_img}" ]; then + mv -v "${output_img}" "${input_img}" + fi + + # Pick up the dimensions of the original image + eval $( imgdims "${input_img}" ) + + # Generate left-side info image + infotxt="" + if [ -f "${freq_file}" ]; then + freq=$( cat "${freq_file}" ) + infotxt="${infotxt}Received on $( \ + formatfreq ${freq} "unknown frequency" )\n" + else + freq="" + infotxt="${infotxt}Received " + fi + + # e.g. 2024-07-14T04-22Z-S1-VK3EME.png + timestamp=$( basename $( realpath "${input_img}" ) \ + | sed -e 's/^\(20..-..-..\)T\(..\)-\(..\)Z.*$/\1T\2:\3Z/' ) + infotxt="${infotxt}at ${timestamp}\n" + infotxt="${infotxt}by ${STATION_IDENT}" + + # Generate a timestamp image go on the left side of the image. + # The positions of the three lines may need tweaking if you use a + # different font. + convert -size ${orig_h}x56 canvas:none \ + -font ${INFO_IMG_FONT} \ + -pointsize 16 -fill white \ + -draw "text 0,16 '$( echo -e "${infotxt}" | tail -n +1 | head -n 1 )'" \ + -draw "text 0,32 '$( echo -e "${infotxt}" | tail -n +2 | head -n 1 )'" \ + -draw "text 0,48 '$( echo -e "${infotxt}" | tail -n +3 | head -n 1 )'" \ + -rotate -90 \ + "${left_img}" + + # Generate right-side info image + mode=$( logfetch "${log_file}" "mode" ) + fsk_id=$( logfetch "${log_file}" "fsk_id" ) + + infotxt="" + if [ -f "${strength_file}" ]; then + strength="$( parsestrength $( cat ${strength_file} ) )" + infotxt="Signal strength: ${strength}" + + # You can make your own meter images, or feel free + # to grab them from here: + # https://static.vk4msl.com/sstv/meter/ + case "${strength}" in + S0) strength_img="${IMAGE_DIR}/meter/meter-0.png" + ;; + S1) strength_img="${IMAGE_DIR}/meter/meter-1.png" + ;; + S2) strength_img="${IMAGE_DIR}/meter/meter-2.png" + ;; + S3) strength_img="${IMAGE_DIR}/meter/meter-3.png" + ;; + S4) strength_img="${IMAGE_DIR}/meter/meter-4.png" + ;; + S5) strength_img="${IMAGE_DIR}/meter/meter-5.png" + ;; + S6) strength_img="${IMAGE_DIR}/meter/meter-6.png" + ;; + S7) strength_img="${IMAGE_DIR}/meter/meter-7.png" + ;; + S8) strength_img="${IMAGE_DIR}/meter/meter-8.png" + ;; + S9) strength_img="${IMAGE_DIR}/meter/meter-9.png" + ;; + S9+10dB) + strength_img="${IMAGE_DIR}/meter/meter-9p.png" + ;; + *) strength_img="${IMAGE_DIR}/meter/meter-9pp.png" + ;; + esac + + if [ ! -f "${strength_img}.pam" ]; then + pngtopam -alpha "${strength_img}" > "${strength_img}.pam" + fi + + strength_cmd="pamcomp -align right -valign top ${strength_img}.pam - " + else + strength_cmd="cat" + fi + + infotxt="${infotxt}\nSSTV Mode: ${mode}" + if [ -n "${fsk_id}" ]; then + infotxt="${infotxt}\nFSK ID: ${fsk_id}" + else + infotxt="${infotxt}\nFSK ID not known" + fi + + # Generate a timestamp image go on the left side of the image. + convert -size ${orig_h}x56 canvas:none \ + -font ${INFO_IMG_FONT} \ + -pointsize 16 -fill white \ + -draw "text 0,16 '$( echo -e "${infotxt}" | tail -n +1 | head -n 1 )'" \ + -draw "text 0,32 '$( echo -e "${infotxt}" | tail -n +2 | head -n 1 )'" \ + -draw "text 0,48 '$( echo -e "${infotxt}" | tail -n +3 | head -n 1 )'" \ + -rotate -90 \ + "${right_img}" + + # Convert to netpbm PAM format + for f in "${input_img}" "${spec_img}"; do + pngtopam ${f} > ${f}.pam + done + # Convert info images with alpha + for f in "${left_img}" "${right_img}"; do + pngtopam -alpha ${f} > ${f}.pam + done + + # Concatenate and generate final output PNG + pamcat -topbottom -black -jcenter \ + "${input_img}.pam" "${spec_img}.pam" \ + | pamcomp -align left -valign top "${left_img}.pam" - \ + | pamcomp -align right -valign top "${right_img}.pam" - \ + | ${strength_cmd} \ + | pamtopng -interlace > "${output_img}" + + # Remove temporary PAM files and images + rm -f *.pam ${left_img} ${right_img} +} + +# Convert an AU format audio file to MP3 if required. If `latest.au` points +# to this file, this is replaced by a `latest.mp3` pointing at the new file. +# Arguments: +# 1: audio file to convert, if it does not have an .mp3 extension, it will be +# encoded as MP3 and the original removed. +# +# Returns the name of the MP3 +audiotomp3() { + input="${1}" + ext="${input##*.}" + if [ "${ext}" != "mp3" ]; then + output="${input%${ext}}mp3" + lame -S "${input}" "${output}" && rm -f "${input}" + + # Update the latest symlink + if [ -L "${IMAGE_DIR}/latest.au" ] \ + && [ "$( readlink "${IMAGE_DIR}/latest.au" )" \ + = "$( basename "${input}" )" ] + then + rm "${IMAGE_DIR}/latest.mp3" \ + "${IMAGE_DIR}/latest.au" + ln -s "$( basename "${output}" )" \ + "${IMAGE_DIR}/latest.mp3" + fi + + # Report the name of the new audio file + echo "${output}" + else + echo "${input}" + fi +} diff --git a/contrib/sstv-cam-script/style.css b/contrib/sstv-cam-script/style.css new file mode 100644 index 0000000..a236a26 --- /dev/null +++ b/contrib/sstv-cam-script/style.css @@ -0,0 +1,64 @@ +.imgcard { + display: inline-block; + padding: 1em; + margin: 20px 20px 20px 20px; + border: 1px solid black; + border-radius: 20px; + background: white; + filter: drop-shadow(20px 20px 10px black); +} + +.imgcard > img { + border-left: 5px solid #666; + border-top: 5px solid #666; + border-right: 5px solid #ccc; + border-bottom: 5px solid #ccc; + border-radius: 5px; +} + +.imgcard > button { + position: fixed; + z-index: 20; + right: 0px; + top: 0px; + padding: 1em; + opacity: 0.4; + font-weight: bold; + font-size: large; + border-radius: 20px; +} + +.imgcard > button:hover { + opacity: 1.0; +} + +#inprogress, #lastrx, #previous { + text-align: center; +} + +.overlay { + z-index: 10; + position: fixed; + left: 0px; + top: 0px; + width: 100vw; + height: 100vh; + text-align: center; + background-color: #333; +} + +.overlay > button { + z-index: 20; + position: fixed; + right: 0px; + top: 0px; + padding: 1em; + opacity: 0.4; + font-weight: bold; + font-size: large; + border-radius: 20px; +} + +.overlay > button:hover { + opacity: 1.0; +} diff --git a/contrib/sstv-cam-script/upload.sh b/contrib/sstv-cam-script/upload.sh new file mode 100755 index 0000000..a0e29c6 --- /dev/null +++ b/contrib/sstv-cam-script/upload.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +# Wrapper script that wraps gengallery.sh safely, preventing it from +# blocking the slowrxd process. + +nohup $( dirname $( realpath $0 ) )/gengallery.sh "$@" > gengallery.log 2>&1 & diff --git a/fft.c b/fft.c new file mode 100644 index 0000000..fb01664 --- /dev/null +++ b/fft.c @@ -0,0 +1,26 @@ +#include +#include + +#include "fft.h" + +FFTStuff fft; + +int fft_init(void) { + fft.in = fftw_alloc_real(FFT_BUFFER_SZ); + if (fft.in == NULL) { + perror("GetVideo: Unable to allocate memory for FFT"); + return -1; + } + fft.out = fftw_alloc_complex(FFT_BUFFER_SZ); + if (fft.out == NULL) { + perror("GetVideo: Unable to allocate memory for FFT"); + fftw_free(fft.in); + return -1; + } + memset(fft.in, 0, sizeof(double) * FFT_BUFFER_SZ); + + fft.PlanHalf = fftw_plan_dft_r2c_1d(FFT_HALF_SZ, fft.in, fft.out, FFTW_ESTIMATE); + fft.PlanFull = fftw_plan_dft_r2c_1d(FFT_FULL_SZ, fft.in, fft.out, FFTW_ESTIMATE); + + return 0; +} diff --git a/fft.h b/fft.h new file mode 100644 index 0000000..13180ad --- /dev/null +++ b/fft.h @@ -0,0 +1,18 @@ +#ifndef _FFT_H_ +#define _FFT_H_ + +#define FFT_BUFFER_SZ (2048) +#define FFT_HALF_SZ (FFT_BUFFER_SZ/2) +#define FFT_FULL_SZ (FFT_BUFFER_SZ) + +typedef struct _FFTStuff FFTStuff; +struct _FFTStuff { + double *in; + fftw_complex *out; + fftw_plan PlanHalf; + fftw_plan PlanFull; +}; +extern FFTStuff fft; + +int fft_init(void); +#endif diff --git a/fsk.c b/fsk.c index a1c27b2..c44082a 100644 --- a/fsk.c +++ b/fsk.c @@ -1,31 +1,55 @@ #include +#include #include -#include #include +#include + #include #include "common.h" +#include "fft.h" +#include "fsk.h" +#include "pcm.h" +#include "pic.h" + +#define FSK_FFT_LEN (FFT_FULL_SZ) + +/* 11ms approximately in frames; half of 22ms bit period */ +#define FSK_11MS_FRAMES PCM_MS_FRAMES(11) +#define FSK_11MS_SAMPLES (FSK_11MS_FRAMES*2) + +/* + * 5.5ms in samples; which is half FSK_11MS_SAMPLES. + * GCC should be smart enough to realise the *2 and /2 cancel. + */ +#define FSK_5M5S_SAMPLES (FSK_11MS_SAMPLES/2) + +/* + * Set a limit of 5 seconds for a FSK. That allows for ~227 bits of FSK + * data or approximately 34 characters for an ID. That should be plenty! + */ +#define FSK_TIMEOUT PCM_MS_FRAMES(5000) /* - * * Decode FSK ID * - * * The FSK IDs are 6-bit bytes, LSB first, 45.45 baud (22 ms/bit), 1900 Hz = 1, 2100 Hz = 0 - * * Text data starts with 20 2A and ends in 01 - * * Add 0x20 and the data becomes ASCII - * + * - The FSK IDs are 6-bit bytes, LSB first, 45.45 baud (22 ms/bit), 1900 Hz = 1, 2100 Hz = 0 + * - Text data starts with 20 2A and ends in 01 + * - Add 0x20 and the data becomes ASCII */ -void GetFSK (char *dest) { +void GetFSK (char* const dest, uint8_t dest_sz) { - guint FFTLen = 2048, i=0, LoBin, HiBin, MidBin, TestNum=0, TestPtr=0; - guchar Bit = 0, AsciiByte = 0, BytePtr = 0, TestBits[24] = {0}, BitPtr=0; - double HiPow,LoPow,Hann[970]; - gboolean InSync = FALSE; + uint32_t i=0, LoBin, HiBin, MidBin, TestNum=0, TestPtr=0; + uint8_t Bit = 0, AsciiByte = 0, BytePtr = 0, TestBits[24] = {0}, BitPtr=0; + double HiPow,LoPow,Hann[FSK_11MS_SAMPLES]; + _Bool InSync = false; + + uint32_t remain = FSK_TIMEOUT; // Bit-reversion lookup table - static const guchar BitRev[] = { + static const uint8_t BitRev[] = { 0x00, 0x20, 0x10, 0x30, 0x08, 0x28, 0x18, 0x38, 0x04, 0x24, 0x14, 0x34, 0x0c, 0x2c, 0x1c, 0x3c, 0x02, 0x22, 0x12, 0x32, 0x0a, 0x2a, 0x1a, 0x3a, @@ -35,32 +59,44 @@ void GetFSK (char *dest) { 0x03, 0x23, 0x13, 0x33, 0x0b, 0x2b, 0x1b, 0x3b, 0x07, 0x27, 0x17, 0x37, 0x0f, 0x2f, 0x1f, 0x3f }; - for (i = 0; i < FFTLen; i++) fft.in[i] = 0; + for (i = 0; i < FSK_FFT_LEN; i++) fft.in[i] = 0; // Create 22ms Hann window - for (i = 0; i < 970; i++) Hann[i] = 0.5 * (1 - cos( 2 * M_PI * i / 969.0 ) ); + for (i = 0; i < FSK_11MS_SAMPLES; i++) { + Hann[i] = 0.5 * (1 - cos( 2 * M_PI * i / ((double)(FSK_11MS_SAMPLES-1)) ) ); + } + + while ( remain > 0 ) { + // Figure out how much data to read? If not in sync, use 5.5ms steps. + uint32_t read_sz = (InSync ? FSK_11MS_SAMPLES: FSK_5M5S_SAMPLES); - while ( TRUE ) { + // Read data from DSP: half the number of samples if not in sync. + readPcm(read_sz); - // Read data from DSP - readPcm(InSync ? 970: 485); + if (remain > read_sz) { + remain -= read_sz; + } else { + remain = 0; + } - if (pcm.WindowPtr < 485) { - pcm.WindowPtr += (InSync ? 970 : 485); + if (pcm.WindowPtr < (int32_t)FSK_5M5S_SAMPLES) { + pcm.WindowPtr += read_sz; continue; } // Apply Hann window - for (i = 0; i < 970; i++) fft.in[i] = pcm.Buffer[pcm.WindowPtr+i- 485] * Hann[i]; + for (i = 0; i < FSK_11MS_SAMPLES; i++) { + fft.in[i] = pcm.Buffer[pcm.WindowPtr+i- FSK_5M5S_SAMPLES] * Hann[i]; + } - pcm.WindowPtr += (InSync ? 970 : 485); + pcm.WindowPtr += read_sz; // FFT of last 22 ms - fftw_execute(fft.Plan2048); + fftw_execute(fft.PlanFull); - LoBin = GetBin(1900+CurrentPic.HedrShift, FFTLen)-1; - MidBin = GetBin(2000+CurrentPic.HedrShift, FFTLen); - HiBin = GetBin(2100+CurrentPic.HedrShift, FFTLen)+1; + LoBin = GetBin(1900+CurrentPic.HedrShift, FSK_FFT_LEN)-1; + MidBin = GetBin(2000+CurrentPic.HedrShift, FSK_FFT_LEN); + HiBin = GetBin(2100+CurrentPic.HedrShift, FSK_FFT_LEN)+1; LoPow = 0; HiPow = 0; @@ -82,7 +118,7 @@ void GetFSK (char *dest) { for (i=0; i<12; i++) TestNum |= TestBits[(TestPtr - (23-i*2)) % 24] << (11-i); if (BitRev[(TestNum >> 6) & 0x3f] == 0x20 && BitRev[TestNum & 0x3f] == 0x2a) { - InSync = TRUE; + InSync = true; AsciiByte = 0; BitPtr = 0; BytePtr = 0; @@ -96,15 +132,17 @@ void GetFSK (char *dest) { if (++BitPtr == 6) { if (AsciiByte < 0x0d || BytePtr > 9) break; - dest[BytePtr] = AsciiByte + 0x20; + if (BytePtr < dest_sz) { + dest[BytePtr] = AsciiByte + 0x20; + } BitPtr = 0; AsciiByte = 0; BytePtr ++; } } - } - dest[BytePtr] = '\0'; - + if (BytePtr < dest_sz) { + dest[BytePtr] = '\0'; + } } diff --git a/fsk.h b/fsk.h new file mode 100644 index 0000000..c3fcee3 --- /dev/null +++ b/fsk.h @@ -0,0 +1,8 @@ +#ifndef _FSK_H_ +#define _FSK_H_ + +#include + +void GetFSK (char* const dest, uint8_t dest_sz); + +#endif diff --git a/gui.c b/gui.c index 63a0d17..b3848f3 100644 --- a/gui.c +++ b/gui.c @@ -6,6 +6,31 @@ #include #include "common.h" +#include "config.h" +#include "gui.h" +#include "listen.h" +#include "modespec.h" +#include "pcm.h" +#include "pic.h" + +static void evt_chooseDir (); +static void evt_show_about (); +static void evt_AbortRx (); +static void evt_changeDevices (); +static void evt_clearPix (); +static void evt_clickimg (); +static void evt_deletewindow (); +static void evt_GetAdaptive (); +static void evt_ManualStart (); + +GuiObjs gui; + +GdkPixbuf *pixbuf_rx = NULL; +GdkPixbuf *pixbuf_disp = NULL; +GdkPixbuf *pixbuf_PWR = NULL; +GdkPixbuf *pixbuf_SNR = NULL; + +GtkListStore *savedstore = NULL; void createGUI() { @@ -20,6 +45,8 @@ void createGUI() { gui.button_clear = GTK_WIDGET(gtk_builder_get_object(builder,"button_clear")); gui.button_start = GTK_WIDGET(gtk_builder_get_object(builder,"button_start")); gui.combo_card = GTK_WIDGET(gtk_builder_get_object(builder,"combo_card")); + gui.combo_rate = GTK_WIDGET(gtk_builder_get_object(builder,"combo_rate")); + gui.combo_channel = GTK_WIDGET(gtk_builder_get_object(builder,"combo_channel")); gui.combo_mode = GTK_WIDGET(gtk_builder_get_object(builder,"combo_mode")); gui.entry_picdir = GTK_WIDGET(gtk_builder_get_object(builder,"entry_picdir")); gui.eventbox_img = GTK_WIDGET(gtk_builder_get_object(builder,"eventbox_img")); @@ -52,6 +79,8 @@ void createGUI() { g_signal_connect (gui.button_clear, "clicked", G_CALLBACK(evt_clearPix), NULL); g_signal_connect (gui.button_start, "clicked", G_CALLBACK(evt_ManualStart), NULL); g_signal_connect (gui.combo_card, "changed", G_CALLBACK(evt_changeDevices), NULL); + g_signal_connect (gui.combo_rate, "changed", G_CALLBACK(evt_changeDevices), NULL); + g_signal_connect (gui.combo_channel, "changed", G_CALLBACK(evt_changeDevices), NULL); g_signal_connect (gui.eventbox_img, "button-press-event",G_CALLBACK(evt_clickimg), NULL); g_signal_connect (gui.menuitem_quit, "activate", G_CALLBACK(evt_deletewindow), NULL); g_signal_connect (gui.menuitem_about,"activate", G_CALLBACK(evt_show_about), NULL); @@ -83,8 +112,25 @@ void createGUI() { } +void showStatusbarMessage(const char* message) { + gdk_threads_enter(); + gtk_statusbar_push( GTK_STATUSBAR(gui.statusbar), 0, message ); + gdk_threads_leave(); +} + +void showPCMError(const char *error) { + gtk_widget_set_tooltip_text(gui.image_devstatus, error); +} + +void showPCMDropWarning(void) { + gdk_threads_enter(); + gtk_image_set_from_stock(GTK_IMAGE(gui.image_devstatus),GTK_STOCK_DIALOG_WARNING,GTK_ICON_SIZE_SMALL_TOOLBAR); + gtk_widget_set_tooltip_text(gui.image_devstatus, "Device is dropping samples"); + gdk_threads_leave(); +} + // Draw signal level meters according to given values -void setVU (double *Power, int FFTLen, int WinIdx, gboolean ShowWin) { +void setVU (double *Power, int FFTLen, int WinIdx) { int x,y, W=100, H=30; guchar *pixelsPWR, *pixelsSNR, *pPWR, *pSNR; unsigned int rowstridePWR,rowstrideSNR, LoBin, HiBin, i; @@ -120,8 +166,8 @@ void setVU (double *Power, int FFTLen, int WinIdx, gboolean ShowWin) { } } - LoBin = (int)((W-1-x)*(6000/W)/44100.0 * FFTLen); - HiBin = (int)((W -x)*(6000/W)/44100.0 * FFTLen); + LoBin = (int)((W-1-x)*(6000/W)/((double)pcm.SampleRate) * FFTLen); + HiBin = (int)((W -x)*(6000/W)/((double)pcm.SampleRate) * FFTLen); logpow = 0; for (i=LoBin; istr); + + scaledpb = gdk_pixbuf_scale_simple (pixbuf_rx, ModeSpec[CurrentPic.Mode].ImgWidth, + ModeSpec[CurrentPic.Mode].NumLines * ModeSpec[CurrentPic.Mode].LineHeight, GDK_INTERP_HYPER); + + ensure_dir_exists(g_key_file_get_string(config,"slowrx","rxdir",NULL)); + gdk_pixbuf_savev(scaledpb, pngfilename->str, "png", NULL, NULL, NULL); + g_object_unref(scaledpb); + g_string_free(pngfilename, TRUE); +} + +// Populate the GUI device list box with the available PCM devices. +void populateDeviceList() { + int card; + char *cardname; + int numcards, row; + + + gdk_threads_enter(); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(gui.combo_card), "default"); + gdk_threads_leave(); + + numcards = 0; + card = -1; + row = 0; + do { + snd_card_next(&card); + if (card != -1) { + row++; + snd_card_get_name(card,&cardname); + gdk_threads_enter(); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(gui.combo_card), cardname); + char *dev = g_key_file_get_string(config,"slowrx","device",NULL); + if (dev == NULL || strcmp(cardname, dev) == 0) + gtk_combo_box_set_active(GTK_COMBO_BOX(gui.combo_card), row); + + gdk_threads_leave(); + numcards++; + } + } while (card != -1); + + if (numcards == 0) { + perror("No sound devices found!\n"); + exit(EXIT_FAILURE); + } +} + +static void evt_chooseDir() { GtkWidget *dialog; dialog = gtk_file_chooser_dialog_new ("Select folder", GTK_WINDOW(gui.window_main), @@ -170,7 +270,144 @@ void evt_chooseDir() { gtk_widget_destroy (dialog); } -void evt_show_about() { +static void evt_show_about() { gtk_dialog_run(GTK_DIALOG(gui.window_about)); gtk_widget_hide(gui.window_about); } + +// Quit +static void evt_deletewindow() { + gtk_main_quit (); +} + +// Transform the NoiseAdapt toggle state into a variable +static void evt_GetAdaptive() { + Adaptive = gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON(gui.tog_adapt)); +} + +// Manual Start clicked +static void evt_ManualStart() { + ManualActivated = TRUE; +} + +// Abort clicked during rx +static void evt_AbortRx() { + Abort = TRUE; +} + +// Another device selected from list +static void evt_changeDevices() { + + int status; + unsigned long rate; + unsigned char channel; + gchar* str; + + pcm.BufferDrop = FALSE; + Abort = TRUE; + + static int init; + if (init) + WaitForListenerStop(); + init = 1; + + if (pcm.handle != NULL) snd_pcm_close(pcm.handle); + + str = gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(gui.combo_channel)); + switch(str[0]) { + case 'L': channel = PCM_CH_LEFT; + break; + case 'R': channel = PCM_CH_RIGHT; + break; + default: + case 'M': channel = PCM_CH_MONO; + break; + } + g_free(str); + + str = gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(gui.combo_rate)); + rate = strtoul(str, NULL, 10); + g_free(str); + + status = initPcmDevice(gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(gui.combo_card)), rate, channel); + + + switch(status) { + case 0: + gtk_image_set_from_stock(GTK_IMAGE(gui.image_devstatus),GTK_STOCK_YES,GTK_ICON_SIZE_SMALL_TOOLBAR); + gtk_widget_set_tooltip_text(gui.image_devstatus, "Device successfully opened"); + break; + case -1: + gtk_image_set_from_stock(GTK_IMAGE(gui.image_devstatus),GTK_STOCK_DIALOG_WARNING,GTK_ICON_SIZE_SMALL_TOOLBAR); + gtk_widget_set_tooltip_text(gui.image_devstatus, "Device was opened, but doesn't support selected sample rate"); + break; + case -2: + gtk_image_set_from_stock(GTK_IMAGE(gui.image_devstatus),GTK_STOCK_DIALOG_ERROR,GTK_ICON_SIZE_SMALL_TOOLBAR); + gtk_widget_set_tooltip_text(gui.image_devstatus, "Failed to open device"); + break; + } + + g_key_file_set_string(config,"slowrx","device",gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(gui.combo_card))); + + StartListener(); + +} + +// Clear received picture & metadata +static void evt_clearPix() { + gdk_pixbuf_fill (pixbuf_disp, 0); + gtk_image_set_from_pixbuf(GTK_IMAGE(gui.image_rx), pixbuf_disp); + gtk_label_set_markup (GTK_LABEL(gui.label_fskid), ""); + gtk_label_set_markup (GTK_LABEL(gui.label_utc), ""); + gtk_label_set_markup (GTK_LABEL(gui.label_lastmode), ""); +} + +// Manual slant adjust +static void evt_clickimg(GtkWidget *widget, GdkEventButton* event, GdkWindowEdge edge) { + static double prevx=0,prevy=0,newrate; + static gboolean secondpress=FALSE; + double x,y,dx,dy,xic; + + (void)widget; + (void)edge; + + if (event->type == GDK_BUTTON_PRESS && event->button == 1 && gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON(gui.tog_setedge))) { + + x = event->x * (ModeSpec[CurrentPic.Mode].ImgWidth / 500.0); + y = event->y * (ModeSpec[CurrentPic.Mode].ImgWidth / 500.0) / ModeSpec[CurrentPic.Mode].LineHeight; + + if (secondpress) { + secondpress=FALSE; + + dx = x - prevx; + dy = y - prevy; + + gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON(gui.tog_setedge),FALSE); + + // Adjust sample rate, if in sensible limits + newrate = CurrentPic.Rate + CurrentPic.Rate * (dx * ModeSpec[CurrentPic.Mode].PixelTime) / (dy * ModeSpec[CurrentPic.Mode].LineHeight * ModeSpec[CurrentPic.Mode].LineTime); + if (newrate > 32000 && newrate < 56000) { + CurrentPic.Rate = newrate; + + // Find x-intercept and adjust skip + xic = fmod( (x - (y / (dy/dx))), ModeSpec[CurrentPic.Mode].ImgWidth); + if (xic < 0) xic = ModeSpec[CurrentPic.Mode].ImgWidth - xic; + CurrentPic.Skip = fmod(CurrentPic.Skip + xic * ModeSpec[CurrentPic.Mode].PixelTime * CurrentPic.Rate, + ModeSpec[CurrentPic.Mode].LineTime * CurrentPic.Rate); + if (CurrentPic.Skip > ModeSpec[CurrentPic.Mode].LineTime * CurrentPic.Rate / 2.0) + CurrentPic.Skip -= ModeSpec[CurrentPic.Mode].LineTime * CurrentPic.Rate; + + // Signal the listener to exit from GetVIS() and re-process the pic + ManualResync = TRUE; + } + + } else { + secondpress = TRUE; + prevx = x; + prevy = y; + } + } else { + secondpress=FALSE; + gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON(gui.tog_setedge), FALSE); + } +} diff --git a/gui.h b/gui.h new file mode 100644 index 0000000..d5024d9 --- /dev/null +++ b/gui.h @@ -0,0 +1,58 @@ +#ifndef _GUI_H_ +#define _GUI_H_ + +extern GdkPixbuf *pixbuf_PWR; +extern GdkPixbuf *pixbuf_SNR; +extern GdkPixbuf *pixbuf_rx; +extern GdkPixbuf *pixbuf_disp; + +extern GtkListStore *savedstore; + +typedef struct _GuiObjs GuiObjs; +struct _GuiObjs { + GtkWidget *button_abort; + GtkWidget *button_browse; + GtkWidget *button_clear; + GtkWidget *button_start; + GtkWidget *combo_card; + GtkWidget *combo_rate; + GtkWidget *combo_channel; + GtkWidget *combo_mode; + GtkWidget *entry_picdir; + GtkWidget *eventbox_img; + GtkWidget *frame_manual; + GtkWidget *frame_slant; + GtkWidget *grid_vu; + GtkWidget *iconview; + GtkWidget *image_devstatus; + GtkWidget *image_pwr; + GtkWidget *image_rx; + GtkWidget *image_snr; + GtkWidget *label_fskid; + GtkWidget *label_lastmode; + GtkWidget *label_utc; + GtkWidget *menuitem_about; + GtkWidget *menuitem_quit; + GtkWidget *spin_shift; + GtkWidget *statusbar; + GtkWidget *tog_adapt; + GtkWidget *tog_fsk; + GtkWidget *tog_rx; + GtkWidget *tog_save; + GtkWidget *tog_setedge; + GtkWidget *tog_slant; + GtkWidget *window_about; + GtkWidget *window_main; +}; +extern GuiObjs gui; + +void createGUI (); +void setVU (double *Power, int FFTLen, int WinIdx); +void showPCMError (const char *error); +void showPCMDropWarning(void); +void populateDeviceList (); +void saveCurrentPic(); + +void showStatusbarMessage(const char* message); + +#endif diff --git a/listen.c b/listen.c new file mode 100644 index 0000000..ae29753 --- /dev/null +++ b/listen.c @@ -0,0 +1,175 @@ +/* + * slowrx - an SSTV decoder + * * * * * * * * * * * * * * + * + */ + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include "common.h" +#include "fsk.h" +#include "listen.h" +#include "modespec.h" +#include "pcm.h" +#include "pic.h" +#include "sync.h" +#include "video.h" +#include "vis.h" + +static pthread_t listener_thread; +TextStatusCallback OnListenerStatusChange; +EventCallback OnListenerWaiting; +EventCallback OnListenerReceivedManual; +EventCallback OnListenerReceiveStarted; +EventCallback OnListenerReceiveFSK; +EventCallback OnListenerAutoSlantCorrect; +EventCallback OnListenerReceiveFinished; +TextStatusCallback OnListenerReceivedFSKID; +_Bool ListenerAutoSlantCorrect; +_Bool ListenerEnableFSKID; +struct tm *ListenerReceiveStartTime = NULL; + +void StartListener(void) { + pthread_create(&listener_thread, NULL, Listen, NULL); +} + +void WaitForListenerStop(void) { + pthread_join(listener_thread, NULL); +} + +// The thread that listens to VIS headers and calls decoders etc +void *Listen() { + + uint8_t Mode=0; + time_t timet; + _Bool Finished; + char id[20]; + + while (true) { + if (OnListenerWaiting) { + OnListenerWaiting(); + } + + pcm.WindowPtr = 0; + snd_pcm_prepare(pcm.handle); + snd_pcm_start (pcm.handle); + Abort = false; + + do { + + // Wait for VIS + Mode = GetVIS(); + + // Stop listening on ALSA error + if (Abort) pthread_exit(NULL); + + // If manual resync was requested, redraw image + if (ManualResync) { + ManualResync = false; + snd_pcm_drop(pcm.handle); + printf("getvideo at %.2f skip %d\n",CurrentPic.Rate,CurrentPic.Skip); + GetVideo(CurrentPic.Mode, CurrentPic.Rate, CurrentPic.Skip, true); + if (OnListenerReceivedManual) { + OnListenerReceivedManual(); + } + pcm.WindowPtr = 0; + snd_pcm_prepare(pcm.handle); + snd_pcm_start (pcm.handle); + } + + } while (Mode == 0); + + // Start reception + + CurrentPic.Rate = (double)pcm.SampleRate; + CurrentPic.Mode = Mode; + + printf(" ==== %s ====\n", ModeSpec[CurrentPic.Mode].Name); + + // Store time of reception + timet = time(NULL); + ListenerReceiveStartTime = gmtime(&timet); + strftime(CurrentPic.timestr, sizeof(CurrentPic.timestr)-1,"%Y%m%d-%H%M%Sz", ListenerReceiveStartTime); + + + // Allocate space for cached Lum + free(StoredLum); + StoredLum = calloc( (int)((ModeSpec[CurrentPic.Mode].LineTime * ModeSpec[CurrentPic.Mode].NumLines + 1) * pcm.SampleRate), sizeof(uint8_t)); + if (StoredLum == NULL) { + perror("Listen: Unable to allocate memory for Lum"); + exit(EXIT_FAILURE); + } + + // Allocate space for sync signal + HasSync = calloc((int)(ModeSpec[CurrentPic.Mode].LineTime * ModeSpec[CurrentPic.Mode].NumLines / (13.0/pcm.SampleRate) +1), sizeof(_Bool)); + if (HasSync == NULL) { + perror("Listen: Unable to allocate memory for sync signal"); + exit(EXIT_FAILURE); + } + + // Get video + if (OnListenerStatusChange) { + OnListenerStatusChange("Receiving video..."); + } + if (OnListenerReceiveStarted) { + OnListenerReceiveStarted(); + } + printf(" getvideo @ %.1f Hz, Skip %d, HedrShift %+d Hz\n", (double)pcm.SampleRate, 0, CurrentPic.HedrShift); + + Finished = GetVideo(CurrentPic.Mode, pcm.SampleRate, 0, false); + + if (OnListenerReceiveFSK) { + OnListenerReceiveFSK(); + } + + id[0] = '\0'; + + if (Finished && ListenerEnableFSKID && OnListenerReceivedFSKID) { + if (OnListenerStatusChange) { + OnListenerStatusChange("Receiving FSK ID..."); + } + GetFSK(id, sizeof(id)); + printf(" FSKID \"%s\"\n",id); + OnListenerReceivedFSKID(id); + } + + snd_pcm_drop(pcm.handle); + + if (Finished && ListenerAutoSlantCorrect) { + + // Fix slant + //setVU(0,6); + if (OnListenerStatusChange) { + OnListenerStatusChange("Calculating slant..."); + } + if (OnListenerAutoSlantCorrect) { + OnListenerAutoSlantCorrect(); + } + printf(" FindSync @ %.1f Hz\n",CurrentPic.Rate); + CurrentPic.Rate = FindSync(CurrentPic.Mode, CurrentPic.Rate, &CurrentPic.Skip); + + // Final image + printf(" getvideo @ %.1f Hz, Skip %d, HedrShift %+d Hz\n", CurrentPic.Rate, CurrentPic.Skip, CurrentPic.HedrShift); + GetVideo(CurrentPic.Mode, CurrentPic.Rate, CurrentPic.Skip, true); + } + + free (HasSync); + HasSync = NULL; + + if (OnListenerReceiveFinished) { + OnListenerReceiveFinished(); + } + + } +} diff --git a/listen.h b/listen.h new file mode 100644 index 0000000..646a8d9 --- /dev/null +++ b/listen.h @@ -0,0 +1,23 @@ +#ifndef _LISTEN_H_ +#define _LISTEN_H_ + +#include + +extern TextStatusCallback OnListenerStatusChange; +extern EventCallback OnListenerWaiting; +extern EventCallback OnListenerReceivedManual; +extern EventCallback OnListenerReceiveStarted; +extern EventCallback OnListenerReceiveFSK; +extern EventCallback OnListenerAutoSlantCorrect; +extern EventCallback OnListenerReceiveFinished; +extern TextStatusCallback OnListenerReceivedFSKID; +extern _Bool ListenerAutoSlantCorrect; +extern _Bool ListenerEnableFSKID; +extern struct tm *ListenerReceiveStartTime; + +void *Listen (); + +void StartListener(void); +void WaitForListenerStop(void); + +#endif diff --git a/modespec.c b/modespec.c index ff9d7b4..6a9303a 100644 --- a/modespec.c +++ b/modespec.c @@ -1,11 +1,4 @@ -#include -#include -#include -#include - -#include - -#include "common.h" +#include "modespec.h" /* * Mode specifications @@ -34,7 +27,7 @@ * */ -_ModeSpec ModeSpec[] = { +const _ModeSpec ModeSpec[] = { [M1] = { // N7CXI, 2000 .Name = "Martin M1", @@ -131,9 +124,9 @@ _ModeSpec ModeSpec[] = { .Name = "Robot 72", .ShortName = "R72", .SyncTime = 9e-3, - .PorchTime = 3e-3, - .SeptrTime = 4.7e-3, - .PixelTime = 0.2875e-3, + .PorchTime = 3.0e-3, + .SeptrTime = 4.5e-3 + 1.5e-3, + .PixelTime = 0.215625e-3, .LineTime = 300e-3, .ImgWidth = 320, .NumLines = 240, @@ -204,7 +197,22 @@ _ModeSpec ModeSpec[] = { .NumLines = 120, .LineHeight = 2, .ColorEnc = BW }, - + + [W260] = { + // Reverse-engineered by taking half the W2120 pixel time as an educated guess then + // tweaking the generated image timings until QSSTV decoded them without slant. -- VK4MSL + .Name = "Wraase SC-2 60", + .ShortName = "W260", + .SyncTime = 5.5225e-3, + .PorchTime = 0.0e-3, + .SeptrTime = 0.5e-3, + .PixelTime = 0.2425859375e-3, + .LineTime = 240.405e-3, + .ImgWidth = 320, + .NumLines = 256, + .LineHeight = 1, + .ColorEnc = RGB }, + [W2120] = { // KB4YZ, 1999 .Name = "Wraase SC-2 120", .ShortName = "W2120", @@ -371,15 +379,26 @@ _ModeSpec ModeSpec[] = { * */ -// 0 1 2 3 4 5 6 7 8 9 A B C D E F - -guchar VISmap[] = { 0, 0, R8BW, 0, R24, 0, R12BW,0, R36, 0, R24BW,0, R72, 0, 0, 0, // 0 - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 1 - M4, 0, 0, 0, M3, 0, 0, 0, M2, 0, 0, 0, M1, 0, 0, 0, // 2 - 0, 0, 0, 0, 0, 0, 0, W2180,S2, 0, 0, 0, S1, 0, 0, W2120, // 3 - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, SDX, 0, 0, 0, // 4 - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, PD50,PD290,PD120, // 5 - PD180,PD240,PD160,PD90,0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 6 - 0, P3, P5, P7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; // 7 - -// 0 1 2 3 4 5 6 7 8 9 A B C D E F +// 0 1 2 3 4 5 6 7 8 9 A B C D E F + +const uint8_t VISmap[] = { + // Normal (even) parity + 0, 0, R8BW, 0, R24, 0, 0, 0, R36, 0, R24BW,0, R72, 0, 0, 0, // 0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 1 + M4, 0, 0, 0, M3, 0, 0, 0, M2, 0, 0, 0, M1, 0, 0, 0, // 2 + 0, 0, 0, 0, 0, 0, 0, W2180,S2, 0, 0, 0, S1, 0, 0, W2120, // 3 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, SDX, 0, 0, 0, // 4 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, PD50,PD290,PD120, // 5 + PD180,PD240,PD160,PD90,0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 6 + 0, P3, P5, P7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 7 + // Inverted (odd) parity + 0, 0, 0, 0, 0, 0, R12BW,0, 0, 0, 0, 0, 0, 0, 0, 0, // 8 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 9 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // A + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, W260, 0, 0, 0, 0, // B + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // C + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // D + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // E + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; // F + +// 0 1 2 3 4 5 6 7 8 9 A B C D E F diff --git a/modespec.h b/modespec.h new file mode 100644 index 0000000..5219939 --- /dev/null +++ b/modespec.h @@ -0,0 +1,43 @@ +#ifndef _MODESPEC_H_ +#define _MODESPEC_H_ + +#include + +// SSTV modes +enum { + UNKNOWN=0, + M1, M2, M3, M4, + S1, S2, SDX, + R72, R36, R24, R24BW, R12BW, R8BW, + PD50, PD90, PD120, PD160, PD180, PD240, PD290, + P3, P5, P7, + W260, W2120, W2180 +}; + +// Color encodings +enum { + GBR, RGB, YUV, BW +}; + +// VIS map +extern const uint8_t VISmap[]; + +#define VIS_PARITY_ODD (1 << 7) + +typedef struct ModeSpec { + char *Name; + char *ShortName; + double SyncTime; + double PorchTime; + double SeptrTime; + double PixelTime; + double LineTime; + uint16_t ImgWidth; + uint16_t NumLines; + uint8_t LineHeight; + uint8_t ColorEnc; +} _ModeSpec; + +extern const _ModeSpec ModeSpec[]; + +#endif diff --git a/pcm.c b/pcm.c index db97df1..4125df7 100644 --- a/pcm.c +++ b/pcm.c @@ -1,25 +1,27 @@ #include #include #include - -#include +#include #include #include #include "common.h" +#include "pcm.h" /* * Stuff related to sound card capture * */ +PcmData pcm; + // Capture fresh PCM data to buffer -void readPcm(gint numsamples) { +void readPcm(int32_t numsamples) { - int samplesread, i; - gint32 tmp[BUFLEN]; // Holds one or two 16-bit channels, will be ANDed to single channel + int32_t samplesread, i; + int32_t tmp[BUFLEN]; // Holds one or two 16-bit channels, will be ANDed to single channel samplesread = snd_pcm_readi(pcm.handle, tmp, (pcm.WindowPtr == 0 ? BUFLEN : numsamples)); @@ -29,8 +31,10 @@ void readPcm(gint numsamples) { printf("ALSA: buffer overrun\n"); else if (samplesread < 0) { printf("ALSA error %d (%s)\n", samplesread, snd_strerror(samplesread)); - gtk_widget_set_tooltip_text(gui.image_devstatus, "ALSA error"); - Abort = TRUE; + if (pcm.OnPCMAbort) { + pcm.OnPCMAbort("ALSA Error"); + } + Abort = true; pthread_exit(NULL); } else @@ -38,95 +42,98 @@ void readPcm(gint numsamples) { // On first appearance of error, update the status icon if (!pcm.BufferDrop) { - gdk_threads_enter(); - gtk_image_set_from_stock(GTK_IMAGE(gui.image_devstatus),GTK_STOCK_DIALOG_WARNING,GTK_ICON_SIZE_SMALL_TOOLBAR); - gtk_widget_set_tooltip_text(gui.image_devstatus, "Device is dropping samples"); - gdk_threads_leave(); - pcm.BufferDrop = TRUE; + if (pcm.OnPCMDrop) { + pcm.OnPCMDrop(); + } + pcm.BufferDrop = true; } } + /* + * Channel selection: + * - For the left channel, we mask and sign-extend the lower 16-bits. + * - For the right channel, we arithmetically right-shift 16-bits. + * - For mono, we sum the left and right channels, divide by two then clamp. + */ + for (i = 0; i < samplesread; i++) { + switch (pcm.Channel) { + case PCM_CH_LEFT: + tmp[i] &= 0x0000ffff; + // Sign-extend! + if (tmp[i] & 0x00008000) { + tmp[i] |= 0xffff0000; + } + break; + case PCM_CH_MONO: + tmp[i] >>= 16; + break; + default: + { + const int16_t right = (int16_t)(tmp[i] >> 16); + const int16_t left = (int16_t)(tmp[i] & 0x0000ffff); + int32_t mono = (left + right) / 2; + if (mono > INT16_MAX) { + tmp[i] = INT16_MAX; + } else if (mono < INT16_MIN) { + tmp[i] = INT16_MIN; + } else { + tmp[i] = mono; + } + } + break; + } + } + if (pcm.WindowPtr == 0) { // Fill buffer on first run for (i=0; i + +#include +#include + +typedef struct _PcmData PcmData; +struct _PcmData { + snd_pcm_t *handle; + int16_t *Buffer; + TextStatusCallback OnPCMAbort; + EventCallback OnPCMDrop; + void (*PCMReadCallback)(int32_t numsamples, const int16_t* samples); + int32_t WindowPtr; + uint16_t SampleRate; + uint8_t Channel; + _Bool BufferDrop; +}; +extern PcmData pcm; + +#define PCM_RES_SUCCESS (0) +#define PCM_RES_SUBOPTIMAL (-1) +#define PCM_RES_FAILURE (-2) + +#define PCM_CH_MONO (0) +#define PCM_CH_LEFT (1) +#define PCM_CH_RIGHT (2) + +/* Compute the number of frames for the given time in msec, rounded to the nearest frame */ +#define PCM_MS_FRAMES(ms) (((((uint32_t)(ms)) * pcm.SampleRate) + 500) / 1000) + +int32_t initPcmDevice (const char *wanteddevname, uint16_t samplerate, uint8_t channel); +void readPcm (int32_t numsamples); + +#endif diff --git a/pic.c b/pic.c new file mode 100644 index 0000000..69ffd5a --- /dev/null +++ b/pic.c @@ -0,0 +1,3 @@ +#include "pic.h" + +PicMeta CurrentPic; diff --git a/pic.h b/pic.h new file mode 100644 index 0000000..a1875af --- /dev/null +++ b/pic.h @@ -0,0 +1,18 @@ +#ifndef _PIC_H_ +#define _PIC_H_ + +#include + +#define PIC_TIMESTR_SZ (40) + +typedef struct _PicMeta PicMeta; +struct _PicMeta { + double Rate; + int32_t Skip; + int16_t HedrShift; + uint8_t Mode; + char timestr[PIC_TIMESTR_SZ]; +}; +extern PicMeta CurrentPic; + +#endif diff --git a/slowrx.c b/slowrx.c index d784fd2..c99b894 100644 --- a/slowrx.c +++ b/slowrx.c @@ -19,233 +19,224 @@ #include #include "common.h" +#include "config.h" +#include "fft.h" +#include "gui.h" +#include "listen.h" +#include "modespec.h" +#include "pcm.h" +#include "pic.h" +#include "video.h" +#include "vis.h" + +static const char *fsk_id; +static GdkPixbuf *thumbbuf; +static guchar *pixels, Mode; +static int rowstride; + +static void onVisIdentified(void) { + gdk_threads_enter(); + gtk_combo_box_set_active (GTK_COMBO_BOX(gui.combo_mode), VISmap[VIS]-1); + gtk_spin_button_set_value (GTK_SPIN_BUTTON(gui.spin_shift), CurrentPic.HedrShift); + + // Synchronise tog_rx with VisAutoStart + VisAutoStart = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gui.tog_rx)); + + // Manual start: update the VIS parameters from the UI + if (ManualActivated) { + int selmode, i; + + gdk_threads_enter(); + gtk_widget_set_sensitive( gui.frame_manual, FALSE ); + gtk_widget_set_sensitive( gui.combo_card, FALSE ); + gdk_threads_leave(); + + selmode = gtk_combo_box_get_active (GTK_COMBO_BOX(gui.combo_mode)) + 1; + CurrentPic.HedrShift = gtk_spin_button_get_value_as_int (GTK_SPIN_BUTTON(gui.spin_shift)); + VIS = 0; + for (i=0; i<0x80; i++) { + if (VISmap[i] == selmode) { + VIS = i; + break; + } + } + } -// The thread that listens to VIS headers and calls decoders etc -void *Listen() { + gdk_threads_leave(); +} - char rctime[8]; +static void onVideoInitBuffer(guchar mode) { + Mode = mode; + g_object_unref(pixbuf_rx); + pixbuf_rx = gdk_pixbuf_new (GDK_COLORSPACE_RGB, FALSE, 8, ModeSpec[Mode].ImgWidth, ModeSpec[Mode].NumLines); + gdk_pixbuf_fill(pixbuf_rx, 0); +} - guchar Mode=0; - struct tm *timeptr = NULL; - time_t timet; - gboolean Finished; - char id[20]; - GtkTreeIter iter; +static void onVideoStartRedraw(void) { + rowstride = gdk_pixbuf_get_rowstride (pixbuf_rx); + pixels = gdk_pixbuf_get_pixels(pixbuf_rx); - while (TRUE) { - - gdk_threads_enter (); - gtk_widget_set_sensitive (gui.grid_vu, TRUE); - gtk_widget_set_sensitive (gui.button_abort, FALSE); - gtk_widget_set_sensitive (gui.button_clear, TRUE); - gdk_threads_leave (); - - pcm.WindowPtr = 0; - snd_pcm_prepare(pcm.handle); - snd_pcm_start (pcm.handle); - Abort = FALSE; - - do { - - // Wait for VIS - Mode = GetVIS(); - - // Stop listening on ALSA error - if (Abort) pthread_exit(NULL); - - // If manual resync was requested, redraw image - if (ManualResync) { - ManualResync = FALSE; - snd_pcm_drop(pcm.handle); - printf("getvideo at %.2f skip %d\n",CurrentPic.Rate,CurrentPic.Skip); - GetVideo(CurrentPic.Mode, CurrentPic.Rate, CurrentPic.Skip, TRUE); - if (gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON(gui.tog_save))) - saveCurrentPic(); - pcm.WindowPtr = 0; - snd_pcm_prepare(pcm.handle); - snd_pcm_start (pcm.handle); - } + g_object_unref(pixbuf_disp); + pixbuf_disp = gdk_pixbuf_scale_simple(pixbuf_rx, 500, + 500.0/ModeSpec[Mode].ImgWidth * ModeSpec[Mode].NumLines * ModeSpec[Mode].LineHeight, GDK_INTERP_BILINEAR); - } while (Mode == 0); + gdk_threads_enter(); + gtk_image_set_from_pixbuf(GTK_IMAGE(gui.image_rx), pixbuf_disp); + gdk_threads_leave(); - // Start reception - - CurrentPic.Rate = 44100; - CurrentPic.Mode = Mode; +} - printf(" ==== %s ====\n", ModeSpec[CurrentPic.Mode].Name); +static void onVideoWritePixel(gushort x, gushort y, guchar r, guchar g, guchar b) { + guchar *p = &pixels[(y * rowstride) + (x * 3)]; + p[0] = r; + p[1] = g; + p[2] = b; +} - // Store time of reception - timet = time(NULL); - timeptr = gmtime(&timet); - strftime(CurrentPic.timestr, sizeof(CurrentPic.timestr)-1,"%Y%m%d-%H%M%Sz", timeptr); - +static void onVideoRefresh(void) { + // Scale and update image + g_object_unref(pixbuf_disp); + pixbuf_disp = gdk_pixbuf_scale_simple(pixbuf_rx, 500, + 500.0 / ModeSpec[Mode].ImgWidth * ModeSpec[Mode].NumLines * ModeSpec[Mode].LineHeight, GDK_INTERP_BILINEAR); - // Allocate space for cached Lum - free(StoredLum); - StoredLum = calloc( (int)((ModeSpec[CurrentPic.Mode].LineTime * ModeSpec[CurrentPic.Mode].NumLines + 1) * 44100), sizeof(guchar)); - if (StoredLum == NULL) { - perror("Listen: Unable to allocate memory for Lum"); - exit(EXIT_FAILURE); - } + gdk_threads_enter(); + gtk_image_set_from_pixbuf(GTK_IMAGE(gui.image_rx), pixbuf_disp); + gdk_threads_leave(); +} - // Allocate space for sync signal - HasSync = calloc((int)(ModeSpec[CurrentPic.Mode].LineTime * ModeSpec[CurrentPic.Mode].NumLines / (13.0/44100) +1), sizeof(gboolean)); - if (HasSync == NULL) { - perror("Listen: Unable to allocate memory for sync signal"); - exit(EXIT_FAILURE); - } - - // Get video - strftime(rctime, sizeof(rctime)-1, "%H:%Mz", timeptr); - gdk_threads_enter (); - gtk_label_set_text (GTK_LABEL(gui.label_fskid), ""); - gtk_widget_set_sensitive (gui.frame_manual, FALSE); - gtk_widget_set_sensitive (gui.frame_slant, FALSE); - gtk_widget_set_sensitive (gui.combo_card, FALSE); - gtk_widget_set_sensitive (gui.button_abort, TRUE); - gtk_widget_set_sensitive (gui.button_clear, FALSE); - gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON(gui.tog_setedge), FALSE); - gtk_statusbar_push (GTK_STATUSBAR(gui.statusbar), 0, "Receiving video..." ); - gtk_label_set_markup (GTK_LABEL(gui.label_lastmode), ModeSpec[CurrentPic.Mode].Name); - gtk_label_set_markup (GTK_LABEL(gui.label_utc), rctime); - gdk_threads_leave (); - printf(" getvideo @ %.1f Hz, Skip %d, HedrShift %+d Hz\n", 44100.0, 0, CurrentPic.HedrShift); - - Finished = GetVideo(CurrentPic.Mode, 44100, 0, FALSE); - - gdk_threads_enter (); - gtk_widget_set_sensitive (gui.button_abort, FALSE); - gdk_threads_leave (); - - id[0] = '\0'; - - if (Finished && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gui.tog_fsk))) { - gdk_threads_enter (); - gtk_statusbar_push (GTK_STATUSBAR(gui.statusbar), 0, "Receiving FSK ID..." ); - gdk_threads_leave (); - GetFSK(id); - printf(" FSKID \"%s\"\n",id); - gdk_threads_enter (); - gtk_label_set_text (GTK_LABEL(gui.label_fskid), id); - gdk_threads_leave (); - } +static void onListenerWaiting(void) { + gdk_threads_enter (); + gtk_widget_set_sensitive (gui.grid_vu, TRUE); + gtk_widget_set_sensitive (gui.button_abort, FALSE); + gtk_widget_set_sensitive (gui.button_clear, TRUE); + gdk_threads_leave (); +} - snd_pcm_drop(pcm.handle); - - if (Finished && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gui.tog_slant))) { - - // Fix slant - //setVU(0,6); - gdk_threads_enter (); - gtk_statusbar_push (GTK_STATUSBAR(gui.statusbar), 0, "Calculating slant..." ); - gtk_widget_set_sensitive (gui.grid_vu, FALSE); - gdk_threads_leave (); - printf(" FindSync @ %.1f Hz\n",CurrentPic.Rate); - CurrentPic.Rate = FindSync(CurrentPic.Mode, CurrentPic.Rate, &CurrentPic.Skip); - - // Final image - printf(" getvideo @ %.1f Hz, Skip %d, HedrShift %+d Hz\n", CurrentPic.Rate, CurrentPic.Skip, CurrentPic.HedrShift); - GetVideo(CurrentPic.Mode, CurrentPic.Rate, CurrentPic.Skip, TRUE); - } +static void onListenerReceivedManual(void) { + if (gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON(gui.tog_save))) + saveCurrentPic(); +} + +static void onListenerReceiveFSK(void) { + gdk_threads_enter (); + gtk_widget_set_sensitive (gui.button_abort, FALSE); + + // Refresh ListenerEnableFSKID and ListenerAutoSlantCorrect while we're here + ListenerEnableFSKID = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gui.tog_fsk)); + ListenerAutoSlantCorrect = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gui.tog_slant)); - free (HasSync); - HasSync = NULL; - - // Add thumbnail to iconview - CurrentPic.thumbbuf = gdk_pixbuf_scale_simple (pixbuf_rx, 100, - 100.0/ModeSpec[CurrentPic.Mode].ImgWidth * ModeSpec[CurrentPic.Mode].NumLines * ModeSpec[CurrentPic.Mode].LineHeight, GDK_INTERP_HYPER); - gdk_threads_enter (); - gtk_list_store_prepend (savedstore, &iter); - gtk_list_store_set (savedstore, &iter, 0, CurrentPic.thumbbuf, 1, id, -1); - gdk_threads_leave (); - - // Save PNG - if (gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON(gui.tog_save))) { - - //setVU(0,6); - - /*ensure_dir_exists("rx-lum"); + gdk_threads_leave (); +} + +static void onListenerReceivedFSKID(const char *id) { + gdk_threads_enter (); + fsk_id = id; + gtk_label_set_text (GTK_LABEL(gui.label_fskid), id); + gdk_threads_leave (); +} + +static void onListenerReceiveStarted(void) { + static char rctime[8]; + + strftime(rctime, sizeof(rctime)-1, "%H:%Mz", ListenerReceiveStartTime); + gdk_threads_enter (); + gtk_label_set_text (GTK_LABEL(gui.label_fskid), ""); + gtk_widget_set_sensitive (gui.frame_manual, FALSE); + gtk_widget_set_sensitive (gui.frame_slant, FALSE); + gtk_widget_set_sensitive (gui.combo_card, FALSE); + gtk_widget_set_sensitive (gui.button_abort, TRUE); + gtk_widget_set_sensitive (gui.button_clear, FALSE); + gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON(gui.tog_setedge), FALSE); + gtk_label_set_markup (GTK_LABEL(gui.label_lastmode), ModeSpec[CurrentPic.Mode].Name); + gtk_label_set_markup (GTK_LABEL(gui.label_utc), rctime); + gdk_threads_leave (); +} + +static void onListenerAutoSlantCorrect(void) { + gdk_threads_enter (); + gtk_widget_set_sensitive (gui.grid_vu, FALSE); + gdk_threads_leave (); +} + +static void onListenerReceiveFinished(void) { + GtkTreeIter iter; + + // Add thumbnail to iconview + thumbbuf = gdk_pixbuf_scale_simple (pixbuf_rx, 100, + 100.0/ModeSpec[CurrentPic.Mode].ImgWidth * ModeSpec[CurrentPic.Mode].NumLines * ModeSpec[CurrentPic.Mode].LineHeight, GDK_INTERP_HYPER); + gdk_threads_enter (); + gtk_list_store_prepend (savedstore, &iter); + gtk_list_store_set (savedstore, &iter, 0, thumbbuf, 1, fsk_id, -1); + gdk_threads_leave (); + + + // Save PNG + if (gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON(gui.tog_save))) { + + //setVU(0,6); + + /*ensure_dir_exists("rx-lum"); LumFile = fopen(lumfilename,"w"); if (LumFile == NULL) - perror("Unable to open luma file for writing"); - fwrite(StoredLum,1,(ModeSpec[Mode].LineTime * ModeSpec[Mode].NumLines) * 44100,LumFile); + perror("Unable to open luma file for writing"); + fwrite(StoredLum,1,(ModeSpec[Mode].LineTime * ModeSpec[Mode].NumLines) * pcm.SampleRate,LumFile); fclose(LumFile);*/ - saveCurrentPic(); - } - - gdk_threads_enter (); - gtk_widget_set_sensitive (gui.frame_slant, TRUE); - gtk_widget_set_sensitive (gui.frame_manual, TRUE); - gtk_widget_set_sensitive (gui.combo_card, TRUE); - gdk_threads_leave (); - + saveCurrentPic(); } -} + gdk_threads_enter (); + gtk_widget_set_sensitive (gui.frame_slant, TRUE); + gtk_widget_set_sensitive (gui.frame_manual, TRUE); + gtk_widget_set_sensitive (gui.combo_card, TRUE); + gdk_threads_leave (); + +} /* * main */ int main(int argc, char *argv[]) { - - FILE *ConfFile; - const gchar *confdir; GString *confpath; - gchar *confdata; - gsize *keylen=NULL; gtk_init (&argc, &argv); gdk_threads_init (); // Load config - confdir = g_get_user_config_dir(); - confpath = g_string_new(confdir); - g_string_append(confpath, "/slowrx.ini"); - - config = g_key_file_new(); - if (g_key_file_load_from_file(config, confpath->str, G_KEY_FILE_KEEP_COMMENTS, NULL)) { - - } else { - printf("No valid config file found\n"); - g_key_file_load_from_data(config, "[slowrx]\ndevice=default", -1, G_KEY_FILE_NONE, NULL); - } + load_config_settings(&confpath); // Prepare FFT - fft.in = fftw_alloc_real(2048); - if (fft.in == NULL) { - perror("GetVideo: Unable to allocate memory for FFT"); - exit(EXIT_FAILURE); - } - fft.out = fftw_alloc_complex(2048); - if (fft.out == NULL) { - perror("GetVideo: Unable to allocate memory for FFT"); - fftw_free(fft.in); + if (fft_init() < 0) { exit(EXIT_FAILURE); } - memset(fft.in, 0, sizeof(double) * 2048); - - fft.Plan1024 = fftw_plan_dft_r2c_1d(1024, fft.in, fft.out, FFTW_ESTIMATE); - fft.Plan2048 = fftw_plan_dft_r2c_1d(2048, fft.in, fft.out, FFTW_ESTIMATE); createGUI(); + OnVideoInitBuffer = onVideoInitBuffer; + OnVideoStartRedraw = onVideoStartRedraw; + OnVideoRefresh = onVideoRefresh; + OnVideoWritePixel = onVideoWritePixel; + OnVideoPowerCalculated = setVU; + OnVisIdentified = onVisIdentified; + OnVisPowerComputed = setVU; + OnListenerStatusChange = showStatusbarMessage; + OnListenerWaiting = onListenerWaiting; + OnListenerReceivedManual = onListenerReceivedManual; + OnListenerReceiveStarted = onListenerReceiveStarted; + OnListenerReceiveFSK = onListenerReceiveFSK; + OnListenerAutoSlantCorrect = onListenerAutoSlantCorrect; + OnListenerReceiveFinished = onListenerReceiveFinished; + OnListenerReceivedFSKID = onListenerReceivedFSKID; + OnVisStatusChange = showStatusbarMessage; + pcm.OnPCMAbort = showPCMError; + pcm.OnPCMDrop = showPCMDropWarning; populateDeviceList(); gtk_main(); // Save config on exit - ConfFile = fopen(confpath->str,"w"); - if (ConfFile == NULL) { - perror("Unable to open config file for writing"); - } else { - confdata = g_key_file_to_data(config,keylen,NULL); - fprintf(ConfFile,"%s",confdata); - fwrite(confdata,1,(size_t)keylen,ConfFile); - fclose(ConfFile); - } + save_config_settings(confpath); g_object_unref(pixbuf_rx); free(StoredLum); diff --git a/slowrx.ui b/slowrx.ui index 85e5d62..123b22c 100644 --- a/slowrx.ui +++ b/slowrx.ui @@ -638,6 +638,7 @@ Pasokon P3 Pasokon P5 Pasokon P7 + Wraase SC-2 60 Wraase SC-2 120 Wraase SC-2 180 @@ -873,6 +874,60 @@ 0 + + + True + False + True + Sound card sample rate + Sound card sample rate + start + 0 + 0 + 1 + + 48000 + 44100 + 32000 + 24000 + 22050 + 16000 + 12000 + 11025 + 8000 + + + + + False + True + 1 + + + + + True + False + True + Sound card audio channel + Sound card audio channel + start + 0 + 0 + 1 + + LEFT + RIGHT + MONO + + + + + False + True + 2 + + True @@ -883,7 +938,7 @@ False True 4 - 1 + 3 diff --git a/slowrxd.c b/slowrxd.c new file mode 100644 index 0000000..f77617f --- /dev/null +++ b/slowrxd.c @@ -0,0 +1,1351 @@ +/* + * slowrx - an SSTV decoder + * * * * * * * * * * * * * * + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include + +#include "common.h" +#include "fft.h" +#include "listen.h" +#include "modespec.h" +#include "pcm.h" +#include "pic.h" +#include "video.h" +#include "vis.h" + +/* Exit status codes */ +#define DAEMON_EXIT_SUCCESS (0) +#define DAEMON_EXIT_INVALID_ARG (1) +#define DAEMON_EXIT_INIT_FFT_ERR (2) +#define DAEMON_EXIT_INIT_PCM_ERR (3) +#define DAEMON_EXIT_INIT_PATH (4) +#define DAEMON_EXIT_INIT_MUTEX (5) +#define DAEMON_EXIT_FORK_FAILURE (6) + +/* Receive refresh interval */ +#define REFRESH_INTERVAL (5) + +/* Receive execute run interval */ +#define RX_EXEC_INTERVAL (30) + +/* Exit status to use when exiting */ +static int daemon_exit_status = DAEMON_EXIT_SUCCESS; + +/* Common log message types */ +const char* logmsg_receive_start = "RECEIVE_START"; +const char* logmsg_vis_detect = "VIS_DETECT"; +const char* logmsg_sig_strength = "SIG_STRENGTH"; +const char* logmsg_image_refreshed = "IMAGE_REFRESHED"; +const char* logmsg_image_finish = "IMAGE_FINISHED"; +const char* logmsg_fsk_detect = "FSK_DETECT"; +const char* logmsg_fsk_received = "FSK_RECEIVED"; +const char* logmsg_receive_end = "RECEIVE_END"; +const char* logmsg_status = "STATUS"; +const char* logmsg_warning = "WARNING"; + +/* The name of the image-in-progress being received */ +static char* path_inprogress_img = "inprogress.png"; + +/* The name of the receive log */ +static char* path_inprogress_log = "inprogress.ndjson"; + +/* The name of the audio dump file */ +static char* path_inprogress_audio = "inprogress.au"; + +/* The name of the latest receive log */ +static char* path_latest_log = "latest.ndjson"; + +/* The name of the latest received image */ +static char* path_latest_img = "latest.png"; + +/* The name of the latest received audio file */ +static char* path_latest_audio = "latest.au"; + +/* The name of the directory where all images will be kept */ +static const char* path_dir = NULL; + +/* The path to an executable to run when an event happens */ +static const char* rx_exec = NULL; + +/* Time the image was last written to */ +static time_t last_refresh = 0; + +/* Time the script was last run */ +static time_t last_rx_exec = 0; + +/* The FSK ID detected after image transmission */ +static const char *fsk_id = NULL; + +/* Pointer to the receive log (NDJSON format) */ +static FILE* rxlog = NULL; +static pthread_mutex_t rxlog_mutex; + +/* Pointer to the audio dump (Sun Audio format) */ +static FILE* audiofile = NULL; + +/* The currently selected mode */ +const _ModeSpec* rxmode = NULL; + +/* Pointer to the raw image data being received */ +static gdImagePtr rximg; + +/* Execute a script, passing in the image file and receive log */ +static void exec_rx_cmd(const char* event, const char* img_path, const char* log_path, const char* audio_path) { + if (rx_exec) { + printf("Running %s %s %s %s %s\n", rx_exec, event, img_path, log_path, (audio_path ? audio_path : "")); + pid_t child_pid = fork(); + if (child_pid > 0) { + printf("Waiting for PID %d\n", child_pid); + waitpid(child_pid, NULL, 0); + } else if (child_pid == 0) { + char* _rx_exec = strdup(rx_exec); + if (!_rx_exec) { + perror("Failed to strdup process name"); + return; + } + + char* _event = strdup(event); + if (!_event) { + perror("Failed to strdup event"); + return; + } + + char* _img_path = strdup(img_path); + if (!_img_path) { + perror("Failed to strdup image path"); + return; + } + + char* _log_path = strdup(log_path); + if (!_log_path) { + perror("Failed to strdup log path"); + return; + } + + char* _audio_path = NULL; + if (audio_path) { + _audio_path = strdup(audio_path); + if (!_audio_path) { + perror("Failed to strdup audio path"); + return; + } + } + + char* argv[] = { _rx_exec, _event, _img_path, _log_path, _audio_path, NULL }; + printf("Executing script\n"); + int res = execv(rx_exec, argv); + if (res < 0) { + perror("Failed to exec() receive command"); + } + exit(DAEMON_EXIT_FORK_FAILURE); + } else if (child_pid < 0) { + perror("Failed to fork() for exec"); + } + } +} + +/* Safely concatenate two strings */ +static int safe_strncat(char* dest, const char* src, size_t* dest_rem) { + size_t src_len = strlen(src); + if (src_len <= *dest_rem) { + strncat(dest, src, *dest_rem); + *dest_rem -= src_len; + + return src_len; + } else { + errno = E2BIG; + return -errno; + } +} + +/* Append a path segment */ +static int path_append(char* path, const char* filename, size_t* path_rem) { + char* path_end = &path[strlen(path)]; + int res; + + if ((path_end > path) && (path_end[-1] != '/')) { + /* Path does not end in a slash, so append one now */ + res = safe_strncat(path, "/", path_rem); + if (res < 0) { + return res; + } + } + + res = safe_strncat(path, filename, path_rem); + if (res < 0) { + /* Wind back concatenations */ + path_end = 0; + return res; + } + + return res; +} + +/* Rename and symlink a file */ +static int renameAndSymlink(const char* existing_path, const char* new_path, const char* symlink_path) { + int res = rename(existing_path, new_path); + if (res < 0) { + perror("Failed to rename receive log"); + return -errno; + } + printf("Renamed %s to %s\n", existing_path, new_path); + + if (symlink_path) { + /* Figure out the target */ + char target[PATH_MAX]; + const char* symlink_target; + strncpy(target, new_path, sizeof(target) - 1); + symlink_target = basename(target); + + res = unlink(symlink_path); + if (res < 0) { + /* ENOENT is okay */ + if (errno != ENOENT) { + perror("Failed to remove old 'latest' symlink"); + return -errno; + } + } + printf("Removed old symlink %s\n", symlink_path); + + res = symlink(symlink_target, symlink_path); + if (res < 0) { + perror("Failed to symlink receive log"); + return -errno; + } + printf("Symlinked %s to %s\n", symlink_target, symlink_path); + } + + return 0; +} + +/* Open the receive log ready for traffic */ +static int openAudioDump(void) { + assert(audiofile == NULL); + audiofile = fopen(path_inprogress_audio, "wb+"); + if (audiofile == NULL) { + perror("Failed to open audio dump file"); + return -errno; + } + + /* https://en.wikipedia.org/wiki/Au_file_format */ + uint32_t hdr[7] = { + 0x2e736e64, // Magic ".snd" + 28, // Data offset: 28 bytes (7*4-bytes) + UINT32_MAX, // Data size: unknown + 3, // Encoding: int16_t linear + pcm.SampleRate, // Sample rate + 1, // Channels + 0, // Annotation (unused) + }; + for (int i = 0; i < 7; i++) { + // Byte swap to big-endian + hdr[i] = htonl(hdr[i]); + } + + // Write the header + size_t sz = fwrite(hdr, sizeof(uint32_t), 7, audiofile); + if (sz < 7) { + perror("Failed to write audio file header"); + int res = fclose(audiofile); + if (res < 0) { + perror("Also failed to close partial file"); + } + return -errno; + } + + printf("Opened audio dump file: %s\n", path_inprogress_audio); + return 0; +} + +static int closeAudioDump(void) { + if (audiofile == NULL) + return 0; + + pcm.PCMReadCallback = NULL; + + int res = fclose(audiofile); + audiofile = NULL; + if (res < 0) { + perror("Failed to clse audio dump file"); + return -errno; + } + + return 0; +} + +static void writeToAudioDump(int32_t numsamples, const int16_t* samples) { + if (audiofile) { + int16_t be_samples[512]; + int res, i; + size_t sz; + + while (numsamples) { + // Clamp at the block size + int32_t block_sz = (numsamples > 512) ? 512 : numsamples; + // Copy to temp buffer + memcpy(be_samples, samples, sizeof(int16_t)*block_sz); + // Byteswap buffer + for (i = 0; i < block_sz; i++) { + be_samples[i] = htons(be_samples[i]); + } + // Write buffer + sz = fwrite(be_samples, sizeof(int16_t), block_sz, audiofile); + if (sz < (size_t)block_sz) { + perror("Failed to write audio block"); + closeAudioDump(); + return; + } + // Advance + numsamples -= block_sz; + samples += block_sz; + } + + res = fflush(audiofile); + if (res < 0) { + perror("Failed to flush audio file"); + closeAudioDump(); + } + } +} + +/* Open the receive log ready for traffic */ +static int openReceiveLog(void) { + assert(rxlog == NULL); + rxlog = fopen(path_inprogress_log, "w+"); + if (rxlog == NULL) { + perror("Failed to open receive log"); + return -errno; + } + + printf("Opened log file: %s\n", path_inprogress_log); + return 0; +} + +/* Close and rename the receive log */ +static int closeReceiveLog(const char* new_path) { + int res; + + assert(rxlog != NULL); + res = fclose(rxlog); + rxlog = NULL; + if (res < 0) { + perror("Failed to close receive log cleanly"); + return -errno; + } + + printf("Closed log file: %s\n", path_inprogress_log); + if (new_path) { + res = renameAndSymlink(path_inprogress_log, new_path, path_latest_log); + if (res < 0) { + return res; + } + } + + return 0; +} + +/* Write a (null-terminated!) raw string to the file */ +static int emitLogRecordRaw(const char* str) { + assert(rxlog != NULL); + int res = fputs(str, rxlog); + if (res < 0) { + perror("Failed to write to receive log"); + return -errno; + } + return 0; +} + +/* Log buffer flusher helper */ +static int emitLogBufferContent(char* buffer, char** const ptr, uint8_t* rem, uint8_t sz) { + /* NB: buffer is not necessarily zero terminated! */ + const uint8_t write_sz = sz - *rem; + assert(rxlog != NULL); + + size_t written = fwrite((void*)buffer, 1, write_sz, rxlog); + if (written < write_sz) { + perror("Truncated write whilst emitting to receive log"); + return -errno; + } + + /* Success, reset the remaining space pointer */ + *ptr = buffer; + *rem = sz; + return 0; +} + +/* Emit a string */ +static int emitLogRecordString(const char* str) { + int res; + char buffer[128]; + char* ptr = buffer; + uint8_t rem = (uint8_t)sizeof(buffer); + + assert(rxlog != NULL); + + // Begin with '"' + *(ptr++) = '"'; + rem--; + + while (*str) { + switch (*str) { + case '\\': + case '"': + // Escape with '\' character + if (!rem) { + res = emitLogBufferContent(buffer, &ptr, &rem, (uint8_t)sizeof(buffer)); + if (res < 0) + return res; + } + *(ptr++) = '\\'; + rem--; + if (!rem) { + res = emitLogBufferContent(buffer, &ptr, &rem, (uint8_t)sizeof(buffer)); + if (res < 0) + return res; + } + *(ptr++) = *str; + rem--; + break; + case '\n': + /* Emit '\n' */ + if (!rem) { + res = emitLogBufferContent(buffer, &ptr, &rem, (uint8_t)sizeof(buffer)); + if (res < 0) + return res; + } + *(ptr++) = '\\'; + rem--; + if (!rem) { + res = emitLogBufferContent(buffer, &ptr, &rem, (uint8_t)sizeof(buffer)); + if (res < 0) + return res; + } + *(ptr++) = 'n'; + rem--; + break; + default: + /* Pass through "safe" ranges */ + if ((*str) < ' ') + break; + + if ((*str) > '~') + break; + + if (!rem) { + res = emitLogBufferContent(buffer, &ptr, &rem, (uint8_t)sizeof(buffer)); + if (res < 0) + return res; + } + *(ptr++) = *str; + rem--; + break; + } + + str++; + } + + // End with '"' + if (!rem) { + res = emitLogBufferContent(buffer, &ptr, &rem, (uint8_t)sizeof(buffer)); + if (res < 0) + return res; + } + *(ptr++) = '"'; + rem--; + + return emitLogBufferContent(buffer, &ptr, &rem, (uint8_t)sizeof(buffer)); +} + +/* Begin a log record in the log file */ +static int beginReceiveLogRecord(const char* type, const char* msg) { + int res; + int64_t time_sec; + int16_t time_msec; + + assert(rxlog != NULL); + assert(type != NULL); + + { + struct timeval tv; + res = gettimeofday(&tv, NULL); + if (res < 0) { + perror("Failed to retrieve current time"); + return -errno; + } + time_sec = (int64_t)tv.tv_sec; + time_msec = (uint16_t)(tv.tv_usec / 1000); + } + + res = pthread_mutex_lock(&rxlog_mutex); + if (res < 0) { + perror("Failed to lock output log"); + return -errno; + } + + res = fprintf(rxlog, "{\"timestamp\": %" PRId64 "%03u, \"type\": ", time_sec, time_msec); + if (res < 0) { + perror("Failed to emit record timestamp or type key"); + return -errno; + } + + res = emitLogRecordString(type); + if (res < 0) { + return res; + } + + if (msg) { + res = emitLogRecordRaw(", \"msg\": "); + if (res < 0) { + return -errno; + } + + res = emitLogRecordString(msg); + if (res < 0) { + return res; + } + } + + return 0; +} + +/* Finish a log record */ +static int finishReceiveLogRecord(const char* endstr) { + int res, unlock_res; + + if (endstr) { + res = emitLogRecordRaw(endstr); + if (res < 0) { + goto unlock; + } + } + + res = emitLogRecordRaw("}\n"); + if (res < 0) { + goto unlock; + } + + res = fflush(rxlog); + if (res < 0) { + perror("Failed to flush log record"); + res = -errno; + goto unlock; + } + + if (res < 0) { + perror("Failed to lock output log"); + res = -errno; + goto unlock; + } + +unlock: + unlock_res = pthread_mutex_unlock(&rxlog_mutex); + if (unlock_res < 0) { + perror("FAILED TO UNLOCK OUTPUT LOG!!!"); + assert(0); + } + return res; +} + +/* Emit a complete simple log message with no keys. */ +static int emitSimpleReceiveLogRecord(const char* type, const char* msg) { + int res = beginReceiveLogRecord(type, msg); + if (res < 0) + return res; + return finishReceiveLogRecord(NULL); +} + +static void showStatusbarMessage(const char* msg) { + printf("Status: %s\n", msg); + if (rxlog) { + if (emitSimpleReceiveLogRecord(logmsg_status, msg) < 0) { + // Bail here! + Abort = true; + } + } +} + +static void onVisIdentified(void) { + char buffer[128]; + const int idx = VISmap[VIS]; + int res = openReceiveLog(); + if (res < 0) { + Abort = true; + return; + } + + last_refresh = 0; + snprintf(buffer, sizeof(buffer), "Detected mode %s (VIS code 0x%02x)", ModeSpec[idx].Name, VIS); + puts(buffer); + res = beginReceiveLogRecord(logmsg_vis_detect, buffer); + if (res < 0) { + Abort = true; + return; + } + + res = fprintf(rxlog, ", \"code\": %d, \"mode\": ", VIS); + if (res < 0) { + perror("Failed to write VIS code or mode key"); + Abort = true; + return; + } + + res = emitLogRecordString(ModeSpec[idx].ShortName); + if (res < 0) { + Abort = true; + return; + } + + res = emitLogRecordRaw(", \"desc\": "); + if (res < 0) { + Abort = true; + return; + } + + res = emitLogRecordString(ModeSpec[idx].Name); + if (res < 0) { + Abort = true; + return; + } + + res = finishReceiveLogRecord(NULL); + if (res < 0) { + Abort = true; + } + + fsk_id = NULL; + exec_rx_cmd(logmsg_vis_detect, path_inprogress_img, path_inprogress_log, path_inprogress_audio); + + if (path_inprogress_audio) { + res = openAudioDump(); + if (res == 0) { + // Fetch the initial part that triggered this recording. + writeToAudioDump(pcm.WindowPtr*2, pcm.Buffer); + } + if (res == 0) { + pcm.PCMReadCallback = writeToAudioDump; + } + } +} + +static void onVideoInitBuffer(uint8_t mode) { + rxmode = &ModeSpec[mode]; + + printf("Init buffer for mode %s\n", rxmode->Name); + + /* Allocate the image */ + rximg = gdImageCreateTrueColor(rxmode->ImgWidth, rxmode->NumLines * rxmode->LineHeight); + if (!rximg) { + perror("Failed to allocate image buffer"); + Abort = true; + } +} + +static void onVideoStartRedraw(void) { + printf("\n\nBEGIN REDRAW\n\n"); +} + +static void onVideoWritePixel(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b) { + /* Check bounds */ + if (x >= rxmode->ImgWidth) + return; + + if (y >= rxmode->NumLines) + return; + + if (!rximg) + return; + + /* Handle line height */ + y *= rxmode->LineHeight; + + /* Draw pixel or line */ + for (uint16_t yo = 0; yo < rxmode->LineHeight; yo++) { + gdImageSetPixel(rximg, x, y + yo, gdImageColorResolve(rximg, r, g, b)); + } +} + +static int refreshImage(_Bool force) { + int res; + + if (!force && last_refresh) { + struct timeval tv; + res = gettimeofday(&tv, NULL); + if (res >= 0) { + uint16_t age = (uint16_t)(tv.tv_sec - last_refresh); + + if (age < REFRESH_INTERVAL) { + /* Hasn't been long enough, don't bother */ + return 0; + } + } + } else if (force) { + printf("Forced refresh\n"); + } else if (!last_refresh) { + printf("First refresh\n"); + } + + /* Open a file for writing. "wb" means "write binary", important + under MSDOS, harmless under Unix. */ + FILE* pngout = fopen(path_inprogress_img, "wb"); + if (pngout) { + /* Output the image to the disk file in PNG format. */ + gdImagePng(rximg, pngout); + + /* Close the files. */ + res = fclose(pngout); + if (res < 0) { + perror("Failed to write in-progress image"); + return -errno; + } else { + struct timeval tv; + res = gettimeofday(&tv, NULL); + if (res >= 0) { + /* Mark refresh time */ + last_refresh = tv.tv_sec; + } + printf("Image refreshed\n"); + } + } else { + perror("Failed to open in-progress image for writing"); + return -errno; + } + + if (force || ((last_refresh - last_rx_exec) > RX_EXEC_INTERVAL)) { + exec_rx_cmd(logmsg_image_refreshed, path_inprogress_img, path_inprogress_log, path_inprogress_audio); + } + return 0; +} + +static void onVideoRefresh(void) { + if (refreshImage(false) < 0) { + Abort = true; + } +} + +static void onListenerWaiting(void) { + printf("Listener is waiting\n"); +} + +static void onListenerReceivedManual(void) { + printf("Listener received something in manual mode\n"); +} + +static void onListenerReceiveFSK(void) { + printf("Listener is now receiving FSK\n"); + if (emitSimpleReceiveLogRecord(logmsg_fsk_detect, NULL) < 0) { + // Bail here! + Abort = true; + } +} + +static void onListenerReceivedFSKID(const char *id) { + if (strlen(id)) { + printf("Listener got FSK %s\n", id); + fsk_id = id; + } else { + printf("No FSK received\n"); + fsk_id = NULL; + return; + } + + int res = beginReceiveLogRecord(logmsg_fsk_received, NULL); + if (res < 0) { + Abort = true; + return; + } + + res = emitLogRecordRaw(", \"id\": "); + if (res < 0) { + Abort = true; + return; + } + + res = emitLogRecordString(id); + if (res < 0) { + Abort = true; + return; + } + + res = finishReceiveLogRecord(NULL); + if (res < 0) { + Abort = true; + return; + } +} + +static void onListenerReceiveStarted(void) { + static char rctime[8]; + strftime(rctime, sizeof(rctime)-1, "%H:%Mz", ListenerReceiveStartTime); + printf("Receive started at %s\n", rctime); + if (emitSimpleReceiveLogRecord(logmsg_receive_start, "Receive started") < 0) { + // Bail here! + Abort = true; + } +} + +static void onListenerAutoSlantCorrect(void) { + printf("Performing slant correction\n"); + if (emitSimpleReceiveLogRecord(logmsg_status, "Performing slant correction") < 0) { + // Bail here! + Abort = true; + } +} + +static void onListenerReceiveFinished(void) { + char timestamp[20]; + strftime(timestamp, sizeof(timestamp)-1, "%Y-%m-%dT%H-%MZ", ListenerReceiveStartTime); + + char output_path_log[PATH_MAX]; + char output_path_img[PATH_MAX]; + char output_path_audio[PATH_MAX]; + size_t output_path_rem = sizeof(output_path_log) - 1; + + if (path_dir) { + strncpy(output_path_log, path_dir, output_path_rem); + output_path_rem -= strlen(path_dir); + } else { + output_path_log[0] = 0; + } + + int res = path_append(output_path_log, timestamp, &output_path_rem); + if (res >= 0) { + res = safe_strncat(output_path_log, "-", &output_path_rem); + if (res >= 0) { + res = safe_strncat(output_path_log, ModeSpec[CurrentPic.Mode].ShortName, &output_path_rem); + } + } + + if (fsk_id && output_path_rem && (res >= 0)) { + /* fsk_id might contain characters that are unsafe! */ + char fsk_safe[strlen(fsk_id) + 1]; + char *c = fsk_safe; + strcpy(fsk_safe, fsk_id); + + while (*c) { + if (isalpha(*c)) { + /* Upper-case letters */ + *c = toupper(*c); + } else if (!isdigit(*c)) { + /* Convert non-digits to hypens */ + *c = '-'; + } + c++; + } + + res = safe_strncat(output_path_log, "-", &output_path_rem); + if (res >= 0) { + res = safe_strncat(output_path_log, fsk_safe, &output_path_rem); + } + } + + if (path_inprogress_audio) { + strncpy(output_path_audio, output_path_log, sizeof(output_path_audio)); + if (output_path_rem < 4) { + /* Truncate to make room for ".au\0" */ + output_path_audio[sizeof(output_path_audio) - 4] = 0; + } + strncat(output_path_audio, ".au", sizeof(output_path_audio) - strlen(output_path_audio) - 1); + } + + strncpy(output_path_img, output_path_log, sizeof(output_path_img)); + if (output_path_rem < 5) { + /* Truncate to make room for ".png\0" */ + output_path_img[sizeof(output_path_img) - 5] = 0; + } + strncat(output_path_img, ".png", sizeof(output_path_img) - strlen(output_path_img) - 1); + + if (output_path_rem < 7) { + /* Truncate to make room for ".ndjson\0" */ + output_path_log[sizeof(output_path_log) - 5] = 0; + } + strncat(output_path_log, ".ndjson", sizeof(output_path_log) - strlen(output_path_log)); + + printf("Output files will be %s (image) and %s (log)\n", + output_path_img, output_path_log); + + /* Refresh one more time, then rename the file */ + refreshImage(true); + renameAndSymlink(path_inprogress_img, output_path_img, path_latest_img); + + /* Release the image buffer */ + gdImageDestroy(rximg); + rximg = NULL; + + res = emitSimpleReceiveLogRecord(logmsg_receive_end, NULL); + if (res == 0) + res = closeReceiveLog(output_path_log); + if (res < 0) { + Abort = true; + return; + } + + if (path_inprogress_audio) { + res = closeAudioDump(); + if (res == 0) { + res = renameAndSymlink(path_inprogress_audio, output_path_audio, path_latest_audio); + } + } + + printf("Received %d × %d pixel image\n", + ModeSpec[CurrentPic.Mode].ImgWidth, + ModeSpec[CurrentPic.Mode].NumLines * ModeSpec[CurrentPic.Mode].LineHeight); + exec_rx_cmd(logmsg_receive_end, output_path_img, output_path_log, output_path_audio); + + /* Wait for all children to exit */ + wait(NULL); +} + +static void showVU(double *Power, int FFTLen, int WinIdx) { + if (!rxlog) { + /* No log, so do nothing */ + return; + } + + /* Scan forwards, find the first non-zero record */ + int first_bucket = 0; + for (int i = first_bucket; i < FFTLen; i++) { + if (Power[i] != 0.0) { + first_bucket = i; + break; + } + } + + /* Scan backwards, find the first non-zero record */ + int last_bucket = FFTLen-1; + for (int i = last_bucket; i >= first_bucket; i--) { + if (Power[i] != 0.0) { + last_bucket = i; + break; + } + } + + int res = beginReceiveLogRecord(logmsg_sig_strength, NULL); + if (res < 0) { + Abort = true; + return; + } + + res = fprintf(rxlog, + ", \"win\": %d, " + "\"num\": %d, " + "\"first\": %d, " + "\"last\": %d, \"fft\": [", + WinIdx, FFTLen, first_bucket, last_bucket); + if (res < 0) { + perror("Failed to write window index or start of FFT array"); + Abort = true; + return; + } + + for (int i = first_bucket; i <= last_bucket; i++) { + if (i > first_bucket) { + res = emitLogRecordRaw(", "); + if (res < 0) { + Abort = true; + return; + } + } + + res = fprintf(rxlog, "%f", Power[i]); + if (res < 0) { + Abort = true; + return; + } + } + + res = finishReceiveLogRecord("]"); +} + +static void showPCMError(const char* error) { + if (rxlog) { + if (emitSimpleReceiveLogRecord(logmsg_warning, error) < 0) { + // Bail here! + Abort = true; + } + } + + printf("\n\nPCM Error: %s\n\n", error); +} + +static void showPCMDropWarning(void) { + if (rxlog) { + if (emitSimpleReceiveLogRecord(logmsg_warning, "PCM frames dropped") < 0) { + // Bail here! + Abort = true; + } + } + + printf("\n\nPCM DROP Warning!!!\n\n"); +} + +/* + * main + */ + +char* path_append_dir_dup(const char* filename) { + char path[PATH_MAX] = {0}; + size_t path_rem = sizeof(path) - 1; + + if (path_dir || (filename[0] == '/')) { + strncpy(path, path_dir, path_rem); + path_rem -= strlen(path_dir); + + int res = path_append(path, filename, &path_rem); + if (res < 0) { + return NULL; + } + } else { + strncpy(path, filename, path_rem); + } + + return strdup(path); +} + +static _Bool is_disabled_path(const char* path) { + return (path[0] == '-') && (path[1] == 0); +} + +int main(int argc, char *argv[]) { + // Initialise mutex + int res = pthread_mutex_init(&rxlog_mutex, NULL); + if (res < 0) { + perror("Failed to create mutex"); + exit(DAEMON_EXIT_INIT_MUTEX); + } + + // Set up defaults + const char* pcm_device = "default"; + ListenerAutoSlantCorrect = true; + ListenerEnableFSKID = true; + VisAutoStart = true; + uint16_t sample_rate = 44100; + uint8_t channel = PCM_CH_LEFT; + + { + _Bool path_inprogress_img_set = false; + _Bool path_inprogress_log_set = false; + _Bool path_inprogress_audio_set = false; + _Bool path_latest_img_set = false; + _Bool path_latest_log_set = false; + _Bool path_latest_audio_set = false; + int opt; + + while ((opt = getopt(argc, argv, "A:FI:SL:a:c:d:hi:l:p:r:x:")) != -1) { + switch (opt) { + case 'A': // In-progress audio path + if (path_inprogress_audio_set) { + free(path_inprogress_audio); + } else { + path_inprogress_audio_set = true; + } + if (is_disabled_path(optarg)) { + path_inprogress_audio = NULL; + } else { + path_inprogress_audio = path_append_dir_dup(optarg); + if (!path_inprogress_audio) { + perror("Failed to compute full in-progress audio path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + break; + case 'F': // Disable FSKID + ListenerEnableFSKID = false; + break; + case 'I': // In-progress image path + if (path_inprogress_img_set) { + free(path_inprogress_img); + } else { + path_inprogress_img_set = true; + } + if (is_disabled_path(optarg)) { + path_inprogress_img = NULL; + } else { + path_inprogress_img = path_append_dir_dup(optarg); + if (!path_inprogress_img) { + perror("Failed to compute full in-progress image path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + break; + case 'S': // Disable slant correction + ListenerAutoSlantCorrect = false; + break; + case 'L': // In-progress receive log path + if (path_inprogress_log_set) { + free(path_inprogress_log); + } else { + path_inprogress_log_set = true; + } + if (is_disabled_path(optarg)) { + path_inprogress_log = NULL; + } else { + path_inprogress_log = path_append_dir_dup(optarg); + if (!path_inprogress_log) { + perror("Failed to compute full in-progress log path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + break; + case 'a': // Latest audio path + if (path_latest_audio_set) { + free(path_latest_audio); + } else { + path_latest_audio_set = true; + } + if (is_disabled_path(optarg)) { + path_latest_audio = NULL; + } else { + path_latest_audio = path_append_dir_dup(optarg); + if (!path_latest_audio) { + perror("Failed to compute full latest audio path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + break; + case 'c': // Audio channel selection + switch (optarg[0]) { + case 'l': + case 'L': + channel = PCM_CH_LEFT; + break; + case 'r': + case 'R': + channel = PCM_CH_RIGHT; + break; + case 'm': + case 'M': + default: + channel = PCM_CH_MONO; + break; + } + break; + case 'd': // Set the output directory + { + char abs_dir[PATH_MAX]; + if (realpath(optarg, abs_dir) == abs_dir) { + path_dir = strdup(abs_dir); + } else { + perror("Failed to determine absolute directory"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + break; + case 'h': + printf("Usage: %s [-h] [-F] [-S] [-A inprogress.au]\n" + "\t[-I inprogress.png] [-L inprogress.ndjson] [-a latest.au]\n" + "\t[-c channel] [-d directory] [-i latest.png]\n" + "\t[-l latest.ndjson] [-p pcmdevice] [-r samplerate]\n" + "\t[-x script]\n" + "\n" + "where:\n" + " -F : disable FSK ID detection\n" + " -S : disable slant correction\n" + " -h : display this help and exit\n" + " -d : set the directory where images are kept\n" + " -A : set the in-progress audio dump path (- to disable)\n" + " -I : set the in-progress image path\n" + " -L : set the in-progress receive log path (- to disable)\n" + " -a : set the latest audio dump path (- to disable)\n" + " -c : set the audio channel to use, left, right or mono\n" + " -i : set the latest image path (- to disable)\n" + " -l : set the latest receive log path (- to disable)\n" + " -r : set the ALSA PCM sample rate\n" + " -p : set the ALSA PCM capture device\n" + " -x : specify a script to run on receive events\n", + argv[0]); + exit(DAEMON_EXIT_SUCCESS); + break; + case 'i': // Latest image path + if (path_latest_img_set) { + free(path_latest_img); + } else { + path_latest_img_set = true; + } + if (is_disabled_path(optarg)) { + path_latest_img = NULL; + } else { + path_latest_img = path_append_dir_dup(optarg); + if (!path_latest_img) { + perror("Failed to compute full latest image path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + break; + case 'l': // Latest receive log path + if (path_latest_log_set) { + free(path_latest_log); + } else { + path_latest_log_set = true; + } + if (is_disabled_path(optarg)) { + path_latest_log = NULL; + } else { + path_latest_log = path_append_dir_dup(optarg); + if (!path_latest_log) { + perror("Failed to compute full latest log path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + break; + case 'r': // Sample rate + { + char* endptr = NULL; + uint32_t rate = strtoul(optarg, &endptr, 10); + if (*endptr) { + printf("Invalid sample rate: %s\n", endptr); + exit(DAEMON_EXIT_INVALID_ARG); + } + if (rate > 48000) { + printf( + "Sample rate %" PRIu32 " out of range, " + "Clamping to 48kHz.\n", + rate); + rate = 48000; + } + sample_rate = rate; + } + break; + case 'p': // PCM device + pcm_device = strdup(optarg); + break; + case 'x': // Execute script on event + rx_exec = strdup(optarg); + break; + default: + printf("Unrecognised: %s\n", optarg); + exit(DAEMON_EXIT_INVALID_ARG); + break; + } + } + + if (!path_dir) { + /* Figure out the current working directory */ + char cwd[PATH_MAX]; + if (getcwd(cwd, sizeof(cwd)) == cwd) { + path_dir = strdup(cwd); + } else { + perror("Failed to determine current working directory"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + + if (!path_inprogress_img_set && path_inprogress_img) { + path_inprogress_img = path_append_dir_dup(path_inprogress_img); + if (!path_inprogress_img) { + perror("Failed to compute full in-progress image path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + + if (!path_inprogress_log_set && path_inprogress_log) { + path_inprogress_log = path_append_dir_dup(path_inprogress_log); + if (!path_inprogress_log) { + perror("Failed to compute full in-progress log path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + + if (!path_inprogress_audio_set && path_inprogress_audio) { + path_inprogress_audio = path_append_dir_dup(path_inprogress_audio); + if (!path_inprogress_audio) { + perror("Failed to compute full in-progress audio dump path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + + if (!path_latest_img_set && path_latest_img) { + path_latest_img = path_append_dir_dup(path_latest_img); + if (!path_latest_img) { + perror("Failed to compute full latest image path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + + if (!path_latest_log_set && path_latest_log) { + path_latest_log = path_append_dir_dup(path_latest_log); + if (!path_latest_log) { + perror("Failed to compute full latest log path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + + if (!path_latest_audio_set) { + path_latest_audio = path_append_dir_dup(path_latest_audio); + if (!path_latest_audio) { + perror("Failed to compute full latest audio dump path"); + exit(DAEMON_EXIT_INIT_PATH); + } + } + } + + // Require an in-progress image file output be provided + if (!path_inprogress_img) { + printf("In-progress image path is required\n"); + exit(DAEMON_EXIT_INVALID_ARG); + } + + // Prepare FFT + if (fft_init() < 0) { + exit(DAEMON_EXIT_INIT_FFT_ERR); + } + + res = initPcmDevice(pcm_device, sample_rate, channel); + switch (res) { + case PCM_RES_SUCCESS: + break; + case PCM_RES_SUBOPTIMAL: + printf("PCM setup is sub-optimal, doing the best we can.\n"); + break; + default: + if (res < 0) { + exit(DAEMON_EXIT_INIT_PCM_ERR); + } + } + + OnVideoInitBuffer = onVideoInitBuffer; + OnVideoStartRedraw = onVideoStartRedraw; + OnVideoRefresh = onVideoRefresh; + OnVideoWritePixel = onVideoWritePixel; + OnVideoPowerCalculated = showVU; + OnVisIdentified = onVisIdentified; + OnVisPowerComputed = showVU; + OnListenerStatusChange = showStatusbarMessage; + OnListenerWaiting = onListenerWaiting; + OnListenerReceivedManual = onListenerReceivedManual; + OnListenerReceiveStarted = onListenerReceiveStarted; + OnListenerReceiveFSK = onListenerReceiveFSK; + OnListenerAutoSlantCorrect = onListenerAutoSlantCorrect; + OnListenerReceiveFinished = onListenerReceiveFinished; + OnListenerReceivedFSKID = onListenerReceivedFSKID; + OnVisStatusChange = showStatusbarMessage; + pcm.OnPCMAbort = showPCMError; + pcm.OnPCMDrop = showPCMDropWarning; + + StartListener(); + WaitForListenerStop(); + + return (daemon_exit_status); +} diff --git a/sync.c b/sync.c index 57177ae..4df0601 100644 --- a/sync.c +++ b/sync.c @@ -2,10 +2,11 @@ #include #include #include -#include #include #include "common.h" +#include "modespec.h" +#include "pcm.h" /* Find the slant angle of the sync singnal and adjust sample rate to cancel it out * Length: number of PCM samples to process @@ -15,29 +16,29 @@ * returns adjusted sample rate * */ -double FindSync (guchar Mode, double Rate, int *Skip) { - - int LineWidth = ModeSpec[Mode].LineTime / ModeSpec[Mode].SyncTime * 4; - int x,y; - int q, d, qMost, dMost; - gushort xAcc[700] = {0}; - gushort lines[600][(MAXSLANT-MINSLANT)*2]; - gushort cy, cx, Retries = 0; - gboolean SyncImg[700][630] = {{FALSE}}; +double FindSync (uint8_t Mode, double Rate, int32_t *Skip) { + + int32_t LineWidth = ModeSpec[Mode].LineTime / ModeSpec[Mode].SyncTime * 4; + int32_t x,y; + int32_t q, d, qMost, dMost; + uint16_t xAcc[700] = {0}; + uint16_t lines[600][(MAXSLANT-MINSLANT)*2]; + uint16_t cy, cx, Retries = 0; + _Bool SyncImg[700][630] = {{false}}; double t=0, slantAngle, s; double ConvoFilter[8] = { 1,1,1,1,-1,-1,-1,-1 }; double convd, maxconvd=0; - int xmax=0; + int32_t xmax=0; // Repeat until slant < 0.5° or until we give up - while (TRUE) { + while (true) { // Draw the 2D sync signal at current rate for (y=0; y 44100\n"); + Rate = pcm.SampleRate; + printf(" -> %u\n", pcm.SampleRate); break; } printf(" -> %.1f recalculating\n", Rate); @@ -98,14 +99,14 @@ double FindSync (guchar Mode, double Rate, int *Skip) { for (y=0; y maxconvd) { maxconvd = convd; xmax = x+4; diff --git a/sync.h b/sync.h new file mode 100644 index 0000000..44fbcb9 --- /dev/null +++ b/sync.h @@ -0,0 +1,8 @@ +#ifndef _SYNC_H_ +#define _SYNC_H_ + +#include + +double FindSync (uint8_t Mode, double Rate, int32_t *Skip); + +#endif diff --git a/video.c b/video.c index 389751f..d64f38e 100644 --- a/video.c +++ b/video.c @@ -1,57 +1,75 @@ #include #include +#include #include #include -#include #include #include #include "common.h" +#include "fft.h" +#include "modespec.h" +#include "pcm.h" +#include "pic.h" +#include "video.h" + +#define VIDEO_MAX_WIDTH (800) +#define VIDEO_MAX_HEIGHT (616) +#define VIDEO_MAX_CHANNELS (3) +#define VIDEO_FFT_LEN (FFT_HALF_SZ) + +// Perform FFT every 6 samples +#define VIDEO_FFT_INTERVAL (6) + +static uint8_t VideoImage[VIDEO_MAX_WIDTH][VIDEO_MAX_HEIGHT][VIDEO_MAX_CHANNELS] = {{{0}}}; +void (*OnVideoInitBuffer)(uint8_t Mode); +void (*OnVideoWritePixel)(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b); +EventCallback OnVideoStartRedraw; +EventCallback OnVideoRefresh; +UpdateVUCallback OnVideoPowerCalculated; + +typedef struct { + int32_t Time; + int16_t X; + int16_t Y; + uint8_t Channel; + _Bool Last; +} _PixelGrid; + /* Demodulate the video signal & store all kinds of stuff for later stages * Mode: M1, M2, S1, S2, R72, R36... * Rate: exact sampling rate used * Skip: number of PCM samples to skip at the beginning (for sync phase adjustment) - * Redraw: FALSE = Apply windowing and FFT to the signal, TRUE = Redraw from cached FFT data - * returns: TRUE when finished, FALSE when aborted + * Redraw: false = Apply windowing and FFT to the signal, true = Redraw from cached FFT data + * returns: true when finished, false when aborted */ -gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { - - guint MaxBin = 0; - guint VideoPlusNoiseBins=0, ReceiverBins=0, NoiseOnlyBins=0; - guint n=0; - guint SyncSampleNum; - guint i=0, j=0; - guint FFTLen=1024, WinLength=0; - guint SyncTargetBin; - int SampleNum, Length, NumChans; - int x = 0, y = 0, tx=0, k=0; - double Hann[7][1024] = {{0}}; - double Freq = 0, PrevFreq = 0, InterpFreq = 0; - int NextSNRtime = 0, NextSyncTime = 0; +_Bool GetVideo(uint8_t Mode, double Rate, int32_t Skip, _Bool Redraw) { + uint32_t MaxBin = 0; + uint32_t VideoPlusNoiseBins=0, ReceiverBins=0, NoiseOnlyBins=0; + uint32_t n=0; + uint32_t SyncSampleNum; + uint32_t i=0, j=0; + uint32_t WinLength=0; + uint32_t SyncTargetBin; + int32_t SampleNum, Length, NumChans; + int32_t x = 0, y = 0, tx=0, k=0; + double Hann[7][VIDEO_FFT_LEN] = {{0}}; + double Freq = 0; + //double PrevFreq = 0, InterpFreq = 0; + int32_t NextSNRtime = 0, NextSyncTime = 0; double Praw, Psync; - double Power[1024] = {0}; + double Power[VIDEO_FFT_LEN] = {0}; double Pvideo_plus_noise=0, Pnoise_only=0, Pnoise=0, Psignal=0; double SNR = 0; double ChanStart[4] = {0}, ChanLen[4] = {0}; - guchar Image[800][616][3] = {{{0}}}; - guchar Channel = 0, WinIdx = 0; - - typedef struct { - int X; - int Y; - int Time; - guchar Channel; - gboolean Last; - } _PixelGrid; - - _PixelGrid *PixelGrid; - PixelGrid = calloc( ModeSpec[Mode].ImgWidth * ModeSpec[Mode].NumLines * 3, sizeof(_PixelGrid) ); + uint8_t Channel = 0, WinIdx = 0; + _PixelGrid *PixelGrid = calloc( ModeSpec[Mode].ImgWidth * ModeSpec[Mode].NumLines * 3, sizeof(_PixelGrid) ); // Initialize Hann windows of different lengths - gushort HannLens[7] = { 48, 64, 96, 128, 256, 512, 1024 }; + uint16_t HannLens[7] = { 48, 64, 96, 128, 256, 512, 1024 }; for (j = 0; j < 7; j++) for (i = 0; i < HannLens[j]; i++) Hann[j][i] = 0.5 * (1 - cos( (2 * M_PI * i) / (HannLens[j] - 1)) ); @@ -60,6 +78,14 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { // Starting times of video channels on every line, counted from beginning of line switch (Mode) { + case R72: + ChanLen[0] = ModeSpec[Mode].PixelTime * ModeSpec[Mode].ImgWidth * 2; + ChanLen[1] = ChanLen[2] = ModeSpec[Mode].PixelTime * ModeSpec[Mode].ImgWidth; + ChanStart[0] = ModeSpec[Mode].SyncTime + ModeSpec[Mode].PorchTime; + ChanStart[1] = ChanStart[0] + ChanLen[0] + ModeSpec[Mode].SeptrTime; + ChanStart[2] = ChanStart[1] + ChanLen[1] + ModeSpec[Mode].SeptrTime; + break; + case R36: case R24: ChanLen[0] = ModeSpec[Mode].PixelTime * ModeSpec[Mode].ImgWidth * 2; @@ -130,21 +156,21 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { } // Plan ahead the time instants (in samples) at which to take pixels out - int PixelIdx = 0; + int32_t PixelIdx = 0; if (NumChans == 4){ //Woking on PD* mode //Each radio frame encodes two image lines for (y = 0; y < ModeSpec[Mode].NumLines; y += 2){ for (Channel = 0; Channel < NumChans; Channel++){ for (x = 0; x < ModeSpec[Mode].ImgWidth; x++){ - PixelGrid[PixelIdx].Time = (int)round(Rate * ( y/2 * ModeSpec[Mode].LineTime + ChanStart[Channel] + + PixelGrid[PixelIdx].Time = (int32_t)round(Rate * ( y/2 * ModeSpec[Mode].LineTime + ChanStart[Channel] + ModeSpec[Mode].PixelTime * 1.0 * (x + 0.5))) + Skip; if (Channel == 0) { PixelGrid[PixelIdx].X = x; PixelGrid[PixelIdx].Y = y; PixelGrid[PixelIdx].Channel = Channel; - PixelGrid[PixelIdx].Last = FALSE; + PixelGrid[PixelIdx].Last = false; PixelIdx++; } @@ -152,13 +178,13 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { PixelGrid[PixelIdx].X = x; PixelGrid[PixelIdx].Y = y; PixelGrid[PixelIdx].Channel = Channel; - PixelGrid[PixelIdx].Last = FALSE; + PixelGrid[PixelIdx].Last = false; PixelIdx++; PixelGrid[PixelIdx].Time = PixelGrid[PixelIdx - 1].Time; PixelGrid[PixelIdx].X = x; PixelGrid[PixelIdx].Y = y + 1; PixelGrid[PixelIdx].Channel = Channel; - PixelGrid[PixelIdx].Last = FALSE; + PixelGrid[PixelIdx].Last = false; PixelIdx++; } @@ -166,13 +192,13 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { PixelGrid[PixelIdx].X = x; PixelGrid[PixelIdx].Y = y + 1; PixelGrid[PixelIdx].Channel = 0; - PixelGrid[PixelIdx].Last = FALSE; + PixelGrid[PixelIdx].Last = false; PixelIdx++; } } } } - PixelGrid[PixelIdx - 1].Last = TRUE; + PixelGrid[PixelIdx - 1].Last = true; } else { for (y = 0; y < ModeSpec[Mode].NumLines; y++) { @@ -193,19 +219,19 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { PixelGrid[PixelIdx].Channel = Channel; } - PixelGrid[PixelIdx].Time = (int)round(Rate * (y * ModeSpec[Mode].LineTime + ChanStart[Channel] + + PixelGrid[PixelIdx].Time = (int32_t)round(Rate * (y * ModeSpec[Mode].LineTime + ChanStart[Channel] + (1.0 * (x - .5) / ModeSpec[Mode].ImgWidth * ChanLen[PixelGrid[PixelIdx].Channel]))) + Skip; PixelGrid[PixelIdx].X = x; PixelGrid[PixelIdx].Y = y; - PixelGrid[PixelIdx].Last = FALSE; + PixelGrid[PixelIdx].Last = false; PixelIdx++; } } } - PixelGrid[PixelIdx - 1].Last = TRUE; + PixelGrid[PixelIdx - 1].Last = true; } for (k = 0; k < PixelIdx; k++) { @@ -229,31 +255,20 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { break;*/ // Initialize pixbuffer - if (!Redraw) { - g_object_unref(pixbuf_rx); - pixbuf_rx = gdk_pixbuf_new (GDK_COLORSPACE_RGB, FALSE, 8, ModeSpec[Mode].ImgWidth, ModeSpec[Mode].NumLines); - gdk_pixbuf_fill(pixbuf_rx, 0); + if ((!Redraw) && OnVideoInitBuffer) { + OnVideoInitBuffer(Mode); } - int rowstride = gdk_pixbuf_get_rowstride (pixbuf_rx); - guchar *pixels, *p; - pixels = gdk_pixbuf_get_pixels(pixbuf_rx); - - g_object_unref(pixbuf_disp); - pixbuf_disp = gdk_pixbuf_scale_simple(pixbuf_rx, 500, - 500.0/ModeSpec[Mode].ImgWidth * ModeSpec[Mode].NumLines * ModeSpec[Mode].LineHeight, GDK_INTERP_BILINEAR); - - gdk_threads_enter(); - gtk_image_set_from_pixbuf(GTK_IMAGE(gui.image_rx), pixbuf_disp); - gdk_threads_leave(); + if (OnVideoStartRedraw) { + OnVideoStartRedraw(); + } - if(NumChans == 4) //In PD* modes, each radio frame encodes two image lines - Length = ModeSpec[Mode].LineTime * ModeSpec[Mode].NumLines/2 * 44100; + Length = ModeSpec[Mode].LineTime * ModeSpec[Mode].NumLines/2 * pcm.SampleRate; else - Length = ModeSpec[Mode].LineTime * ModeSpec[Mode].NumLines * 44100; - SyncTargetBin = GetBin(1200 + CurrentPic.HedrShift, FFTLen); - Abort = FALSE; + Length = ModeSpec[Mode].LineTime * ModeSpec[Mode].NumLines * pcm.SampleRate; + SyncTargetBin = GetBin(1200 + CurrentPic.HedrShift, VIDEO_FFT_LEN); + Abort = false; SyncSampleNum = 0; // Loop through signal @@ -263,7 +278,7 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { /*** Read ahead from sound card ***/ - if (pcm.WindowPtr == 0 || pcm.WindowPtr >= BUFLEN-1024) readPcm(2048); + if (pcm.WindowPtr == 0 || pcm.WindowPtr >= BUFLEN-FFT_HALF_SZ) readPcm(FFT_FULL_SZ); /*** Store the sync band for later adjustments ***/ @@ -272,20 +287,20 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { Praw = Psync = 0; - memset(fft.in, 0, sizeof(double)*FFTLen); + memset(fft.in, 0, sizeof(double)*VIDEO_FFT_LEN); // Hann window for (i = 0; i < 64; i++) fft.in[i] = pcm.Buffer[pcm.WindowPtr+i-32] / 32768.0 * Hann[1][i]; - fftw_execute(fft.Plan1024); + fftw_execute(fft.PlanHalf); - for (i=GetBin(1500+CurrentPic.HedrShift,FFTLen); i<=GetBin(2300+CurrentPic.HedrShift, FFTLen); i++) + for (i=GetBin(1500+CurrentPic.HedrShift,VIDEO_FFT_LEN); i<=GetBin(2300+CurrentPic.HedrShift, VIDEO_FFT_LEN); i++) Praw += power(fft.out[i]); for (i=SyncTargetBin-1; i<=SyncTargetBin+1; i++) - Psync += power(fft.out[i]) * (1- .5*abs(SyncTargetBin-i)); + Psync += power(fft.out[i]) * (1- .5*abs((int32_t)(SyncTargetBin-i))); - Praw /= (GetBin(2300+CurrentPic.HedrShift, FFTLen) - GetBin(1500+CurrentPic.HedrShift, FFTLen)); + Praw /= (GetBin(2300+CurrentPic.HedrShift, VIDEO_FFT_LEN) - GetBin(1500+CurrentPic.HedrShift, VIDEO_FFT_LEN)); Psync /= 2.0; // If there is more than twice the amount of power per Hz in the @@ -303,35 +318,35 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { if (SampleNum == NextSNRtime) { - memset(fft.in, 0, sizeof(double)*FFTLen); + memset(fft.in, 0, sizeof(double)*VIDEO_FFT_LEN); // Apply Hann window - for (i = 0; i < FFTLen; i++) fft.in[i] = pcm.Buffer[pcm.WindowPtr + i - FFTLen/2] / 32768.0 * Hann[6][i]; + for (i = 0; i < VIDEO_FFT_LEN; i++) fft.in[i] = pcm.Buffer[pcm.WindowPtr + i - VIDEO_FFT_LEN/2] / 32768.0 * Hann[6][i]; - fftw_execute(fft.Plan1024); + fftw_execute(fft.PlanHalf); // Calculate video-plus-noise power (1500-2300 Hz) Pvideo_plus_noise = 0; - for (n = GetBin(1500+CurrentPic.HedrShift, FFTLen); n <= GetBin(2300+CurrentPic.HedrShift, FFTLen); n++) + for (n = GetBin(1500+CurrentPic.HedrShift, VIDEO_FFT_LEN); n <= GetBin(2300+CurrentPic.HedrShift, VIDEO_FFT_LEN); n++) Pvideo_plus_noise += power(fft.out[n]); // Calculate noise-only power (400-800 Hz + 2700-3400 Hz) Pnoise_only = 0; - for (n = GetBin(400+CurrentPic.HedrShift, FFTLen); n <= GetBin(800+CurrentPic.HedrShift, FFTLen); n++) + for (n = GetBin(400+CurrentPic.HedrShift, VIDEO_FFT_LEN); n <= GetBin(800+CurrentPic.HedrShift, VIDEO_FFT_LEN); n++) Pnoise_only += power(fft.out[n]); - for (n = GetBin(2700+CurrentPic.HedrShift, FFTLen); n <= GetBin(3400+CurrentPic.HedrShift, FFTLen); n++) + for (n = GetBin(2700+CurrentPic.HedrShift, VIDEO_FFT_LEN); n <= GetBin(3400+CurrentPic.HedrShift, VIDEO_FFT_LEN); n++) Pnoise_only += power(fft.out[n]); // Bandwidths - VideoPlusNoiseBins = GetBin(2300, FFTLen) - GetBin(1500, FFTLen) + 1; + VideoPlusNoiseBins = GetBin(2300, VIDEO_FFT_LEN) - GetBin(1500, VIDEO_FFT_LEN) + 1; - NoiseOnlyBins = GetBin(800, FFTLen) - GetBin(400, FFTLen) + 1 + - GetBin(3400, FFTLen) - GetBin(2700, FFTLen) + 1; + NoiseOnlyBins = GetBin(800, VIDEO_FFT_LEN) - GetBin(400, VIDEO_FFT_LEN) + 1 + + GetBin(3400, VIDEO_FFT_LEN) - GetBin(2700, VIDEO_FFT_LEN) + 1; - ReceiverBins = GetBin(3400, FFTLen) - GetBin(400, FFTLen); + ReceiverBins = GetBin(3400, VIDEO_FFT_LEN) - GetBin(400, VIDEO_FFT_LEN); // Eq 15 Pnoise = Pnoise_only * (1.0 * ReceiverBins / NoiseOnlyBins); @@ -347,9 +362,9 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { /*** FM demodulation ***/ - if (SampleNum % 6 == 0) { // Take FFT every 6 samples + if (SampleNum % VIDEO_FFT_INTERVAL == 0) { // Take FFT every interval - PrevFreq = Freq; + //PrevFreq = Freq; // Adapt window size to SNR @@ -366,20 +381,20 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { // Minimum winlength can be doubled for Scottie DX if (Mode == SDX && WinIdx < 6) WinIdx++; - memset(fft.in, 0, sizeof(double)*FFTLen); - memset(Power, 0, sizeof(double)*1024); + memset(fft.in, 0, sizeof(double)*VIDEO_FFT_LEN); + memset(Power, 0, sizeof(double)*VIDEO_FFT_LEN); // Apply window function WinLength = HannLens[WinIdx]; for (i = 0; i < WinLength; i++) fft.in[i] = pcm.Buffer[pcm.WindowPtr + i - WinLength/2] / 32768.0 * Hann[WinIdx][i]; - fftw_execute(fft.Plan1024); + fftw_execute(fft.PlanHalf); MaxBin = 0; // Find the bin with most power - for (n = GetBin(1500 + CurrentPic.HedrShift, FFTLen) - 1; n <= GetBin(2300 + CurrentPic.HedrShift, FFTLen) + 1; n++) { + for (n = GetBin(1500 + CurrentPic.HedrShift, VIDEO_FFT_LEN) - 1; n <= GetBin(2300 + CurrentPic.HedrShift, VIDEO_FFT_LEN) + 1; n++) { Power[n] = power(fft.out[n]); if (MaxBin == 0 || Power[n] > Power[MaxBin]) MaxBin = n; @@ -387,14 +402,14 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { } // Find the peak frequency by Gaussian interpolation - if (MaxBin > GetBin(1500 + CurrentPic.HedrShift, FFTLen) - 1 && MaxBin < GetBin(2300 + CurrentPic.HedrShift, FFTLen) + 1) { + if (MaxBin > GetBin(1500 + CurrentPic.HedrShift, VIDEO_FFT_LEN) - 1 && MaxBin < GetBin(2300 + CurrentPic.HedrShift, VIDEO_FFT_LEN) + 1) { Freq = MaxBin + (log( Power[MaxBin + 1] / Power[MaxBin - 1] )) / (2 * log( pow(Power[MaxBin], 2) / (Power[MaxBin + 1] * Power[MaxBin - 1]))); // In Hertz - Freq = Freq / FFTLen * 44100; + Freq = Freq / VIDEO_FFT_LEN * pcm.SampleRate; } else { // Clip if out of bounds - Freq = ( (MaxBin > GetBin(1900 + CurrentPic.HedrShift, FFTLen)) ? 2300 : 1500 ) + CurrentPic.HedrShift; + Freq = ( (MaxBin > GetBin(1900 + CurrentPic.HedrShift, VIDEO_FFT_LEN)) ? 2300 : 1500 ) + CurrentPic.HedrShift; } } /* endif (SampleNum == PixelGrid[PixelIdx].Time) */ @@ -418,53 +433,50 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { Channel = PixelGrid[PixelIdx].Channel; // Store pixel - Image[x][y][Channel] = StoredLum[SampleNum]; + VideoImage[x][y][Channel] = StoredLum[SampleNum]; // Some modes have R-Y & B-Y channels that are twice the height of the Y channel if (Channel > 0 && (Mode == R36 || Mode == R24)) - Image[x][y+1][Channel] = StoredLum[SampleNum]; + VideoImage[x][y+1][Channel] = StoredLum[SampleNum]; // Calculate and draw pixels to pixbuf on line change if (x == ModeSpec[Mode].ImgWidth - 1 || PixelGrid[PixelIdx].Last) { for (tx = 0; tx < ModeSpec[Mode].ImgWidth; tx++) { - p = pixels + y * rowstride + tx * 3; + uint8_t r = 0, g = 0, b = 0; switch(ModeSpec[Mode].ColorEnc) { case RGB: - p[0] = Image[tx][y][0]; - p[1] = Image[tx][y][1]; - p[2] = Image[tx][y][2]; + r = VideoImage[tx][y][0]; + g = VideoImage[tx][y][1]; + b = VideoImage[tx][y][2]; break; case GBR: - p[0] = Image[tx][y][2]; - p[1] = Image[tx][y][0]; - p[2] = Image[tx][y][1]; + r = VideoImage[tx][y][2]; + g = VideoImage[tx][y][0]; + b = VideoImage[tx][y][1]; break; case YUV: - p[0] = clip((100 * Image[tx][y][0] + 140 * Image[tx][y][1] - 17850) / 100.0); - p[1] = clip((100 * Image[tx][y][0] - 71 * Image[tx][y][1] - 33 * - Image[tx][y][2] + 13260) / 100.0); - p[2] = clip((100 * Image[tx][y][0] + 178 * Image[tx][y][2] - 22695) / 100.0); + r = clip((100 * VideoImage[tx][y][0] + 140 * VideoImage[tx][y][1] - 17850) / 100.0); + g = clip((100 * VideoImage[tx][y][0] - 71 * VideoImage[tx][y][1] - 33 * + VideoImage[tx][y][2] + 13260) / 100.0); + b = clip((100 * VideoImage[tx][y][0] + 178 * VideoImage[tx][y][2] - 22695) / 100.0); break; case BW: - p[0] = p[1] = p[2] = Image[tx][y][0]; + r = g = b = VideoImage[tx][y][0]; break; } - } - if (!Redraw || y % 5 == 0 || PixelGrid[PixelIdx].Last) { - // Scale and update image - g_object_unref(pixbuf_disp); - pixbuf_disp = gdk_pixbuf_scale_simple(pixbuf_rx, 500, - 500.0 / ModeSpec[Mode].ImgWidth * ModeSpec[Mode].NumLines * ModeSpec[Mode].LineHeight, GDK_INTERP_BILINEAR); + if (OnVideoWritePixel) { + OnVideoWritePixel(tx, y, r, g, b); + } + } - gdk_threads_enter(); - gtk_image_set_from_pixbuf(GTK_IMAGE(gui.image_rx), pixbuf_disp); - gdk_threads_leave(); + if ((!Redraw || y % 5 == 0 || PixelGrid[PixelIdx].Last) && OnVideoRefresh) { + OnVideoRefresh(); } } @@ -472,13 +484,13 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { } } /* endif (SampleNum == PixelGrid[PixelIdx].Time) */ - if (!Redraw && SampleNum % 8820 == 0) { - setVU(Power, FFTLen, WinIdx, TRUE); + if (!Redraw && (SampleNum % (2*PCM_MS_FRAMES(100)) == 0) && OnVideoPowerCalculated) { + OnVideoPowerCalculated(Power, VIDEO_FFT_LEN, WinIdx); } if (Abort) { free(PixelGrid); - return FALSE; + return false; } pcm.WindowPtr ++; @@ -486,6 +498,6 @@ gboolean GetVideo(guchar Mode, double Rate, int Skip, gboolean Redraw) { } free(PixelGrid); - return TRUE; + return true; } diff --git a/video.h b/video.h new file mode 100644 index 0000000..37ad9d8 --- /dev/null +++ b/video.h @@ -0,0 +1,15 @@ +#ifndef _VIDEO_H_ +#define _VIDEO_H_ + +#include +#include + +extern void (*OnVideoInitBuffer)(uint8_t Mode); +extern EventCallback OnVideoStartRedraw; +extern void (*OnVideoWritePixel)(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b); +extern EventCallback OnVideoRefresh; +extern UpdateVUCallback OnVideoPowerCalculated; + +_Bool GetVideo (uint8_t Mode, double Rate, int32_t Skip, _Bool Redraw); + +#endif diff --git a/vis.c b/vis.c index 6f7eb26..eaf0aaa 100644 --- a/vis.c +++ b/vis.c @@ -1,11 +1,22 @@ #include +#include #include -#include #include #include #include "common.h" +#include "fft.h" +#include "modespec.h" +#include "pcm.h" +#include "pic.h" + +/* Approximate number of frames / samples for 10ms: NB can change at runtime! */ +#define VIS_10MS_FRAMES PCM_MS_FRAMES(10) +#define VIS_10MS_SAMPLES (VIS_10MS_FRAMES*2) + +/* Ditto, for 20ms */ +#define VIS_20MS_FRAMES PCM_MS_FRAMES(20) /* * @@ -15,59 +26,74 @@ * */ -guchar GetVIS () { +TextStatusCallback OnVisStatusChange; +EventCallback OnVisIdentified; +UpdateVUCallback OnVisPowerComputed; +uint8_t VIS; +_Bool VisAutoStart; + +#define VIS_FFT_LEN (FFT_FULL_SZ) +static double VisPower[VIS_FFT_LEN] = {0}; + +uint8_t GetVIS () { + + int32_t ptr=0; + int32_t Parity = 0, HedrPtr = 0; + uint32_t i=0, j=0, k=0, MaxBin = 0; + double HedrBuf[100] = {0}, tone[100] = {0}, Hann[VIS_10MS_SAMPLES]; + _Bool gotvis = false; + uint8_t Bit[8] = {0}, ParityBit = 0; - int selmode, ptr=0; - int VIS = 0, Parity = 0, HedrPtr = 0; - guint FFTLen = 2048, i=0, j=0, k=0, MaxBin = 0; - double Power[2048] = {0}, HedrBuf[100] = {0}, tone[100] = {0}, Hann[882] = {0}; - gboolean gotvis = FALSE; - guchar Bit[8] = {0}, ParityBit = 0; + memset(Hann, 0, sizeof(double) * VIS_10MS_SAMPLES); - for (i = 0; i < FFTLen; i++) fft.in[i] = 0; + for (i = 0; i < VIS_FFT_LEN; i++) fft.in[i] = 0; // Create 20ms Hann window - for (i = 0; i < 882; i++) Hann[i] = 0.5 * (1 - cos( (2 * M_PI * (double)i) / 881 ) ); + for (i = 0; i < VIS_10MS_SAMPLES; i++) { + Hann[i] = 0.5 * (1 - cos( (2 * M_PI * (double)i) / (VIS_10MS_SAMPLES-1) ) ); + } - ManualActivated = FALSE; + ManualActivated = false; printf("Waiting for header\n"); - gdk_threads_enter(); - gtk_statusbar_push( GTK_STATUSBAR(gui.statusbar), 0, "Listening" ); - gdk_threads_leave(); + if (OnVisStatusChange) { + OnVisStatusChange("Listening"); + } - while ( TRUE ) { + while ( true ) { if (Abort || ManualResync) return(0); // Read 10 ms from sound card - readPcm(441); + readPcm(VIS_10MS_FRAMES); // Apply Hann window - for (i = 0; i < 882; i++) fft.in[i] = pcm.Buffer[pcm.WindowPtr + i - 441] / 32768.0 * Hann[i]; + for (i = 0; i < VIS_10MS_SAMPLES; i++) { + fft.in[i] = pcm.Buffer[pcm.WindowPtr + i - VIS_10MS_FRAMES] / 32768.0 * Hann[i]; + } // FFT of last 20 ms - fftw_execute(fft.Plan2048); + fftw_execute(fft.PlanFull); // Find the bin with most power MaxBin = 0; - for (i = 0; i <= GetBin(6000, FFTLen); i++) { - Power[i] = power(fft.out[i]); - if ( (i >= GetBin(500,FFTLen) && i < GetBin(3300,FFTLen)) && - (MaxBin == 0 || Power[i] > Power[MaxBin])) + for (i = 0; i <= GetBin(6000, VIS_FFT_LEN); i++) { + VisPower[i] = power(fft.out[i]); + if ( (i >= GetBin(500,VIS_FFT_LEN) && i < GetBin(3300,VIS_FFT_LEN)) && + (MaxBin == 0 || VisPower[i] > VisPower[MaxBin])) MaxBin = i; } // Find the peak frequency by Gaussian interpolation - if (MaxBin > GetBin(500, FFTLen) && MaxBin < GetBin(3300, FFTLen) && - Power[MaxBin] > 0 && Power[MaxBin+1] > 0 && Power[MaxBin-1] > 0) - HedrBuf[HedrPtr] = MaxBin + (log( Power[MaxBin + 1] / Power[MaxBin - 1] )) / - (2 * log( pow(Power[MaxBin], 2) / (Power[MaxBin + 1] * Power[MaxBin - 1]))); + if (MaxBin > GetBin(500, VIS_FFT_LEN) && MaxBin < GetBin(3300, VIS_FFT_LEN) && + VisPower[MaxBin] > 0 && VisPower[MaxBin+1] > 0 && VisPower[MaxBin-1] > 0) + HedrBuf[HedrPtr] = MaxBin + (log( VisPower[MaxBin + 1] / VisPower[MaxBin - 1] )) / + (2 * log( pow(VisPower[MaxBin], 2) / (VisPower[MaxBin + 1] * VisPower[MaxBin - 1]))); else HedrBuf[HedrPtr] = HedrBuf[(HedrPtr-1) % 45]; // In Hertz - HedrBuf[HedrPtr] = HedrBuf[HedrPtr] / FFTLen * 44100; + HedrBuf[HedrPtr] = HedrBuf[HedrPtr] / VIS_FFT_LEN * pcm.SampleRate; // Header buffer holds 45 * 10 msec = 450 msec HedrPtr = (HedrPtr + 1) % 45; @@ -78,7 +104,7 @@ guchar GetVIS () { // Is there a pattern that looks like (the end of) a calibration header + VIS? // Tolerance ±25 Hz CurrentPic.HedrShift = 0; - gotvis = FALSE; + gotvis = false; for (i = 0; i < 3; i++) { if (CurrentPic.HedrShift != 0) break; for (j = 0; j < 3; j++) { @@ -93,12 +119,12 @@ guchar GetVIS () { // Attempt to read VIS - gotvis = TRUE; + gotvis = true; for (k = 0; k < 8; k++) { if (tone[6*3+i+3*k] > tone[0+j] - 625 && tone[6*3+i+3*k] < tone[0+j] - 575) Bit[k] = 0; else if (tone[6*3+i+3*k] > tone[0+j] - 825 && tone[6*3+i+3*k] < tone[0+j] - 775) Bit[k] = 1; else { // erroneous bit - gotvis = FALSE; + gotvis = false; break; } } @@ -113,19 +139,22 @@ guchar GetVIS () { Parity = Bit[0] ^ Bit[1] ^ Bit[2] ^ Bit[3] ^ Bit[4] ^ Bit[5] ^ Bit[6]; - if (VISmap[VIS] == R12BW) Parity = !Parity; - if (Parity != ParityBit) { - printf(" Parity fail\n"); - gotvis = FALSE; + // Maybe this mode uses odd parity? + printf(" Parity inconclusive, trying odd parity\n"); + if (VISmap[VIS | VIS_PARITY_ODD] == UNKNOWN) { + // Nope! + printf(" Parity fail\n"); + gotvis = false; + } else { + // Yep, that was it. Inverted parity. + VIS |= VIS_PARITY_ODD; + break; + } } else if (VISmap[VIS] == UNKNOWN) { printf(" Unknown VIS\n"); - gotvis = FALSE; + gotvis = false; } else { - gdk_threads_enter(); - gtk_combo_box_set_active (GTK_COMBO_BOX(gui.combo_mode), VISmap[VIS]-1); - gtk_spin_button_set_value (GTK_SPIN_BUTTON(gui.spin_shift), CurrentPic.HedrShift); - gdk_threads_leave(); break; } } @@ -133,41 +162,31 @@ guchar GetVIS () { } } - if (gotvis) - if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gui.tog_rx))) break; + if (gotvis && OnVisIdentified) { + OnVisIdentified(); + } + if (gotvis && VisAutoStart) { + break; + } // Manual start if (ManualActivated) { - - gdk_threads_enter(); - gtk_widget_set_sensitive( gui.frame_manual, FALSE ); - gtk_widget_set_sensitive( gui.combo_card, FALSE ); - gdk_threads_leave(); - - selmode = gtk_combo_box_get_active (GTK_COMBO_BOX(gui.combo_mode)) + 1; - CurrentPic.HedrShift = gtk_spin_button_get_value_as_int (GTK_SPIN_BUTTON(gui.spin_shift)); - VIS = 0; - for (i=0; i<0x80; i++) { - if (VISmap[i] == selmode) { - VIS = i; - break; - } - } - break; } if (++ptr == 10) { - setVU(Power, 2048, 6, FALSE); + if (OnVisPowerComputed) { + OnVisPowerComputed(VisPower, VIS_FFT_LEN, 6); + } ptr = 0; } - pcm.WindowPtr += 441; + pcm.WindowPtr += VIS_10MS_FRAMES; } // Skip the rest of the stop bit - readPcm(20e-3 * 44100); - pcm.WindowPtr += 20e-3 * 44100; + readPcm(VIS_20MS_FRAMES); + pcm.WindowPtr += VIS_20MS_FRAMES; if (VISmap[VIS] != UNKNOWN) return VISmap[VIS]; else printf(" No VIS found\n"); diff --git a/vis.h b/vis.h new file mode 100644 index 0000000..c977d15 --- /dev/null +++ b/vis.h @@ -0,0 +1,15 @@ +#ifndef _VIS_H_ +#define _VIS_H_ + +#include +#include + +extern TextStatusCallback OnVisStatusChange; +extern EventCallback OnVisIdentified; +extern UpdateVUCallback OnVisPowerComputed; +extern uint8_t VIS; +extern _Bool VisAutoStart; + +uint8_t GetVIS(); + +#endif