ゼロ・クロッシング

前回の続きです。
バッファの切れ目でノイズが発生するのはなぜでしょう?
バッファに波形データを書き込んだ際に、バッファの終端が「波形の途中」で終わってしまうことがあります。バッファ長が波長の整数倍であることは普通期待できませんから、「波形の途中」で終わってしまうことは回避できません。その状態から、次回のバッファで「最初から」波形を書き出してしまうと、波形が不連続になってしまいます。これがノイズの原因です。
さて、これを解決するには2通りのアプローチがあります。

  • 前回のバッファ終端での「位相」を記憶し、次のバッファではその位相から波形を書き始める
  • 前回のバッファ書き込みの「はみ出し分」を次のバッファ書き込みの先頭にくっつける

オシレータ系の信号処理であれば、どちらの方法を採用しても結果は同じになりますが、後者のほうが、位相を意識しないオーディオ信号のバッファリングにも適用できるので、こちらを実装しました。バッファ長を超えた地点から、信号が「マイナスから0を超える」地点(ゼロ・クロッシング)までを検索して切り出し、「はみ出し分」とします。

これのロジックをaudioStreamWriteメソッドに実装すると煩雑になるので、BufferSupportというクラスとしてくくり出し、再利用できるようにました。使い方は

  • グローバル変数としてBufferSupportを持つ
  • setup時に new BufferSupport(BUFFSIZE, BufferSupport.ZERO_CROSSING) する
  • audioStreamWriteでは直接streamに書き込まず、BufferSupportのworkBufferに書き込む
  • 最後にprepareBufferメソッドを呼ぶ

です。

このBufferSupportクラスを使えばその他のプログラムにも適用できます。自由にコピペしてくださいませ。
おまけで「クロスフェード」モードも用意しています。こちらは「はみ出し分」と「次のバッファ」を重ねて混ぜ合わせます。new BufferSupport(BUFFSIZE, BufferSupport.ZERO_CROSSING) の部分を new BufferSupport(BUFFSIZE, BufferSupport.CROSSFADE) に書き換えればOKです。ZERO_CROSSINGの音に納得いかない場合はこちらも試してみてください。

http://toshiyakobayashi.googlepages.com/LFOBufferSupport.html

import krister.Ess.*;

float SAMPLE_RATE = 44100;
long currentFrame = 0;
BufferSupport support;

AudioStream myStream; // Audio stream to write into
LFO lfo;
int offset = 0;
int BUFFSIZE = 10000;

void setup() {
  size(640, 480);  
  Ess.start(this); // Start Ess
  myStream = new AudioStream(BUFFSIZE); // Create a new AudioStream
  myStream.smoothPan = true;
  myStream.pan = -1.0f;
  lfo = new LFO(1f, 200);
  support = new BufferSupport(BUFFSIZE, BufferSupport.ZERO_CROSSING);
  myStream.start(); // Start audio
  noLoop();
}

void draw() {
}

void mouseMoved() {  
}

void audioStreamWrite(AudioStream s) {
  background(255);

  // バッファの切れ目の波形を描画
  int lx = 0, ly = height / 2;
  for (int i = 0; i < width / 2; i++) {
    int x, y;
    x = i;
    y = (int)((s.buffer[s.buffer.length - (width / 2) + i] * -1 + 1.0f) / 2 * height);
    line(lx, ly, x, y);
    lx = x;
    ly = y;
  }

  float last2 = s.buffer[s.buffer.length-2];
  float last1 = s.buffer[s.buffer.length-1];

  // process !!
  process(s);

  for (int i = 0; i < (width / 2); i++) {
    int x, y;
    x = i + (width / 2);
    y = (int)((s.buffer[i] * -1 + 1.0f) / 2 * height);
      line(lx, ly, x, y);
      lx = x;
      ly = y;
  }

  redraw();
}

void process(AudioStream s) {
  
  // mouseXで周波数、mouseYで振幅を操作する
  lfo.fq = (float)mouseX / width * 10;
  lfo.amp = (float)(height - mouseY) / height * 200;
  
  // 1波形ずつ生成
  // s.bufferではなく、support.workBufferに対して波形データを書き込む
  for (int offset = 0; offset < support.workBuffer.length; ) {
    float fq = lfo.getValue(currentFrame + offset) + 440;
    int genFrames = (int)((1 / fq) * SAMPLE_RATE);
    SineWave wave = new SineWave(fq, 1f);
    wave.generate(support.workBuffer, SAMPLE_RATE, offset, genFrames);
    offset += genFrames;
  }
  support.prepareBuffer(s.buffer);
  
  currentFrame += s.buffer.length;
}

class LFO {
  float fq;
  float amp;
  
  LFO(float fq, float amp) {
    this.fq = fq;
    this.amp = amp;
  }

  float getValue(long frame) {
    float phaseRad = getRadianByFrame(frame, fq);
    return (float)Math.sin(phaseRad) * amp;    
  }
  
  float getRadianByFrame(long frame, float fq) {
    int priodFrames = (int)((1F / fq) * SAMPLE_RATE); // 1周期のframe数
    int phaseFrame = (int)(frame % priodFrames); // 位相をframe数で
    return (float)((phaseFrame / (float)priodFrames) * 2 * Math.PI); // 位相をラジアンで
  }
}

class BufferSupport {

  static final int ZERO_CROSSING = 0;
  static final int CROSSFADE = 1;

  int mode;
  static final int DEFAULT_EXTRA_LENGTH = 2000;
  float[] extraBuffer; // 「はみだし分」を保持する
  float[] workBuffer; // このバッファに波形データを作成する

  int extraLength = DEFAULT_EXTRA_LENGTH;

  BufferSupport(int length, int mode) {
    this.mode = mode;
    extraBuffer = new float[extraLength];
    workBuffer = new float[length + extraLength];
  }

  BufferSupport(int length, int mode, int crossfadeLength) {
    this.mode = mode;
    this.extraLength = crossfadeLength;
    extraBuffer = new float[crossfadeLength];
    workBuffer = new float[length + crossfadeLength];
  }

  // 最後にこのメソッドを呼べば、streamBufferを適切に整えてくれます
  void prepareBuffer(float[] streamBuffer) {
    if (mode == ZERO_CROSSING) {
      int crossPoint = 0;
      for (int i = 1; i < extraLength; i++) {
        if (extraBuffer[i-1] < 0 && extraBuffer[i] >= 0) {
          crossPoint = i;
        }
      }
      System.arraycopy(extraBuffer, 0, streamBuffer, 0, crossPoint);
      System.arraycopy(workBuffer, 0, streamBuffer, crossPoint, streamBuffer.length - crossPoint);
      System.arraycopy(workBuffer, streamBuffer.length - crossPoint, extraBuffer, 0, extraLength);
    } else if (mode == CROSSFADE) {
      FadeOut fadeOut = new FadeOut(Ess.SLOW);
      fadeOut.filter(extraBuffer, SAMPLE_RATE);
      FadeIn fadeIn = new FadeIn(Ess.SLOW);
      fadeIn.filter(workBuffer, SAMPLE_RATE, 0, extraLength);
      for (int i = 0; i < extraLength; i++) {
        workBuffer[i] += extraBuffer[i];
      }
      System.arraycopy(workBuffer, 0, streamBuffer, 0, streamBuffer.length);
      System.arraycopy(workBuffer, streamBuffer.length, extraBuffer, 0, extraLength);
    }
  }
}

(※8/8 ちょっとcrossPointのところ変だったので修正)