差し替えたばかりの環境音を、寝る前にイヤホンで流していたときでした。十秒ほどで折り返す短いループなのですが、その折り返しの一点だけ、ごく小さくプチッと鳴ります。
デスクのスピーカーでは分かりません。翌朝もう一度スピーカーで確かめて、聞こえないので気のせいだと思い、また夜にイヤホンで気づく——それを二日ほど繰り返しておりました。
個人開発で運用しているヒーリング音源アプリは、就寝中に何時間もループを回す使われ方をします。一回では聞き取れない小さな段差でも、四百回繰り返せば必ず誰かの耳に届いてしまうのです。
最初に音声理解へ投げて、返ってこなかったもの
手元に Gemini API の環境が整っていたので、私はまず音源をそのまま渡しました。「不自然に聞こえる箇所があれば、その時刻とともに教えてください」という素直な依頼です。
返ってきたのは、音の質感や情感についての落ち着いた説明でした。低音の厚み、余韻の長さ、繰り返しの単調さ。どれも読んで納得できる内容で、しかし私が探している十ミリ秒に満たない段差については、ひと言もありません。
二度、三度とプロンプトを変えても同じでした。「クリック音」「不連続」といった語を明示しても、モデルは音楽的な印象の側から答えを組み立ててきます。
いま思えば当然でした。音声理解は意味と印象の層を担当しているのであって、標本値の並びが一サンプル単位で跳んでいるかどうかを見る道具ではないのです。私は道具の担当範囲を勘違いしたまま、プロンプトの書き方を疑っておりました。
波形が壊れているかは自分が数え、壊れて聞こえるかを Gemini に訊く。 この順番を逆にしていたことが、二日を溶かした原因でした。
段差の絶対値では決められません
継ぎ目の段差そのものは、素朴に計算できます。ループの末尾サンプルと先頭サンプルの差を取るだけです。
けれども、この絶対値にしきい値を置く方法は、私の手元ではうまくいきませんでした。静かなパッド系の音源と、雨音のようにざわついた音源とでは、内部で普段起きている変化量がまるで違うからです。
雨音では隣り合うサンプルが常に大きく動いています。そこに 0.05 の段差があっても、耳には埋もれて聞こえません。一方で静かなパッドに 0.05 の段差が入ると、はっきりプチッと鳴ります。
そこで、段差を「その音源の内部で普段どれくらい動いているか」で割ることにしました。分母には、隣接サンプル差の分布の 99.9 パーセンタイルを使います。最大値ではなく上位のパーセンタイルを取るのは、素材にもともと入っている一発の物音に引きずられないためです。
指標 意味 使いどころ
jump末尾サンプルと先頭サンプルの差の絶対値 参考値。単独では判定に使いません
p999_step隣接サンプル差の 99.9 パーセンタイル その音源の「普段の動きの上限」
ratiojump ÷ p999_step判定の主指標 。1 を超えたら人が聴きます
edge_rms先頭と末尾 10 ミリ秒の実効値 端が無音の素材を別扱いにするため
edge_rms を別に持っているのには理由があります。端が無音の素材は段差が原理的にゼロになり、ratio は必ず合格します。合格したから安全なのではなく、そもそも判定の土俵に乗っていないだけです。この二つを混同すると、無音で始まる素材が全部素通りしてしまいます。SILENT_EDGE が返ったときだけは扱いを分けており、この場合は段差ではなくループ長そのものと無音の長さを確かめます。
継ぎ目を数える四十行
外部ライブラリは numpy だけにしました。音源の検査は差し替えのたびに走らせるものですから、依存が増えるほど動かなくなる日が来ます。WAV の読み取りは標準ライブラリの wave で足ります。
"""ループ音源の継ぎ目を、耳で聴く前に数値でふるい分ける。
標準ライブラリ + numpy だけで動きます(wave は 16bit PCM WAV 前提)。"""
import sys, wave
import numpy as np
def read_wav_mono (path):
with wave.open(path, "rb" ) as w:
if w.getsampwidth() != 2 :
raise ValueError ( f " { path } : 16bit PCM の WAV だけを受け付けます" )
ch, sr, n = w.getnchannels(), w.getframerate(), w.getnframes()
raw = np.frombuffer(w.readframes(n), dtype = "<i2" ).astype(np.float64) / 32768.0
if ch > 1 : # ステレオはモノラルへ落として判定する
raw = raw.reshape( - 1 , ch).mean( axis = 1 )
return raw, sr
def seam_metrics (x, sr, edge_ms = 10.0 ):
"""継ぎ目の段差を「その音源の内部で普段どれくらい動いているか」で正規化する。
絶対値のしきい値は音源ごとにばらつくため、比で見るのが実務では安定する。"""
step = np.abs(np.diff(x))
p999 = float (np.percentile(step, 99.9 )) # 内部で起きる最大級の変化
jump = abs ( float (x[ 0 ]) - float (x[ - 1 ])) # ループ点でいきなり跳ぶ量
k = max ( 1 , int (sr * edge_ms / 1000 ))
edge = np.concatenate([x[:k], x[ - k:]])
return {
"jump" : jump,
"ratio" : jump / max (p999, 1e-12 ), # ← 判定に使う主指標
"p999_step" : p999,
"edge_rms" : float (np.sqrt(np.mean(edge ** 2 ))),
"samples" : int ( len (x)),
}
def verdict (m, ratio_threshold = 1.0 , edge_rms_floor = 1e-3 ):
if m[ "edge_rms" ] < edge_rms_floor:
return "SILENT_EDGE" # 端が無音。段差は出ないが尺のずれを別に疑う
return "SUSPECT" if m[ "ratio" ] >= ratio_threshold else "OK"
if __name__ == "__main__" :
for path in sys.argv[ 1 :]:
x, sr = read_wav_mono(path)
m = seam_metrics(x, sr)
print ( f " { path :24s } { verdict(m) :12s } ratio= { m[ 'ratio' ] :8.3f } "
f "jump= { m[ 'jump' ] :.5f } p999= { m[ 'p999_step' ] :.5f } "
f "edge_rms= { m[ 'edge_rms' ] :.5f } n= { m[ 'samples' ] } " )
しきい値の 1.0 は「継ぎ目の跳びが、その音源の内部で起きる最大級の変化と同じ大きさになったら疑う」という意味です。厳密な根拠があるわけではなく、手元の素材を通したときに人の耳の判断と食い違わなかった値を、そのまま採用しています。
手元の素材で出た数字
検証には、合成した十秒のパッド音源を二種類用意しました。片方は基音の周期がループ長をちょうど割り切るように置いたもの、もう片方はわずかに離調させて折り返しが波の途中に落ちるようにしたものです。
素材 判定 ratio jump p999_step
周期が揃っている版 OK 0.16 0.00912 0.05781
離調させた版 SUSPECT 4.92 0.26472 0.05382
注目していただきたいのは p999_step がほぼ同じ値だという点です。素材としての質感は変わっていないのに、ratio だけがおよそ 30 倍に開きます。分母を素材ごとに取り直しているからこそ、この差が出ます。
補正は等パワーのクロスフェードで行いました。末尾の一定区間を先頭に重ねて、その分だけ全長を詰める、という素直な処理です。
def crossfade_loop (x, sr, ms = 30.0 ):
"""末尾 ms 分を先頭へ等パワーで畳み込み、その長さだけ全長を詰める。"""
L = int (sr * ms / 1000 )
f = np.linspace( 0 , 1 , L)
mixed = x[:L] * np.sqrt(f) + x[ - L:] * np.sqrt( 1 - f) # 等パワー則
return np.concatenate([mixed, x[L: - L]])
クロスフェード長を変えて ratio を測り直したところ、次のようになりました。
クロスフェード長 ratio 残る全長(サンプル)
5 ms 0.017 440,780
15 ms 0.395 440,339
30 ms 0.354 439,677
60 ms 0.242 438,354
120 ms 0.372 435,708
どの長さでも ratio は 1 を大きく下回りました。長ければ良いという単調な関係ではありません。つまりクロスフェード長は正しさの問題ではなく、音の余韻をどれだけ残したいかという趣味の問題として扱ってよい、ということになります。私はここでずいぶん長いあいだ「最適値」を探していたのですが、探すべきものではなかったのです。
波形で直したのに、配布形式に変えたら壊れました
ここからが、私にとっていちばん意外な部分でした。
クロスフェードを当てた WAV は ratio が 0.354 で合格しています。これをアプリに載せる形式へ変換し、デコードして測り直したところ、こうなりました。
配布形式 判定 ratio サンプル数 元との差
WAV(元) OK 0.354 439,677 —
MP3 192 kbps(LAME) OK 0.198 439,677 ±0
AAC 192 kbps(.m4a) SUSPECT 12.49 440,320 +643
AAC 側でサンプル数が 643 だけ増えていました。元の 439,677 サンプルを 1024 で割ると 381 余ります。増えたあとの 440,320 は 1024 でちょうど 430 回です。つまり、フレーム長の切りの良いところまで無音が足されているのでした。
デコードした末尾を実際に覗くと、最後の四百五十サンプルほどは完全な無音でした。先頭は振幅 0.66 の途中から始まりますから、ループの折り返しで 0 から 0.66 へ一気に跳びます。丁寧に畳み込んだクロスフェードは、その手前で切り離されて誰にも聞かれない位置へ押しやられていたのです。
一方で MP3 は無傷でした。LAME が書き込むギャップレス情報をデコーダが読んで、前後の余白を落としてくれるからです。「圧縮すると音が劣化する」という一般論とは別の軸で、コンテナと符号化器が尺そのものを書き換えるかどうかという問題がここにあります。
検査するのは配布する形式で。マスターの合格は配布物の合格ではありません。 私は原稿の段階で満足して、その先を測っていませんでした。
この取り違えを回避する手立ては一つしか見つかっていません。変換したあとのファイルをもう一度デコードして、まったく同じ検査を通すことです。符号化器の種類ごとに挙動を覚えるより、同じ計測を機械的に二回走らせるほうが、私にとっては確実でした。
同じ性質の落とし穴は、モデルやツールの世代交代でも起こります。手元で通ったはずの構成が別の層で書き換わっていないかを最初に確かめる姿勢については、Gemini のモデル選びで最初に確かめる一行 にも書き残しました。
数値で残ったものだけを Gemini に渡す
ふるいを通しても、SUSPECT は残ります。そこで初めて音声理解の出番になりますが、渡し方を変えました。丸ごとではなく、継ぎ目の前後だけを切り出して、頭とお尻を入れ替えた短い音声 を作って渡します。
つまり「末尾一秒 + 先頭一秒」を連結した二秒の音声です。この二秒の真ん中が、実際の再生でループが折り返す瞬間そのものになります。人が聴くときも同じことをしますし、モデルにとっても探す範囲が限定されるぶん答えやすくなります。
import os, wave
import numpy as np
from google import genai
from google.genai import types
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ]) # export GEMINI_API_KEY=YOUR_API_KEY
def build_seam_clip (x, sr, context_ms = 1000.0 ):
"""再生時に折り返す瞬間を中央へ持ってきた短いクリップを作る。"""
k = int (sr * context_ms / 1000 )
return np.concatenate([x[ - k:], x[:k]]) # 末尾 → 先頭 の順に並べる
def to_wav_bytes (x, sr):
import io
buf = io.BytesIO()
with wave.open(buf, "wb" ) as w:
w.setnchannels( 1 ); w.setsampwidth( 2 ); w.setframerate(sr)
w.writeframes((np.clip(x, - 1 , 1 ) * 32767 ).astype( "<i2" ).tobytes())
return buf.getvalue()
PROMPT = (
"これはループ再生の折り返し部分だけを切り出した音声です。"
"ちょうど中央が折り返しの瞬間にあたります。"
"中央付近に、クリック・プツッという瞬間的な異音が聞こえますか。"
"聞こえる場合は audible=true とし、中央からのおおよその位置をミリ秒で答えてください。"
"音楽的な印象や音質の講評は不要です。"
)
def ask_seam_audible (x, sr, model = "gemini-3.8-flash" ):
clip = build_seam_clip(x, sr)
res = client.models.generate_content(
model = model,
contents = [
types.Part.from_bytes( data = to_wav_bytes(clip, sr), mime_type = "audio/wav" ),
PROMPT ,
],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = {
"type" : "object" ,
"properties" : {
"audible" : { "type" : "boolean" },
"offset_ms" : { "type" : "number" },
"note" : { "type" : "string" },
},
"required" : [ "audible" ],
},
),
)
return res.text # JSON 文字列。呼び出し側で json.loads する
切り出しの効果は、聞き分けやすさだけではありません。音声入力のトークンは秒数に比例します。Gemini API の音声理解ドキュメント で案内されている一秒あたり三十二トークンの換算に当てはめると、十秒の素材を丸ごと渡せば三百二十トークン、継ぎ目の前後一秒ずつなら六十四トークンです。素材が三分あれば、この差はさらに開きます。
出力を JSON スキーマで縛っているのは、最初の失敗の名残です。自由記述にすると、モデルはまた音楽的な講評の側へ戻っていきました。答えの形を先に決めておくと、答えられないときは audible: false として素直に返ってきます。
自動で通した判定をどこまで信じるかについては、抜き取りの設計と一緒に考える必要があります。私は 自動採用された出力こそ抜き取る で整理した考え方を、この音源チェックにもそのまま持ち込みました。
いま素材を差し替えるときの順番
二段構えにしてから、差し替え作業はこの順番に落ち着きました。
マスターの WAV を seamcheck.py に通し、ratio が 1 未満であることを確かめます
SILENT_EDGE が出た素材は、段差ではなく尺と無音長を別途確認します
配布形式へ変換し、デコードし直してもう一度 同じ検査を通します
ここで残った SUSPECT だけ、継ぎ目の前後二秒を Gemini に渡します
モデルが audible: true を返したものだけ、自分の耳で確かめます
三番目の工程が、いちばん見落としやすい場所でした。手順の外に置いておくと、自分でも必ず飛ばしてしまいます。
なお、モデルに白黒を付けさせるのではなく判断材料を出させるという構えについては、画像の側で試したことを 来歴ゲートの記事 に書きました。音でも考え方は同じで、最終的に決めるのは自分だという線を動かさないほうが、結果として運用が軽くなります。
測ってから訊く
もし同じようにループ素材を扱っておられるなら、まず手持ちの音源をひとつだけ seamcheck.py に通して、ratio の値を眺めてみていただければと思います。合格でも不合格でも、その数字が自分の素材の基準線になります。
私自身、二日かけて耳で探したものが十数行の計算で出たことに、少し情けない気持ちになりました。それでも、道具の担当範囲を取り違えていたと気づけたことのほうが、たぶん長く役に立つのだと思います。
最後までお読みくださり、ありがとうございました。