Skip to content

納品前チェックを CI で回す ​

マスターが納品されようとしている。「聴いた感じは良さそう」という段階からプラットフォーム側の受け入れチェックまでのどこかで、この仕様を満たしているかという単純な問いに、誰かが白黒をつけなければなりません。このページはその問いを 1 本のスクリプトに変えます。送信前に人間が手元で実行でき、誰も覚えていなくても CI がコミットのたびに実行してくれるスクリプトです。

この作業の目的はラウドネスをゼロから測り直すことではありません。マスタリングの段階がすでに計測した数値を読み、その段階ではカバーされないフォーマットと破損のチェックを足し、どれか 1 つでも目標を外したらはっきり失敗として知らせることです。

このページで身につくこと ​

このページを読むと、次のことができるようになります。

  • フォーマット・破損・ラウドネス・True Peak を納品仕様と突き合わせるゲートスクリプトを書き、いずれか 1 つでも失敗した瞬間に非ゼロで終了させる。
  • 各チェックのしきい値を、当てずっぽうではなく理由をもって決める。
  • ステレオファイルではラウドネスと True Peak をマスタリングレポートから読むべき理由と、別途 lufs や mastering-streaming を呼ぶとゲートを誤らせる理由を説明できる。
  • このスクリプトをコミットのたびに実行される CI ジョブへ組み込む。

ゲートスクリプト ​

用意するもの

  • インストール: pip install libsonare(Python 3.11 以降)。スクリプトの最後のチェックが Python パッケージを読み込むので、最初の 2 つのコマンドにネイティブの sonare-cli を使う場合(そこでは sonare を sonare-cli に読み替えます)でも必要です。
  • master.wav と report.json: ステレオのマスターと、マスタリングの段階がそれについて書き出した --report。どちらも CLI だけでミックスからマスタリングまで で作れます。このページの数値はすべて、そのページのスクリプトを記載どおりに実行して書き出されるステムから取っています。
  • スクリプト: 下のブロックを gate.sh として保存し、chmod +x gate.sh で実行権限を付けてください。

ページ全体で 3 つの単位が出てきます。LU はラウドネスの差(1 LU = 1 dB)、LUFS は番組全体で測ったラウドネス、dBTP は変換器が保存されたサンプルの間に復元する波形のピーク(True Peak)で、信号を 4 倍にオーバーサンプリングして求めます。詳しくは True Peak と True Peak の安全性 を参照してください。既定値は一般的なストリーミングの目標に合わせてあり、ほかのプラットフォームの要求は 配信ターゲット にあります。

マスタリングの段階がすでに実行済みで、マスター本体とそれが書き出した --report の両方が残っていることを前提にします。その段階については CLI だけでミックスからマスタリングまで を参照してください。

bash
#!/usr/bin/env bash
set -euo pipefail

MASTER="${1:-master.wav}"
REPORT="${2:-report.json}"

WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT

# Delivery contract for this target. Change these, not the checks below.
# Each one can also be set in the environment for a one-off run.
# -14 LUFS is the common streaming playback reference; -1 dBTP leaves room for
# codec overshoot (the table below measures how much).
export GATE_SAMPLE_RATE="${GATE_SAMPLE_RATE:-48000}"
export GATE_TARGET_LUFS="${GATE_TARGET_LUFS:--14.0}"
export GATE_LUFS_TOLERANCE="${GATE_LUFS_TOLERANCE:-0.5}"
export GATE_CEILING_DBTP="${GATE_CEILING_DBTP:--1.0}"
export GATE_MAX_CLICKS_PER_S="${GATE_MAX_CLICKS_PER_S:-0.2}"
export GATE_MAX_CRACKLE_PER_S="${GATE_MAX_CRACKLE_PER_S:-1.0}"

# 1. Format: the file that ships is the file you think it is.
sonare info "$MASTER" --json 2>/dev/null > "$WORKDIR/info.json"

# 2. Damage: --detect writes nothing, so it is free to run on every delivery.
sonare repair "$MASTER" --detect --json 2>/dev/null > "$WORKDIR/defects.json"

python3 - "$MASTER" "$REPORT" "$WORKDIR/info.json" "$WORKDIR/defects.json" <<'PY'
import json
import os
import sys

from libsonare import Audio, mastering_streaming_preview_stereo

master_path, report_path, info_path, defects_path = sys.argv[1:5]
target_lufs = float(os.environ["GATE_TARGET_LUFS"])
tolerance = float(os.environ["GATE_LUFS_TOLERANCE"])
ceiling_dbtp = float(os.environ["GATE_CEILING_DBTP"])
sample_rate = int(os.environ["GATE_SAMPLE_RATE"])
max_clicks = float(os.environ["GATE_MAX_CLICKS_PER_S"])
max_crackle = float(os.environ["GATE_MAX_CRACKLE_PER_S"])

failures = []

info = json.load(open(info_path))
if info["channels"] != 2:
    failures.append(f"channels = {info['channels']} (expected 2)")
if info["sample_rate"] != sample_rate:
    failures.append(f"sample_rate = {info['sample_rate']} (expected {sample_rate})")
if info["duration"] < 1.0:
    failures.append(f"duration = {info['duration']:.3f}s, file looks truncated")

defects = json.load(open(defects_path))["defects"]
if not defects.get("measured"):
    # 短すぎて測定できないファイルは、他のフィールドを一切返さない。
    failures.append("damage not measured (file too short for the detectors)")
else:
    if defects["clip_sample_fraction"] > 0.0:
        failures.append(f"clipping: {defects['clip_sample_fraction'] * 100:.3f}% of samples")
    if defects["click_per_second"] > max_clicks:
        failures.append(f"clicks: {defects['click_per_second']:.2f}/s")
    if defects["crackle_per_second"] > max_crackle:
        failures.append(f"crackle: {defects['crackle_per_second']:.2f}/s")
    if defects["hum_peak_found"] and defects["hum_fundamental_prominence"] > 6.0:
        failures.append(
            f"mains hum at {defects['hum_fundamental_hz']:.0f} Hz, "
            f"prominence {defects['hum_fundamental_prominence']:.1f}"
        )

# Loudness and true peak come from the mastering report -- the stereo
# measurement the chain itself took -- not from a separate `lufs` call.
after = json.load(open(report_path))["after"]
lufs_gap = after["integrated_lufs"] - target_lufs
if abs(lufs_gap) > tolerance:
    failures.append(
        f"integrated_lufs = {after['integrated_lufs']:.2f} "
        f"({lufs_gap:+.2f} LU from {target_lufs} target)"
    )
if after["true_peak_dbtp"] > ceiling_dbtp:
    failures.append(f"true_peak_dbtp = {after['true_peak_dbtp']:.2f} (ceiling {ceiling_dbtp})")

# The report must describe this file. Re-measure the shipped stereo pair; a
# mismatch means report.json came from another render, or the file changed after it.
if info["channels"] == 2:
    pair = []
    for i in range(2):
        with Audio.from_file_channel(master_path, i) as audio:
            pair.append(audio.data)
    spec = [{"name": "spec", "targetLufs": target_lufs, "ceilingDb": ceiling_dbtp}]
    shipped = json.loads(mastering_streaming_preview_stereo(*pair, sample_rate, spec))["platforms"][0]
    if (abs(shipped["integratedLufs"] - after["integrated_lufs"]) > 0.1
            or abs(shipped["truePeakDb"] - after["true_peak_dbtp"]) > 0.1):
        failures.append(
            f"report does not match the file: file measures {shipped['integratedLufs']:.2f} LUFS, "
            f"{shipped['truePeakDb']:.2f} dBTP"
        )

if failures:
    print("DELIVERY GATE: FAIL")
    for line in failures:
        print(f"  - {line}")
    sys.exit(1)

print("DELIVERY GATE: PASS")
print(
    f"  {info['sample_rate']} Hz, {info['channels']}ch, "
    f"{info['duration']:.2f}s, {after['integrated_lufs']:.2f} LUFS, "
    f"{after['true_peak_dbtp']:.2f} dBTP"
)
PY
納品の合否を判定する
yesnomaster.wavreport.jsonsonare inforepair --detectラウドネス +True Peakすべて合格?exit 0 -- 出荷exit 1, 失敗理由を出力
フォーマットと破損はマスターファイル自身から。ラウドネスと True Peak は別途 lufs を呼ぶのではなく、ステレオのマスタリングレポートから取ります。

ミックスの手順で作ったマスターに対して実行します。

bash
./gate.sh master.wav report.json
text
DELIVERY GATE: FAIL
  - clicks: 2.60/s

一覧にラウドネス、True Peak、クラックルはなく、落ちたのはクリックのチェックです。これはマスターのレベルではなく、テスト素材の性質によるものです。テスト用のベースは素の矩形波で、切り替わるたびに 1 サンプルで値が跳ぶため、ボーカルを重ねると検出器がクリックと読みます。

テスト用マスターが 1 秒あたり 2.60 回と読まれる理由

テスト用のベースは np.sign で作った素の矩形波で、切り替わるたびに 1 サンプルで値が跳びます。ベースのステム単体では、検出器はこの段差を規則的な波形の一部とみなして除外するので、クリックは 0 回です。ボーカルを重ねると、一部の段差が規則的に見えなくなってクリックの判定を通ります。その結果、ミックスダウンで 29 回(1 秒あたり 2.52 回)、マスターで 30 回(1 秒あたり 2.60 回)となりました。ベースとボーカルのステムだけを足しても同じことが起き、クリックは 25 回で、デクリッカーが修復した位置はどれもベースの段差の上にあります。実際の録音でクリックが出たら耳で確かめる価値がありますが、ここではステムを作るスクリプトの手抜きが表に出ているだけです。

既定値の 0.2 は通常の楽曲向けなので(後述)、スクリプトを書き換えずに、この 1 回の実行だけ環境変数で上書きします。

bash
GATE_MAX_CLICKS_PER_S=3 ./gate.sh master.wav report.json
text
DELIVERY GATE: PASS
  48000 Hz, 2ch, 11.52s, -14.05 LUFS, -1.00 dBTP

-1.00 は偶然ではありません。マスタリングレポートの True Peak は、リミッター自身の 4 倍オーバーサンプリング(BS.1770)の検出器で測られ、リミッターはその測り方でマスターをシーリングに保ちます。このレポートは -1.0000004 で、マスターは -1.0 のシーリングの下ではなく、ちょうどそこにいます。そのためレポートの値だけでは不足は現れません。不足が見えるのは、マスターを作ったときより低いシーリングと比べたときや、別のメーターがファイルを読んだときです。同じマスターを -1.2 のシーリングに対して実行します。

bash
GATE_MAX_CLICKS_PER_S=3 GATE_CEILING_DBTP=-1.2 ./gate.sh master.wav report.json
text
DELIVERY GATE: FAIL
  - true_peak_dbtp = -1.00 (ceiling -1.2)

-1.00 は -1.2 より大きい、つまりより大きな音です。2 つの数値を目で見比べると逆に読みたくなりますが、ゲートは符号を正しく扱っています。直す場所はこのスクリプトではなくマスタリングの段階です。--ceiling-db -1.2 で再実行すれば、同じゲートが -1.20 dBTP(-14.06 LUFS)で通ります。

シーリングの決め方 ​

納品後にエンコードされるファイルは -2 dBTP でマスタリングします。レポートが正確なのに、なぜ仕様より低くマスタリングするのか。仕様はリミッターの測り方で満たされているだけで、ファイルは別のメーターにも読まれるからです。次の表は、同じミックスを 3 つのシーリングでマスタリングし、それぞれをレポート、出荷ファイルへの 16 倍メーター、非可逆エンコーダーを往復させた後の 16 倍メーターで読んだものです(すべて dBTP、悪い側のチャンネル)。

--ceiling-dbレポート(4 倍)ファイル、16 倍AAC 256 kbit/s、16 倍MP3 320 kbit/s、16 倍
-1.0(既定)-1.000-0.994-0.464-0.473
-1.2-1.200-1.194-1.103-0.707
-2.0-2.000-1.994-1.150-1.461

-1.0 の仕様に対して読みます。より細かいメーターはこのファイルを 0.006 dB 高く読むので、既定のシーリングのマスターは 16 倍のチェッカーでは -0.994 > -1.0 で不合格になり、-1.2 のマスターは余裕をもって通ります。非可逆エンコードはピークをさらに大きく動かし、既定のマスターでは AAC でも MP3 でも約 0.5 dB 上がります。-1.2 でも足りず、MP3 のデコード結果は -1.0 を超えます(-0.707。AAC は -1.103 で収まります)。両方を -1.0 の下に収めたのは 配信ターゲット にある -2 dBTP のコーデック安全寄りのシーリングだけ(-1.150 と -1.461)で、これでマスタリングし、GATE_CEILING_DBTP もそれに合わせてください。1 つの素材と ffmpeg 内蔵の AAC および LAME エンコーダーでの値で、別のエンコーダーなら読みも変わるため、実際の配信経路で測ってください。表のコマンドは次のとおりです。

bash
ffmpeg -i master.wav -c:a aac -b:a 256k master.m4a && ffmpeg -i master.m4a master-aac.wav
ffmpeg -i master.wav -c:a libmp3lame -b:a 320k master.mp3 && ffmpeg -i master.mp3 master-mp3.wav
python
import libsonare as sonare
from libsonare import Audio


def true_peak_db(path, oversample):
    peaks = []
    for i in range(Audio.file_channel_count(path)):
        with Audio.from_file_channel(path, i) as audio:
            peaks.append(sonare.metering_true_peak_db(audio.data, audio.sample_rate, oversample))
    return max(peaks)


print(true_peak_db("master.wav", 16))  # -0.9942

やってはいけないのは、このファイルがたまたま通るところまでゲートのシーリングを緩めることです。

各チェックの中身としきい値の決め方 ​

フォーマット: sonare info ​

bash
sonare info master.wav --json
json
{"path": "master.wav", "duration": 11.5219375, "sample_rate": 48000,
 "channels": 2, "samples": 553053, "peak_db": -1.071, "rms_db": -19.45}

channels と sample_rate は最も安く済むチェックです。壊れたパイプライン段から出てきたレンダリングを、音そのものを表す数値に時間をかける前に捕まえます。channels はこのページ全体がステレオ納品を前提にしているため 2 に固定し、GATE_SAMPLE_RATE はプロジェクトごとに本当に変わる部分なので変数にしてあります。duration < 1.0 は途中で切れたファイルを捕まえます。空のまま終わった、あるいは途中で止まったレンダリングはパイプラインの手前側のほとんどのツールでは exit 0 のまま通ってしまうため、これがそれに気づく唯一のチェックになることも珍しくありません。

破損: repair --detect ​

bash
sonare repair master.wav --detect --json
typescript
import { readFileSync } from 'node:fs';
import { decodeChannels, init, masteringRepairAnalyze } from '@libraz/libsonare';

await init();
// マスターの各チャンネルを、まとめずに別々に計測する
const { sampleRate, channels } = decodeChannels(new Uint8Array(readFileSync('master.wav')));
const { defects } = masteringRepairAnalyze({ channels, sampleRate });
if (!defects.measured) throw new Error('damage not measured');
console.log(defects.clipSampleFraction, defects.clickPerSecond, defects.cracklePerSecond, defects.humFundamentalProminence);
python
import libsonare as sonare
from libsonare import Audio

# マスターの各チャンネルを、まとめずに別々に計測する
channels = []
for i in range(Audio.file_channel_count("master.wav")):
    with Audio.from_file_channel("master.wav", i) as audio:
        channels.append(audio.data)
        sample_rate = audio.sample_rate

defects = sonare.mastering_repair_analyze(channels, sample_rate)["defects"]
if not defects["measured"]:
    raise SystemExit("damage not measured")
print(defects["clipSampleFraction"], defects["clickPerSecond"], defects["cracklePerSecond"], defects["humFundamentalProminence"])
json
{"mode": "detect", "defects": {
  "measured": true,
  "clip_sample_fraction": 0.0, "clip_run_count": 0,
  "click_count": 30, "click_per_second": 2.60,
  "crackle_per_second": 0.0, "noise_floor_dbfs": -120.0,
  "hum_peak_found": false, "hum_fundamental_hz": 51.75,
  "hum_fundamental_prominence": 1.0
}}

--detect は計測するだけで何も書き出さないので、コミットのたびに実行してもコストがかかりません。短すぎて検出器にかけられないファイルは {"measured": false} だけを返します。ゲートはこれを問題なしではなく不合格として扱います。ライブラリの呼び出しは同じ計測値を camelCase の名前(clipSampleFraction、clickPerSecond)で返します。hum_peak_found は CLI の出力にだけ存在し、突出度が 1.0 を超えたことを意味します。clip_sample_fraction のしきい値は 0.0 にします。仕上がったマスターがクリップすることは本来ないはずで、それを防ぐのがリミッターの仕事そのものだからです。click_per_second と crackle_per_second には余裕が必要です。密度の高い素材では検出器がゼロではない基準値を返すことがあるからです。既定の 0.2 と 1.0 は通常の楽曲向けの出発点であって、破損のないマスターの実測値ではありません。4 本の合成ステムを足したマスターは、破損がないのに 1 秒あたり 2.60 回のクリックを記録します(どのステムも単体ではクリックしません)。だからこそ上のウォークスルーではこの 1 回だけクリックの上限を引き上げました。クラックルは 0.00 で、上書きは要りません。ジャンルの近い良品マスターを何本か測って調整し、変更がレビューで見えるようにコントラクトのブロックに置いてください。

hum_peak_found はそれ単体では真偽値にすぎず、ノイズフロア以下の商用電源ハムはよくあることで、聞こえもしません。ゲートはその有無だけで落とすべきではありません。それを実用的なチェックに変えるのが hum_fundamental_prominence です。これは dB ではなく単純な比で、最も強いハム候補のエネルギーを、試したすべての候補の中央値で割ったものです。1.0 はピークがまったくないことを表します。このマスターは hum_peak_found: false、突出度 1.0 です。キックの低域エネルギーは定常ではないため、ハム候補として挙がりません。一方、録音素材をまとめて整音する の破損したテイクは 50 Hz で 24.0 を記録しています。ハムのない単体のステムでも候補が出ることはあります(テスト用ベースは 2.18、ギターは 2.15)。6.0 はそれらと本物のハムの中間に置いてあり、弱い候補を無視しつつ聞こえるハムは捕まえるだけの高さです。これは突出度が 10 を超えたときだけ dehum を実行するリペアアシスタントより厳しい基準です(録音素材をまとめて整音する を参照)。突出度が 6 から 10 の間のテイクはこのゲートでは不合格になり、アシスタントは手を付けません。

repair --detect が測るのはステレオマスターをモノラルにまとめたもの

CLI はダメージを調べる前に master.wav をモノラルにまとめ、その旨を標準エラー出力に出します。片方のチャンネルだけのクリックやクリップは、まとめたときに小さくなったり打ち消されたりするので、CLI のレポートがきれいでも各チャンネルがきれいだとは言えません。チャンネルごとに調べるには、上の Node か Python のタブにあるライブラリの呼び出しを両チャンネルに対して実行してください。masteringRepairAnalyze と mastering_repair_analyze は渡されたすべてのチャンネルを計測します。

ラウドネスと True Peak: マスタリングレポート ​

json
{"before": {"integrated_lufs": -20.49, "true_peak_dbtp": -2.10},
 "after":  {"integrated_lufs": -14.05, "true_peak_dbtp": -1.00}}

どちらの数値も、マスタリング段階が書き出した --report の after から取ります。チェーン自身がステレオペア全体に対して行った計測です。GATE_LUFS_TOLERANCE は納品仕様が目標をどれだけ厳しく定めているかに合わせます。±0.5 LU は、EBU R128 がファイルベースの放送番組に認めている許容幅です(生放送の番組は ±1 LU)。ストリーミングのプラットフォームは許容幅をそもそも公表していません。再生時に正規化して大きすぎるマスターを下げるので、ストリーミング向けでは許容幅は自分たちの一貫性のための決まりで、シーリングのほうが重要です。GATE_CEILING_DBTP はマスタリングの段階自身に指示したシーリングと同じ値にします。アシスタントはその値をすでに絶対の制約として扱っているので、レポートがそれを超えているなら、プラットフォームが何であれ上流の何かが自分自身の設定を守れなかったということであり、はっきり失敗として知らせる価値があります。

最後のチェックは、レポートがこのファイルのものかを確かめます。report.json を読むだけのゲートでは、以前のレンダリングの古いレポートや、マスタリング後にレベルや True Peak が変わったマスター(たとえばリサンプリングでピークが動いたもの)でも通ってしまいます。スクリプトは納品するステレオペアを mastering_streaming_preview_stereo で測り直し、ラウドネスか True Peak がレポートと 0.1 dB より大きく違えば不合格にします。書き出したばかりの 16 ビットのファイルなら、自分のレポートとの差はそれよりはるかに小さくなります。このチェックはディザー(-96 dBFS 付近のノイズ)や無音のトリムを検出しません。どちらも積分ラウドネス(無音は BS.1770 のゲートで除外されます)にも True Peak にも 0.1 dB 動かすほどの影響を与えないからです。それが問題になるなら、内容のハッシュを比較してください。

同じファイルに対して sonare info は peak_db: -1.071 を返します(先にモノラルへダウンミックスし、警告を出します)。右チャンネルで保存されている最大のサンプルは -1.057 ですが、そのチャンネルの True Peak は -1.000 です。コンバーターが保存サンプルの間を再構成した波形は、どの保存サンプルよりも高くなります。下のデモが描いているのはこの現象です。ゲートが info の peak_db ではなくレポートの true_peak_dbtp を読むのは、このためです。

LOUDNESS · TRUE PEAKIDLE
サンプル間ピーク — マスターが再生時にクリップする理由

ドットは正弦波のサンプル、曲線はその解析的な波形です。山の頂点がサンプル間に来ると、保存された値より高いピークが生じます。その差は周波数と位相で変わり、ナイキスト周波数に近づくほど単調に増えるわけではありません。解析的なピークと、オーバーサンプリングしたメーターの推定値を比較してください。サンプルピークを 0 dBFS に近づけると、サンプル間の超過が生じ、ヘッドルームの足りないコンバーターや後段の処理でクリップする可能性があります。

サンプルピーク
-0.3 dBFS
周波数(×ナイキスト)
0.4

report.json に loudness_target_limited: true と大きな負の値の max_gain_reduction_db(たとえば -6 dB より小さい値)が並んでいる場合は、マスターが目標ラウドネスに届く前にミックスのヘッドルームが尽きたということです。これはしきい値の調整では直せないミックス側の問題です。CLI だけでミックスからマスタリングまで の「どの数値を信じるか」を参照してください。

ステレオファイルでは lufs や mastering-streaming からラウドネスや True Peak を読んではいけません

どちらのコマンドも計測の前にステレオ入力をモノラルへダウンミックスし、そうしたことを標準エラー出力に警告として出します。当てはめられる普遍的な補正値はありません。m = (L + R) / 2 とすると、左右が同一ならモノラル合算はステレオのチャンネルパワーの読みより 3.01 LU 低く、同じパワーで無相関のチャンネルなら 6.02 LU 低くなり、逆極性では打ち消し合います。これは信号に依存する例なので、実際のステレオ素材はレポートまたはステレオ API で比較してください。ITU-R BS.1770 はチャンネルのパワーを合算するため、ダウンミックスで合否が逆転することがあります。

bash
sonare lufs master.wav --json
# warning: 2-channel input is downmixed to mono by this CLI command
# {"integrated_lufs": -17.37, ...}          <- モノラルに畳んだ値
# report.json after.integrated_lufs = -14.05 <- ステレオのマスター

同じダウンミックスが Spotify 向けのプレビューも歪めます。

bash
sonare mastering-streaming master.wav --json
json
{"platforms": [
  {"name": "Spotify", "integrated_lufs": -17.37, "normalization_gain_db": 3.37,
   "true_peak_db": -1.07, "ceiling_risk": true},
  /* Apple Music, YouTube -- same integrated_lufs, each platform's own gain and risk */
]}

上の数値は 1 つの素材についての例であり、補正係数ではありません。そのゲインをモノラルに畳んだ後のピーク値に適用すると +2.30 dBTP(-1.07 + 3.37)まで達し、-1.0 dBTP のシーリングを 3.30 dB も超える計算になり、深刻な失敗に見えます。同じ計算をレポートの本当のステレオの値(-14.05 LUFS、-1.00 dBTP)でやり直すと、必要なゲインは +0.05 dB ほどまで下がり、予測されるピークは -0.95 dBTP になります。それでもシーリングは超えていますが、超過は 3.30 ではなく 0.05 dB です。ダウンミックスがこの問題を作り出しているわけではなく、小さくて本物の超過を約 70 倍ひどく見せているだけです。納品物がモノラルならダウンミックス分を補正する必要はなく、lufs や mastering-streaming はファイルをそのまま読みます。次の take-clean.wav(録音素材をまとめて整音する の修復済みモノラルテイク)を見てください。

bash
sonare lufs take-clean.wav --json
# {"integrated_lufs": -13.7335, ...}
sonare mastering-profile take-clean.wav --json
# {"loudness": {"integrated_lufs": -13.7335, ...}, ...}

mastering-streaming を直接使うのは、納品物がモノラルのときか、クロスチェックとしてです。マスタリングレポート以外の場面でステレオのまま正確に測りたいときは、Python API のステレオ用エントリポイントを使ってください。mastering_streaming_preview_stereo などのステレオ用関数はダウンミックスではなく両チャンネルを測ります。

左右がどれだけ似ているかで、モノラルへの畳み込みによる損失は変わります。デモでは、同じサイン波の位相差を広げていくと平均が小さくなり、180° で打ち消されます。

STEREO · MONO FOLDIDLE
モノフォールド — ステレオ幅が打ち消されるとき

左と右に同じサイン波を置き、位相差を 0° から 180° まで動かします。モノフォールドは左右の平均です。90° では左チャンネル比 −3.01 dB、180° では打ち消し、相関は +1、0、−1 と連続して変化します。左の基準音とモノフォールドを切り替えて、レベル差を聴き比べられます。

位相差
90 °
試聴

mastering-streaming は組み込みの 3 プラットフォームの代わりに、自分の納品仕様を渡すこともできます。ここでの platforms.json の中身は次のとおりです。

json
[{"name": "Spotify", "targetLufs": -14, "ceilingDb": -1},
 {"name": "Apple Music", "targetLufs": -16, "ceilingDb": -1}]
bash
sonare mastering-streaming master.wav --platforms-file platforms.json --json
json
{"platforms": [
  {"name": "Spotify", "integrated_lufs": -17.37, "normalization_gain_db": 3.37, "true_peak_db": -1.07, "ceiling_risk": true},
  {"name": "Apple Music", "integrated_lufs": -17.37, "normalization_gain_db": 1.37, "true_peak_db": -1.07, "ceiling_risk": true}
]}

--platforms はファイルではなく同じ配列をその場で渡す形です。組み込みの 3 種類が納品先と一致していると決めつけず、モノラルのファイルで、あるいはステレオなら mastering_streaming_preview_stereo を通して、実際の納品契約の数値をどちらかに渡してください。

納品物にプロジェクトドキュメントが含まれる場合 ​

ステムや DAW 間の受け渡しを伴う納品では、音声と一緒にプロジェクトファイルを渡すことがよくあります。sonare project validate は、読み込みには成功するものの、渡した先のツールが情報を黙って落としてしまうような診断を抱えたドキュメントを捕まえます。

bash
sonare project validate --in project.json --strict --json
json
{"valid": true, "bytes": 518, "diagnostic_count": 1,
 "diagnostics": ["dropped_automation_lane_target_id: track 1 carried an automation lane with target id 0; lane dropped"]}

--strict を付けなければこれは exit 0 で終わります。診断が 1 件あるだけではドキュメント全体を落とす理由にはならず、ローダーは問題の断片だけを外して回復するからです。--strict はその同じ回復を納品の不合格(exit 9、無効な状態)に変えます。オートメーションレーンが 1 本黙って消えることが、その納品物にとって許容できないなら、これが正しい選択です。

CI で実行する ​

yaml
name: delivery-gate
on: [push]
jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install libsonare
      - run: ./gate.sh master.wav report.json

ジョブはリポジトリをチェックアウトするので、gate.sh、master.wav、report.json をリポジトリにコミットしておく必要があります。gate.sh は実行権限付きで(git add の前に chmod +x gate.sh)コミットしてください。actions/setup-python は pip install が書き込める Python をジョブに用意します。ランナーのシステム Python はグローバルなインストールを拒否することがあります。

このスクリプトが呼ぶコマンドはすべて --json を受け付け、失敗はすべて素の 1 ではなく規定の終了コードで返ります。冒頭の set -euo pipefail があるからこそ、それが「実際に壊れた段階でジョブが失敗する」という挙動に変わります。SONARE_LEGACY_EXIT=1 は、パイプラインの上流にまだ古い「失敗はすべて 1」という前提を決め打ちしている呼び出し側がいる場合のためのものです。このゲートスクリプト自身は特定の終了コードで分岐しているわけではなく自前のしきい値で判定しているので、これを必要としません。

次に読むもの ​

  • ゲートが落ちている理由が納品契約ではなくマスター自体にある場合は CLI だけでミックスからマスタリングまで に戻ってください。
  • 同じ録音で破損チェックが何度も引っかかる場合は 録音素材をまとめて整音する は、納品ファイル側ではなくソース側で直す方法を扱っています。
  • このロジックをシェルスクリプトではなくアプリに組み込みたい場合は、同じ計測が Python API にもあり、CLI からは使えないステレオ用エントリポイントも含まれています。