Created
June 26, 2022 15:29
-
-
Save spicyjpeg/97c450320796fc0cb00c2a1490fad98d to your computer and use it in GitHub Desktop.
Audio CD image (.bin + .cue) generator script
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
"""Audio CD image generator | |
This is a very simple command-line tool to generate a .bin and .cue file for an | |
audio-only CD from a set of input tracks in any format. Most of the heavy | |
lifting here is done by FFmpeg and NumPy, this script merely writes converted | |
audio data to the .bin file and generates the cuesheet. | |
It's recommended to only use lossless audio files with this script. If you need | |
to include data tracks in the image in addition to audio tracks, consider using | |
mkpsxiso (or literally any other CD image authoring tool) instead. | |
""" | |
__version__ = "0.1.0" | |
__author__ = "spicyjpeg" | |
import os, sys, math, logging | |
from argparse import ArgumentParser, FileType | |
import numpy, av | |
## Helpers | |
SECTOR_SIZE = 2352 | |
BYTES_PER_SEC = 2352 * 75 | |
MAX_SECTOR = 449999 - 150 # = (99:59:74 - pregap) | |
def _toMSF(sector): | |
m = sector // 4500 | |
s = (sector // 75) % 60 | |
f = sector % 75 | |
return f"{m:02}:{s:02}:{f:02}" | |
def _frameToCDDA(frame, gain = 1.0): | |
data = frame.to_ndarray()[0] | |
data *= gain * 32768.0 | |
data = data.clip(-32768.0, 32767.0).astype("<i2") | |
return data.tobytes() | |
def _convertTrack(avFile, outputFile, gain = 1.0, pad = 0, align = 0): | |
resampler = av.AudioResampler("flt", 2, 44100) | |
offset = 0 | |
with avFile: | |
for inputFrame in avFile.decode(audio = 0): | |
for frame in resampler.resample(inputFrame): | |
offset += outputFile.write(_frameToCDDA(frame, gain)) | |
# Flush any samples buffered by the resampler once the entire input file | |
# has been processed. | |
for frame in resampler.resample(None): | |
offset += outputFile.write(_frameToCDDA(frame, gain)) | |
# Insert empty padding after the audio data if necessary. | |
padLength = pad or 0 | |
if align: | |
padLength += align - (offset % align) | |
offset += outputFile.write(bytes(padLength)) | |
return offset | |
## Main | |
def _loggerSetup(verbose = None): | |
logging.basicConfig( | |
format = "[%(funcName)-13s %(levelname)-7s] %(message)s", | |
level = ( | |
logging.WARNING, | |
logging.INFO, # -v | |
logging.DEBUG # -vv | |
)[min(verbose or 0, 2)] | |
) | |
def _createParser(): | |
parser = ArgumentParser( | |
description = "Builds an audio CD image (.bin + .cue) from one or more audio files.", | |
add_help = False | |
) | |
group = parser.add_argument_group("Tool options") | |
group.add_argument( | |
"-h", "--help", | |
action = "help", | |
help = "Show this help message and exit" | |
) | |
group.add_argument( | |
"-v", "--verbose", | |
action = "count", | |
help = "Increase logging verbosity (-v = info, -vv = info + debug)" | |
) | |
group = parser.add_argument_group("Track options") | |
group.add_argument( | |
"-g", "--gain", | |
type = float, | |
default = 1.0, | |
help = "Change volume of all tracks when transcoding (e.g. to prevent clipping, default 1.0)", | |
metavar = "value" | |
) | |
group.add_argument( | |
"-p", "--pad", | |
type = float, | |
default = 1.0, | |
help = "Add a fixed amount of silence after each track (default 1 second)", | |
metavar = "seconds" | |
) | |
group.add_argument( | |
"-a", "--align", | |
type = float, | |
default = 1.0, | |
help = "Add silence between tracks to make sure the offset of each track is a multiple of the specified value (default 1 second)", | |
metavar = "seconds" | |
) | |
group = parser.add_argument_group("Cuesheet options") | |
group.add_argument( | |
"-n", "--no-pregap", | |
action = "store_true", | |
help = "Do not add a PREGAP tag to the first track" | |
) | |
group.add_argument( | |
"-l", "--lf", | |
action = "store_true", | |
help = "Use LF line terminators instead of CRLF in the cuesheet" | |
) | |
group = parser.add_argument_group("File paths") | |
group.add_argument( | |
"outputFile", | |
type = FileType("wb"), | |
help = "Name of .bin file to be generated (cuesheet will be generated in the same directory)" | |
) | |
group.add_argument( | |
"trackFile", | |
type = str, | |
nargs = "+", | |
help = "Source audio files, specify one for each track" | |
) | |
return parser | |
def main(): | |
parser = _createParser() | |
args = parser.parse_args() | |
_loggerSetup(args.verbose) | |
# Compute the name of the cuesheet to be generated by basically replacing | |
# the .bin extension in the given output path with .cue. | |
cuePath, binFile = os.path.split(args.outputFile.name) | |
cueFile = f"{os.path.splitext(binFile)[0]}.cue" | |
cuePath = os.path.join(cuePath, cueFile) | |
pad = max(math.ceil(BYTES_PER_SEC * args.pad), 0) | |
align = max(math.ceil(BYTES_PER_SEC * args.align), SECTOR_SIZE) | |
linefeed = "\n" if args.lf else "\r\n" | |
cuesheet = [ f"FILE \"{binFile}\" BINARY" ] | |
offset = 0 | |
with args.outputFile as outputFile: | |
for index, path in enumerate(args.trackFile): | |
msf = _toMSF(offset) | |
logging.info(f"Adding track {index + 1} at {msf}") | |
cuesheet.append(f" TRACK {index + 1:02} AUDIO") | |
if not index and not args.no_pregap: | |
cuesheet.append(" PREGAP 00:02:00") | |
cuesheet.append(f" INDEX 01 {msf}") | |
offset += _convertTrack( | |
av.open(path, "r"), | |
outputFile, | |
args.gain, | |
pad, | |
align | |
) // SECTOR_SIZE | |
if offset > (MAX_SECTOR * SECTOR_SIZE): | |
logging.warning(f"Track {index + 1} ends after 99:59:74") | |
# Some poorly written cuesheet parsers expect every line to be terminated | |
# by a linefeed, so an empty line must be added at the end to ensure the | |
# last line is parsed properly. | |
cuesheet.append("") | |
with open(cuePath, "wt", newline = "") as outputFile: | |
outputFile.write(linefeed.join(cuesheet)) | |
logging.info(f"Done, total disc duration: {_toMSF(offset)}") | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment