diff options
author | Přemysl Eric Janouch <p@janouch.name> | 2021-07-03 23:58:05 +0200 |
---|---|---|
committer | Přemysl Eric Janouch <p@janouch.name> | 2021-07-05 01:10:46 +0200 |
commit | a439a56ee9c4cbf92817bd5bd1c89c59c4e5964b (patch) | |
tree | a3aace0b50fe1662a05833817d1cf64a1b293646 | |
parent | 120a11ca1b4644d9761ebba2e0341cf59411cf89 (diff) | |
download | nncmpp-a439a56ee9c4cbf92817bd5bd1c89c59c4e5964b.tar.gz nncmpp-a439a56ee9c4cbf92817bd5bd1c89c59c4e5964b.tar.xz nncmpp-a439a56ee9c4cbf92817bd5bd1c89c59c4e5964b.zip |
Add an optional spectrum visualiser
This is really more of a demo. It's doable, just rather ugly.
It would deserve some further tuning, if anyone cared enough.
-rw-r--r-- | CMakeLists.txt | 25 | ||||
-rw-r--r-- | NEWS | 2 | ||||
-rw-r--r-- | config.h.in | 1 | ||||
-rw-r--r-- | nncmpp.adoc | 22 | ||||
-rw-r--r-- | nncmpp.c | 464 |
5 files changed, 510 insertions, 4 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 3489cda..97763e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,7 +21,6 @@ include (AddThreads) find_package (Termo QUIET NO_MODULE) option (USE_SYSTEM_TERMO "Don't compile our own termo library, use the system one" ${Termo_FOUND}) - if (USE_SYSTEM_TERMO) if (NOT Termo_FOUND) message (FATAL_ERROR "System termo library not found") @@ -38,9 +37,18 @@ else () set (Termo_LIBRARIES termo-static) endif () +pkg_check_modules (fftw fftw3 fftw3f) +option (WITH_FFTW "Use FFTW to enable spectrum visualisation" ${fftw_FOUND}) +if (WITH_FFTW) + if (NOT fftw_FOUND) + message (FATAL_ERROR "FFTW not found") + endif() +endif () + include_directories (${Unistring_INCLUDE_DIRS} - ${Ncursesw_INCLUDE_DIRS} ${Termo_INCLUDE_DIRS} ${curl_INCLUDE_DIRS}) -link_directories (${curl_LIBRARY_DIRS}) + ${Ncursesw_INCLUDE_DIRS} ${Termo_INCLUDE_DIRS} ${curl_INCLUDE_DIRS} + ${fftw_INCLUDE_DIRS}) +link_directories (${curl_LIBRARY_DIRS} ${fftw_LIBRARY_DIRS}) # Configuration include (CheckFunctionExists) @@ -53,6 +61,14 @@ if ("${CMAKE_SYSTEM_NAME}" MATCHES "BSD") add_definitions (-D__BSD_VISIBLE=1 -D_BSD_SOURCE=1) endif () +# -lm may or may not be a part of libc +foreach (extra m) + find_library (extra_lib_${extra} ${extra}) + if (extra_lib_${extra}) + list (APPEND extra_libraries ${extra_lib_${extra}}) + endif () +endforeach () + # Generate a configuration file configure_file (${PROJECT_SOURCE_DIR}/config.h.in ${PROJECT_BINARY_DIR}/config.h) @@ -61,7 +77,8 @@ include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR}) # Build the main executable and link it add_executable (${PROJECT_NAME} ${PROJECT_NAME}.c) target_link_libraries (${PROJECT_NAME} ${Unistring_LIBRARIES} - ${Ncursesw_LIBRARIES} termo-static ${curl_LIBRARIES}) + ${Ncursesw_LIBRARIES} termo-static ${curl_LIBRARIES} + ${fftw_LIBRARIES} ${extra_libraries}) add_threads (${PROJECT_NAME}) # Installation @@ -3,6 +3,8 @@ * Now requesting and processing terminal de/focus events, using a new "defocused" attribute for selected rows + * Made it possible to show a spectrum visualiser when built against FFTW + 1.0.0 (2020-11-05) diff --git a/config.h.in b/config.h.in index 0fd8a30..67fd7cd 100644 --- a/config.h.in +++ b/config.h.in @@ -5,6 +5,7 @@ #define PROGRAM_VERSION "${PROJECT_VERSION}" #cmakedefine HAVE_RESIZETERM +#cmakedefine WITH_FFTW #endif // ! CONFIG_H diff --git a/nncmpp.adoc b/nncmpp.adoc index fd4f888..0909dd3 100644 --- a/nncmpp.adoc +++ b/nncmpp.adoc @@ -55,6 +55,7 @@ colors = { odd = "" selection = "reverse" multiselect = "-1 6" + defocused = "ul" scrollbar = "" } streams = { @@ -70,6 +71,27 @@ schemes in the _contrib_ directory. // TODO: it seems like liberty should contain an includable snippet about // the format, which could form a part of nncmpp.conf(5). +Spectrum visualiser +------------------- +When built against the FFTW library, *nncmpp* can make use of MPD's "fifo" +output plugin to show the audio spectrum. This has some caveats, namely that +it may not be properly synchronized, only one instance of a client can read from +a given named pipe at a time, it will cost you some CPU time, and finally you'll +need to set it up manually to match your MPD configuration, e.g.: + +.... +settings = { + ... + spectrum_path = "~/.mpd/mpd.fifo" # "path" + spectrum_format = "44100:16:2" # "format" (samplerate:bits:channels) + spectrum_bars = 8 # beware of exponential complexity + ... +} +.... + +The sample rate should be greater than 40 kHz, the number of bits 8 or 16, +and the number of channels doesn't matter, as they're simply averaged together. + Files ----- *nncmpp* follows the XDG Base Directory Specification. @@ -95,6 +95,13 @@ enum #include <curl/curl.h> +// The spectrum analyser requires a DFT transform. The FFTW library is fairly +// efficient, and doesn't have a requirement on the number of bins. + +#ifdef WITH_FFTW +#include <fftw3.h> +#endif // WITH_FFTW + #define APP_TITLE PROGRAM_NAME ///< Left top corner // --- Utilities --------------------------------------------------------------- @@ -560,6 +567,273 @@ item_list_resize (struct item_list *self, size_t len) self->len = len; } +// --- Spectrum analyzer ------------------------------------------------------- + +#ifdef WITH_FFTW + +struct spectrum +{ + int sampling_rate; ///< Number of samples per seconds + int channels; ///< Number of sampled channels + int bits; ///< Number of bits per sample + int bars; ///< Number of output vertical bars + + int bins; ///< Number of DFT bins + int useful_bins; ///< Bins up to the Nyquist frequency + int samples; ///< Number of windows to average + float accumulator_scale; ///< Scaling factor for accum. values + int *top_bins; ///< Top DFT bin index for each bar + char *spectrum; ///< String buffer for the "render" + + void *buffer; ///< Input buffer + size_t buffer_len; ///< Input buffer fill level + size_t buffer_size; ///< Input buffer size + + /// Decode the respective part of the buffer into the last 1/3 of data + void (*decode) (struct spectrum *, int sample); + + float *data; ///< Normalized audio data + float *window; ///< Sampled window function + float *windowed; ///< data * window + fftwf_complex *out; ///< DFT output + fftwf_plan p; ///< DFT plan/FFTW configuration + float *accumulator; ///< Accumulated powers of samples +}; + +// - - Windows - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Out: float[n] of 0..1 +static void +window_hann (float *coefficients, size_t n) +{ + for (size_t i = 0; i < n; i++) + { + float sine = sin (M_PI * i / n); + coefficients[i] = sine * sine; + } +} + +// In: float[n] of -1..1, float[n] of 0..1; out: float[n] of -1..1 +static void +window_apply (const float *in, const float *coefficients, float *out, size_t n) +{ + for (size_t i = 0; i < n; i++) + out[i] = in[i] * coefficients[i]; +} + +// In: float[n] of 0..1; out: float 0..n, describing the coherent gain +static float +window_coherent_gain (const float *in, size_t n) +{ + float sum = 0; + for (size_t i = 0; i < n; i++) + sum += in[i]; + return sum; +} + +// - - Decoding - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +spectrum_decode_8 (struct spectrum *s, int sample) +{ + size_t n = s->useful_bins; + float *data = s->data + n; + int8_t *p = (int8_t *) s->buffer + sample * n * s->channels; + while (n--) + { + int32_t acc = 0; + for (int ch = 0; ch < s->channels; ch++) + acc += *p++; + *data++ = (float) acc / -INT8_MIN / s->channels; + } +} + +static void +spectrum_decode_16 (struct spectrum *s, int sample) +{ + size_t n = s->useful_bins; + float *data = s->data + n; + int16_t *p = (int16_t *) s->buffer + sample * n * s->channels; + while (n--) + { + int32_t acc = 0; + for (int ch = 0; ch < s->channels; ch++) + acc += *p++; + *data++ = (float) acc / -INT16_MIN / s->channels; + } +} + +// - - Spectrum analysis - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static const char *spectrum_bars[] = + { " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" }; + +/// Assuming the input buffer is full, updates the rendered spectrum +static void +spectrum_sample (struct spectrum *s) +{ + memset (s->accumulator, 0, sizeof *s->accumulator * s->useful_bins); + + // Credit for the algorithm goes to Audacity's /src/SpectrumAnalyst.cpp, + // apparently Welch's method + for (int sample = 0; sample < s->samples; sample++) + { + // We use 50% overlap and start with data from the last run (if any) + memmove (s->data, s->data + s->useful_bins, + sizeof *s->data * s->useful_bins); + s->decode (s, sample); + + window_apply (s->data, s->window, s->windowed, s->bins); + fftwf_execute (s->p); + + for (int bin = 0; bin < s->useful_bins; bin++) + { + // out[0][0] is the DC component, not useful to us + float re = s->out[bin + 1][0]; + float im = s->out[bin + 1][1]; + s->accumulator[bin] += re * re + im * im; + } + } + + int last_bin = 0; + char *p = s->spectrum; + for (int bar = 0; bar < s->bars; bar++) + { + int top_bin = s->top_bins[bar]; + + // Think of this as accumulating energies within bands, + // so that it matches our non-linear hearing--there's no averaging. + // For more precision, we could employ an "equal loudness contour". + float acc = 0; + for (int bin = last_bin; bin < top_bin; bin++) + acc += s->accumulator[bin]; + + last_bin = top_bin; + float db = 10 * log10f (acc * s->accumulator_scale); + if (db > 0) + db = 0; + + // Assuming decibels are always negative (i.e., properly normalized). + // The division defines the cutoff: 9 * 7 = 63 dB of range. + int height = N_ELEMENTS (spectrum_bars) - 1 + (int) (db / 7); + p += strlen (strcpy (p, spectrum_bars[MAX (height, 0)])); + } +} + +static bool +spectrum_init (struct spectrum *s, char *format, int bars, struct error **e) +{ + errno = 0; + + long sampling_rate, bits, channels; + if (!format + || (sampling_rate = strtol (format, &format, 10), *format++ != ':') + || (bits = strtol (format, &format, 10), *format++ != ':') + || (channels = strtol (format, &format, 10), *format) + || errno != 0) + return error_set (e, "invalid format, expected RATE:BITS:CHANNELS"); + + if (sampling_rate < 20000 || sampling_rate > INT_MAX) + return error_set (e, "unsupported sampling rate (%ld)", sampling_rate); + if (bits != 8 && bits != 16) + return error_set (e, "unsupported bit count (%ld)", bits); + if (channels < 1 || channels > INT_MAX) + return error_set (e, "no channels to sample (%ld)", channels); + if (bars < 1 || bars > 12) + return error_set (e, "requested too few or too many bars (%d)", bars); + + // All that can fail henceforth is memory allocation + *s = (struct spectrum) + { + .sampling_rate = sampling_rate, + .bits = bits, + .channels = channels, + .bars = bars, + }; + + // The number of bars is always smaller than that of the samples (bins). + // Let's start with the equation of the top FFT bin to use for a given bar: + // top_bin = (num_bins + 1) ^ (bar / num_bars) - 1 + // N.b. if we didn't subtract, the power function would make this ≥ 1. + // N.b. we then also need to extend the range by the same amount. + // + // We need the amount of bins for the first bar to be at least one: + // 1 ≤ (num_bins + 1) ^ (1 / num_bars) - 1 + // + // Solving with Wolfram Alpha gives us: + // num_bins ≥ (2 ^ num_bars) - 1 [for y > 0] + // + // And we need to remember that half of the FFT bins are useless/missing-- + // FFTW skips useless points past the Nyquist frequency. + int necessary_bins = 2 << s->bars; + + // Discard frequencies above 20 kHz, which take up a constant ratio + // of all bins, given by the sampling rate. A more practical/efficient + // solution would be to just handle 96/192/... kHz as bitshifts. + // + // Trying to filter out sub-20 Hz frequencies would be even more wasteful. + double audible_ratio = s->sampling_rate / 2. / 20000; + s->bins = ceil (necessary_bins * MAX (audible_ratio, 1)); + s->useful_bins = s->bins / 2; + + int used_bins = necessary_bins / 2; + s->spectrum = xcalloc (sizeof *s->spectrum, s->bars * 3 + 1); + s->top_bins = xcalloc (sizeof *s->top_bins, s->bars); + for (int bar = 0; bar < s->bars; bar++) + { + int top_bin = floor (pow (used_bins + 1, (bar + 1.) / s->bars)) - 1; + s->top_bins[bar] = MIN (top_bin, used_bins); + } + + // Limit updates to 30 times per second to limit CPU load + s->samples = s->sampling_rate / s->bins * 2 / 30; + if (s->samples < 1) + s->samples = 1; + + if (s->bits == 8) s->decode = spectrum_decode_8; + if (s->bits == 16) s->decode = spectrum_decode_16; + + s->buffer_size = s->samples * s->useful_bins * s->bits / 8 * s->channels; + s->buffer = xcalloc (1, s->buffer_size); + + // Prepare the window + s->window = xcalloc (sizeof *s->window, s->bins); + window_hann (s->window, s->bins); + + // Multiply by 2 for only using half of the DFT's result, then adjust to + // the total energy of the window. Both squared, because the accumulator + // contains squared values. Compute the average, and convert to decibels. + // See also the mildly confusing https://dsp.stackexchange.com/a/14945. + float coherent_gain = window_coherent_gain (s->window, s->bins); + s->accumulator_scale = 2 * 2 / coherent_gain / coherent_gain / s->samples; + + s->data = xcalloc (sizeof *s->data, s->bins); + s->windowed = fftw_malloc (sizeof *s->windowed * s->bins); + s->out = fftw_malloc (sizeof *s->out * (s->useful_bins + 1)); + s->p = fftwf_plan_dft_r2c_1d (s->bins, s->windowed, s->out, FFTW_MEASURE); + s->accumulator = xcalloc (sizeof *s->accumulator, s->useful_bins); + return true; +} + +static void +spectrum_free (struct spectrum *s) +{ + free (s->accumulator); + fftwf_destroy_plan (s->p); + fftw_free (s->out); + fftw_free (s->windowed); + free (s->data); + free (s->window); + + free (s->spectrum); + free (s->top_bins); + free (s->buffer); + + memset (s, 0, sizeof *s); +} + +#endif // WITH_FFTW + // --- Application ------------------------------------------------------------- // Function names are prefixed mostly because of curses which clutters the @@ -675,6 +949,13 @@ static struct app_context int gauge_offset; ///< Offset to the gauge or -1 int gauge_width; ///< Width of the gauge, if present +#ifdef WITH_FFTW + struct spectrum spectrum; ///< Spectrum analyser + int spectrum_fd; ///< FIFO file descriptor (non-blocking) + int spectrum_column, spectrum_row; ///< Position for fast refresh + struct poller_fd spectrum_event; ///< FIFO watcher +#endif // WITH_FFTW + struct line_editor editor; ///< Line editor struct poller_idle refresh_event; ///< Refresh the screen @@ -750,6 +1031,22 @@ static struct config_schema g_config_settings[] = .comment = "Where all the files MPD is playing are located", .type = CONFIG_ITEM_STRING }, +#ifdef WITH_FFTW + { .name = "spectrum_path", + .comment = "Visualizer feed path to a FIFO audio output", + .type = CONFIG_ITEM_STRING }, + // MPD's "outputs" command doesn't include this information + { .name = "spectrum_format", + .comment = "Visualizer feed data format", + .type = CONFIG_ITEM_STRING, + .default_ = "\"44100:16:2\"" }, + // 10 is about the useful limit, then it gets too computationally expensive + { .name = "spectrum_bars", + .comment = "Number of computed audio spectrum bars", + .type = CONFIG_ITEM_INTEGER, + .default_ = "8" }, +#endif // WITH_FFTW + // Disabling this minimises MPD traffic and has the following caveats: // - when MPD stalls on retrieving audio data, we keep ticking // - when the "play" succeeds in ACTION_MPD_REPLACE for the same item as @@ -904,6 +1201,11 @@ app_init_context (void) g.playback_info = str_map_make (free); g.playback_info.key_xfrm = tolower_ascii_strxfrm; +#ifdef WITH_FFTW + g.spectrum_fd = -1; + g.spectrum_row = g.spectrum_column = -1; +#endif // WITH_FFTW + // This is also approximately what libunistring does internally, // since the locale name is canonicalized by locale_charset(). // Note that non-Unicode locales are handled pretty inefficiently. @@ -957,6 +1259,15 @@ app_free_context (void) strv_free (&g.streams); item_list_free (&g.playlist); +#ifdef WITH_FFTW + spectrum_free (&g.spectrum); + if (g.spectrum_fd != -1) + { + poller_fd_reset (&g.spectrum_event); + xclose (g.spectrum_fd); + } +#endif // WITH_FFTW + line_editor_free (&g.editor); config_free (&g.config); @@ -1218,6 +1529,21 @@ app_draw_header (void) g.tabs_offset = g.header_height; LIST_FOR_EACH (struct tab, iter, g.tabs) row_buffer_append (&buf, iter->name, attrs[iter == g.active_tab]); + +#ifdef WITH_FFTW + // This seems like the most reasonable, otherwise unoccupied space + if (g.spectrum_fd != -1) + { + // Find some space and remember where it was, for fast refreshes + row_buffer_ellipsis (&buf, COLS - g.spectrum.bars - 1); + row_buffer_align (&buf, COLS - g.spectrum.bars, attrs[false]); + g.spectrum_row = g.header_height; + g.spectrum_column = buf.total_width; + + row_buffer_append (&buf, g.spectrum.spectrum, attrs[false]); + } +#endif // WITH_FFTW + app_flush_header (&buf, attrs[false]); const char *header = g.active_tab->header; @@ -3421,6 +3747,137 @@ debug_tab_init (void) return super; } +// --- Spectrum analyser ------------------------------------------------------- + +#ifdef WITH_FFTW + +static void +spectrum_redraw (void) +{ + // A full refresh would be too computationally expensive, + // let's hack around it in this case + if (g.spectrum_row != -1) + { + attrset (APP_ATTR (TAB_BAR)); + mvaddstr (g.spectrum_row, g.spectrum_column, g.spectrum.spectrum); + attrset (0); + refresh (); + } + else + app_invalidate (); +} + +// When any problem occurs with the FIFO, we'll just give up on it completely +static void +spectrum_discard_fifo (void) +{ + if (g.spectrum_fd != -1) + { + poller_fd_reset (&g.spectrum_event); + xclose (g.spectrum_fd); + g.spectrum_fd = -1; + + spectrum_free (&g.spectrum); + g.spectrum_row = g.spectrum_column = -1; + app_invalidate (); + } +} + +static void +spectrum_on_fifo_readable (const struct pollfd *pfd, void *user_data) +{ + (void) user_data; + struct spectrum *s = &g.spectrum; + + bool update = false; + ssize_t n; +restart: + while ((n = read (pfd->fd, + s->buffer + s->buffer_len, s->buffer_size - s->buffer_len)) > 0) + if ((s->buffer_len += n) == s->buffer_size) + { + update = true; + spectrum_sample (s); + s->buffer_len = 0; + } + + if (!n) + spectrum_discard_fifo (); + else if (errno == EINTR) + goto restart; + else if (errno != EAGAIN) + { + print_error ("spectrum: %s", strerror (errno)); + spectrum_discard_fifo (); + } + else if (update) + spectrum_redraw (); +} + +// When playback is stopped, we need to feed the analyser some zeroes ourselves. +// We could also just hide it. Hard to say which is simpler or better. +static void +spectrum_clear (void) +{ + if (g.spectrum_fd != -1) + { + struct spectrum *s = &g.spectrum; + memset (s->buffer, 0, s->buffer_size); + spectrum_sample (s); + spectrum_sample (s); + s->buffer_len = 0; + + spectrum_redraw (); + } +} + +static void +spectrum_setup_fifo (void) +{ + const char *spectrum_path = + get_config_string (g.config.root, "settings.spectrum_path"); + const char *spectrum_format = + get_config_string (g.config.root, "settings.spectrum_format"); + struct config_item *spectrum_bars = + config_item_get (g.config.root, "settings.spectrum_bars", NULL); + if (!spectrum_path) + return; + + struct error *e = NULL; + char *path = resolve_filename + (spectrum_path, resolve_relative_config_filename); + + if (!path) + print_error ("spectrum: %s", "FIFO path could not be resolved"); + else if (!g.locale_is_utf8) + print_error ("spectrum: %s", "UTF-8 locale required"); + else if (!spectrum_init (&g.spectrum, + (char *) spectrum_format, spectrum_bars->value.integer, &e)) + { + print_error ("spectrum: %s", e->message); + error_free (e); + } + else if ((g.spectrum_fd = open (path, O_RDONLY | O_NONBLOCK)) == -1) + { + print_error ("spectrum: %s: %s", path, strerror (errno)); + spectrum_free (&g.spectrum); + } + else + { + g.spectrum_event = poller_fd_make (&g.poller, g.spectrum_fd); + g.spectrum_event.dispatcher = spectrum_on_fifo_readable; + poller_fd_set (&g.spectrum_event, POLLIN); + } + + free (path); +} + +#else // ! WITH_FFTW +#define spectrum_setup_fifo() +#define spectrum_clear() +#define spectrum_discard_fifo() +#endif // ! WITH_FFTW + // --- MPD interface ----------------------------------------------------------- static void @@ -3482,6 +3939,9 @@ mpd_update_playback_state (void) if (!strcmp (state, "pause")) g.state = PLAYER_PAUSED; } + if (g.state == PLAYER_STOPPED) + spectrum_clear (); + // Values in "time" are always rounded. "elapsed", introduced in MPD 0.16, // is in millisecond precision and "duration" as well, starting with 0.20. // Prefer the more precise values but use what we have. @@ -3736,6 +4196,8 @@ mpd_on_connected (void *user_data) mpd_request_info (); library_tab_reload (NULL); } + + spectrum_setup_fifo (); } static void @@ -3752,6 +4214,8 @@ mpd_on_failure (void *user_data) mpd_update_playback_state (); current_tab_update (); info_tab_update (); + + spectrum_discard_fifo (); } static void |