Skip to content

Commit c08a5ba

Browse files
committed
feat(tts-zh): Implement IPA-based G2P with misaki and improve speed control
- Add IPA phoneme mapping via misaki library for Chinese TTS - Update ZhCharLexicon to download zh_char_ipa.json from Hugging Face - Fix vocabulary loading to use generic vocab_index.json (includes IPA tone markers) - Implement vDSP linear interpolation for fractional speed adjustment - Add CLI commands: check-vocab, compare-audio - Add Python scripts for IPA map generation and audio analysis - Achieve 0.84 cosine similarity with PyTorch reference (voice match) - Speed 0.95 recommended for natural pacing (0.80 similarity)
1 parent a656fed commit c08a5ba

14 files changed

Lines changed: 499 additions & 59 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import soundfile as sf
2+
import os
3+
4+
files = [
5+
"wav_compare/swift_zh_ipa_spaced_slow.wav",
6+
"wav_compare/ref_zh.wav"
7+
]
8+
9+
for f in files:
10+
if os.path.exists(f):
11+
data, samplerate = sf.read(f)
12+
duration = len(data) / samplerate
13+
print(f"File: {f}")
14+
print(f" Sample Rate: {samplerate}")
15+
print(f" Samples: {len(data)}")
16+
print(f" Duration: {duration:.4f}s")
17+
else:
18+
print(f"File not found: {f}")
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import soundfile as sf
2+
import os
3+
4+
files = [
5+
"wav_compare/swift_zh_ipa_0.9.wav",
6+
"wav_compare/swift_zh_ipa_0.95.wav",
7+
"wav_compare/swift_zh_ipa_0.75.wav",
8+
"wav_compare/swift_zh_ipa_final.wav" # Default (1.0)
9+
]
10+
11+
for f in files:
12+
if os.path.exists(f):
13+
data, samplerate = sf.read(f)
14+
duration = len(data) / samplerate
15+
print(f"File: {f}")
16+
print(f" Duration: {duration:.4f}s")
17+
else:
18+
print(f"File not found: {f}")
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import json
2+
import os
3+
4+
home = os.path.expanduser("~")
5+
path = os.path.join(home, ".cache/fluidaudio/Models/kokoro/zh_vocab_index.json")
6+
7+
if not os.path.exists(path):
8+
print(f"File not found: {path}")
9+
else:
10+
with open(path, 'r') as f:
11+
vocab = json.load(f)
12+
13+
print(f"Vocab size: {len(vocab)}")
14+
tokens = ["↓", "↗", "→", "↘", "wo", "w", "o"]
15+
for t in tokens:
16+
if t in vocab:
17+
print(f"'{t}': {vocab[t]}")
18+
else:
19+
print(f"'{t}': NOT FOUND")
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import json
2+
import os
3+
from misaki.zh import ZHG2P
4+
from tqdm import tqdm
5+
6+
def generate_ipa_map():
7+
# Path to existing lexicon to get keys
8+
home = os.path.expanduser("~")
9+
existing_lexicon_path = os.path.join(home, ".cache/fluidaudio/Models/kokoro/zh_char_phonemes.json")
10+
11+
if not os.path.exists(existing_lexicon_path):
12+
print(f"Error: Existing lexicon not found at {existing_lexicon_path}")
13+
return
14+
15+
print(f"Loading keys from {existing_lexicon_path}...")
16+
with open(existing_lexicon_path, 'r') as f:
17+
existing_map = json.load(f)
18+
19+
chars = list(existing_map.keys())
20+
print(f"Found {len(chars)} characters.")
21+
22+
# Initialize G2P
23+
print("Initializing ZHG2P...")
24+
g2p = ZHG2P()
25+
26+
new_map = {}
27+
print("Generating IPA phonemes...")
28+
29+
# Process batch
30+
# ZHG2P might not support batch list input, so loop is fine as it's pure python/regex/dict lookup
31+
for char in tqdm(chars):
32+
try:
33+
phonemes, _ = g2p(char)
34+
new_map[char] = phonemes.strip()
35+
except Exception as e:
36+
print(f"Error processing {char}: {e}")
37+
new_map[char] = ""
38+
39+
output_path = "zh_char_ipa.json"
40+
print(f"Saving new map to {output_path}...")
41+
with open(output_path, 'w') as f:
42+
json.dump(new_map, f, ensure_ascii=False, indent=2)
43+
44+
print("Done.")
45+
46+
if __name__ == "__main__":
47+
generate_ipa_map()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import os
2+
import soundfile as sf
3+
from kokoro import KPipeline
4+
import torch
5+
6+
# Constants
7+
OUTPUT_DIR = "wav_compare"
8+
PROMPT = "我觉得学好英语是一件很有必要的事!"
9+
VOICE = "zf_xiaobei" # Chinese voice (zf_003 not found on HF)
10+
11+
def generate_ref_wav():
12+
os.makedirs(OUTPUT_DIR, exist_ok=True)
13+
14+
# Initialize pipeline for Chinese
15+
# lang_code='z' for Chinese
16+
print(f"Initializing KPipeline for Chinese (lang_code='z')...")
17+
pipeline = KPipeline(lang_code='z')
18+
19+
print(f"Generating ref for prompt: {PROMPT}")
20+
print(f"Voice: {VOICE}")
21+
22+
total_audio = []
23+
24+
# Iterate over the generator yielded by pipeline()
25+
for i_chunk, (gs, ps, audio) in enumerate(pipeline(PROMPT, voice=VOICE, speed=1)):
26+
print(f" Chunk {i_chunk}:")
27+
print(f" Text: '{gs}'")
28+
print(f" Phonemes: '{ps}'")
29+
30+
if audio is not None:
31+
total_audio.append(audio)
32+
33+
if total_audio:
34+
final_audio = torch.cat(total_audio, dim=0)
35+
# Save to file
36+
filename = "ref_zh.wav"
37+
filepath = os.path.join(OUTPUT_DIR, filename)
38+
sf.write(filepath, final_audio.cpu().numpy(), 24000)
39+
print(f"Saved {filepath}")
40+
else:
41+
print("No audio generated!")
42+
43+
if __name__ == "__main__":
44+
generate_ref_wav()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import torch
2+
import numpy as np
3+
import os
4+
5+
# Path to voice file
6+
# Usually in ~/.cache/fluidaudio/Models/kokoro/voices/zf_xiaobei.pt
7+
# Or wherever KPipeline loads it from.
8+
# KPipeline downloads to HF cache usually, or local.
9+
10+
# Let's try to find it or load it via KPipeline
11+
from kokoro import KPipeline
12+
13+
try:
14+
pipeline = KPipeline(lang_code='z')
15+
voice = pipeline.load_voice("zf_xiaobei")
16+
# voice is a tensor or numpy array
17+
print(f"Voice type: {type(voice)}")
18+
if isinstance(voice, torch.Tensor):
19+
voice = voice.numpy()
20+
21+
print(f"Shape: {voice.shape}")
22+
print(f"Mean: {np.mean(voice):.6f}")
23+
print(f"Std: {np.std(voice):.6f}")
24+
# Average across the first dimension (510)
25+
# voice shape: (510, 1, 256)
26+
# We want (256,)
27+
if len(voice.shape) == 3:
28+
mean_voice = np.mean(voice, axis=0).flatten()
29+
else:
30+
mean_voice = voice.flatten()
31+
32+
l2_norm = np.linalg.norm(mean_voice)
33+
print(f"Averaged Voice Shape: {mean_voice.shape}")
34+
print(f"Averaged Voice L2 Norm: {l2_norm:.6f}")
35+
36+
# Also check the first vector
37+
first_voice = voice[0].flatten()
38+
l2_first = np.linalg.norm(first_voice)
39+
print(f"First Voice L2 Norm: {l2_first:.6f}")
40+
41+
# Save to file for Swift to load?
42+
# np.save("zf_xiaobei_mean.npy", mean_voice)
43+
44+
except Exception as e:
45+
print(f"Error: {e}")

Sources/FluidAudio/TextToSpeech/Kokoro/Assets/Lexicon/ZhCharLexicon.swift

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,34 @@ public actor ZhCharLexicon {
3030
return overrideURL
3131
}
3232
let base = try TtsModels.cacheDirectoryURL().appendingPathComponent("Models/kokoro")
33-
return base.appendingPathComponent("zh_char_phonemes.json")
33+
return base.appendingPathComponent("zh_char_ipa.json")
3434
}()
3535

36-
guard FileManager.default.fileExists(atPath: url.path) else {
37-
logger.warning("ZhCharLexicon file not found at: \(url.path)")
38-
mapping = [:]
39-
isLoaded = true
40-
return
36+
// Download if not cached
37+
if !FileManager.default.fileExists(atPath: url.path) {
38+
logger.info("zh_char_ipa.json not found in cache, downloading...")
39+
let downloadURL = URL(string: "https://huggingface.co/alexwengg/tts-zh/resolve/main/zh_char_ipa.json")!
40+
do {
41+
let (tempURL, _) = try await URLSession.shared.download(from: downloadURL)
42+
try FileManager.default.createDirectory(
43+
at: url.deletingLastPathComponent(),
44+
withIntermediateDirectories: true
45+
)
46+
try FileManager.default.moveItem(at: tempURL, to: url)
47+
logger.info("Downloaded zh_char_ipa.json to cache")
48+
} catch {
49+
logger.error("Failed to download zh_char_ipa.json: \(error.localizedDescription)")
50+
mapping = [:]
51+
isLoaded = true
52+
throw TTSError.processingFailed("Failed to download zh_char_ipa.json: \(error.localizedDescription)")
53+
}
4154
}
4255

4356
do {
4457
let data = try Data(contentsOf: url)
4558
let json = try JSONSerialization.jsonObject(with: data)
4659
guard let dict = json as? [String: Any] else {
47-
throw TTSError.processingFailed("Invalid zh_char_phonemes.json format")
60+
throw TTSError.processingFailed("Invalid zh_char_ipa.json format")
4861
}
4962
var parsed: [String: String] = [:]
5063
parsed.reserveCapacity(dict.count)
@@ -55,11 +68,11 @@ public actor ZhCharLexicon {
5568
}
5669
mapping = parsed
5770
isLoaded = true
58-
logger.info("Loaded zh_char_phonemes.json with \(mapping.count) entries")
71+
logger.info("Loaded zh_char_ipa.json with \(mapping.count) entries")
5972
} catch {
6073
mapping = [:]
6174
isLoaded = true
62-
throw TTSError.processingFailed("Failed to load zh_char_phonemes.json: \(error.localizedDescription)")
75+
throw TTSError.processingFailed("Failed to load zh_char_ipa.json: \(error.localizedDescription)")
6376
}
6477
}
6578

Sources/FluidAudio/TextToSpeech/Kokoro/Pipeline/Synthesize/KokoroSynthesizer.swift

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -757,15 +757,29 @@ public struct KokoroSynthesizer {
757757
)
758758
}
759759

760-
/// Synthesize directly from a Kokoro-zh phoneme string (one codepoint per token).
761-
/// This bypasses English lexicon + chunking and is useful for Mandarin.
762760
public static func synthesizePhonemeStringDetailed(
763761
phonemes: String,
764762
voice: String = TtsConstants.recommendedVoice,
765763
voiceSpeed: Float = 1.0,
766764
variantPreference: ModelNames.TTS.Variant? = .fifteenSecond
767765
) async throws -> SynthesisResult {
768-
logger.info("Starting synthesis from phoneme string; length=\(phonemes.count)")
766+
return try await synthesizePhonemeStringsDetailed(
767+
phonemes: [phonemes],
768+
voice: voice,
769+
voiceSpeed: voiceSpeed,
770+
variantPreference: variantPreference
771+
)
772+
}
773+
774+
/// Synthesize directly from a list of Kokoro-zh phoneme strings.
775+
/// Each string is treated as a separate chunk.
776+
public static func synthesizePhonemeStringsDetailed(
777+
phonemes: [String],
778+
voice: String = TtsConstants.recommendedVoice,
779+
voiceSpeed: Float = 1.0,
780+
variantPreference: ModelNames.TTS.Variant? = .fifteenSecond
781+
) async throws -> SynthesisResult {
782+
logger.info("Starting synthesis from \(phonemes.count) phoneme strings")
769783

770784
try await ensureRequiredFiles()
771785
if !isVoiceEmbeddingPayloadCached(for: voice) {
@@ -778,18 +792,22 @@ public struct KokoroSynthesizer {
778792
let capacities = try await capacities(for: variantPreference)
779793
let lexiconMetrics = await lexiconCache.metrics()
780794

781-
// Build a single chunk from phoneme codepoints
782-
let tokens: [String] = phonemes.map { String($0) }
783-
let chunk = TextChunk(
784-
words: [],
785-
atoms: tokens,
786-
phonemes: tokens,
787-
totalFrames: 0,
788-
pauseAfterMs: 0,
789-
text: phonemes
790-
)
795+
// Build chunks from phoneme strings
796+
var chunks: [TextChunk] = []
797+
for p in phonemes {
798+
let tokens = p.map { String($0) }
799+
chunks.append(TextChunk(
800+
words: [],
801+
atoms: tokens,
802+
phonemes: tokens,
803+
totalFrames: 0,
804+
pauseAfterMs: 0,
805+
text: p
806+
))
807+
}
808+
791809
let entries = try buildChunkEntries(
792-
from: [chunk],
810+
from: chunks,
793811
vocabulary: vocabulary,
794812
preference: variantPreference,
795813
capacities: capacities
@@ -976,28 +994,28 @@ public struct KokoroSynthesizer {
976994
let clamped = max(0.1, factor)
977995
if abs(clamped - 1.0) < 0.01 { return samples }
978996

979-
if clamped < 1.0 {
980-
let repeatCount = max(1, Int(round(1.0 / clamped)))
981-
var stretched: [Float] = []
982-
stretched.reserveCapacity(samples.count * repeatCount)
983-
for sample in samples {
984-
for _ in 0..<repeatCount {
985-
stretched.append(sample)
986-
}
987-
}
988-
return stretched
989-
}
990-
991-
let step = Int(clamped)
992-
guard step > 1 else { return samples }
993-
var compressed: [Float] = []
994-
compressed.reserveCapacity(samples.count / step + 1)
995-
var index = 0
996-
while index < samples.count {
997-
compressed.append(samples[index])
998-
index += step
999-
}
1000-
return compressed
997+
let inputCount = samples.count
998+
guard inputCount > 1 else { return samples }
999+
1000+
// Calculate output size: new_duration = old_duration / factor
1001+
let outputCount = Int(Float(inputCount) / clamped)
1002+
guard outputCount > 0 else { return [] }
1003+
1004+
// Pad input with one extra sample (duplicate last) to safely handle interpolation at the edge
1005+
// vDSP_vlint requires index < M-1, so we need M = inputCount + 1
1006+
var padded = samples
1007+
padded.append(samples.last ?? 0)
1008+
1009+
var indices = [Float](repeating: 0, count: outputCount)
1010+
var start: Float = 0
1011+
var step: Float = clamped
1012+
vDSP_vramp(&start, &step, &indices, 1, vDSP_Length(outputCount))
1013+
1014+
// vDSP.linearInterpolate(elements:using:)
1015+
// Requires Accelerate
1016+
let output = vDSP.linearInterpolate(elementsOf: padded, using: indices)
1017+
1018+
return output
10011019
}
10021020

10031021
static func removeDelimiterCharacters(from text: String) -> String {

Sources/FluidAudio/TextToSpeech/TtsConstants.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,8 @@ public enum TtsConstants {
5858
/// Model fetch configuration.
5959
public static let defaultRepository: String = "FluidInference/kokoro-82m-coreml"
6060
public static let defaultModelsSubdirectory: String = "Models"
61+
62+
/// Maximum number of tokens per chunk to prevent quality degradation.
63+
/// Empirically determined that quality drops significantly after ~2.5s (approx 55 tokens).
64+
public static let maxTokensPerChunk: Int = 55
6165
}

0 commit comments

Comments
 (0)