はじめに
Praat Scrptを使っていると、 「文法は分かった、でも実際にコードを書く時にはどうすればいいの?」 と戸惑うことが多いかと思います。
「Praat Scriptを活用したい」、「コードを読みやすく整理したい」、そんなあなたに、コーディングのテクニック集をまとめました。
元ネタは、公式リファレンス、CPrAN、そして学生時代試行錯誤した私の体験談です。
かなり主観が入っているところもあるのでご了承ください。「もっと楽なやり方あるぞ」等コメントでのツッコミ大歓迎です。
なお、Praatの分析機能や音声処理については範囲が広いので本記事の対象外としました。また、Praat Scriptの基本的な構文については前回の記事をご覧ください。
シリーズ一覧
- Praat Script基礎文法最速マスター2022
- Effective Praat Script (当記事)
- 悪用厳禁?Praat Scriptの黒魔術、謎文法
- Praat Scriptでゲームを作ってみよう
以下一覧です。めちゃくちゃ長いですがどこから読み始めても大丈夫です。
バージョン
appendInfoLine: praatVersion$
; 6.2.11
コーディングスタイル
変数、Procedure名にはcamelCaseを用いる
組み込み関数、定数は全てcamelCaseで定義されています (appendInfoLine
, sumOver
, praatVersion
等)。自分で定義する場合もこの命名に合わせたほうがよいでしょう。
procedure myProc:
appendInfoLine: "foo"
end
myStr$ = "foo"
また、変数名の先頭に _
や大文字を付けることはできません。
定数を表したい場合は MY_CONST
ではなく kMyConst
のような形式を使う必要があります1。
; _foo = 1 ; syntax error
; Foo = 1 ; syntax error
procedureの引数の渡し方を統一する
procedureの引数の書き方には、以下の2種類があります。
- かっこでくくる形式
@foo: a, b
- コロンの後に羅列する方式
@foo(a, b)
どちらも意味的には同じですが、リポジトリ内では統一することをおすすめします。
なお、本記事では公式リファレンスに倣いコロン形式で記述しています。
# 混ぜて書くと読みづらい...
procedure printAdd: .a, .b
appendInfoLine: .a + .b
endproc
procedure printSub(.a, .b)
appendInfoLine(.a - .b)
endproc
@printAdd: 1, 2
@printSub(1, 2)
使用する論理演算子を統一する
こちらもスタイルの統一の話です。論理演算子(and, or, not)の書き方はいくつかありますが、
-
&
,|
,!
-
&&
,||
,!
-
and
,or
,not
のどれかの組み合わせにすることをおすすめします。
予約語と同じ変数名を使わない
and
, or
, not
は予約語でありながら、なぜか変数名として宣言可能です。もちろん参照時に構文エラーになるので使わないようにしましょう。
and = 1
appendInfoLine: and # error: symbol misplaced
比較演算子には =
より ==
を使用する
=
は代入演算子としても、比較演算子 (==
と同じ) としても使用できます。
公式リファレンスでも =
が使用されていますが、紛らわしいので ==
をおすすめします。
# 代入?比較?
isSame = a = b
# これなら分かりやすい
isSame = a == b
ちなみに等しくない場合は !=
, <>
の2通りの書き方がありますが、好みで良いと思います。
長すぎる行は ...
で折り返す
1行が長いと、スクロールが必要で見づらいので改行しましょう。
procedure main(.wavDirectoryPath$)
# ...
# 長い行を ... で途中改行
.tableObj = do("Create Table with column names...",
... "stats", .numFiles, "file mean max min")
ちなみに例は音声強度時系列抽出コードからの引用です。詳細については以前の記事をご覧ください。
違う型で同名の変数を定義しない
型が違えば全く別の変数として扱われるので、a
と a$
を同時に定義することができます。が、紛らわしいだけなのでやめましょう。
#
は説明のためのコメント、;
はコメントアウトに使う
# printAddは引数の和を表示します。
procedure printAdd: .a, .b
appendInfoLine: .a + .b
;appendInfoLine: "debug: a=", .a, " b=", .b
endproc
公式リファレンスより
Because of its visibility, you are advised to use "#" for comments that structure your script, and ";" perhaps only for "commenting out" a statement, i.e. to temporarily put it before a line that you don't want to execute.
見やすさのため、"#" はコードを構造化するためのコメントに、 ";" は単なる文の「コメントアウト」(例:実行したくない文の先頭に一時的に付ける)に使うことをおすすめします。
ただし、文中コメントは ;
しか使えません。
area = pi * r ^ 2 ; 円の面積の公式 πr^2
true, falseを変数宣言する
Praat Scriptにはbool型はありません。number型をboolの代わりに使用します(0がfalsy, それ以外がtrythy)。また、真偽値判定する組み込み関数も trueとして 1
, falseとして 0
を返します。
初見ではマジックナンバーに見えてしまうので、true
, false
をグローバルに宣言すると意図が伝わりやすいです。
特にprocedureの戻り値に 0
や 1
と書くとnumberを返すように見えてしまうので、 true
, false
にした方がぱっと見で分かりやすいです2。
true = 1
false = 0
procedure contains: .strings$#, .element$
.found = false
for .i from 1 to size(.strings$#)
if .strings$#[.i] == .element$
.found = true
endif
endfor
.return = .found
endproc
@contains: {"foo", "bar", "baz"}, "bar"
appendInfoLine: contains.return ; 1
Praatの機能を呼び出す際には do
を用いる
Praat ScriptはPraat自動化のための言語なので、Praatでできることはなんでもコード化可能です。
UI上でポチポチしたあとPraat Scriptのエディタで「Paste History」すると、操作をすべてコード化してくれます。
About Praat
しかし、ここで貼り付けられるコードは古い文法なので、空白の扱いが厄介です(古い文法については「call
よりも @
を使う」参照)。可能な限り do
を使った形式に書き直しましょう。
do("About Praat")
Command "xxx" not available
と怒られた場合は、...
を後ろに付けると大抵うまくいきます。
# 古い文法
Read from file: "1.wav"
# これはエラー
; do("Read from file", "1.wav")
# これならok
do("Read from file...", "1.wav")
do
関数の利点については、「Praatスクリプト基礎文法最速マスター」3 で詳しく解説されています。
大きな定数は指数表記する
0
が多いと見づらいので、 1e8
のような形式で宣言しましょう。
n = 1e8
appendInfoLine: n ; 100000000
文字列処理
ソースコードの先頭に clearinfo
を必ず記載する
clearinfo
はinfoへの出力を全消去する命令です。
よく使う appendInfoLine
は名前の通りinfoへの「追記」なので、実行するたびにinfoの出力が増えてしまいます。
appendInfoLine: "hello"
hello
hello
hello
...
何度実行しても hello
を1つだけ表示したいので、先頭に clearinfo
を付けましょう。
# 最初にinfoを全消去
clearinfo
appendInfoLine: "hello"
一応、最初の文字列出力のみ writeInfoLine
(infoを消してから表示)を使うことでも可能です。しかし、後でもっと前に文字列出力が必要になった際 appendInfoLine
に修正が必要で面倒なのでお勧めしません。
改行、タブは \n
, \t
ではなく newline$
, tab$
を用いる
appendInfoLine
で \n
, \t
を使うことはできません。組み込み定数 newline$
, tab$
を使いましょう。
appendInfoLine: "status", newline$, "------------------"
appendInfoLine: tab$, "HP:", tab$, 100/100
appendInfoLine: tab$, "ATK:", tab$, 50
appendInfoLine: tab$, "DEF:", tab$, 70
status
------------------
HP: 1
ATK: 50
DEF: 70
改行したくない場合は appendInfo
を用いる
複数要素を同じ行に出力したい場合は appendInfo
(appendInfoLine
の改行無し版)が便利です。
for i from 1 to 10
appendInfo: i, ","
endfor
appendInfoLine: ""
1,2,3,4,5,6,7,8,9,10,
文字列のn文字目の取得には mid$
を用いる
他の言語のように s$[i]
と書くことはできません。s$[i]
は疑似配列という別の文法要素です(「「疑似配列」よりもVectorを使う」参照)。代わりに、 mid$
関数で部分文字列を切り出しましょう。
s$ = "abcde"
# 3文字目から1文字取得
appendInfoLine: mid$(s$, 3, 1) ; c
# こうは書けない!(疑似配列 `s$[3]` の宣言になってしまう!)
; appendInfoLine: s$[3]
ダブルクォーテーションのエスケープは ""
と書く
\"
は使えません。
appendInfoLine: "name is ""foo""" ; name is "foo"
小数の表示には fixed$
を使用する
Praat Scriptには printf
はありません。numberを表示すると桁がだらだらと表示されてしまいます。
appendInfoLine: 1 / 3 ; 0.3333333333333333
特定の桁数で丸めたい場合は、fixed$
を使いましょう。
appendInfoLine: fixed$(1/3, 4) ; 0.3333
文字列分割には splitByWhitespace$#
を用いる
空白限定ですが、splitByWhitespace$#
でstringをstring vectorに分割可能です。
animal$ = "cat dog mouse rabbit"
animals$# = splitByWhitespace$#(animal$)
for i from 1 to size(animals$#)
appendInfoLine: animals$#[i]
endfor
cat
dog
mouse
rabbit
Vector, Matrix, String Vector
vector (a#
), matrix (a##
), string vector (a$#
) は6系で導入された比較的新しい型のため、あまり使用例が出てきません。しかし、簡潔なコードを書くためにぜひとも押さえておきたいです。
Vector, Matrix, String Vectorの構文をおさえる
vectorはいわゆる「numberの配列」です。第一級オブジェクトで、要素の参照、破壊的変更も可能です。
vec# = {2, 4, 6}
appendInfoLine: vec# ; 2 4 6
appendInfoLine: vec#[2] ; 4 (添え字は1始まりなので注意!)
# 破壊的変更
vec#[2] = 5
appendInfoLine: vec# ; 2 5 6
matrixは行列、つまり「vectorの配列」です。特徴はvector同様です。
mat## = {{1, 2}, {3, 4}}
appendInfoLine: mat##
; 1 2
; 3 4
# 添え字の書き方に注意!
appendInfoLine: mat##[1, 2] ; 2
string vectorはvectorのstring版です。末尾の記号の覚え方は 「string $
vector #
」です。
strvec$# = {"foo", "bar", "baz"}
appendInfoLine: strvec$#; foo bar baz
appendInfoLine: strvec$#[2]; bar
組み込み関数を使いシンプルにvectorを初期化する
vectorの生成関数をうまく活用しましょう。
# 空
appendInfoLine: zero#(3) ; 0 0 0
appendInfoLine: zero##(2, 3)
; 0 0 0
; 0 0 0
appendInfoLine: empty$#(3) ; "" "" ""
# 1からnまで
appendInfoLine: to#(4) ; 1 2 3 4
# 等差数列 (Pythonでいうrange)
# (from, to, 要素数, toを含むかどうか(optional, 1:True, 0:False))
appendInfoLine: linear#(0, 9, 3, 0) ;0 4.5 9
appendInfoLine: linear#(0, 9, 3, 1) ;1.5 4.5 7.5
# 引数は省略可能
appendInfoLine: linear# (0, 9, 3) ;0 4.5 9
数列を使用したvector, matrix要素更新を活用する
~
を利用すると、vector全要素を要素数を含む式で初期化可能です。数列を生成する際に便利です。
vec# = zero#(5)
# 要素を設定
# vec#[i] = i * 3 + 1
vec# ~ col * 3 + 1
appendInfoLine: vec# ; 4 7 10 13 16
# matrixでも使用可能
m## = zero##(2, 2)
m## ~ row * 10 + col
appendInfoLine: m##
; 11 12
; 21 22
vectorの演算子を使いこなす
名前の通り、vectorはベクトルとしての演算が可能です。for文で各要素を更新せずとも、1行で加減乗除が可能です。
# vectorどうし
appendInfoLine: {1, 2} + {3, 4} ; 4 6 (連結ではないので注意!)
appendInfoLine: {1, 2} - {3, 4} ; -2 -2
appendInfoLine: {1, 2} * {3, 4} ; 3 8
appendInfoLine: {1, 2} / {3, 4} ; 0.333... 0.5
# vectorとnumber
appendInfoLine: 3 + {1, 2} ; 4 5
appendInfoLine: {1, 2} + 3 ; 4 5
appendInfoLine: 3 * {1, 2} ; 3 6
# matrixとmatrix
appendInfoLine: {{1, 0}, {0, 1}} + {{0, 2}, {2, 0}}
; 1 2
; 2 1
# NOTE: 行列積ではなく要素積
appendInfoLine: {{1, 2}, {3, 4}} * {{1, 2}, {2, 1}}
; 1 4
; 6 4
# matrixとnumber
appendInfoLine: {{1, 0}, {0, 1}} + 3
; 4 3
; 3 4
# matrixとvector
appendInfoLine: {{1, 1}, {1, 1}} + {1, 2}
; 2 3
; 2 3
vectorの関数を使いこなす
vectorやmatrixを扱う関数も豊富に用意されています。for文で各要素を更新せずとも(ry
# 繰り返し
appendInfoLine: repeat#({1,2}, 3) ; 1 2 1 2 1 2
# 内積、直積
appendInfoLine: inner({1, 2}, {3, 4}) ; 11
appendInfoLine: outer##({1, 2}, {3, 4})
; 3 4
; 6 8
# 行列積
m1## = {{1, 2}, {3, 4}}
m2## = {{2, 0}, {0, 2}}
appendInfoLine: mal##(m1##, m2##)
; 2 4
; 6 8
# 統計
appendInfoLine: sum({1, 2, 100}) ; 103
appendInfoLine: mean({1, 2, 100}) ; 34.33...
appendInfoLine: stdev({1, 2, 100}) ; 56.87...
appendInfoLine: center({1, 2, 100}) ; 2.96...
# 乱数
# 乱数(要素数, min, max)
appendInfoLine: randomInteger#(zero#(10), 1, 100)
# 一様分布(要素数, min, max)
appendInfoLine: randomUniform#(zero#(10), 1, 100)
# 正規分布(要素数, mean, std)
appendInfoLine: randomGauss#(zero#(10), 50, 10)
# 負(とundefined)の要素を0にする
appendInfoLine: rectify#({1, -2, 3, -4}) ; 1 0 3 0
# 要素の合計
squares# = {1, 4, 9, 16, 25}
appendInfoLine: sumOver(i to 5, i * squares#[i]) ;225
matrix初期化にvectorを活用する
matrixにvectorを埋め込むことも可能です。うまく使えば初期化がシンプルに書けます。
vec# = {1, 2}
mat## ={vec#, vec#*2, vec#*3}
appendInfoLine: mat##
; 1 2
; 2 4
; 3 6
ただし、matrixの要素参照でvectorを取得することはできません...
; appendInfoLine: mat##[1] ; これはむり
範囲外の要素の参照をさけるため size
を使う
vector等で範囲外の要素を参照すると index out of bounds
エラーが発生します。
vec# = {1, 2, 3}
; appendInfoLine: vec#[2] ; index out of bounds
# 末尾を-1で取ることはできない
; appendInfoLine: vec#[-1] ; the element index should be positive
要素数を size()
で取得しましょう。
# 末尾要素の取得
appendInfoLine: vec#[size(vec#)] ; 3
# matrixの場合は行、列それぞれ確認可能
mat## = {{1, 2}, {3, 4}, {5, 6}}
appendInfoLine: numberOfRows(mat##) ; 3
appendInfoLine: numberOfColumns(mat##) ; 2
「疑似配列」よりもVectorを使う
vector導入の前から、疑似配列(pseudo array)という配列のような構文が存在しました。ネットでコード例を探してもよく登場します。
pseudoArr[1] = 10
pseudoArr[2] = 20
一見vectorと同じように見えますが、疑似配列は単なる変数名にすぎず (pseudoArr[1]
という名前の変数) 、配列全体を pseudoArr
という1変数で参照することはできません 。これが「疑似」配列と呼ばれるゆえんです。
特に、疑似配列は要素数を取得することができません。for文で要素をなめることができないので、理由が無ければvectorを使用しましょう。
# vector
vec# = {10, 20}
for i from 1 to size(vec#)
appendInfoLine: i
endfor
# pseudo arrayは要素一覧が分からない...
pseudoArr[1] = 10
pseudoArr[2] = 20
for i from 1 to ???
また、vectorの要素書き換えのつもりで #
を忘れてしまい疑似配列を宣言してしまうパターンも要注意です!
vec# = {10, 20}
# あっ!(vec#[1] = 100)と書きたかった
vec[1] = 100
連想配列が欲しい場合は疑似配列を使用する
前項で疑似配列をディスってしまいましたが 疑似配列にしかできないこととして
- 連続しない添え字を使用できる
- 文字列の添え字を使用できる
があげられます。Enumや辞書が欲しい場合は、変数をまとめられる疑似配列を使うのが良いです。
price["water"] = 0
price["coffee"] = 450
price["tea"] = 400
時刻処理には date$
より date#
を使う
現在時刻の取得方法は date$
, date#
の2種類あります。今までは date$
を頑張ってパースする必要がありましたが、今は date#
のvectorを使うのが手軽です。
appendInfoLine: date$() ; Fri Apr 29 12:45:21 2022
appendInfoLine: date#() ; 2022 4 29 12 45 21
now# = date#()
if now#[4] >= 12
appendInfoLine: "PM"
else
appendInfoLine: "AM"
endif
Procedure
procedureはPraat Scriptで最も複雑な概念の1つです(個人の意見です)。関数と思って使うと戸惑いますが、使いこなせれば「Praat Scriptらしい」コードが書けるようになります。
call
よりも @
を使う
procedureの呼び出し方法には @
と call
の2種類があります。...が、@
を使うことをおすすめします。
call
は古い文法に準拠していて、ハマりどころが多いためです。
procedure printName: .first$, .last$
appendInfoLine: .first$, " ", .last$
endproc
# @ を使った呼び出し
@printName: "Taro", "Yamada"
# call を使った呼び出し
call printName Taro Yamada
call
をはじめ、古い文法の組み込み関数では引数をスペース区切りで渡します。
また、""
で区切らずとも文字列リテラルとして扱われます。ややこしいことに、スペースの数が引数よりも多いときは、残りがまるごと最後の変数に代入されます。
procedure printName: .first$, .last$
appendInfoLine: "first: ", .first$
appendInfoLine: "last: ", .last$
endproc
call printName Johann Sebastian Bach
first: Johann
last: Sebastian Bach
また、引数に変数を渡したい場合は、 ''
で囲む必要があります。シングルクォーテーションによる変数の展開は強力な黒魔術なので、使わないに越したことはありません。
first$ = "Taro"
last$ = "Yamada"
call printName 'first$' 'last$'
同名のprocedureを定義しないよう注意する
同名のprocedureが複数定義された場合、最初のものだけが有効です。バグのもとなので名前が被らないようにしましょう。特に、include (後述) すると名前の衝突に気づきづらいので注意が必要です。
@foo() ; foo1
procedure foo()
appendInfoLine("foo1")
endproc
@foo() ; foo1
procedure foo()
appendInfoLine("foo2")
endproc
@foo() ; foo1
procedure内部では必ずローカル変数 (.foo
) を使用する
変数の先頭に .
を付けると、ローカル変数として宣言できます。
foo$ = "global"
procedure myproc:
# ローカル変数
.foo$ = "local"
# グローバル変数の上書き
foo$ = "overwrite!"
endproc
@myproc:
# グローバル変数なので参照可能
appendInfoLine: foo$ ; "overwrite!"
# ローカル変数なので参照不可
; appendInfoLine: .foo$ ; unknown variable
グローバル変数を触るのはバグの元なので、なるべくローカル変数にしましょう。特に引数の .
は忘れがちなので要注意!
foo = 1
# 引数がグローバル変数になっている!
procedure myproc: foo
appendInfoLine: foo
endproc
@myproc: 2
appendInfoLine: foo ; 2
procedureが名前空間であることを意識する
procedureの実態は関数よりも「名前空間変更を伴うgoto」に近いです。
先ほどの説明では .
付きの変数を「ローカル変数」と説明しましたが、実際には procedureの名前空間4に定義されます。(procedure名).(変数名)
で、スコープを抜けても参照可能です。
procedure myProc:
.val = 1
endproc
@myProc:
# myProcのローカル変数は外側からも参照可能!
appendInfoLine: myProc.val ; 1
上記コード実行時には、以下のことが起こっています。
- myProcの呼び出し (
@myProc
) によって myProc 名前空間の内部にジャンプ-
.val = 1
はmyProc.val = 1
と同等 - 「関数」ではないので、スコープを抜けた後も変数は死なない
-
-
endproc
でスコープを抜けることによって、デフォルトの名前空間 (prefix無し)にジャンプ
これだけ読んでも「そんなバカな? 」とお思いでしょう。そこで、実際にgotoを使ってprocedureが名前空間の変更であることを確かめてみます5。
.foo$ = "outer"
@myproc:
appendInfoLine: myproc.foo$ ; inner
appendInfoLine: .foo$ ; outer
# gotoをこれ以上踏まないようスクリプト終了
exitScript: "bye"
procedure myproc:
# gotoでprocedureの外に出る
goto outOfProc
label myprocAgain
endproc
label outOfProc
.foo$ = "inner"
# 再びmyprocの中へ
goto myprocAgain
procedureの外で代入した .foo$ = "outer"
は、一見 goto outOfProc
で飛んだ先の .foo$ = "inner"
に上書きされそうです。しかし実際には myproc.foo$ = "inner"
として評価されています。このことから、procedure呼び出しによって現在の名前空間を変更し、endprocで元に戻していることが分かります。
procedureの(疑似)戻り値には .return
ローカル変数を用いる
上記の名前空間の仕組みを使えば、 .return
というローカル変数を(いわゆる「関数」の)戻り値として利用することができます。名前はなんでもいいのですが、 .return
が分かりやすいでしょう。
procedure add: .a, .b
.return = .a + .b
endproc
@add: 1, 3
appendInfoLine: add.return ; 4
@add: 2, 5
appendInfoLine: add.return ; 7
疑似配列を使うことで、多値返却も可能です。
ローカル変数をprivateにしたいときは、procedure名を _
始まりにする
これは少し裏技です。
上記の通り、procedureのローカル変数は基本外からでも読み書きすることが可能です 6。内部を弄られたくない場合は、 _
で始まるprocedure名を使用しましょう。
procedure _secretProc: .a, .b
.c = .a + .b
appendInfoLine: .c
endproc
@_secretProc: 1, 2
# 外からは参照不可能!
; c = _secretProc.c ; unknown symbol: c
変数名の先頭に _
を付けられないことを思い出してください!外部から参照する場合の名前空間が変数として使用できない名前になるため、構文エラーが発生します。
変な名前のprocedureを定義しない
変数と違い、procedureに使える文字には制限がありません。とはいえ、変な名前は読みづらいのでやめましょう。
# むちゃくちゃな名前でも作れる! (`s$="hello";assign` がprocedure名)
procedure s$="hello";assign: .a, .b
.c = .a + .b
appendInfoLine: .c
endproc
@s$="hello";assign: 1, 2 ; 3
複雑な条件分岐では、早期endprocを用いる
引き続き裏技です。濫用厳禁。
関数で複雑な条件分岐が必要な場合、早期returnを使ってネストを減らすテクニックがあります。
# (説明のために少し冗長な書き方をしています)
def is_even(x):
# int以外をあらかじめ弾いておくことで、この後のネストが深くなるのを防ぐ
if type(x) is not int:
return False
if x % 2:
return False
return True
Praat Scriptでも早期returnでコードをシンプルにしましょう...と言いたいところですが、Praat Scriptにはreturnがありません。そこで、関数の途中で endproc
を入れ、関数から途中で脱出させます。
true = 1
false = 0
procedure isEven: .x
# 整数以外を弾いておく
if floor(.x) != .x
.return = false
endproc
endif
if .x mod 2 == 0
.return = false
endproc
endif
.return = true
endproc
endproc
は関数の末尾に1つしか書けないので、当然上記のコードは正しく実行できません。そこで、インタープリターを騙してif文に入った時だけ endproc
が評価されるようにします。
endproc$ = "endproc"
procedure isEven: .x
# 整数以外を弾いておく
if floor(.x) != .x
.return = false
# インタープリターをだます黒魔術!(後述)
'endproc$'
endif
if .x mod 2 == 0
.return = false
'endproc$'
endif
.return = true
endproc
@isEven: 2
appendInfoLine: isEven.return ; 0
@isEven: 2.5
appendInfoLine: isEven.return ; 0
@isEven: 5
appendInfoLine: isEven.return ; 1
今度は正しく実行できました。
種明かしをすると、「call
よりも @
を使う」で紹介した、古い文法で変数を引数に渡す構文を悪用しています。
''
で括ると文の評価時に変数の中身が展開されてから実行されます。(イメージとしてはマクロの実行時版です)
first$ = "Taro"
last$ = "Yamada"
# 変数を渡すときに '' で括っている
# 文の評価時に変数の中身が展開され、 `call printName "Taro" Yamada` が評価される
call printName 'first$' 'last$'
'endproc$'
は、
- パース時には
endproc
として認識されないので、その後の文もprocedureとしてパースできる - 実行時に当該の文を評価した際は
endproc
として評価されるので、procedureを抜ける
ので、早期returnの代わりに使えるというわけです。
また、break
、continue
の代わりにも使えます。
procedure contains: .strings$#, .element$
for .i from 1 to size(.strings$#)
if .strings$#[.i] == .element$
.return = true
# 見つかったらループを抜ける!
'endproc$'
endif
endfor
.return = false
endproc
@contains: {"foo", "bar", "baz"}, "bar"
appendInfoLine: contains.return ; 1
繰り返しですが、構文の悪用なので濫用厳禁です。それでも使いたい方は次回の黒魔術の記事へ
procedureに状態を持たせたい場合は variableExists
の使用を検討する
procedureをただの「戻り値を返すのに手間がかかる関数」と捉えるのはもったいないです。procedureにできて関数にできないこととして 状態管理 があげられます。
変数が死なないことを活かし、procedureをオブジェクトやイテレータのように利用することができます。
この際に使えるのが、変数が定義されているかどうか調べる variableExists
です。未定義であれば初期化、定義済みであれば変更することで、呼び出すたびに戻り値を変えることができます。
procedure counter:
# 変数名の文字列を指定することで、定義されているかどうか呼び出し前に確認可能
if variableExists(".return")
# 定義済みならインクリメント
.return += 1
else
# 未定義なら初期化
.return = 1
endif
endproc
@counter:
appendInfoLine: counter.return ; 1
@counter:
appendInfoLine: counter.return ; 2
@counter:
引数に破壊的変更を加える代わりに、新しい値を返すことを検討する
Procedureは全て値渡し(「参照の値渡し」でもない)ので、引数に破壊的変更を加えても呼び出し元の値に影響は及びません。
引数のvectorに変更を加えただけでは意味が無いので、変更を加えたvectorを返しましょう。
procedure double: .vec#
.vec# *= 2
endproc
v# = {1, 2, 3}
@double: v#
# 値渡しなのでv#はそのまま
appendInfoLine: v# ; 1 2 3
# もちろん double.vec#は破壊的変更が加わっている
appendInfoLine: double.vec# ; 2 4 6
procedure double: .vec#
.return# = .vec# * 2
endproc
v# = {1, 2, 3}
@double: v#
appendInfoLine: double.return# ; 2 4 6
スコープ
ループ変数をループの外から参照しない
スコープを作れるのはprocedureだけです! for文のループ変数はループを抜けても生き残りますが、可読性が下がるので触らないようにしましょう。
for .i from 1 to 10
appendInfoLine(.i)
endfor
appendInfoLine(.i) ; 11
sumOver
のループ変数も同様です。
squares# = {1, 4, 9, 16, 25}
appendInfoLine: sumOver(i to 5, i * squares#[i]) ;225
appendInfoLine: i ;6
ファイル操作
文字コードをUTF-8に設定する
デフォルトではファイル出力の文字コードに UTF-16BE
が用いられます。他でも使いやすいように UTF-8
を設定しましょう。
# 以降のファイル出力形式がすべてUTF-8になる
do("Text writing preferences...", "UTF-8")
writeFileLine: "~/foo.txt", "あ"
ファイル読み込み、ファイル名読み込みにstring vectorを活用する
fileNames$#()
は、パターンにマッチするファイル名の一覧をstring vectorで取得できます。
appendInfoLine: fileNames$#("wavs/*.wav") ; 1.wav 2.wav 3.wav 4.wav 5.wav
また、readLinesFromFile$#()
はファイルの中身を行ごとのstring vectorで取得できます。
line1
line2
line3
fileName$ = "path/to/text.txt"
lines$# = readLinesFromFile$#(fileName$)
appendInfoLine: lines$#[2] ; line2
どちらもファイル操作を簡素化できるので活用したいです。
オブジェクトの操作にはIDを用いる
Praatでは、読み込んだファイル(音声、csv等)や生成した情報(韻律、強度等)を「オブジェクト」として管理しています。「Praat Objects」の画面に一覧表示され、各オブジェクトはオートインクリメントで払い出されたIDを持っています。
Praat Scriptでファイル操作する場合も、基本的にこのオブジェクトIDを使用します。
オブジェクトを生成する関数はオブジェクトIDを返します。
また、オブジェクト生成に別オブジェクトが必要な場合は、現在選択されている(画面上青くなっている)オブジェクトが使用されます。
fileName$ = "1.wav"
# wavファイル読み込み。戻り値は音声オブジェクトのID
soundObj = do("Read from file...", fileName$)
# 現在選択されているsoundObjを利用して、韻律オブジェクト生成
pitchObj = do("To Pitch...", 0, 75, 600)
選択されているオブジェクトIDは selected
で取得可能です。
appendInfoLine: selected() ;選択されているオブジェクトのうち先頭のもののID
appendInfoLine: selected#() ; 選択されている全オブジェクトのID
生成したオブジェクトは使い終わったら削除する
オブジェクトを開きっぱなしにするとメモリを食います。for文でバッチ処理を行う場合最悪落ちる(経験談)ので、使い終わったらすぐに removeObject
で削除しましょう。
# wavファイルから韻律情報を生成する例
fileName$ = "1.wav"
# 後で消せるように、ここでオブジェクトIDを取得しておく
soundObj = do("Read from file...", fileName$)
# 韻律オブジェクト生成
do("To Pitch...", 0, 75, 600)
# 使い終わったらお掃除!
removeObject(soundObj)
韻律オブジェクトも後で消したい場合は、同様に pitchObj = do("To Pitch...", 0, 75, 600)
とオブジェクトIDを取っておきましょう。
対象ファイルをユーザーに選ばせたい場合は chooseReadFile$
を使用する
chooseReadFile$
を使うと、ファイルエクスプローラーが開き、ユーザーがファイルを選ぶことができます。
# 引数はファイルエクスプローラーのタイトル
fileName$ = chooseReadFile$("open text file")
text$ = readFile$(fileName$)
appendInfoLine: text$
エラーハンドリング、安全な操作
環境依存の処理では、組み込み定数を活用する
Praatには、実行環境情報を得る定数がいくつか存在します。設定ファイル読み込みやOS依存処理がある場合は、以下の定数が役に立ちます。
# ディレクトリ関連
appendInfoLine: shellDirectory$ ; C:\WINDOWS\system32
appendInfoLine: defaultDirectory$ ; C:\WINDOWS\system32
appendInfoLine: preferencesDirectory$ ; C:\Users\syuparn\Praat
appendInfoLine: homeDirectory$ ; C:\Users\syuparn
appendInfoLine: temporaryDirectory$ ; C:\Users\syuparn
# OS関連
if macintosh
appendInfoLine: "Mac"
elsif windows
appendInfoLine: "Win" ; Win
elsif unix
appendInfoLine: "Unix"
else
appendInfoLine: "Unknown"
endif
また、Praatの本体バージョンも praatVersion
で確認できます。バージョンに応じた場合分けに活用できます。(基本的に後方互換を謳っているので対処不要ですが)
appendInfoLine: praatVersion ; 6211
appendInfoLine: praatVersion$ ; "6.2.11"
0除算はエラーにならないので事前にチェックする
Praat Scriptでは、0除算は undefined
(他言語の NaN
に相当)を返し実行し続けてしまうので、事前に弾いておきましょう。
appendInfoLine: 0 / 0 ; undefined
エラーを握りつぶす場合は nocheck
を使用する
夜中にしかけたバッチ処理がたった1ファイルのエラーで止まるのは悲しいです(体験談)。エラーを許容できる処理の場合、 nocheck
を使ってエラーを握りつぶしましょう。
コード例は、昔作ったwavファイル変換コード を新しい構文に書き直したものです7。
dir$ = "C:/Users/user/work/wavs"
@convertWav: dir$, 16000
procedure convertWav: .directory$, .resampleRateHz
# wavファイル名リストオブジェクトの作成
.wavNames$# = fileNames$#(.directory$ + "/*.wav")
for .i to size(.wavNames$#)
.wavName$ = .directory$ + "/" + .wavNames$#[.i]
.file = do("Read from file...", .wavName$)
# モノラルに変更
do("Convert to mono")
# サンプリングレート変更
do("Resample...", .resampleRateHz, 50)
# 変更後のwavファイルを上書き保存
# nocheckを付けることで、失敗しても後続の処理を続けられる!
nocheck do("Save as WAV file...", .wavName$)
removeObject(.file)
endfor
endproc
同様に、nowarn
で警告を握りつぶすことができます。
危険な操作を伴う場合は、pause
でユーザー確認画面を出す
ファイル削除等取り返しのつかない操作は、実行前にユーザーに確認画面を出すと親切です。 pause
を使うと、スクリプトを一時停止してダイアログを表示します。
pauseScript: "danger!!!!"
# 実際はファイル削除が走る
appendInfoLine: "deleted all files..."
stop
を押すとスクリプトが中断されます。
ユニットテスト
assert
を使ってユニットテストを書く
assert
を使うと、procedure等の細かい粒度でテストが可能です。
ちなみに学生時代の私はTDDを知らなかったので、「スクリプトの生成ファイルに差分が無ければOK」というゴリラな開発をしていました。
assert
に渡せるのは真偽値(number)のみで、0の場合エラーが発生します。
procedure add: .a, .b
.return = .a + .b
endproc
@add: 1, 2
assert add.return == 3 ; 成功すれば何も起きない
@add: 3, 4
assert add.return == 6 ; script assertion fails
異常系のテストには asserterror
を使用する
asserterror
は、期待したエラーが 発生しない 場合にエラーになります。チェック対象は直後の文で、asserterrorの引数にはエラーメッセージを指定します。
procedure add: .a, .b
.return .a + .b
endproc
# 引数が足りないときにエラーが発生することを確認
# (残念ながら、引数は古い文法しか対応していません...)
asserterror Empty formula.
@add: 1
エラーメッセージが期待したものと異なる場合もエラーが発生します。
asserterror Empty formula.
x += 1
公式リポジトリのテストでも多く使われています。
出力内容のテストには info$()
を使用する
appendInfoLine
等の出力結果をテストしたい場合は、 info$()
でinfoの出力内容を取得して検証しましょう。
procedure hello:
appendInfoLine: "Hello, world!"
endproc
# テスト前にclearinfoを忘れずに!
clearinfo
@hello:
assert info$() == "Hello, world!" + newline$
ベンチマークを stopwatch
で取得する
stopwatch
を使うと、実行時間の測定が可能です。
n = 1e7
# 測定開始
stopwatch
sum = 0
for i from 1 to n
sum += i
endfor
# 直前のstopwatchからの経過時間[s]を返す
time = stopwatch
appendInfoLine: "for loop: ", tab$, time
# 何度も使用可能
stopwatch
sum = sum(to#(n))
time = stopwatch
appendInfoLine: "sum vector: ", tab$, time
for loop: 2.46576189994812
sum vector: 0.015965938568115234
ソースコードの構造化
スクリプトが大規模化すると、1ファイルでの管理が苦しくなってきます。Praat Scriptのファイル分割には大きく
include
runScript
の2種類があります。以下、それぞれの特徴を紹介します。
ソースコードのモジュール化には include を使用する
include
を使用すると、別ファイルのソースコードを読み込めます。ファイルパスは呼び出し元から見た相対パスで指定します。
procedure greet: .name$
appendInfoLine: "Hello, " + .name$ + "!"
endproc
clearinfo
include greet.praat
@greet: "Taro"
includeは名前空間を作りません。procedure名が衝突しないように注意しましょう。
(大規模化したら、procedure greet.greet
のように(疑似的な)階層構造のある名前を付けるのもありかもしれません)
includeする用のソースコードにはprocedure/定数定義以外含めない
includeしたソースコードはすべて実行されます。ライブラリとしての再利用性を上げるため、includeする用のソースコードにはprocedureや定数の定義だけ書くようにしましょう。
# もし処理を含むと...
appendInfoLine: "this is greet.praat!"
procedure greet: .name$
appendInfoLine: "Hello, " + .name$ + "!"
endproc
clearinfo
# procedureが利用したいだけなのに余計な処理が実行されてしまって使いづらい...
include greet.praat
@greet: "Taro"
includeを使用する場合、ディレクトリ構成をフラットにする
includeは、実行ファイルから見た相対パスで指定します。ディレクトリ構成が入れ子になっていると、思わぬパス指定ミスが起こってしまうこともあります。
- main.praat
- sub
- sub1.praat
- sub2.praat
# 「相対パスで指定すればいいんだな」
include sub2.praat
procedure sub1:
@sub2:
endproc
procedure sub2:
appendInfoLine: "sub2"
endproc
include sub/sub1.praat
@sub1:
一見よさそうですが、 Cannot open file sub2.praat
が発生してしまいます。includeは main.praatの相対パスで参照されるので、 sub/sub1.praat
には include sub/sub2.praat
と書く必要がありました。
このようにパス指定がややこしくなってしまうので、なるべくフラットなディレクトリ構成にすることをおすすめします。
スクリプト実行には runScript
を使用する
もう一つファイルを分ける方法として runScript
があります。 include
がライブラリの読み込み向けだったのに対し、こちらは実行スクリプトの呼び出し向けです。
appendInfoLine: "hello"
runScript: "hello.praat"
両者の特徴を比較すると以下の通りです。
include | runScript | |
---|---|---|
用途 | ライブラリの利用 | 実行スクリプトの呼び出し |
ファイル名 | 固定 | 動的に変えられる |
パス | 相対パス | 相対パス |
呼び出し元との関係 | 密結合 | 疎結合(実装は隠蔽される) |
runScript
の引数で呼び出し先スクリプトに引数を渡す
runScript
に引数を指定することで、呼び出し先スクリプトのformに値を渡すことができます。
form Get Name
# 型 変数名 デフォルト値
word name John
endform
appendInfoLine: "Hello, " + name$ + "!"
runScript: "hello.praat", "Taro" ; Hello, Taro!
runScript: "hello.praat", "Hanako" ; Hello, Hanako!
# 足りないとエラー(デフォルト値にはならない)
; runScript: "hello.praat" ; Found 0 arguments but expected more.
exitScript
のエラーメッセージで呼び出し元にエラーを返す
runScript
されたスクリプトが呼び出し元にエラーを投げたい場合は exitScript
を使用します。
exitScript
はスクリプトを異常終了する関数で、runScript
関係なしに使うこともできます。
form Get Name
# 型 変数名 デフォルト値
word name John
endform
if name$ == ""
exitScript: "name is invalid!"
endif
appendInfoLine: "Hello, " + name$ + "!"
runScript: "hello.praat", "" ; エラー: name is invalid!
設定値を環境変数から読み込めるようにする
実行環境によって変えたい設定値は、コードにべた書きせずに環境変数から読み込めるようにしましょう。environment$
を使うと環境変数を読み込めます。
appendInfoLine: environment$("TEMP") ; C:\Users\syuparn\AppData\Local\Temp
ライブラリ
CPrANを活用する
CPrANはPraat Scriptの(非公式)プラグインマネージャで、JSONや文字列処理等の便利ライブラリが多数登録されています。
また、色々なテクニックが使われているので、コードリーディングするだけでも勉強になります(本記事のテクニックもいくつかはCPrANが元ネタです)。
とここまで言ったものの、実は使ったことはありません...活用した方の記事お待ちしております
その他
公式リポジトリからPraat Scriptの構文を見つける
残念ながら、公式リファレンスに載っていない文法、組み込み関数も多く存在します。そんなときは、公式リポジトリの以下の実装を確認すると新たな出会いがあるかもしれません。
-
インタープリター実装
- 予約語は
U'xxx'
の形で定義
- 予約語は
- 式の定義
- Praat Scriptのテストコード
おわりに
以上、Praat Scriptを活用するためのテクニックでした。(もし通読された方がいれば、お疲れさまでした!)
分析ツールは秘伝のタレ化して自分でも読めなくなってしまうことがあるので、記憶が新しいうちにリファクタリングしておきたいですね。今回紹介したテクニックが1つでもお役に立てれば幸いです。
次回は趣向を変えて、実用性皆無のPraat Script黒魔術を紹介します。お楽しみに!