paramatch / app.py
henomoto's picture
Upload folder using huggingface_hub
030b699 verified
"""Paramatch Classification Frontend — HF Spaces Gradio app.
Calls a remote backend (HF Space or URL) via gradio_client for predictions,
renders results as styled HTML cards.
Set the BACKEND_SPACE env var (or HF Spaces secret) to the backend
Space name or URL, e.g. "your-username/paramatch-backend" or "http://localhost:7860".
"""
from __future__ import annotations
import argparse
import os
import subprocess
import tempfile
import gradio as gr
import librosa
from gradio_client import Client, handle_file
MAX_DURATION_SEC = 30
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEFAULT_TOP_K = 3
# ---------------------------------------------------------------------------
# CSS
# ---------------------------------------------------------------------------
CARD_CSS = """\
.result-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-card {
border: 1px solid var(--border-color-primary);
border-radius: 12px;
padding: 12px 16px;
background: var(--background-fill-primary);
transition: box-shadow 0.2s;
}
.result-card:first-child {
border: 2px solid var(--color-accent);
box-shadow: 0 2px 12px var(--color-accent-soft);
}
.result-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.rank-badge {
font-size: 1.2em;
min-width: 28px;
text-align: center;
}
.prob-bar-container {
flex: 1;
height: 6px;
background: var(--background-fill-secondary);
border-radius: 3px;
overflow: hidden;
}
.prob-bar {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.prob-text {
font-size: 0.95em;
font-weight: 600;
min-width: 52px;
text-align: right;
}
.speaker-name {
font-size: 1.1em;
padding-left: 38px;
color: var(--body-text-color);
}"""
RANK_ICONS = ["\U0001f947", "\U0001f948", "\U0001f949"]
# ---------------------------------------------------------------------------
# HTML builder
# ---------------------------------------------------------------------------
def build_result_html(data: dict) -> str:
"""Build styled HTML cards from backend response."""
results = data.get("results", [])
if not results:
return (
'<div style="text-align:center;padding:32px 16px;color:var(--body-text-color-subdued);">'
'<p style="font-size:1.3em;margin-bottom:8px;">音声をアップロードして「分析」を押してください</p>'
'<p style="font-size:0.9em;">対応形式: WAV, MP3, OGG, FLAC など</p>'
"</div>"
)
show_badges = len(results) > 1
cards: list[str] = []
for i, r in enumerate(results):
prob_pct = r.get("probability", 0)
bar_color = "var(--color-accent)"
if show_badges:
rank_icon = (
RANK_ICONS[i]
if i < len(RANK_ICONS)
else f"<span style='font-weight:600;'>{i + 1}</span>"
)
badge_html = f'<span class="rank-badge">{rank_icon}</span>'
name_pad = "padding-left:38px;"
else:
badge_html = '<span class="rank-badge">\U0001f3af</span>'
name_pad = "padding-left:38px;"
card = f"""\
<div class="result-card">
<div class="result-card-header">
{badge_html}
<div class="prob-bar-container">
<div class="prob-bar" style="width:{prob_pct:.1f}%;background:{bar_color};"></div>
</div>
<span class="prob-text" style="color:{bar_color};">{prob_pct:.1f}%</span>
</div>
<div class="speaker-name" style="{name_pad}">{r.get("speaker_name", "Unknown")}</div>
</div>"""
cards.append(card)
return (
f'<style>{CARD_CSS}</style><div class="result-container">{"".join(cards)}</div>'
'<p style="text-align:right;font-size:0.95em;margin-top:8px;">※敬称略</p>'
)
# ---------------------------------------------------------------------------
# Backend client
# ---------------------------------------------------------------------------
backend_space = os.environ.get("BACKEND_SPACE", "")
backend_client: Client | None = None
def get_client() -> Client:
global backend_client
if backend_client is None:
if not backend_space:
raise gr.Error(
"BACKEND_SPACE env var is not set. "
"Set it to the backend Space name or URL."
)
backend_client = Client(backend_space)
return backend_client
def compress_audio(audio_path: str) -> str:
"""Compress audio to 16kHz mono OGG Vorbis to reduce network transfer."""
stem = os.path.splitext(os.path.basename(audio_path))[0]
out_path = os.path.join(tempfile.gettempdir(), f"{stem}.ogg")
subprocess.run(
["ffmpeg", "-y", "-i", audio_path, "-ar", "16000", "-ac", "1",
"-c:a", "libvorbis", "-q:a", "4", out_path],
capture_output=True, check=True,
)
return out_path
def process(audio_file: str | None) -> str:
if audio_file is None:
return build_result_html({})
duration = librosa.get_duration(path=audio_file)
if duration > MAX_DURATION_SEC:
raise gr.Error(
f"音声が長すぎます({duration:.1f}秒)。{MAX_DURATION_SEC}秒以内の音声をアップロードしてください。"
)
try:
compressed = compress_audio(audio_file)
except subprocess.CalledProcessError:
raise gr.Error("音声ファイルの読み込みに失敗しました。別のファイルをお試しください。")
try:
client = get_client()
data = client.predict(handle_file(compressed), DEFAULT_TOP_K, api_name="/classify")
finally:
os.unlink(compressed)
if not data.get("results"):
return (
'<div style="text-align:center;padding:32px 16px;color:var(--body-text-color-subdued);">'
'<p style="font-size:1.3em;margin-bottom:8px;">一致する話者が見つかりませんでした</p>'
'<p style="font-size:0.9em;">別の音声ファイルをお試しください</p>'
"</div>"
)
return build_result_html(data)
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
TITLE_HTML = """\
<h1 style="text-align:center;margin-bottom:4px;">
<ruby>Paramatch<rp>(</rp><rt>ぱらまっち</rt><rp>)</rp></ruby> — 声優推定デモ
</h1>
"""
DESCRIPTION_MD = f"""\
音声ファイルをアップロードすると、話者識別モデル **Paramatch** が声優の方々の声との類似性を分析し、その結果をスコアとして算出します。
### 開発の目的
話者識別モデル **Paramatch** は、実演家の皆様や事務所・権利者の方々が、生成AI等による**無断学習やなりすまし**といった状況の事実確認を行う際の、客観的な判断をサポートするために開発されました。
このデモ版の公開は、表現者のアイデンティティである「声」の権利を守るための有効な対抗策を模索し、実際の不正事例に対する技術の有効性を検証することを主な目的としています。
"""
DISCLAIMER_ACCURACY_MD = f"""\
### 注意事項
- **{MAX_DURATION_SEC}秒以下**の、BGMやノイズ、複数人の会話が含まれない**声のみのクリアな音声**をご使用ください。
- 音声ファイルの**長さが長いほど精度が向上**します。数秒程度の短い音声では、正確な推定が難しい場合があります。
- 表示される声優名やスコアはあくまで参考値であり、**高スコアであっても、該当する声優本人を元とした音声であることを保証するものではありません。**
声質が似ている別の話者が検知されたり、逆に本人であっても状況によりスコアが低くなったり検知できなかったりする場合があります。
- 本モデルは現在研究開発途中であり、認識精度は完全ではありません。このデモの結果は**参考情報としてのみ**ご利用ください。法的判断の根拠等として使用することはできません。
"""
DISCLAIMER_DATA_MD = """\
### データの取り扱いについて
- 学習データは適切な公開ソースから収集したものであり、音声からの情報解析を行う**Paramatchモデルの学習にのみ**使用しています。音声合成等の生成モデルや、弊社の他製品等への転用は行っておりません。
- アップロードされた音声は、このデモでの分析処理のみに使用され、サーバーへの保存や二次利用は一切行われません。
"""
def ping() -> str:
print("Ping received")
return "pong"
def build_app() -> gr.Blocks:
with gr.Blocks(
title="Paramatch — 声優推定デモ",
theme=gr.themes.Ocean(),
delete_cache=(3600, 600),
) as demo:
gr.HTML(TITLE_HTML)
gr.Markdown(DESCRIPTION_MD)
with gr.Row():
with gr.Column(scale=1):
audio_input = gr.Audio(label="入力音声", type="filepath")
submit_btn = gr.Button("分析", variant="primary")
with gr.Column(scale=1):
output_html = gr.HTML(
value=build_result_html({}),
label="推定結果",
)
gr.Markdown(DISCLAIMER_ACCURACY_MD)
gr.Markdown(DISCLAIMER_DATA_MD)
submit_btn.click(
fn=process,
inputs=[audio_input],
outputs=output_html,
api_visibility="private",
)
gr.Textbox(visible=False).change(fn=ping, inputs=[], outputs=[], api_name="ping")
return demo
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Paramatch Classification Frontend")
parser.add_argument(
"--share", action="store_true", help="Create a public Gradio link"
)
parser.add_argument(
"--top-k", type=int, default=DEFAULT_TOP_K, help="Default top-K results"
)
parser.add_argument("--port", type=int, default=None)
args = parser.parse_args()
DEFAULT_TOP_K = args.top_k
app = build_app()
app.queue(default_concurrency_limit=1, max_size=10, api_open=False).launch(
share=args.share,
server_port=args.port,
)