Last active
December 7, 2020 09:34
-
-
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).
This file contains hidden or 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
/* | |
* 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; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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)