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

# TL; DR

[Praat](https://www.fon.hum.uva.nl/praat/) で「カンサスシティスタンダード」のエンコーダー/デコーダーを実装

https://github.com/Syuparn/kcs-praat/tree/main

# はじめに

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

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

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

https://ja.wikipedia.org/wiki/%E3%82%AB%E3%83%B3%E3%82%B5%E3%82%B9%E3%82%B7%E3%83%86%E3%82%A3%E3%82%B9%E3%82%BF%E3%83%B3%E3%83%80%E3%83%BC%E3%83%89

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

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

# 作ったもの

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

https://github.com/Syuparn/kcs-praat/tree/main

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

https://www.fon.hum.uva.nl/praat/

https://www.fon.hum.uva.nl/praat/manual/Scripting.html

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

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

```praat: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
```

音声にエンコードします。

```bash
praat encode.praat fizzbuzz.praat
```

できた音声がこちらです（音量注意）。FAXのようなザリザリとした音がします。~~ずっと聴いていると耳が悪くなりそう~~

https://github.com/Syuparn/kcs-praat/blob/main/example/data/fizzbuzz.praat.wav

続いて、デコーダーで音声をテキストファイルに戻してみます。

```bash
praat decode.praat fizzbuzz.praat.wav
```

差分なく復元できました。

```bash
$ diff fizzbuzz.praat fizzbuzz.praat_decoded.txt
$
```

もちろん実行すれば想定通りfizzbuzzが出力されます。これで「音声からのプログラムの読み込み」を再現できました :tada:

```bash
$ praat fizzbuzz.praat_decoded.txt 
1
2
fizz
...
```

# エンコード形式

Wikipediaにある仕様説明にもとづいて実装しました。

https://ja.wikipedia.org/wiki/%E3%82%AB%E3%83%B3%E3%82%B5%E3%82%B9%E3%82%B7%E3%83%86%E3%82%A3%E3%82%B9%E3%82%BF%E3%83%B3%E3%83%80%E3%83%BC%E3%83%89

データは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の構文の説明は割愛しますのでご了承ください。
（テクニックについては[こちらの記事](https://qiita.com/Syuparn/items/f2e108f370836c4567cb)にまとめています）

## エンコード

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

```praat: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(関数)を差し替えるためのサービスロケータです。ユニットテストでは検証を簡単にするため、音声生成関数をモックに差し替えています。~~あまりお行儀のよい実装ではありません~~ [^variable]。

続いて各工程についてです。

### バイトをフレームのビット一覧に変換
フレームには長さ11のvectorを使用しています。

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

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

```praat: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
```

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

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

```praat: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と誤検知してしまったためです[^pitch]。

:::note info
名誉のために、これはPraatの精度が低いということでは**ありません**。ここまで触れてきませんでしたが、Praatは音声学のためのツールであり、（人間の）声を対象にしています。**1秒に数百回も周波数が切り替わる合成音は本来のターゲットではありません**。~~こんなことに使う方が悪い~~

また、「フォルマント」は発声の際のスペクトルのピークを表すもので、母音、子音の種類によって変化します。
:::

### フレームの開始位置を判定
抽出したビット列は、まだフレームとして使うには不十分です。というのも、データがどこから始まっているか分からないためです。

エンコーダーが吐いたwavファイルを直接読み込む場合は0秒から最初のフレームが開始しますが、実際のカセットテープの場合はずれることが考えられます。

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

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

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

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

```praat: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
```

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

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

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

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

[^praat]: 音声と言っても正確には「声」の分析を対象にしたソフトウェアです。
[^variable]: くわしくはこちら：「[悪用厳禁？Praat Scriptの黒魔術、謎文法](https://qiita.com/Syuparn/items/252e121ca7a20502a871#%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AE%E5%85%83%E5%87%B6%E5%A4%89%E6%95%B0%E7%BD%AE%E6%8F%9B)」
[^pitch]: 周波数が人間の声の範囲よりも高かったのも一因かもしれません（`To Pitch` は声の基本周波数を分析するための機能です）。