1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Praatでカセットテープのデータエンコード形式「カンサスシティスタンダード」を実装してみた

Posted at

TL; DR

Praat で「カンサスシティスタンダード」のエンコーダー/デコーダーを実装

はじめに

かつてマイコンはプログラムをカセットテープに保存し、その音を鳴らすことでプログラムを読み込んでいた。

――であれば、私のソースコードからはどんな音が聴こえるのだろう?

気になったらいてもたってもいられなくなったので、実際にデータフォーマット「カンサスシティスタンダード」に沿ったエンコーダー/デコーダーを作ってみました。

本記事の実装は実際のマイコン上のアルゴリズムとは関係ありませんのであらかじめご了承ください。

(あくまでソースコードから音声へのエンコード・デコードを試すのが趣旨です)

作ったもの

ソースコードはこちらです。

実装はPraat script製です。
Praat scriptは、音声分析や編集を行うことができるソフトウェア「Praat」上で動作するプログラミング言語です1

ちなみにPraatの採用理由は単に手になじんでいるからです。汎用性を考えるならPython等で作ったほうが良いと思います

動作確認

まずはエンコーダーでソースコードを音声に変換します。手ごろなサイズの入力が欲しいので、FizzBuzzを使用しました。

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が出力されます。これで「音声からのプログラムの読み込み」を再現できました :tada:

$ 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の構文の説明は割愛しますのでご了承ください。
(テクニックについてはこちらの記事にまとめています)

エンコード

上記のエンコード形式をそのまま実装しています。

kcs.praat
# テキストデータ .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を使用しています。

kcs.praat
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 (正弦波の音を作成)を使用します。

kcs.praat
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 を使用します。

kcs.praat
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

デコード

続いてデコードです。音声からテキストデータを復元します。

kcs.praat
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)どちらに近いかで判定しています。

kcs.praat
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秒から最初のフレームが開始しますが、実際のカセットテープの場合はずれることが考えられます。

そこで、何ビット目からデータが始まっているかを推定し、その前の不要な部分を削除します。

実際のマイコンの動作は分かりませんが、ここでは以下のアルゴリズムでフレームの先頭ビットを推定しています。

  1. 先頭ビットがフレーム先頭の条件を満たすなら、フレーム先頭ビットの候補となる
  2. ビット列の長さが3フレーム未満であれば候補をそのままフレーム先頭ビットとする
  3. 3フレーム以上であれば、1フレーム先、2フレーム先のビットもフレーム先頭の条件を満たす場合フレーム先頭ビットとする
  4. そうでない場合、次のビットへ移る(以下繰り返し)

3. を入れたのは、偶然フレーム先頭の条件を満たしてしまうビットによってフレーム読み込み位置がずれてしまうのを防ぐためです。

kcs.praat
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

フレーム一覧を文字列に変換

最後に、フレーム一覧を文字列に変換します。こちらはエンコーダーの単なる逆変換なので割愛します。

おわりに

以上、カンサスシティスタンダードの実装の紹介でした。想像で作ったので実態に即していない箇所があるかもしれません(特にデコーダー)。

理論上はこれをカセットテープに焼けばマイコン上でプログラムが動く...はずですが、ここで力尽きたので読者のみなさまに託します。

  1. 音声と言っても正確には「声」の分析を対象にしたソフトウェアです。

  2. くわしくはこちら:「悪用厳禁?Praat Scriptの黒魔術、謎文法

  3. 周波数が人間の声の範囲よりも高かったのも一因かもしれません(To Pitch は声の基本周波数を分析するための機能です)。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?