チャーハンノート

チャーハンの作り方に関する覚書

スペクトログラムに文字列を表示する

f:id:friedrice_mushroom:20220108163155p:plain:w600
iphoneのスペクトログラム用アプリで”青椒肉絲”を表示した様子。

目次

スペクトログラムとは

スペクトログラムは、音声などの信号情報を可視化する手法。
縦軸を周波数、横軸を時間とする座標上に、信号成分の強さを色で表現する。

高額な機材は必要なく、スマホ用アプリなんかもある。iPhone用だとSpectrogram Proというアプリ(有料)を日常で多用している。

Spectrogram Pro (with super-smooth 60Hz update)

Spectrogram Pro (with super-smooth 60Hz update)

  • Dominik Seibold
  • ミュージック
  • ¥370
apps.apple.com

口笛で小刻みに半音ずつ上げたり下げたりを繰り返すと、iPhoneにはキレイなジグザク模様が描かれる。 そんな様を眺めては悦に入っている。

f:id:friedrice_mushroom:20220108163448p:plain

スペクトログラムを使った例で好きなのは、電車のインバータ音(磁励音)を表示したもの。

youtu.be

この動画では一般的に「ノイズ」と言われがちな電車の磁励音を、特殊なアンプを使用することで高音質に、スペクトログラムとともに提供されている。
さらにはインバータ音の再現版も作成されており、本物の磁励音と再現音とを視覚的に比較できる。
鉄道に興味がなかった自分でも「こんな法則で音がでているのか」と驚くとともに、聴いているとなんだか癒やされてきて、次第に鉄道(というかモーター)にも興味が湧くようになった。

話を戻すと、
音声を画像で表現できるということは、画像を音声に変換することも可能ということになる。
同じことを考える人は多数おり、ググれば無数に情報が上がっている。
今回は自分がほしいと思う用途に特化して、Pythonで試作することにした。

作りたいもの

step1 文字列を入力
step2 スペクトログラムで見ることができる音声に変換
step3 発音

この3ステップをスムーズに実行できる環境を構築したい。
これが何の役に立つのかはわからない。
とにかく色んな文字列を音声に変換してはスペクトログラムで表示し、悦に入ってみたかった。

Pythonista 3を使う

今回はPythonista 3 (以下、Pythonista)というiPhoneアプリ(有料)を使用した。
iPhoneは11 Proを使用したが、アプリが動けば機種はとくに問わないはず。

Pythonista 3

Pythonista 3

  • omz:software
  • 仕事効率化
  • ¥1,220
apps.apple.com

PythonistaはiPhoneでPython3を扱えるという奇跡のアプリ。Android版もあるらしい。

奇跡と言っておきながら、3年ほど前にダウンロードしてから今まで放置していた。
放置した理由は、タッチパネルとキーボードを行ったり来たりの操作に嫌気が差したから。

そんなPythonistaを再開したきっかけは、M1 MacBook Air を使い始めたこと。
ふとMacApp StoreをみるとiPad用のPythonistaがダウンロードできたのだった。

f:id:friedrice_mushroom:20220108163942p:plain:w400

MacBookのM1もiPhoneのA13も同じARMアーキテクチャなので、原理的には同様に使うことができるのだろう。
普段はMacで制作(コピー&ペースト)し、あるていど形になったところでiCloud経由でiPhoneと同期してプレビューするというフローが確立できた。
そうして、少しずつPythonistaの扱い方に慣れることができた。

さらに、PythonistaにはGUIのアプリを作る機能もある。Xcodeを使ったガチアプリ作成に挫折した自分にとっては、この機能は渡りに船だった。

Pythonistaで日本語フォントを扱う準備

今回のプログラムでは、任意の文字列を画像に変換するという工程がある。
そこで使用したのは、PillowライブラリのImageFontというモジュール。

pillow.readthedocs.io

ちなみに、ダウンロードしたフォントをPythonistaで扱うにはひと手間かかる。
自分はここで少し躓いた。
今回は一番手軽に扱えるであろう方法について紹介する。

フォントのダウンロード

今回作成するプログラムでは日本語の等幅フォントを使用したかった。
等幅フォントとは、文字の間隔が一定のフォントのこと。
今回はIPAゴシックの等幅フォント(無償)を使用することにした。

moji.or.jp

リンク先からIPAゴシック(Ver.※※※)を見つけ、zipファイルをダウンロードする。

f:id:friedrice_mushroom:20220108164206p:plain:w400

iPhoneでダウンロードした場合は、”ファイル”アプリからダウンロードフォルダを開く。
(Chromeだと”ダンロードフォルダを開く”というメニューがある).

f:id:friedrice_mushroom:20220108164312p:plain:w400

zipファイルをタップするとたちまち解凍フォルダが作成される。
それを開くと”ipag.ttf”というファイルがある。これがフォントの本体。

f:id:friedrice_mushroom:20220108164338p:plain:w400

フォントファイルをPythonistaのディレクトリに移動する

フォントファイルはPythonistaが扱えるディレクトリに移動する必要がある。
やり方はいくつかあるが、今回はiCloudを介さない方法を示す。

Pythonistaのメニューから”EXTERNAL FILES”の”Open…”をタップする。

f:id:friedrice_mushroom:20220108164408p:plain:w400

ファイルかフォルダを開くかを問われるので、フォルダを選択する。

f:id:friedrice_mushroom:20220108164421p:plain:w400

フォントファイルを解凍したフォルダへ移動し、フォルダを選択して右上の完了ボタンをタップ。
すると、Open...の下にさきほど選択したフォルダが追加される。

f:id:friedrice_mushroom:20220108164440p:plain:w400

その中にフォントファイル(ipag.ttf)があるので、Editモードでファイルを移動する。 移動先は今回作成するプログラムと同じフォルダ。(そうしないと動かない)

作成したプログラム

今回はGUI化は置いといて、コマンドライン上に入力した文字列をスペクトログラムで可視化するWAVファイルとして生成するプログラムを作成する。

コードはこちら。 適当な名前で.pyファイルを作成し、コードをコピペして保存と実行したら行けるはず。

from PIL import Image, ImageFont, ImageDraw, ImageOps
import unicodedata
import numpy as np
import wave
import struct

import numpy as np
import matplotlib.pyplot as plt


def text2specgram(text,start_freq,end_freq,freq_step,step_scale,char_speed,fs,text_inv):

    print("\nStart text to specgram")

# 関数に入力するパラメータの説明
# text         スペクトログラムに表示したい文字列(str)
# start_freq   文字列のスライスを開始する周波数(低周波側)(float)
# end_freq 文字列のスライスを終了する周波数(高周波側)(float)
# freq_step    文字列を周波数ごとにスライスする間隔(int)
# step_scale   周波数のStepの区切り方(bool) 等比型ならTrue、等差型ならFalse
# char_speed   半角1文字あたりの時間(float)  単位は秒
# fs           WAVファイルのサンプリング周波数(int) end_freqの2倍以上必要
# text_inv 生成する文字列画像のタイプ(bool)
#              黒字に白文字ならTrue、白地の黒文字ならFalse


# ■ Step1 文字数をカウントする
# 音声化する文字列を全角=2、半角=1としてカウントする
# east_asian_width関数の戻り値がF/W/Aのとき2,それ以外を1としてカウントする
# F: fullwidth 全角英数
# W: Wide 漢字、全角かな 等
# A: Ambigious 特殊文字
 
    count=0
    for n in text:
        if unicodedata.east_asian_width(n) in 'FWA':
            count += 2
        else:
            count += 1
    print(text, ": ",count," byte") #文字列のバイト数を表示


# ■ Step2 文字列を画像化する 
# 等幅フォントのIPAゴシックを使用する。ダウンロード --> https://moji.or.jp/ipafont/ipa00303/
# ダウンロードしたフォントはプログラムと同じディレクトリに保存

    image_length = int( count*freq_step/2 + freq_step )
    
    # imgオブジェクトとして黒地の長方形を作成
    img = Image.new(mode='RGB', size=(image_length, freq_step), color=(0,0,0))
    draw = ImageDraw.Draw(img)
    
    # imgオブジェクトに文字列を描画(白文字)
    kbd = ImageFont.truetype("ipag.ttf", freq_step)
    draw.text( (int(freq_step/2),0 ),text,font=kbd, fill=(255,255,255))
    img = img.convert("L") #グレースケール変換
    
    # 白地に黒文字にするときはFalse
    if text_inv ==False:
        img = ImageOps.invert(img)
    
    # 念のため保存
    img.save("test.png")


# ■ Step3 文字列 → 音声への変換
# □ Step3-1 生成する音声のデータ長さを定義
    image_length_fit = int(image_length/freq_step*char_speed*fs)

# □ step3-2 文字列画像の横幅(ピクセル)を、音声データの長さに合わせて引き伸ばす 
    im=np.array(img.resize((image_length_fit, freq_step)))

# □ step3-3 引き伸ばした文字列画像について、1行ごと、対応する周波数のサイン波との積をとる
    wav1=np.zeros(len(im.T)) #サイン波生成用
    wav=np.zeros(len(im.T)) #音声データ格納用
    
    for m in range(freq_step):
        print(m,end=":")

        if step_scale == True:
            # start_freq から end_freqまでの周波数をfreq_stepで指数関数的に増やす       
            freq = start_freq*(end_freq/start_freq)**(m/freq_step)
        else:
            # start_freq から end_freqまでの周波数を等間隔に増やす     
            freq = start_freq+(end_freq-start_freq)*(m/freq_step)
        
#      print(freq, end="Hz, ")
        print("%dHz"%(freq),end=", ")

        for n in range(len(im.T)):
            wav1[n]=np.sin(2*np.pi * freq*n/fs + m) #サイン波の生成(と位相ずらし)
        wav = wav + im[freq_step-m-1,:]*wav1 #文字列画像(引き伸ばし後)の一行分とサイン波の積
        wav=wav+wav1 #積算
    print("end")

# □ Step3-4 wavファイルへの書き出し
    wav=wav/max(wav)*24000 #振幅調整 
    wav = [int(x) for x in wav] #整数化(-32768〜32,767)
    wav_bin=struct.pack("h"*len(wav),*wav) #バイナリ化
    
    w = wave.Wave_write("ttos.wav")
    p=(1,2,fs,len(wav_bin),'NONE','not compressed')
    w.setparams(p)
    w.writeframes(wav_bin)
    w.close
        
# ■ Step4 波形とスペクトログラムの表示

    s_length = len(wav)/fs #生成波形の長さ(秒)
    x=np.arange(0, s_length, 1/fs) #横軸の定義
    
    # 上段に波形表示
    plt.subplot(2,1,1)
    plt.plot(x,wav,linewidth=0.5)
    plt.title("waveform")
    plt.xlabel("Time[s]")
    plt.ylabel("Amplitude")
    plt.grid(True)
    plt.xlim([0,s_length])

    # 下段にSpectrogramを表示
    plt.subplot(2,1,2)
    plt.specgram(wav,256, fs, noverlap=256-1,cmap='jet',scale='linear')
    plt.grid(linestyle='--', linewidth=0.5)
    plt.title("spectrogram")
    plt.xlabel("Time[s]")
    plt.ylabel("Frequency[Hz]")
    plt.ylim([start_freq,end_freq])
    plt.xlim([0,s_length])
    
    plt.tight_layout() 
    plt.show()
    plt.close()

関数の定義はここまで。
Pythonistaの適当なフォルダに上記のコードをコピーした.pyファイルを作成し、実行する。
前述のフォントファイルも同じフォルダに格納しておく。

その後、下記のコマンドを実行すると波形とスペクトログラムが表示されるはず。

コマンドの内容は”青椒肉絲”という文字列をスペクトログラム上に400〜8000Hzの間で表示させるというもの。
(それぞれの項目の内容は後述).

コマンド
text2specgram("青椒肉絲",400,8000,100,True,1,20000,True)

実行結果
f:id:friedrice_mushroom:20220108164657p:plain

波形とスペクトログラムが表示されたら成功。
成功したら、プログラムがあるフォルダに ”ttos.wav” という音声ファイルが生成されているほず。
ファイルをタップし、再生ボタン(▷)をタップすると音声が再生される。

f:id:friedrice_mushroom:20220108164810p:plain:w400

スペクトログラムのアプリで表示した様子はこちら。

プログラムの中身

文字列をスペクトログラムに表示するイメージは下図のとおり。
低周波〜高周波のサイン波が格納された行列と、任意の文字画像(例では”A”)を乗算させる。

残った波形をすべて積算したものが音声データとなる。
これをスペクトログラムで表示すると任意の文字列が表示されるはず。

f:id:friedrice_mushroom:20220108164910p:plain

今回作成したプログラムは下記のステップで構成される。
・Step1 : 文字数をカウント
・Step2 : 文字列を画像化
・Step3 : 文字列 → 音声への変換
・Step4 : (おまけ)波形とスペクトログラムの表示

関数の構成

下記の関数を作成した。()内それぞれの項目の内容は下記のとおり。

text2specgram(text,start_freq,end_freq,freq_step,step_scale,char_speed,fs,text_inv):
# 関数に入力するパラメータの説明
# text         スペクトログラムに表示したい文字列(str)
# start_freq   文字列のスライスを開始する周波数(低周波側)(float)
# end_freq 文字列のスライスを終了する周波数(高周波側)(float)
# freq_step    文字列を周波数ごとにスライスする間隔(int)
# step_scale   周波数のStepの区切り方(bool) 等比型ならTrue、等差型ならFalse
# char_speed   半角1文字あたりの時間(float)  単位は秒
# fs           WAVファイルのサンプリング周波数(int) end_freqの2倍以上必要
# text_inv 生成する文字列画像のタイプ(bool)
#              黒字に白文字ならTrue、白地の黒文字ならFalse

補足1 : 文字数をカウントする方法

文字数や文字の種類(全角か半角か)によって画像のサイズを変更したいので、フォントは等幅フォントを使用する。Pythonistaへの日本語フォントの導入方法は上述。

Pythonで文字数をカウントするにはunicodedataモジュールを使用する。
east_asian_width関数というものを使用することで、戻り値がF/W/Aのとき2,それ以外を1としてカウントする。これにより、全角=2、半角=1としてカウントできる。
戻り値F/W/Aの定義は下記のとおり。

F: fullwidth 全角英数. W: Wide 漢字、全角かな 等. A: Ambigious 特殊文字.

   count=0
    for n in text:
        if unicodedata.east_asian_width(n) in 'FWA':
            count += 2
        else:
            count += 1
    print(text, ": ",count," byte") #文字列のバイト数を表示

(独習Pythonから引用)

補足2 : 周波数ステップの区切り方

周波数のステップの区切り方については等比型と等差型の2通りを用意した。

等差型の場合は 100Hz, 200Hz, 300Hz, 400Hz, … と、100Hzずつ周波数が増加する。(交差が100). 等比型の場合は 100Hz, 200Hz, 400Hz, 800Hz, … と、周波数が2倍ずつ変化する。(公比が2).

前述の実行例は等比型のステップだったが、等差型のステップも可能。
関数の第5項(step_scale に該当)をFalseにすると等差型になる。

コマンド text2specgram("青椒肉絲",400,8000,100,False,1,20000,True)

実行結果 f:id:friedrice_mushroom:20220108165220p:plain

スペクトログラムのアプリで表示した様子はこちら。

「ビービビビー」という音が聞こえるはず。いかにもノイズな音で、聴き映えしない印象。
ちなみに、スペクトログラムのアプリ側では縦軸をlinearへ変更する必要がある。

聴き比べてみれば、周波数を等比型で区切ったほうが聴感的には自然に感じられるはず。
普段聴くような音楽もそうで、ピアノの鍵盤は半音上がるたびに周波数が21/12倍になる。
1オクターブ上がると周波数が2倍になるが、これは21/12の12乗が2であることと同じ。
音楽用語ではこれを12平均律というのだそう。

反転モードも用意した

上述の例で示したものは、実行文字列の”塗り”に該当する部分についてスペクトログラムの信号が強くなる。それを反転させるモードも用意した。

関数の最終項(text_inv に該当)のパラメータをFalseにすると、文字の背景に該当する部分の信号が強くなり、”塗り”に該当する部分の信号は弱くなる。
下記のとおり入力して実行すると、スペクトログラムは下図のように表示される。

コマンド
text2specgram("青椒肉絲",400,8000,100,True,1,20000,False)

実行結果
f:id:friedrice_mushroom:20220108171008p:plain

実際に発音させて、スペクトログラムで表示した様子はこちら。
かなりアグレッシブな音にな感じられ、これはこれで好き。

終わりに

スペクトログラムに任意の文字列を表示する方法は確立できたが、用途は未だに思い浮かばない。

動画制作や波形編集アプリには音声のスペクトログラムを表示できるものがあり、キュー的な使い方もできるかと思った。
しかし実際は時間軸のレンジを頻繁に変えるので、結局は何が書かれているのかわからないことがほとんど。ちゃんとインデックス機能が使いやすいアプリを選ぶしかない。

まあ用途なんてものは後付なので、今はいろんな文字列をスペクトログラムに表示させてはどんな音が映えるか調べている。画数の多い四字熟語や中華料理が映えやすい、と思う。

今後のアイデアとしては、ホワイトノイズを背景に文字列の”塗り”に該当する信号成分がゼロになるようなものを作ってみたい。ググってみると事例はすでにあったりする。
これは今回のようなサイン波を重ねる方法とは別物で、バンドストップフィルタを使う必要がある。 しかし、そうして作った音は隠しメッセージのような使い方が多いので、映える音には程遠いかもしれない。
でも、作ってみないとわからないというジレンマにいる。
あとはGUI機能を活用して、よりスムーズに変換作業を行いたい。

理想の映え音探しは終わらない。