mplay.mp3

Controllable music player (mp3)
git clone https://git.sinitax.com/sinitax/mplay.mp3
Log | Files | Refs | Submodules | LICENSE | sfeed.txt

commit 428a418313c7522f0e9b1d4dd40311295afaa04a
Author: Louis Burda <quent.burda@gmail.com>
Date:   Sat,  9 Mar 2024 22:10:29 +0100

Initial version with mplay api

Diffstat:
A.gitignore | 3+++
A.gitmodules | 6++++++
ALICENSE | 21+++++++++++++++++++++
AMakefile | 28++++++++++++++++++++++++++++
Alib/minimp3 | 1+
Alib/mplay | 1+
Amplay.mp3.c | 659+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 719 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,3 @@ +.cache +compile_commands.json +mplay.mp3 diff --git a/.gitmodules b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/minimp3"] + path = lib/minimp3 + url = git@github.com:lieff/minimp3.git +[submodule "lib/mplay"] + path = lib/mplay + url = git@sinitax.com:sinitax/mplay diff --git a/LICENSE b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Louis Burda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile @@ -0,0 +1,28 @@ +PREFIX ?= /usr/local +BINDIR ?= /bin + +CFLAGS = -I lib/mplay -I lib/minimp3/ +CFLAGS += -Wunused-variable -Wunused-function -Wconversion +LDFLAGS = -lpulse -lpulse-simple + +ifeq ($(DEBUG),1) +CFLAGS += -Og -g +else +CFLAGS += -O2 +endif + +all: mplay.mp3 + +clean: + rm -f mplay.mp3 + +mplay.mp3: mplay.mp3.c + $(CC) -o $@ $^ $(CFLAGS) $(LDFLAGS) + +install: + install -m755 mplay.mp3 -t "$(DESTDIR)$(PREFIX)$(BINDIR)" + +uninstall: + rm -f "$(DESTDIR)$(PREFIX)$(BINDIR)/mplay.mp3" + +.PHONY: all clean install uninstall diff --git a/lib/minimp3 b/lib/minimp3 @@ -0,0 +1 @@ +Subproject commit afb604c06bc8beb145fecd42c0ceb5bda8795144 diff --git a/lib/mplay b/lib/mplay @@ -0,0 +1 @@ +Subproject commit 5eb60cb925e4b3c477efd07016acb43510e7d6e9 diff --git a/mplay.mp3.c b/mplay.mp3.c @@ -0,0 +1,659 @@ +#include "mplay.h" + +#define MINIMP3_IMPLEMENTATION +#include "minimp3.h" + +#include <pulse/sample.h> +#include <pulse/introspect.h> +#include <pulse/operation.h> +#include <pulse/stream.h> +#include <pulse/thread-mainloop.h> +#include <pulse/volume.h> +#include <pulse/pulseaudio.h> +#include <pulse/def.h> + +#include <sys/mman.h> +#include <sys/stat.h> +#include <pthread.h> +#include <fcntl.h> +#include <termios.h> +#include <unistd.h> + +#include <stdarg.h> +#include <stdint.h> +#include <stdbool.h> +#include <stdio.h> +#include <stdlib.h> + +struct audiofile { + uint8_t *data; + size_t len; +}; + +struct mp3d { + mp3dec_t dec; + uint8_t *pos; + ssize_t left; + + mp3d_sample_t *samples; + size_t sample_cnt; + size_t sample_cap; + + size_t sample_next; + + bool seek; + + int rate; + int channels; + + bool init; + bool pause; +}; + +static pa_sample_spec pa_spec = { + .format = PA_SAMPLE_S16LE, + .rate = 44100, + .channels = 2 +}; + +static pa_buffer_attr pa_buf = { + .fragsize = UINT32_MAX, + .maxlength = UINT32_MAX, + .tlength = UINT32_MAX, + .prebuf = UINT32_MAX, + .minreq = UINT32_MAX, +}; + +static pa_stream_flags_t pa_stream_flags = PA_STREAM_START_CORKED + | PA_STREAM_AUTO_TIMING_UPDATE | PA_STREAM_ADJUST_LATENCY; + +static pa_threaded_mainloop *pa_mloop; +static pa_context *pa_ctx; +static pa_stream *pa_strm; +static pa_sink_input_info pa_strm_sink; +static bool pa_strm_sink_update; + +static struct audiofile audiofile = { 0 }; + +static struct mp3d mp3d = { 0 }; + +static pthread_t input_worker_thread; +static bool input_worker_alive = false; + +static struct termios term_old; +static struct termios term_new; +static bool term_set = false; + +static bool headless = false; +static bool verbose = false; +static bool istty = false; + +__attribute__((noreturn)) static void +die(const char *fmt, ...) +{ + va_list ap; + + fputs("mplay.mp3: ", stderr); + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + if (*fmt && fmt[strlen(fmt)-1] == ':') { + perror(NULL); + } else { + fputc('\n', stderr); + } + + exit(1); +} + +static void +term_set_canonical(void) +{ + term_new.c_lflag |= ICANON; + term_new.c_lflag |= ECHO; + if (tcsetattr(0, TCSANOW, &term_new)) + die("tcsetattr:"); +} + +static void +term_set_raw(void) +{ + term_new.c_lflag &= ~(0U | ICANON); + term_new.c_lflag &= ~(0U | ECHO); + if (tcsetattr(0, TCSANOW, &term_new)) + die("tcsetattr:"); +} + +static void +get_line(char *buf, int size) +{ + char *c; + + putc('>', stderr); + if (istty) term_set_canonical(); + fgets(buf, size, stdin); + if ((c = strchr(buf, '\n'))) *c = '\0'; + if (istty) term_set_raw(); +} + +static uint8_t * +map_file(const char *path, size_t *len) +{ + struct stat attr; + uint8_t *buf; + int fd; + + fd = open(path, O_RDONLY); + if (fd < 0) die("open %s:", path); + + if (fstat(fd, &attr)) + die("fstat %s:", path); + + if ((attr.st_mode & S_IFMT) == S_IFDIR) + die("not a file: %s", path); + + buf = mmap(NULL, (size_t) attr.st_size, PROT_READ, MAP_SHARED, fd, 0); + if (!buf) die("mmap %s:", path); + + *len = (size_t) attr.st_size; + + close(fd); + + return buf; +} + +static size_t +decode_next_frame(mp3d_sample_t *samples, size_t *size) +{ + static const size_t max_cnt = MINIMP3_MAX_SAMPLES_PER_FRAME; + mp3dec_frame_info_t info; + size_t cnt; + + if (size) *size = 0; + + if (mp3d.sample_next < mp3d.sample_cnt) { + cnt = MIN(max_cnt, mp3d.sample_cnt - mp3d.sample_next); + *size = cnt * sizeof(mp3d_sample_t); + } else { + if (mp3d.sample_cnt + max_cnt > mp3d.sample_cap) { + mp3d.sample_cap = MAX(mp3d.sample_cap * 2, + mp3d.sample_cnt + max_cnt); + mp3d.samples = realloc(mp3d.samples, + mp3d.sample_cap * sizeof(mp3d_sample_t)); + if (!mp3d.samples) die("realloc:"); + } + + cnt = (size_t) mp3dec_decode_frame(&mp3d.dec, mp3d.pos, + (int) mp3d.left, &mp3d.samples[mp3d.sample_cnt], &info); + cnt *= (size_t) info.channels; + mp3d.sample_cnt += cnt; + + if (size) *size = (size_t) info.frame_bytes; + + if (!info.frame_bytes) + return 0; + + if (!mp3d.init) { + mp3d.rate = info.hz; + mp3d.channels = info.channels; + } + + mp3d.pos += info.frame_bytes; + mp3d.left -= info.frame_bytes; + } + + if (samples != NULL) + memcpy(samples, &mp3d.samples[mp3d.sample_next], + cnt * sizeof(mp3d_sample_t)); + mp3d.sample_next += cnt; + + return cnt; +} + +static void +decoder_init(void) +{ + mp3d_sample_t samples[MINIMP3_MAX_SAMPLES_PER_FRAME]; + size_t size; + + mp3dec_init(&mp3d.dec); + mp3d.pos = audiofile.data; + mp3d.left = (ssize_t) audiofile.len; + + mp3d.sample_cnt = 0; + mp3d.sample_next = 0; + mp3d.sample_cap = MINIMP3_MAX_SAMPLES_PER_FRAME; + mp3d.samples = malloc(mp3d.sample_cap * sizeof(mp3d_sample_t)); + if (!mp3d.samples) die("malloc:"); + + /* decode channel specs */ + decode_next_frame(samples, &size); + if (!size) die("invalid mp3"); + + mp3d.seek = false; + mp3d.pause = false; + mp3d.init = true; +} + +static void +decoder_seek(size_t sample_pos) +{ + size_t size, cnt; + + while (mp3d.sample_next < sample_pos) { + cnt = decode_next_frame(NULL, &size); + if (!size) { + mplay_status(MPLAY_STATUS_EXIT, "from seek"); + exit(0); + } + } + mp3d.sample_next = sample_pos; +} + +static void +pa_stream_drain_callback(pa_stream *stream, int success, void *data) +{ + mplay_status(MPLAY_STATUS_EXIT, NULL); + exit(0); +} + +static void +pa_stream_write_callback(pa_stream *stream, size_t requested, void *data) +{ + mp3d_sample_t samples[MINIMP3_MAX_SAMPLES_PER_FRAME]; + pa_operation *op; + ssize_t remaining; + size_t cnt, size; + + remaining = (ssize_t) requested; + while (remaining > 0) { + cnt = decode_next_frame(samples, &size); + if (!size) { + op = pa_stream_drain(pa_strm, + pa_stream_drain_callback, NULL); + pa_operation_unref(op); + return; + } + if (!cnt) continue; + + pa_stream_write(stream, samples, + cnt * sizeof(mp3d_sample_t), NULL, 0, + mp3d.seek ? PA_SEEK_RELATIVE_ON_READ: PA_SEEK_RELATIVE); + + mp3d.seek = false; + remaining -= (ssize_t) (cnt * sizeof(mp3d_sample_t)); + } +} + +static void +pa_stream_underflow_callback(struct pa_stream *stream, void *data) +{ + fprintf(stderr, "pulseaudio underflow!\n"); +} + +static void +pa_stream_overflow_callback(struct pa_stream *stream, void *data) +{ + fprintf(stderr, "pulseaudio overflow!\n"); +} + +static void +update_sink_input_info_callback(struct pa_context *ctx, + const pa_sink_input_info *info, int eol, void *data) +{ + if (eol) return; + memcpy(&pa_strm_sink, info, sizeof(pa_sink_input_info)); + pa_strm_sink_update = true; + pa_threaded_mainloop_signal(pa_mloop, true); +} + +static void +update_sink_input_info(void) +{ + pa_operation *op; + + pa_strm_sink_update = false; + op = pa_context_get_sink_input_info(pa_ctx, + pa_stream_get_index(pa_strm), + update_sink_input_info_callback, NULL); + if (!op) die("pa_context_get_sink_input_info failed"); + + while (!pa_strm_sink_update) { + pa_threaded_mainloop_unlock(pa_mloop); + pa_threaded_mainloop_wait(pa_mloop); + pa_threaded_mainloop_lock(pa_mloop); + } + + pa_threaded_mainloop_accept(pa_mloop); + + pa_operation_unref(op); +} + +static size_t +stream_samples_buffered(void) +{ + static size_t last_read_index = 0, last_write_index = 0; + const pa_timing_info *info; + size_t buffered; + + info = pa_stream_get_timing_info(pa_strm); + if (!info || info->write_index_corrupt || info->read_index_corrupt) + return 0; + + if (info->read_index != last_read_index) + last_write_index = (size_t) MAX(0, info->write_index); + + buffered = (last_write_index - last_read_index) + / sizeof(mp3d_sample_t); + + last_read_index = (size_t) MAX(0, info->read_index); + + return buffered; +} + +static double +user_vol(void) +{ + pa_volume_t vol; + + update_sink_input_info(); + if (!pa_strm_sink.has_volume) + return -1; + + vol = pa_cvolume_avg(&pa_strm_sink.volume); + + return (double) vol * 100.F / (double) PA_VOLUME_NORM; +} + +static double +user_time(void) +{ + size_t sample_pos; + + sample_pos = MAX(0, mp3d.sample_next - stream_samples_buffered()); + + return (double) sample_pos * 1.F / (double) (mp3d.rate * mp3d.channels); +} + +static void +cmd_setvol(double vol) +{ + pa_operation *op; + + update_sink_input_info(); + pa_cvolume_set(&pa_strm_sink.volume, 2, + (pa_volume_t) (vol * PA_VOLUME_NORM / 100.F)); + if (pa_cvolume_avg(&pa_strm_sink.volume) > 2 * PA_VOLUME_NORM) + pa_cvolume_set(&pa_strm_sink.volume, 2, 2 * PA_VOLUME_NORM); + + op = pa_context_set_sink_input_volume(pa_ctx, + pa_stream_get_index(pa_strm), + &pa_strm_sink.volume, NULL, NULL); + pa_operation_unref(op); + + mplay_status(MPLAY_ACTION_STATUS_VOL, "%02.2f", (double) pa_cvolume_avg(&pa_strm_sink.volume) + * 100.f / (double) PA_VOLUME_NORM); +} + +static void +cmd_seek(double time_sec) +{ + size_t sample_pos; + pa_operation *op; + + if (time_sec < 0) time_sec = 0; + sample_pos = (size_t) (time_sec * mp3d.channels * mp3d.rate); + decoder_seek(sample_pos); + + op = pa_stream_flush(pa_strm, NULL, NULL); + pa_operation_unref(op); + mp3d.seek = true; + + mplay_status(MPLAY_ACTION_STATUS_SEEK, "%02.2f", user_time()); +} + +static void +cmd_playpause(void) +{ + pa_operation *op; + + mp3d.pause ^= 1; + op = pa_stream_cork(pa_strm, mp3d.pause, NULL, NULL); + pa_operation_unref(op); + + mplay_status(MPLAY_ACTION_STATUS_PLAYPAUSE, "%i", mp3d.pause); +} + +static void +cmd_status(void) +{ + update_sink_input_info(); + + mplay_status(MPLAY_ACTION_STATUS_STATUS, + "volume=%02.2f,time=%02.2f,pause=%i", + user_vol(), user_time(), mp3d.pause); +} + +static bool +key_input(void) +{ + char linebuf[16], *end; + float fval; + int key; + + while ((key = mplay_getkey()) == MPLAY_KEY_EOF); + + pa_threaded_mainloop_lock(pa_mloop); + + switch (mplay_action(key)) { + case MPLAY_ACTION_VOL: + get_line(linebuf, sizeof(linebuf)); + fval = strtof(linebuf, &end); + if (!end || *end) { + mplay_status(MPLAY_ACTION_STATUS_VOL, "fail:bad float"); + break; + } + cmd_setvol(fval); + break; + case MPLAY_ACTION_VOL_UP: + cmd_setvol(user_vol() + 5); + break; + case MPLAY_ACTION_VOL_DOWN: + cmd_setvol(user_vol() - 5); + break; + case MPLAY_ACTION_SEEK: + get_line(linebuf, sizeof(linebuf)); + fval = strtof(linebuf, &end); + if (!end || *end) { + mplay_status(MPLAY_ACTION_STATUS_SEEK, "fail:bad float"); + break; + } + cmd_seek(fval); + break; + case MPLAY_ACTION_SEEK_BWD: + cmd_seek(user_time() - 5); + break; + case MPLAY_ACTION_SEEK_FWD: + cmd_seek(user_time() + 5); + break; + case MPLAY_ACTION_PLAYPAUSE: + cmd_playpause(); + break; + case MPLAY_ACTION_STATUS: + cmd_status(); + break; + default: + mplay_status(MPLAY_STATUS_INPUT, "fail"); + break; + } + + pa_threaded_mainloop_unlock(pa_mloop); + + return true; +} + +static void * +input_worker(void *arg) +{ + input_worker_alive = true; + + setvbuf(stdin, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); + + if (istty) { + if (tcgetattr(0, &term_old)) + die("tcgetattr:"); + term_new = term_old; + term_new.c_iflag |= BRKINT; + term_new.c_iflag &= ~(0U | IGNBRK); + term_set = true; + + term_set_raw(); + } + + mplay_status(MPLAY_STATUS_READY, NULL); + + while (key_input()); + + return NULL; +} + +static void +pulse_context_init(void) +{ + pa_mainloop_api *pa_mloop_api; + int ret; + + pa_mloop_api = pa_threaded_mainloop_get_api(pa_mloop); + if (!pa_mloop_api) die("pa_threaded_mainloop_get_api"); + + pa_ctx = pa_context_new(pa_mloop_api, "mplay.mp3"); + if (!pa_ctx) die("pa_context_new"); + + ret = pa_context_connect(pa_ctx, NULL, 0, NULL); + if (ret) die("pa_context_connect: %s", + pa_strerror(pa_context_errno(pa_ctx))); + + while (pa_context_get_state(pa_ctx) != PA_CONTEXT_READY) { + pa_threaded_mainloop_unlock(pa_mloop); + pa_threaded_mainloop_wait(pa_mloop); + pa_threaded_mainloop_lock(pa_mloop); + } +} + +static void +pulse_stream_init(void) +{ + pa_channel_map pa_chmap; + int ret; + + pa_channel_map_init_stereo(&pa_chmap); + + pa_strm = pa_stream_new(pa_ctx, "mplay.mp3", &pa_spec, &pa_chmap); + if (!pa_strm) die("pa_stream_new: %s", + pa_strerror(pa_context_errno(pa_ctx))); + + pa_stream_set_write_callback(pa_strm, pa_stream_write_callback, NULL); + + if (verbose) { + pa_stream_set_underflow_callback(pa_strm, + pa_stream_underflow_callback, NULL); + pa_stream_set_overflow_callback(pa_strm, + pa_stream_overflow_callback, NULL); + } + + ret = pa_stream_connect_playback(pa_strm, NULL, + &pa_buf, pa_stream_flags, NULL, NULL); + if (ret) die("pa_stream_connect_playback failed"); + + while (pa_stream_get_state(pa_strm) != PA_STREAM_READY) { + pa_threaded_mainloop_unlock(pa_mloop); + pa_threaded_mainloop_wait(pa_mloop); + pa_threaded_mainloop_lock(pa_mloop); + } +} + +static void +sigint_handler(int sig) +{ + exit(0); +} + +static void +cleanup(void) +{ + if (term_set) tcsetattr(0, TCSANOW, &term_old); + + pa_stream_disconnect(pa_strm); + pa_stream_unref(pa_strm); + + pa_context_disconnect(pa_ctx); + pa_context_unref(pa_ctx); + + munmap(audiofile.data, audiofile.len); + + if (input_worker_alive) + pthread_kill(input_worker_thread, SIGINT); +} + +static void +usage(void) +{ + fprintf(stderr, "Usage: mplay.mp3 [" MPLAY_FLAG_HEADLESS "] FILE\n"); + exit(1); +} + +int +main(int argc, const char **argv) +{ + pthread_mutex_t lock; + const char **arg; + const char *file; + + file = NULL; + for (arg = argv + 1; *arg; arg++) { + if (!strcmp(*arg, MPLAY_FLAG_HEADLESS)) { + headless = true; + } else if (!strcmp(*arg, "-v")) { + verbose = true; + } else { + if (file) usage(); + file = *arg; + } + } + if (!file) usage(); + + istty = isatty(0); + + audiofile.data = map_file(file, &audiofile.len); + decoder_init(); + + pa_spec.channels = (uint8_t) mp3d.channels; + pa_spec.rate = (uint32_t) mp3d.rate; + + pa_mloop = pa_threaded_mainloop_new(); + if (!pa_mloop) die("pa_threaded_mainloop_new"); + + pa_threaded_mainloop_start(pa_mloop); + + pa_threaded_mainloop_lock(pa_mloop); + pulse_context_init(); + pulse_stream_init(); + pa_stream_cork(pa_strm, 0, NULL, NULL); + pa_threaded_mainloop_unlock(pa_mloop); + + atexit(cleanup); + signal(SIGINT, sigint_handler); + + if (!headless) { + if (pthread_create(&input_worker_thread, NULL, input_worker, NULL)) + die("pthread_create"); + pthread_join(input_worker_thread, NULL); + } else { + pthread_mutex_init(&lock, NULL); + pthread_mutex_lock(&lock); + pthread_mutex_lock(&lock); + } + + return 0; +}