簡易波形表示

自作のアプリケーションに波形表示機能をつけたくていろいろ探ったあげく、何となくそれらしき物ができたのでメモ。

基本的には、ここで紹介されているスクリプトを RubyMotion で動くようにしてみた。

StackOverflow - Drawing waveform with AVAssetReader

http://stackoverflow.com/questions/5032775/drawing-waveform-with-avassetreader

かなり強引に、とりあえず動くようにしただけなので、もっとちゃんとできるはずだけど、とりあえず動く時点でメモをしてみる。

部分ごとに簡単に説明っぽいことを描くことを試みるけど、実際にはよくわかっていない部分が多いので、適当に。

あと、今の時点でわかっている問題に、最初の一秒未満のところで、圧縮音源だとデータをが抜けているところがある。原因はわからないので、対処していない。一番最初から音が入っていると、ギャップがあるのが見える(音は聞こえる)。

ちゃんと動けば、こんな感じのが出るはずです。

波形を描くためのデータを取り出す部分。

def mainProcess(sender)

# メディアファイルの URL から波形データを取り出すための AVAsset オブジェクトを作り出す。

asset = AVAsset.assetWithURL(url)

# AVAsset オブジェクトから、オーディオトラックを取り出す。[0]、つまり、一番 ID の小さいトラックが取り出されるので、複数ある場合は、[0] を変えればいい。

audioTrack = asset.tracksWithMediaType(AVMediaTypeAudio)[0]

# 情報を取り出すために AVAssetReader オブジェクトを作っているが、細かいことはよくわかっていない。できるのは、AVAssetTrack オブジェクト。

assetReader = AVAssetReader.assetReaderWithAsset(asset,error:nil)

# WAV や AIFF だけでなく、MP4 (AAC) や MP3 などからでも、PCM に変換してデータを取り出すための設定。ここはこのままで。

outputSettings = {AVFormatIDKey => KAudioFormatLinearPCM,

AVLinearPCMBitDepthKey => 16,

AVLinearPCMIsBigEndianKey => false,

AVLinearPCMIsFloatKey => false,

AVLinearPCMIsNonInterleaved => false}

# AVAssetReaderTrackOutput オブジェクトを作り、AVAssetReader オブジェクトに output として追加。

output = AVAssetReaderTrackOutput.alloc.initWithTrack(audioTrack,outputSettings:outputSettings)

assetReader.addOutput(output)

# AVAssetTrack オブジェクトから、サンプルレートとチャンネル数の情報を取り出す。

# 他にも情報は取り出せるが、必要なのはこの 2 つだけなので、ここではそれだけを取り出す。

formatDesc = audioTrack.formatDescriptions

sampleRate = 0

channelCount = 0

formatDesc.each do |item|

fmtDesc = CMAudioFormatDescriptionGetStreamBasicDescription(item)

if fmtDesc

sampleRate = fmtDesc.value.mSampleRate

channelCount = fmtDesc.value.mChannelsPerFrame

end

end

# 16 ビットデータを取り出しているので、各チャンネル 2 バイトごとで一つのサンプル(音情報?)になるので、それを bytesPerSample としておく。

bytesPerSample = 2 * channelCount

# normalizeMax は、ここでの波形簡易表示では、最大値を元にして描画するので、最大値を記録するための物。

normalizeMax = 0

# ここで、データを読み始める。

assetReader.startReading

# データは、すべて扱うと膨大な量になるので、ある一定サンプルごとの平均値を取るようになっている。

# それをどれくらいにするかというのは、samplePerPixel のところの 100 という数字で、44,100Hz の場合は 441 サンプルの平均値ごとにデータを描く。

# この数値を大きくすると、細かく描画できる(より正確になる)が、このスクリプトだとデータ量が多くなりすぎて処理に時間がかかる。

totalLeft = 0

totalRight = 0

sampleTally = 0

samplesPerPixel = sampleRate / 100

# @valAry という、データをためていく配列を用意する。元のスクリプトでは、C(?)のバイトデータのまま扱うが、Ruby では、16 bit integer は扱いづらいのと、その辺りのデータの取り扱いがよくわかっていないので、現状では配列を選択した。

@valAry = Array.new

# ここから、データが読み込めている間 while でループしてデータを取り出す。

while assetReader.status == AVAssetReaderStatusReading

# AVAssetReader オブジェクトの output からバッファにデータをコピーしている(らしい)。

trackOutput = assetReader.outputs.objectAtIndex(0)

sampleBufferRef = trackOutput.copyNextSampleBuffer

# データがあれば、その処理をする。

if sampleBufferRef

# データを取り出してデータの大きさの情報を得ている。

blockBufferRef = CMSampleBufferGetDataBuffer(sampleBufferRef)

length = CMBlockBufferGetDataLength(blockBufferRef)

# オブジェクトの管理なんかは自動のはずだが、iOS でも大丈夫な設計のためか、

# RubyMotion では、いろいろあるらしく、autorelease_pool でその都度いらないオブジェクトを破棄しながら作業をしていく。

# このおかげで、小さいファイルでバカみたいにメモリを食うこともないはず。

autorelease_pool {

# 上で得たデータ長分の NSMutableData オブジェクトを用意して、データを取り出している。

data = NSMutableData.dataWithLength(length)

CMBlockBufferCopyDataBytes(blockBufferRef, 0, length, data.mutableBytes)

samples = data.mutableBytes

sampleCount = length / bytesPerSample

# 取り出したサンプルデータを 2 x チャンネル数で割った物がサンプル数になるので、その分だけループでまわしている。

i = 0

sampleCount.times do

# 上の所にあるが、データは 16bit int little endian で取り出されるので、取り出した 2 bit 分のデータは、下位・上位の順で並んでいるので、2 bit 目の分に 256 をかけて integer にしている。

# さらに、データは -32768 ~ 32767 らしいので、-32768 としている。

# これを、後で記述するメソッドでデシベルに変換し、平均を出すために合計に加えていく。

# データは、2 bit 分読み込んだので、その分 i を進めておく。

left = samples[i] + samples[i+1] * 256 - 32768

left = decibel(left)

totalLeft += left

i += 2

# ステレオの場合は、left に続いて right のデータがあるので、同じく処理をする。

if channelCount == 2

right = samples[i] + samples[i+1] * 256 - 32768

right = decibel(right)

totalRight += right

i += 2

end

# ここで、サンプル一つ読み込んだという記録をする。

sampleTally +=1

# サンプルが決められた数を超えると、一つのデータとして扱うために、ここまでの合計を平均する。

if sampleTally > samplesPerPixel

leftVal = totalLeft / sampleTally

# ここで、最大値の更新も行う。最大値は絶対値で扱う。

fix = leftVal.abs

if fix > normalizeMax

normalizeMax = fix

end

# ステレオの場合は right チャンネルも処理する。

if channelCount == 2

rightVal = totalRight / sampleTally

fix = rightVal.abs

if fix > normalizeMax

normalizeMax = fix

end

# 平均値を出した物を @valAry に入れていく。モノラルの場合は当然 left だけだが、後での処理のために配列に入れる。

@valAry << [leftVal,rightVal]

else

@valAry << [leftVal]

end

# また、次の一定サンプルの平均を取るために値を初期化する。

totalLeft = 0

totalRight = 0

sampleTally = 0

end

end

}

end

end

# おまじない。問題が起きたら止める。

if (assetReader.status == AVAssetReaderStatusFailed || assetReader.status == AVAssetReaderStatusUnknown)

return nil

end

# データを読み終わったら、描画処理に移る。ここでは、横幅はデータの量、縦は 200 としている。

if (assetReader.status == AVAssetReaderStatusCompleted)

self.drawImage(@valAry.length,200.0,normalizeMax,channelCount)

end

end

取り出したデータを描く部分。

def drawImage(displayWidth,imageHeight,normalizeMax,channelCount)

# 描画には NSImage の imageWithSize:flipped:drawingHandler を使っている。

# NSImageView, NSView などをサブクラスにして描画してもいいが、データが多いと、描画に時間がかかるので、サンプルの通り、PNG に書き出して使うようにしている。

image = NSImage.imageWithSize([displayWidth, imageHeight],flipped:true,drawingHandler:Proc.new { |drawingHandler|

# まずは、表示域の設定。

# CGSize ではなく、NSSize でもいいはずだが、以前に NSSize.new でエラーが出たので、無難に CGSize.new を使っている。

imageSize = CGSize.new(displayWidth, imageHeight)

bgcolor = NSColor.blackColor

leftcolor = NSColor.whiteColor

rightcolor = NSColor.redColor

centercolor = NSColor.blueColor.set

# 背景色 (bgcolor) の指定。

bgcolor.set

# 表示域全体をここでは黒で塗りつぶしている。塗りつぶしは NSRectFill(rect)。

rect = CGRect.new

rect.size = imageSize

rect.origin.x = 0

rect.origin.y = 0

NSRectFill(rect)

# 次に、線の太さの指定。ここでの描画は、実際の波形ではなく、データの棒グラフを並べた形になっている。

# しかも、中心線の上下対象になっていて、正確な音量の表示ではない(これは元のスクリプトがそうなっているから)。

NSBezierPath.setDefaultLineWidth(1.0)

# グラフの半分の高さを計算して、それぞれのチャンネルの部分の位置を割り出している。

halfGraphHeight = (imageHeight.to_f / 2) / channelCount

centerLeft = halfGraphHeight

centerRight = (halfGraphHeight*3)

# 波形というか棒グラフの高さを、最大値を基準に調整するための値。

sampleAdjustmentFactor = (imageHeight.to_f/ channelCount) / normalizeMax

# 配列に入れたデータをブロックで処理。

@valAry.each_with_index do |intSample,idx|

left = intSample[0]

# データの値に調整値をかけて長さを決めている。

leftPixels = left * sampleAdjustmentFactor.to_f / 2

# 左の波形(棒グラフ)の色の設定。

leftcolor.set

# NSBezierPath の strokeLineFromPoint:toPoint: で起点と終点を指定して線を描いている。

NSBezierPath.strokeLineFromPoint([idx, centerLeft-leftPixels],toPoint:[idx, centerLeft+leftPixels])

# 当然ステレオなら right チャンネルも処理。

if channelCount == 2

right = intSample[1]

rightPixels = right * sampleAdjustmentFactor.to_f / 2

rightcolor.set

NSBezierPath.strokeLineFromPoint([idx, centerRight-rightPixels],toPoint:[idx, centerRight+rightPixels])

end

end

# 元スクリプトにはないが、中心に線を入れたかったので入れてみた。

centercolor.set

NSBezierPath.strokeLineFromPoint([0, centerLeft],toPoint:[displayWidth, centerLeft])

NSBezierPath.strokeLineFromPoint([0, centerRight],toPoint:[displayWidth, centerRight]) if channelCount == 2

})

# ここでは、NSImageView オブジェクトを作り、それに描いた波形を割当て、NSScrollView で表示している。これで、横長でもスクロールして表示できる。

# もし、音声ファイル全体を画面に収まるように調整するなら、そんな必要はないかも。

imageView = NSImageView.alloc.initWithFrame([[0,0],[displayWidth, imageHeight]])

# ここでは、NSImage オブジェクトから、NSBitmapImageRep オブジェクトを作り、それから、PNG データを作っている。

# ここでできた imageData を writeToFile:atomically: で保存すれば、PNG ファイルとして保存できて、プレビューなどでも開ける。

bitmapRep = NSBitmapImageRep.imageRepWithData(image.TIFFRepresentation)

imageData = bitmapRep.representationUsingType(NSPNGFileType,properties:nil)

# このスクリプトでは、imageData を NSImage オブジェクトにして、それを表示している。

# NSImage のままだと、大きくなる(横に長くなる)と表示がもたつくようになるので、PNG にして読み込むことでそれを回避している。

waveImage = NSImage.alloc.initWithData(imageData)

# NSImageView に設定して、それを NSScrollView の document にして表示している。

imageView.setImage(waveImage)

@scrollView.setDocumentView(imageView)

end

おまけ(ないと動かないけど)。

# ここでは、元データを見やすくするためにデシベル(音量)に変換しているので、その変換部分のスクリプト。

# Math の log を使っている。

def decibel(amp)

if amp == 0

return 0

else

return 20.0 * Math.log(amp.abs/32767.0,10)

end

end

最後に、スクリプトだけ。

def mainProcess(sender)

asset = AVAsset.assetWithURL(url)

audioTrack = asset.tracksWithMediaType(AVMediaTypeAudio)[0]

assetReader = AVAssetReader.assetReaderWithAsset(asset,error:nil)

outputSettings = {AVFormatIDKey => KAudioFormatLinearPCM,

AVLinearPCMBitDepthKey => 16,

AVLinearPCMIsBigEndianKey => false,

AVLinearPCMIsFloatKey => false,

AVLinearPCMIsNonInterleaved => false}

output = AVAssetReaderTrackOutput.alloc.initWithTrack(audioTrack,outputSettings:outputSettings)

assetReader.addOutput(output)

formatDesc = audioTrack.formatDescriptions

sampleRate = 0

channelCount = 0

formatDesc.each do |item|

fmtDesc = CMAudioFormatDescriptionGetStreamBasicDescription(item)

if fmtDesc

sampleRate = fmtDesc.value.mSampleRate

channelCount = fmtDesc.value.mChannelsPerFrame

end

end

bytesPerSample = 2 * channelCount

normalizeMax = 0

assetReader.startReading

totalLeft = 0

totalRight = 0

sampleTally = 0

samplesPerPixel = sampleRate / 100

@valAry = Array.new

while assetReader.status == AVAssetReaderStatusReading

trackOutput = assetReader.outputs.objectAtIndex(0)

sampleBufferRef = trackOutput.copyNextSampleBuffer

if sampleBufferRef

blockBufferRef = CMSampleBufferGetDataBuffer(sampleBufferRef)

length = CMBlockBufferGetDataLength(blockBufferRef)

autorelease_pool {

data = NSMutableData.dataWithLength(length)

CMBlockBufferCopyDataBytes(blockBufferRef, 0, length, data.mutableBytes)

samples = data.mutableBytes

sampleCount = length / bytesPerSample

i = 0

sampleCount.times do

left = samples[i] + samples[i+1] * 256 - 32768

left = decibel(left)

totalLeft += left

i += 2

if channelCount == 2

right = samples[i] + samples[i+1] * 256 - 32768

right = decibel(right)

totalRight += right

i += 2

end

sampleTally +=1

if sampleTally > samplesPerPixel

leftVal = totalLeft / sampleTally

fix = leftVal.abs

if fix > normalizeMax

normalizeMax = fix

end

if channelCount == 2

rightVal = totalRight / sampleTally

fix = rightVal.abs

if fix > normalizeMax

normalizeMax = fix

end

@valAry << [leftVal,rightVal]

else

@valAry << [leftVal]

end

totalLeft = 0

totalRight = 0

sampleTally = 0

end

end

}

end

end

if (assetReader.status == AVAssetReaderStatusFailed || assetReader.status == AVAssetReaderStatusUnknown)

return nil

end

if (assetReader.status == AVAssetReaderStatusCompleted)

self.drawImage(@valAry.length,200.0,normalizeMax,channelCount)

end

end

def drawImage(displayWidth,imageHeight,normalizeMax,channelCount)

image = NSImage.imageWithSize([displayWidth, imageHeight],flipped:true,drawingHandler:Proc.new { |drawingHandler|

imageSize = CGSize.new(displayWidth, imageHeight)

bgcolor = NSColor.blackColor

leftcolor = NSColor.whiteColor

rightcolor = NSColor.redColor

centercolor = NSColor.blueColor.set

bgcolor.set

rect = CGRect.new

rect.size = imageSize

rect.origin.x = 0

rect.origin.y = 0

NSRectFill(rect)

NSBezierPath.setDefaultLineWidth(1.0)

halfGraphHeight = (imageHeight.to_f / 2) / channelCount

centerLeft = halfGraphHeight

centerRight = (halfGraphHeight*3)

sampleAdjustmentFactor = (imageHeight.to_f/ channelCount) / normalizeMax

@valAry.each_with_index do |intSample,idx|

left = intSample[0]

leftPixels = left * sampleAdjustmentFactor.to_f / 2

leftcolor.set

NSBezierPath.strokeLineFromPoint([idx, centerLeft-leftPixels],toPoint:[idx, centerLeft+leftPixels])

if channelCount == 2

right = intSample[1]

rightPixels = right * sampleAdjustmentFactor.to_f / 2

rightcolor.set

NSBezierPath.strokeLineFromPoint([idx, centerRight-rightPixels],toPoint:[idx, centerRight+rightPixels])

end

end

centercolor.set

NSBezierPath.strokeLineFromPoint([0, centerLeft],toPoint:[displayWidth, centerLeft])

NSBezierPath.strokeLineFromPoint([0, centerRight],toPoint:[displayWidth, centerRight]) if channelCount == 2

})

imageView = NSImageView.alloc.initWithFrame([[0,0],[displayWidth, imageHeight]])

bitmapRep = NSBitmapImageRep.imageRepWithData(image.TIFFRepresentation)

imageData = bitmapRep.representationUsingType(NSPNGFileType,properties:nil)

waveImage = NSImage.alloc.initWithData(imageData)

imageView.setImage(waveImage)

@scrollView.setDocumentView(imageView)

end

def decibel(amp)

if amp == 0

return 0

else

return 20.0 * Math.log(amp.abs/32767.0,10)

end

end