Skip to content

Instantly share code, notes, and snippets.

@mountainstorm
Last active December 7, 2020 09:34
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mountainstorm/8106250 to your computer and use it in GitHub Desktop.
Save mountainstorm/8106250 to your computer and use it in GitHub Desktop.
Starling2D extension to allow playing of looped mp3's without the 'click' as it loops (due to the issue with mp3 frame sizes).
/*
* Copyright (c) 2013 Mountainstorm
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package starling.extensions
{
import flash.media.Sound;
import flash.media.SoundTransform;
import flash.events.SampleDataEvent;
import flash.utils.ByteArray;
import flash.utils.Endian;
/** The GaplessLoopedSound class extends the standard flash Sound class with support for
* playback of gapless looped sound (avoid the click when looping mp3's). As is described all
* over the internet, but notably here:
* * http://www.compuphase.com/mp3/mp3loops.htm
* * http://www.iis.fraunhofer.de/content/dam/iis/de/dokumente/amm/conference/AES116_guideline-to-audio-codec-delay.pdf
*
* Basically there are two problems.
* 1. an mp3 file is constructed of frames, each frame holds 1152 samples of audio. If your
* audio isn't an exact multiple of 1152 theres going to be a gap at the end of the last
* frame. This gets padded with zeros. When they designed mp3 they forgot to get the
* encoder to write out how many of the 1152 samples in the last frame are padding; so
* when the decoder converts the mp3 back to raw pcm its longer - with the padding
* converted as well.
*
* 2. The way most encoders work they have to fill up some internal buffers before they can
* start working this gets written out to the output file as 'padding' at the start of the
* audio. As such you get a delay at the beginning (similar to the padding at the end).
* Once again this gets decoded to pcm making it even longer than the original.
*
* To make matters worse the decoder also has this same problem and thus adds even more
* extra delay at the start ... grr
*
* Now I'm no expert, but I'm pretty sure from the testing I've done that Adobe's
* implementation of extract hides the decoder delay; as everything I see can be acounted
* for by the encoder delay and encoder padding
*
* According to forum posts this isn't a probem if you use Flash CC (etc) as it stores all this
* info along with the exported mp3 - hence why it stores them in swf/swc's. I don't have
* Flash so I'll have to believe what I read; but this would gel with my thoughts re. Adobe
* hiding the decoder delay when you extract the bytes
*
* This class provides a workaround for this issue (which you hear as a small click when
* your mp3 loops). It's based on the one described here - but much more involved:
* * http://blog.andre-michelle.com/2010/playback-mp3-loop-gapless/
* * In general you probably want 1.5 frames delay, and 0.5 frame padding (1728 : 576)
*
* What I do know for sure is that when you encode the reference file with LAME; the 100 * 1152
* samples create a file with 102 mp3 frames in; and the decoded pcm has 102 * 1152 samples.
* Basically this appears to be one frame for the LAME Xing/Info section, one for encoder
* delay/padding (the LAME Info section tells us that it has a delay of 576 samples, and
* padding of 576). What this really tells us is that Adobe's decoder doesn't know how to
* handle Xing/Info sections :(
*/
public class GaplessLoopedSound extends Sound
{
// any decent size will do - a multiple of 1152 is probably sensble
protected const BUFFER_READ_SIZE:Number = MP3File.MP3_SAMPLES_PER_FRAME * 4;
static protected const ZERO_LIMIT:Number = 0.1;
// actual sound file we'll be getting our samples from
protected var mSound:Sound = new Sound();
/*
* |-mDelay-|-----mSampleCount-----|-padding-|
* |-mOffset->
*/
protected var mDelay:uint = 0; // samples to skip at the begining
protected var mSampleCount:uint = 0; // samples to play in loop
protected var mOffset:uint = 0; // sample location in the audio stream
// byQuiet uses a different set of metrics; dropping the quietest frames at start/end
// as if your looping and notice the click your audio is probbaly not silent at the crossover
static public function createWithGuess(snd:Sound,
originalSamples:uint,
byQuiet:Boolean=false):GaplessLoopedSound
{
var delay:uint = GaplessLoopedSound.calculateTotalDelay(snd, originalSamples);
var padding:uint = 0;
if (byQuiet == false)
{
// rules:
// * there will always be a delay
// * padding should will only be there if originalSamples isn't a multiple of 1152
// * we may still get padding, but its likley to be half a frame
// we use 576 (1152/2) here as if the encoder used MDCT it works in half frame sizes
// http://lame.sourceforge.net/tech-FAQ.txt
var delta:uint = (originalSamples % MP3File.MP3_SAMPLES_PER_FRAME / 2) // no of extra samples needed to make it fit
delay -= delta;
padding += delta;
if (delay > MP3File.MP3_SAMPLES_PER_FRAME / 2)
{
delay -= MP3File.MP3_SAMPLES_PER_FRAME / 2;
padding += MP3File.MP3_SAMPLES_PER_FRAME / 2;
}
}
else
{
var totalDelayHF:uint = delay / (MP3File.MP3_SAMPLES_PER_FRAME / 2);
var delayHF:uint = 0;
var paddingHF:uint = 0;
// get averages from start and end of sound
var beginHF:Array = GaplessLoopedSound.averagePerHalfFrame(snd, totalDelayHF, true);
var endHF:Array = GaplessLoopedSound.averagePerHalfFrame(snd, totalDelayHF, false);
// work through loosing the quietest sample at start or end
var begin:Number = Math.abs(beginHF.shift());
var end:Number = Math.abs(endHF.shift());
while (totalDelayHF > 0)
{
if (begin < end)
{
delayHF++;
begin = Math.abs(beginHF.shift()); // get next begin value
}
else
{
paddingHF++;
end = Math.abs(endHF.shift()); // get next begin value
}
totalDelayHF--;
}
delay = delayHF * (MP3File.MP3_SAMPLES_PER_FRAME / 2);
padding = paddingHF * (MP3File.MP3_SAMPLES_PER_FRAME / 2);
}
return new GaplessLoopedSound(snd, delay, padding);
}
static public function createWithLAME(bytes:ByteArray):GaplessLoopedSound
{
var snd:Sound = new Sound();
snd.loadCompressedDataFromByteArray(bytes, bytes.length);
var mp3:MP3File = new MP3File(bytes);
return new GaplessLoopedSound(snd, mp3.delay, mp3.padding);
}
static public function calculateTotalDelay(snd:Sound, originalSamples:uint):uint
{
// calc delay by figuring out how much longer it is than it should be
var sampleCount:Number = ( snd.length
/ MP3File.MILLISECONDS_IN_SECOND
* MP3File.SAMPLE_RATE);
return uint(sampleCount - originalSamples);
}
// return is an array of frames * 2 elements; each element is the average sample value; we
// use half a frame as MDCT encoders work on half frames - so its likley to be quanta of these
// uf start is false, returned values are in reverse order (1st element is last half frame)
static public function averagePerHalfFrame(snd:Sound, frames:uint, start:Boolean):Array
{
// calculate the number of zeros in frames (1152 frames), in sound from the start or end
var offset:Number = 0;
if (start == false)
{
var sampleCount:Number = ( snd.length
/ MP3File.MILLISECONDS_IN_SECOND
* MP3File.SAMPLE_RATE);
offset = sampleCount - (frames * MP3File.MP3_SAMPLES_PER_FRAME);
if (offset < 0)
{
offset = 0; // check for tiny files
}
}
var pcmBytesPerFrame:Number = MP3File.MP3_SAMPLES_PER_FRAME * 2 * 4; // stereo, floats
var retVal:Array = new Array();
var bytes:ByteArray = new ByteArray();
var lavg:Number = 0;
var ravg:Number = 0;
snd.extract(bytes, (frames * MP3File.MP3_SAMPLES_PER_FRAME), offset);
bytes.position = 0;
while (bytes.bytesAvailable > 0)
{
lavg += bytes.readFloat();
ravg += bytes.readFloat();
if (bytes.position % (pcmBytesPerFrame / 2) == 0)
{
lavg = lavg / (MP3File.MP3_SAMPLES_PER_FRAME / 2);
ravg = ravg / (MP3File.MP3_SAMPLES_PER_FRAME / 2);
retVal.push((lavg + ravg) / 2);
lavg = 0;
ravg = 0;
}
}
return retVal;
}
/** Construct a GaplessLoopedSound object, using the supplied sound object and dropping
* delay samples at the start, and padding samples at the end of the audio.
*
* default values appeear to work for LAME and logic's encoder ... might work for others
*/
public function GaplessLoopedSound(snd:Sound, delay:uint=1728, padding:uint=576)
{
super();
mDelay = delay;
mSampleCount = ( snd.length
/ MP3File.MILLISECONDS_IN_SECOND
* MP3File.SAMPLE_RATE) - mDelay - padding;
mSound = snd;
addEventListener(SampleDataEvent.SAMPLE_DATA, sampleData);
trace("GaplessLoopedSound - delay: " + delay + ", padding: " + padding);
}
private function sampleData(event:SampleDataEvent):void
{
var target: ByteArray = event.data;
var length:int = BUFFER_READ_SIZE;
while (length > 0)
{
if (mOffset + length > mSampleCount)
{
// we'll be going past the end of the samples
var left:uint = mSampleCount - mOffset;
mSound.extract(event.data, left, mDelay + mOffset);
mOffset += left;
length -= left;
}
else
{
// we can read everything we want to
mSound.extract(event.data, length, mDelay + mOffset);
mOffset += length;
length = 0;
}
if (mOffset == mSampleCount)
{
mOffset = 0;
}
}
}
}
}
import flash.utils.ByteArray;
import flash.utils.Endian;
/** Helper class for parsing raw mp3's; it's not really needed but helped during the testing
*/
class MP3File extends Object
{
// used to calculate combined delay
static public const REFERENCE_SAMPLES:Number = 100; // arbitary decent size
static public const MILLISECONDS_IN_SECOND:Number = 1000;
static public const MP3_SAMPLES_PER_FRAME:Number = 1152; // see spec
static public const SAMPLE_RATE:Number = 44100; // Adobe always uses this
static public const CHANNEL_COUNT:Number = 2; // Adobe always uses 2
static public const FLOAT_SIZE:Number = 4; // Adobe returns 32bit samples
// big endian constants for parsing MP3 file
static private const TAGV1:uint = 0x54414700; // 'TAG'
static private const TAGV2:uint = 0x49443300; // 'ID3'
// big endian constants for parsing LAME mp3 file
static private const INFO:uint = 0x496E666F; // 'Info'
static private const XING:uint = 0x58696E67; // 'Xing'
static private const LAME:uint = 0x4C414D45; // 'LAME'
public var delay:uint = 0;
public var padding:uint = 0;
public function MP3File(bytes:ByteArray)
{
parseMP3(bytes);
}
private function parseMP3(bytes:ByteArray):void
{
var frameCount:uint = 0;
bytes.position = 0;
bytes.endian == Endian.BIG_ENDIAN;
// see: http://www.multiweb.cz/twoinches/mp3inside.htm
while (bytes.bytesAvailable >= 4)
{
// read in next dword
var hdr:uint = bytes.readUnsignedInt();
// check if its an TAG v1
if ((hdr & 0xFFFFFF00) == MP3File.TAGV1) // TAG
{
// TAG v1
if (bytes.bytesAvailable != (128 - 4)) // 128 bytes long at at end of file
{
throw new Error("TAG v1 detected; yet not at end of file - invalid MP3");
}
//trace("tag v1");
}
else if ((hdr & 0xFFFFFF00) == MP3File.TAGV2) // ID3
{
// TAG v2 (ID3)
var tagVersion:uint = (bytes.readUnsignedByte() << 8) | (hdr & 0xFF);
var flags:uint = bytes.readUnsignedByte();
var sizeOfTag:uint = ( (bytes.readUnsignedByte() << 21)
| (bytes.readUnsignedByte() << 14)
| (bytes.readUnsignedByte() << 7)
| bytes.readUnsignedByte());
bytes.position += sizeOfTag; // skip tag
//trace("tag v2");
}
else
{
// it should be an MP3 frame
// format: AAAAAAAA AAABBCCD EEEEFFGH IIJJKLMM
// F F F B A 0 4 0
var frameSyncBits:uint = ((hdr >> 21) & 0x000007FF); // A
var mpegVersionBits:uint = ((hdr >> 19) & 0x00000003); // B
var mpegLayerBits:uint = ((hdr >> 17) & 0x00000003); // C
var protectionBits:uint = ((hdr >> 16) & 0x00000001); // D
var bitRateBits:uint = ((hdr >> 12) & 0x0000000F); // E
var sampleRateBits:uint = ((hdr >> 10) & 0x00000003); // F
var paddingBits:uint = ((hdr >> 9) & 0x00000001); // G
var privateBits:uint = ((hdr >> 8) & 0x00000001); // H
var channelModeBits:uint = ((hdr >> 6) & 0x00000003); // I
var modeExtensionBits:uint = ((hdr >> 4) & 0x00000003); // J
var copyrightBits:uint = ((hdr >> 3) & 0x00000001); // K
var originalBits:uint = ((hdr >> 2) & 0x00000001); // L
var emphasisBits:uint = ((hdr >> 0) & 0x00000003); // M
if (frameSyncBits != 0x7FF)
{
throw new Error("Invalid MP3 frame sync");
}
// XXX: add support for other mpeg versions/layer encodings
if (mpegVersionBits != 0x3 || mpegLayerBits != 0x1)
{
throw new Error("We currently don't support anything but MPEG1 layer 3'")
}
var bitRate:Array = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
var sampleRate:Array = [44100, 48000, 32000];
var frameSize:uint = 144 * bitRate[bitRateBits] * 1000 / sampleRate[sampleRateBits] + paddingBits;
/*
trace("mpegVersionBits: " + mpegVersionBits + ", " +
"mpegLayerBits: " + mpegLayerBits + ", " +
"bitRateBits: " + bitRateBits + ":" + bitRate[bitRateBits] + ", " +
"sampleRateBits: " + sampleRateBits +":" + sampleRate[sampleRateBits] + ", " +
"paddingBits: " + paddingBits + ", " +
"FrameSize: " + frameSize.toString(16));
*/
frameCount++;
if (frameCount == 1)
{
// if its the first frame, check to see if we have a LAME/Xing header to ignore
var firstFrame:ByteArray = new ByteArray();
bytes.readBytes(firstFrame, 0, frameSize - 4) // we've already read the hdr
if (readLAMEInfo(firstFrame))
{
// if we have LAME info we need to increase the delay by one frame's worth
delay += MP3File.MP3_SAMPLES_PER_FRAME;
}
break; // and then break as we're done
}
else
{
bytes.position += frameSize - 4; // size includes header
}
}
}
}
private function readLAMEInfo(bytes:ByteArray):Boolean
{
var retVal:Boolean = false;
bytes.position = 0;
bytes.endian == Endian.BIG_ENDIAN;
var sampleRateCode:uint = 0;
var i:uint = 0;
while (bytes.bytesAvailable > 0)
{
// look for Info/Xing header unaligned
var hdr:uint = bytes.readUnsignedByte();
if (hdr == (MP3File.INFO >> 24) || hdr == (MP3File.XING >> 24))
{
hdr = ( (hdr << 24)
| (bytes.readUnsignedByte() << 16)
| (bytes.readUnsignedByte() << 8)
| bytes.readUnsignedByte());
if (hdr == MP3File.INFO || hdr == MP3File.XING)
{
bytes.position += -4 + 120; // -4 we read; skip actual header (should say LAME)
if (bytes.readUnsignedInt() == MP3File.LAME)
{
/* We're going to do what's described here: http://www.hydrogenaudio.org/forums/index.php?s=467ec7f8fafc0ca9fbc60a3c3e9d7966&showtopic=69525&st=0&p=615515&#entry615515
* Also look here for info: http://gabriel.mp3-tech.org/mp3infotag.html#delays
* uint32_t tmp24bits =
* ( (uint32_t)*( p + 0x15 ) << 16 )
* | ( (uint32_t)*( p + 0x16 ) << 8 )
* | ( (uint32_t)*( p + 0x17 ) );
* Delay = (uint16_t)( tmp24bits >> 12L );
* Padding = (uint16_t)( tmp24bits & 0x0FFFL );
*/
bytes.position += -4 + 0x15; // -4 for the uint we just read
var tmp24bits:uint = bytes.readUnsignedByte() << 16
| bytes.readUnsignedByte() << 8
| bytes.readUnsignedByte();
delay = ((tmp24bits >> 12) & 0xFFFF);
padding = tmp24bits & 0x0FFF;
//trace("lame info - delay: " + delay + ", padding: " + padding);
retVal = true;
break;
}
}
}
}
return retVal;
}
}
@mountainstorm
Copy link
Author

Starling2D extension to allow playing of looped mp3's without the 'click' as it loops (due to the issue with mp3 frame sizes).

In short mp3's are terrible for looping. The way the codec works you end up with encoder and decoder delay at the start and potential padding at the end. When this plays you gets little gap when it loops :(

Thankfully there is a solution if you've used LAME to do the encoding.

To use this library you have four choices (in preference order):

  1. (Recommended) var rain:Sound = GaplessLoopedSound.createWithLAME(assets.getByteArray("rain"));
  2. var rain:Sound = new GaplessLoopedSound(assets.getSound("rain"), delay, padding)
  3. var rain:Sound = GaplessLoopedSound.createWithGuess(assets.getSound("rain"), originalSampleLength); // guesses
  4. var rain:Sound = new GaplessLoopedSound(assets.getSound("rain")); // uses some defaults which might work

@Oldes
Copy link

Oldes commented Jan 20, 2014

How do you dispose these sounds?

@kheftel-old
Copy link

Looks like it extends the AS3 Sound class, so you can treat it just like other sounds.

OMG, after tons of fruitless Googling and research, this class saved my bacon for a Starling-based AIR iOS game I'm making! Thanks so much for your work! As long as I encode with LAME, my MP3s should seamlessly loop!

@juliaplayrix
Copy link

Hi! Does this work with .ogg files, inserted into loaded .swf file? (I have a lot of .ogg sounds, embeded into one swf file by Adobe Flash CC)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment