diff options
| -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 | 
