Skip to content

Instantly share code, notes, and snippets.

@Aluminite
Forked from spicyjpeg/build_audio_cd.py
Last active October 6, 2024 16:35
Show Gist options
  • Save Aluminite/a8375f5a32af645848babea2685968d7 to your computer and use it in GitHub Desktop.
Save Aluminite/a8375f5a32af645848babea2685968d7 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.1"
__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", "stereo", 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