diff --git a/.github/workflows/cpiped-build.yml b/.github/workflows/cpiped-build.yml new file mode 100644 index 0000000..c121090 --- /dev/null +++ b/.github/workflows/cpiped-build.yml @@ -0,0 +1,21 @@ +name: cpiped build + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: install alsasound + run: sudo apt install -y libasound2-dev + - uses: actions/checkout@v4 + - name: make + run: make + - name: check executable + run: bash -c "[[ -x cpiped ]]" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dab040f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +cpiped diff --git a/README.md b/README.md index 8f8f737..202e382 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,127 @@ -cpiped captures an audio stream and outputs it to a pipe with a bit of buffering -to handle clock mismatch. It also includes silence/sound detection and the ability -to run a command on detection. +

cpiped

-I'm using this to capture line-in audio from a sound card and send it to -forked-daapd (https://github.com/ejurgensen/forked-daapd). On sound detection, it -runs a simple script to start playing the associated pipe in forked-daapd. +[![Release][release-shield]][release-url] +![GitHub commit activity][commit-shield] +[![Issues][issues-shield]][issues-url] +[![Contributors][contributors-shield]][contributors-url] +[![project_license][license-shield]][license-url] +[![Stargazers][stars-shield]][stars-url] +[![Forks][forks-shield]][forks-url] +![Build flow][build-shield] + +![c][c-shield] + + cpiped captures an audio stream and outputs it to a pipe + +`cpiped` was born to capture line-in audio from a sound card and send it to [forked-daapd](https://github.com/ejurgensen/forked-daapd). On sound detection, it runs a simple script to start playing the associated pipe in forked-daapd. + +## Contents +- [Contents](#contents) +- [Features](#features) +- [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Installation](#installation) +- [Usage](#usage) + - [Options](#options) + - [Env variables](#env-variables) +- [ToDo(s)](#todos) +- [Acknowledgments](#acknowledgments) + - [Top contributors:](#top-contributors) + +## Features +- Buffering to handle clock mismatch +- Command execution on sound and silence detection +- User defined noise threshold +- Different sample rate handling +- Multiple instances running in parallel thanks to parametrized pid + +## Getting Started + +### Prerequisites +`cpiped` relies on [ALSA lib](http://www.alsa-project.org) as only prerequisite + +- Debian + ```sh + sudo apt install -y libasound2-dev + ``` + +### Installation +1. Clone the repository + ```sh + git clone https://github.com/ale275/cpiped.git + ``` +2. Make cpiped + ```sh + cd cpiped + make + ``` +4. Install cpiped + ```sh + sudo install cpiped /usr/local/sbin/ + ``` + +## Usage + +`cpiped` is an application meant to be run from command line or demonized + +```sh +cpiped [-s arg] [-e arg] [OPTIONS] FIFO +``` +FIFO: path to the named pipe where sound will be written + +### Options + +| Option | Description | Type | Default | Range | Required? | +| ------ | ------------------------------------------------------------- | --------- | ----------------------- | ---------------- | --------- | +| `-d` | ALSA capture device in *hw:\,\* format. | `string` | `'default'` | | No | +| `-f` | ALSA capture device sample rate in Hz. | `integer` | `44100` | `16000 - 192000` | No | +| `-b` | Buffer size in seconds. | `float` | `.25` | `.1 - 5.0` | No | +| `-s` | Command to run, in background, when sound is detected. | `string` | | | Yes | +| `-e` | Command to run, in background, when silence is detected. | `string` | | | Yes | +| `-t` | Silence threshold expressed as signal power. | `integer` | `100` | `1 - 32767` | No | +| `-D` | Daemonize. | `bool` | | | No | +| `-p` | Path to pidfile. | `string` | `'/var/run/cpiped.pid'` | | No | +| `-v` | Log verbosity. (`-vv` and `-vvv` for higher verbosity levels) | `bool` | | | No | + +### Env variables +On start `cpiped` will set the following env variables +- *CPIPED_SR* audio capture sample rate in *Hz* +- *CPIPED_SS* audio capture sample size in *bit(s)* +- *CPIPED_CC* audio capture channel(s) count + +Those variables are visible by the sound and silence detect commands + +## ToDo(s) +- [ ] Improve Makefile to create dedicated build folder +- [ ] Improve Makefile to add install directive + +See the [open issues](https://github.com/ale275/cpiped/issues) for a full list of proposed features (and known issues). + +## Acknowledgments + +* [cpiped](https://github.com/b-fitzpatrick/cpiped) original code of cpiped by [b-fitzpatrick](https://github.com/b-fitzpatrick) +* [NDpiped](https://github.com/natedreger/NDpiped) modified version writing the buffer only when sound was detected +* [maweki cpiped fork](https://github.com/maweki/cpiped) for parametrized pid file + +### Top contributors: + + + contrib.rocks image + + + +[build-shield]: https://github.com/ale275/cpiped/actions/workflows/cpiped-build.yml/badge.svg +[commit-shield]: https://img.shields.io/github/commit-activity/t/ale275/cpiped?style=flat +[release-shield]: https://img.shields.io/github/release/ale275/cpiped.svg?colorB=58839b +[release-url]: https://github.com/ale275/cpiped/releases/latest +[contributors-shield]: https://img.shields.io/github/contributors/ale275/cpiped.svg +[contributors-url]: https://github.com/ale275/cpiped/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/ale275/cpiped.svg?style=flat +[forks-url]: https://github.com/ale275/cpiped/network/members +[stars-shield]: https://img.shields.io/github/stars/ale275/cpiped.svg?style=flat +[stars-url]: https://github.com/ale275/cpiped/stargazers +[issues-shield]: https://img.shields.io/github/issues/ale275/cpiped.svg +[issues-url]: https://github.com/ale275/cpiped/issues +[license-shield]: https://img.shields.io/github/license/ale275/cpiped.svg +[license-url]: https://github.com/ale275/cpiped/blob/master/LICENSE +[c-shield]: https://img.shields.io/badge/made%20with%20C-1f425f?style=flat&logo=c&logoColor=white \ No newline at end of file diff --git a/cpiped.c b/cpiped.c index 1899b34..a4d44f7 100644 --- a/cpiped.c +++ b/cpiped.c @@ -20,33 +20,51 @@ // Use the newer ALSA API #define ALSA_PCM_NEW_HW_PARAMS_API -#include -#include #include +#include +#include #include #include +#include +#include #include #include #include -#include -#include snd_pcm_t *handle; int daemonize = 0; +int log_verb = 0; int readfd = 0; int writefd = 0; void mylog(int level, const char *format, ...) { - va_list args; - va_start (args, format); + va_list args; + va_start (args, format); + + if (daemonize) { + vsyslog(level, format, args); + } else { + vprintf(format, args); + } + + va_end(args); +} + +void mylogverb(int level, int msg_verb, const char *format, ...) +{ + va_list args_verb; + va_start (args_verb, format); + if (msg_verb <= log_verb) { if (daemonize) { - vsyslog(level, format, args); + vsyslog(level, format, args_verb); } else { - vprintf(format, args); + vprintf(format, args_verb); } - va_end(args); + } + + va_end(args_verb); } void myterm() { @@ -58,23 +76,55 @@ void myterm() { exit(EXIT_SUCCESS); } +int setenvvar(const char *var_name, size_t var_val_len, unsigned int var_overwrite, const char *format, ...) { + va_list args; + va_start (args, format); + + char *var_value; + int ret; + + // allocate space for the variable value + var_value = malloc((var_val_len + 1) * sizeof(char)); + + if (var_value == NULL) { + mylog(LOG_ERR, "Unable to allocate memory for ENV variable setup '%s'\n", strerror(errno)); + return 1; + } + + // set variable value as string + vsnprintf(var_value, var_val_len + 1, format, args); + + // Set the variable + ret = setenv(var_name, var_value, var_overwrite); + + // Clean-up + free(var_value); + va_end(args); + + return ret; +} + int main(int argc, char *argv[]) { int rc; int capsize; snd_pcm_hw_params_t *params; - unsigned int val = 44100; + unsigned int samplerate = 44100; + unsigned int samplesize = 16; + unsigned int capchannels = 2; unsigned int capusec; int dir; snd_pcm_uframes_t frames; char *capbuffer; int16_t *scapbuffer; char *capdev = "default"; + char *pidpath = "/var/run/cpiped.pid"; char *fifonam; struct stat status; int readbytes = 0; int writebytes = 0; char *buf; - size_t bufsize = 176400; + size_t bufsize = 0; + float bufdur = 0.25; int bufstart; int bufend; int bufused; @@ -93,15 +143,14 @@ int main(int argc, char *argv[]) { char endcmd[1000] = ""; int wrote = 0; int silentt = 100; - + extern char *optarg; extern int optind; int opt; int err = 0; - + pid_t pid, sid; char pidstr[10]; - char pidfile[50]; int pidfd; // Ignore pipe signals @@ -111,14 +160,14 @@ int main(int argc, char *argv[]) { signal(SIGTERM, myterm); // Process command-line options and arguments - while ((opt = getopt(argc, argv, "d:b:s:e:t:D")) != -1) { + while ((opt = getopt(argc, argv, "d:b:s:e:f:t:Dp:v:")) != -1) { switch (opt) { case 'd': capdev = optarg; break; case 'b': - bufsize = atof(optarg) * val * 8; - if (bufsize < 33280 || bufsize > 1764000) { + bufdur = atof(optarg); + if (bufdur < 0.1 || bufdur > 5.0) { mylog(LOG_ERR, "Invalid buffer. Range is 0.1-5.0 sec.\n"); goto error; } @@ -146,9 +195,28 @@ int main(int argc, char *argv[]) { goto error; } break; + case 'f': + samplerate = atoi(optarg); + if (samplerate == 0) { + mylog(LOG_INFO, "No sample rate specified. Reverting to default.\n"); + samplerate = 44100; + } else if ((samplerate < 16000) || (samplerate > 192000)) { + mylog(LOG_ERR, "Invalid sample rate. Range is 16000-192000.\n"); + goto error; + } + break; case 'D': daemonize = 1; break; + case 'p': + pidpath = calloc(strlen(optarg), sizeof(char)); + sprintf(pidpath, optarg); + break; + case 'v': + log_verb = 1; + if (strcmp(optarg, "v" ) == 0) {log_verb = 2;} + if (strcmp(optarg, "vv") == 0) {log_verb = 3;} + break; case '?': err = 1; mylog(LOG_ERR, "Invalid option: %s\n", argv[optind - 1]); @@ -158,21 +226,23 @@ int main(int argc, char *argv[]) { if ((optind + 1) != argc) err = 1; if (err) { if (!daemonize) { - printf("\nUsage:\n %s [-d arg] [-b arg] [-s arg] " - "[-e arg] [-t arg] [-D] FIFO\n" + printf("\nUsage:\n %s [-s arg] [-e arg] [OPTIONS] FIFO\n" " -d : ALSA capture device ['default']\n" - " -b : target buffer in seconds [.5]\n" + " -b : target buffer in seconds [.25]\n" " -s : command to run when sound detected\n" " -e : command to run when silence detected\n" " -t : silence threshold (1 - 32767, [100])\n" + " -f : sample rate (16000 - 192000, [44100])\n" " -D : daemonize\n" + " -p : path to pidfile [/var/run/cpiped.pid]\n" + " -v : enable more verbose logs (-vv and -vvv for higher verbosity levels)\n" " FIFO : path to a named pipe\n", argv[0]); } - goto error; + exit(EXIT_FAILURE); } - + mylog(LOG_INFO, "Starting up.\n"); - + fifonam = argv[argc - 1]; // Daemonize @@ -183,15 +253,15 @@ int main(int argc, char *argv[]) { } else if (pid > 0) { exit(EXIT_SUCCESS); } - + umask(0); - + sid = setsid(); if (sid < 0) { mylog(LOG_ERR, "Error creating new session: %s\n", strerror(errno)); goto error; } - + if (chdir("/") != 0) { mylog(LOG_ERR, "Error on chdir /: %s\n", strerror(errno)); goto error; @@ -199,12 +269,24 @@ int main(int argc, char *argv[]) { } // Write the pidfile - sprintf(pidfile, "/var/run/cpiped.pid"); sprintf(pidstr, "%d\n", getpid()); - pidfd = open(pidfile, O_RDWR|O_CREAT, 0644); + pidfd = open(pidpath, O_RDWR|O_CREAT, 0644); write(pidfd, pidstr, strlen(pidstr)); close(pidfd); - + + // Log configurations + if (log_verb) { + mylogverb(LOG_INFO, 1, "Demonize: '%d'\n", daemonize); + mylogverb(LOG_INFO, 1, "Log verbosity level: '%d'\n", log_verb); + mylogverb(LOG_INFO, 1, "Pid path: '%s'\n", pidpath); + mylogverb(LOG_INFO, 1, "Capture device: '%s'\n", capdev); + mylogverb(LOG_INFO, 1, "Sample rate: '%d'\n", samplerate); + mylogverb(LOG_INFO, 1, "Buffer duration: '%f'\n", bufdur); + mylogverb(LOG_INFO, 1, "Sound detect command: '%s'\n", startcmd); + mylogverb(LOG_INFO, 1, "Silence detect command: '%s'\n", endcmd); + mylogverb(LOG_INFO, 1, "Silence threshold: '%d'\n", silentt); + } + // Open the FIFO for read first, so the open for write succeeds. readfd = open(fifonam, O_RDONLY | O_NONBLOCK); if (readfd <= 0) { @@ -225,10 +307,7 @@ int main(int argc, char *argv[]) { goto error; } close(readfd); - - // Set the FIFO size to 8192 bytes to minimize latency - fcntl(writefd, F_SETPIPE_SZ, 8192); - + // Open PCM device for recording (capture). rc = snd_pcm_open(&handle, capdev, SND_PCM_STREAM_CAPTURE, SND_PCM_NONBLOCK); if (rc != 0) { @@ -239,8 +318,11 @@ int main(int argc, char *argv[]) { snd_pcm_hw_params_any(handle, params); snd_pcm_hw_params_set_access(handle, params, SND_PCM_ACCESS_RW_INTERLEAVED); snd_pcm_hw_params_set_format(handle, params, SND_PCM_FORMAT_S16_LE); - snd_pcm_hw_params_set_channels(handle, params, 2); - snd_pcm_hw_params_set_rate_near(handle, params, &val, &dir); + snd_pcm_hw_params_set_channels(handle, params, capchannels); + snd_pcm_hw_params_set_rate_near(handle, params, &samplerate, &dir); + + // set buffer size in byte + bufsize = bufdur * samplerate * capchannels * (samplesize / 8); // Set period size to 1024 frames. frames = 1024; @@ -255,13 +337,23 @@ int main(int argc, char *argv[]) { // Determine the elapsed time of one period snd_pcm_hw_params_get_period_time(params, &capusec, &dir); - + // Create a capture buffer large enough to hold one period snd_pcm_hw_params_get_period_size(params, &frames, &dir); - capsize = frames * 4; // 2 bytes/sample, 2 channels + capsize = frames * (samplesize / 8) * capchannels; // ( / 8) bytes/sample * channels capbuffer = calloc(capsize, sizeof(char)); scapbuffer = (int16_t*)capbuffer; - + + // Set the FIFO size to twice the period size + int pipesize = fcntl(writefd, F_SETPIPE_SZ, 2 * capsize); + if (pipesize < 0) { + mylog(LOG_ERR, "Error setting FIFO size: %s\n", strerror(errno)); + goto error; + } + if (pipesize != 2 * capsize) { + mylog(LOG_NOTICE, "WARN setting FIFO size to %ld, actual size %ld\n", 2*capsize, pipesize); + } + // Determine write size writebytes = capsize - 32 ; // Write a bit less to make sure write controls pace @@ -270,34 +362,20 @@ int main(int argc, char *argv[]) { buf = calloc(bufsize, sizeof(char)); bufstart = 0; bufend = 0; - - while (1) { - // Determine the amount of buffer used - bufused = bufend - bufstart + (bufend < bufstart) * (bufsize - 1); - //printf("size:%d, start:%d, end:%d, used:%d, wrote:%d\n", bufsize, bufstart, bufend, bufused, wrote); - - if (bufused < capsize) { - // Buffer is almost empty, don't send to pipe - mylog(LOG_INFO, "Filling buffer\n"); - fillbuf = 1; - } - - if (bufused > (int)bufsize - 1 - capsize) { - // Buffer is almost full, don't store captured samples - mylog(LOG_INFO, "Buffer full\n"); - buffull = 1; - } - // Resume both capture storage and write to pipe when the buffer reaches half-full - if ((fillbuf == 1) && (bufused > (int)bufsize / 2)) - fillbuf = 0; - if ((buffull == 1) && (bufused < (int)bufsize / 2)) - buffull = 0; - + // Export capture parameters to ENV variables + if (setenvvar("CPIPED_SR", 6, 1, "%ld", samplerate) == -1) + mylog(LOG_NOTICE, "WARN CPIPED_SR to '%ld' FAILED\n", samplerate); + if (setenvvar("CPIPED_SS", 2, 1, "%ld", samplesize) == -1) + mylog(LOG_NOTICE, "WARN CPIPED_SS to '%ld' FAILED\n", samplesize); + if (setenvvar("CPIPED_CC", 2, 1, "%d", capchannels) == -1) + mylog(LOG_NOTICE, "WARN CPIPED_CC to '%d' FAILED\n", capchannels); + + while (1) { // Capture samples if (!wrote) // When not writing, wait a bit between captures. usleep(capusec * .95); - rc = snd_pcm_readi(handle, capbuffer, frames); + rc = snd_pcm_readi(handle, capbuffer, frames); // return number of captured frames if (rc == -EPIPE) { // EPIPE means overrun mylog(LOG_NOTICE, "Overrun occurred\n"); snd_pcm_prepare(handle); @@ -306,16 +384,17 @@ int main(int argc, char *argv[]) { } else if (rc < 0) { mylog(LOG_ERR, "Capture error: %s\n", snd_strerror(rc)); } - - // Compute RMS power level regularly + + // Check if there is sound and if so compute RMS power level regularly and count sounds and silences if (capcount > 10 && rc > 0) { // Approx. every quarter second power = 0; + // Each frame holds one sample per channel for (i = 0; i < rc / 2; i++) { power += pow(scapbuffer[i], 2) + pow(scapbuffer[i + 1], 2); } power = sqrt(power/rc); capcount = 0; - + if (power > silentt) { soundcount++; silentcount = 0; @@ -323,23 +402,23 @@ int main(int argc, char *argv[]) { silentcount++; soundcount = 0; } - + if (silent && soundcount > 1) { silent = 0; - mylog(LOG_INFO, "Sound detected. Last two values: %d, %d\n", prevpower, (int)power); + mylogverb(LOG_INFO, 0, "Sound detected. Last two values: %d, %d\n", prevpower, (int)power); if (strcmp(startcmd, "") != 0) { - mylog(LOG_INFO, "Running '%s'\n", startcmd); + mylogverb(LOG_INFO, 0, "Running '%s'\n", startcmd); if (system(startcmd) != 0) { mylog(LOG_ERR, "Error running start command.\n"); } } } - + if (!silent && silentcount > 55) { silent = 1; - mylog(LOG_INFO, "Silence detected. Last two values: %d, %d\n", prevpower, (int)power); + mylogverb(LOG_INFO, 0, "Silence detected. Last two values: %d, %d\n", prevpower, (int)power); if (strcmp(endcmd, "") != 0) { - mylog(LOG_INFO, "Running '%s'\n", endcmd); + mylogverb(LOG_INFO, 0, "Running '%s'\n", endcmd); if (system(endcmd) != 0) { mylog(LOG_ERR, "Error running end command.\n"); } @@ -348,45 +427,72 @@ int main(int argc, char *argv[]) { } prevpower = power; capcount++; - - if (!buffull && rc > 0) { - // Store samples in buffer - readbytes = rc * 4; - bufendtoend = bufsize - bufend; - if (readbytes <= bufendtoend) { - // No wrap required - memcpy(buf + bufend, capbuffer, readbytes); - } else { - // Wrap required - memcpy(buf + bufend, capbuffer, bufendtoend); - memcpy(buf, capbuffer + bufendtoend, readbytes - bufendtoend); + // end of soundcounter + + // Check if there is sound, if so write to the pipe + if (soundcount > 0) { + + // Determine the amount of buffer used + bufused = bufend - bufstart + (bufend < bufstart) * (bufsize - 1); + mylogverb(LOG_INFO, 3, "size:%d, start:%d, end:%d, used:%d, wrote:%d\n", bufsize, bufstart, bufend, bufused, wrote); + + if (bufused < capsize) { + // Buffer is almost empty, don't send to pipe + mylogverb(LOG_INFO, 1, "Filling buffer\n"); + fillbuf = 1; } - bufend = (bufend + readbytes) % bufsize; - } - - wrote = 0; - if (!fillbuf) { - bufstarttoend = bufsize - bufstart; - if (writebytes <= bufstarttoend) { // No wrap required - rc = write(writefd, buf + bufstart, writebytes); - if (rc == writebytes) wrote = 1; - } else { // Wrap required - rc = write(writefd, buf + bufstart, bufstarttoend); - rc = write(writefd, buf, writebytes - bufstarttoend); - if (rc == writebytes - bufstarttoend) wrote = 1; + + if (bufused > (int)bufsize - 1 - capsize) { + // Buffer is almost full, don't store captured samples + mylogverb(LOG_INFO, 0, "Buffer full\n"); + buffull = 1; } - if (wrote) { - bufstart = (bufstart + writebytes) % bufsize; - } else { - bufstart = (bufstart + readbytes) % bufsize; // Keep buffer used constant + + // Resume both capture storage and write to pipe when the buffer reaches half-full + if ((fillbuf == 1) && (bufused > (int)bufsize / 2)) + fillbuf = 0; + if ((buffull == 1) && (bufused < (int)bufsize / 2)) + buffull = 0; + + if (!buffull && rc > 0) { + // Store samples in buffer + readbytes = rc * (samplesize / 8) * capchannels; + bufendtoend = bufsize - bufend; + if (readbytes <= bufendtoend) { + // No wrap required + memcpy(buf + bufend, capbuffer, readbytes); + } else { + // Wrap required + memcpy(buf + bufend, capbuffer, bufendtoend); + memcpy(buf, capbuffer + bufendtoend, readbytes - bufendtoend); + } + bufend = (bufend + readbytes) % bufsize; } - } + + wrote = 0; + if (!fillbuf) { + bufstarttoend = bufsize - bufstart; + if (writebytes <= bufstarttoend) { // No wrap required + rc = write(writefd, buf + bufstart, writebytes); + if (rc == writebytes) wrote = 1; + } else { // Wrap required + rc = write(writefd, buf + bufstart, bufstarttoend); + rc = write(writefd, buf, writebytes - bufstarttoend); + if (rc == writebytes - bufstarttoend) wrote = 1; + } + if (wrote) { + bufstart = (bufstart + writebytes) % bufsize; + } else { + bufstart = (bufstart + readbytes) % bufsize; // Keep buffer used constant + } + } + } // end if } exit(EXIT_SUCCESS); - -error: - if (readfd > 0) close(readfd); - if (writefd > 0) close(writefd); - mylog(LOG_INFO, "Stopping due to error."); - exit(EXIT_FAILURE); + + error: + if (readfd > 0) close(readfd); + if (writefd > 0) close(writefd); + mylog(LOG_INFO, "Stopping due to error."); + exit(EXIT_FAILURE); }