ソフトウェア/LMStudio/Orpheus の履歴(No.2)

更新


公開メモ

Orpheus TTS(英語読み上げAI)環境構築メモ

概要

Orpheus は非常に自然な英語を話せるモデル。
ここに英文を与えて wav ファイルを作成できる環境を作りたい。

具体的には、

  • Windows11 機(LM Studio)を TTS 推論サーバーとして利用
  • 別マシン(クライアント)からリモートでテキスト→音声変換を行う

目次

構成

  • サーバー側(Windows, VRAM 12GB): LM Studio + orpheus-3b-0.1-ft (GGUF, isaiahbjork版)
  • クライアント側(Debian等): isaiahbjork/orpheus-tts-local の gguf_orpheus.py

サーバー側(Windows機)セットアップ

  1. LM Studioをインストール
  2. モデルをダウンロード
    • Discover(検索)タブで検索、または以下のCLI
      LANG:console
      $ lms get isaiahbjork/orpheus-3b-0.1-ft-Q4_K_M-GGUF
  3. LAN内にサーバーを公開
    • Developerタブ → サーバー設定 → Serve on Local Network を ON
    • Windowsファイアウォールでポート 1234 を開放 (ダイアログで聞かれたら OK する)
    • ダイアログが出なければ、以下のコマンドで開放可能
      LANG:console
      > New-NetFirewallRule -DisplayName "LM Studio API" -Direction Inbound -Protocol TCP -LocalPort 1234 -Action Allow
  4. 実際のモデルIDを確認(フォルダ名やHF repo名とは一致しないので注意)
    LANG:console
    $ curl http://<Windows機IP>:1234/v1/models
    • "id" フィールドの値(例: orpheus-3b-0.1-ft)を後の手順で使う

クライアント側セットアップ

  1. リポジトリをclone
    LANG:console
    $ git clone https://github.com/isaiahbjork/orpheus-tts-local.git
    $ cd orpheus-tts-local
  2. 仮想環境作成(現時点では Python 3.11推奨。PyTorchのCUDA版ホイールが提供される範囲で選ぶのが無難)
    LANG:console
    $ uv venv --python 3.11
    $ source .venv/Scripts/activate
  3. requirements.txt の罠を先に除去
    • 「wave」という行がある場合は削除する(標準ライブラリ名と同名の無関係なPyPIパッケージがヒットし、mysql-pythonのビルドエラーで失敗するため)
      LANG:console
      $ sed -i '/^wave/d' requirements.txt
  4. 依存関係インストール
    LANG:console
    uv pip install -r requirements.txt
  5. API_URLをサーバー機のIPに書き換え(gguf_orpheus.py 冒頭、コマンドライン引数化されていないため直接編集が必要)
    API_URL = "http://<Windows機IP>:1234/v1/completions"

モデルの切り替え(switch_model.py)

背景・注意点

  • LM Studioの推論エンドポイント(/v1/completions等)に含まれる "model" フィールドは、 バージョンやエンドポイントによっては無視されることがあり、JITローディングによる自動切り替えは 信頼できない(公式バグトラッカーでも同種の報告あり)
  • そのため、切り替えは明示的に unload → load を呼ぶ方式で行う
  • 明示的にloadしたモデルは「手動ロード」扱いとなりAuto-Evictの対象外になるため、 何も考えずに毎回loadだけを呼ぶと、同じモデルの複数インスタンスがVRAM上に積み上がっていく
    (実際に3重ロードが発生した実績あり。unloadしないままloadを繰り返すと危険)

モデル一覧・ロード状況の確認

LANG:console
$ curl http://<Windows機IP>:1234/api/v1/models
  • レスポンスのトップレベルキーは "data" ではなく "models"
  • 各モデルの "loaded_instances" 配列に、実際にロード中のインスタンス("id" フィールド持つ)が入る
  • ロードされていなければ loaded_instances は空配列

switch_model.py

LM Studioのモデルを安全に切り替えるスクリプト。CLIとしても、他スクリプトからimportしても使える。

LANG:console
$ python switch_model.py <切り替え先モデルID> [--host 192.168.0.216:1234]

動作:

  1. 現在ロード済みのインスタンスを確認
  2. 切り替え先が既にロード済みなら何もせず終了
  3. そうでなければ、ロード済みの全インスタンスをunloadしてから、切り替え先をload

他スクリプトから使う場合:

from switch_model import switch_model
switch_model("orpheus-3b-0.1-ft", host="192.168.0.216:1234")

スクリプト本体は別途 switch_model.py を参照。

LANG:python
#!/usr/bin/env python
"""switch_model.py — LM Studioのモデルを安全に切り替える

CLIとしても、他スクリプトからのimportとしても使える。
使い方(CLI): python switch_model.py <切り替え先モデルのID> [--host 192.168.0.216:1234]
使い方(import): from switch_model import switch_model
                 switch_model("orpheus-3b-0.1-ft")
"""

import argparse
import sys
import requests


def get_base_url(host: str) -> str:
    return f"http://{host}/api/v1"


def get_loaded_instance_ids(base_url: str) -> tuple[list[str], dict]:
    """現在ロード済みのモデルインスタンスIDを取得する。"""
    resp = requests.get(f"{base_url}/models", timeout=10)
    resp.raise_for_status()
    data = resp.json()

    ids = []
    for model in data.get("models", []):
        instances = model.get("loaded_instances", [])
        for inst in instances:
            if isinstance(inst, dict):
                ids.append(inst.get("id") or inst.get("instance_id"))
            else:
                ids.append(inst)
    return [i for i in ids if i], data


def unload_model(base_url: str, instance_id: str) -> None:
    print(f"=== {instance_id} をunload中 ===")
    resp = requests.post(
        f"{base_url}/models/unload",
        json={"instance_id": instance_id},
        timeout=30,
    )
    if resp.status_code != 200:
        print(f"警告: unload失敗(既にアンロード済みかもしれません): {resp.text}")


def load_model(base_url: str, model_id: str) -> dict:
    print(f"=== {model_id} をload中 ===")
    resp = requests.post(
        f"{base_url}/models/load",
        json={"model": model_id},
        timeout=120,
    )
    resp.raise_for_status()
    return resp.json()


def switch_model(target_model: str, host: str = "192.168.0.216:1234", quiet: bool = False) -> bool:
    """指定したモデルに切り替える。既にロード済みなら何もしない。

    戻り値: 切り替え(またはロード確認)に成功したら True
    """
    base_url = get_base_url(host)

    def log(msg):
        if not quiet:
            print(msg)

    log("=== 現在ロード済みのモデルを確認 ===")
    try:
        loaded_ids, raw_data = get_loaded_instance_ids(base_url)
    except requests.exceptions.RequestException as e:
        print(f"エラー: LM Studioへの接続に失敗しました: {e}", file=sys.stderr)
        return False

    if not loaded_ids:
        log("現在ロード済みのモデルはありません(またはレスポンス構造が想定と違います)。")
        if not quiet:
            print("--- 生のレスポンス(構造確認用) ---")
            print(raw_data)
            print("-------------------------------------")
    else:
        log(f"ロード済み: {loaded_ids}")
        if target_model in loaded_ids:
            log(f"既に {target_model} はロード済みなので何もしません。")
            return True
        for instance_id in loaded_ids:
            unload_model(base_url, instance_id)

    try:
        result = load_model(base_url, target_model)
    except requests.exceptions.RequestException as e:
        print(f"エラー: loadに失敗しました: {e}", file=sys.stderr)
        return False

    log(result)
    if result.get("status") == "loaded":
        log(f"=== 切り替え完了: {target_model} ===")
        return True
    else:
        print("エラー: loadのレスポンスにstatus=loadedが含まれていません。", file=sys.stderr)
        return False


def main():
    parser = argparse.ArgumentParser(description="LM Studioのモデルを安全に切り替える")
    parser.add_argument("target_model", help="切り替え先のモデルID(例: orpheus-3b-0.1-ft)")
    parser.add_argument("--host", default="192.168.0.216:1234", help="LM StudioのホストIP:ポート")
    args = parser.parse_args()

    success = switch_model(args.target_model, host=args.host)
    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()

個別の手動操作(デバッグ用)

# unload
curl -X POST http://<Windows機IP>:1234/api/v1/models/unload \
  -H "Content-Type: application/json" \
  -d '{"instance_id": "<loaded_instancesで確認したid>"}'
# load
curl -X POST http://<Windows機IP>:1234/api/v1/models/load \
  -H "Content-Type: application/json" \
  -d '{"model": "<モデルのkey>"}'
  • 成功時のレスポンス例: {"type": "llm", "instance_id": "orpheus-3b-0.1-ft", "load_time_seconds": 3.35, "status": "loaded"}
  • 通常運用ではswitch_model.pyに任せ、この手動操作は動作確認・トラブルシュート時のみ使用する

音声生成(単発)

LANG:console
$ python gguf_orpheus.py --text "Hello, this is a test" --voice tara --output test.wav
  • 利用可能な声: tara, leah, jess, leo, dan, mia, zac, zoe(tara推奨)
  • 感情タグ: <laugh> <chuckle> <sigh> <cough> <sniffle> <groan> <yawn> <gasp>
  • 声一覧確認: python gguf_orpheus.py --list-voices

音声生成(バッチ)

大量のテキストを含むものを一気に渡すとコンテキスト長を超えてしまうので、 空行を境に少しずつ切り分けて変換して、個々の wav ファイルを得る → part0001.wav, part0002.wav, ...

行頭に "01:05" のようなタイムコードがあれば、それをそのチャンクの開始時刻としてファイル名に埋め込む。 (part0002-0105.wav のようになる)

以下の orpheus.py を chmod u+x orpheus.py しておけば、テキストファイル名を与えるだけで変換できる。

orpheus.py

LANG:python
#!/usr/bin/env python
"""
orpheus.py — テキストファイルを適切に分割し、各チャンクをOrpheus TTSでwav化する。
行頭に "01:05" のようなタイムコードがあれば、それをそのチャンクの開始時刻としてファイル名に埋め込む。

使い方:
    python orpheus.py script.txt --voice tara --outdir parts/
    python orpheus.py script.txt --dry-run   # 分割結果だけ確認

出力例:
    parts/part0001.wav          ← タイムコード指定なし
    parts/part0002-0105.wav     ← 01:05 開始指定あり
    parts/part0003.wav
"""

import argparse
import os
import re
import sys

# 同じディレクトリの gguf_orpheus.py から関数を再利用
from gguf_orpheus import generate_speech_from_api, DEFAULT_VOICE, AVAILABLE_VOICES
from switch_model import switch_model 

# [hh:]mm:ss の後にスペース、その後がテキスト本体
TIMECODE_RE = re.compile(r'^\s*(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s+(.*)$')

MAX_CHARS_PER_CHUNK = 400  # Orpheusの MAX_TOKENS=1200 に対する安全マージン(目安。英語基準)

def parse_timecode(line):
    """行頭のタイムコードを抽出。見つかれば (秒数, 残りのテキスト) を返す。無ければ (None, line)。"""
    m = TIMECODE_RE.match(line)
    if not m:
        return None, line
    hh, mm, ss = m.group(1), m.group(2), m.group(3)
    total_seconds = int(mm) * 60 + int(ss)
    if hh:
        total_seconds += int(hh) * 3600
    return total_seconds, m.group(4)


def split_into_chunks(text, max_chars=MAX_CHARS_PER_CHUNK):
    """空行区切りで段落分割し、長すぎる段落はさらに文単位で分割する。
    戻り値: [(start_seconds_or_None, text), ...]
    """
    paragraphs = re.split(r'\n\s*\n', text.strip())
    chunks = []

    for para in paragraphs:
        para = para.strip()
        if not para:
            continue

        lines = para.split('\n')
        start_seconds, first_line = parse_timecode(lines[0])
        body = ' '.join([first_line] + lines[1:]).strip()
        body = re.sub(r'\s+', ' ', body)

        if not body:
            continue

        if len(body) <= max_chars:
            chunks.append((start_seconds, body))
            continue

        # 長すぎる場合は文単位でさらに分割(タイムコードは最初の断片にだけ付与)
        sentences = re.split(r'(?<=[.!?。!?])\s+', body)
        buf = ''
        first = True
        for sent in sentences:
            candidate = (buf + ' ' + sent).strip() if buf else sent
            if len(candidate) > max_chars and buf:
                chunks.append((start_seconds if first else None, buf))
                first = False
                buf = sent
            else:
                buf = candidate
        if buf:
            chunks.append((start_seconds if first else None, buf))

    return chunks


def make_filename(index, start_seconds):
    if start_seconds is None:
        return f"part{index:04d}.wav"
    mm, ss = divmod(start_seconds, 60)
    hh, mm = divmod(mm, 60)
    code = f"{hh:02d}{mm:02d}{ss:02d}" if hh else f"{mm:02d}{ss:02d}"
    return f"part{index:04d}-{code}.wav"


def main():
    parser = argparse.ArgumentParser(description="テキストファイルを分割してOrpheusでwav化する")
    parser.add_argument("input_file", help="原稿テキストファイル")
    parser.add_argument("--voice", default=DEFAULT_VOICE, choices=AVAILABLE_VOICES)
    parser.add_argument("--outdir", default="parts", help="出力先ディレクトリ")
    parser.add_argument("--max-chars", type=int, default=MAX_CHARS_PER_CHUNK,
                         help="1チャンクあたりの最大文字数(目安)")
    parser.add_argument("--dry-run", action="store_true", help="分割結果だけ確認して生成はしない")
    parser.add_argument("--model", default="orpheus-3b-0.1-ft",
                         help="LM Studio上で使うモデルID(切り替えが必要な場合に指定)")
    parser.add_argument("--host", default="192.168.0.216:1234",
                         help="LM StudioのホストIP:ポート")

    args = parser.parse_args()
    with open(args.input_file, encoding="utf-8") as f:
        text = f.read()

    chunks = split_into_chunks(text, max_chars=args.max_chars)

    if not chunks:
        print("分割結果が空です。入力ファイルを確認してください。", file=sys.stderr)
        sys.exit(1)

    os.makedirs(args.outdir, exist_ok=True)

    print(f"{len(chunks)} 個のチャンクに分割されました。\n")
    for i, (start, body) in enumerate(chunks, start=1):
        filename = make_filename(i, start)
        time_label = f"[{start}秒〜]" if start is not None else "[時刻指定なし]"
        preview = body[:40] + ("..." if len(body) > 40 else "")
        print(f"  {filename}  {time_label}  {preview}")

    if args.dry_run:
        print("\n--dry-run のため生成はスキップしました。")
        return

    # 生成前にモデルを切り替え
    print()
    if not switch_model(args.model, host=args.host):
        print("モデル切り替えに失敗しました。処理を中断します。", file=sys.stderr)
        sys.exit(1)

    print()
    for i, (start, body) in enumerate(chunks, start=1):
        filename = make_filename(i, start)
        output_path = os.path.join(args.outdir, filename)
        print(f"=== [{i}/{len(chunks)}] {filename} を生成中 ===")
        generate_speech_from_api(
            prompt=body,
            voice=args.voice,
            output_file=output_path,
        )

    print(f"\n完了。{args.outdir}/ に {len(chunks)} 個のwavファイルを出力しました。")


if __name__ == "__main__":
    main()

テスト結果

デフォルトで使える "tara" "leah" "jess" "leo" "dan" "mia" "zac" "zoe" の8つのモデルについて比較用の音声を作ってみました。

  • "tara", "jess", "zac" が使いやすそう
  • 他のはわざと癖のある感じにしてるっぽい?

A spur gear is the simplest and most common type of gear, which is used to transmit rotation and power between two parallel shafts.

It has a cylindrical shape, and its tooth trace is straight and parallel to the axis.

LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=tara.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=leah.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=jess.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=leo.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=dan.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=mia.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=zac.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=zoe.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}

トラブルシューティングメモ

  • 「ModuleNotFoundError」が出るのに pip list には入っている
    • 原因: python コマンドが仮想環境と違う実体を指している
    • 確認: type -a python (whichではなくtypeを使う。aliasやbash内部キャッシュの影響を見るため)
    • 対処: unalias python、または hash -r、または .venv/Scripts/python で直接パス指定
  • LM Studioで "model_not_found" エラー
    • 原因: HuggingFaceのリポジトリ名(例: isaiahbjork/orpheus-3b-0.1-ft-Q4_K_M-GGUF)と、LM StudioがAPI上で使うID(例: orpheus-3b-0.1-ft)は別物
    • 対処: /v1/models で実際のIDを確認してから使う
  • requirements.txt のインストールでビルドエラー(Visual C++ 14.0が必要 等)
    • 標準ライブラリ名と衝突する野良PyPIパッケージ(wave, os等)が紛れ込んでいないか確認
    • 該当行を削除して標準ライブラリ側を使わせる

VoiceCore(日本語ナレーション)環境構築メモ

概要

Orpheus を日本語音声合成用にトレーニングしなおした VoiceCore というものがある。
以下は Orpheus 英語版と同じ LM Studio 環境 + Orpheus 用スクリプトに手を加えて VoiceCore を動かす手順。

Ollamaは公式に非対応(カスタムトークナイザー未対応)。
LM Studioも公式には「未チェック」だったが、プロンプト形式を正しく合わせれば動作した。


モデルのダウンロード

  1. LM StudioのDiscoverタブ、または lms コマンドで取得
    lms get webbigdata/VoiceCore_gguf
  2. 量子化レベルの選択に注意
    • 開発元により「量子化に敏感なモデル」と警告されている
    • VoiceCore-Q4_K-f16.gguf(2.66GB)が実用的

重要:プロンプト形式が英語 Orpheus と異なる

英語 Orpheus(gguf_orpheus.py標準)とVoiceCoreでは、開始・終了トークンが別物。
これを間違えると、エラーは出ないまま延々とトークンを生成し続ける「暴走」状態になる。
(finish_reason が length のまま止まらない)

開始終了
Orpheus(英語)<|audio|><|eot_id|>
VoiceCore(日本語)<custom_token_3><|begin_of_text|><|eot_id|><custom_token_4><custom_token_5><custom_token_1>

正しいVoiceCore用プロンプト例:

<custom_token_3><|begin_of_text|>matsukaze_male: こんにちは!<|eot_id|><custom_token_4><custom_token_5><custom_token_1>

声の指定形式

「話者名: テキスト」という形式は共通だが、声名リストがOrpheusと別。

ORPHEUS_VOICES = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
VOICECORE_VOICES = ["matsukaze_male", "dahara1_male", "amitaro", "naraku", "shiguu", "nekketsu"]

感情タグ付き指定も可能(例: matsukaze_male[neutral])だが、さしあたり使わない形で。


声ごとのライセンス(商用/個人で扱いが変わる)

2026/07/26 に確認した範囲です:

声名商用利用クレジット表記備考
matsukaze_male⭕ 可能✅ 必須(CC-BY)「松風」制限なし。個人利用でも表記必須
dahara1_male⭕ 可能任意(Apache 2.0)制限なし。一番手続きが軽い
amitaro⭕ 可能(要事後報告)✅ 必須「あみたろの声素材工房」エロ・グロ/政治宗教/ヘイト不可
naraku⭕ 可能(商用は要事前連絡)個人:不要 / 商用:必須「極楽唯」反社・政治宗教/品位を損なう行為 不可
shiguu⭕ 可能(商用は要事前連絡)✅ 必須「刻鳴時雨(CV:丸ころ)」品位を損なう行為/政治宗教 不可
nekketsu⭕ 可能(商用は要事前連絡)任意「紅葉美兎及びAI生成音声」明記悪用(詐欺広告/フェイクニュース等)不可

共通ルール:

  • 再配布・素材としての単体販売は禁止。加工・編集は可
  • 個人の検証目的なら「商用は要連絡」系の条件は基本的に不要と読める
  • クレジット表記義務(CC-BY等)は商用/個人を問わず必須のものが多い点に注意

詳細: https://huggingface.co/webbigdata/VoiceCore#%E3%83%87%E3%82%A3%E3%83%95%E3%82%A9%E3%83%AB%E3%83%88%E5%88%A9%E7%94%A8%E5%8F%AF%E8%83%BD%E3%81%AA%E9%9F%B3%E5%A3%B0%E3%81%AE%E6%8F%90%E4%BE%9B%E5%85%83%E3%81%A8%E3%81%9D%E3%81%AE%E3%83%A9%E3%82%A4%E3%82%BB%E3%83%B3%E3%82%B9-default-voice-providers-and-their-licenses


コード側の対応(英日自動判定)

voice名からモデルファミリーを自動判定し、プロンプト形式とmodel名を自動で切り替える。

LANG: python
ORPHEUS_VOICES = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
VOICECORE_VOICES = ["matsukaze_male", "dahara1_male", "amitaro", "naraku", "shiguu", "nekketsu"]
AVAILABLE_VOICES = ORPHEUS_VOICES + VOICECORE_VOICES  # argparseのchoices用

def detect_model_family(voice):
    base_voice = voice.split("[")[0]
    if base_voice in VOICECORE_VOICES:
        return "voicecore"
    elif base_voice in ORPHEUS_VOICES:
        return "orpheus"
    else:
        return "orpheus"  # 不明な場合はorpheus形式にフォールバック

def format_prompt(prompt, voice=DEFAULT_VOICE):
    model_family = detect_model_family(voice)
    formatted_prompt = f"{voice}: {prompt}"
    if model_family == "voicecore":
        special_start = "<custom_token_3><|begin_of_text|>"
        special_end = "<|eot_id|><custom_token_4><custom_token_5><custom_token_1>"
    else:
        special_start = "<|audio|>"
        special_end = "<|eot_id|>"
    return f"{special_start}{formatted_prompt}{special_end}"

model名(LM Studio上のモデルID)も引数化が必須

複数モデル(voicecore_gguf と orpheus-3b-0.1-ft)を同時ロードしている場合、 payload内の "model" フィールドの値がハードコードされたままだと 「Invalid model identifier」エラーになる。generate_speech_from_api等に model_name引数を追加し、switch_model.pyで切り替えた先と一致させること。

テスト結果

各音声のクレジットは上の表のとおりです。

平歯車はもっとも単純でもっとも良く使われる歯車であり、平行な2つの回転軸の間で回転力を伝えるのに使われます。

平歯車は円筒形で、その歯筋は軸に平行な直線です。

LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=matsukaze_male.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=dahara1_male.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=amitaro.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=naraku.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=shiguu.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}
LANG:p5js_live
// =============== ここが設定
const wav_url = 'https://dora.bk.tsukuba.ac.jp/~takeuchi/?plugin=attach&refer=%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%2FLMStudio%2FOrpheus&openfile=nekketsu.wav';
const multi = 1000;    // スライダーの分解能
const fps = 30;         // 描画頻度 (frame per second)
const maxWidth = 600;   // 横幅最大値
const btnWidth = 60;    // 再生/一時停止ボタンの幅
const loop = false;      // true: ループ再生 / false: 1回のみ

// ================ ここから下は汎用コード

let sound = null;
let posSlider = null;
let playBtn = null;
let fileName = '';
let w, h;
let isPausing = false; // pause()による ended イベントを無視するためのフラグ

p.preload = () => sound = p.loadSound(wav_url);

const updateBtnLabel = () => {
  playBtn.html(sound.isPlaying() ? '⏸' : '▶');
};

const draw = () => {
  const ratio = posSlider.value() / (multi - 1);
  const t = ratio * sound.duration();
  const barW = w - btnWidth;

  p.background(240);
  p.noStroke();
  p.fill(90, 160, 220);
  p.rect(btnWidth, 0, barW * ratio, h);

  p.fill(20);
  p.textAlign(p.LEFT, p.CENTER);
  p.text(fileName, btnWidth, h / 2 + 12);
  p.textAlign(p.RIGHT, p.CENTER);
  p.text(t.toFixed(2) + ' s / ' + sound.duration().toFixed(2) + ' s', w, h / 2 + 12);
}

p.setup = () => {
  p.frameRate(fps);
  w = maxWidth;
  h = 30;
  p.createCanvas(w, h + 40);

  fileName = decodeURIComponent(wav_url.split(/[\/=]/).pop());
  sound.setLoop(loop);

  posSlider = p.createSlider(0, multi - 1, 0);
  posSlider.position(btnWidth, h);
  posSlider.size(w - btnWidth);
  posSlider.input(() => {
    const ratio = posSlider.value() / (multi - 1);
    sound.jump(ratio * sound.duration());
    draw();
  });

  playBtn = p.createButton('▶');
  playBtn.position(0, h);
  playBtn.size(btnWidth - 5, 20);
  playBtn.mousePressed(() => {
    p.userStartAudio();
    if (sound.isPlaying()) {
      isPausing = true;   // ← これから pause する、と印をつける
      sound.pause();
    } else {
      sound.play();
    }
    updateBtnLabel();
  });

  sound.onended(() => {
    if (isPausing) {
      isPausing = false;
      return;
    }
    // 自然終了とわかっているので、判定せず直接 ▶ に戻す
    playBtn.html('▶');
    posSlider.value(0);
  }); 

  draw();
}

p.draw = () => {
  if (p.mouseIsPressed) return;
  if (sound.isPlaying()) {
    const ratio = sound.currentTime() / sound.duration();
    posSlider.value(Math.round(ratio * (multi - 1)));
  }
  draw();
}

コメント・質問





Counter: 142 (from 2010/06/03), today: 1, yesterday: 4