#!/usr/bin/env python3 if __name__ == "__main__": print(":: C bytebeat generator: compiler unit") from argparse import ArgumentParser from decimal import Decimal from math import ceil from os import getcwd, environ, makedirs, name as os_name, rename from os.path import exists, join as path_join from shlex import join as command_line_join, split as command_line_split from shutil import which from sys import stdin, stdout from tempfile import TemporaryDirectory from typing import Dict, Union import subprocess # Definitions BITS_PER_BYTE = 8 EXIT_FAILURE = 1 EXIT_SUCCESS = 0 # Paths PATHS = { "src_dir": "src", "bin_dir": "bin", "template": "template.c", "substitute": "substituted.c", "executable": "render_bytebeat", "fwrite_le_header": "fwrite_le.h", "fwrite_le": "fwrite_le.c", "include_directory": "include" } # Add current directory before all paths for compilation CURRENT_DIRECTORY = getcwd() for key in ["src_dir", "bin_dir", "include_directory"]: PATHS[key] = path_join(CURRENT_DIRECTORY, PATHS[key]) # Resolve paths PATHS["template"] = path_join(PATHS["src_dir"], PATHS["template"]) PATHS["substitute_kept"] = path_join(PATHS["bin_dir"], PATHS["substitute"]) PATHS["executable_kept"] = path_join(PATHS["bin_dir"], PATHS["executable"]) PATHS["fwrite_le"] = path_join(PATHS["src_dir"], PATHS["fwrite_le"]) # Default parameters DEFAULT_PARAMETERS = { "CC": "cc", "CFLAGS": "-Ofast -march=native -mtune=native -Wall -Wextra -Wpedantic " "-pedantic -Wno-unused-variable -Wno-unused-but-set-variable " "-Wno-dangling-else -Wno-parentheses -std=c99" } stdout_atty = hasattr(stdout, "isatty") and stdout.isatty() def fetch(name: str): if from_env := environ.get(name): return from_env elif name != "CFLAGS_EXTRA": return DEFAULT_PARAMETERS[name] def read_file(path: str) -> str: return open(path, "r", encoding="utf-8-sig").read() def overwrite_file(path: str, content: str) -> int: return open(path, "w", encoding="utf-8").write(content) def read_from_file_or_stdin(path: str) -> str: if path == "-": print("Reading from STDIN...", flush=True) return "\n".join(stdin) elif exists(path): return read_file(path) else: raise SystemExit(f"The specified file {path} doesn't exist") def substitute_vars(replacements: Dict[str, Union[bool, str]], text: str, verbose: bool) -> str: if verbose: print("Substituting values:") for placeholder, replacement in replacements.items(): if isinstance(replacement, bool): replacement = preprocessor_bool(replacement) if verbose and placeholder != "bytebeat_contents": print(placeholder, ": ", replacement, sep="") text = text.replace(f"`{placeholder}`", str(replacement)) if verbose: print() return text def run_command(*command: list[str], stage: str) -> None: print("[>]", command_line_join(command), flush=True) if subprocess.run(command).returncode != EXIT_SUCCESS: if stage == "rendering": print("An error occured during rendering!") raise SystemExit(EXIT_FAILURE) def compile_substituted_file(input_file: str, executable_file: str) -> None: print("Compiling") run_command( CC, *command_line_split(CFLAGS), input_file, PATHS["fwrite_le"], "-o", executable_file, "-I" + PATHS["include_directory"], stage="compilation" ) run_command(executable_file, stage="rendering") def main_workflow(input_file: str, executable_file: str, \ substitute_contents: Dict[str, str]) -> None: overwrite_file(input_file, substitute_contents) compile_substituted_file(input_file, executable_file) preprocessor_bool = lambda value: "1" if value else "0" C_str_repr = lambda s: '"' + s.replace("\\", "\\\\").replace(r'"', r'\"') + '"' CC = fetch("CC") CC_SEARCH_LIST = [ "gcc", "clang", "tcc" ] if os_name == "nt": CC_SEARCH_LIST = [ "msc", *CC_SEARCH_LIST ] CFLAGS = fetch("CFLAGS") if extra := fetch("CFLAGS_EXTRA"): CFLAGS += " " + extra is_cmd_available = lambda cmd: which(cmd) is not None is_cmd_unavailable = lambda cmd: which(cmd) is None if __name__ == "__main__": parser = ArgumentParser(description=\ "Substitutes supplied C (non-JavaScript!) bytebeat into the template, " "then attempts to compile the instance of the template. Accepts " "environmental variables `CC`, `CFLAGS`. `CFLAGS_EXTRA` can be used to " "add to default `CFLAGS`.") parser.add_argument("file", type=str, help="bytebeat formula file (use `-` to read from stdin)") parser.add_argument("-o", "--output", default="output.wav", type=str, help="specify output WAVE file path (default is `output.wav`") parser.add_argument("-r", "--sample-rate", default=8000, type=int, help="sample rate (Hz)") parser.add_argument("-p", "--final-sample-rate", default=None, type=int, help="convert the output to a different sample rate (usually one that " "is set in the system, to improve sound quality) during generation " "(not just reinterpretation)") parser.add_argument("-b", "--bit-depth", default=8, type=int, help="bit depth") parser.add_argument("-s", "--signed", default=False, action="store_true", help="is signed?") parser.add_argument("-R", "--precalculate-ratio", default=False, action="store_true", help="precalculate sample ratio to speed up rendering (may produce " "inaccurate results") parser.add_argument("-m", "--faster-sample-ratio-math", default=False, action="store_true", help="faster sample ratio math (implies argument -R)") parser.add_argument("-f", "--floating-point", default=False, action="store_true", help="use floating point as the return type") parser.add_argument("-c", "--channels", default=1, type=int, help="amount of channels") parser.add_argument("-t", "--seconds", default=None, type=Decimal, help="length in seconds (samples = sample rate * seconds) : " "default = 30 seconds") parser.add_argument("-l", "--samples", default=None, type=int, help="length in samples (adds to `-t`; supports negative numbers) : " "default = +0 samples") parser.add_argument("-a", "--custom-return-code", default=False, action="store_true", help="do not insert return statement before the code") parser.add_argument("-u", "--mode", default="sequential", type=str, help="mode of writing: `sequential` or `instant` (the latter is not " "recommended, since the whole result would be stored in RAM)") parser.add_argument("-n", "--block-size", default=65536, type=int, help="sequential mode only: block size of each sequence, bytes") parser.add_argument("-q", "--silent", default=False, action="store_true", help="do not output anything during generation") parser.add_argument("-v", "--verbose", default=False, action="store_true", help="show progress during generation") parser.add_argument("-E", "--show-substituted-values", default=False, action="store_true", help="show substituted values") parser.add_argument("--color", default="auto", type=str, help="ANSI escape codes. Set to 'always' to enable them, 'none' to " "disable. Default: 'auto'.") parser.add_argument("--keep-files", default=False, action="store_true", help="keep generated files: substituted source code of runtime unit " "and the executable it will be compiled to.") args = parser.parse_args() bytebeat_contents = read_from_file_or_stdin(args.file).strip() if not bytebeat_contents: raise SystemExit("Empty file or STDIN") # - Compilation if not args.custom_return_code: # Insert `return` statement # XXX: The bytebeat code is enclosed in parentheses to allow for the # use of commas as a comma operator, enabling more formulas to function. bytebeat_contents = f"return ({bytebeat_contents})" original_sample_rate = args.sample_rate final_sample_rate_code = "" if args.faster_sample_ratio_math: args.precalculate_ratio = True if args.precalculate_ratio and not args.final_sample_rate is None: if args.faster_sample_ratio_math: sample_rate_ratio = args.sample_rate / args.final_sample_rate final_sample_rate_code = f"time *= {sample_rate_ratio}L;" else: sample_rate_ratio = args.final_sample_rate / args.sample_rate final_sample_rate_code = f"time /= {sample_rate_ratio}L;" args.sample_rate = args.final_sample_rate final_sample_rate = \ value if (value := args.final_sample_rate) else original_sample_rate samples = 0 while True: no_seconds = args.seconds is None or args.seconds == 0 no_samples = args.samples is None or args.samples == 0 seconds_specified = not no_seconds samples_specified = not no_samples if seconds_specified and args.seconds < 0: raise SystemExit("CLI: Count of seconds can't be less than zero.") if no_seconds and samples_specified: samples = args.samples elif seconds_specified and samples_specified: samples = args.seconds * final_sample_rate + args.samples elif seconds_specified and no_samples: samples = args.seconds * final_sample_rate elif no_seconds and no_samples: args.seconds = 30 # default continue else: raise SystemExit("CLI: Incorrect seconds/samples length format.") break if samples <= 0: raise SystemExit("CLI: Count of samples should be greater than zero.") # round the number of samples samples = ceil(samples) if args.mode != "sequential" and args.mode != "instant": raise SystemExit(f"Invalid mode '{args.mode}'") gen_length = args.channels * samples ansi_escape_codes_supported = args.color == "auto" and stdout_atty or \ args.color == "always" if is_cmd_unavailable(CC): print(f"Compiler {CC} is not available, searching:") still_unavailable = True for compiler in CC_SEARCH_LIST: print(f"* Trying CC={compiler}", end="") if is_cmd_available(compiler): print(": OK") CC = compiler still_unavailable = False break else: print() if still_unavailable: raise SystemExit("Could not find an available compiler. Please " "specify it by setting\nan environmental variable " "CC.") substitute_contents = substitute_vars({ "bytebeat_contents": bytebeat_contents, "output_file": C_str_repr(args.output), "sample_rate": \ value if (value := args.final_sample_rate) else args.sample_rate, "original_sample_rate": original_sample_rate, "final_sample_rate_code": final_sample_rate_code, "bit_depth": args.bit_depth, "is_signed": args.signed, "precalculated_ratio": args.precalculate_ratio, "faster_sample_ratio_math": args.precalculate_ratio, "fp_return_type": args.floating_point, "channels": args.channels, "length": samples, "wav_product": gen_length * (args.bit_depth // BITS_PER_BYTE), "gen_length": gen_length, "sequential_mode": args.mode == "sequential", "block_size": args.block_size, "silent_mode": args.silent, "verbose_mode": args.verbose and not args.silent, "fwrite_le": PATHS["fwrite_le_header"], "ansi_escape_codes_supported": ansi_escape_codes_supported }, read_file(PATHS["template"]), args.show_substituted_values) if args.keep_files: makedirs(PATHS["bin_dir"], exist_ok=True) substitute_file = PATHS["substitute_kept"] executable_file = PATHS["executable_kept"] main_workflow(substitute_file, executable_file, substitute_contents) else: with TemporaryDirectory() as tmpdirname: temporary_path = lambda path: path_join(tmpdirname, path) substitute_temp = temporary_path(PATHS["substitute"]) executable_temp = temporary_path(PATHS["executable"]) main_workflow(substitute_temp, executable_temp, substitute_contents)