Skip to content

Instantly share code, notes, and snippets.

@spicyjpeg
Created June 26, 2022 15:29
Show Gist options
  • Save spicyjpeg/97c450320796fc0cb00c2a1490fad98d to your computer and use it in GitHub Desktop.
Save spicyjpeg/97c450320796fc0cb00c2a1490fad98d to your computer and use it in GitHub Desktop.
Audio CD image (.bin + .cue) generator script
#!/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