/* vgmstream123.c * * Simple player frontend for vgmstream * Copyright (c) 2017 Daniel Richard G. * * Permission to use, copy, modify, and distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ #ifdef HAVE_CONFIG_H # include #endif #include #include #include #include #include #include #include #ifdef WIN32 # include # include #else # include # include # include #endif #include "../src/vgmstream.h" #include "../src/plugins.h" #ifndef VERSION # include "version.h" #endif #ifndef VERSION # define VERSION "(unknown version)" #endif //TODO: improve WIN32 builds (some features/behaviors are missing but works) #ifdef WIN32 #define getline(line, line_mem, f) 0 #define mkdtemp(temp_dir) 0 #define signal(sig, interrupt_handler) /*nothing*/ #define WIFSIGNALED(ret) 0 #define WTERMSIG(ret) 0 #define SIGQUIT 0 #define SIGINT 0 #define SIGHUP 0 #endif /* If two interrupts (i.e. Ctrl-C) are received * within a span of this many seconds, then exit */ #define DOUBLE_INTERRUPT_TIME 1.0 #define LITTLE_ENDIAN_OUTPUT 1 /* untested in BE */ #define DEFAULT_PARAMS { 0, -1, 2.0, 10.0, 0.0, 0, 0, 0, 0 } typedef struct { int stream_index; double min_time; double loop_count; double fade_time; double fade_delay; int ignore_loop; int force_loop; int really_force_loop; int play_forever; } song_settings_t; static const char *out_filename = NULL; static int driver_id; static ao_device *device = NULL; static ao_option *device_options = NULL; static ao_sample_format current_sample_format; static sample_t *buffer = NULL; /* reportedly 1kb helps Raspberry Pi Zero play FFmpeg formats without stuttering * (presumably other low powered devices too), plus it's the default in other plugins */ static int buffer_size_kb = 1; static int repeat = 0; static int verbose = 0; static volatile int interrupted = 0; static double interrupt_time = 0.0; static int play_file(const char *filename, song_settings_t *par); static void interrupt_handler(int signum) { interrupted = 1; } static int record_interrupt(void) { int ret = 0; struct timeval tv = { 0, 0 }; double t; if (gettimeofday(&tv, NULL)) return -1; t = (double)tv.tv_sec + (double)tv.tv_usec / 1.0e6; if (t - interrupt_time < DOUBLE_INTERRUPT_TIME) ret = 1; interrupt_time = t; interrupted = 0; return ret; } static void usage(const char *progname) { song_settings_t default_par = DEFAULT_PARAMS; const char *default_driver = "???"; { ao_info *info = ao_driver_info(driver_id); if (info) default_driver = info->short_name; } printf("vgmstream123 " VERSION ", built " __DATE__ "\n" "\n" "Usage: %s [options] INFILE ...\n" "Play streamed audio from video games.\n" "\n" "Options:\n" " -d DRV Use output driver DRV [%s]; available drivers:\n" " ", progname, default_driver); { ao_info **info_list; int driver_count = 0; int i; info_list = ao_driver_info_list(&driver_count); for (i = 0; i < driver_count; i++) printf("%s ", info_list[i]->short_name); } printf("\n" " -f OUTFILE Set output filename for a file driver specified with -d\n" " -o KEY:VAL Pass option KEY with value VAL to the output driver\n" " (see https://www.xiph.org/ao/doc/drivers.html)\n" " -b N Use an audio buffer of N kilobytes [%d]\n" " -@ LSTFILE Read playlist from LSTFILE\n" " -h Print this help\n" " -r Repeat playback again (with fade, use -p for infinite loops)\n" " -v Display stream metadata and playback progress\n" " -S N Play substream with index N\n" "\n" "Looping options:\n" " -M MINTIME Loop for a playback time of at least MINTIME seconds\n" " -L N Loop N times [%.1f]\n" " -F FTIME End playback with a fade-out of FTIME seconds [%.1f]\n" " -D FDELAY Delay fade-out for an additional FDELAY seconds [%.1f]\n" " -i Ignore loop\n" " -e Force loop (loop only if file doesn't have loop points)\n" " -E Really force loop (repeat file)\n" " -p Play forever (loops file until stopped)\n" "\n" "INFILE can be any stream file type supported by vgmstream, or an .m3u/.m3u8\n" "playlist referring to same. This program supports the \"EXT-X-VGMSTREAM\" tag\n" "in playlists, and files compressed with gzip/bzip2/xz.\n", buffer_size_kb, default_par.loop_count, default_par.fade_time, default_par.fade_delay ); } /* Opens the audio device with the appropriate parameters */ static int set_sample_format(int channels, int sample_rate) { ao_sample_format format; memset(&format, 0, sizeof(format)); format.bits = 8 * sizeof(sample_t); format.channels = channels; format.rate = sample_rate; format.byte_format = #if LITTLE_ENDIAN_OUTPUT AO_FMT_LITTLE #else AO_FMT_BIG #endif ; if (memcmp(&format, ¤t_sample_format, sizeof(format))) { /* Sample format has changed, so (re-)open audio device */ ao_info *info = ao_driver_info(driver_id); if (!info) return -1; if ((info->type == AO_TYPE_FILE) != !!out_filename) { if (out_filename) fprintf(stderr, "Live output driver \"%s\" does not take an output file\n", info->short_name); else fprintf(stderr, "File output driver \"%s\" requires an output filename\n", info->short_name); return -1; } if (device) ao_close(device); memcpy(¤t_sample_format, &format, sizeof(format)); if (out_filename) device = ao_open_file(driver_id, out_filename, 1, &format, device_options); else device = ao_open_live(driver_id, &format, device_options); if (!device) { fprintf(stderr, "Error opening \"%s\" audio device\n", info->short_name); return -1; } } return 0; } static void apply_config(VGMSTREAM* vgmstream, song_settings_t* cfg) { vgmstream_cfg_t vcfg = {0}; vcfg.allow_play_forever = 1; vcfg.play_forever = cfg->play_forever; vcfg.fade_time = cfg->fade_time; vcfg.loop_count = cfg->loop_count; vcfg.fade_delay = cfg->fade_delay; vcfg.ignore_loop = cfg->ignore_loop; vcfg.force_loop = cfg->force_loop; vcfg.really_force_loop = cfg->really_force_loop; vgmstream_apply_config(vgmstream, &vcfg); } static int play_vgmstream(const char *filename, song_settings_t *cfg) { int ret = 0; STREAMFILE* sf; VGMSTREAM *vgmstream; FILE *save_fps[4]; size_t buffer_size; int32_t max_buffer_samples; int i; int output_channels; sf = open_stdio_streamfile(filename); if (!sf) { fprintf(stderr, "%s: cannot open file\n", filename); return -1; } sf->stream_index = cfg->stream_index; vgmstream = init_vgmstream_from_STREAMFILE(sf); close_streamfile(sf); if (!vgmstream) { fprintf(stderr, "%s: error opening stream\n", filename); return -1; } printf("Playing stream: %s\n", filename); /* Print metadata in verbose mode */ if (verbose) { char description[4096] = { '\0' }; describe_vgmstream(vgmstream, description, sizeof(description)); puts(description); putchar('\n'); } /* If the audio device hasn't been opened yet, then describe it */ if (!device) { ao_info *info = ao_driver_info(driver_id); printf("Audio device: %s\n", info->name); printf("Comment: %s\n", info->comment); putchar('\n'); } /* Stupid hack to hang onto a few low-numbered file descriptors * so that play_compressed_file() doesn't break, due to POSIX * wackiness like https://bugs.debian.org/590920 */ for (i = 0; i < 4; i++) save_fps[i] = fopen("/dev/null", "r"); /* Calculate how many loops are needed to achieve a minimum * playback time. Note: This calculation is derived from the * logic in get_vgmstream_play_samples(). */ if (vgmstream->loop_flag && cfg->loop_count < 0) { double intro = (double)vgmstream->loop_start_sample / vgmstream->sample_rate; double loop = (double)(vgmstream->loop_end_sample - vgmstream->loop_start_sample) / vgmstream->sample_rate; double end = cfg->fade_time + cfg->fade_delay; if (loop < 1.0) loop = 1.0; cfg->loop_count = ((cfg->min_time - intro - end) / loop + 0.99); if (cfg->loop_count < 1.0) cfg->loop_count = 1.0; } /* Config */ apply_config(vgmstream, cfg); output_channels = vgmstream->channels; vgmstream_mixing_enable(vgmstream, 0, NULL, &output_channels); /* query */ /* Buffer size in bytes (after getting channels) */ buffer_size = 1024 * buffer_size_kb; if (!buffer) { if (buffer_size_kb < 1) { fprintf(stderr, "Invalid buffer size '%d'\n", buffer_size_kb); return -1; } buffer = malloc(buffer_size); if (!buffer) goto fail; } max_buffer_samples = buffer_size / (output_channels * sizeof(sample)); vgmstream_mixing_enable(vgmstream, max_buffer_samples, NULL, NULL); /* enable */ /* Init */ ret = set_sample_format(output_channels, vgmstream->sample_rate); if (ret) goto fail; /* Decode */ { double total; int time_total_min; double time_total_sec; int play_forever = vgmstream_get_play_forever(vgmstream); if (out_filename && play_forever) { fprintf(stderr, "%s: cannot play forever and use output filename\n", filename); ret = -1; goto fail; } int32_t decode_pos_samples = 0; int32_t length_samples = vgmstream_get_samples(vgmstream); if (length_samples <= 0) goto fail; total = (double)length_samples / vgmstream->sample_rate; time_total_min = (int)total / 60; time_total_sec = total - 60 * time_total_min; while (!interrupted) { int to_do; if (decode_pos_samples + max_buffer_samples > length_samples && !play_forever) to_do = length_samples - decode_pos_samples; else to_do = max_buffer_samples; if (to_do <= 0) { break; /* EOF */ } render_vgmstream(buffer, to_do, vgmstream); #if LITTLE_ENDIAN_OUTPUT swap_samples_le(buffer, output_channels * to_do); #endif if (verbose && !out_filename) { double played = (double)decode_pos_samples / vgmstream->sample_rate; double remain = (double)(length_samples - decode_pos_samples) / vgmstream->sample_rate; int time_played_min = (int)played / 60; double time_played_sec = played - 60 * time_played_min; int time_remain_min = (int)remain / 60; double time_remain_sec = remain - 60 * time_remain_min; /* Time: 01:02.34 [08:57.66] of 10:00.00 */ printf("\rTime: %02d:%05.2f [%02d:%05.2f] of %02d:%05.2f ", time_played_min, time_played_sec, time_remain_min, time_remain_sec, time_total_min, time_total_sec); fflush(stdout); } if (!ao_play(device, (char *)buffer, to_do * output_channels * sizeof(sample))) { fputs("\nAudio playback error\n", stderr); ao_close(device); device = NULL; ret = -1; break; } decode_pos_samples += to_do; } if (verbose && !ret) { /* Clear time status line */ putchar('\r'); for (i = 0; i < 64; i++) putchar(' '); putchar('\r'); fflush(stdout); } if (out_filename && !ret) printf("Wrote %02d:%05.2f of audio to %s\n\n", time_total_min, time_total_sec, out_filename); if (interrupted) { fputs("Playback terminated.\n\n", stdout); ret = record_interrupt(); if (ret) fputs("Exiting...\n", stdout); } } fail: close_vgmstream(vgmstream); for (i = 0; i < 4; i++) fclose(save_fps[i]); return ret; } static int play_playlist(const char *filename, song_settings_t *default_par) { int ret = 0; FILE *f; char *line = NULL; size_t line_mem = 0; ssize_t line_len = 0; song_settings_t par; memcpy(&par, default_par, sizeof(par)); f = fopen(filename, "r"); if (!f) { fprintf(stderr, "%s: cannot open playlist file\n", filename); return -1; } while ((line_len = getline(&line, &line_mem, f)) >= 0) { /* Remove any leading whitespace */ size_t ws_len = strspn(line, "\t "); if (ws_len > 0) { line_len -= ws_len; memmove(line, line + ws_len, line_len + 1); } /* Remove trailing whitespace */ while (line_len >= 1 && (line[line_len - 1] == '\r' || line[line_len - 1] == '\n')) line[--line_len] = '\0'; #define EXT_PREFIX "#EXT-X-VGMSTREAM:" if (!strncmp(line, EXT_PREFIX, sizeof(EXT_PREFIX) - 1)) { /* Parse vgmstream-specific metadata */ char *param = strtok(line + sizeof(EXT_PREFIX) - 1, ","); #define PARAM_MATCHES(NAME) (!strncmp(param, NAME "=", sizeof(NAME)) && arg) while (param) { char *arg = strchr(param, '='); if (arg) arg++; if (PARAM_MATCHES("FADEDELAY")) par.fade_delay = atof(arg); else if (PARAM_MATCHES("FADETIME")) par.fade_time = atof(arg); else if (PARAM_MATCHES("LOOPCOUNT")) par.loop_count = atof(arg); else if (PARAM_MATCHES("STREAMINDEX")) par.stream_index = atoi(arg); param = strtok(NULL, ","); } } /* Skip blank or comment lines */ if (line[0] == '\0' || line[0] == '#') continue; ret = play_file(line, &par); if (ret) break; /* Reset playback options to default */ memcpy(&par, default_par, sizeof(par)); } free(line); fclose(f); return ret; } static int play_compressed_file(const char *filename, song_settings_t *par, const char *expand_cmd) { int ret; char temp_dir[128] = "/tmp/vgmXXXXXX"; const char *base_name; char *last_slash, *last_dot; char *cmd = NULL, *temp_file = NULL; FILE *in_fp, *out_fp; cmd = malloc(strlen(filename) + 1024); temp_file = malloc(strlen(filename) + 256); if (!cmd || !temp_file) return -2; if (!mkdtemp(temp_dir)) { fprintf(stderr, "%s: error creating temp dir for decompression\n", temp_dir); ret = -1; goto fail; } /* Get the base name of the file path */ last_slash = strrchr(filename, '/'); if (last_slash) base_name = last_slash + 1; else base_name = filename; sprintf(temp_file, "%s/%s", temp_dir, base_name); /* Chop off the compressed-file extension */ last_dot = strrchr(temp_file, '.'); if (last_dot) *last_dot = '\0'; printf("Decompressing file: %s\n", filename); in_fp = fopen(filename, "rb"); out_fp = fopen(temp_file, "wb"); if (in_fp && out_fp) { setbuf(in_fp, NULL); setbuf(out_fp, NULL); /* Don't put filenames into the system() arg; that's insecure! */ sprintf(cmd, "%s <&%d >&%d ", expand_cmd, fileno(in_fp), fileno(out_fp)); ret = system(cmd); if (WIFSIGNALED(ret) && (WTERMSIG(ret) == SIGINT || WTERMSIG(ret) == SIGQUIT)) interrupted = 1; } else ret = -1; if (in_fp && fclose(in_fp)) ret = -1; if (out_fp && fclose(out_fp)) ret = -1; if (ret) { if (interrupted) { putchar('\r'); ret = record_interrupt(); if (ret) fputs("Exiting...\n", stdout); } else fprintf(stderr, "%s: error decompressing file\n", filename); } else ret = play_file(temp_file, par); remove(temp_file); remove(temp_dir); fail: free(cmd); free(temp_file); return ret; } static int play_file(const char *filename, song_settings_t *par) { size_t len = strlen(filename); #define ENDS_IN(EXT) !strcasecmp(EXT, filename + len - sizeof(EXT) + 1) if (ENDS_IN(".m3u") || ENDS_IN(".m3u8")) return play_playlist(filename, par); else if (ENDS_IN(".bz2")) return play_compressed_file(filename, par, "bzip2 -cd"); else if (ENDS_IN(".gz")) return play_compressed_file(filename, par, "gzip -cd"); else if (ENDS_IN(".lzma")) return play_compressed_file(filename, par, "lzma -cd"); else if (ENDS_IN(".xz")) return play_compressed_file(filename, par, "xz -cd"); else return play_vgmstream(filename, par); } static void add_driver_option(const char *key_value) { char buf[1024]; char *value = NULL; char *sep; strncpy(buf, key_value, sizeof(buf)); buf[sizeof(buf) - 1] = '\0'; sep = strchr(buf, ':'); if (sep) { *sep = '\0'; value = sep + 1; } ao_append_option(&device_options, buf, value); } int main(int argc, char **argv) { int error = 0; int opt; song_settings_t cfg; int extension = 0; signal(SIGHUP, interrupt_handler); signal(SIGINT, interrupt_handler); signal(SIGQUIT, interrupt_handler); ao_initialize(); driver_id = ao_default_driver_id(); memset(¤t_sample_format, 0, sizeof(current_sample_format)); if (argc == 1) { /* We were invoked with no arguments */ usage(argv[0]); goto done; } again_opts: { song_settings_t default_par = DEFAULT_PARAMS; cfg = default_par; } while ((opt = getopt(argc, argv, "-D:F:L:M:S:b:d:f:o:@:hrvieEp")) != -1) { switch (opt) { case 1: /* glibc getopt extension * (files may appear multiple times in any position, ex. "file.adx -L 1.0 file.adx") */ extension = 1; if (play_file(optarg, &cfg)) { error = 1; goto done; } break; case '@': if (play_playlist(optarg, &cfg)) { error = 1; goto done; } break; case 'D': cfg.fade_delay = atof(optarg); break; case 'F': cfg.fade_time = atof(optarg); break; case 'L': cfg.loop_count = atof(optarg); break; case 'M': cfg.min_time = atof(optarg); cfg.loop_count = -1.0; break; case 'S': cfg.stream_index = atoi(optarg); break; case 'i': cfg.ignore_loop = 1; break; case 'e': cfg.force_loop = 1; break; case 'E': cfg.really_force_loop = 1; break; case 'p': cfg.play_forever = 1; break; case 'b': if (!buffer) buffer_size_kb = atoi(optarg); break; case 'd': driver_id = ao_driver_id(optarg); if (driver_id < 0) { fprintf(stderr, "Invalid output driver \"%s\"\n", optarg); error = 1; goto done; } break; case 'f': out_filename = optarg; break; case 'h': usage(argv[0]); goto done; case 'o': add_driver_option(optarg); break; case 'r': repeat = 1; break; case 'v': verbose = 1; break; default: VGM_LOG("vgmstream123: unknown opt %x", opt); goto done; } } again_files: if (!extension) { /* standard POSIX getopt * (files are expected to go at the end, optind is at that point) */ for (opt = optind; opt < argc; ++opt) { if (play_file(argv[opt], &cfg)) { error = 1; goto done; } } } if (repeat) { if (extension) { optind = 0; /* mark reset (BSD may need optreset?) */ goto again_opts; } else { goto again_files; } } done: if (device) ao_close(device); if (buffer) free(buffer); ao_free_options(device_options); ao_shutdown(); return error; }