簡易波形表示
自作のアプリケーションに波形表示機能をつけたくていろいろ探ったあげく、何となくそれらしき物ができたのでメモ。
基本的には、ここで紹介されているスクリプトを 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