Open28

音声の局所的編集について

AckkermanAckkerman

NotebookLMから出力された音声を聴くと、読み間違い(特に漢字に多い)が気になってしまう。
一度の生成にかかる時間は数分。インストラクションに読み方を書いてもうまく反映されず、消しても消してもモグラ叩きのように修正箇所は位置を変えるばかりで一向に埒が明かない始末だった。
自己学習用途ではこのクオリティで十分だが、外部に公開できるクオリティにするには特定箇所を直接編集できるような仕組みが必要にだと強く感じた。

AckkermanAckkerman

音声編集技術というのは現状どの程度実現されているのだろうか?先行研究や取り組みを調査する。

調査に当たってはPerplexity, Grok, Gemini, ChatGPTのDeepResearchを用いた。

AckkermanAckkerman

Prompt:

NotebookLMから出力された音声を聴くと、読み間違い(特に漢字に多い)が気になってしまう。
一度の生成にかかる時間は数分。インストラクションに読み方を書いてもうまく反映されず、消しても消してもモグラ叩きのように修正箇所は位置を変えるばかりで一向に埒が明かない始末だった。
自己学習用途ではこのクオリティで十分だが、外部に公開できるクオリティにするには特定箇所を直接編集できるような仕組みが必要にだと強く感じた。

音声編集技術というのは現状どの程度実現されているのだろうか?

AIから生成された音声、つまりWavファイルやMp4ファイルなどに対して直接編集(局所的な編集)を行う技術について
1. 現在どこまで研究が進んでいるのか
2. 実装はどこまで進んでいるのか
3. 何ができて何ができていないのか
を明らかにしつつ、調査レポートをまとめなさい。

なお、調査時には以下の観点を含めなさい。
- 多言語対応を前提としつつ、日本語における現状にも焦点を当てる
- 商用利用を想定し、研究段階・実装状況・技術的限界を網羅する
- 編集対象はナレーションおよび会話で、歌唱音声は除外する
AckkermanAckkerman

1. 研究最前線:主要手法 × レポート言及状況

手法 / モデル 研究段階 Perplexity Gemini ChatGPT 日本語対応の言及 主な強み 主な課題
VoiceCraft 2024 公開OSS ✔︎ △(触れのみ) ✔︎ ×(英語のみ) ゼロショット話者編集・OSSで試せる 多言語は未学習、GPU必須
FluentEditor 2 2023 研究 ✔︎ × × 韻律を壊さず部分置換 実装コード非公開
EdiTTS 2022 研究 × × ✔︎ ◎(日本語適用報告) Score-based編集、改造しやすい 感情音声で不安定
DiffVoice 2023 研究 ✔︎ ✔︎ × 潜在拡散で高自然度 生成遅い、日本語未
AudioLM / SoundStorm 2022– ✔︎ × 音声継続生成・高速 内容制御が弱い
Voicebox (Meta) 2023 非公開 ✔︎ ✔︎ × 6 言語で万能編集 モデル非公開
EditSpeech / CampNet 2021–22 ✔︎ × ✔︎ 軽量AR系、文脈融和 話者汎用性が低い

✔︎ = 詳述  = 触れのみ × = 未言及/未対応


2. 商用ツール:実装レベルと日本語対応

ツール / サービス 単語置換 音素編集 日本語クローン API / 自動化 主要レポート
Descript Overdub × × Perplexity / Gemini / ChatGPT
Resemble AI Edit Perplexity / Gemini / ChatGPT
ElevenLabs ○(再合成) Gemini / ChatGPT
A.I.VOICE Editor ○(辞書) △(抑揚調整) × Perplexity
CoeFont ○(再合成) Perplexity
Vozo AI Perplexity
Adobe Podcast / Premiere Pro × × Gemini
Cleanvoice AI / Auphonic ノイズ除去特化 Gemini

完全対応  部分対応(再合成で代替等)  研究レベル or α版  未対応


3. 編集粒度ごとの「できる / まだ難しい」

粒度 現在 広く実装 限定的に実装 研究段階
ブロック(文〜段落) ほぼ全商用TTSでOK(全文再合成)
単語 Descript, Resemble, Vozo など ElevenLabs(API組み込み) 一部拡散モデル
音素 / アクセント 辞書補正(A.I.VOICE) EdiTTS, DiffVoice, Voicebox
感情・スタイル 基本的なスピード/トーン ElevenLabs emotion, CoeFont声色 精密制御 (CampNet等)
リアルタイム 低遅延TTS(ElevenLabs v2) 研究プロト (SoundStorm)

4. 将来ロードマップ(編集視点)

期間 技術トレンド 期待される実用化
短期 (〜2026) 単語置換の精度向上、日本語アクセント自動推定 Resemble / ElevenLabs が日本語 Overdub を正式提供
中期 (2027–2029) 音素レベル編集の商用化、Few‑shot 高品質クローン VoiceCraft‑JP や DiffVoice‑JP が SaaS 化
長期 (2030〜) リアルタイム対話中のオンザフライ修正、言語間スタイル保持 会議 / メタバースでライブ音声を即時書き換え

使い分けガイド(日本語ナレーション前提)

  1. 即戦力で一箇所だけ直したい
    Resemble AI Edit(Web UI or API)
  2. 大量スクリプトを前処理から自動化したい
    ElevenLabs + 自前スクリプトで全文再合成 → 差分を貼替
  3. 発音辞書を細かく管理したい
    A.I.VOICE Editor(ローカル / SSML 自動生成)
  4. 最先端モデルを試して将来に備えたい
    VoiceCraft を Colab で動かし日本語コーパスで再学習
  5. アクセントを含む細粒度編集を研究開発
    EdiTTS / DiffVoice を日本語音素系に合わせてファインチューニング

要点まとめ

  • 編集粒度は “文 → 単語 → 音素” へと進化中
  • 日本語対応は単語置換までは商用で可能、音素編集は研究段階。
  • 現場の最適解Resemble AI(多言語)+辞書/SSML整形で運用。
  • R&D 視点では VoiceCraft‑JP の誕生がブレイクポイントになる見込み。
AckkermanAckkerman

Descriptが特定ワードの除去機能を備えてるよう。

AckkermanAckkerman
  • Resemble AI(Edit機能)
    • これこれ!まさにこれがやりたいこと。
    • 日本語読み上げはElevenLabsと比べると遥かに流暢。ただ音声編集の日本語における有効性は現時点では未知数
AckkermanAckkerman

Resembleでは日本語を含む様々な言語の音声合成をサポートしており、アクセントや言語切替も設定できます。そのため、例えば日本語ナレーションの録音で一部を修正したい場合でも、Resemble Editなら対応できる可能性があります(要:十分な日本語クローン音声の品質確保)。(ChatGPT DeepResearch結果より)

対応言語一覧

AckkermanAckkerman

Resemble AIが実現したいことを(恐らく)実現できているもよう。
利用価格を確認する。

AckkermanAckkerman

リバースエンジニアリングを試みる

Resemble AI「Edit」──内部で走っていそうな処理フロー(推定)

# 処理ブロック 主なアルゴリズム候補 目的 / 出力 技術的ヒント
1 アップロード & 解析 - 高精度 ASR(Whisper‑large v3 相当 or 自社 CTC モデル)
- Forced Alignment で単語位置決定(CTC‑Segmentation や Rev AI API 相当)
① 秒単位の音声
② 単語ごとのタイムスタンプ付き転写テキスト
Resemble の UI は「自動で文字起こし→ハイライト編集」構造 (Resemble AI)
2 Voice Clone 準備 - 既存の Custom Voice embedding をロード
- 無ければ数十秒録音→x‑vector / speaker‑embedding を即時推論
話者固有の 256〜512 dim の埋め込みベクトル Resemble の Custom Voice API が“build voices instantly”と明記 (Resemble AI)
3 編集検出 - フロントエンドでテキスト差分(diff-match‑patch)
- 置換・挿入・削除ごとに エディットチャンク生成
(start, end, new_text) リスト UI は「ハイライト → タイプ → 即再生」方式 (Resemble AI)
4 言語前処理 - Text‑Normalization(数字→読み等)
- G2P + アクセント推定
- SSML 拡張(強弱・ポーズタグ)
発音記号 & 韻律パラメータ列 日本語では JTS/OpenJTalk 系、英語では CMUdict + Prosody
5 TTS 再合成 - 非自己回帰 FastSpeech2 or VITS 派生 + Speaker Embedding
- 低遅延モード(>2×RT)
- 生成単位はセンテンス or 句 + 前後0.2 s バッファ
新しい波形チャンク Resemble は「極低レイテンシ」謳う (Resemble AI)
6 ポストプロセス 1. Loudness match(RMS/LUFS 揃え)
2. EQ カーブ近似
3. クロスフェード or 時間伸縮で滑らか接続
4. 最終全体ノイズ除去(RNNoise / Demucs)
完整合成波形 ブログ記事で「自然に溶け込む」と強調 (Resemble AI)
7 プレビュー & 書き出し - WASM ベース AudioBuffer で即再生
- 非破壊編集のまま JSON/EDL 保存
- エクスポート時は FFmpeg で WAV/MP3/MP4 コンテナ化
ユーザ確認・ダウンロード 編集結果は数秒でプレビュー可能との記載 (Resemble AI)
AckkermanAckkerman

処理を支える鍵技術(推定詳細)

技術ピース なぜ必要か 具体的実装イメージ
Forced Alignment 元音声と転写テキストを「単語境界」で正確に同期させ、差し替え開始点/終了点を決めるため torchaudio CTC‑Segmentation or Wav2Vec2 CTC forced_alignment (PyTorch)
差分バッファ生成 置換箇所単体だと前後とのピッチ・息継ぎがずれるため、±200 ms 程度余白を含め再生成 splice_start = edit_start ‑ 0.2 s etc.
Speaker Embedding + Prosody Transfer 新規生成部分を元の声質・トーンに合わせる x‑vector → prosody encoder (pitch/energy) → conditional decoder
Waveform Stitching 長さが変わるとタイミングがずれるため、TD‑PSOLA 的 time‑scale‑modification かフェードで調整 crossfade = 50 ms・cos curve
低レイテンシ推論 “タイプ後すぐ再生” UX を実現 on‑device ONNX ORT / TensorRT 最適化、バッチなし
キャッシュ & バージョン管理 同じ単語を何度も再生成せず高速化 / 取り消し可能に (voice_id, text_hash) → wav cache DB
AckkermanAckkerman

従来の類似プロダクト(Descript Overdub)は ASR→diff→TTS の3段構えで実装されており、市場要件から見ても同系統である可能性が高い。

AckkermanAckkerman

「aligner」=Forced Alignment(強制アラインメント)サービス

観点 内容
何をする? ① 音声ファイルと ② その文字起こし(テキスト) を入力すると、テキスト中の 各単語・フレーズが波形のどこに現れるか
開始秒 / 終了秒 で割り出す。
なぜ必要? 🔧 差し替える区間をピンポイントで切り出す ため。
録音済みナレーションの一単語だけを AI で置き換える場合、
「0.53 s〜0.88 s が こんにちは の発話」
と分からないと波形を綺麗にスプライスできない。
どうやって? - 背後で ASR 音響モデル(例:Wav2Vec2)を使い、音声をフレームごとの音素確率列に変換
- CTC Segmentation(または Montreal Forced Aligner 方式)で、
 テキスト列が確率列に最も自然に埋め込まれる位置を動的計画法で探索
- 結果として word → (start, end) のタイムスタンプを得る
このプロジェクト内での役割 1️⃣ ASR サービスが全文文字起こし
2️⃣ aligner サービスが単語タイムコードを追加
3️⃣ TTS サービスが編集文を再合成
4️⃣ stitcher
  original[0:0.53] + new_wav + original[0.88:] のように
  波形を滑らかに接合
代表的な実装 - Montreal Forced Aligner (MFA): Kaldi ベースで高精度
- CTC‑Segmentation: 軽量 Python/Cython ライブラリ(今回採用)
- Gentle / Aeneas: 英語中心の軽量ツール
AckkermanAckkerman

下図のような構成の検証環境を作成した。

  • コア・ロジック
    • ASR:faster-whisper
    • Aligner: ctc-segmentation
    • TTS: TTS
  • タスク管理
    • Celery
  • 監視
    • Prometheus
    • Grafana
  • API実装
    • FastAPI
    • GraphQL

docker-compose.yml
docker-compose.yml
# app/docker-compose.yml
version: "3.9"

# -------------------------------------------------
#  shared settings / overrides
# -------------------------------------------------
x-py-env: &py-env
  PYTHONUNBUFFERED: "1"          # show logs immediately

# -------------------------------------------------
#  infrastructure
# -------------------------------------------------
services:
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    ports: ["6379:6379"]

# -------------------------------------------------
#  core micro‑services
# -------------------------------------------------
  asr:
    build: ./services/asr
    environment:
      <<: *py-env
    volumes:
      - ./samples:/data:ro        # original & replacement wavs
      - asr_tmp:/tmp              # scratch area
      - shared_data:/shared
    ports:
      - "8001:8000"

  aligner:
    build: ./services/aligner
    environment:
      <<: *py-env
    volumes:
      - ./samples:/data:ro
      - aligner_tmp:/tmp
      - shared_data:/shared
    ports:
      - "8002:8000"
    depends_on:
      - asr

  tts:
    build: ./services/tts
    environment:
      <<: *py-env
      TTS_DEVICE: "${TTS_DEVICE:-cpu}"   # set to 'cuda' to enable GPU
    volumes:
      - ./samples:/data:ro
      - tts_tmp:/tmp
      - shared_data:/shared
    ports:
      - "8003:8000"

  stitcher:
    build: ./services/stitcher
    environment:
      <<: *py-env
    volumes:
      - ./samples:/data              # write stitched wavs here
      - stitch_tmp:/tmp
      - shared_data:/shared
    ports:
      - "8004:8000"
    depends_on:
      - tts

# -------------------------------------------------
#  orchestration layer (GraphQL + Celery)
# -------------------------------------------------
  api-gateway:
    build: ./services/api-gateway
    environment:
      <<: *py-env
      CELERY_BROKER_URL: "redis://redis:6379/0"
    volumes:
      - ./samples:/data
      - shared_data:/shared
    ports:
      - "8080:8080"
    depends_on:
      - redis
      - asr
      - aligner
      - tts
      - stitcher

  worker:                      # Celery worker (separate from API process)
    build: ./services/api-gateway
    command: celery -A app.tasks worker --loglevel=info --concurrency=2
    environment:
      <<: *py-env
      CELERY_BROKER_URL: "redis://redis:6379/0"
    volumes:
      - ./samples:/data
      - shared_data:/shared
    depends_on:
      - redis
      - asr
      - aligner
      - tts
      - stitcher

  celery-exporter:
    image:  danihodovic/celery-exporter:latest
    command: >
      --broker-url=redis://redis:6379/0
    ports:
      - 9808:9808
    depends_on:
      - redis
      - worker
  
  prometheus:
    image: prom/prometheus
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "9090:9090"          # Prometheus UI
    depends_on:
      - celery-exporter

  grafana:
    image: grafana/grafana-oss
    ports:
      - "3000:3000"
    environment:
      GF_SECURITY_ADMIN_PASSWORD: "admin"
    volumes:
      - grafana_data:/var/lib/grafana
    depends_on: 
      - prometheus

# -------------------------------------------------
#  named scratch volumes (kept between rebuilds)
# -------------------------------------------------
volumes:
  asr_tmp:
  aligner_tmp:
  tts_tmp:
  stitch_tmp:
  shared_data:
  grafana_data:

AckkermanAckkerman

Celeryのタスクキューの状況をPrometheus+Grafanaで監視する構成にしている。

AckkermanAckkerman
/                                     Monorepo Root
├ apps/
│  ├ api/                             API & Microservices
│  │   ├ docker-compose.yml           ASR, Aligner, TTS, Stitcher, Gateway
│  │   ├ monitoring/                  Prometheus & Grafana configs
│  │   ├ samples/                     テスト用 WAV
│  │   └ services/                    各サービス実装
│  └ web/                             (フロントエンド)
├ docs/
│  ├ architecture/                   アーキテクチャ図・設計ドキュメント
│  └study/                           調査メモ
├ experiments/                       検証用Python環境
├ assets/                            デモ用動画・素材
├ packages/
│   └ sound2wave/                    Rust ユーティリティライブラリ
├ scripts/                           開発支援スクリプト
├ pnpm-workspace.yaml                pnpm ワークスペース設定
├ package.json / package-lock.json   Web 依存管理
└ turbo.json                         Turborepo 設定
AckkermanAckkerman

ゼロショットTTSを試す。

AckkermanAckkerman

TTS: MeloTTS
Converter: OpenVoice
で実装した。

melo.apiは以下のメソッドを持つ。

    def tts_to_file(self, text, speaker_id, output_path=None, sdp_ratio=0.2, noise_scale=0.6, noise_scale_w=0.8, speed=1.0, pbar=None, format=None, position=None, quiet=False,):
        language = self.language
        texts = self.split_sentences_into_pieces(text, language, quiet)
        audio_list = []
        if pbar:
            tx = pbar(texts)
        else:
            if position:
                tx = tqdm(texts, position=position)
            elif quiet:
                tx = texts
            else:
                tx = tqdm(texts)
        for t in tx:
            if language in ['EN', 'ZH_MIX_EN']:
                t = re.sub(r'([a-z])([A-Z])', r'\1 \2', t)
            device = self.device
            bert, ja_bert, phones, tones, lang_ids = utils.get_text_for_tts_infer(t, language, self.hps, device, self.symbol_to_id)
            with torch.no_grad():
                x_tst = phones.to(device).unsqueeze(0)
                tones = tones.to(device).unsqueeze(0)
                lang_ids = lang_ids.to(device).unsqueeze(0)
                bert = bert.to(device).unsqueeze(0)
                ja_bert = ja_bert.to(device).unsqueeze(0)
                x_tst_lengths = torch.LongTensor([phones.size(0)]).to(device)
                del phones
                speakers = torch.LongTensor([speaker_id]).to(device)
                audio = self.model.infer(
                        x_tst,
                        x_tst_lengths,
                        speakers,
                        tones,
                        lang_ids,
                        bert,
                        ja_bert,
                        sdp_ratio=sdp_ratio,
                        noise_scale=noise_scale,
                        noise_scale_w=noise_scale_w,
                        length_scale=1. / speed,
                    )[0][0, 0].data.cpu().float().numpy()
                del x_tst, tones, lang_ids, bert, ja_bert, x_tst_lengths, speakers
                # 
            audio_list.append(audio)
        torch.cuda.empty_cache()
        audio = self.audio_numpy_concat(audio_list, sr=self.hps.data.sampling_rate, speed=speed)

        if output_path is None:
            return audio
        else:
            if format:
                soundfile.write(output_path, audio, self.hps.data.sampling_rate, format=format)
            else:
                soundfile.write(output_path, audio, self.hps.data.sampling_rate)

また、openvoice.apiは以下のメソッドを持つ。

    def extract_se(self, ref_wav_list, se_save_path=None):
        if isinstance(ref_wav_list, str):
            ref_wav_list = [ref_wav_list]
        
        device = self.device
        hps = self.hps
        gs = []
        
        for fname in ref_wav_list:
            audio_ref, sr = librosa.load(fname, sr=hps.data.sampling_rate)
            y = torch.FloatTensor(audio_ref)
            y = y.to(device)
            y = y.unsqueeze(0)
            y = spectrogram_torch(y, hps.data.filter_length,
                                        hps.data.sampling_rate, hps.data.hop_length, hps.data.win_length,
                                        center=False).to(device)
            with torch.no_grad():
                g = self.model.ref_enc(y.transpose(1, 2)).unsqueeze(-1)
                gs.append(g.detach())
        gs = torch.stack(gs).mean(0)

        if se_save_path is not None:
            os.makedirs(os.path.dirname(se_save_path), exist_ok=True)
            torch.save(gs.cpu(), se_save_path)

        return gs

これらを組み合わせて、以下のように書けば動かせる

    # 1) generate base TTS with MeloTTS
    source_se = torch.load(
        f'checkpoints_v2/base_speakers/ses/{_LANG.lower().replace("_", "-")}.pth', 
        map_location=device)
    base_path = str(pathlib.Path(tempfile.gettempdir()) / f"ov_base_{uuid.uuid4()}.wav")
    melo.tts_to_file(
        text=text,
        speaker_id=speaker_ids[_LANG],
        output_path=base_path,
    )

    # 2) extract embedding from reference voice
    # embed_path = str(pathlib.Path(tempfile.gettempdir()) / f"ov_embed_{uuid.uuid4()}.pth")
    converter = ToneColorConverter(f'{ckpt_converter}/config.json', device='cpu')
    converter.load_ckpt(f'{ckpt_converter}/checkpoint.pth')
    if torch.backends.mps.is_available() and device == 'cpu':
        torch.backends.mps.is_available = lambda: False
    target_se, audio_name = se_extractor.get_se(ref_wav, converter, vad=True)

    # 3) convert tone color
    converter.convert(
        audio_src_path=base_path, 
        src_se=source_se, 
        tgt_se=target_se, 
        output_path=out_path
    )

https://github.com/ackkerman/s2/pull/6/files