TL; DR
Praat で「カンサスシティスタンダード」のエンコーダー/デコーダーを実装
はじめに
かつてマイコンはプログラムをカセットテープに保存し、その音を鳴らすことでプログラムを読み込んでいた。
――であれば、私のソースコードからはどんな音が聴こえるのだろう?
気になったらいてもたってもいられなくなったので、実際にデータフォーマット「カンサスシティスタンダード」に沿ったエンコーダー/デコーダーを作ってみました。
本記事の実装は実際のマイコン上のアルゴリズムとは関係ありませんのであらかじめご了承ください。
(あくまでソースコードから音声へのエンコード・デコードを試すのが趣旨です)
作ったもの
ソースコードはこちらです。
実装はPraat script製です。
Praat scriptは、音声分析や編集を行うことができるソフトウェア「Praat」上で動作するプログラミング言語です1。
ちなみにPraatの採用理由は単に手になじんでいるからです。汎用性を考えるならPython等で作ったほうが良いと思います
動作確認
まずはエンコーダーでソースコードを音声に変換します。手ごろなサイズの入力が欲しいので、FizzBuzzを使用しました。
clearinfo
for i from 1 to 30
if i mod 15 == 0
appendInfoLine("fizzbuzz")
elsif i mod 3 == 0
appendInfoLine("fizz")
elsif i mod 5 == 0
appendInfoLine("buzz")
else
appendInfoLine(i)
endif
endfor
音声にエンコードします。
praat encode.praat fizzbuzz.praat
できた音声がこちらです(音量注意)。FAXのようなザリザリとした音がします。ずっと聴いていると耳が悪くなりそう
続いて、デコーダーで音声をテキストファイルに戻してみます。
praat decode.praat fizzbuzz.praat.wav
差分なく復元できました。
$ diff fizzbuzz.praat fizzbuzz.praat_decoded.txt
$
もちろん実行すれば想定通りfizzbuzzが出力されます。これで「音声からのプログラムの読み込み」を再現できました
$ praat fizzbuzz.praat_decoded.txt
1
2
fizz
...
エンコード形式
Wikipediaにある仕様説明にもとづいて実装しました。
データは8ビットごとに「フレーム」という形式で以下のように変換されます。
- 先頭に
0
1つ - データ(最下位ビットから順に)
- 末尾に
1
2つ
# 元データ
01100001
01100010
# フレーム
01000011011
00100011011
01100001 01100010
↓
| 0 | 10000110 | 11 | 0 | 01000110 | 11 |
そして、フレームの各ビットは
-
0
: 1200Hz -
1
: 2400Hz
の正弦波に変換されます。各ビットに対応する音を順番に鳴らすことでエンコードされます。
1ビットの音の長さは 1/300秒(=300ボー)です。
実装
以下のソースコード例ではPraat scriptの構文の説明は割愛しますのでご了承ください。
(テクニックについてはこちらの記事にまとめています)
エンコード
上記のエンコード形式をそのまま実装しています。
# テキストデータ .data$ を音声にエンコード
# パラメータ: .freqHi = 2400 [Hz], .freqLo = 1200 [Hz], .baud = 300 [/s]
procedure encodeKCS(.data$, .freqHi, .freqLo, .baud)
.soundObjects# = zero#(length(.data$) * kcsFrameSize)
# テキストデータを1バイトごとに分割
for .i from 1 to length(.data$)
# .data$の i文字目 (iバイト目)
# ※Praat scriptのindexは1オリジン
.char$ = right$(left$(.data$, .i), 1)
# バイトをフレームのビット一覧に変換
@toKCSFrame(unicode(.char$))
.frame# = toKCSFrame.return#
# フレームの各ビットに対応する音声を生成
for .j from 1 to kcsFrameSize
.bit = .frame#[.j]
.bitIndex = (.i - 1) * kcsFrameSize + .j
@'locator$["genSound"]'(.freqHi, .freqLo, .baud, .bit, .bitIndex)
.soundObj = 'locator$["genSound"]'.return
.soundObjects#[.bitIndex] = .soundObj
endfor
endfor
# 生成した音声を結合
@'locator$["concatSound"]'(.soundObjects#)
.return = 'locator$["concatSound"]'.return
endproc
@'locator$["genSound"]'
と怪しげなことをしていますが、こちらはprocedure(関数)を差し替えるためのサービスロケータです。ユニットテストでは検証を簡単にするため、音声生成関数をモックに差し替えています。あまりお行儀のよい実装ではありません 2。
続いて各工程についてです。
バイトをフレームのビット一覧に変換
フレームには長さ11のvectorを使用しています。
kcsFrameSize = 11
procedure toKCSFrame(byte)
.frame# = zero#(kcsFrameSize)
# データフォーマット
# [1]: 0
# [2]~[9]: bits (逆順)
# [10], [11]: 1
mask = 1
for .i from 2 to 9
# 最下位ビットから順に抽出
.frame#[.i] = floor(byte / mask) mod 2
mask *= 2
endfor
.frame#[10] = 1
.frame#[11] = 1
.return# = .frame#
endproc
フレームの各ビットに対応する音声を生成
続いて、フレーム形式に変換された各ビットを音声にエンコードします。音声の生成には、Praatの組み込み機能 Create Sound as pure tone
(正弦波の音を作成)を使用します。
procedure genKCSSound(.freqHi, .freqLo, .baud, .bit, .bitIndex)
if .bit == 1
# 2400Hz
.freq = .freqHi
else
# 1200Hz
.freq = .freqLo
endif
# 音声の長さは 1 / ボー [s]
.period = 1 / .baud
.tStart = (.bitIndex - 1) * .period
.tEnd = .bitIndex * .period
# 音声を生成
# name, channels(mono), tstart, tend, sampling[Hz], freq[Hz], amp[Pa], fade-in[s], fade-out[s]
.sound = do("Create Sound as pure tone...", "tone", 1, .tStart, .tEnd, 44100, .freq, 0.2, 1e-14, 1e-14)
.return = .sound
endproc
生成した音声を結合
最後に、各ビットの音声を順々に結合し1つの音声にします。こちらもPraatの組み込み機能 Concatenate
を使用します。
procedure concatKCSSound(.soundObjects#)
# 関係ない音声が入らないよう選択解除
selectObject()
for .i from 1 to size(.soundObjects#)
plusObject(.soundObjects#[.i])
endfor
# 選択された音声を1つに結合
.concatenated = do("Concatenate")
# 各ビットの音声は不要になったので削除
for .i from 1 to size(.soundObjects#)
removeObject(.soundObjects#[.i])
endfor
.return = .concatenated
endproc
デコード
続いてデコードです。音声からテキストデータを復元します。
procedure decodeKCS(.soundObj, .freqHi, .freqLo, .baud)
# ビット列を抽出
@extractBits(.soundObj, .freqHi, .freqLo, .baud)
.bits# = extractBits.return#
# フレームの開始位置を判定
@trimFrames(.bits#)
.bits# = trimFrames.bits#
# フレーム一覧を文字列に変換
@framesToString(.bits#)
.return$ = framesToString.return$
endproc
ビット列を抽出
音声からビット列を抽出します。ビットの周期ごとに周波数をサンプリングし、Hi (2400Hz)、 Lo(1200Hz)どちらに近いかで判定しています。
procedure extractBits(.soundObj, .freqHi, .freqLo, .baud)
.duration = do("Get total duration")
.period = 1 / .baud
.nBits = ceiling(.duration / .period)
.bits# = zero#(.nBits)
selectObject(.soundObj)
# step, nFormants, ceilingHz(default), windowLength, pre-emphasis[Hz](default)
do("To Formant (burg)...", .period, 1, 5000, .period / 3, 50)
# 1か0かを識別する閾値
.threshold = (.freqHi + .freqLo) / 2
for .i from 1 to .nBits
.t = (.i - 0.5) * .period
# フォルマントを抽出
# index(nth formant), time, unit(default), interpolation(default)
.formant = do("Get value at time...", 1, .t, "hertz", "linear")
if .formant > .threshold
.bit = 1
else
.bit = 0
endif
.bits#[.i] = .bit
endfor
.return# = .bits#
endproc
ハックとして、周波数の抽出には To Pitch
(周波数抽出) ではなく To Formant (burg)
(フォルマント抽出)を使用しています。
To Pitch
を使用しなかった理由は、Hi (2400Hz)と Lo(1200Hz)がオクターブの関係なので倍音とみなされすべてLoと誤検知してしまったためです3。
名誉のために、これはPraatの精度が低いということではありません。ここまで触れてきませんでしたが、Praatは音声学のためのツールであり、(人間の)声を対象にしています。1秒に数百回も周波数が切り替わる合成音は本来のターゲットではありません。こんなことに使う方が悪い
また、「フォルマント」は発声の際のスペクトルのピークを表すもので、母音、子音の種類によって変化します。
フレームの開始位置を判定
抽出したビット列は、まだフレームとして使うには不十分です。というのも、データがどこから始まっているか分からないためです。
エンコーダーが吐いたwavファイルを直接読み込む場合は0秒から最初のフレームが開始しますが、実際のカセットテープの場合はずれることが考えられます。
そこで、何ビット目からデータが始まっているかを推定し、その前の不要な部分を削除します。
実際のマイコンの動作は分かりませんが、ここでは以下のアルゴリズムでフレームの先頭ビットを推定しています。
- 先頭ビットがフレーム先頭の条件を満たすなら、フレーム先頭ビットの候補となる
- ビット列の長さが3フレーム未満であれば候補をそのままフレーム先頭ビットとする
- 3フレーム以上であれば、1フレーム先、2フレーム先のビットもフレーム先頭の条件を満たす場合フレーム先頭ビットとする
- そうでない場合、次のビットへ移る(以下繰り返し)
3.
を入れたのは、偶然フレーム先頭の条件を満たしてしまうビットによってフレーム読み込み位置がずれてしまうのを防ぐためです。
procedure detectFrameStart(.bits#)
for .i from 1 to size(.bits#)
@canBeFrameStartBit(.bits#, .i)
.isStart = canBeFrameStartBit.return
if .isStart
if .i + kcsFrameSize * 2 > size(.bits#)
.return = .i
'endproc$'
endif
# 誤検知を防ぐため、1フレーム、2フレーム先も条件を満たすか確認
@canBeFrameStartBit(.bits#, .i + kcsFrameSize)
.isStart2 = canBeFrameStartBit.return
@canBeFrameStartBit(.bits#, .i + kcsFrameSize * 2)
.isStart3 = canBeFrameStartBit.return
if .isStart2 && .isStart3
.return = .i
'endproc$'
endif
endif
endfor
# not found
.return = size(.bits#) + 1
endproc
procedure canBeFrameStartBit(.bits#, .i)
.return = 0 ; false
# out of range
if .i + kcsFrameSize - 1 > size(.bits#)
'endproc$'
endif
# フレームの条件 frame#[1] = 0, frame#[10] = 1, frame#[11] = 1 を満たさない
if .bits#[.i] != 0 || .bits#[.i+9] != 1 || .bits#[.i+10] != 1
'endproc$'
endif
.return = 1 ; true
endproc
フレーム一覧を文字列に変換
最後に、フレーム一覧を文字列に変換します。こちらはエンコーダーの単なる逆変換なので割愛します。
おわりに
以上、カンサスシティスタンダードの実装の紹介でした。想像で作ったので実態に即していない箇所があるかもしれません(特にデコーダー)。
理論上はこれをカセットテープに焼けばマイコン上でプログラムが動く...はずですが、ここで力尽きたので読者のみなさまに託します。
-
音声と言っても正確には「声」の分析を対象にしたソフトウェアです。 ↩
-
くわしくはこちら:「悪用厳禁?Praat Scriptの黒魔術、謎文法」 ↩
-
周波数が人間の声の範囲よりも高かったのも一因かもしれません(
To Pitch
は声の基本周波数を分析するための機能です)。 ↩