かっこいい “ローカル LLM” の使い方 (Qwen 3-TTS 編)

  • URLをコピーしました!

こんにちはジョージです。最新の音声合成モデル Qwen 3-TTS を使って、クラウドを介さない Mac のローカル環境だけで 完結するボイスクローン・カスタム App を作ってみたよ。Apple シリコン搭載の Mac で動作可能な、低遅延と実用性を兼ね備えた、ローカル LLM の「かっこいい」活用術をご紹介します。

※本記事は技術的な検証を目的としています。ボイスクローン技術を利用する際は、対象者の許諾を必ず得た上で、各国の法律を遵守してください。

目次

Qwen について

Qwen(クウェン)は、Alibaba Cloud が開発し、世界中のエンジニアがその性能に驚愕しているオープンソースの LLM シリーズです。特筆すべきは、軽量でありながら極めて高い精度を誇ること。今回使用する Qwen3-TTS は、その系譜を継ぐ最新の音声合成モデルで、音声クローン、音声デザイン、高品質の人間のような音声生成、自然言語ベースの音声制御を包括的にサポートします。そこで今回は「音声クローン」を対象に、カスタム App に組み込む方法を考えてみました。

Qwen3-TTS 紹介記事ページ
https://qwen.ai/blog?id=qwen3tts-0115

カスタム App から LLM を実行する方法の考察

FileMaker(カスタム App)から LLM を動かすには、いくつかのルートがあります。それぞれのメリット、デメリットを整理してみましょう。

技術 実行場所インターネット運用コスト特徴・今回の本命との違い
1. 商用 AI ( API 経由)クラウド必須基本的に従量課金OpenAI 等を利用。最強のモデルが使える反面、機密データの外部送信とコストが課題。
2. FileMaker AI Serverサーバ基本的に不要 (ローカル・LAN 環境設置時)ライセンス等Claris 公式 の構成。サーバ等、準備のオーバーヘッドがある。現時点ではテキスト生成モデル、埋め込みモデルにしか対応していない。
3. LM Studio / ComfyUIローカルPC基本的に不要基本的に無料GUI で手軽にサーバー化。開発時の検証には最適だが、運用時にはバックグラウンドで別アプリを常時起動させておく必要がある。
4. llama.cppローカルPC基本的に不要基本的に無料C++ 実装で高速だが、別途インストールが必要。FileMaker から呼ぶには Python 等の「中継役」を介する。
5. プラグイン経由カスタム App 内不要基本的に無料(一般配布には別途、Apple のコード署名と公証が必須)dylib を埋めこみ、カスタム Appから直接モデルの呼び出しが可能に。Apple シリコンに最適化された低遅延を実現。

Qwen3-TTS のような LLM をカスタム App から呼び出すには「商用 AI (API 経由) 」をのぞき、なかなか準備が大変な事がわかります。そこで開発は大変だが、展開と運用だけはしやすいように「5. プラグイン経由」にトライしてみました。

今回のエンジン qwen3-tts.cpp

「プラグイン開発の『エンジン』として採用したのがこのリポジトリです。これは Alibaba の Qwen3-TTS モデルを純粋な C++ で推論可能にした非常にエネルギッシュなプロジェクトで、その最適化には目を見張るものがあります。

なお、現時点でリポジトリに明示的なライセンス表記がないため、残念ながら完成したプラグインの公開・配布は見送りますが、今回はエンジニアの探究心として、『どのようにして Apple シリコン環境へ LLM を組み込んだのか』 その具体的な実装プロセスを共有したいと思います。」

predict-woo/qwen3-tts.cpp GitHub
https://github.com/predict-woo/qwen3-tts.cpp

Qwen3-TTS.cpp をビルドして動かす

まずは Apple シリコン環境で、この C++ エンジンがどれほどのスピードで動くのかを体験してみましょう。まずは GitHub から qwen3-tts.cpp のリポジトリをクローン、または ZIP 形式でダウンロードして手元に用意します。


つづいて Qwen3-TTS のモデルデータをダウンロードします。このエンジンでは、軽量で扱いやすい GGUF 形式 が推奨されています。Hugging Face 等から、自分の Mac(今回は M1 mac メモリ 16GB 環境で検証)のメモリ容量に合った量子化モデルを入手しましょう。とりあえず軽量なこちらのモデルを使ってみました。

https://huggingface.co/Volko76/Qwen3-TTS-12Hz-0.6B-Base-Qwen3tts.cpp_quants-GGUF/tree/main

リポジトリの Quickstart 通りに先に ggml をビルドして 次に qwen3-tts-cli をビルドすると、コマンドラインで動作する qwen3-tts-cli が完成します。サンプリングしたい音声(10秒くらい)を .wav ファイルに保存してコマンドを実行すると約 22.843 秒 で音声ファイルが完成します。そして、なかなかの品質です。

./build/qwen3-tts-cli \
    -m /モデルまでのパス/Volko76/Qwen3-TTS-12Hz-0.6B-Base-Qwen3tts.cpp_quants-GGUF/ \
    -t "私のクローン音声を作成しています。" \
    -r ~/Desktop/sample.wav \
    -l ja \
    -j 8 \
    -o ~/Desktop/cloned_final.wav

dylib(ダイナミックライブラリ)

ビルドが無事に完了すると、src ディレクトリの中に libqwen3tts.dylibというファイルが生成されます。また ggml/buld/src 配下に libggml-base.dylib, libggml-cpu.dylib , libggml.dylib , さらに ggml-metal, ggml-blas 配下に libggml-metal.dylib , libggml-blas.dylib の 6 つのファイルが生成されます。dylib とは「Dynamic Library(動的ライブラリ)」の略で、Windowsでいう「DLL」にあたるものです。特定の機能(今回は Qwen3-TTS による音声合成)だけをギュッと詰め込んだ、Mac専用の「プログラムの部品」だと考えてください。

これらを FileMaker Plugin SDK のフレームワークとして埋め込むと、カスタム App の計算式から関数を介して Qwen3-TTS への橋渡しをしてくれるようになるのです。

FileMaker Plugin SDK をダウンロードして開発開始

今回は Google Gemini をバイブコーディングの相棒として使いプラグイン開発しました。プロジェクトのリネーム方法やターゲットの指定なんかは普段 XCode 使わないから忘れちゃうのですが、全部 AI に聞いて、教えられた通りやったら完成しました。なので、この部分は割愛させて頂きポイントだけ紹介させて頂きます。後述するソースを AI に渡せば丁寧に実装方法をおしえてくれるはずです。

必要なファイルをプロジェクトに追加

とりあえずヘッダーファイル qwen3tts_c_api.h と、ビルドして完成した dylib を XCode のプロジェクトの適当な位置にドラッグしてコピーします。通常、CMake などでビルドされたライブラリは、libqwen3tts.0.1.0.dylib という本体に対し、libqwen3tts.0.dylib や libqwen3tts.dylib といった「シンボリックリンク(ショートカット)」が作成される構成が一般的です。プラグインに埋め込む場合、リンク構造をそのまま持ち込むと、管理が煩雑になりがちになるのでシンプルに本体を libqwen3tts.0.dylib としてリネームしてから埋め込んでます。

qwen3tts_c_api.h
libggml-base.0.dylib
libggml-blas.0.dylib
libggml-cpu.0.dylib
libggml-metal.0.dylib
libggml.0.dylib
libqwen3tts.0.dylib

メインファイルに、関数の登録

準備ができたら、FileMaker から呼び出す関数(以下 FileMaker Plugin SDK とのソース差分)を追加します。Do_PluginInit , Do_PluginShutdown に Do_Qwen3TTS_Generate の登録と解除をかけば大まかな準備は完了です。* エラーがでたら、随時コードを修正してください 🙏

#include "qwen3tts_c_api.h"
#include <string>
#include <vector>

// エンジン保持用のグローバル変数
static Qwen3Tts* g_tts_engine = nullptr;

#include "qwen3tts_c_api.h"
#include <string>
#include <vector>

// エンジン保持用のグローバル変数
static Qwen3Tts* g_tts_engine = nullptr;

// WAVヘッダ構造体(24000Hz / Float32 / Mono 用)
#pragma pack(push, 1)
struct WavHeader {
	char     chunkID[4];      // "RIFF"
	uint32_t chunkSize;       // 36 + dataSize
	char     format[4];       // "WAVE"
	char     subchunk1ID[4];  // "fmt "
	uint32_t subchunk1Size;   // 16
	uint16_t audioFormat;     // 3 (IEEE Float)
	uint16_t numChannels;     // 1
	uint32_t sampleRate;      // 24000
	uint32_t byteRate;        // 24000 * 4
	uint16_t blockAlign;      // 4
	uint16_t bitsPerSample;   // 32
	char     subchunk2ID[4];  // "data"
	uint32_t subchunk2Size;   // dataSize
};
#pragma pack(pop)

enum {
	kQwen3TTS_GenerateID = 2000 // 他と被らない適当な番号
};

#include <string>
#include <vector>

static std::string Base64Encode(const unsigned char* data, size_t len) {
	static const char* lookup = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
	std::string out;
	out.reserve(((len + 2) / 3) * 4);
	for (size_t i = 0; i < len; i += 3) {
		int val = (data[i] << 16) | ((i + 1 < len ? data[i + 1] : 0) << 8) | (i + 2 < len ? data[i + 2] : 0);
		out.push_back(lookup[(val >> 18) & 0x3F]);
		out.push_back(lookup[(val >> 12) & 0x3F]);
		out.push_back(lookup[i + 1 < len ? (val >> 6) & 0x3F : '=']);
		out.push_back(lookup[i + 2 < len ? (val & 0x3F) : '=']);
	}
	return out;
}

#include "FMWrapper/FMXBinaryData.h" // これを必ず追加
#include <unistd.h> // sync, usleep を使うために必要

static FMX_PROC(fmx::errcode) Do_Qwen3TTS_Generate(short /* funcId */, const fmx::ExprEnv& /* env */, const fmx::DataVect& parms, fmx::Data& results)
{
	// 引数チェック (text, modelDir, referenceWav)
	if (parms.Size() < 3) return 1;

	// --- 1. 文字列の取得 (SDK 22.x 対応) ---
	auto getStr = [](const fmx::Text& fmxText) {
		fmx::uint32 charCount = fmxText.GetSize();
		fmx::uint32 bufferSize = charCount * 4 + 1;
		std::vector<char> buf(bufferSize, 0);
		fmxText.GetBytes(buf.data(), bufferSize, 0, charCount, fmx::Text::kEncoding_UTF8);
		return std::string(buf.data());
	};

	std::string text = getStr(parms.AtAsText(0));
	std::string modelDir = getStr(parms.AtAsText(1));

	// --- 2. オブジェクトフィールドからバイナリを取得 ---
	const fmx::BinaryData& refWavBin = parms.At(2).GetBinaryData();
	int32_t streamCount = refWavBin.GetCount();
	if (streamCount == 0) return 1;

	// 最もデータサイズが大きいストリーム(WAVの実体)を特定
	int32_t targetIndex = 0;
	uint32_t maxSize = 0;
	for (int32_t i = 0; i < streamCount; ++i) {
		uint32_t sSize = refWavBin.GetSize(i);
		if (sSize > maxSize) {
			maxSize = sSize;
			targetIndex = i;
		}
	}

	if (maxSize == 0) return 1;

	// バイナリデータをバッファに吸い出す
	std::vector<char> wavBuffer(maxSize);
	refWavBin.GetData(targetIndex, 0, maxSize, wavBuffer.data());

	// --- 3. 一時ファイルに書き出す (ライブラリのWAV解析機能を利用するため) ---
	std::string tempWavPath = "/tmp/qwen3_ref_direct.wav";
	std::ofstream tempOfs(tempWavPath, std::ios::binary | std::ios::trunc);
	if (tempOfs) {
		tempOfs.write(wavBuffer.data(), maxSize);
		tempOfs.close();
		sync();           // OSのディスクキャッシュを強制フラッシュ
		usleep(100000);   // 0.1秒待機して書き込みを安定させる
	} else {
		return 1;
	}

	// --- 4. エンジンの初期化 & キャッシュ利用 ---
	if (g_tts_engine == nullptr) {
		if (!modelDir.empty() && modelDir.back() != '/') modelDir += "/";
		g_tts_engine = qwen3_tts_create(modelDir.c_str(), 8);
	}
	if (!g_tts_engine) return 1;

	Qwen3TtsParams params;
	qwen3_tts_default_params(¶ms);
	params.language_id = 2058; // 日本語(ja)
	params.n_threads = 8;      // スレッド数はMacの性能に応じて調整可能

	// --- 5. 音声生成 (ボイスクローン) ---
	Qwen3TtsAudio* audio = qwen3_tts_synthesize_with_voice_file(
		g_tts_engine,
		text.c_str(),
		tempWavPath.c_str(),
		¶ms
	);

	// --- 6. 結果の構築 (WAVヘッダ付与 + Base64エンコード) ---
	if (audio && audio->samples) {
		uint32_t outDataSize = audio->n_samples * sizeof(float);
		
		WavHeader header;
		memcpy(header.chunkID, "RIFF", 4);
		header.chunkSize = 36 + outDataSize;
		memcpy(header.format, "WAVE", 4);
		memcpy(header.subchunk1ID, "fmt ", 4);
		header.subchunk1Size = 16;
		header.audioFormat = 3; // IEEE Float32
		header.numChannels = 1;
		header.sampleRate = 24000;
		header.byteRate = 24000 * 4;
		header.blockAlign = 4;
		header.bitsPerSample = 32;
		memcpy(header.subchunk2ID, "data", 4);
		header.subchunk2Size = outDataSize;

		// 全データを一つのバッファに結合
		std::vector<unsigned char> fullWav(sizeof(WavHeader) + outDataSize);
		memcpy(fullWav.data(), &header, sizeof(WavHeader));
		memcpy(fullWav.data() + sizeof(WavHeader), audio->samples, outDataSize);

		// Base64文字列へ変換
		std::string b64String = Base64Encode(fullWav.data(), fullWav.size());

		// FileMakerへテキストとして返却
		fmx::TextUniquePtr outB64;
		outB64->Assign(b64String.c_str(), fmx::Text::kEncoding_UTF8);
		results.SetAsText(*outB64, results.GetLocale());

		qwen3_tts_free_audio(audio);
		return 0; // 成功
	}
	
	return 1; // 失敗
}

Build Phases に Run Script を追加

とりあえず、必要な部品がすべてそろったのですが、今回のようにdylib を埋め込んだ Xcodeでプロジェクトはビルドするだけでは正しく動かないようです。生成されたプラグイン(.fmplugin)の中に埋め込んだ dylib を、macOSが正しく認識し、かつセキュリティをパスして実行できるようにするための「おまじない」が必要と AI が教えてくれたので今回は Build Phases に以下の Run Script を追加し、ビルドの最終工程を自動化しました。

# 1. パス定義(Xcodeの変数を利用)
FW_DIR="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}"
TARGET_BIN="${TARGET_BUILD_DIR}/${EXECUTABLE_PATH}"

# 2. 実行ファイルに「自分の隣のFrameworksフォルダを検索パスに入れろ」と命令
# これにより、内部の @rpath/lib...dylib が自動で見つかるようになります
install_name_tool -add_rpath "@loader_path/../Frameworks" "$TARGET_BIN" 2>/dev/null || true

# 3. 署名を更新
codesign --force --sign - "$FW_DIR"/*.dylib
codesign --force --sign - "$TARGET_BIN"

echo "Path fix complete."

とりあえずビルドがうまくいったので、プラグインフォルダに完成した Qwen3TTS.fmplugin をインストールしてカスタム App を起動します。ちなみに、自分でビルドしたプラグインを別の Mac にコピーして FileMaker を起動すると、「悪質なソフトウェアが含まれていないか確認できないため、開けません」という警告が出て、プラグインの読み込みがブロックされることがあります。

これは macOS の「検疫(Quarantine)」機能によるガードです。本来は Apple の公証を受けるべきですが、テスト環境などでとりあえず動作させたい場合は、ターミナルから以下のコマンドを実行して検疫フラグを解除します。

sudo xattr -dr com.apple.quarantine "~/Library/Application Support/FileMaker/FileMaker Pro/22.0/Extensions/Qwen3TTS.0.fmplugin"

カスタム App で動かしてみる

FileMaker からプラグインを認識されたら関数を実行してみます。第一引数は「AI で合成したいセリフ」第二引数は「モデルの場所」第三引数は「リファレンスの.wav」が入ったオブジェクトフィールドか変数になります。

Qwen3TTS_Generate ( 
  @Sys::transcript ; 
  "/モデルまでのパス/models/Volko76/Qwen3-TTS-12Hz-0.6B-Base-Qwen3tts.cpp_quants-GGUF/" ; 
  @Sys::reference
)

ひと昔前はオブジェクトフィールドに直接音声を録音できた気がしたのですが、どうやら v13 以降 からなくなってしまったようです。FileMaker Go からはマイクから音声を直接登録できるので、FileMaker Go から音声録音するか QuickTime を起動して音声を録音します。

ソフトウェア アップデート: FileMaker Server 12.0v4 および FileMaker Server 12.0v4 Advanced
https://support.claris.com/s/answerview?anum=000025018&language=ja

ちなみに FileMaker Go から音声を録音すると .m4a という形式の音声ファイルになるので、Web ビューアの JavaScript で .wav に書き換えます。

<script>
// FileMaker から呼び出される関数
async function convert(base64M4a) {
    document.getElementById('status').innerText = "変換中...";
    try {
        // 1. Base64 を ArrayBuffer に変換
        const binaryString = window.atob(base64M4a);
        const bytes = new Uint8Array(binaryString.length);
        for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);

        // 2. Web Audio API でデコード (24000Hzにリサンプリング)
        const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
        const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer);
        const rawData = audioBuffer.getChannelData(0); // 1ch(Mono)

        // 3. WAV (Float32) の構築
        const wavBuffer = new ArrayBuffer(44 + rawData.length * 4);
        const view = new DataView(wavBuffer);

        // ヘッダ作成
        const writeString = (v, o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
        writeString(view, 0, 'RIFF');
        view.setUint32(4, 36 + rawData.length * 4, true);
        writeString(view, 8, 'WAVE');
        writeString(view, 12, 'fmt ');
        view.setUint32(16, 16, true);
        view.setUint16(20, 3, true); // IEEE Float
        view.setUint16(22, 1, true); // Mono
        view.setUint32(24, 24000, true);
        view.setUint32(28, 24000 * 4, true);
        view.setUint16(32, 4, true);
        view.setUint16(34, 32, true);
        writeString(view, 36, 'data');
        view.setUint32(40, rawData.length * 4, true);

        // 波形データの書き込み
        for (let i = 0; i < rawData.length; i++) {
            view.setFloat32(44 + i * 4, rawData[i], true);
        }

        // 4. 結果を HEX (または Base64) で FileMaker に戻す
        // ここでは HEX 変換を例にします (プラグインが HEX を受ける想定)
        let hex = "";
        const hexBytes = new Uint8Array(wavBuffer);
        for (let b of hexBytes) hex += b.toString(16).padStart(2, '0').toUpperCase();

        document.getElementById('status').innerText = "完了";
        FileMaker.PerformScriptWithOption("return", hex, 5);

    } catch (e) {
        document.getElementById('status').innerText = "エラー: " + e.message;
    }
}
</script>

さて、リファレンス音声を録音して、セリフを登録したら「スクリプトを実行」してみます。無事に動作してクローンされた音声ファイルが作成されました。実行時間も 20 秒そこそこ程度で完了したので、コマンドラインから実行したのとほぼ同じ実行速度でした。

まとめ

ここまで長々と書き綴りましたが、結局 AI ってすごいな〜というのがわかりました。そして個人利用の mac でもボイスクローンが普通に動き、かなり高音質の音声ファイルが作成されるのがわかりました。今後はより一層、プライバシーやセキュリティに気をつける必要があるなと感じました。

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!
目次