Skip to main content

8 posts tagged with "tts"

View All Tags

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

本文僅覆蓋「連續性測試」相關的主題。

連續性測試

下述內容與其他文章有高度關聯,缺乏上下文的情況可能很難理解。

關於通用 GPU 加速與標準化請見:

Qwen3 TTS 之旅:語音嵌入

關於資料視覺化請見:

Qwen3 TTS 之旅:資料視覺化


採集兩個人的聲音(例如:一男一女),並對其進行嵌入運算得到嵌入向量,再兩個向量進行線性內插。

將內插的數個向量分別給予 Voice Clone 模型進行運算,會得到「女 100% + 男 0 %」、「女 90% + 男 10 %」...的聲音,如果空間是連續的,理應得到一個漸近改變聲音的過程。

這是一個進行視覺化以前就應該先進行的基本測試,不論是 AI 還是教授(?)都建議我先做這個測試。之所以一拖再拖的原因是我尚未完成 TTS 相關程式基礎設施的建立,包含通用 GPU 加速與標準化。所以不是很想在實驗內加入需要運行太多次 TTS 的步驟。

不過也因為這個測試意外的讓我發現嵌入伺服器實作有 bug。

測試結果

它並沒有很明顯的漸進,因此我們可以知道特徵空間不是線性的,不過也沒有出現「非人聲音」或是 TTS 故障的現象,可以肯定空間至少是連續的。

至於女聲跟男聲在 60~70%時劇烈變化,問題原因可能在於兩個樣本處於空間的位置有關,

如果女聲的樣本距離男聲區很遠,自然大部份內插值都落於女聲區。

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

並且預計是系列文章的收尾。

標準化

關於模型組件的標準化細節請見:

Qwen3 TTS 之旅:語音嵌入

簡單來說,在在我的工作流程中,一個模型的標準化代表著:

  • OCI (Open Container Initiative) 映像檔封裝,可以透過 Docker 或其他相同技術佈署。
  • 可以透過 OpenAI-Compatible API 呼叫。
  • 通用 GPU 加速,使用 Vulkan、WebGPU...等通用 GPU 界面實現硬體加速,避免被硬體供應商鎖定。
  • 使用 Hugging Face SDK,除了分離程式碼與模型檔案管理的邏輯以外,可透過設定 HF_ENDPOINT 等參數指向鏡像站獲得本地下載加速。

從而將使用者的認知負荷降到最低,不論是實驗還是產品研發,調用者不用花費太多經歷在配置模型,這才符合我對「可用模型」的標準。換句話說,使用 CUDA 加速、凌亂的 Python 程式碼皆未達該門檻。

Qwen3 TTS 的 ONNX 移植

ONNX 是目前我覺得最可靠的格式,雖然可以想像轉檔相關的工具必定存在,只是我還是稍微搜尋一下看有沒有現成實作,畢竟能不自己寫程式就不自己寫(?)

DLL、C#、量化的方案不考慮,刪去法剃除後就只剩下 xkos/Qwen3-TTS-12Hz-1.7B-ONNX 看起來比較可行,但是它是 Hugging Face 上的野雞專案,作者也沒有提供足夠多的資訊構成公信力:

模型跟程式碼沒有分離,通通放在 Hugging Face 上,專案下也沒有足夠多的討論,這對我而言不夠成足夠多的可信度。

另一方面,xkos 的實作不知道為什麼比 Qwen3 官方的操作方式來得複雜,需要「預先生成」一些檔案。

安全性檢查

綜合上述上傳者缺乏公信力,程式碼又稱不上乾淨,模型相關的封裝全部擠在一個 1.3k 行的程式內,很難一眼看出有什麼問題,於是我用 opengrep 做了一個簡單的掃描:

$ opengrep scan --config auto . -v

synthesize.py
❯❯❱ trailofbits.python.pickles-in-numpy.pickles-in-numpy
Functions reliant on pickle can result in arbitrary code execution. Consider using fickling or
switching to a safer serialization method
Details: https://sg.run/ryKe

64┆ data = np.load(args.speaker, allow_pickle=True)

tts_engine.py
❯❯❱ trailofbits.python.pickles-in-numpy.pickles-in-numpy
Functions reliant on pickle can result in arbitrary code execution. Consider using fickling or
switching to a safer serialization method
Details: https://sg.run/ryKe

559┆ data = np.load(cache_path, allow_pickle=True)

掃到的兩個 pickle 都是前面說的「預先生成的檔案」,可能問題不大。ONNX 本身似乎也有風險1,但是已經是相對安全的模型格式。

info

沒用 semgrep 的原因是因為:

$ podman run --rm -v "$PWD:/src" docker.io/semgrep/semgrep:1.157.0-nonroot semgrep ci
run `semgrep login` before using `semgrep ci` or use `semgrep scan` and set `--config`
There were errors during analysis but Semgrep will succeed because there were no blocking findings, use --no-suppress-errors if you want Semgrep to fail when there are errors.

開源軟體還要我登入?相關的故事可以在 Rddit 的討論上看到2

重構與壞味道

info

以下不是針對 xkos,而是就程式碼本身進行探討。因為他至少也是把程式跟模型上傳給別人使用了,也有標記 Qwen3 TTS 原本的開源許可證,所以以下就稱呼他為「熱心鄉民」。

為了封裝成 API 伺服器,我需要對熱心鄉民的程式碼進行重構,抽出我用得上的邏輯,因為並不是所有 Qwen3 TTS 的模型我都需要,我只需要負責 Voice Clone 的模型。

程式碼與模型未分離

即便是 Qwen3-TTS 官方也是採取程式碼模型分離的策略。

熱心鄉民則是將程式碼與模型一同上傳到 Hugging Face,並且手刻 os.path.join 來讀取模型,同時將未量化模型與量化模型放在同一個 repo 內。

Qwen3-TTS 官方至少也是採取 1.7B 和 0.6B 兩種大小的模型分開來放的措施。

透過 Hugging Face SDK 實現程式與模型解偶除了我前面提到的可以用環境變數指定鏡像來源以外,還能在程式內實作「有用到才下載」的邏輯,這種整包上傳的方式,不是只能用 Git LFS 一次下載就是要一個手動挑選檔案,不管怎樣都不是應該發生在 Hugging Face SDK 已經成為實質產業標準的現代,屬於非常不成熟的作法。

違反使用直覺的封裝

在 Qwen3-TTS 的官方實作使用模型是像這樣的:

Sample Code
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
device_map="cuda:0",
dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)

# single inference
wavs, sr = model.generate_custom_voice(
text="其实我真的有发现,我是一个特别善于观察别人情绪的人。",
language="Chinese", # Pass `Auto` (or omit) for auto language adaptive; if the target language is known, set it explicitly.
speaker="Vivian",
instruct="用特别愤怒的语气说", # Omit if not needed.
)
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
device_map="cuda:0",
dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)

# single inference
wavs, sr = model.generate_voice_design(
text="哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
language="Chinese",
instruct="体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。",
)
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-Base",
device_map="cuda:0",
dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)

ref_audio = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone.wav"
ref_text = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."

wavs, sr = model.generate_voice_clone(
text="I am solving the equation: x = [-b ± √(b²-4ac)] / 2a? Nobody can — it's a disaster (◍•͈⌔•͈◍), very sad!",
language="English",
ref_audio=ref_audio,
ref_text=ref_text,
)

每一種模型的使用都非常直觀。

反觀熱心鄉民的程式長這樣:

# Step 1: Generate Global Cache (one-time) 
python generate_cache.py --model_dir ./model

# Step 2: Create Speaker Profile (once per voice)
Step 2: Create Speaker Profile (once per voice)
python create_speaker.py \
--model_dir ./model \
--ref_audio reference.wav \
--ref_text "Transcript of the reference audio" \
--language english \
--output ./speakers/my_voice.npz

# Step 3: Synthesize Speech
python synthesize.py \
--model_dir ./model \
--speaker ./speakers/my_voice.npz \
--text "The weather is wonderful today." \
--output output.wav

讓我無法理解的是 generate_cache.py 的設計,如果這是一個 pure function 每次都產生一樣的東西,那為什麼不乾脆做成模型直接載入,更別提為什麼 Qwen3 TTS 的官方實作就沒有這個問題?

當然,這可能是我對 ONNX 的理解還不夠深入而產生的疑問。

上帝物件

在熱心鄉民的程式碼中,tts_engine.py 總計 1.3k 行的程式碼中包含了一個 1k 行的 Qwen3TTSONNXInference class,同時處理了以下幾種模型:

  • 16f量化/原始
    • speaker_encoder.onnx
    • speech_tokenizer_decoder.onnx
    • speech_tokenizer_encoder.onnx
    • Vocie Design/Voice Clone
      • code_predictor.onnx
      • code_predictor_kv.onnx
      • text_embedding

至少 18 種排列組合,表面上在遵守 DRY 原則:避免重複撰寫載入模型的程式,但是實際上卻因為缺乏抽象,內部充滿大量的 if 判斷式,讓整個實例的狀態與可能性變得非常臃腫且不好輕易理解狀態變化。

舉例來說 def get_codec_embedding(self, input_ids: np.ndarray) -> np.ndarray: 的 call stack 需要追朔如下這麼多層才知道到底是用哪一個模型計算的:

# self.get_codec_embedding
# self.codec_embedding
# models["codec_embedding"]
# models = self._vc_models
# self._vc_models = self._load_talker_set(self.onnx_vc_dir, "voice_clone")
# self._load_talker_set
# result[name] = ONNXInferenceSession(path, self.use_gpu)
# ("codec_embedding", "codec_embedding.onnx"),

某某Service某某Repository 這在後端的軟體開發中是十分常見的模式,就算不使用反轉注入或工廠模式那種高深的 OOP 技巧,單純的根據職責切割、輾平也不會寫出這種毫無工程美感的程式碼。

info

我知道實務上超過一萬行的 class 並不罕見,但是以現代開發的建議來說,500 行以上就算多了3

可疑且意義不明的模型拆分

最後我終於碰到了放棄前的最後一個障礙:

(…)main/onnx/voice_clone/talker_decode.onnx: 100%|████████████████████████████████████████████████████████████████████████████████████████| 1.72M/1.72M [00:05<00:00, 308kB/s]
2026-04-05 22:51:44.604204820 [W:onnxruntime:, session_state.cc:1327 VerifyEachNodeIsAssignedToAnEp] Some nodes were not assigned to the preferred execution providers which may or may not have an negative impact on performance. e.g. ORT explicitly assigns shape related ops to CPU to improve perf.
2026-04-05 22:51:44.604225200 [W:onnxruntime:, session_state.cc:1329 VerifyEachNodeIsAssignedToAnEp] Rerunning with verbose output on a non-minimal build will show node assignments.
2026-04-05 22:51:45.667070708 [E:onnxruntime:, inference_session.cc:2600 operator()] Exception during initialization: filesystem error: cannot get file size: No such file or directory [/home/flyskypie/.cache/huggingface/hub/models--xkos--Qwen3-TTS-12Hz-1.7B-ONNX/snapshots/6023a58eba391c4e2dbe7ff2dd73fbc9f039c76e/onnx/voice_clone/layers.10.input_layernorm.weight]

熱心鄉民的 repo 內充滿了這種意義不明的小檔案:

原本我想說可能是上傳了一些中間文件,可能最後根本用不到,事實證明這些檔案是有仰賴關係的。

我試著用 netron 觀察 ONNX 檔案的模型結構,但是並沒有太大幫助:

注意右邊和下方的捲軸塊,顯示這是一張十分巨大的結構圖,可見這個 ONNX 模型似乎以非常不自然的方式被呈現。

接下來合理的方案應該是放棄熱心鄉民的實作,直接自己轉 ONNX,不過想必有額外不少知識需要理解,也不知道需要花多少時間,於是我決定這個旅途先到此為止。

小結

過程中的幾個 ETL 步驟的程式碼我整理過之後上傳 GitHub 歸檔了:

https://github.com/FlySkyPie/qwen3-tts-etl

這一系列文章其實也是寫給我自己的紀錄,方便過一陣子之後回來處理這個主題的時候可以透過文字回憶一下細節。


在「序」中,我提到了整個旅途是因 Qwen3 Voice Embedding 而起,不過實際上還有其他原因。

我對於包含建造仿生人形機器人在內以及 TTS 等技術都有興趣,這個部份的情感與思緒比較複雜,改天有機會再談。今天先談「資料專案」的部份。

工作的時候因為公司內部的 AI 專案做準備,當時讀了不少資料,其中一個鐵人 30 天系列我很推薦:

吵什麼 AI 煉金術?!你家有礦嗎?(資料領域必知的 30 個詞彙) :: 2023 iThome 鐵人賽

雖然內容缺乏組織,但是我認為建立基本概念以及提供足夠多的領域關鍵字上是十分適合的入門材料,ETL 跟資料專案的概念我就是從這系列文章為起點建立起來的。

求職未果後,我便想著我似乎還沒跑過一次資料專案,手上剛好有著一個資料集以及可以用來玩它的嵌入模型,就想著當著模擬跑一遍流程試試看,反正 S3 實例、PyPi 鏡像、Hugging Face 鏡像...等等資料專案大概會用到的基礎設施我都準備好了。

很遺憾花了兩個多禮拜並沒有達到我一開始預期的進度,不過我還有其他主題需要處理,只好先寫文章把結果做個整理之後先告一段落了。

Footnotes

  1. LobotoMl/ONNX_runtime_hacks at main · alkaet/LobotoMl. Retrieved 2026-04-07, from https://github.com/alkaet/LobotoMl/tree/main/ONNX_runtime_hacks

  2. Opengrep - a truly Open Source fork of the Code Security tool Semgrep - Announced : r/devops. Retrieved 2026-04-07, from https://www.reddit.com/r/devops/comments/1i83yde/opengrep_a_truly_open_source_fork_of_the_code/

  3. max-lines - ESLint - Pluggable JavaScript Linter. Retrieved 2026-04-07, from https://eslint.org/docs/latest/rules/max-lines

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

資料處理的幾個前置作業請見:

本文僅覆蓋「使用主成份分析進行資料視覺化」相關的主題。

PCA

主成份分析 (PCA, Principal component analysis) 簡單來說是一種對資料進行線性降維的技巧。

在開始寫程式之前我有稍微學習一下相關理論,學習筆記放在其他文章,細節我就不再此贅述:

Qwen3 TTS 之旅:流形學習

如果知道 PCA 原理的話大概可以直觀的發覺它不適合直接套用在 140k 筆 2048 維資料上,實際上可能需要使用增量 PCA (Incremental PCA),並搭配 joblib 實現持久化與斷點續傳之類的機制。

不過為了驗證整個流程的可行性,在正式跑以前,我先抽比較小的樣本直接跑 PCA,或是搭配其他非線性算法。

抽樣失敗

以下是我第一次嘗試,抽樣 1000 個資料跑 PCA 的結果:

我原本以為是資料原本特性的關係,所以試著跑 PCA 降到 50 維,再跑幾個非線性的降維:

不過結果都不太理想。

累積解釋變異圖

累積解釋變異圖 (Cumulative Explained Variance) 是根據特徵值大小推論不同維度主成份的「資訊量」(或理解為「影響力」):

綠色是每一個主成份的資訊量佔比,紅色曲線是把前 N 個主成份的佔比累計,上述的「取 50 維」是根據這個圖表中佔比 75% 對應的主成份數得來的。

抽樣成功

後來在做連續性測試的時候才發現嵌入伺服器實作有問題,簡單來說就是 Garbage in, garbage out 的具體例子,細節不在此贅述,請見其他文章:

Qwen3 TTS 之旅:語音嵌入

修復後重新跑一遍終於正常了,男聲和女聲的差異在三個主成份以內就能分離:

Wei Ji

前情提要

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

本文僅覆蓋「資料預處理」相關的主題。

Mozilla Common Voice

Common Voice 是 由 Mozilla 基金會所发起的群眾參與專案,旨在為語音辨識軟體建立自由資料庫,並且主要宗旨為建立多樣化的語音樣本,因此裡面也包含了一個台灣語音資料集,2023 年的時候我就以防萬一下載了一份放在手邊。

關於 Mozilla Common Voice 我認為有一點值得一提,2024 年的時候我收到這樣一封電子郵件:

大意就是語音資料的貢獻者想要撤銷貢獻,因此 Mozilla 通知下載過資料集的人(也就是我)刪除對應的資料。

先不談這個機制是否對下載的人有實質約束力,至少在這個「AI 公司」在網際網路上掠奪數據與資料,致個人的資料權利於無物的時代,Mozilla Common Voice 可以說是一股清流,至少我持有的資料是這些志工自願貢獻的,在法律與道德上我皆有權利在合理的範圍內自由使用。

然而我並沒有對 2023 年的資料做適度的處理,也很難從中挑出要被撤銷的檔案,於是我直接重新下載了一份 2026 版的。

資料集與預處理

Mozilla Common Voice 2026 (cv-corpus-25.0-2026-03-09/zh_TW) 內容物大致上長這樣:

├── clips/*.mp3
├── validated_sentences.tsv
├── unvalidated_sentences.tsv
├── other.tsv
├── validated.tsv
└── invalidated.tsv

首先映入眼簾的是 .tsv 檔案,它們提供了標籤資訊,也就是某個 .mp3 是誰※貢獻的、什麼性別、什麼年齡區間、字稿...。

info

資料集本身是使用去識別化的 id 去辨識貢獻者,同時 Mozilla Common Voice 的使用者授權也明確的要求下載的人禁止對貢獻者進行再識別,所以若後續文章我需要描述「某人」時,我會使用專案內部重新生成的 id,而不是資料集本身提供的 id。

這是一個尚未經過正規化 (Normalization) 的資料,如果我要讓應用程式能夠更方便的提取特定貢獻者的資料或是其他標籤(性別...等),勢必需要使用關聯資料庫,並對資料進行正規化。

另外一個問題是該資料集包含 140k 筆語音資料,也就是 140k 個 .mp3 檔案,而這種在檔案系統上大量且碎片化的檔案在進行遷移(複製)時非常耗時,

因此我先把這些原始資料預處理成 SQLite 檔案,並用 BLOB 資料欄位儲存 .mp3

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

本文僅覆蓋「資料集嵌入」相關的主題。

大型資料處理

即便在 ONNX 中使用 WebGPU 實現硬體加速,資料集包含 140k 筆資料,逐一進行嵌入依然需要歷時兩個小時左右。

info

關於嵌入伺服器的更多資訊請見: Qwen3 TTS 之旅:語音嵌入 本步驟的前置作業請見: Qwen3 TTS 之旅:資料集預處理

運行當下 intel_gpu_top 顯示只有使用一半,另一方面所有 CPU 則是跑滿的狀態且負載皆在伺服器端,但是我並沒有仔細研究性能瓶頸在哪裡,推測是伺服器的 mp3 轉換的影響。 當然也有 JSON 和 base64 的解編碼運算,不過這件事情資料處理端也有,但是並沒有佔用太多 CPU,因此可能性比較低。

因此我花了一點時間實作了運算進度存檔的機制,以 100 筆資料為一個單位,除了將 100 筆作為一個 Transaction 以外,還會在用於紀錄進度的資料表紀錄進度,若過程中中斷,下次運算便可從紀錄點繼續。

向量資料庫

Milvus 是蠻有名氣的向量資料庫實作之一,不過當下我並沒有檢索的需求,僅需要一個儲存的載體,因此選擇了其 Lite 版本,會將資料儲存成 SQLite 格式。

然而實際使用時在運算到 4.9k 的資料左右會出現異常:

Assert "suc"  => failed to parse insert data from records at /workspace/milvus-lite/thirdparty/milvus/internal/core/src/segcore/segment_c.cpp:303

不確定是不是到達 1M~10M 左右的官方上限:

info

嵌入向量是 2048 維,中斷時大約是 4.9k資料;因此: 4900×2048≈10M

所以最後將向量的 JSON 序化成字串後直接使用 SQLite 儲存,雖然效率很差,但是當下最重要的是先把資料做嵌入之後找地方放,而且方便後續步驟提取,應該避免無謂的為了效率而提高程式碼複雜度。

SQLite 雖然是「輕量」的資料庫實作,但是不代表它隨便,專案下的測試程式碼甚至比實作本身還多。並且在摸索資料的階段,可攜性、方便轉移複製版控...與生產環境需要考量的性能、低冗餘與最佳化...屬於不同情境。

dataset

在進行這個主題的額外收穫是發現這個名為 dataset 的 Python 套件,它可以像這樣:

import dataset

db = dataset.connect('sqlite:///:memory:')

table = db['sometable']
table.insert(dict(name='John Doe', age=37))
table.insert(dict(name='Jane Doe', age=34, gender='female'))

john = table.find_one(name='John Doe')

直接操作一個 SQLite 實例讀寫資料,無須預先定義 Schema 或是新增資料表。

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

在我開始撰寫主成份分析、視覺化...等程式以前,先對相關的領域知識進行學習,最後定位了「流形學習 (manifold learning)」這個領域,雖然「流形學習」這個詞比較正式且有嚴謹的定義,不過本文比較接近學習筆記。

本文雖然跟學術性的文章比起來隨性很多,但是跟其他軟體實作相比又抽象了許多,所以額外拉出來當成獨立的主題談:

視覺化與降維

一個語音經過 Qwen3 模型嵌入之後,會得到 2048 個數字,這 2048 個數字稍微學術一點會被稱呼為「高維向量」或是「高維空間」,2048 個數字排列組合構成的所有可能性便是「空間」,特定的 2048 個數字,也就是某個資料點就是「向量」。

然而人類並不能直接理解這 2048 個數字代表的意思,也就是人類不能理解高維空間或是高維向量的內含。

理解空間的其中一種方法就是視覺化,但是紙張只能呈現二維的圖像、就算透過軟體與之互動也只能呈現三維的畫面,那麼我們要如何把 2048 個數字變成等效的 3 個數字然後做成圖表呢?是的這個過程就是降維。

「沒吃過豬也看過豬走路」是我的中心思想之一,在進行更進一步的介紹以前我們先隨便看幾個降維的例子吧1

它分別對兩個三維的資料用各種不同的方法降成二維。

流形 (Manifolds)

warning

以下是關於流形這個概念的通俗解釋,不具備數學上的嚴謹性。

「2048 維的高維空間」聽起來好像很厲害,不過我們可以這樣假設:

這個高維空間其實很稀疏

舉例來說,從宏觀角度來看宇宙,會發現它像海綿一樣充滿了空孔2

只有少數的空間有被恆星和物質填充。有興趣的朋友可以聽聽看 YouTube 頻道 Kurzgesagt 的介紹

又或是揉成一團的報紙,即便我們在三維空間觀察它是一個球體,但是它實際上充滿了空氣,真正有資訊的部份是以二維紙張的形式分佈的。

這個實際上是低維情報,但是在高維空間中被觀察到的東西,就是流形。

info

嚴謹的流形定義還包含了「局部近似歐幾里得空間」,只是解釋起來比較麻煩,所以以上描述省略了。 就像地球表面是弧形的,但是放大之後會接近平面。

流形學習則是把揉成球的報紙攤平的過程,並且是一種機器學習。

降維

最簡單的降維方式就是投影:

現實的例子就是你跟你的影子,找一個平面/方向就能把三維的資料壓成二維的,如何找到那個「正確的平面」則是最重要的問題。

然後讓我們回來看這張圖:

可以發現 S 型的資料在 ISOMAP 算法下被攤成漂亮的方形,接著再想想要投影出這個漂亮的方形的「面」長什麼樣子?想必不是一個平面對吧?

學術上會把這兩者分別稱呼成線性跟非線性的降維技術。

流形學習其實就是降維炫泡一點的講法。

PCA

主成份分析 (PCA, Principal component analysis) 簡單來說是一種對資料進行線性降維的技巧,而且屬於非監督機器學習。

OS:恩?怎麼突然之間我就機器學習了!?(◐_◑)

至於不簡單來說嘛...它建立在不少統計與數學工具與概念之上...

敘述統計

把幾個基本的敘述統計概念列一下。

  • 母體平均數 (Mean)
    • μ\mu
  • 標準差
    • σ=1Ni=1N(xiμ)2\sigma ={\sqrt {{\frac {1}{N}}\sum _{i=1}^{N}(x_{i}-\mu )^{2}}}
    • 需要先建立在平均數之上。
  • 變異數 (Variance)
    • Var(X)=σ2\operatorname {Var} (X)=\sigma ^{2}
    • 概念上是標準差的平方,但是實務上通常會先算出變異數,開根號之後才會得到標準差。

共變異數 (Covariance)

cov(X,Y)=1Ni=1N(xiμx)(yiμy)\operatorname {cov}(X, Y) = {{\frac {1}{N}}\sum _{i=1}^{N}(x_{i}-\mu_x )(y_{i}-\mu_y )}

計算兩個隨機變數的關聯性,越接近零代表兩個隨機變數越不相關。

共變異數矩陣(Covariance Matrix)

共變異數矩陣是 pp 個特徵交叉進行獲得共變異數構成的矩陣。以下是五維資料的共變異數矩陣例子3

共變異數矩陣的符號:

  • C=[Cjj]\mathbf {C} =[C_{jj'}]
  • KXiXj=cov[Xi,Xj]\operatorname {K} _{X_{i}X_{j}}=\operatorname {cov} [X_{i},X_{j}]
  • 在一些地方會使用符號 Σ\Sigma 代表。

特徵值與特徵向量

一般的 PCA 介紹大概會提到這個步驟:

對共變異數矩陣求特徵值 (eigenvalue) 與特徵向量 (eigenvector)

但是這個步驟開始比較抽象了,特徵值?特徵向量是啥?為什麼共變異矩陣的特徵值跟特徵向量會跟主成份有關系?

這裡我要試著用工程數學的經隨:先射箭再畫靶,來解釋這件事情。

首先我們假設一個白化空間 (whitening space),每一個維度都是標準常態分佈且每一個維度的隨機變數都獨立於彼此:

在這個 nn 維空間的向量可以透過一個 n×nn\times n矩陣變換到另外一個歪曲的空間去,圓形可能變成橢圓形。

共變異數矩陣反應的就是這個白化空間經過變換矩陣得到的觀察結果,那個歪曲的空間就是我們觀測到的空間,因此求那個矩陣的的特徵值與特徵向量就是分解這個矩陣的變換行為。

特徵向量是原本在這個白化空間中正交的向量經過變換後在觀察空間的表示,而特徵值則是從白化空間變換到觀察空間拉伸的比率。

所以我們可以知道,白化空間中一部分的維度在觀察空間中會被放大,換言之,觀察空間中的主要變化可能來自於白化空間中少數幾個維度的貢獻。

這個白化空間就是我們的主成份空間。(這句話嚴謹意義上不完全正確,但是方便理解)

解釋變異量 (Explained Variance)

解釋變異量是各個特徵值在所有特徵值和的佔比,代表著主成份空間某個維度對觀察空間資訊量貢獻的比例,因為特徵值越大、成份在觀察空間中放大的程度就越大。

將解釋變異量排序後畫成圖表便能一目了然各個主成份的佔比為何,積分後便得到累積解釋變異圖(Cumulative Explained Variance):

綠色方條是解釋變異量;紅色曲線是累積解釋變異。透過累積解釋變異我們可以知道降維到幾個主成份可以保留足夠多的特徵。

實務上可取 70% 左右的固定值或是觀察拐點;即便主成份比例低,但是其他成份分佈過於均勻的話依然可以視為雜訊。

PCA 降維

PCA 的最後一個步驟。有了特徵向量、特徵值並選定主成份維度,便可構造一個矩陣把高維資料進行線性變換以實現降維了。

Footnotes

  1. Principal Component Analysis(PCA) | by venkateshtantravahi | Medium. Retrieved 2026-04-06, from https://vtantravahi.medium.com/principal-component-analysis-pca-37dc2c22cdf0

  2. New Research Supports the Idea That We Live in a Void. Retrieved 2026-04-06, from https://scitechdaily.com/new-research-supports-the-idea-that-we-live-in-a-void/

  3. Principal Component Analysis Made Easy: A Step-by-Step Tutorial | by Marcus Sena | TDS Archive | Medium. Retrieved 2026-04-06, from https://medium.com/data-science/principal-component-analysis-made-easy-a-step-by-step-tutorial-184f295e97fe

Wei Ji

從我踏入 Qwen3 TTS 這個兔子洞到現在也已經兩個多禮拜了 (2026-03-20~2026-04-06),即便還沒徹底完成一開始設定的目標也該設定一個存檔點;把歷程做個紀錄,然後回去忙其他事情了。然而因為整個過程並不是一個線性的故事,很難結構化的描述,因此本文會先概述整個事件的全面以及這個兔子洞是怎麼發生的,細節將由其他文章補充。

火種

這個旅途說是因為一張圖而起的也不違過:

Qwen3-TTS 是 2026 年一月釋出極為先進的 TTS 開放權重模型,而有個老兄將該模型前段一區用於進行語音嵌入的類神經網路抽出做成可獨立運行的模型1,這裡要稍微解釋一下這件事為什麼了不起,從嵌入的角度來說:

  1. OpenAI-Compatible API 中的 /embeddings 端點僅覆蓋了「文字嵌入」,語音嵌入在工程應用領域依然相對稀疏。(細節我會在文章後面的內容詳談)
  2. 它是開箱即用的開放權重模型,不像一些語音嵌入模型僅有論文。
  3. 該模型產出的嵌入向量可作為後端聲音複製 (Voice Clone) 模型的輸入,因此該嵌入向量是有額外工程用途的,而不是單純比較餘弦距離來判斷語音音色的相似度。

從語音的角度:

  1. 基於文字的 TTS 其輸出並不穩定,因為「一個沈重的男子聲音」可以存在無限多種解。
  2. 大部分基於語音複製的 TTS 需要仰賴原音樣本的存在。
  3. 大部分基於語音複製模型須提供語音的內文標籤,往往會鎖定特定語言。(原音樣本鎖定特定語言)

因此只要能調控嵌入空間,就能產生一個穩定的 TTS 客製化流水線。

構想

於是一個構想油然而生:

透過資料降維的技術將一個已知資料集的嵌入向量視覺化,使用者在三維的的空間中可以選定某個資料點試聽,同時也可以也可以選定空間中不存在資料點的空間,透過降維的反函數生成嵌入向量,將開嵌入向量傳遞給 TTS 進行生成。

在三維空間中透過已知資料集的標籤與特徵讓使用者理解某個區域具有某些語音特徵,例如:男性特徵、女性特徵、年長者特徵...。

簡單來說這是一個能夠客製化聲音的 TTS 工作流程。

旅程概覽

整個旅途到目前為止大概可以分成幾個主題:

  • Voice Embedding: 我把玩 Qwen3 Voice Embedding 以及進行一些模組化的過程。
  • Audio ETL (Extract, Transform, Load):對 cv-corpus-25.0-2026-03-09 (Mozilla Common Voice 25.0) 進行一些預處理的過程。
  • Embedding ETL:對資料集進行嵌入的過程。
  • PCA ETL:對嵌入向量的資料集進行主成份分析 (Principal components analysis) 的過程。
  • Qwen3 TTS ONNX:我把玩 Qwen3 TTS ONNX 以及試圖進行一些模組化的過程。
  • Continuity Test:嵌入空間連續性測試。
  • Manifold Learning:對「資料降維」這一領域進行學習的過程。

並且上述主題並沒有明確的嵌後順序,而且事件相互交錯,在後續的文章我會試著僅以一個主題為主軸、相關事件為輔的方式描述,讓整個敘述比較不會太混亂。

Footnotes

  1. Qwen3's most underrated feature: Voice embeddings : r/LocalLLaMA. Retrieved 2026-04-02, from https://www.reddit.com/r/LocalLLaMA/comments/1rc59ze/qwen3s_most_underrated_feature_voice_embeddings/

Wei Ji

前情提要

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

本文僅覆蓋「語音嵌入」相關的主題。

"運行" Qwen3-Voice-Embedding

我當然可以依照作者提供的程式碼直接運行這個模型:

import librosa
import torch
from transformers import AutoModel, AutoProcessor

processor = AutoProcessor.from_pretrained(
"marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B", trust_remote_code=True,
)
model = AutoModel.from_pretrained(
"marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B", trust_remote_code=True,
)
model.eval()

audio, sr = librosa.load("audio.wav", sr=None, mono=True)
inputs = processor(audio, sampling_rate=sr)

with torch.no_grad():
embedding = model(**inputs).last_hidden_state # (1, 2048)

但是對我而言「運行」不只是這樣,它必須遵守幾個基本要件:

  1. 軟體編排的解偶:模型推論本質上是仰賴 GPU 的重負載運算,因此不能跟應用程式的業務邏輯耦合在一起,必須使用 OpenAI-Compatible API。
  2. 硬體的解偶:不能對特定的 GPU 品盤形成供應商鎖定,因此不能使用 CUDA。
  3. GPU 加速:這類模型典型的「無 CUDA 備用方案」是直接降回使用 CPU 運算,這對我而言是不能接受。

簡化之後的描述為:

  1. 軟體編排的解偶:封裝成 OpenAI-Compatible API。
  2. 硬體的解偶:在不使用 CUDA, ROCm...等專有 SDK 的前提實現 GPU 加速推論。

軟體編排的解偶

info

關於這個主題我有撰寫一份非線性筆記

OpenAI 嵌入 API 設計上只能處理「文字→向量」的嵌入運算,從官方的規格書可以看到:

輸入必然為字串,原因是 OpenAI 提供的嵌入模型僅有:text-embedding-3-smalltext-embedding-3-largetext-embedding-ada-002 這幾款文字嵌入模型,因此無法處理多模態問題,諸如嵌入音訊、影像、圖像...之類的。

即便參考其他多模態嵌入 API 設計,其 API 設計僅部份參考 OpenAI API 但不兼容,例如:

LCO-Embedding-Omni-7B-GGUF 透過 llama.cpp 的 API:
curl -s http://localhost:8080/embeddings \
-d '{"content": [{"prompt_string": "<__media__>", "multimodal_data": ["<base64-audio-data>"]}]}'
nvidia/llama-nemotron-embed-vl-1b-v2 透過 OpenRouter 的 API:
curl https://openrouter.ai/api/v1/embeddings \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "nvidia/llama-nemotron-embed-vl-1b-v2:free",
"input": [
{
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://live.staticflickr.com/3851/14825276609_098cac593d_b.jpg"}}
]
}
],
"encoding_format": "float"
}'
mistralai/voxtral-small-24b-2507 透過 OpenRouter 的 API:
curl https://openrouter.ai/api/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-d '{
"model": "mistralai/voxtral-small-24b-2507",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What is in this audio?"
},
{
"type": "input_audio",
"input_audio": {
"data": "UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB",
"format": "wav"
}
}
]
}
}'

它本身並不是嵌入模型,但是作為「使用 OpenAI API 輸入音訊檔案」的參照。

最後我決定透過引入 RFC 2397 與 RFC 3003 並同時遵守 OpenAI API 的設計哲學並兼容 OpenAI 嵌入 API,同時擴增支援多模態的能力。

# Note: "input" also supports batch processing with arrays: ["text1", "text2", "text3"]
curl https://openrouter.ai/api/v1/embeddings \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B",
"input": "data:audio/mpeg;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA
AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO
9TXL0Y4OHwAAAABJRU5ErkJggg==",
"encoding_format": "float"
}'

硬體的解偶

我原本想遵循之前使用 Zero123++ 時的路徑,使用 IPEX (Intel® Extension for PyTorch) 來實現 GPU 加速,不過過程並不順利,然而 Qwen3-Voice-Embedding 上傳者也準備了一份 ONNX 版本的模型,於是我便研究一下 ONNX 這條路徑要怎麼跑這個模型。

就像 llama.cpp 支援諸如 CUDA、Vulkan ...之類的多種後端 (backend) 一樣,ONNX 則是透過 EP (Execution Provider) 的架構來和運行時的硬體加速解偶1,同時它也允許運行在網頁瀏覽器使用 WebGPU 作為 EP,然而官方文件與資料卻鮮少提及如何在 Python 使用 WebGPU 作為 EP。

經過一番搜尋終於找到了線索,總之需要安裝 onnxruntime-webgpu 這個套件,程式碼大致如下:

run.py
import numpy as np
import onnxruntime as ort
import librosa
from huggingface_hub import hf_hub_download

model_path = hf_hub_download(
repo_id="marksverdhei/Qwen3-Voice-Embedding-12Hz-1.7B-onnx",
filename="speaker_encoder_fp32.onnx",
)

# Load model
session = ort.InferenceSession(
model_path,
providers=["WebGpuExecutionProvider"],
)

# Compute mel spectrogram (must match training preprocessing)
audio, sr = librosa.load("female.mp3", sr=24000, mono=True)
mel = librosa.feature.melspectrogram(
y=audio,
sr=24000,
n_fft=1024,
hop_length=256,
n_mels=128,
fmin=0,
fmax=12000,
)
mel = np.log(np.clip(mel, a_min=1e-5, a_max=None))
mel = mel.T[np.newaxis, ...] # (1, time, 128)

# Run inference
embedding = session.run(None, {"mel_spectrogram": mel.astype(np.float32)})[0]
print(embedding)

最後做個補充,我亦考慮過 llama.cpp 的 GGUF 路徑來解決這個問題,然而 GGUF 的生態系是以 LLAMA,即 LLM 建立起來的,對於多模態應用生態系覆蓋的不夠完善,舉例來說,我們可以在上述程式碼看到使用 librosa 來處理音訊檔案,這在 llama.cpp 中可不是只靠一個 mmprog (多模態映射)就能在類神經結構上解決的問題,而需要仰賴額外的音訊整理實做;API 的格式也是相同的情況,從 LCO-Embedding-Omni-7B-GGUF 就可以看到非通用標準的 API 格式。

結論

封裝成 OpenAI-Compatible API 之後,不論是後續實驗還是生產佈署都可以大幅簡化環境設定,只要有 Docker 就能跑起來:

services:
qwen3-voice-embedding-server:
image: ghcr.io/flyskypie/qwen3-voice-embedding-server:0.1.2
devices:
- /dev/dri/:/dev/dri/
ports:
- 8000:8000
environment:
- HF_HOME=/cache
volumes:
- ./cache:/cache

程式碼:https://github.com/FlySkyPie/qwen3-voice-embedding-server

插曲

一直到在進行連續性測試的時候,才發現嵌入伺服器這邊有 bug:輸入女聲進行嵌入、嵌入向量 TTS 出來的聲音是男聲。

雖然早在進行視覺化的前期就有徵兆了:

可以看到男聲(藍色資料)跟女聲(粉紅色)混在一起,但是當時以為是資料特性,並沒有意識到是嵌入出 bug。

原因在於 pytorch 的實作中並沒有複雜的參數設定:

audio, sr = librosa.load("audio.wav", sr=None, mono=True)
inputs = processor(audio, sampling_rate=sr)

而 ONNX 的有:

audio, sr = librosa.load("audio.wav", sr=24000, mono=True)
mel = librosa.feature.melspectrogram(
y=audio, sr=24000, n_fft=1024, hop_length=256,
n_mels=128, fmin=0, fmax=12000,
)

特別是採樣率的部份我疏忽了,造成輸入的樣本在處理過程中被放慢兩倍,因而產生女聲變男聲的問題。

Footnotes

  1. Execution Providers | onnxruntime. Retrieved 2026-04-02, from https://onnxruntime.ai/docs/execution-providers/