Skip to content

Instantly share code, notes, and snippets.

@timoschinkel
Last active April 2, 2021 08:12
Show Gist options
  • Save timoschinkel/e39eb8600d2584513c6129811ba2eb62 to your computer and use it in GitHub Desktop.
Save timoschinkel/e39eb8600d2584513c6129811ba2eb62 to your computer and use it in GitHub Desktop.
MP3 and cue file joining

I wanted to combine some old fashioned MP3 files and I did not want to download a separate application. Only thing necessary when combining MP3 files is stripping the ID3 tags - if present.

php -f join_mp3.php {destination} [{source}]

Edit: Turned out I has some multidisc mixtapes with .cue files lying around as well. So I wrote a joiner script for that as well:

php -f join_cue.php {destination} [{source}:{duration_of_source}]

NB These scripts come without any warranty :)

NB2 Joining MP3 files can also be done via ffmpeg - https://bytefreaks.net/applications/how-we-concatenate-multiple-mp3-files-into-one-using-ffmpeg - but writing it yourself is way more fun.

<?php
/*
* https://www.gnu.org/software/ccd2cue/manual/html_node/INDEX-_0028CUE-Command_0029.html
*/
final class Time
{
private $seconds;
private $frames;
public function __construct(int $seconds, int $frames = 0)
{
$this->seconds = $seconds;
$this->frames = $frames;
}
public function seconds(): int { return $this->seconds; }
public function frames(): int { return $this->frames; }
public function add(Time $time): Time
{
$frames = $this->frames() + $time->frames();
return new Time(
$this->seconds() + $time->seconds() + floor($frames / 75),
$frames % 75
);
}
public function toCueNotation(): string
{
return sprintf(
'%02d:%02d:%02d',
floor($this->seconds / 60),
$this->seconds % 60,
$this->frames
);
}
public static function fromTimeNotation(string $duration): Time
{
$parts = array_reverse(explode(':', $duration));
$seconds =
$parts[0] +
intval($parts[1] ?? '0') * 60 +
intval($parts[2] ?? '0') * 3600;
return new Time($seconds);
}
public static function fromCueNotation(string $duration): Time
{
$parts = array_reverse(explode(':', $duration));
return new Time(
intval($parts[1] ?? '0') + intval($parts[2] ?? '0') * 60,
intval($parts[0] ?? '0')
);
}
}
final class Track
{
private $raw;
private $number;
private $indices;
public function __construct(string $raw, int $number, string ...$indices)
{
$this->raw = $raw;
$this->number = $number;
$this->indices = $indices;
}
public function raw(): string { return $this->raw; }
public function number(): int { return $this->number; }
public function indices(): array { return $this->indices; }
}
function extract_tracks(string $contents): array
{
preg_match_all('/(?P<track>\s*TRACK (?P<number>\d+) AUDIO.+?(\s*INDEX \d{2} \d+(:\d+)*)+)/s', $contents, $matches);
$tracks = [];
for($i = 0; $i < count($matches['track']); $i++) {
preg_match_all('/INDEX \d+ (?P<time>\d+(:\d+)*)/s', $matches['track'][$i], $indexMatches);
$tracks[] = new Track(
$matches['track'][$i],
intval($matches['number'][$i]),
...$indexMatches['time']
);
}
return $tracks;
}
function debug(string $line): void
{
print $line . PHP_EOL;
}
if (count($argv) < 3) {
$filename = basename(__FILE__);
print "Usage php -f ${filename} <destination> [<source>:<duration>]" . PHP_EOL;
exit(0);
}
$destination = $argv[1];
$sources = array_slice($argv, 2);
$trackNumber = 0;
$totalDuration = new Time(0);
foreach ($sources as $index => $source) {
if (preg_match('/^(?P<filename>.+?):(?P<duration>(\d+:)*\d+)$/si', $source, $matches) !== 1) {
print "Unable to split filename and duration for ${source}" . PHP_EOL;
exit(1);
}
$filename = $matches['filename'];
$time = Time::fromTimeNotation($matches['duration']);
$contents = utf8_encode(file_get_contents($filename));
$tracks = extract_tracks($contents);
if ($index === 0) {
// Copy first file as is
file_put_contents($destination, trim($contents));
} else {
foreach ($tracks as $track) {
$raw = str_replace( // update track number
[sprintf('TRACK %d', $track->number()), sprintf('TRACK %02d', $track->number())],
sprintf('TRACK %02d', $trackNumber + $track->number()),
$track->raw()
);
foreach ($track->indices() as $index) {
$newIndex = Time::fromCueNotation($index)->add($totalDuration);
$raw = str_replace(
$index,
$newIndex->toCueNotation(),
$raw
);
}
file_put_contents($destination, $raw, FILE_APPEND);
}
}
$trackNumber += count($tracks); // let's assume sequential track numbers
$totalDuration = $totalDuration->add($time);
}
// Leave a nice line break at the end of the file
file_put_contents($destination, PHP_EOL, FILE_APPEND);
<?php
/*
* SETTINGS
*/
$CHUNK_SIZE = 8096;
$KEEP_ID3_TAGS_OF_FIRST_FILE = true;
function skip_id3_v1 ($handle): void
{
$position = ftell($handle);
$id = fread($handle, 3);
if ($id === 'TAG') {
// ID3 v1 is always 128 byte long
fseek($handle, 128 - 3, SEEK_CUR);
} else {
// reset
fseek($handle, $position, SEEK_SET);
}
}
function skip_id3_v2($handle): void
{
/*
* Inspiration for the ID3v2 code is drawn from http://www.zedwood.com/article/php-calculate-duration-of-mp3
*/
$position = ftell($handle);
// pre-emptive read 10 bytes
$block = fread($handle, 10);
if (substr($block, 0, 3) === 'ID3') {
$flagIsFooterPresent = (bool)ord($block[5]) & 0x10;
$z0 = ord($block[6]);
$z1 = ord($block[7]);
$z2 = ord($block[8]);
$z3 = ord($block[9]);
if ( (($z0&0x80)==0) && (($z1&0x80)==0) && (($z2&0x80)==0) && (($z3&0x80)==0) ) {
$tagSize = (($z0&0x7f) * 2097152) + (($z1&0x7f) * 16384) + (($z2&0x7f) * 128) + ($z3&0x7f);
fseek($handle, $tagSize + ($flagIsFooterPresent ? 10 : 0), SEEK_CUR);
} else {
// reset
fseek($handle, $position, SEEK_SET);
}
} else {
// reset
fseek($handle, $position, SEEK_SET);
}
}
function debug(string $line): void
{
print $line . PHP_EOL;
}
if (count($argv) < 3) {
$filename = basename(__FILE__);
print "Usage php -f ${filename} <destination> [<source>]" . PHP_EOL;
exit(0);
}
$destination = $argv[1];
$sources = array_slice($argv, 2);
$writer = fopen($destination, 'wb+');
foreach ($sources as $index => $source) {
debug("Adding ${source}");
$reader = fopen($source, 'rb+');
if ($index !== 0 || $KEEP_ID3_TAGS_OF_FIRST_FILE === false) {
skip_id3_v2($reader);
skip_id3_v1($reader);
}
while (!feof($reader)) {
fwrite($writer, fread($reader, $CHUNK_SIZE));
}
fclose($reader);
}
fclose($writer);
exit(0);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment