only-bible-app v3.7.2+24

#kotlin#android#ios

git clone https://git.pyrossh.dev/only-bible-app

The only bible app you will ever need. No ads. No in-app purchases. No distractions.


4dcf728 — Peter John 2026-07-22T21:15:37+05:30
move bible-tts to models

Generated files not shown: bible-tts/uv.lock

bible-tts/.gitignore DELETED
@@ -1,4 +0,0 @@
1
- __pycache__/
2
- *.pyc
3
- .venv/
4
- .pytest_cache/
bible-tts/README.md DELETED
@@ -1,296 +0,0 @@
1
- # Bible-TTS
2
-
3
- A purpose-built text-to-speech model for the English Bible using a textless two-stage architecture. Designed for mobile deployment (Android + iOS via Flutter) at **~5 MB** (int8 quantized).
4
-
5
- > **Size:** ~5.6 MB (int8 ONNX) | ~16 MB (float16 ONNX) | ~11 MB APK impact with ONNX Runtime
6
-
7
- ## Architecture
8
-
9
- ```
10
- Text ──→ CharTokenizer ──→ FlowLM Transformer ──→ VQ Decoder ──→ Audio
11
- (3 layers, d=256) (512-entry codebook, 94Hz)
12
- ```
13
-
14
- Two-stage textless approach:
15
- - **VQ-VAE Codec** (~2.5M params): Compresses 24kHz audio into 94Hz discrete tokens (512-entry codebook). Trained on your narrator's voice.
16
- - **FlowLM Transformer** (~3M params): Character-level → predicts codec token sequences using flow matching (LSD).
17
-
18
- No phonemes, no linguistic features, no forced alignment needed for the model itself (alignment is only needed for data preparation).
19
-
20
- ## Prerequisites
21
-
22
- - Python 3.10+
23
- - PyTorch 2.5+
24
- - ~40 hours of English Bible audio (single narrator)
25
- - For chapter-to-verse splitting: [Montreal Forced Aligner](https://montreal-forced-aligner.readthedocs.io/)
26
-
27
- ## Quick Start
28
-
29
- ```bash
30
- # Install dependencies
31
- uv sync
32
-
33
- # All pipelines are in src/bible_tts/{data,models,training,inference}/
34
- # Scripts at scripts/{create_manifest,encode_audio,split_chapters_to_verses}.py
35
- ```
36
-
37
- ---
38
-
39
- ## Data Preparation
40
-
41
- ### If you have verse-level audio
42
-
43
- Place WAV files named `{Book}_{Chapter}_{Verse}.wav` in a directory, then:
44
-
45
- ```bash
46
- uv run python3 scripts/create_manifest.py \
47
- --bible-text en_bsb.txt \
48
- --audio-dir /path/to/verse/audio \
49
- --output data/manifest.jsonl
50
- ```
51
-
52
- ### If you have chapter-level audio (recommended pipeline)
53
-
54
- Split chapters into individual verse WAVs using forced alignment:
55
-
56
- ```bash
57
- # Install MFA
58
- pip install montreal-forced-aligner
59
-
60
- # Chapter audio files should be named: {Book}_{Chapter}.wav
61
- # e.g., "Genesis_1.wav", "Exodus_15.wav", "1_Samuel_17.wav"
62
-
63
- uv run python3 scripts/split_chapters_to_verses.py \
64
- --bible-text en_bsb.txt \
65
- --audio-dir /path/to/chapter/audio \
66
- --output-dir /path/to/verse/audio
67
-
68
- # This produces:
69
- # /path/to/verse/audio/Genesis_1_1.wav
70
- # /path/to/verse/audio/Genesis_1_2.wav
71
- # ... (31K files)
72
- # /path/to/verse/audio/manifest.jsonl
73
- ```
74
-
75
- **What the script does:**
76
- 1. Groups verses from `en_bsb.txt` by chapter
77
- 2. Creates text files with `{verse_N}` markers for MFA
78
- 3. Runs Montreal Forced Aligner to get word-level timestamps
79
- 4. Extracts verse boundaries from the alignment
80
- 5. Splits chapter WAVs at verse boundaries at 24kHz
81
- 6. Writes a `manifest.jsonl` ready for training
82
-
83
- > **Timing:** MFA takes ~2-4 hours for 40 hours of audio across 66 books.
84
-
85
- ---
86
-
87
- ## Training Pipeline
88
-
89
- ### Phase 1: Train VQ-VAE Codec (2 days on 1 GPU)
90
-
91
- Trains the neural audio codec that compresses waveforms into discrete tokens.
92
-
93
- ```bash
94
- uv run python3 src/bible_tts/training/train_vqvae.py \
95
- --manifest /path/to/verse/audio/manifest.jsonl \
96
- --checkpoint-dir checkpoints \
97
- --batch-size 16 \
98
- --max-steps 200000
99
-
100
- # Checkpoints saved to: checkpoints/vqvae_best.pt, checkpoints/vqvae_final.pt
101
- ```
102
-
103
- ### Phase 2: Encode Audio to Tokens (1 day)
104
-
105
- Runs the trained VQ-VAE on all audio to produce (text, code_tokens) pairs.
106
-
107
- ```bash
108
- uv run python3 scripts/encode_audio.py \
109
- --checkpoint checkpoints/vqvae_best.pt \
110
- --manifest /path/to/verse/audio/manifest.jsonl \
111
- --output data/encoded_data.jsonl
112
- ```
113
-
114
- Output is a JSONL file with one entry per verse:
115
- ```json
116
- {"text": "In the beginning God created...", "codes": [42, 157, 33, ...], "audio_path": ".../Genesis_1_1.wav"}
117
- ```
118
-
119
- ### Phase 3: Train FlowLM Transformer (3 days on 1 GPU)
120
-
121
- Trains the text-to-codec flow-matching transformer.
122
-
123
- ```bash
124
- uv run python3 src/bible_tts/training/train_flow.py \
125
- --encoded-data data/encoded_data.jsonl \
126
- --checkpoint-dir checkpoints \
127
- --batch-size 32 \
128
- --max-steps 300000
129
-
130
- # Checkpoints saved to: checkpoints/flow_best.pt, checkpoints/flow_final.pt
131
- ```
132
-
133
- ### Phase 4: Export for Mobile
134
-
135
- Export the two ONNX models for Android/iOS:
136
-
137
- ```bash
138
- # Export trained models to ONNX
139
- uv run python3 -c "
140
- from bible_tts.inference.export_onnx import export_flow_lm_onnx, export_vqvae_decoder_onnx
141
- export_flow_lm_onnx('checkpoints/flow_best.pt', 'models/flow_lm.onnx')
142
- export_vqvae_decoder_onnx('checkpoints/vqvae_best.pt', 'models/vq_decoder.onnx')
143
- "
144
-
145
- # Also export the codebook weights (needed for nearest-neighbor search)
146
- uv run python3 -c "
147
- import torch
148
- state = torch.load('checkpoints/vqvae_best.pt', map_location='cpu')
149
- codebook = state['model']['quantizer.codebook.weight']
150
- codebook.numpy().tofile('models/codebook_weights.bin')
151
- print(f'Exported codebook: {codebook.shape}')
152
- "
153
- ```
154
-
155
- ---
156
-
157
- ## Mobile Deployment (Flutter)
158
-
159
- Copy the two `.onnx` files and `codebook_weights.bin` to your Flutter project's `assets/models/`.
160
-
161
- ### pubspec.yaml
162
-
163
- ```yaml
164
- dependencies:
165
- flutter_onnxruntime: ^0.2.0
166
- audioplayers: ^6.0.0
167
-
168
- flutter:
169
- assets:
170
- - assets/models/flow_lm.onnx
171
- - assets/models/vq_decoder.onnx
172
- - assets/models/codebook_weights.bin
173
- ```
174
-
175
- ### Dart Integration
176
-
177
- Full Dart implementation in `docs/mobile_integration.md`. Core inference loop:
178
-
179
- ```dart
180
- class BibleTTS {
181
- Future<Float32List> synthesize(String text) async {
182
- final tokens = _tokenize(text);
183
- final codeTokens = <int>[];
184
-
185
- for (int step = 0; step < 300; step++) {
186
- // 1. Run FlowLM: text + last code → next latent
187
- final outputs = await _flowSession.run({
188
- 'text_tokens': textTensor,
189
- 'code_tokens': codeTensor,
190
- });
191
- // 2. Quantize: latent → nearest codebook entry
192
- final nextCode = _quantize(outputs['next_latent']);
193
- codeTokens.add(nextCode);
194
- if (_isEos(outputs['is_eos'])) break;
195
- }
196
-
197
- // 3. Run VQ Decoder: code sequence → audio waveform
198
- return _decoderSession.run({'indices': codeTensor})['audio'];
199
- }
200
- }
201
- ```
202
-
203
- ### sherpa-onnx
204
-
205
- Our two-stage pipeline (FlowLM + VQ decoder) doesn't match sherpa-onnx's built-in TTS model types (VITS, Matcha-TTS, Kokoro). For custom models, use `flutter_onnxruntime` directly — it works identically on Android and iOS.
206
-
207
- See `docs/mobile_integration.md` for complete Dart, Kotlin, and Swift code.
208
-
209
- ---
210
-
211
- ## Model Architecture Details
212
-
213
- ### VQ-VAE Codec
214
-
215
- | Parameter | Value |
216
- |---|---|
217
- | Frame rate | 93.75 Hz (stride 256 = 4×4×4) |
218
- | Codebook | 512 entries × 64 dim |
219
- | Encoder channels | 32 → 64 → 128 → 256 |
220
- | Decoder channels | 256 → 128 → 64 → 32 |
221
- | Total params | ~3.3M |
222
- | Training | 200K steps, AdamW (lr=3e-4), Cosine LR, 5K warmup |
223
-
224
- ### FlowLM Transformer
225
-
226
- | Parameter | Value |
227
- |---|---|
228
- | Layers | 3 |
229
- | Model dim | 256 |
230
- | Heads | 4 |
231
- | FFN hidden | 1024 |
232
- | Position | RoPE |
233
- | Conditioning | AdaLN |
234
- | Decode | LSD (4 steps at inference) |
235
- | Total params | ~4.6M |
236
- | Training | 300K steps, AdamW (lr=2e-4), Cosine LR, 5K warmup |
237
-
238
- ### Inference Model Size
239
-
240
- | Component | float16 | int8 |
241
- |---|---|---|
242
- | FlowLM ONNX | ~12 MB | ~3 MB |
243
- | VQ Decoder ONNX | ~4 MB | ~1 MB |
244
- | Codebook | 128 KB | 128 KB |
245
- | **Total** | **~16 MB** | **~4 MB** |
246
-
247
- ---
248
-
249
- ## Project Structure
250
-
251
- ```
252
- bible-tts/
253
- ├── src/bible_tts/
254
- │ ├── data/
255
- │ │ ├── audio_dataset.py # AudioDataset — loads WAV + text pairs
256
- │ │ └── text_processor.py # CharTokenizer — 88-token char vocab
257
- │ ├── models/
258
- │ │ ├── vq_vae.py # VQVAE, Encoder, Decoder, VectorQuantizer, ResBlock
259
- │ │ ├── flow_lm.py # FlowLMModel, StreamingTransformer, FlowMLP
260
- │ │ └── lsd.py # lsd_decode — Lagrangian Self Distillation
261
- │ ├── training/
262
- │ │ ├── train_vqvae.py # VQ-VAE training CLI
263
- │ │ └── train_flow.py # FlowLM training CLI
264
- │ └── inference/
265
- │ ├── pipeline.py # TTSPipeline — text→audio inference
266
- │ └── export_onnx.py # ONNX export for mobile
267
- ├── scripts/
268
- │ ├── create_manifest.py # Create JSONL manifest from verse WAVs
269
- │ ├── encode_audio.py # Encode audio through trained VQ-VAE
270
- │ └── split_chapters_to_verses.py # Chapter→verse splitting with MFA
271
- ├── tests/ # 16 tests, all passing
272
- ├── docs/
273
- │ ├── mobile_integration.md # Flutter/Kotlin/Swift integration guide
274
- │ ├── superpowers/specs/ # Design specification
275
- │ └── superpowers/plans/ # Implementation plan
276
- └── pyproject.toml
277
- ```
278
-
279
- ## Tests
280
-
281
- ```bash
282
- uv run pytest tests/ -v
283
- # 16 passed
284
- ```
285
-
286
- ## Performance Targets
287
-
288
- | Device | Latency per verse | Notes |
289
- |---|---|---|
290
- | iPhone 13+ | 200-400ms | 3-5× realtime |
291
- | Pixel 6+ | 300-500ms | 2-3× realtime |
292
- | Mid-range Android | 500-800ms | 1-2× realtime |
293
-
294
- ## License
295
-
296
- MIT
bible-tts/docs/android_integration.md DELETED
@@ -1,232 +0,0 @@
1
- # Android Integration Guide
2
-
3
- ## Dependencies
4
-
5
- Add to your `app/build.gradle.kts`:
6
-
7
- ```kotlin
8
- dependencies {
9
- implementation("com.microsoft.onnxruntime:onnxruntime-android:latest")
10
- }
11
- ```
12
-
13
- ## Model Files
14
-
15
- Copy the exported ONNX models to `app/src/main/assets/models/`:
16
- - `flow_lm.onnx` (~12 MB float16)
17
- - `vq_decoder.onnx` (~4 MB float16)
18
-
19
- **Total model size: ~16 MB** (can be reduced to ~5 MB with int8 quantization).
20
-
21
- ## Model Preparation
22
-
23
- Before building the Android app, run the export scripts to generate ONNX models:
24
-
25
- ```bash
26
- # From the bible-tts project root
27
- uv run python3 -c "
28
- from bible_tts.inference.export_onnx import export_flow_lm_onnx, export_vqvae_decoder_onnx
29
- export_flow_lm_onnx('checkpoints/flow_best.pt', 'models/flow_lm.onnx')
30
- export_vqvae_decoder_onnx('checkpoints/vqvae_best.pt', 'models/vq_decoder.onnx')
31
- "
32
- ```
33
-
34
- ## Basic Usage (Kotlin)
35
-
36
- ```kotlin
37
- package com.example.bibletts
38
-
39
- import android.content.Context
40
- import android.util.Log
41
- import ai.onnxruntime.OnnxTensor
42
- import ai.onnxruntime.OrtEnvironment
43
- import ai.onnxruntime.OrtSession
44
- import java.io.File
45
- import java.nio.FloatBuffer
46
-
47
- class BibleTTS(private val context: Context) {
48
- private val environment: OrtEnvironment
49
- private val flowSession: OrtSession
50
- private val decoderSession: OrtSession
51
-
52
- // Character tokenizer vocabulary
53
- companion object {
54
- private val CHARS = listOf(
55
- ' ', '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/',
56
- '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?',
57
- '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O',
58
- 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', '\\', ']', '^', '_',
59
- '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
60
- 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~'
61
- )
62
- private const val BOS_IDX = 1L
63
- private const val EOS_IDX = 2L
64
- private const val CODEBOOK_SIZE = 512
65
- private const val MAX_FRAMES = 300
66
- private const val LSD_STEPS = 4
67
- }
68
-
69
- init {
70
- environment = OrtEnvironment.getEnvironment()
71
- val sessionOptions = OrtSession.SessionOptions()
72
- sessionOptions.setIntraOpNumThreads(2)
73
-
74
- flowSession = environment.createSession(
75
- loadModelFile("models/flow_lm.onnx"), sessionOptions
76
- )
77
- decoderSession = environment.createSession(
78
- loadModelFile("models/vq_decoder.onnx"), sessionOptions
79
- )
80
- }
81
-
82
- fun synthesize(text: String): FloatArray {
83
- val tokens = charTokenize(text)
84
-
85
- // Autoregressive generation loop
86
- val codeTokens = mutableListOf<Long>()
87
- for (step in 0 until MAX_FRAMES) {
88
- val currentCodes = if (codeTokens.isEmpty()) {
89
- longArrayOf(0L) // BOS
90
- } else {
91
- longArrayOf(codeTokens.last())
92
- }
93
-
94
- // Run flow LM
95
- val textTensor = OnnxTensor.createTensor(
96
- environment, longArrayOf(1, tokens.size.toLong()), tokens
97
- )
98
- val codeTensor = OnnxTensor.createTensor(
99
- environment, longArrayOf(1, 1), currentCodes
100
- )
101
-
102
- val inputs = mapOf(
103
- "text_tokens" to textTensor,
104
- "code_tokens" to codeTensor,
105
- )
106
- val outputs = flowSession.run(inputs)
107
-
108
- // Get next latent and quantize
109
- val nextLatent = outputs[0].floatBuffer
110
- val nextCode = quantize(nextLatent)
111
- codeTokens.add(nextCode)
112
-
113
- // Check EOS
114
- val isEos = outputs[1].floatBuffer[0] > 0.5f
115
- if (isEos && step > 10) break
116
-
117
- textTensor.close()
118
- codeTensor.close()
119
- outputs.forEach { it.close() }
120
- }
121
-
122
- // Decode codes to audio
123
- val codeArray = codeTokens.toLongArray()
124
- val codeTensor = OnnxTensor.createTensor(
125
- environment, longArrayOf(1, codeArray.size.toLong()), codeArray
126
- )
127
- val decoderOutputs = decoderSession.run(mapOf("indices" to codeTensor))
128
- val audio = decoderOutputs[0].floatBuffer
129
- val audioArray = FloatArray(audio.remaining()).also { audio.get(it) }
130
-
131
- codeTensor.close()
132
- decoderOutputs.forEach { it.close() }
133
-
134
- return audioArray
135
- }
136
-
137
- private fun charTokenize(text: String): LongArray {
138
- val tokens = mutableListOf(BOS_IDX)
139
- for (c in text) {
140
- val idx = CHARS.indexOf(c).takeIf { it >= 0 } ?: 0
141
- tokens.add(idx.toLong() + 3) // offset by 3 for PAD/BOS/EOS
142
- }
143
- tokens.add(EOS_IDX)
144
- return tokens.toLongArray()
145
- }
146
-
147
- private fun quantize(latent: FloatBuffer): Long {
148
- // Nearest-neighbor search against codebook
149
- // In production, the codebook weights would be loaded from the checkpoint
150
- // For now, returns a random code (placeholder)
151
- return (latent[0] * 1000).toLong().mod(CODEBOOK_SIZE).toLong()
152
- }
153
-
154
- private fun loadModelFile(path: String): File {
155
- val file = File(context.filesDir, path)
156
- if (!file.exists()) {
157
- context.assets.open(path).use { input ->
158
- file.outputStream().use { output ->
159
- input.copyTo(output)
160
- }
161
- }
162
- }
163
- return file
164
- }
165
-
166
- fun close() {
167
- flowSession.close()
168
- decoderSession.close()
169
- environment.close()
170
- }
171
- }
172
- ```
173
-
174
- ## Performance
175
-
176
- | Device | Latency per verse | Notes |
177
- |---|---|---|
178
- | Pixel 6+ | 300-500ms | 2-3× realtime |
179
- | Pixel 4 | 500-800ms | ~1-2× realtime |
180
- | Budget Android | 1-2s | May need int8 quantization |
181
-
182
- ## Memory
183
-
184
- - Peak inference memory: ~30 MB
185
- - Model loading: ~20 MB (both models)
186
- - Steady state: ~15 MB after GC
187
-
188
- ## Size Optimization
189
-
190
- ### int8 Quantization
191
-
192
- Apply quantization after ONNX export using `onnxruntime`'s quantization tools:
193
-
194
- ```python
195
- from onnxruntime.quantization import quantize_dynamic, QuantType
196
-
197
- quantize_dynamic("models/flow_lm.onnx", "models/flow_lm_int8.onnx", weight_type=QuantType.QInt8)
198
- quantize_dynamic("models/vq_decoder.onnx", "models/vq_decoder_int8.onnx", weight_type=QuantType.QInt8)
199
- ```
200
-
201
- This reduces models from ~16 MB to ~5 MB with minimal quality loss.
202
-
203
- ### Model Bundling
204
-
205
- For production, bundle models in the APK under `assets/models/`. ONNX Runtime Android supports loading from assets directly:
206
-
207
- ```kotlin
208
- val session = environment.createSession(
209
- context.assets.openFd("models/flow_lm.onnx"),
210
- OrtSession.SessionOptions()
211
- )
212
- ```
213
-
214
- ## Streaming Playback
215
-
216
- For low-latency display, chunk the generated audio and play via `AudioTrack`:
217
-
218
- ```kotlin
219
- val audioTrack = AudioTrack(
220
- AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build(),
221
- AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
222
- .setSampleRate(24000).setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(),
223
- bufferSize, AudioTrack.MODE_STREAM
224
- )
225
- audioTrack.play()
226
- audioTrack.write(audio, 0, audio.size, AudioTrack.WRITE_BLOCKING)
227
- ```
228
-
229
- ## Notes
230
-
231
- - The ONNX export script creates models without KV caching for simplicity. For production, implement KV caching in the model to reduce per-step computation by ~3×.
232
- - The quantized int8 models may give slightly different output than float16. Test with WER evaluation before deploying.
bible-tts/docs/mobile_integration.md DELETED
@@ -1,413 +0,0 @@
1
- # Mobile Integration Guide (Android + iOS)
2
-
3
- ## Approach: ONNX Runtime via Flutter
4
-
5
- The bible-tts model exports as two ONNX models that run on any platform ONNX Runtime supports. For Flutter, use `flutter_onnxruntime` (wraps ONNX Runtime for both Android and iOS) or native method channels.
6
-
7
- ## Model Preparation
8
-
9
- Export ONNX models from your trained checkpoints:
10
-
11
- ```bash
12
- uv run python3 -c "
13
- from bible_tts.inference.export_onnx import export_flow_lm_onnx, export_vqvae_decoder_onnx
14
- export_flow_lm_onnx('checkpoints/flow_best.pt', 'models/flow_lm.onnx')
15
- export_vqvae_decoder_onnx('checkpoints/vqvae_best.pt', 'models/vq_decoder.onnx')
16
- "
17
- ```
18
-
19
- Copy the two `.onnx` files to your Flutter project's `assets/models/`.
20
-
21
- ## Flutter Integration (Recommended)
22
-
23
- ### Add dependencies
24
-
25
- ```yaml
26
- # pubspec.yaml
27
- dependencies:
28
- flutter_onnxruntime: ^0.2.0 # ONNX Runtime for both platforms
29
- audioplayers: ^6.0.0 # Audio playback
30
- ```
31
-
32
- ### Model assets
33
-
34
- ```yaml
35
- # pubspec.yaml
36
- flutter:
37
- assets:
38
- - assets/models/flow_lm.onnx
39
- - assets/models/vq_decoder.onnx
40
- ```
41
-
42
- ### Dart Inference Code
43
-
44
- ```dart
45
- import 'dart:typed_data';
46
- import 'dart:math';
47
- import 'package:flutter/services.dart';
48
- import 'package:flutter_onnxruntime/flutter_onnxruntime.dart';
49
-
50
- class BibleTTS {
51
- late OrtSession _flowSession;
52
- late OrtSession _decoderSession;
53
- late OrtEnvironment _env;
54
-
55
- // Character tokenizer: 81 printable ASCII + space
56
- static const _CHARS = ' !\"#\$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
57
- static const _BOS_IDX = 1;
58
- static const _EOS_IDX = 2;
59
- static const _CODEBOOK_SIZE = 512;
60
- static const _MAX_FRAMES = 300;
61
-
62
- BibleTTS._();
63
-
64
- static Future<BibleTTS> create() async {
65
- final tts = BibleTTS._();
66
- _env = OrtEnvironment();
67
- await _env.init();
68
-
69
- final flowBytes = await rootBundle.load('assets/models/flow_lm.onnx');
70
- final decoderBytes = await rootBundle.load('assets/models/vq_decoder.onnx');
71
- final sessionOptions = OrtSessionOptions()..setIntraOpNumThreads(2);
72
-
73
- tts._flowSession = await _env.createSession(flowBytes.buffer.asUint8List(), sessionOptions);
74
- tts._decoderSession = await _env.createSession(decoderBytes.buffer.asUint8List(), sessionOptions);
75
- return tts;
76
- }
77
-
78
- Float32List synthesize(String text) {
79
- final tokens = _charTokenize(text);
80
- final codeTokens = <int>[];
81
-
82
- // Autoregressive generation loop
83
- for (int step = 0; step < _MAX_FRAMES; step++) {
84
- final currentCodes = Int64List.fromList(
85
- codeTokens.isEmpty ? [0] : [codeTokens.last],
86
- );
87
-
88
- final textTensor = OrtTensor.int64([1, tokens.length], tokens);
89
- final codeTensor = OrtTensor.int64([1, 1], currentCodes);
90
-
91
- final outputs = _flowSession.run({
92
- 'text_tokens': textTensor,
93
- 'code_tokens': codeTensor,
94
- });
95
-
96
- final nextLatent = outputs['next_latent']!.float32List;
97
- final isEos = outputs['is_eos']!.float32List[0] > 0.5;
98
-
99
- final nextCode = _quantize(nextLatent);
100
- codeTokens.add(nextCode);
101
-
102
- if (isEos && step > 10) break;
103
- }
104
-
105
- // Decode code tokens to audio
106
- final codeArray = Int64List.fromList(codeTokens);
107
- final codeTensor = OrtTensor.int64([1, codeArray.length], codeArray);
108
- final decoderOutputs = _decoderSession.run({'indices': codeTensor});
109
- return decoderOutputs['audio']!.float32List;
110
- }
111
-
112
- Int64List _charTokenize(String text) {
113
- final tokens = <int>[_BOS_IDX];
114
- for (int i = 0; i < text.length; i++) {
115
- final idx = _CHARS.indexOf(text[i]);
116
- tokens.add(idx >= 0 ? idx + 3 : 0); // offset by 3 for PAD/BOS/EOS
117
- }
118
- tokens.add(_EOS_IDX);
119
- return Int64List.fromList(tokens);
120
- }
121
-
122
- int _quantize(Float32List latent) {
123
- // Nearest-neighbor search against codebook
124
- // In production, load codebook weights from checkpoint
125
- final codebook = _loadCodebook();
126
- int bestIdx = 0;
127
- double bestDist = double.infinity;
128
- for (int i = 0; i < codebook.length; i++) {
129
- double dist = 0;
130
- for (int j = 0; j < min(latent.length, codebook[i].length); j++) {
131
- dist += (latent[j] - codebook[i][j]) * (latent[j] - codebook[i][j]);
132
- }
133
- if (dist < bestDist) {
134
- bestDist = dist;
135
- bestIdx = i;
136
- }
137
- }
138
- return bestIdx;
139
- }
140
-
141
- List<Float32List> _loadCodebook() {
142
- // TODO: Load from a bundled codebook_weights.bin file
143
- // For now, dummy placeholder
144
- return List.generate(_CODEBOOK_SIZE, (_) => Float32List(64));
145
- }
146
-
147
- void dispose() {
148
- _flowSession.release();
149
- _decoderSession.release();
150
- _env.release();
151
- }
152
- }
153
- ```
154
-
155
- ### Audio Playback
156
-
157
- ```dart
158
- import 'package:audioplayers/audioplayers.dart';
159
-
160
- class BibleReader {
161
- final BibleTTS tts;
162
- final AudioPlayer player = AudioPlayer();
163
-
164
- Future<void> speak(String verse) async {
165
- final audio = tts.synthesize(verse);
166
- // Convert float32 to WAV bytes
167
- final wavBytes = _float32ToWav(audio, 24000);
168
- await player.play(BytesSource(wavBytes));
169
- }
170
-
171
- Uint8List _float32ToWav(Float32List samples, int sampleRate) {
172
- // Convert to 16-bit PCM WAV
173
- final numSamples = samples.length;
174
- final dataSize = numSamples * 2;
175
- final buffer = ByteData(44 + dataSize);
176
-
177
- // WAV header
178
- buffer.setUint8(0, 0x52); buffer.setUint8(1, 0x49); // RIFF
179
- buffer.setUint8(2, 0x46); buffer.setUint8(3, 0x46);
180
- buffer.setUint32(4, 36 + dataSize, Endian.little);
181
- buffer.setUint8(8, 0x57); buffer.setUint8(9, 0x41); // WAVE
182
- buffer.setUint8(10, 0x56); buffer.setUint8(11, 0x45);
183
- buffer.setUint8(12, 0x66); buffer.setUint8(13, 0x6D); // fmt
184
- buffer.setUint8(14, 0x74); buffer.setUint8(15, 0x20);
185
- buffer.setUint32(16, 16, Endian.little);
186
- buffer.setUint16(20, 1, Endian.little); // PCM
187
- buffer.setUint16(22, 1, Endian.little); // mono
188
- buffer.setUint32(24, sampleRate, Endian.little);
189
- buffer.setUint32(28, sampleRate * 2, Endian.little);
190
- buffer.setUint16(32, 2, Endian.little); // block align
191
- buffer.setUint16(34, 16, Endian.little); // bits per sample
192
- buffer.setUint8(36, 0x64); buffer.setUint8(37, 0x61); // data
193
- buffer.setUint8(38, 0x74); buffer.setUint8(39, 0x61);
194
- buffer.setUint32(40, dataSize, Endian.little);
195
-
196
- // Sample data
197
- for (int i = 0; i < numSamples; i++) {
198
- final sample = (samples[i].clamp(-1.0, 1.0) * 32767).round();
199
- buffer.setInt16(44 + i * 2, sample.clamp(-32768, 32767), Endian.little);
200
- }
201
-
202
- return buffer.buffer.asUint8List();
203
- }
204
- }
205
- ```
206
-
207
- ## Native Integration (Kotlin — Android)
208
-
209
- For native Android without Flutter, see the Kotlin code in the original guide at `docs/android_integration.md`.
210
-
211
- ## Native Integration (Swift — iOS)
212
-
213
- ### Dependencies
214
-
215
- ```swift
216
- // Package.swift or SPM
217
- dependencies: [
218
- .package(url: "https://github.com/microsoft/onnxruntime-swift", from: "1.15.0")
219
- ]
220
- ```
221
-
222
- ### Swift Inference Code
223
-
224
- ```swift
225
- import ONNXRuntime
226
-
227
- class BibleTTS {
228
- private let flowSession: ORTSession
229
- private let decoderSession: ORTSession
230
- private let env: ORTEnv
231
-
232
- // Character set for tokenizer
233
- private let chars = Array(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~")
234
- private let bosIdx: Int64 = 1
235
- private let eosIdx: Int64 = 2
236
- private let codebookSize: Int64 = 512
237
- private let maxFrames = 300
238
-
239
- init() throws {
240
- env = try ORTEnv(loggingLevel: .warning)
241
- let options = try ORTSessionOptions()
242
- options.setIntraOpNumThreads(2)
243
-
244
- let flowPath = Bundle.main.path(forResource: "flow_lm", ofType: "onnx")!
245
- let decoderPath = Bundle.main.path(forResource: "vq_decoder", ofType: "onnx")!
246
-
247
- flowSession = try ORTSession(env: env, modelPath: flowPath, sessionOptions: options)
248
- decoderSession = try ORTSession(env: env, modelPath: decoderPath, sessionOptions: options)
249
- }
250
-
251
- func synthesize(_ text: String) throws -> [Float] {
252
- let tokens = charTokenize(text)
253
- var codeTokens: [Int64] = []
254
-
255
- for step in 0..<maxFrames {
256
- let currentCodes: [Int64] = codeTokens.isEmpty ? [0] : [codeTokens.last!]
257
-
258
- let textShape: [NSNumber] = [1, NSNumber(value: tokens.count)]
259
- let textData = NSMutableData(bytes: tokens, length: tokens.count * MemoryLayout<Int64>.size)
260
- let textTensor = try ORTValue(tensorData: textData, elementType: .int64, shape: textShape)
261
-
262
- let codeShape: [NSNumber] = [1, 1]
263
- let codeData = NSMutableData(bytes: currentCodes, length: currentCodes.count * MemoryLayout<Int64>.size)
264
- let codeTensor = try ORTValue(tensorData: codeData, elementType: .int64, shape: codeShape)
265
-
266
- let outputs = try flowSession.run(
267
- withInputs: ["text_tokens": textTensor, "code_tokens": codeTensor],
268
- outputNames: ["next_latent", "is_eos"],
269
- runOptions: nil
270
- )
271
-
272
- let nextLatent = try outputs["next_latent"]!.tensorData() as Data
273
- let isEosData = try outputs["is_eos"]!.tensorData() as Data
274
-
275
- var isEos: Float32 = 0
276
- isEosData.withUnsafeBytes { ptr in
277
- isEos = ptr.load(as: Float32.self)
278
- }
279
-
280
- let latentArr = nextLatent.withUnsafeBytes { ptr -> [Float32] in
281
- Array(ptr.bindMemory(to: Float32.self))
282
- }
283
- let nextCode = quantize(latentArr)
284
- codeTokens.append(nextCode)
285
-
286
- if isEos > 0.5 && step > 10 { break }
287
- }
288
-
289
- // Decode to audio
290
- let codeShape: [NSNumber] = [1, NSNumber(value: codeTokens.count)]
291
- let codeData = NSMutableData(bytes: codeTokens, length: codeTokens.count * MemoryLayout<Int64>.size)
292
- let codeTensor = try ORTValue(tensorData: codeData, elementType: .int64, shape: codeShape)
293
-
294
- let decoderOutputs = try decoderSession.run(
295
- withInputs: ["indices": codeTensor],
296
- outputNames: ["audio"],
297
- runOptions: nil
298
- )
299
-
300
- let audioData = try decoderOutputs["audio"]!.tensorData() as Data
301
- return audioData.withUnsafeBytes { ptr -> [Float] in
302
- Array(ptr.bindMemory(to: Float.self))
303
- }
304
- }
305
-
306
- private func charTokenize(_ text: String) -> [Int64] {
307
- var tokens: [Int64] = [bosIdx]
308
- for char in text {
309
- if let idx = chars.firstIndex(of: char) {
310
- tokens.append(Int64(idx) + 3)
311
- } else {
312
- tokens.append(0) // PAD for unknown chars
313
- }
314
- }
315
- tokens.append(eosIdx)
316
- return tokens
317
- }
318
-
319
- private func quantize(_ latent: [Float32]) -> Int64 {
320
- // Nearest-neighbor codebook search
321
- // Load codebook from bundled file in production
322
- return Int64.random(in: 0..<codebookSize)
323
- }
324
- }
325
- ```
326
-
327
- ## Using sherpa-onnx
328
-
329
- sherpa-onnx supports offline TTS but expects specific model architectures (VITS, Matcha-TTS, Kokoro, Piper). Our two-stage pipeline (FlowLM + VQ decoder) doesn't match these natively.
330
-
331
- **Option 1: Use sherpa-onnx's general ONNX runtime**
332
-
333
- sherpa-onnx internally uses ONNX Runtime. You can run each model through its underlying engine:
334
-
335
- ```dart
336
- // sherpa_onnx dart package provides access to the underlying
337
- // audio processing and playback. For custom models, use
338
- // flutter_onnxruntime and play via audioplayers (shown above).
339
- ```
340
-
341
- **Option 2: Bundle both models via Flutter assets (recommended)**
342
-
343
- The Flutter approach above (`flutter_onnxruntime`) works identically on Android and iOS — no platform-specific code needed. This is the simplest path.
344
-
345
- **Option 3: Export a single combined model**
346
-
347
- If you want sherpa-onnx native TTS support, the cleanest path is to merge FlowLM + VQ decoder into a single ONNX model:
348
-
349
- ```python
350
- class CombinedTTS(torch.nn.Module):
351
- def __init__(self, flow_model, vqvae_model):
352
- super().__init__()
353
- self.flow = flow_model
354
- self.codebook = vqvae_model.quantizer.codebook
355
- self.vq_decoder = vqvae_model.decoder
356
-
357
- def forward(self, text_tokens):
358
- # Single forward pass: text → audio
359
- # Run autoregressive loop internally
360
- ...
361
- ```
362
-
363
- This is more complex but lets you use sherpa-onnx's `OfflineTts` directly with a single model file.
364
-
365
- ## Platform Comparison
366
-
367
- | | Android | iOS |
368
- |---|---|---|
369
- | ONNX Runtime | `onnxruntime-android` | `onnxruntime-cpu` (iOS) |
370
- | Flutter plugin | `flutter_onnxruntime` | same plugin |
371
- | Model size | ~16 MB (float16) / ~5 MB (int8) | same |
372
- | Inference (Pixel 6 / iPhone 13) | 300-500ms | 200-400ms |
373
-
374
- ## Codebook Bundling
375
-
376
- The quantizer needs the 512×64 codebook weights at inference. Export them:
377
-
378
- ```python
379
- import numpy as np
380
- codebook = model.vqvae.quantizer.codebook.weight.detach().numpy()
381
- codebook.tofile("models/codebook_weights.bin")
382
- ```
383
-
384
- Load in Dart:
385
- ```dart
386
- final codebookBytes = await rootBundle.load('assets/models/codebook_weights.bin');
387
- final codebook = Float32List.view(codebookBytes.buffer);
388
- // Shape: [512, 64]
389
- ```
390
-
391
- ## Size Optimization
392
-
393
- Apply int8 quantization after export:
394
-
395
- ```python
396
- from onnxruntime.quantization import quantize_dynamic, QuantType
397
- quantize_dynamic("models/flow_lm.onnx", "models/flow_lm_int8.onnx", weight_type=QuantType.QInt8)
398
- quantize_dynamic("models/vq_decoder.onnx", "models/vq_decoder_int8.onnx", weight_type=QuantType.QInt8)
399
- ```
400
-
401
- | Format | Size per model | Total |
402
- |---|---|---|
403
- | float16 | ~12 MB + ~4 MB | ~16 MB |
404
- | int8 | ~3 MB + ~1 MB | ~4 MB |
405
-
406
- ## Performance Targets
407
-
408
- | Device | Latency | RTF |
409
- |---|---|---|
410
- | iPhone 13+ | 200-400ms | 3-5× realtime |
411
- | Pixel 6+ | 300-500ms | 2-3× realtime |
412
- | Mid-range Android | 500-800ms | 1-2× realtime |
413
- | Budget phones | 1-2s | <1× realtime |
bible-tts/docs/superpowers/plans/2026-07-08-bible-tts-implementation.md DELETED
@@ -1,1804 +0,0 @@
1
- # Bible-TTS Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Build a textless TTS model for the English Bible using VQ-VAE codec + flow matching transformer, targeting < 10MB on Android.
6
-
7
- **Architecture:** Two-stage pipeline. Stage 1: VQ-VAE codec compresses 24kHz audio to 94Hz discrete tokens (512-entry codebook). Stage 2: character-level flow-matching transformer predicts token sequences from text. Inference uses the VQ decoder + flow transformer only.
8
-
9
- **Tech Stack:** Python 3.10+, PyTorch 2.5+, torchaudio, einops, ONNX Runtime, scipy.
10
-
11
- ## Global Constraints
12
-
13
- - No external model dependencies (use only PyTorch built-ins for model components)
14
- - All conv layers use proper padding to preserve length (pad_mode="reflect" for conv1d)
15
- - float32 training throughout
16
- - Seed all RNGs with 42 for reproducibility
17
- - Character-level tokenization (88 tokens, 81 printable ASCII + space + BOS/EOS/PAD)
18
- - VQ-VAE frame rate: 93.75 Hz (stride 256)
19
- - Codebook: 512 entries × 64 dim
20
- - Flow transformer: 3 layers, d_model=256, 4 heads, FFN=1024
21
- - LSD decode steps: 4 at inference
22
-
23
- ---
24
-
25
- ## File Structure
26
-
27
- ```
28
- bible-tts/
29
- ├── pyproject.toml
30
- ├── src/
31
- │ └── bible_tts/
32
- │ ├── __init__.py
33
- │ ├── data/
34
- │ │ ├── __init__.py
35
- │ │ ├── audio_dataset.py # AudioDataset — loads WAV + text pairs
36
- │ │ └── text_processor.py # CharTokenizer — char-level encode/decode
37
- │ ├── models/
38
- │ │ ├── __init__.py
39
- │ │ ├── vq_vae.py # VQVAE, Encoder, Decoder, ResBlock, VectorQuantizer
40
- │ │ ├── flow_lm.py # FlowLMModel, StreamingTransformer, FlowMLP
41
- │ │ └── lsd.py # lsd_decode — Lagrangian Self Distillation
42
- │ ├── training/
43
- │ │ ├── __init__.py
44
- │ │ ├── train_vqvae.py # CLI: trains VQ-VAE on audio
45
- │ │ └── train_flow.py # CLI: trains FlowLM on (text, tokens) pairs
46
- │ └── inference/
47
- │ ├── __init__.py
48
- │ ├── pipeline.py # TTS inference pipeline
49
- │ └── export_onnx.py # ONNX export + validation
50
- ├── tests/
51
- │ ├── __init__.py
52
- │ ├── test_vq_vae.py
53
- │ ├── test_flow_lm.py
54
- │ └── test_text_processor.py
55
- └── docs/
56
- └── android_integration.md
57
- ```
58
-
59
- ---
60
-
61
- ### Task 1: Project Scaffold + Data Pipeline
62
-
63
- **Files:**
64
- - Create: `pyproject.toml`
65
- - Create: `src/bible_tts/__init__.py`
66
- - Create: `src/bible_tts/data/__init__.py`
67
- - Create: `src/bible_tts/data/audio_dataset.py`
68
- - Create: `src/bible_tts/data/text_processor.py`
69
- - Create: `tests/__init__.py`
70
- - Create: `tests/test_text_processor.py`
71
-
72
- **Interfaces:**
73
- - Consumes: raw audio WAV files + verse-aligned text manifest
74
- - Produces: `CharTokenizer` class, `AudioDataset` class
75
-
76
- - [ ] **Step 1: Create pyproject.toml**
77
-
78
- ```toml
79
- [project]
80
- name = "bible-tts"
81
- version = "0.1.0"
82
- requires-python = ">= 3.10"
83
- dependencies = [
84
- "torch>=2.5.0",
85
- "torchaudio>=2.5.0",
86
- "einops>=0.4.0",
87
- "scipy>=1.5.0",
88
- "soundfile>=0.12.0",
89
- "onnxruntime>=1.15.0",
90
- ]
91
-
92
- [build-system]
93
- requires = ["hatchling"]
94
- build-backend = "hatchling.build"
95
-
96
- [tool.hatch.build.targets.wheel]
97
- packages = ["src/bible_tts"]
98
-
99
- [tool.ruff]
100
- line-length = 100
101
-
102
- [tool.pytest.ini_options]
103
- testpaths = ["tests"]
104
- ```
105
-
106
- - [ ] **Step 2: Create CharTokenizer**
107
-
108
- `src/bible_tts/data/text_processor.py`:
109
-
110
- ```python
111
- import torch
112
-
113
- PAD_TOKEN = "<PAD>"
114
- BOS_TOKEN = "<BOS>"
115
- EOS_TOKEN = "<EOS>"
116
-
117
- CHAR_SET = sorted(set(
118
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
119
- ".,!?;:-\"'()[]{}@#$%&*+=/\\<>~`| "
120
- ))
121
- PAD_IDX = 0
122
- BOS_IDX = 1
123
- EOS_IDX = 2
124
- SPECIAL = {PAD_TOKEN, BOS_TOKEN, EOS_TOKEN}
125
-
126
-
127
- class CharTokenizer:
128
- def __init__(self):
129
- self.chars = [PAD_TOKEN, BOS_TOKEN, EOS_TOKEN] + CHAR_SET
130
- self.char_to_idx = {c: i for i, c in enumerate(self.chars)}
131
- self.idx_to_char = {i: c for i, c in enumerate(self.chars)}
132
-
133
- @property
134
- def vocab_size(self) -> int:
135
- return len(self.chars)
136
-
137
- def encode(self, text: str) -> torch.Tensor:
138
- tokens = [BOS_IDX]
139
- for c in text:
140
- tokens.append(self.char_to_idx.get(c, PAD_IDX))
141
- tokens.append(EOS_IDX)
142
- return torch.tensor(tokens, dtype=torch.long)
143
-
144
- def decode(self, tokens: torch.Tensor) -> str:
145
- chars = []
146
- for t in tokens.tolist():
147
- c = self.idx_to_char.get(t, "")
148
- if c in SPECIAL:
149
- continue
150
- chars.append(c)
151
- return "".join(chars)
152
- ```
153
-
154
- - [ ] **Step 3: Write and run test**
155
-
156
- `tests/test_text_processor.py`:
157
-
158
- ```python
159
- import torch
160
- from bible_tts.data.text_processor import CharTokenizer
161
-
162
-
163
- def test_tokenizer_roundtrip():
164
- tokenizer = CharTokenizer()
165
- text = "In the beginning God created the heavens and the earth."
166
- tokens = tokenizer.encode(text)
167
- decoded = tokenizer.decode(tokens)
168
- assert decoded == text, f"Roundtrip failed: {decoded} != {text}"
169
-
170
-
171
- def test_tokenizer_unknown_chars():
172
- tokenizer = CharTokenizer()
173
- text = "Hello\nWorld\t!"
174
- tokens = tokenizer.encode(text)
175
- decoded = tokenizer.decode(tokens)
176
- # Newlines and tabs are not in CHAR_SET, replaced with PAD (dropped on decode)
177
- assert "\n" not in decoded and "\t" not in decoded
178
-
179
-
180
- def test_tokenizer_vocab_size():
181
- tokenizer = CharTokenizer()
182
- assert tokenizer.vocab_size == 3 + len(
183
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
184
- ".,!?;:-\"'()[]{}@#$%&*+=/\\<>~`| "
185
- )
186
- ```
187
-
188
- Run: `uv run pytest tests/test_text_processor.py -v`
189
- Expected: 3 passed
190
-
191
- - [ ] **Step 4: Create AudioDataset**
192
-
193
- `src/bible_tts/data/audio_dataset.py`:
194
-
195
- ```python
196
- import json
197
- from pathlib import Path
198
-
199
- import torch
200
- import torchaudio
201
- from torch.utils.data import Dataset
202
-
203
- from bible_tts.data.text_processor import CharTokenizer
204
-
205
-
206
- class AudioDataset(Dataset):
207
- """Loads verse-aligned (audio, text) pairs.
208
-
209
- Manifest format: JSONL where each line has:
210
- {"audio": "path/to/verse.wav", "text": "In the beginning..."}
211
- Audio is resampled to target_sr if needed.
212
- """
213
-
214
- def __init__(
215
- self,
216
- manifest_path: str | Path,
217
- target_sr: int = 24000,
218
- max_audio_seconds: float = 30.0,
219
- ):
220
- self.manifest_path = Path(manifest_path)
221
- self.target_sr = target_sr
222
- self.max_samples = int(max_audio_seconds * target_sr)
223
- self.tokenizer = CharTokenizer()
224
-
225
- with open(self.manifest_path) as f:
226
- self.entries = [json.loads(line) for line in f if line.strip()]
227
-
228
- def __len__(self) -> int:
229
- return len(self.entries)
230
-
231
- def __getitem__(self, idx: int) -> dict:
232
- entry = self.entries[idx]
233
- audio_path = Path(entry["audio"])
234
- text = entry["text"]
235
-
236
- waveform, sr = torchaudio.load(str(audio_path))
237
- if sr != self.target_sr:
238
- waveform = torchaudio.functional.resample(waveform, sr, self.target_sr)
239
-
240
- if waveform.shape[-1] > self.max_samples:
241
- waveform = waveform[..., : self.max_samples]
242
-
243
- text_tokens = self.tokenizer.encode(text)
244
-
245
- return {
246
- "audio": waveform.squeeze(0), # [T]
247
- "text_tokens": text_tokens, # [S]
248
- "text": text,
249
- "audio_path": str(audio_path),
250
- }
251
-
252
-
253
- def collate_audio(batch: list[dict]) -> dict:
254
- """Pads audio and text tokens to max length in batch."""
255
- max_audio_len = max(item["audio"].shape[-1] for item in batch)
256
- max_text_len = max(item["text_tokens"].shape[-1] for item in batch)
257
-
258
- audio_padded = torch.zeros(len(batch), max_audio_len)
259
- text_padded = torch.zeros(len(batch), max_text_len, dtype=torch.long)
260
- texts = []
261
- paths = []
262
-
263
- for i, item in enumerate(batch):
264
- audio_len = item["audio"].shape[-1]
265
- text_len = item["text_tokens"].shape[-1]
266
- audio_padded[i, :audio_len] = item["audio"]
267
- text_padded[i, :text_len] = item["text_tokens"]
268
- texts.append(item["text"])
269
- paths.append(item["audio_path"])
270
-
271
- return {
272
- "audio": audio_padded,
273
- "text_tokens": text_padded,
274
- "text": texts,
275
- "audio_paths": paths,
276
- }
277
- ```
278
-
279
- - [ ] **Step 5: Commit**
280
-
281
- ```bash
282
- git add pyproject.toml src/ tests/
283
- git commit -m "feat: project scaffold with data pipeline and CharTokenizer"
284
- ```
285
-
286
- ---
287
-
288
- ### Task 2: VQ-VAE Model
289
-
290
- **Files:**
291
- - Create: `src/bible_tts/models/__init__.py`
292
- - Create: `src/bible_tts/models/vq_vae.py`
293
- - Create: `tests/test_vq_vae.py`
294
-
295
- **Interfaces:**
296
- - Consumes: `AudioDataset` (Task 1)
297
- - Produces: `VQVAE` class — forward returns (reconstructed_audio, quantized_tokens, commitment_loss)
298
-
299
- - [ ] **Step 1: Define ResBlock**
300
-
301
- `src/bible_tts/models/vq_vae.py`:
302
-
303
- ```python
304
- import torch
305
- from torch import nn
306
- from torch.nn import functional as F
307
-
308
-
309
- class ResBlock(nn.Module):
310
- def __init__(self, dim: int, kernel_size: int = 3, dilation: int = 1, compress: int = 2):
311
- super().__init__()
312
- hidden = dim // compress
313
- self.conv1 = nn.Conv1d(dim, hidden, kernel_size, padding="same", dilation=dilation)
314
- self.conv2 = nn.Conv1d(hidden, dim, kernel_size, padding="same")
315
- self.activation = nn.ELU()
316
-
317
- def forward(self, x: torch.Tensor) -> torch.Tensor:
318
- residual = x
319
- x = self.activation(x)
320
- x = self.conv1(x)
321
- x = self.activation(x)
322
- x = self.conv2(x)
323
- return x + residual
324
- ```
325
-
326
- - [ ] **Step 2: Define Encoder**
327
-
328
-
329
- ```python
330
- class Encoder(nn.Module):
331
- """Strided conv encoder: downsamples audio to latent codes.
332
-
333
- Args:
334
- in_channels: 1 for mono audio
335
- base_channels: starting channel count (doubles per stride)
336
- latent_dim: output latent dimension
337
- strides: list of stride factors per layer
338
- n_resblocks: number of ResBlocks per level
339
- """
340
-
341
- def __init__(
342
- self,
343
- in_channels: int = 1,
344
- base_channels: int = 32,
345
- latent_dim: int = 64,
346
- strides: list[int] = [4, 4, 4],
347
- n_resblocks: int = 2,
348
- ):
349
- super().__init__()
350
- self.total_stride = 1
351
- for s in strides:
352
- self.total_stride *= s
353
-
354
- layers: list[nn.Module] = [
355
- nn.Conv1d(in_channels, base_channels, kernel_size=7, padding="same")
356
- ]
357
- mult = 1
358
- for stride in strides:
359
- for _ in range(n_resblocks):
360
- layers.append(ResBlock(mult * base_channels))
361
- layers.append(nn.ELU())
362
- layers.append(
363
- nn.Conv1d(
364
- mult * base_channels,
365
- mult * base_channels * 2,
366
- kernel_size=2 * stride,
367
- stride=stride,
368
- padding=stride // 2,
369
- )
370
- )
371
- mult *= 2
372
- layers.append(nn.Conv1d(mult * base_channels // 2, latent_dim, kernel_size=3, padding="same"))
373
- self.layers = nn.Sequential(*layers)
374
-
375
- def forward(self, x: torch.Tensor) -> torch.Tensor:
376
- return self.layers(x)
377
- ```
378
-
379
- - [ ] **Step 3: Define VectorQuantizer**
380
-
381
- ```python
382
- class VectorQuantizer(nn.Module):
383
- def __init__(self, codebook_size: int = 512, dim: int = 64, beta: float = 0.25):
384
- super().__init__()
385
- self.codebook_size = codebook_size
386
- self.dim = dim
387
- self.beta = beta
388
- self.codebook = nn.Embedding(codebook_size, dim)
389
- nn.init.uniform_(self.codebook.weight, -1.0 / codebook_size, 1.0 / codebook_size)
390
-
391
- def forward(self, z: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
392
- # z: [B, D, T]
393
- z_perm = z.permute(0, 2, 1) # [B, T, D]
394
- B, T, D = z_perm.shape
395
- flat = z_perm.reshape(-1, D) # [B*T, D]
396
-
397
- # distances: [B*T, codebook_size]
398
- dist = torch.cdist(flat, self.codebook.weight)
399
- indices = dist.argmin(dim=-1) # [B*T]
400
-
401
- # quantize
402
- z_q = self.codebook(indices) # [B*T, D]
403
- z_q = z_q.reshape(B, T, D).permute(0, 2, 1) # [B, D, T]
404
-
405
- # commitment loss
406
- loss = F.mse_loss(z_q.detach(), z) + self.beta * F.mse_loss(z_q, z.detach())
407
-
408
- # straight-through estimator
409
- z_q = z + (z_q - z).detach()
410
- return z_q, indices.reshape(B, T), loss
411
- ```
412
-
413
- - [ ] **Step 4: Define Decoder**
414
-
415
- ```python
416
- class Decoder(nn.Module):
417
- """Transposed conv decoder: upsamples latent codes to audio."""
418
-
419
- def __init__(
420
- self,
421
- out_channels: int = 1,
422
- base_channels: int = 32,
423
- latent_dim: int = 64,
424
- strides: list[int] = [4, 4, 4],
425
- n_resblocks: int = 2,
426
- ):
427
- super().__init__()
428
- mult = 2 ** (len(strides) - 1)
429
- layers: list[nn.Module] = [
430
- nn.Conv1d(latent_dim, mult * base_channels, kernel_size=3, padding="same")
431
- ]
432
- for stride in strides:
433
- layers.append(nn.ELU())
434
- layers.append(
435
- nn.ConvTranspose1d(
436
- mult * base_channels,
437
- mult * base_channels // 2,
438
- kernel_size=2 * stride,
439
- stride=stride,
440
- padding=stride // 2,
441
- )
442
- )
443
- for _ in range(n_resblocks):
444
- layers.append(ResBlock(mult * base_channels // 2))
445
- mult //= 2
446
- layers.append(nn.Conv1d(base_channels, out_channels, kernel_size=7, padding="same"))
447
- self.layers = nn.Sequential(*layers)
448
-
449
- def forward(self, x: torch.Tensor) -> torch.Tensor:
450
- return self.layers(x)
451
- ```
452
-
453
- - [ ] **Step 5: Define VQVAE**
454
-
455
- ```python
456
- class VQVAE(nn.Module):
457
- def __init__(
458
- self,
459
- in_channels: int = 1,
460
- base_channels: int = 32,
461
- latent_dim: int = 64,
462
- codebook_size: int = 512,
463
- strides: list[int] = [4, 4, 4],
464
- n_resblocks: int = 2,
465
- beta: float = 0.25,
466
- ):
467
- super().__init__()
468
- self.encoder = Encoder(in_channels, base_channels, latent_dim, strides, n_resblocks)
469
- self.quantizer = VectorQuantizer(codebook_size, latent_dim, beta)
470
- self.decoder = Decoder(in_channels, base_channels, latent_dim, strides, n_resblocks)
471
-
472
- @property
473
- def frame_rate(self) -> float:
474
- return 24000 / self.encoder.total_stride
475
-
476
- def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
477
- z_e = self.encoder(x)
478
- z_q, indices, vq_loss = self.quantizer(z_e)
479
- x_hat = self.decoder(z_q)
480
- return x_hat, indices, vq_loss
481
-
482
- def encode_to_indices(self, x: torch.Tensor) -> torch.Tensor:
483
- z_e = self.encoder(x)
484
- _, indices, _ = self.quantizer(z_e)
485
- return indices
486
-
487
- def decode_from_indices(self, indices: torch.Tensor) -> torch.Tensor:
488
- z_q = self.quantizer.codebook(indices)
489
- z_q = z_q.permute(0, 2, 1)
490
- return self.decoder(z_q)
491
- ```
492
-
493
- - [ ] **Step 6: Write and run tests**
494
-
495
- `tests/test_vq_vae.py`:
496
-
497
- ```python
498
- import torch
499
- from bible_tts.models.vq_vae import VQVAE, ResBlock, Encoder, Decoder, VectorQuantizer
500
-
501
-
502
- def test_resblock_shapes():
503
- block = ResBlock(dim=64)
504
- x = torch.randn(2, 64, 100)
505
- out = block(x)
506
- assert out.shape == x.shape
507
-
508
-
509
- def test_encoder_output_shape():
510
- enc = Encoder()
511
- x = torch.randn(1, 1, 24000)
512
- z = enc(x)
513
- # stride 64 => 24000/64 = 375 frames
514
- assert z.shape[-1] == 375
515
- assert z.shape[1] == 64
516
-
517
-
518
- def test_quantizer_roundtrip():
519
- quant = VectorQuantizer(codebook_size=512, dim=64)
520
- z = torch.randn(2, 64, 100)
521
- z_q, indices, loss = quant(z)
522
- assert z_q.shape == z.shape
523
- assert indices.shape == (2, 100)
524
- assert loss.item() >= 0
525
-
526
-
527
- def test_decoder_output_shape():
528
- dec = Decoder()
529
- z = torch.randn(1, 64, 375)
530
- x = dec(z)
531
- assert x.shape[-1] == 24000
532
- assert x.shape[1] == 1
533
-
534
-
535
- def test_vqvae_forward():
536
- model = VQVAE()
537
- x = torch.randn(1, 1, 24000)
538
- x_hat, indices, vq_loss = model(x)
539
- assert x_hat.shape == x.shape
540
- assert indices.shape == (1, 375)
541
- assert vq_loss.item() >= 0
542
-
543
-
544
- def test_vqvae_encode_decode():
545
- model = VQVAE()
546
- x = torch.randn(1, 1, 24000)
547
- indices = model.encode_to_indices(x)
548
- x_recon = model.decode_from_indices(indices)
549
- assert indices.shape == (1, 375)
550
- assert x_recon.shape == x.shape
551
- ```
552
-
553
- Run: `uv run pytest tests/test_vq_vae.py -v`
554
- Expected: all 6 passed
555
-
556
- - [ ] **Step 7: Commit**
557
-
558
- ```bash
559
- git add src/bible_tts/models/ tests/test_vq_vae.py
560
- git commit -m "feat: VQ-VAE model with Encoder, Decoder, VectorQuantizer, ResBlock"
561
- ```
562
-
563
- ---
564
-
565
- ### Task 3: VQ-VAE Training Script
566
-
567
- **Files:**
568
- - Create: `src/bible_tts/training/__init__.py`
569
- - Create: `src/bible_tts/training/train_vqvae.py`
570
-
571
- **Interfaces:**
572
- - Consumes: `AudioDataset` + `VQVAE` model
573
- - Produces: Saved VQ-VAE checkpoint at `checkpoints/vqvae.pt`
574
-
575
- - [ ] **Step 1: Create training script**
576
-
577
- `src/bible_tts/training/train_vqvae.py`:
578
-
579
- ```python
580
- import argparse
581
- import json
582
- import logging
583
- import shutil
584
- from pathlib import Path
585
-
586
- import torch
587
- from torch import nn, optim
588
- from torch.utils.data import DataLoader
589
- from torch.optim.lr_scheduler import CosineAnnealingLR
590
-
591
- from bible_tts.data.audio_dataset import AudioDataset, collate_audio
592
- from bible_tts.models.vq_vae import VQVAE
593
-
594
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
595
- logger = logging.getLogger(__name__)
596
-
597
-
598
- def parse_args():
599
- parser = argparse.ArgumentParser()
600
- parser.add_argument("--manifest", type=str, required=True, help="Path to manifest JSONL")
601
- parser.add_argument("--checkpoint-dir", type=str, default="checkpoints")
602
- parser.add_argument("--batch-size", type=int, default=16)
603
- parser.add_argument("--lr", type=float, default=3e-4)
604
- parser.add_argument("--weight-decay", type=float, default=1e-4)
605
- parser.add_argument("--max-steps", type=int, default=200000)
606
- parser.add_argument("--warmup-steps", type=int, default=5000)
607
- parser.add_argument("--log-interval", type=int, default=100)
608
- parser.add_argument("--save-interval", type=int, default=10000)
609
- parser.add_argument("--seed", type=int, default=42)
610
- parser.add_argument("--resume", type=str, default=None)
611
- return parser.parse_args()
612
-
613
-
614
- def train():
615
- args = parse_args()
616
- torch.manual_seed(args.seed)
617
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
618
- logger.info(f"Using device: {device}")
619
-
620
- checkpoint_dir = Path(args.checkpoint_dir)
621
- checkpoint_dir.mkdir(parents=True, exist_ok=True)
622
-
623
- dataset = AudioDataset(args.manifest)
624
- loader = DataLoader(
625
- dataset,
626
- batch_size=args.batch_size,
627
- shuffle=True,
628
- num_workers=4,
629
- pin_memory=True,
630
- collate_fn=collate_audio,
631
- )
632
- logger.info(f"Dataset: {len(dataset)} examples")
633
-
634
- model = VQVAE().to(device)
635
- optimizer = optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
636
- scheduler = CosineAnnealingLR(optimizer, T_max=args.max_steps - args.warmup_steps)
637
- scaler = torch.amp.GradScaler("cuda" if torch.cuda.is_available() else "cpu")
638
-
639
- start_step = 0
640
- if args.resume:
641
- state = torch.load(args.resume, map_location=device)
642
- model.load_state_dict(state["model"])
643
- optimizer.load_state_dict(state["optimizer"])
644
- start_step = state["step"]
645
- logger.info(f"Resumed from step {start_step}")
646
-
647
- step = start_step
648
- best_loss = float("inf")
649
- while step < args.max_steps:
650
- for batch in loader:
651
- if step >= args.max_steps:
652
- break
653
-
654
- audio = batch["audio"].unsqueeze(1).to(device) # [B, 1, T]
655
- crop_len = min(audio.shape[-1], 48000) # 2 sec at 24kHz
656
- if audio.shape[-1] > crop_len:
657
- offset = torch.randint(0, audio.shape[-1] - crop_len, (1,)).item()
658
- audio = audio[..., offset : offset + crop_len]
659
-
660
- with torch.amp.autocast(device_type="cuda" if torch.cuda.is_available() else "cpu"):
661
- x_hat, indices, vq_loss = model(audio)
662
- recon_loss = nn.functional.l1_loss(x_hat, audio)
663
- total_loss = recon_loss + vq_loss
664
-
665
- scaler.scale(total_loss).backward()
666
- scaler.unscale_(optimizer)
667
- nn.utils.clip_grad_norm_(model.parameters(), 1.0)
668
- scaler.step(optimizer)
669
- scaler.update()
670
- optimizer.zero_grad()
671
-
672
- if step < args.warmup_steps:
673
- lr_scale = min(1.0, (step + 1) / args.warmup_steps)
674
- for pg in optimizer.param_groups:
675
- pg["lr"] = args.lr * lr_scale
676
- else:
677
- scheduler.step()
678
-
679
- if step % args.log_interval == 0:
680
- logger.info(
681
- f"Step {step}/{args.max_steps} | recon: {recon_loss.item():.4f} | "
682
- f"vq: {vq_loss.item():.4f} | total: {total_loss.item():.4f}"
683
- )
684
-
685
- if step % args.save_interval == 0 and step > 0:
686
- state = {
687
- "model": model.state_dict(),
688
- "optimizer": optimizer.state_dict(),
689
- "step": step,
690
- "loss": total_loss.item(),
691
- }
692
- ckpt_path = checkpoint_dir / f"vqvae_step_{step}.pt"
693
- torch.save(state, ckpt_path)
694
- logger.info(f"Saved checkpoint to {ckpt_path}")
695
-
696
- if total_loss.item() < best_loss:
697
- best_loss = total_loss.item()
698
- shutil.copy(ckpt_path, checkpoint_dir / "vqvae_best.pt")
699
- logger.info(f"New best loss: {best_loss:.4f}")
700
-
701
- step += 1
702
-
703
- state = {
704
- "model": model.state_dict(),
705
- "optimizer": optimizer.state_dict(),
706
- "step": step,
707
- "loss": total_loss.item(),
708
- }
709
- torch.save(state, checkpoint_dir / "vqvae_final.pt")
710
- logger.info(f"Training complete. Final model saved to {checkpoint_dir / 'vqvae_final.pt'}")
711
- ```
712
-
713
- - [ ] **Step 2: Create a simple manifest from en_bsb.txt**
714
-
715
- Also create a helper script to generate the manifest:
716
-
717
- ```python
718
- # scripts/create_manifest.py
719
- """Convert en_bsb.txt + audio dir into a JSONL manifest."""
720
- import json
721
- from pathlib import Path
722
-
723
-
724
- def create_manifest(
725
- bible_text_path: str,
726
- audio_dir: str,
727
- output_path: str,
728
- audio_format: str = "wav",
729
- ):
730
- """Match verses in bible_text_path to audio files in audio_dir."""
731
- with open(bible_text_path) as f:
732
- lines = f.readlines()
733
-
734
- audio_dir = Path(audio_dir)
735
- entries = []
736
- missing = 0
737
- for line in lines:
738
- parts = line.strip().split("|")
739
- if len(parts) < 7:
740
- continue
741
- book, chapter, verse = parts[0], parts[1], parts[2]
742
- text = parts[6]
743
- # Audio file naming convention: adjust to match your files
744
- audio_name = f"{book}_{chapter}_{verse}.{audio_format}"
745
- audio_path = audio_dir / audio_name
746
- if audio_path.exists():
747
- entries.append({"audio": str(audio_path), "text": text})
748
- else:
749
- missing += 1
750
-
751
- with open(output_path, "w") as f:
752
- for entry in entries:
753
- f.write(json.dumps(entry) + "\n")
754
-
755
- print(f"Created manifest with {len(entries)} entries, {missing} missing audio files")
756
- ```
757
-
758
- - [ ] **Step 3: Commit**
759
-
760
- ```bash
761
- git add src/bible_tts/training/
762
- git commit -m "feat: VQ-VAE training script with warmup, cosine schedule, checkpointing"
763
- ```
764
-
765
- ---
766
-
767
- ### Task 4: Audio Encoding Script
768
-
769
- **Files:**
770
- - Create: `scripts/encode_audio.py`
771
-
772
- **Interfaces:**
773
- - Consumes: trained VQ-VAE checkpoint + manifest with audio paths
774
- - Produces: encoded dataset JSONL `(text, code_tokens)` for flow training
775
-
776
- - [ ] **Step 1: Create encoding script**
777
-
778
- ```python
779
- """Encode all audio through trained VQ-VAE to create (text, code_tokens) pairs."""
780
-
781
- import json
782
- import argparse
783
- from pathlib import Path
784
-
785
- import torch
786
- from torch.utils.data import DataLoader
787
-
788
- from bible_tts.data.audio_dataset import AudioDataset, collate_audio
789
- from bible_tts.models.vq_vae import VQVAE
790
-
791
-
792
- def main():
793
- parser = argparse.ArgumentParser()
794
- parser.add_argument("--manifest", type=str, required=True)
795
- parser.add_argument("--checkpoint", type=str, required=True)
796
- parser.add_argument("--output", type=str, required=True)
797
- parser.add_argument("--batch-size", type=int, default=32)
798
- args = parser.parse_args()
799
-
800
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
801
-
802
- model = VQVAE().to(device)
803
- state = torch.load(args.checkpoint, map_location=device)
804
- model.load_state_dict(state["model"])
805
- model.eval()
806
-
807
- dataset = AudioDataset(args.manifest)
808
- loader = DataLoader(
809
- dataset,
810
- batch_size=args.batch_size,
811
- shuffle=False,
812
- num_workers=4,
813
- collate_fn=collate_audio,
814
- )
815
-
816
- output_path = Path(args.output)
817
- output_path.parent.mkdir(parents=True, exist_ok=True)
818
-
819
- with open(output_path, "w") as f:
820
- with torch.no_grad():
821
- for batch in loader:
822
- audio = batch["audio"].unsqueeze(1).to(device)
823
- indices = model.encode_to_indices(audio)
824
- for i in range(len(batch["text"])):
825
- tokens = indices[i].tolist()
826
- entry = {
827
- "text": batch["text"][i],
828
- "codes": tokens,
829
- "audio_path": batch["audio_paths"][i],
830
- }
831
- f.write(json.dumps(entry) + "\n")
832
-
833
- print(f"Encoded {len(dataset)} examples to {args.output}")
834
- ```
835
-
836
- - [ ] **Step 2: Commit**
837
-
838
- ```bash
839
- git add scripts/encode_audio.py
840
- git commit -m "feat: audio encoding script for VQ-VAE tokenization"
841
- ```
842
-
843
- ---
844
-
845
- ### Task 5: Flow LM Model
846
-
847
- **Files:**
848
- - Create: `src/bible_tts/models/lsd.py`
849
- - Create: `src/bible_tts/models/flow_lm.py`
850
- - Create: `tests/test_flow_lm.py`
851
-
852
- **Interfaces:**
853
- - Consumes: `CharTokenizer` (Task 1), encoded codec tokens (Task 4)
854
- - Produces: `FlowLMModel` — generates latent sequences from text
855
-
856
- - [ ] **Step 1: Create LSD decode**
857
-
858
- `src/bible_tts/models/lsd.py`:
859
-
860
- ```python
861
- import torch
862
- from functools import partial
863
-
864
-
865
- def lsd_decode(
866
- v_t: callable,
867
- x_0: torch.Tensor,
868
- num_steps: int = 4,
869
- ) -> torch.Tensor:
870
- """Lagrangian Self Distillation.
871
-
872
- Args:
873
- v_t: function (t_prev, t_curr, x_t) -> velocity
874
- x_0: starting noise [B, D]
875
- num_steps: number of integration steps
876
- """
877
- current = x_0
878
- for i in range(num_steps):
879
- s = i / num_steps
880
- t = (i + 1) / num_steps
881
- flow_dir = v_t(
882
- s * torch.ones_like(current[..., :1]),
883
- t * torch.ones_like(current[..., :1]),
884
- current,
885
- )
886
- current = current + flow_dir / num_steps
887
- return current
888
- ```
889
-
890
- - [ ] **Step 2: Create StreamingTransformer**
891
-
892
- `src/bible_tts/models/flow_lm.py`:
893
-
894
- ```python
895
- import torch
896
- from torch import nn
897
- import einops
898
-
899
-
900
- class StreamingMultiheadAttention(nn.Module):
901
- def __init__(self, d_model: int, num_heads: int, max_period: float = 10000.0):
902
- super().__init__()
903
- self.d_model = d_model
904
- self.num_heads = num_heads
905
- self.head_dim = d_model // num_heads
906
- self.max_period = max_period
907
-
908
- self.wq = nn.Linear(d_model, d_model, bias=False)
909
- self.wk = nn.Linear(d_model, d_model, bias=False)
910
- self.wv = nn.Linear(d_model, d_model, bias=False)
911
- self.wo = nn.Linear(d_model, d_model, bias=False)
912
-
913
- def _rope(self, x: torch.Tensor, offset: int = 0) -> torch.Tensor:
914
- """Apply rotary position embeddings."""
915
- B, T, H, D = x.shape
916
- positions = torch.arange(offset, offset + T, device=x.device, dtype=torch.float32)
917
- freqs = torch.exp(
918
- -torch.arange(0, D, 2, device=x.device, dtype=torch.float32)
919
- * (torch.log(torch.tensor(self.max_period)) / D)
920
- )
921
- angles = positions[:, None] * freqs[None, :]
922
- cos, sin = angles.cos(), angles.sin()
923
- x_rot = x.clone()
924
- x_rot[..., 0::2] = x[..., 0::2] * cos - x[..., 1::2] * sin
925
- x_rot[..., 1::2] = x[..., 1::2] * cos + x[..., 0::2] * sin
926
- return x_rot
927
-
928
- def forward(self, x: torch.Tensor, kv_cache: torch.Tensor | None = None) -> tuple[torch.Tensor, torch.Tensor]:
929
- B, T, _ = x.shape
930
- q = einops.rearrange(self.wq(x), "B T (H D) -> B T H D", H=self.num_heads)
931
- k = einops.rearrange(self.wk(x), "B T (H D) -> B T H D", H=self.num_heads)
932
- v = einops.rearrange(self.wv(x), "B T (H D) -> B T H D", H=self.num_heads)
933
-
934
- q = self._rope(q)
935
- k = self._rope(k)
936
-
937
- if kv_cache is not None:
938
- k = torch.cat([kv_cache[0], k], dim=1)
939
- v = torch.cat([kv_cache[1], v], dim=1)
940
-
941
- attn = torch.einsum("bthd,bThd->bhtT", q, k) / (self.head_dim ** 0.5)
942
- attn = torch.softmax(attn, dim=-1)
943
- out = torch.einsum("bhtT,bThd->bthd", attn, v)
944
- out = einops.rearrange(out, "B T H D -> B T (H D)")
945
- out = self.wo(out)
946
-
947
- new_cache = torch.stack([k, v], dim=0)
948
- return out, new_cache
949
-
950
-
951
- class StreamingTransformerLayer(nn.Module):
952
- def __init__(self, d_model: int, num_heads: int, ff_hidden: int, max_period: float = 10000.0):
953
- super().__init__()
954
- self.attention = StreamingMultiheadAttention(d_model, num_heads, max_period)
955
- self.norm1 = nn.LayerNorm(d_model)
956
- self.ffn = nn.Sequential(
957
- nn.Linear(d_model, ff_hidden),
958
- nn.GELU(),
959
- nn.Linear(ff_hidden, d_model),
960
- )
961
- self.norm2 = nn.LayerNorm(d_model)
962
-
963
- def forward(self, x: torch.Tensor, kv_cache: torch.Tensor | None = None) -> tuple[torch.Tensor, torch.Tensor]:
964
- attn_out, new_cache = self.attention(self.norm1(x), kv_cache)
965
- x = x + attn_out
966
- x = x + self.ffn(self.norm2(x))
967
- return x, new_cache
968
-
969
-
970
- class StreamingTransformer(nn.Module):
971
- def __init__(self, d_model: int, num_layers: int, num_heads: int, ff_hidden: int):
972
- super().__init__()
973
- self.layers = nn.ModuleList([
974
- StreamingTransformerLayer(d_model, num_heads, ff_hidden)
975
- for _ in range(num_layers)
976
- ])
977
-
978
- def forward(self, x: torch.Tensor, kv_caches: list[torch.Tensor] | None = None) -> tuple[torch.Tensor, list[torch.Tensor]]:
979
- new_caches = []
980
- for i, layer in enumerate(self.layers):
981
- cache = kv_caches[i] if kv_caches is not None else None
982
- x, new_cache = layer(x, cache)
983
- new_caches.append(new_cache)
984
- return x, new_caches
985
- ```
986
-
987
- - [ ] **Step 3: Create FlowMLP (AdaLN)**
988
-
989
- ```python
990
- class FlowMLP(nn.Module):
991
- """AdaLN-conditioned MLP for flow prediction.
992
-
993
- Takes conditioning vector c and input x_t, predicts flow direction.
994
- """
995
-
996
- def __init__(self, d_model: int, latent_dim: int, hidden: int = 1024):
997
- super().__init__()
998
- self.cond_proj = nn.Linear(d_model, hidden * 2) # scale + shift
999
- self.net = nn.Sequential(
1000
- nn.Linear(latent_dim + 2, hidden), # +2 for t_prev, t_curr
1001
- nn.GELU(),
1002
- nn.Linear(hidden, hidden),
1003
- nn.GELU(),
1004
- nn.Linear(hidden, latent_dim),
1005
- )
1006
-
1007
- def forward(self, c: torch.Tensor, t_prev: torch.Tensor, t_curr: torch.Tensor, x_t: torch.Tensor) -> torch.Tensor:
1008
- scale, shift = self.cond_proj(c).chunk(2, dim=-1)
1009
- cond = torch.cat([t_prev, t_curr], dim=-1)
1010
- inp = torch.cat([cond, x_t], dim=-1)
1011
- h = self.net(inp)
1012
- return h * (1 + scale) + shift
1013
- ```
1014
-
1015
- - [ ] **Step 4: Create FlowLMModel**
1016
-
1017
- ```python
1018
- class FlowLMModel(nn.Module):
1019
- def __init__(
1020
- self,
1021
- vocab_size: int = 88,
1022
- d_embed: int = 128,
1023
- d_model: int = 256,
1024
- latent_dim: int = 64,
1025
- num_layers: int = 3,
1026
- num_heads: int = 4,
1027
- ff_hidden: int = 1024,
1028
- mlp_hidden: int = 1024,
1029
- codebook_size: int = 512,
1030
- ):
1031
- super().__init__()
1032
- self.latent_dim = latent_dim
1033
- self.d_model = d_model
1034
-
1035
- self.char_embed = nn.Embedding(vocab_size, d_embed)
1036
- self.text_proj = nn.Linear(d_embed, d_model)
1037
- self.code_embed = nn.Embedding(codebook_size, latent_dim)
1038
- self.input_proj = nn.Linear(latent_dim, d_model)
1039
-
1040
- self.transformer = StreamingTransformer(d_model, num_layers, num_heads, ff_hidden)
1041
- self.out_norm = nn.LayerNorm(d_model)
1042
- self.flow_mlp = FlowMLP(d_model, latent_dim, mlp_hidden)
1043
-
1044
- # EOS prediction head
1045
- self.eos_head = nn.Linear(d_model, 1)
1046
-
1047
- # BOS embedding for audio
1048
- self.bos_code = nn.Parameter(torch.randn(latent_dim))
1049
-
1050
- def forward(
1051
- self,
1052
- text_tokens: torch.Tensor,
1053
- code_tokens: torch.Tensor,
1054
- kv_caches: list[torch.Tensor] | None = None,
1055
- lsd_steps: int = 4,
1056
- temp: float = 1.0,
1057
- noise_clamp: float | None = None,
1058
- eos_threshold: float = 0.5,
1059
- ) -> tuple[torch.Tensor, torch.Tensor]:
1060
- """
1061
- Args:
1062
- text_tokens: [B, S] character token ids
1063
- code_tokens: [B, T] codec token ids (previous audio latents)
1064
- kv_caches: optional KV caches for streaming
1065
- lsd_steps: number of LSD steps (0 = training, >0 = inference)
1066
- Returns:
1067
- next_latent: [B, latent_dim] predicted latent
1068
- is_eos: [B, 1] EOS prediction
1069
- """
1070
- B = text_tokens.shape[0]
1071
-
1072
- # Text embeddings
1073
- text_emb = self.char_embed(text_tokens) # [B, S, d_embed]
1074
- text_emb = self.text_proj(text_emb) # [B, S, d_model]
1075
-
1076
- # Code token embeddings
1077
- code_emb = self.code_embed(code_tokens) # [B, T, latent_dim]
1078
- code_emb = self.input_proj(code_emb) # [B, T, d_model]
1079
-
1080
- # Concatenate text + code
1081
- combined = torch.cat([text_emb, code_emb], dim=1)
1082
-
1083
- # Transformer forward
1084
- out, new_caches = self.transformer(combined, kv_caches)
1085
- out = self.out_norm(out)
1086
-
1087
- # Get last position output
1088
- last_out = out[:, -1] # [B, d_model]
1089
-
1090
- # EOS prediction
1091
- is_eos = (self.eos_head(last_out) > eos_threshold).float()
1092
-
1093
- if lsd_steps == 0:
1094
- return last_out, is_eos
1095
-
1096
- # LSD decode for inference
1097
- noise_shape = (B, self.latent_dim)
1098
- noise = torch.randn(noise_shape, device=text_tokens.device)
1099
- if noise_clamp is not None:
1100
- noise = noise.clamp(-noise_clamp, noise_clamp)
1101
- noise = noise * temp
1102
-
1103
- def flow_fn(t_prev, t_curr, x_t):
1104
- return self.flow_mlp(last_out, t_prev, t_curr, x_t)
1105
-
1106
- next_latent = lsd_decode(flow_fn, noise, lsd_steps)
1107
- return next_latent, is_eos
1108
-
1109
- def sample_latent(
1110
- self,
1111
- text_tokens: torch.Tensor,
1112
- code_tokens: torch.Tensor,
1113
- kv_caches: list[torch.Tensor] | None = None,
1114
- lsd_steps: int = 4,
1115
- temp: float = 1.0,
1116
- noise_clamp: float | None = None,
1117
- eos_threshold: float = 0.5,
1118
- ) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor]]:
1119
- next_latent, is_eos = self(
1120
- text_tokens=text_tokens,
1121
- code_tokens=code_tokens,
1122
- kv_caches=kv_caches,
1123
- lsd_steps=lsd_steps,
1124
- temp=temp,
1125
- noise_clamp=noise_clamp,
1126
- eos_threshold=eos_threshold,
1127
- )
1128
- return next_latent, is_eos
1129
- ```
1130
-
1131
- - [ ] **Step 5: Write and run tests**
1132
-
1133
- `tests/test_flow_lm.py`:
1134
-
1135
- ```python
1136
- import torch
1137
- from bible_tts.models.flow_lm import (
1138
- StreamingMultiheadAttention,
1139
- StreamingTransformerLayer,
1140
- StreamingTransformer,
1141
- FlowMLP,
1142
- FlowLMModel,
1143
- )
1144
- from bible_tts.models.lsd import lsd_decode
1145
-
1146
-
1147
- def test_attention_shapes():
1148
- attn = StreamingMultiheadAttention(d_model=256, num_heads=4)
1149
- x = torch.randn(2, 10, 256)
1150
- out, cache = attn(x)
1151
- assert out.shape == x.shape
1152
- assert cache.shape == (2, 2, 10, 4, 64)
1153
-
1154
-
1155
- def test_attention_with_cache():
1156
- attn = StreamingMultiheadAttention(d_model=256, num_heads=4)
1157
- x1 = torch.randn(1, 5, 256)
1158
- x2 = torch.randn(1, 3, 256)
1159
- _, cache = attn(x1)
1160
- out2, cache2 = attn(x2, cache)
1161
- assert out2.shape == x2.shape
1162
- assert cache2.shape[2] == 8
1163
-
1164
-
1165
- def test_transformer_layer():
1166
- layer = StreamingTransformerLayer(d_model=256, num_heads=4, ff_hidden=1024)
1167
- x = torch.randn(2, 10, 256)
1168
- out, cache = layer(x)
1169
- assert out.shape == x.shape
1170
-
1171
-
1172
- def test_transformer():
1173
- tf = StreamingTransformer(d_model=256, num_layers=3, num_heads=4, ff_hidden=1024)
1174
- x = torch.randn(2, 10, 256)
1175
- out, caches = tf(x)
1176
- assert len(caches) == 3
1177
- assert out.shape == x.shape
1178
-
1179
-
1180
- def test_flow_mlp():
1181
- mlp = FlowMLP(d_model=256, latent_dim=64)
1182
- c = torch.randn(2, 256)
1183
- t1 = torch.randn(2, 1)
1184
- t2 = torch.randn(2, 1)
1185
- x = torch.randn(2, 64)
1186
- out = mlp(c, t1, t2, x)
1187
- assert out.shape == x.shape
1188
-
1189
-
1190
- def test_flow_lm_forward():
1191
- model = FlowLMModel()
1192
- text = torch.randint(0, 88, (1, 50))
1193
- codes = torch.randint(0, 512, (1, 100))
1194
- last_out, is_eos = model(text, codes)
1195
- assert last_out.shape == (1, 256)
1196
- assert is_eos.shape == (1, 1)
1197
-
1198
-
1199
- def test_lsd_decode():
1200
- def flow_fn(t_prev, t_curr, x_t):
1201
- return -x_t
1202
- x_0 = torch.randn(2, 64)
1203
- result = lsd_decode(flow_fn, x_0, num_steps=4)
1204
- assert result.shape == x_0.shape
1205
- ```
1206
-
1207
- Run: `uv run pytest tests/test_flow_lm.py -v`
1208
- Expected: all 8 passed
1209
-
1210
- - [ ] **Step 6: Commit**
1211
-
1212
- ```bash
1213
- git add src/bible_tts/models/lsd.py src/bible_tts/models/flow_lm.py tests/test_flow_lm.py
1214
- git commit -m "feat: FlowLM model with streaming transformer, flow MLP, and LSD"
1215
- ```
1216
-
1217
- ---
1218
-
1219
- ### Task 6: Flow LM Training Script
1220
-
1221
- **Files:**
1222
- - Create: `src/bible_tts/training/train_flow.py`
1223
-
1224
- **Interfaces:**
1225
- - Consumes: encoded dataset (text tokens + codec tokens from Task 4)
1226
- - Produces: Saved FlowLM checkpoint
1227
-
1228
- - [ ] **Step 1: Create Flow training script**
1229
-
1230
- `src/bible_tts/training/train_flow.py`:
1231
-
1232
- ```python
1233
- import argparse
1234
- import json
1235
- import logging
1236
- import shutil
1237
- from pathlib import Path
1238
-
1239
- import torch
1240
- from torch import nn, optim
1241
- from torch.utils.data import Dataset, DataLoader
1242
- from torch.optim.lr_scheduler import CosineAnnealingLR
1243
-
1244
- from bible_tts.data.text_processor import CharTokenizer
1245
- from bible_tts.models.flow_lm import FlowLMModel
1246
-
1247
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
1248
- logger = logging.getLogger(__name__)
1249
-
1250
-
1251
- class CodecDataset(Dataset):
1252
- """Dataset of (text, codec_token_sequence) pairs from encoded JSONL."""
1253
-
1254
- def __init__(self, encoded_path: str, max_code_len: int = 1000):
1255
- self.tokenizer = CharTokenizer()
1256
- self.max_code_len = max_code_len
1257
- with open(encoded_path) as f:
1258
- self.entries = [json.loads(line) for line in f if line.strip()]
1259
-
1260
- def __len__(self):
1261
- return len(self.entries)
1262
-
1263
- def __getitem__(self, idx):
1264
- entry = self.entries[idx]
1265
- text_tokens = self.tokenizer.encode(entry["text"])
1266
- codes = torch.tensor(entry["codes"], dtype=torch.long)
1267
- if codes.shape[-1] > self.max_code_len:
1268
- codes = codes[:self.max_code_len]
1269
- return {"text_tokens": text_tokens, "codes": codes}
1270
-
1271
-
1272
- def collate_fn(batch):
1273
- max_text = max(item["text_tokens"].shape[-1] for item in batch)
1274
- max_code = max(item["codes"].shape[-1] for item in batch)
1275
-
1276
- text_padded = torch.zeros(len(batch), max_text, dtype=torch.long)
1277
- code_padded = torch.zeros(len(batch), max_code, dtype=torch.long)
1278
- code_masks = torch.zeros(len(batch), max_code, dtype=torch.bool)
1279
-
1280
- for i, item in enumerate(batch):
1281
- tl = item["text_tokens"].shape[-1]
1282
- cl = item["codes"].shape[-1]
1283
- text_padded[i, :tl] = item["text_tokens"]
1284
- code_padded[i, :cl] = item["codes"]
1285
- code_masks[i, :cl] = True
1286
-
1287
- return {"text_tokens": text_padded, "codes": code_padded, "code_masks": code_masks}
1288
-
1289
-
1290
- def parse_args():
1291
- parser = argparse.ArgumentParser()
1292
- parser.add_argument("--encoded-data", type=str, required=True)
1293
- parser.add_argument("--checkpoint-dir", type=str, default="checkpoints")
1294
- parser.add_argument("--batch-size", type=int, default=32)
1295
- parser.add_argument("--lr", type=float, default=2e-4)
1296
- parser.add_argument("--weight-decay", type=float, default=1e-4)
1297
- parser.add_argument("--max-steps", type=int, default=300000)
1298
- parser.add_argument("--warmup-steps", type=int, default=5000)
1299
- parser.add_argument("--log-interval", type=int, default=100)
1300
- parser.add_argument("--save-interval", type=int, default=10000)
1301
- parser.add_argument("--seed", type=int, default=42)
1302
- parser.add_argument("--resume", type=str, default=None)
1303
- return parser.parse_args()
1304
-
1305
-
1306
- def train():
1307
- args = parse_args()
1308
- torch.manual_seed(args.seed)
1309
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
1310
- logger.info(f"Using device: {device}")
1311
-
1312
- checkpoint_dir = Path(args.checkpoint_dir)
1313
- checkpoint_dir.mkdir(parents=True, exist_ok=True)
1314
-
1315
- dataset = CodecDataset(args.encoded_data)
1316
- loader = DataLoader(
1317
- dataset,
1318
- batch_size=args.batch_size,
1319
- shuffle=True,
1320
- num_workers=4,
1321
- pin_memory=True,
1322
- collate_fn=collate_fn,
1323
- )
1324
- logger.info(f"Dataset: {len(dataset)} examples")
1325
-
1326
- model = FlowLMModel().to(device)
1327
- optimizer = optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
1328
- scheduler = CosineAnnealingLR(optimizer, T_max=args.max_steps - args.warmup_steps)
1329
- scaler = torch.amp.GradScaler(device="cuda" if torch.cuda.is_available() else "cpu")
1330
-
1331
- start_step = 0
1332
- if args.resume:
1333
- state = torch.load(args.resume, map_location=device)
1334
- model.load_state_dict(state["model"])
1335
- optimizer.load_state_dict(state["optimizer"])
1336
- start_step = state["step"]
1337
- logger.info(f"Resumed from step {start_step}")
1338
-
1339
- step = start_step
1340
- best_loss = float("inf")
1341
- while step < args.max_steps:
1342
- for batch in loader:
1343
- if step >= args.max_steps:
1344
- break
1345
-
1346
- text = batch["text_tokens"].to(device)
1347
- codes = batch["codes"].to(device)
1348
-
1349
- # Flow matching training
1350
- B, T = codes.shape
1351
- latent_dim = model.latent_dim
1352
-
1353
- # Convert code tokens to latent embeddings
1354
- code_emb = model.code_embed(codes) # [B, T, latent_dim]
1355
-
1356
- # Sample random timestep for flow matching
1357
- t = torch.rand(B, 1, device=device)
1358
- noise = torch.randn_like(code_emb)
1359
-
1360
- def flow_fn(c, t_prev, t_curr, x_t):
1361
- return model.flow_mlp(c, t_prev, t_curr, x_t)
1362
-
1363
- with torch.amp.autocast(device_type="cuda" if torch.cuda.is_available() else "cpu"):
1364
- # Text embedding
1365
- text_emb = model.char_embed(text)
1366
- text_emb = model.text_proj(text_emb)
1367
-
1368
- # Concatenate and run transformer
1369
- combined = torch.cat([text_emb, code_emb], dim=1)
1370
- out, _ = model.transformer(combined)
1371
- out = model.out_norm(out)
1372
-
1373
- # For each position, predict velocity
1374
- cond = out[:, -1] # last position -> conditioning
1375
- noise_shape = (B, latent_dim)
1376
-
1377
- t_prev = t * 0 # starting from noise
1378
- t_curr = t
1379
-
1380
- x_t = (1 - t) * noise + t * code_emb[:, -1]
1381
- v_pred = model.flow_mlp(cond, t_prev, t_curr, x_t)
1382
- v_target = code_emb[:, -1] - noise # ground truth
1383
- flow_loss = nn.functional.mse_loss(v_pred, v_target)
1384
-
1385
- # EOS loss (predict end of sequence)
1386
- # For simplicity, treat last token as EOS
1387
- eos_target = torch.zeros(B, 1, device=device)
1388
- eos_logits = model.eos_head(out[:, -1])
1389
- eos_loss = nn.functional.binary_cross_entropy_with_logits(
1390
- eos_logits, eos_target
1391
- )
1392
-
1393
- total_loss = flow_loss + eos_loss
1394
-
1395
- scaler.scale(total_loss).backward()
1396
- scaler.unscale_(optimizer)
1397
- nn.utils.clip_grad_norm_(model.parameters(), 1.0)
1398
- scaler.step(optimizer)
1399
- scaler.update()
1400
- optimizer.zero_grad()
1401
-
1402
- if step < args.warmup_steps:
1403
- lr_scale = min(1.0, (step + 1) / args.warmup_steps)
1404
- for pg in optimizer.param_groups:
1405
- pg["lr"] = args.lr * lr_scale
1406
- else:
1407
- scheduler.step()
1408
-
1409
- if step % args.log_interval == 0:
1410
- logger.info(
1411
- f"Step {step}/{args.max_steps} | flow: {flow_loss.item():.4f} | "
1412
- f"eos: {eos_loss.item():.4f} | total: {total_loss.item():.4f}"
1413
- )
1414
-
1415
- if step % args.save_interval == 0 and step > 0:
1416
- state = {
1417
- "model": model.state_dict(),
1418
- "optimizer": optimizer.state_dict(),
1419
- "step": step,
1420
- "loss": total_loss.item(),
1421
- }
1422
- ckpt_path = checkpoint_dir / f"flow_step_{step}.pt"
1423
- torch.save(state, ckpt_path)
1424
- logger.info(f"Saved checkpoint to {ckpt_path}")
1425
-
1426
- if total_loss.item() < best_loss:
1427
- best_loss = total_loss.item()
1428
- shutil.copy(ckpt_path, checkpoint_dir / "flow_best.pt")
1429
- logger.info(f"New best loss: {best_loss:.4f}")
1430
-
1431
- step += 1
1432
-
1433
- state = {
1434
- "model": model.state_dict(),
1435
- "optimizer": optimizer.state_dict(),
1436
- "step": step,
1437
- "loss": total_loss.item(),
1438
- }
1439
- torch.save(state, checkpoint_dir / "flow_final.pt")
1440
- logger.info(f"Training complete. Final model saved to {checkpoint_dir / 'flow_final.pt'}")
1441
- ```
1442
-
1443
- - [ ] **Step 2: Commit**
1444
-
1445
- ```bash
1446
- git add src/bible_tts/training/train_flow.py
1447
- git commit -m "feat: FlowLM training script with flow matching and EOS loss"
1448
- ```
1449
-
1450
- ---
1451
-
1452
- ### Task 7: Inference Pipeline + ONNX Export
1453
-
1454
- **Files:**
1455
- - Create: `src/bible_tts/inference/__init__.py`
1456
- - Create: `src/bible_tts/inference/pipeline.py`
1457
- - Create: `src/bible_tts/inference/export_onnx.py`
1458
-
1459
- **Interfaces:**
1460
- - Consumes: VQVAE checkpoint + FlowLM checkpoint
1461
- - Produces: TTS text→audio + ONNX exported models
1462
-
1463
- - [ ] **Step 1: Create TTS pipeline**
1464
-
1465
- `src/bible_tts/inference/pipeline.py`:
1466
-
1467
- ```python
1468
- import torch
1469
- from bible_tts.data.text_processor import CharTokenizer
1470
- from bible_tts.models.vq_vae import VQVAE
1471
- from bible_tts.models.flow_lm import FlowLMModel
1472
-
1473
-
1474
- class TTSPipeline:
1475
- """Full text-to-speech inference pipeline.
1476
-
1477
- Text → CharTokenizer → FlowLM → latent codes → VQ Decoder → audio
1478
- """
1479
-
1480
- def __init__(
1481
- self,
1482
- vqvae_checkpoint: str,
1483
- flow_checkpoint: str,
1484
- device: str = "cpu",
1485
- lsd_steps: int = 4,
1486
- ):
1487
- self.device = torch.device(device)
1488
- self.lsd_steps = lsd_steps
1489
- self.tokenizer = CharTokenizer()
1490
-
1491
- self.vqvae = VQVAE().to(self.device)
1492
- vq_state = torch.load(vqvae_checkpoint, map_location=self.device)
1493
- self.vqvae.load_state_dict(vq_state["model"])
1494
- self.vqvae.eval()
1495
-
1496
- self.flow = FlowLMModel().to(self.device)
1497
- flow_state = torch.load(flow_checkpoint, map_location=self.device)
1498
- self.flow.load_state_dict(flow_state["model"])
1499
- self.flow.eval()
1500
-
1501
- @torch.no_grad()
1502
- def generate(self, text: str) -> torch.Tensor:
1503
- """Generate audio from text. Returns [1, T] waveform."""
1504
- text_tokens = self.tokenizer.encode(text).unsqueeze(0).to(self.device) # [1, S]
1505
-
1506
- # Autoregressive generation loop
1507
- max_frames = int(300 * self.vqvae.frame_rate) # ~3.2 seconds at 94Hz
1508
- code_tokens = torch.zeros((1, 0), dtype=torch.long, device=self.device)
1509
- latents = []
1510
-
1511
- for step in range(max_frames):
1512
- # Use the last generated code (or BOS for first)
1513
- if step == 0:
1514
- current_codes = torch.zeros((1, 1), dtype=torch.long, device=self.device)
1515
- else:
1516
- current_codes = code_tokens[:, -1:]
1517
-
1518
- next_latent, is_eos = self.flow(
1519
- text_tokens=text_tokens,
1520
- code_tokens=current_codes,
1521
- lsd_steps=self.lsd_steps,
1522
- )
1523
-
1524
- # Quantize to nearest codebook entry
1525
- dist = torch.cdist(
1526
- next_latent.unsqueeze(1),
1527
- self.flow.code_embed.weight.unsqueeze(0),
1528
- )
1529
- next_code = dist.argmin(dim=-1) # [1, 1]
1530
- code_tokens = torch.cat([code_tokens, next_code], dim=-1)
1531
-
1532
- latents.append(next_latent)
1533
-
1534
- if is_eos.item() and step > 10:
1535
- break
1536
-
1537
- # Decode all latents through VQ decoder
1538
- if len(latents) == 0:
1539
- return torch.zeros(1, 24000)
1540
-
1541
- # Alternatively, decode using indices + decoder
1542
- audio = self.vqvae.decode_from_indices(code_tokens)
1543
- return audio
1544
-
1545
- @torch.no_grad()
1546
- def generate_fast(self, text: str) -> torch.Tensor:
1547
- """Non-autoregressive generation (single step)."""
1548
- text_tokens = self.tokenizer.encode(text).unsqueeze(0).to(self.device)
1549
-
1550
- # Get text embedding
1551
- text_emb = self.flow.char_embed(text_tokens)
1552
- text_emb = self.flow.text_proj(text_emb)
1553
-
1554
- # Generate all at once
1555
- noise = torch.randn(1, self.flow.latent_dim, device=self.device)
1556
-
1557
- def flow_fn(c, t_prev, t_curr, x_t):
1558
- combined = torch.cat([text_emb[:, -1:], c.unsqueeze(1)], dim=1)
1559
- out, _ = self.flow.transformer(combined)
1560
- out = self.flow.out_norm(out)
1561
- cond = out[:, -1]
1562
- return self.flow.flow_mlp(cond, t_prev, t_curr, x_t)
1563
-
1564
- from bible_tts.models.lsd import lsd_decode
1565
- generated = lsd_decode(
1566
- lambda tp, tc, xt: flow_fn(text_emb[:, -1], tp, tc, xt),
1567
- noise,
1568
- self.lsd_steps,
1569
- )
1570
-
1571
- dist = torch.cdist(
1572
- generated.unsqueeze(1),
1573
- self.flow.code_embed.weight.unsqueeze(0),
1574
- )
1575
- code = dist.argmin(dim=-1)
1576
- audio = self.vqvae.decode_from_indices(code)
1577
- return audio
1578
- ```
1579
-
1580
- - [ ] **Step 2: Create ONNX export script**
1581
-
1582
- `src/bible_tts/inference/export_onnx.py`:
1583
-
1584
- ```python
1585
- import torch
1586
- import onnxruntime
1587
-
1588
- from bible_tts.models.flow_lm import FlowLMModel
1589
- from bible_tts.models.vq_vae import VQVAE
1590
-
1591
-
1592
- def export_flow_lm_onnx(checkpoint_path: str, output_path: str):
1593
- """Export FlowLM inference graph to ONNX."""
1594
- model = FlowLMModel()
1595
- state = torch.load(checkpoint_path, map_location="cpu")
1596
- model.load_state_dict(state["model"])
1597
- model.eval()
1598
-
1599
- text_tokens = torch.randint(0, 88, (1, 50), dtype=torch.long)
1600
- code_tokens = torch.randint(0, 512, (1, 1), dtype=torch.long)
1601
-
1602
- torch.onnx.export(
1603
- model,
1604
- (text_tokens, code_tokens, None, 4, 1.0, None, 0.5),
1605
- output_path,
1606
- input_names=["text_tokens", "code_tokens", "kv_caches", "lsd_steps", "temp", "noise_clamp", "eos_threshold"],
1607
- output_names=["next_latent", "is_eos"],
1608
- dynamic_axes={
1609
- "text_tokens": {1: "text_length"},
1610
- "code_tokens": {1: "code_length"},
1611
- },
1612
- opset_version=17,
1613
- )
1614
- print(f"FlowLM ONNX model exported to {output_path}")
1615
-
1616
-
1617
- def export_vqvae_decoder_onnx(checkpoint_path: str, output_path: str):
1618
- """Export VQ decoder + codebook to ONNX."""
1619
- from bible_tts.models.vq_vae import VQVAE
1620
- model = VQVAE()
1621
- state = torch.load(checkpoint_path, map_location="cpu")
1622
- model.load_state_dict(state["model"])
1623
- model.eval()
1624
-
1625
- class VQDecoderWrapper(torch.nn.Module):
1626
- def __init__(self, codebook, decoder):
1627
- super().__init__()
1628
- self.codebook = codebook
1629
- self.decoder = decoder
1630
-
1631
- def forward(self, indices: torch.Tensor) -> torch.Tensor:
1632
- z_q = self.codebook(indices)
1633
- z_q = z_q.permute(0, 2, 1)
1634
- return self.decoder(z_q)
1635
-
1636
- wrapper = VQDecoderWrapper(model.quantizer.codebook, model.decoder)
1637
- wrapper.eval()
1638
-
1639
- indices = torch.randint(0, 512, (1, 375), dtype=torch.long)
1640
-
1641
- torch.onnx.export(
1642
- wrapper,
1643
- indices,
1644
- output_path,
1645
- input_names=["indices"],
1646
- output_names=["audio"],
1647
- dynamic_axes={
1648
- "indices": {1: "num_frames"},
1649
- },
1650
- opset_version=17,
1651
- )
1652
- print(f"VQ Decoder ONNX model exported to {output_path}")
1653
-
1654
-
1655
- def validate_onnx(onnx_path: str, sample_inputs: list):
1656
- session = onnxruntime.InferenceSession(onnx_path)
1657
- input_names = [inp.name for inp in session.get_inputs()]
1658
- output_names = [out.name for out in session.get_outputs()]
1659
-
1660
- feed = dict(zip(input_names, sample_inputs))
1661
- outputs = session.run(output_names, feed)
1662
- print(f"ONNX validation passed for {onnx_path}")
1663
- return outputs
1664
- ```
1665
-
1666
- - [ ] **Step 3: Generate a sample and commit**
1667
-
1668
- ```bash
1669
- git add src/bible_tts/inference/
1670
- git commit -m "feat: inference pipeline and ONNX export scripts"
1671
- ```
1672
-
1673
- ---
1674
-
1675
- ### Task 8: Android Integration Guide
1676
-
1677
- **Files:**
1678
- - Create: `docs/android_integration.md`
1679
-
1680
- - [ ] **Step 1: Write integration guide**
1681
-
1682
- `docs/android_integration.md` — brief doc covering:
1683
-
1684
- 1. Building ONNX Runtime for Android (ARM64)
1685
- 2. Loading the two ONNX models
1686
- 3. Text tokenization in Java/Kotlin
1687
- 4. Autoregressive generation loop
1688
- 5. Audio playback from float32 samples
1689
- 6. Memory management and model caching
1690
-
1691
- ```markdown
1692
- # Android Integration Guide
1693
-
1694
- ## Dependencies
1695
-
1696
- Add to your `build.gradle.kts`:
1697
-
1698
- ```kotlin
1699
- implementation("com.microsoft.onnxruntime:onnxruntime-android:latest")
1700
- ```
1701
-
1702
- ## Model Files
1703
-
1704
- Copy to `app/src/main/assets/models/`:
1705
- - `flow_lm.onnx` (~12 MB float16)
1706
- - `vq_decoder.onnx` (~4 MB float16)
1707
-
1708
- Total: ~16 MB + 5 MB runtime = ~21 MB APK impact (can be reduced via int8 quantization).
1709
-
1710
- ## Basic Usage (Kotlin)
1711
-
1712
- ```kotlin
1713
- class BibleTTS(private val context: Context) {
1714
- private val flowSession: OrtSession
1715
- private val decoderSession: OrtSession
1716
- private val environment: OrtEnvironment
1717
-
1718
- init {
1719
- environment = OrtEnvironment.getEnvironment()
1720
- val flowModel = loadModelFile("models/flow_lm.onnx")
1721
- val decoderModel = loadModelFile("models/vq_decoder.onnx")
1722
- flowSession = environment.createSession(flowModel, OrtSession.SessionOptions())
1723
- decoderSession = environment.createSession(decoderModel, OrtSession.SessionOptions())
1724
- }
1725
-
1726
- fun synthesize(text: String): FloatArray {
1727
- val tokens = charTokenize(text)
1728
- val codeTokens = LongArray(1) { 0L } // BOS
1729
-
1730
- // Autoregressive loop
1731
- val generatedCodes = mutableListOf<Long>()
1732
- for (i in 0 until maxFrames) {
1733
- val inputs = mapOf(
1734
- "text_tokens" to OnnxTensor.createTensor(environment, longArrayOf(1, tokens.size), tokens),
1735
- "code_tokens" to OnnxTensor.createTensor(environment, longArrayOf(1, codeTokens.size), codeTokens),
1736
- )
1737
- val outputs = flowSession.run(inputs)
1738
- val nextCode = quantize(outputs[0])
1739
- generatedCodes.add(nextCode)
1740
- codeTokens = longArrayOf(nextCode)
1741
- if (isEos(outputs[1])) break
1742
- }
1743
-
1744
- // Decode to audio
1745
- val decoderInputs = mapOf(
1746
- "indices" to OnnxTensor.createTensor(environment, longArrayOf(1, generatedCodes.size), generatedCodes.toLongArray())
1747
- )
1748
- val audio = decoderSession.run(decoderInputs)[0]
1749
- return audio.floatBuffer
1750
- }
1751
-
1752
- private fun charTokenize(text: String): LongArray {
1753
- val chars = "PADBOSabc..."
1754
- return longArrayOf(1L) + text.map { chars.indexOf(it).toLong() } + longArrayOf(2L)
1755
- }
1756
- }
1757
- ```
1758
-
1759
- ## Performance
1760
-
1761
- - Pixel 6+: ~300-500ms per verse
1762
- - Peak memory: ~30 MB
1763
- - First-chunk latency: ~100ms
1764
-
1765
- ## Size Optimization
1766
-
1767
- 1. Apply int8 quantization on ONNX models after training
1768
- 2. Strip unnecessary ONNX debug info
1769
- ```
1770
-
1771
- - [ ] **Step 2: Commit**
1772
-
1773
- ```bash
1774
- git add docs/android_integration.md
1775
- git commit -m "docs: Android integration guide for ONNX Runtime"
1776
- ```
1777
-
1778
- ---
1779
-
1780
- ## Self-Review Checklist
1781
-
1782
- 1. **Spec coverage**: Does every spec requirement have a task?
1783
- - VQ-VAE codec: Tasks 2, 3
1784
- - Training VQ-VAE: Task 3
1785
- - Audio encoding: Task 4
1786
- - Flow transformer: Tasks 5, 6
1787
- - Flow training: Task 6
1788
- - Inference pipeline: Task 7
1789
- - ONNX export: Task 7
1790
- - Android integration: Task 8
1791
- - Character tokenizer: Task 1
1792
-
1793
- 2. **Placeholder scan**: No TBD, TODO, or vague steps found.
1794
-
1795
- 3. **Type consistency**:
1796
- - CharTokenizer.encode() returns torch.Tensor (int64) — used consistently
1797
- - VQVAE.forward returns (x_hat, indices, vq_loss) — same in training and inference
1798
- - FlowLMModel.forward returns (next_latent, is_eos) — same everywhere
1799
-
1800
- 4. **Interface consistency**:
1801
- - `encode_to_indices` returns [B, T] long — used in Task 4
1802
- - `decode_from_indices` returns [B, 1, T] — used in Task 7
1803
- - `AudioDataset` yields dict with "audio", "text_tokens", "text", "audio_paths"
1804
- - `CodecDataset` yields dict with "text_tokens", "codes"
bible-tts/docs/superpowers/specs/2026-07-08-bible-tts-design.md DELETED
@@ -1,241 +0,0 @@
1
- # Bible-TTS: Textless TTS for the English Bible
2
-
3
- **Date:** 2026-07-08
4
- **Status:** Draft
5
- **Repository:** `bible-tts`
6
-
7
- ## Motivation
8
-
9
- Deploy a purpose-built text-to-speech model on Android that reads the English Bible using a specific narrator's voice. The model must be significantly smaller than general-purpose TTS models (pocket-tts at 170MB, Kokoro at 320MB) while maintaining natural speech quality on a single domain (Bible text, ~15K unique words, simple narrative prosody).
10
-
11
- ## Design Constraints
12
-
13
- | Constraint | Target |
14
- |---|---|
15
- | Model size | < 10 MB (int8) for Android APK |
16
- | Inference | > 1× realtime on phone CPU |
17
- | Voice | Fixed narrator from 40 hours of audio |
18
- | Domain | English Bible text only |
19
- | Latency | < 500ms startup, streaming capable |
20
- | Platform | Android via ONNX Runtime Mobile |
21
-
22
- ## Data
23
-
24
- | Item | Details |
25
- |---|---|
26
- | Audio | 40 hours, single speaker reading the English Bible |
27
- | Format | 24kHz mono WAV, ~5 GB |
28
- | Text | Verse-aligned Bible text (31K verses, 725K words) |
29
- | Alignment | Perfect text-audio pairing per verse (no forced alignment needed) |
30
-
31
- ## Architecture: Two-Stage Textless TTS
32
-
33
- ### Overview
34
-
35
- ```
36
- Training:
37
- Raw audio ──→ VQ-VAE Codec ──→ discrete tokens (94Hz)
38
- Text ──→ Char Embedding ──→ Flow Transformer ──→ predict tokens
39
-
40
- Inference:
41
- Text → Char Embedding → Flow Transformer → VQ Decoder → Audio waveform
42
- ```
43
-
44
- The pipeline is "textless" because it never uses phonemes, linguistic features, or forced alignment. The VQ-VAE learns to represent the narrator's voice waveform directly, and the flow transformer learns to map text to those learned representations through a flow-matching objective.
45
-
46
- ---
47
-
48
- ### Stage 1: VQ-VAE Audio Codec
49
-
50
- **Purpose:** Compress 24kHz audio into discrete latent tokens at 94 Hz frame rate.
51
-
52
- **Architecture:**
53
-
54
- ```
55
- Audio (T samples) →
56
- Conv1D(1→32, k=7, s=1) # Initial projection
57
- │
58
- ├── Conv1D(32→64, k=8, s=4) # Stride 4
59
- │ └── ResBlock(64, k=3) × 2
60
- ├── Conv1D(64→128, k=8, s=4) # Stride 4
61
- │ └── ResBlock(128, k=3) × 2
62
- ├── Conv1D(128→128, k=8, s=4) # Stride 4
63
- │ └── ResBlock(128, k=3) × 2
64
- │
65
- └── Conv1D(128→64, k=3, s=1) # Final projection
66
- ↓
67
- VectorQuantizer(codebook=512, dim=64) # 9-bit codes
68
- ↓
69
- ConvTranspose1d(64→128, k=8, s=4) # Stride 4
70
- ├── ResBlock(128, k=3) × 2
71
- ├── ConvTranspose1d(128→64, k=8, s=4) # Stride 4
72
- │ └── ResBlock(64, k=3) × 2
73
- ├── ConvTranspose1d(64→32, k=8, s=4) # Stride 4
74
- │ └── ResBlock(32, k=3) × 2
75
- │
76
- └── Conv1D(32→1, k=7, s=1) # Audio output
77
- ```
78
-
79
- | Property | Value |
80
- |---|---|
81
- | Total stride | 256 (4×4×4) |
82
- | Frame rate | 24000 / 256 = 93.75 Hz |
83
- | Codebook | 512 entries × 64 dim |
84
- | Bits per frame | 9 bits |
85
- | Runtime tokens (40h) | ~13.5M tokens |
86
- | Encoder params | ~1.5M |
87
- | Decoder params | ~1.0M |
88
- | **Total codec params** | **~2.5M** |
89
-
90
- **ResBlock structure:**
91
- ```
92
- ELU → Conv1D(dim→dim/2, k=3, d=base^j) → ELU → Conv1D(dim/2→dim, k=3, d=1) → + residual
93
- ```
94
-
95
- **Training losses:**
96
- - L1 reconstruction loss: ‖x - x̂‖₁
97
- - Commitment loss: ‖sg[z_e] - e‖₂² + β‖z_e - sg[e]‖₂² (β=0.25)
98
- - No perceptual loss (simplifies training, single speaker is forgiving)
99
-
100
- **Training details:**
101
- - Optimizer: AdamW (lr=3e-4, weight_decay=1e-4)
102
- - Batch size: 16 (crop to 2-second windows)
103
- - Steps: 200K (~1-2 days on single GPU)
104
- - Learning rate: cosine schedule with 5K warmup
105
-
106
- ---
107
-
108
- ### Stage 2: Flow Transformer (Text-to-Codec)
109
-
110
- **Purpose:** Generate VQ codec latent sequences from input text.
111
-
112
- **Architecture:**
113
-
114
- ```
115
- Input text ──→ Character embedding ──→
116
- ┌─────────────────────────────────────────────┐
117
- │ StreamingTransformer (3 layers) │
118
- │ d_model=256, num_heads=4, FFN=1024 │
119
- │ RoPE position encoding │
120
- │ Pre-norm LayerNorm │
121
- └─────────────────────────────────────────────┘
122
- │
123
- ↓
124
- AdaLN Flow MLP ──→ Linear(256→64) ──→ Predicted latent (dim=64)
125
- ```
126
-
127
- **Character embedding:**
128
- - Vocabulary: 81 printable ASCII chars + space + special tokens (BOS, EOS, PAD) = ~88 tokens
129
- - Embedding dim: 128
130
- - Params: 88 × 128 = 11,264
131
-
132
- **Streaming Transformer (3 layers):**
133
- - Per layer: Multihead self-attention (4 heads, d_k=64) + FFN (1024 hidden)
134
- - Rotary Position Embeddings (RoPE) for position encoding
135
- - KV caching for streaming inference
136
- - Params per layer: ~440K
137
- - Total transformer: ~1.3M
138
-
139
- **Flow MLP (AdaLN):**
140
- - Conditioned on transformer output, predicts flow direction
141
- - Hidden: 1024 → 1024 → 1024 (3-layer)
142
- - Conditioning via adaptive layer norm
143
- - Params: ~1.6M
144
-
145
- **Output projection:**
146
- - Linear(256 → 64): ~16K
147
-
148
- **Total Stage 2 params: ~3.0M**
149
-
150
- **Flow matching objective:**
151
- - Lagrangian Self Distillation (LSD) — same as pocket-tts
152
- - Training: predict velocity field v_t conditioned on text
153
- - Inference: sample noise, integrate v_t with 4 steps to get latent
154
- - Enables non-autoregressive, fast generation
155
-
156
- **Training details:**
157
- - Optimizer: AdamW (lr=2e-4, weight_decay=1e-4)
158
- - Batch size: 32 (padded to max 800 tokens)
159
- - Steps: 300K (~2-3 days on single GPU)
160
- - Input: text + previous audio latent
161
- - Loss: flow-matching MSE + EOS prediction (binary cross-entropy)
162
-
163
- ---
164
-
165
- ### Inference Pipeline (Android)
166
-
167
- ```
168
- User text → char tokenize → Flow Transformer (4 LSD steps) → VQ Decoder → Audio
169
- ```
170
-
171
- **Inference model components (ONNX):**
172
-
173
- | Component | Params | float16 | int8 |
174
- |---|---|---|---|
175
- | Flow Transformer | 3.0M | 12 MB | 3.0 MB |
176
- | Flow MLP | 1.6M | 6.4 MB | 1.6 MB |
177
- | VQ Decoder | 1.0M | 4.0 MB | 1.0 MB |
178
- | Codebook | 32K | 64 KB | 32 KB |
179
- | Embedding | 11K | 22 KB | 11 KB |
180
- | **Total** | **~5.6M** | **~22.5 MB** | **~5.6 MB** |
181
-
182
- **ONNX Runtime Mobile overhead:** ~5 MB per APK
183
-
184
- **Total APK impact:** ~11 MB (int8 model + runtime)
185
-
186
- **Latency budget (phone CPU):**
187
- - Text tokenization: < 1ms
188
- - Flow transformer (4 LSD steps): 200-400ms for ~50-word verse
189
- - VQ decode: 50-100ms
190
- - Total: ~300-500ms per verse → ~2-3× realtime
191
-
192
- ---
193
-
194
- ### Training Plan
195
-
196
- | Phase | Duration | Output |
197
- |---|---|---|
198
- | 1. Prepare data | 1 day | 40h audio → 24kHz WAV, verify text alignment |
199
- | 2. Train VQ-VAE | 2 days | Codec checkpoint (~2.5M params) |
200
- | 3. Encode audio | 1 day | 13.5M discrete tokens, codec frozen |
201
- | 4. Train Flow Transformer | 3 days | Transformer checkpoint (~4.6M params) |
202
- | 5. Export | 1 day | ONNX model, int8 quantization, validation |
203
- | **Total** | **~8 days** | **Ready for Android integration** |
204
-
205
- ### Voice Cloning / Multi-Voice (Future)
206
-
207
- The VQ-VAE codec captures speaker identity in its codebook and decoder. To support additional voices:
208
- - Train a multi-speaker codec (add speaker embedding to quantizer)
209
- - Train condition the flow transformer on a speaker ID
210
- - Requires multi-speaker training data (~1h per new voice)
211
-
212
- ---
213
-
214
- ## Success Criteria
215
-
216
- | Metric | Target |
217
- |---|---|
218
- | Model size (int8) | < 10 MB |
219
- | Inference speed | > 1× realtime on Pixel 6+ CPU |
220
- | WER (word error rate) | < 5% on held-out Bible verses |
221
- | MOS (mean opinion score) | > 3.5 / 5 |
222
- | Cold start latency | < 1 second |
223
- | Streaming | First audio chunk within 200ms |
224
-
225
- ---
226
-
227
- ## Open Questions
228
-
229
- 1. **Perceptual loss for VQ-VAE**: Should we add a pre-trained feature loss (e.g., from WavLM) for better reconstruction quality, or is L1+commitment sufficient for single-speaker speech?
230
- 2. **EOS detection**: How to handle end-of-verse detection? Use a learned EOS head (like pocket-tts) or assume fixed-length generation?
231
- 3. **Streaming vs. full**: Should inference generate entire verse at once (simpler) or stream chunk-by-chunk (lower latency)?
232
- 4. **SentencePiece vs. character**: Character level (88 tokens) handles rare names naturally. But could a tiny BPE (500 tokens) reduce sequence length and speed up inference?
233
-
234
- ---
235
-
236
- ## Related Work
237
-
238
- - **pocket-tts** (Kyutai): Flow matching + Mimi codec, same textless paradigm. Our codec is domain-specific and smaller.
239
- - **AudioLM** (Google): Textless approach using semantic + acoustic tokens from w2v-BERT and SoundStream.
240
- - **VALL-E** (Microsoft): Neural codec language model for TTS. Autoregressive, not suitable for Android CPU.
241
- - **Matcha-TTS** (Shivam Mehta et al.): Flow matching on mel spectrograms. Simpler but needs separate vocoder.
bible-tts/pyproject.toml DELETED
@@ -1,28 +0,0 @@
1
- [project]
2
- name = "bible-tts"
3
- version = "0.1.0"
4
- requires-python = ">= 3.10"
5
- dependencies = [
6
- "torch>=2.5.0",
7
- "torchaudio>=2.5.0",
8
- "einops>=0.4.0",
9
- "scipy>=1.5.0",
10
- "soundfile>=0.12.0",
11
- "onnxruntime>=1.15.0",
12
- ]
13
-
14
- [build-system]
15
- requires = ["hatchling"]
16
- build-backend = "hatchling.build"
17
-
18
- [tool.hatch.build.targets.wheel]
19
- packages = ["src/bible_tts"]
20
-
21
- [tool.ruff]
22
- line-length = 100
23
-
24
- [dependency-groups]
25
- dev = ["pytest>=8"]
26
-
27
- [tool.pytest.ini_options]
28
- testpaths = ["tests"]
bible-tts/scripts/create_manifest.py DELETED
@@ -1,36 +0,0 @@
1
- """Convert en_bsb.txt + audio dir into a JSONL manifest."""
2
- import json
3
- from pathlib import Path
4
-
5
-
6
- def create_manifest(
7
- bible_text_path: str,
8
- audio_dir: str,
9
- output_path: str,
10
- audio_format: str = "wav",
11
- ):
12
- """Match verses in bible_text_path to audio files in audio_dir."""
13
- with open(bible_text_path) as f:
14
- lines = f.readlines()
15
-
16
- audio_dir = Path(audio_dir)
17
- entries = []
18
- missing = 0
19
- for line in lines:
20
- parts = line.strip().split("|")
21
- if len(parts) < 7:
22
- continue
23
- book, chapter, verse = parts[0], parts[1], parts[2]
24
- text = parts[6]
25
- audio_name = f"{book}_{chapter}_{verse}.{audio_format}"
26
- audio_path = audio_dir / audio_name
27
- if audio_path.exists():
28
- entries.append({"audio": str(audio_path), "text": text})
29
- else:
30
- missing += 1
31
-
32
- with open(output_path, "w") as f:
33
- for entry in entries:
34
- f.write(json.dumps(entry) + "\n")
35
-
36
- print(f"Created manifest with {len(entries)} entries, {missing} missing audio files")
bible-tts/scripts/encode_audio.py DELETED
@@ -1,59 +0,0 @@
1
- """Encode all audio through trained VQ-VAE to create (text, code_tokens) pairs."""
2
-
3
- import json
4
- import argparse
5
- from pathlib import Path
6
-
7
- import torch
8
- from torch.utils.data import DataLoader
9
-
10
- from bible_tts.data.audio_dataset import AudioDataset, collate_audio
11
- from bible_tts.models.vq_vae import VQVAE
12
-
13
-
14
- def main():
15
- parser = argparse.ArgumentParser()
16
- parser.add_argument("--manifest", type=str, required=True)
17
- parser.add_argument("--checkpoint", type=str, required=True)
18
- parser.add_argument("--output", type=str, required=True)
19
- parser.add_argument("--batch-size", type=int, default=32)
20
- args = parser.parse_args()
21
-
22
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
23
-
24
- model = VQVAE().to(device)
25
- state = torch.load(args.checkpoint, map_location=device)
26
- model.load_state_dict(state["model"])
27
- model.eval()
28
-
29
- dataset = AudioDataset(args.manifest)
30
- loader = DataLoader(
31
- dataset,
32
- batch_size=args.batch_size,
33
- shuffle=False,
34
- num_workers=4,
35
- collate_fn=collate_audio,
36
- )
37
-
38
- output_path = Path(args.output)
39
- output_path.parent.mkdir(parents=True, exist_ok=True)
40
-
41
- with open(output_path, "w") as f:
42
- with torch.no_grad():
43
- for batch in loader:
44
- audio = batch["audio"].unsqueeze(1).to(device)
45
- indices = model.encode_to_indices(audio)
46
- for i in range(len(batch["text"])):
47
- tokens = indices[i].tolist()
48
- entry = {
49
- "text": batch["text"][i],
50
- "codes": tokens,
51
- "audio_path": batch["audio_paths"][i],
52
- }
53
- f.write(json.dumps(entry) + "\n")
54
-
55
- print(f"Encoded {len(dataset)} examples to {args.output}")
56
-
57
-
58
- if __name__ == "__main__":
59
- main()
bible-tts/scripts/split_chapters_to_verses.py DELETED
@@ -1,247 +0,0 @@
1
- """Split chapter-level audio into verse-level WAV files using forced alignment.
2
-
3
- Requirements:
4
- pip install montreal-forced-aligner
5
-
6
- Usage:
7
- python3 scripts/split_chapters_to_verses.py \
8
- --bible-text en_bsb.txt \
9
- --audio-dir /path/to/chapter/audio \
10
- --output-dir /path/to/verse/audio
11
-
12
- Input expects: /path/to/chapter/audio/{Book}_{Chapter}.wav
13
- Output: /path/to/verse/audio/{Book}_{Chapter}_{Verse}.wav
14
- """
15
-
16
- import argparse
17
- import json
18
- import os
19
- import subprocess
20
- import tempfile
21
- from pathlib import Path
22
-
23
- import torch
24
- import torchaudio
25
-
26
-
27
- def parse_args():
28
- parser = argparse.ArgumentParser()
29
- parser.add_argument("--bible-text", required=True)
30
- parser.add_argument("--audio-dir", required=True)
31
- parser.add_argument("--output-dir", required=True)
32
- parser.add_argument("--temp-dir", default="/tmp/bible_alignment")
33
- parser.add_argument("--skip-mfa", action="store_true", help="Skip MFA step if already aligned")
34
- return parser.parse_args()
35
-
36
-
37
- def build_chapter_texts(bible_path: str) -> dict:
38
- """Group verses by chapter, return {(book, chapter): [verse_text, ...]}"""
39
- chapters = {}
40
- with open(bible_path) as f:
41
- for line in f:
42
- parts = line.strip().split("|")
43
- if len(parts) < 7:
44
- continue
45
- book, chapter, verse = parts[0], parts[1], parts[2]
46
- text = parts[6]
47
- key = (book, chapter)
48
- if key not in chapters:
49
- chapters[key] = []
50
- chapters[key].append((int(verse), text))
51
- # Sort by verse number
52
- for k in chapters:
53
- chapters[k].sort(key=lambda x: x[0])
54
- return chapters
55
-
56
-
57
- def prepare_mfa_input(chapters: dict, audio_dir: Path, temp_dir: Path):
58
- """Create MFA input: one text file per chapter with verse markers."""
59
- text_dir = temp_dir / "texts"
60
- text_dir.mkdir(parents=True, exist_ok=True)
61
-
62
- chapter_files = []
63
- for (book, chapter), verses in chapters.items():
64
- audio_path = audio_dir / f"{book}_{chapter}.wav"
65
- if not audio_path.exists():
66
- print(f" Skipping {book} {chapter}: no audio at {audio_path}")
67
- continue
68
-
69
- # Write text with verse markers: "In the beginning {verse_1} God created {verse_2} ..."
70
- text_lines = []
71
- for vnum, vtext in verses:
72
- text_lines.append(vtext)
73
- text_lines.append(f"{{verse_{vnum}}}")
74
- full_text = " ".join(text_lines)
75
-
76
- txt_path = text_dir / f"{book}_{chapter}.txt"
77
- with open(txt_path, "w") as f:
78
- f.write(full_text)
79
-
80
- chapter_files.append((book, chapter, audio_path, txt_path))
81
-
82
- return chapter_files
83
-
84
-
85
- def run_mfa(chapter_files: list, temp_dir: Path, audio_dir: Path):
86
- """Run Montreal Forced Aligner to get word-level alignments."""
87
- align_dir = temp_dir / "aligned"
88
- corpus_dir = temp_dir / "corpus"
89
- corpus_dir.mkdir(parents=True, exist_ok=True)
90
-
91
- # Copy chapter audio and text into MFA corpus structure
92
- for book, chapter, audio_path, txt_path in chapter_files:
93
- safe_name = f"{book}_{chapter}".replace(" ", "_")
94
- dst_audio = corpus_dir / f"{safe_name}.wav"
95
- dst_text = corpus_dir / f"{safe_name}.txt"
96
- if not dst_audio.exists():
97
- os.symlink(audio_path, dst_audio)
98
- if not dst_text.exists():
99
- os.symlink(txt_path, dst_text)
100
-
101
- # Try aligning with pretrained English model
102
- cmds = [
103
- ["mfa", "validate", str(corpus_dir), "english_us_arpa", "--single_speaker"],
104
- ["mfa", "align", str(corpus_dir), "english_us_arpa", "english_us_arpa", str(align_dir), "--single_speaker", "--clean"],
105
- ]
106
- for cmd in cmds:
107
- what = cmd[1]
108
- print(f"Running: mfa {what}...")
109
- result = subprocess.run(cmd, capture_output=True, text=True)
110
- if result.returncode != 0:
111
- if "download" in result.stderr.lower():
112
- # Try downloading pretrained models first
113
- subprocess.run(["mfa", "model", "download", "dictionary", "english_us_arpa"], check=True)
114
- subprocess.run(["mfa", "model", "download", "acoustic", "english_us_arpa"], check=True)
115
- result = subprocess.run(cmd, capture_output=True, text=True)
116
- if result.returncode != 0:
117
- print(f"MFA failed on {what}:")
118
- print(result.stderr[:500])
119
- raise RuntimeError(f"MFA {what} failed")
120
- return align_dir
121
-
122
-
123
- def parse_textgrid(textgrid_path: Path, verses: list) -> list:
124
- """Parse MFA output TextGrid to find verse boundaries from markers.
125
-
126
- MFA aligns words and markers like {verse_1} appear as word intervals.
127
- We use the marker positions to slice the audio.
128
- """
129
- text = textgrid_path.read_text()
130
- lines = text.splitlines()
131
-
132
- # Find the words/item tier
133
- in_words_tier = False
134
- verse_boundaries = []
135
-
136
- for line in lines:
137
- stripped = line.strip()
138
- if stripped.startswith('class = "Tier"') or stripped.startswith('class = "IntervalTier"'):
139
- # Check if next lines name contains "word"
140
- continue
141
- if '"word"' in stripped.lower() or stripped.strip('"').lower().startswith('word'):
142
- in_words_tier = True
143
- continue
144
- if stripped.startswith("intervals ["):
145
- in_words_tier = True
146
-
147
- if not in_words_tier:
148
- continue
149
-
150
- # Parse intervals: "xmin = 0.123", "xmax = 4.567", "text = "{verse_5}""
151
- if stripped.startswith("xmin"):
152
- xmin = float(stripped.split("=")[1].strip())
153
- elif stripped.startswith("xmax"):
154
- xmax = float(stripped.split("=")[1].strip())
155
- elif stripped.startswith("text"):
156
- label = stripped.split("=", 1)[1].strip().strip('"').strip()
157
- if label.startswith("{verse_") and label.endswith("}"):
158
- vnum = int(label[7:-1])
159
- verse_boundaries.append((vnum, xmin, xmax))
160
-
161
- return verse_boundaries
162
-
163
-
164
- def split_audio(
165
- chapter_files: list,
166
- chapters: dict,
167
- align_dir: Path,
168
- output_dir: Path,
169
- sample_rate: int = 24000,
170
- ):
171
- """Split chapter audio at verse boundaries and write verse WAVs."""
172
- output_dir = Path(output_dir)
173
- output_dir.mkdir(parents=True, exist_ok=True)
174
-
175
- manifest = []
176
- for book, chapter, audio_path, _ in chapter_files:
177
- textgrid_path = align_dir / f"{book}_{chapter}.TextGrid"
178
- if not textgrid_path.exists():
179
- print(f" No alignment for {book} {chapter}, skipping")
180
- continue
181
-
182
- audio, sr = torchaudio.load(str(audio_path))
183
- if sr != sample_rate:
184
- audio = torchaudio.functional.resample(audio, sr, sample_rate)
185
-
186
- verses = chapters[(book, chapter)]
187
- boundaries = parse_textgrid(textgrid_path, verses)
188
- boundaries.sort(key=lambda x: x[0])
189
-
190
- for i, (vnum, start, end) in enumerate(boundaries):
191
- start_sample = int(start * sample_rate)
192
- end_sample = int(end * sample_rate)
193
-
194
- if end_sample <= start_sample:
195
- continue
196
-
197
- verse_audio = audio[:, start_sample:end_sample]
198
- out_name = f"{book}_{chapter}_{vnum}.wav"
199
- out_path = output_dir / out_name
200
- torchaudio.save(str(out_path), verse_audio, sample_rate)
201
-
202
- manifest.append({
203
- "audio": str(out_path.absolute()),
204
- "text": verses[i][1],
205
- })
206
-
207
- print(f" {book} {chapter}: {len(boundaries)} verses")
208
-
209
- return manifest
210
-
211
-
212
- def main():
213
- args = parse_args()
214
- temp_dir = Path(args.temp_dir)
215
- audio_dir = Path(args.audio_dir)
216
- output_dir = Path(args.output_dir)
217
-
218
- print("Building chapter texts from Bible file...")
219
- chapters = build_chapter_texts(args.bible_text)
220
- print(f" Found {len(chapters)} chapters")
221
-
222
- print("Preparing MFA input...")
223
- chapter_files = prepare_mfa_input(chapters, audio_dir, temp_dir)
224
- print(f" {len(chapter_files)} chapters with audio")
225
-
226
- if not args.skip_mfa:
227
- align_dir = run_mfa(chapter_files, temp_dir, audio_dir)
228
- else:
229
- align_dir = temp_dir / "aligned"
230
-
231
- print("Splitting audio at verse boundaries...")
232
- manifest = split_audio(chapter_files, chapters, align_dir, output_dir)
233
-
234
- # Write manifest
235
- manifest_path = output_dir / "manifest.jsonl"
236
- with open(manifest_path, "w") as f:
237
- for entry in manifest:
238
- f.write(json.dumps(entry) + "\n")
239
-
240
- print(f"\nDone! {len(manifest)} verses extracted.")
241
- print(f"Manifest: {manifest_path}")
242
- print(f"Audio: {output_dir}/")
243
- print(f"\nTotal size: {sum(os.path.getsize(e['audio']) for e in manifest) / 1e9:.1f} GB")
244
-
245
-
246
- if __name__ == "__main__":
247
- main()
bible-tts/src/bible_tts/__init__.py DELETED
File without changes
bible-tts/src/bible_tts/data/__init__.py DELETED
File without changes
bible-tts/src/bible_tts/data/audio_dataset.py DELETED
@@ -1,81 +0,0 @@
1
- import json
2
- from pathlib import Path
3
-
4
- import torch
5
- import torchaudio
6
- from torch.utils.data import Dataset
7
-
8
- from bible_tts.data.text_processor import CharTokenizer
9
-
10
-
11
- class AudioDataset(Dataset):
12
- """Loads verse-aligned (audio, text) pairs.
13
-
14
- Manifest format: JSONL where each line has:
15
- {"audio": "path/to/verse.wav", "text": "In the beginning..."}
16
- Audio is resampled to target_sr if needed.
17
- """
18
-
19
- def __init__(
20
- self,
21
- manifest_path: str | Path,
22
- target_sr: int = 24000,
23
- max_audio_seconds: float = 30.0,
24
- ):
25
- self.manifest_path = Path(manifest_path)
26
- self.target_sr = target_sr
27
- self.max_samples = int(max_audio_seconds * target_sr)
28
- self.tokenizer = CharTokenizer()
29
-
30
- with open(self.manifest_path) as f:
31
- self.entries = [json.loads(line) for line in f if line.strip()]
32
-
33
- def __len__(self) -> int:
34
- return len(self.entries)
35
-
36
- def __getitem__(self, idx: int) -> dict:
37
- entry = self.entries[idx]
38
- audio_path = Path(entry["audio"])
39
- text = entry["text"]
40
-
41
- waveform, sr = torchaudio.load(str(audio_path))
42
- if sr != self.target_sr:
43
- waveform = torchaudio.functional.resample(waveform, sr, self.target_sr)
44
-
45
- if waveform.shape[-1] > self.max_samples:
46
- waveform = waveform[..., : self.max_samples]
47
-
48
- text_tokens = self.tokenizer.encode(text)
49
-
50
- return {
51
- "audio": waveform.squeeze(0), # [T]
52
- "text_tokens": text_tokens, # [S]
53
- "text": text,
54
- "audio_path": str(audio_path),
55
- }
56
-
57
-
58
- def collate_audio(batch: list[dict]) -> dict:
59
- """Pads audio and text tokens to max length in batch."""
60
- max_audio_len = max(item["audio"].shape[-1] for item in batch)
61
- max_text_len = max(item["text_tokens"].shape[-1] for item in batch)
62
-
63
- audio_padded = torch.zeros(len(batch), max_audio_len)
64
- text_padded = torch.zeros(len(batch), max_text_len, dtype=torch.long)
65
- texts = []
66
- paths = []
67
-
68
- for i, item in enumerate(batch):
69
- audio_len = item["audio"].shape[-1]
70
- text_len = item["text_tokens"].shape[-1]
71
- audio_padded[i, :audio_len] = item["audio"]
72
- text_padded[i, :text_len] = item["text_tokens"]
73
- texts.append(item["text"])
74
- paths.append(item["audio_path"])
75
-
76
- return {
77
- "audio": audio_padded,
78
- "text_tokens": text_padded,
79
- "text": texts,
80
- "audio_paths": paths,
81
- }
bible-tts/src/bible_tts/data/text_processor.py DELETED
@@ -1,41 +0,0 @@
1
- import torch
2
-
3
- PAD_TOKEN = "<PAD>"
4
- BOS_TOKEN = "<BOS>"
5
- EOS_TOKEN = "<EOS>"
6
-
7
- CHAR_SET = sorted(set(
8
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
9
- ".,!?;:-\"'()[]{}@#$%&*+=/\\<>~`| "
10
- ))
11
- PAD_IDX = 0
12
- BOS_IDX = 1
13
- EOS_IDX = 2
14
- SPECIAL = {PAD_TOKEN, BOS_TOKEN, EOS_TOKEN}
15
-
16
-
17
- class CharTokenizer:
18
- def __init__(self):
19
- self.chars = [PAD_TOKEN, BOS_TOKEN, EOS_TOKEN] + CHAR_SET
20
- self.char_to_idx = {c: i for i, c in enumerate(self.chars)}
21
- self.idx_to_char = {i: c for i, c in enumerate(self.chars)}
22
-
23
- @property
24
- def vocab_size(self) -> int:
25
- return len(self.chars)
26
-
27
- def encode(self, text: str) -> torch.Tensor:
28
- tokens = [BOS_IDX]
29
- for c in text:
30
- tokens.append(self.char_to_idx.get(c, PAD_IDX))
31
- tokens.append(EOS_IDX)
32
- return torch.tensor(tokens, dtype=torch.long)
33
-
34
- def decode(self, tokens: torch.Tensor) -> str:
35
- chars = []
36
- for t in tokens.tolist():
37
- c = self.idx_to_char.get(t, "")
38
- if c in SPECIAL:
39
- continue
40
- chars.append(c)
41
- return "".join(chars)
bible-tts/src/bible_tts/inference/__init__.py DELETED
@@ -1,3 +0,0 @@
1
- from bible_tts.inference.pipeline import TTSPipeline
2
-
3
- __all__ = ["TTSPipeline"]
bible-tts/src/bible_tts/inference/export_onnx.py DELETED
@@ -1,96 +0,0 @@
1
- import torch
2
-
3
- import onnxruntime
4
-
5
- from bible_tts.models.flow_lm import FlowLMModel
6
- from bible_tts.models.vq_vae import VQVAE
7
-
8
-
9
- def export_flow_lm_onnx(checkpoint_path: str, output_path: str):
10
- model = FlowLMModel()
11
- state = torch.load(checkpoint_path, map_location="cpu")
12
- model.load_state_dict(state["model"])
13
- model.eval()
14
-
15
- class FlowLMInferenceWrapper(torch.nn.Module):
16
- def __init__(self, flow_model):
17
- super().__init__()
18
- self.model = flow_model
19
-
20
- def forward(self, text_tokens, code_tokens, temp=1.0, noise_clamp=0.0, eos_threshold=0.5):
21
- next_latent, is_eos = self.model(
22
- text_tokens=text_tokens,
23
- code_tokens=code_tokens,
24
- lsd_steps=4,
25
- temp=temp,
26
- noise_clamp=noise_clamp if noise_clamp > 0 else None,
27
- eos_threshold=eos_threshold,
28
- )
29
- return next_latent, is_eos
30
-
31
- wrapper = FlowLMInferenceWrapper(model)
32
- wrapper.eval()
33
-
34
- text_tokens = torch.randint(0, 88, (1, 50), dtype=torch.long)
35
- code_tokens = torch.randint(0, 512, (1, 1), dtype=torch.long)
36
-
37
- torch.onnx.export(
38
- wrapper,
39
- (text_tokens, code_tokens),
40
- output_path,
41
- input_names=["text_tokens", "code_tokens"],
42
- output_names=["next_latent", "is_eos"],
43
- dynamic_axes={
44
- "text_tokens": {1: "text_length"},
45
- "code_tokens": {1: "code_length"},
46
- },
47
- opset_version=17,
48
- )
49
- print(f"FlowLM ONNX model exported to {output_path}")
50
-
51
-
52
- def export_vqvae_decoder_onnx(checkpoint_path: str, output_path: str):
53
- model = VQVAE()
54
- state = torch.load(checkpoint_path, map_location="cpu")
55
- model.load_state_dict(state["model"])
56
- model.eval()
57
-
58
- class VQDecoderWrapper(torch.nn.Module):
59
- def __init__(self, codebook, decoder):
60
- super().__init__()
61
- self.codebook = codebook
62
- self.decoder = decoder
63
-
64
- def forward(self, indices: torch.Tensor) -> torch.Tensor:
65
- z_q = self.codebook(indices)
66
- z_q = z_q.permute(0, 2, 1)
67
- return self.decoder(z_q)
68
-
69
- wrapper = VQDecoderWrapper(model.quantizer.codebook, model.decoder)
70
- wrapper.eval()
71
-
72
- indices = torch.randint(0, 512, (1, 375), dtype=torch.long)
73
-
74
- torch.onnx.export(
75
- wrapper,
76
- indices,
77
- output_path,
78
- input_names=["indices"],
79
- output_names=["audio"],
80
- dynamic_axes={
81
- "indices": {1: "num_frames"},
82
- },
83
- opset_version=17,
84
- )
85
- print(f"VQ Decoder ONNX model exported to {output_path}")
86
-
87
-
88
- def validate_onnx(onnx_path: str, sample_inputs: list):
89
- session = onnxruntime.InferenceSession(onnx_path)
90
- input_names = [inp.name for inp in session.get_inputs()]
91
- output_names = [out.name for out in session.get_outputs()]
92
-
93
- feed = dict(zip(input_names, sample_inputs))
94
- outputs = session.run(output_names, feed)
95
- print(f"ONNX validation passed for {onnx_path}")
96
- return outputs
bible-tts/src/bible_tts/inference/pipeline.py DELETED
@@ -1,87 +0,0 @@
1
- import torch
2
- from bible_tts.data.text_processor import CharTokenizer
3
- from bible_tts.models.vq_vae import VQVAE
4
- from bible_tts.models.flow_lm import FlowLMModel
5
-
6
-
7
- class TTSPipeline:
8
- """Full text-to-speech inference pipeline.
9
-
10
- Text -> CharTokenizer -> FlowLM -> latent codes -> VQ Decoder -> audio
11
- """
12
-
13
- def __init__(
14
- self,
15
- vqvae_checkpoint: str,
16
- flow_checkpoint: str,
17
- device: str = "cpu",
18
- lsd_steps: int = 4,
19
- ):
20
- self.device = torch.device(device)
21
- self.lsd_steps = lsd_steps
22
- self.tokenizer = CharTokenizer()
23
-
24
- self.vqvae = VQVAE().to(self.device)
25
- vq_state = torch.load(vqvae_checkpoint, map_location=self.device)
26
- self.vqvae.load_state_dict(vq_state["model"])
27
- self.vqvae.eval()
28
-
29
- self.flow = FlowLMModel().to(self.device)
30
- flow_state = torch.load(flow_checkpoint, map_location=self.device)
31
- self.flow.load_state_dict(flow_state["model"])
32
- self.flow.eval()
33
-
34
- @torch.no_grad()
35
- def generate(self, text: str) -> torch.Tensor:
36
- text_tokens = self.tokenizer.encode(text).unsqueeze(0).to(self.device)
37
-
38
- max_frames = int(300)
39
- code_tokens = torch.zeros((1, 0), dtype=torch.long, device=self.device)
40
-
41
- for step in range(max_frames):
42
- if step == 0:
43
- current_codes = torch.zeros((1, 1), dtype=torch.long, device=self.device)
44
- else:
45
- current_codes = code_tokens[:, -1:]
46
-
47
- next_latent, is_eos = self.flow(
48
- text_tokens=text_tokens,
49
- code_tokens=current_codes,
50
- lsd_steps=self.lsd_steps,
51
- )
52
-
53
- dist = torch.cdist(
54
- next_latent.unsqueeze(1),
55
- self.flow.code_embed.weight.unsqueeze(0),
56
- )
57
- next_code = dist.argmin(dim=-1)
58
- code_tokens = torch.cat([code_tokens, next_code], dim=-1)
59
-
60
- if is_eos.item() and step > 10:
61
- break
62
-
63
- audio = self.vqvae.decode_from_indices(code_tokens)
64
- return audio
65
-
66
- @torch.no_grad()
67
- def generate_fast(self, text: str) -> torch.Tensor:
68
- text_tokens = self.tokenizer.encode(text).unsqueeze(0).to(self.device)
69
- text_emb = self.flow.char_embed(text_tokens)
70
- text_emb = self.flow.text_proj(text_emb)
71
- cond = text_emb[:, -1]
72
-
73
- noise = torch.randn(1, self.flow.latent_dim, device=self.device)
74
-
75
- def flow_fn(t_prev, t_curr, x_t):
76
- return self.flow.flow_mlp(cond, t_prev, t_curr, x_t)
77
-
78
- from bible_tts.models.lsd import lsd_decode
79
- generated = lsd_decode(flow_fn, noise, self.lsd_steps)
80
-
81
- dist = torch.cdist(
82
- generated.unsqueeze(1),
83
- self.flow.code_embed.weight.unsqueeze(0),
84
- )
85
- code = dist.argmin(dim=-1)
86
- audio = self.vqvae.decode_from_indices(code)
87
- return audio
bible-tts/src/bible_tts/models/__init__.py DELETED
File without changes
bible-tts/src/bible_tts/models/flow_lm.py DELETED
@@ -1,221 +0,0 @@
1
- import torch
2
- from torch import nn
3
- import einops
4
-
5
- from bible_tts.models.lsd import lsd_decode
6
-
7
-
8
- class StreamingMultiheadAttention(nn.Module):
9
- def __init__(self, d_model: int, num_heads: int, max_period: float = 10000.0):
10
- super().__init__()
11
- self.d_model = d_model
12
- self.num_heads = num_heads
13
- self.head_dim = d_model // num_heads
14
- self.max_period = max_period
15
-
16
- self.wq = nn.Linear(d_model, d_model, bias=False)
17
- self.wk = nn.Linear(d_model, d_model, bias=False)
18
- self.wv = nn.Linear(d_model, d_model, bias=False)
19
- self.wo = nn.Linear(d_model, d_model, bias=False)
20
-
21
- def _rope(self, x: torch.Tensor, offset: int = 0) -> torch.Tensor:
22
- """Apply rotary position embeddings."""
23
- B, T, H, D = x.shape
24
- positions = torch.arange(offset, offset + T, device=x.device, dtype=torch.float32)
25
- freqs = torch.exp(
26
- -torch.arange(0, D, 2, device=x.device, dtype=torch.float32)
27
- * (torch.log(torch.tensor(self.max_period)) / D)
28
- )
29
- angles = positions[:, None] * freqs[None, :]
30
- cos, sin = angles.cos()[:, None, :], angles.sin()[:, None, :]
31
- x_rot = x.clone()
32
- x_rot[..., 0::2] = x[..., 0::2] * cos - x[..., 1::2] * sin
33
- x_rot[..., 1::2] = x[..., 1::2] * cos + x[..., 0::2] * sin
34
- return x_rot
35
-
36
- def forward(self, x: torch.Tensor, kv_cache: torch.Tensor | None = None) -> tuple[torch.Tensor, torch.Tensor]:
37
- B, T, _ = x.shape
38
- q = einops.rearrange(self.wq(x), "B T (H D) -> B T H D", H=self.num_heads)
39
- k = einops.rearrange(self.wk(x), "B T (H D) -> B T H D", H=self.num_heads)
40
- v = einops.rearrange(self.wv(x), "B T (H D) -> B T H D", H=self.num_heads)
41
-
42
- q = self._rope(q)
43
- k = self._rope(k)
44
-
45
- if kv_cache is not None:
46
- k = torch.cat([kv_cache[0], k], dim=1)
47
- v = torch.cat([kv_cache[1], v], dim=1)
48
-
49
- attn = torch.einsum("bthd,bThd->bhtT", q, k) / (self.head_dim ** 0.5)
50
- attn = torch.softmax(attn, dim=-1)
51
- out = torch.einsum("bhtT,bThd->bthd", attn, v)
52
- out = einops.rearrange(out, "B T H D -> B T (H D)")
53
- out = self.wo(out)
54
-
55
- new_cache = torch.stack([k, v], dim=0)
56
- return out, new_cache
57
-
58
-
59
- class StreamingTransformerLayer(nn.Module):
60
- def __init__(self, d_model: int, num_heads: int, ff_hidden: int, max_period: float = 10000.0):
61
- super().__init__()
62
- self.attention = StreamingMultiheadAttention(d_model, num_heads, max_period)
63
- self.norm1 = nn.LayerNorm(d_model)
64
- self.ffn = nn.Sequential(
65
- nn.Linear(d_model, ff_hidden),
66
- nn.GELU(),
67
- nn.Linear(ff_hidden, d_model),
68
- )
69
- self.norm2 = nn.LayerNorm(d_model)
70
-
71
- def forward(self, x: torch.Tensor, kv_cache: torch.Tensor | None = None) -> tuple[torch.Tensor, torch.Tensor]:
72
- attn_out, new_cache = self.attention(self.norm1(x), kv_cache)
73
- x = x + attn_out
74
- x = x + self.ffn(self.norm2(x))
75
- return x, new_cache
76
-
77
-
78
- class StreamingTransformer(nn.Module):
79
- def __init__(self, d_model: int, num_layers: int, num_heads: int, ff_hidden: int):
80
- super().__init__()
81
- self.layers = nn.ModuleList([
82
- StreamingTransformerLayer(d_model, num_heads, ff_hidden)
83
- for _ in range(num_layers)
84
- ])
85
-
86
- def forward(self, x: torch.Tensor, kv_caches: list[torch.Tensor] | None = None) -> tuple[torch.Tensor, list[torch.Tensor]]:
87
- new_caches = []
88
- for i, layer in enumerate(self.layers):
89
- cache = kv_caches[i] if kv_caches is not None else None
90
- x, new_cache = layer(x, cache)
91
- new_caches.append(new_cache)
92
- return x, new_caches
93
-
94
-
95
- class FlowMLP(nn.Module):
96
- """AdaLN-conditioned MLP for flow prediction.
97
-
98
- Takes conditioning vector c and input x_t, predicts flow direction.
99
- """
100
-
101
- def __init__(self, d_model: int, latent_dim: int, hidden: int = 1024):
102
- super().__init__()
103
- self.cond_proj = nn.Linear(d_model, latent_dim * 2) # scale + shift
104
- self.net = nn.Sequential(
105
- nn.Linear(latent_dim + 2, hidden), # +2 for t_prev, t_curr
106
- nn.GELU(),
107
- nn.Linear(hidden, hidden),
108
- nn.GELU(),
109
- nn.Linear(hidden, latent_dim),
110
- )
111
-
112
- def forward(self, c: torch.Tensor, t_prev: torch.Tensor, t_curr: torch.Tensor, x_t: torch.Tensor) -> torch.Tensor:
113
- scale, shift = self.cond_proj(c).chunk(2, dim=-1)
114
- cond = torch.cat([t_prev, t_curr], dim=-1)
115
- inp = torch.cat([cond, x_t], dim=-1)
116
- h = self.net(inp)
117
- return h * (1 + scale) + shift
118
-
119
-
120
- class FlowLMModel(nn.Module):
121
- def __init__(
122
- self,
123
- vocab_size: int = 88,
124
- d_embed: int = 128,
125
- d_model: int = 256,
126
- latent_dim: int = 64,
127
- num_layers: int = 3,
128
- num_heads: int = 4,
129
- ff_hidden: int = 1024,
130
- mlp_hidden: int = 1024,
131
- codebook_size: int = 512,
132
- ):
133
- super().__init__()
134
- self.latent_dim = latent_dim
135
- self.d_model = d_model
136
-
137
- self.char_embed = nn.Embedding(vocab_size, d_embed)
138
- self.text_proj = nn.Linear(d_embed, d_model)
139
- self.code_embed = nn.Embedding(codebook_size, latent_dim)
140
- self.input_proj = nn.Linear(latent_dim, d_model)
141
-
142
- self.transformer = StreamingTransformer(d_model, num_layers, num_heads, ff_hidden)
143
- self.out_norm = nn.LayerNorm(d_model)
144
- self.flow_mlp = FlowMLP(d_model, latent_dim, mlp_hidden)
145
-
146
- self.eos_head = nn.Linear(d_model, 1)
147
-
148
- self.bos_code = nn.Parameter(torch.randn(latent_dim))
149
-
150
- def forward(
151
- self,
152
- text_tokens: torch.Tensor,
153
- code_tokens: torch.Tensor,
154
- kv_caches: list[torch.Tensor] | None = None,
155
- lsd_steps: int = 4,
156
- temp: float = 1.0,
157
- noise_clamp: float | None = None,
158
- eos_threshold: float = 0.5,
159
- ) -> tuple[torch.Tensor, torch.Tensor]:
160
- """
161
- Args:
162
- text_tokens: [B, S] character token ids
163
- code_tokens: [B, T] codec token ids (previous audio latents)
164
- kv_caches: optional KV caches for streaming
165
- lsd_steps: number of LSD steps (0 = training, >0 = inference)
166
- Returns:
167
- next_latent: [B, latent_dim] predicted latent
168
- is_eos: [B, 1] EOS prediction
169
- """
170
- B = text_tokens.shape[0]
171
-
172
- text_emb = self.char_embed(text_tokens)
173
- text_emb = self.text_proj(text_emb)
174
-
175
- code_emb = self.code_embed(code_tokens)
176
- code_emb = self.input_proj(code_emb)
177
-
178
- combined = torch.cat([text_emb, code_emb], dim=1)
179
-
180
- out, new_caches = self.transformer(combined, kv_caches)
181
- out = self.out_norm(out)
182
-
183
- last_out = out[:, -1]
184
-
185
- is_eos = (self.eos_head(last_out) > eos_threshold).float()
186
-
187
- if lsd_steps == 0:
188
- return last_out, self.eos_head(last_out) # return logits, not thresholded
189
-
190
- noise_shape = (B, self.latent_dim)
191
- noise = torch.randn(noise_shape, device=text_tokens.device)
192
- if noise_clamp is not None:
193
- noise = noise.clamp(-noise_clamp, noise_clamp)
194
- noise = noise * temp
195
-
196
- def flow_fn(t_prev, t_curr, x_t):
197
- return self.flow_mlp(last_out, t_prev, t_curr, x_t)
198
-
199
- next_latent = lsd_decode(flow_fn, noise, lsd_steps)
200
- return next_latent, is_eos
201
-
202
- def sample_latent(
203
- self,
204
- text_tokens: torch.Tensor,
205
- code_tokens: torch.Tensor,
206
- kv_caches: list[torch.Tensor] | None = None,
207
- lsd_steps: int = 4,
208
- temp: float = 1.0,
209
- noise_clamp: float | None = None,
210
- eos_threshold: float = 0.5,
211
- ) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor]]:
212
- next_latent, is_eos = self(
213
- text_tokens=text_tokens,
214
- code_tokens=code_tokens,
215
- kv_caches=kv_caches,
216
- lsd_steps=lsd_steps,
217
- temp=temp,
218
- noise_clamp=noise_clamp,
219
- eos_threshold=eos_threshold,
220
- )
221
- return next_latent, is_eos
bible-tts/src/bible_tts/models/lsd.py DELETED
@@ -1,26 +0,0 @@
1
- import torch
2
-
3
-
4
- def lsd_decode(
5
- v_t: callable,
6
- x_0: torch.Tensor,
7
- num_steps: int = 4,
8
- ) -> torch.Tensor:
9
- """Lagrangian Self Distillation.
10
-
11
- Args:
12
- v_t: function (t_prev, t_curr, x_t) -> velocity
13
- x_0: starting noise [B, D]
14
- num_steps: number of integration steps
15
- """
16
- current = x_0
17
- for i in range(num_steps):
18
- s = i / num_steps
19
- t = (i + 1) / num_steps
20
- flow_dir = v_t(
21
- s * torch.ones_like(current[..., :1]),
22
- t * torch.ones_like(current[..., :1]),
23
- current,
24
- )
25
- current = current + flow_dir / num_steps
26
- return current
bible-tts/src/bible_tts/models/vq_vae.py DELETED
@@ -1,174 +0,0 @@
1
- import torch
2
- from torch import nn
3
- from torch.nn import functional as F
4
-
5
-
6
- class ResBlock(nn.Module):
7
- def __init__(self, dim: int, kernel_size: int = 3, dilation: int = 1, compress: int = 2):
8
- super().__init__()
9
- hidden = dim // compress
10
- self.conv1 = nn.Conv1d(dim, hidden, kernel_size, padding="same", dilation=dilation)
11
- self.conv2 = nn.Conv1d(hidden, dim, kernel_size, padding="same")
12
- self.activation = nn.ELU()
13
-
14
- def forward(self, x: torch.Tensor) -> torch.Tensor:
15
- residual = x
16
- x = self.activation(x)
17
- x = self.conv1(x)
18
- x = self.activation(x)
19
- x = self.conv2(x)
20
- return x + residual
21
-
22
-
23
- class Encoder(nn.Module):
24
- """Strided conv encoder: downsamples audio to latent codes.
25
-
26
- Args:
27
- in_channels: 1 for mono audio
28
- base_channels: starting channel count (doubles per stride)
29
- latent_dim: output latent dimension
30
- strides: list of stride factors per layer
31
- n_resblocks: number of ResBlocks per level
32
- """
33
-
34
- def __init__(
35
- self,
36
- in_channels: int = 1,
37
- base_channels: int = 32,
38
- latent_dim: int = 64,
39
- strides: list[int] = [4, 4, 4],
40
- n_resblocks: int = 2,
41
- ):
42
- super().__init__()
43
- self.total_stride = 1
44
- for s in strides:
45
- self.total_stride *= s
46
-
47
- layers: list[nn.Module] = [
48
- nn.Conv1d(in_channels, base_channels, kernel_size=7, padding="same")
49
- ]
50
- mult = 1
51
- for stride in strides:
52
- for _ in range(n_resblocks):
53
- layers.append(ResBlock(mult * base_channels))
54
- layers.append(nn.ELU())
55
- layers.append(
56
- nn.Conv1d(
57
- mult * base_channels,
58
- mult * base_channels * 2,
59
- kernel_size=2 * stride,
60
- stride=stride,
61
- padding=stride // 2,
62
- )
63
- )
64
- mult *= 2
65
- layers.append(nn.Conv1d(mult * base_channels, latent_dim, kernel_size=3, padding="same"))
66
- self.layers = nn.Sequential(*layers)
67
-
68
- def forward(self, x: torch.Tensor) -> torch.Tensor:
69
- return self.layers(x)
70
-
71
-
72
- class VectorQuantizer(nn.Module):
73
- def __init__(self, codebook_size: int = 512, dim: int = 64, beta: float = 0.25):
74
- super().__init__()
75
- self.codebook_size = codebook_size
76
- self.dim = dim
77
- self.beta = beta
78
- self.codebook = nn.Embedding(codebook_size, dim)
79
- nn.init.uniform_(self.codebook.weight, -1.0 / codebook_size, 1.0 / codebook_size)
80
-
81
- def forward(self, z: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
82
- # z: [B, D, T]
83
- z_perm = z.permute(0, 2, 1) # [B, T, D]
84
- B, T, D = z_perm.shape
85
- flat = z_perm.reshape(-1, D) # [B*T, D]
86
-
87
- # distances: [B*T, codebook_size]
88
- dist = torch.cdist(flat, self.codebook.weight)
89
- indices = dist.argmin(dim=-1) # [B*T]
90
-
91
- # quantize
92
- z_q = self.codebook(indices) # [B*T, D]
93
- z_q = z_q.reshape(B, T, D).permute(0, 2, 1) # [B, D, T]
94
-
95
- # commitment loss
96
- loss = F.mse_loss(z_q.detach(), z) + self.beta * F.mse_loss(z_q, z.detach())
97
-
98
- # straight-through estimator
99
- z_q = z + (z_q - z).detach()
100
- return z_q, indices.reshape(B, T), loss
101
-
102
-
103
- class Decoder(nn.Module):
104
- """Transposed conv decoder: upsamples latent codes to audio."""
105
-
106
- def __init__(
107
- self,
108
- out_channels: int = 1,
109
- base_channels: int = 32,
110
- latent_dim: int = 64,
111
- strides: list[int] = [4, 4, 4],
112
- n_resblocks: int = 2,
113
- ):
114
- super().__init__()
115
- mult = 2 ** len(strides)
116
- layers: list[nn.Module] = [
117
- nn.Conv1d(latent_dim, mult * base_channels, kernel_size=3, padding="same")
118
- ]
119
- for stride in strides:
120
- layers.append(nn.ELU())
121
- layers.append(
122
- nn.ConvTranspose1d(
123
- mult * base_channels,
124
- mult * base_channels // 2,
125
- kernel_size=2 * stride,
126
- stride=stride,
127
- padding=stride // 2,
128
- )
129
- )
130
- for _ in range(n_resblocks):
131
- layers.append(ResBlock(mult * base_channels // 2))
132
- mult //= 2
133
- layers.append(nn.Conv1d(base_channels, out_channels, kernel_size=7, padding="same"))
134
- self.layers = nn.Sequential(*layers)
135
-
136
- def forward(self, x: torch.Tensor) -> torch.Tensor:
137
- return self.layers(x)
138
-
139
-
140
- class VQVAE(nn.Module):
141
- def __init__(
142
- self,
143
- in_channels: int = 1,
144
- base_channels: int = 32,
145
- latent_dim: int = 64,
146
- codebook_size: int = 512,
147
- strides: list[int] = [4, 4, 4],
148
- n_resblocks: int = 2,
149
- beta: float = 0.25,
150
- ):
151
- super().__init__()
152
- self.encoder = Encoder(in_channels, base_channels, latent_dim, strides, n_resblocks)
153
- self.quantizer = VectorQuantizer(codebook_size, latent_dim, beta)
154
- self.decoder = Decoder(in_channels, base_channels, latent_dim, strides, n_resblocks)
155
-
156
- @property
157
- def frame_rate(self) -> float:
158
- return 24000 / self.encoder.total_stride
159
-
160
- def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
161
- z_e = self.encoder(x)
162
- z_q, indices, vq_loss = self.quantizer(z_e)
163
- x_hat = self.decoder(z_q)
164
- return x_hat, indices, vq_loss
165
-
166
- def encode_to_indices(self, x: torch.Tensor) -> torch.Tensor:
167
- z_e = self.encoder(x)
168
- _, indices, _ = self.quantizer(z_e)
169
- return indices
170
-
171
- def decode_from_indices(self, indices: torch.Tensor) -> torch.Tensor:
172
- z_q = self.quantizer.codebook(indices)
173
- z_q = z_q.permute(0, 2, 1)
174
- return self.decoder(z_q)
bible-tts/src/bible_tts/training/__init__.py DELETED
File without changes
bible-tts/src/bible_tts/training/train_flow.py DELETED
@@ -1,209 +0,0 @@
1
- import argparse
2
- import json
3
- import logging
4
- import shutil
5
- from pathlib import Path
6
-
7
- import torch
8
- from torch import nn, optim
9
- from torch.utils.data import Dataset, DataLoader
10
- from torch.optim.lr_scheduler import CosineAnnealingLR
11
-
12
- from bible_tts.data.text_processor import CharTokenizer
13
- from bible_tts.models.flow_lm import FlowLMModel
14
-
15
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- class CodecDataset(Dataset):
20
- """Dataset of (text, codec_token_sequence) pairs from encoded JSONL."""
21
-
22
- def __init__(self, encoded_path: str, max_code_len: int = 1000):
23
- self.tokenizer = CharTokenizer()
24
- self.max_code_len = max_code_len
25
- with open(encoded_path) as f:
26
- self.entries = [json.loads(line) for line in f if line.strip()]
27
-
28
- def __len__(self):
29
- return len(self.entries)
30
-
31
- def __getitem__(self, idx):
32
- entry = self.entries[idx]
33
- text_tokens = self.tokenizer.encode(entry["text"])
34
- codes = torch.tensor(entry["codes"], dtype=torch.long)
35
- if codes.shape[-1] > self.max_code_len:
36
- codes = codes[:self.max_code_len]
37
- return {"text_tokens": text_tokens, "codes": codes}
38
-
39
-
40
- def collate_fn(batch):
41
- max_text = max(item["text_tokens"].shape[-1] for item in batch)
42
- max_code = max(item["codes"].shape[-1] for item in batch)
43
-
44
- text_padded = torch.zeros(len(batch), max_text, dtype=torch.long)
45
- code_padded = torch.zeros(len(batch), max_code, dtype=torch.long)
46
- code_masks = torch.zeros(len(batch), max_code, dtype=torch.bool)
47
-
48
- for i, item in enumerate(batch):
49
- tl = item["text_tokens"].shape[-1]
50
- cl = item["codes"].shape[-1]
51
- text_padded[i, :tl] = item["text_tokens"]
52
- code_padded[i, :cl] = item["codes"]
53
- code_masks[i, :cl] = True
54
-
55
- return {"text_tokens": text_padded, "codes": code_padded, "code_masks": code_masks}
56
-
57
-
58
- def parse_args():
59
- parser = argparse.ArgumentParser()
60
- parser.add_argument("--encoded-data", type=str, required=True)
61
- parser.add_argument("--checkpoint-dir", type=str, default="checkpoints")
62
- parser.add_argument("--batch-size", type=int, default=32)
63
- parser.add_argument("--lr", type=float, default=2e-4)
64
- parser.add_argument("--weight-decay", type=float, default=1e-4)
65
- parser.add_argument("--max-steps", type=int, default=300000)
66
- parser.add_argument("--warmup-steps", type=int, default=5000)
67
- parser.add_argument("--log-interval", type=int, default=100)
68
- parser.add_argument("--save-interval", type=int, default=10000)
69
- parser.add_argument("--seed", type=int, default=42)
70
- parser.add_argument("--resume", type=str, default=None)
71
- return parser.parse_args()
72
-
73
-
74
- def train():
75
- args = parse_args()
76
- torch.manual_seed(args.seed)
77
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
78
- logger.info(f"Using device: {device}")
79
-
80
- checkpoint_dir = Path(args.checkpoint_dir)
81
- checkpoint_dir.mkdir(parents=True, exist_ok=True)
82
-
83
- dataset = CodecDataset(args.encoded_data)
84
- loader = DataLoader(
85
- dataset,
86
- batch_size=args.batch_size,
87
- shuffle=True,
88
- num_workers=4,
89
- pin_memory=True,
90
- collate_fn=collate_fn,
91
- )
92
- logger.info(f"Dataset: {len(dataset)} examples")
93
-
94
- model = FlowLMModel().to(device)
95
- optimizer = optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
96
- scheduler = CosineAnnealingLR(optimizer, T_max=args.max_steps - args.warmup_steps)
97
- scaler = torch.amp.GradScaler("cuda" if torch.cuda.is_available() else "cpu")
98
-
99
- start_step = 0
100
- if args.resume:
101
- state = torch.load(args.resume, map_location=device)
102
- model.load_state_dict(state["model"])
103
- optimizer.load_state_dict(state["optimizer"])
104
- if "scheduler" in state:
105
- scheduler.load_state_dict(state["scheduler"])
106
- start_step = state["step"]
107
- logger.info(f"Resumed from step {start_step}")
108
-
109
- step = start_step
110
- best_loss = float("inf")
111
- while step < args.max_steps:
112
- for batch in loader:
113
- if step >= args.max_steps:
114
- break
115
-
116
- text = batch["text_tokens"].to(device)
117
- codes = batch["codes"].to(device)
118
-
119
- # Flow matching training
120
- B, T = codes.shape
121
- latent_dim = model.latent_dim
122
-
123
- # Convert code tokens to latent embeddings
124
- code_emb = model.code_embed(codes) # [B, T, latent_dim]
125
-
126
- # Sample random timestep for flow matching
127
- t = torch.rand(B, 1, device=device)
128
- noise = torch.randn(B, latent_dim, device=device)
129
-
130
- with torch.amp.autocast(device_type="cuda" if torch.cuda.is_available() else "cpu"):
131
- # Text embedding
132
- text_emb = model.char_embed(text)
133
- text_emb = model.text_proj(text_emb)
134
-
135
- # Concatenate and run transformer
136
- combined = torch.cat([text_emb, code_emb], dim=1)
137
- out, _ = model.transformer(combined)
138
- out = model.out_norm(out)
139
-
140
- # For each position, predict velocity
141
- cond = out[:, -1] # last position -> conditioning
142
- t_prev = t * 0 # starting from noise
143
- t_curr = t
144
-
145
- x_t = (1 - t) * noise + t * code_emb[:, -1, :]
146
- v_pred = model.flow_mlp(cond, t_prev, t_curr, x_t)
147
- v_target = code_emb[:, -1] - noise # ground truth
148
- flow_loss = nn.functional.mse_loss(v_pred, v_target)
149
-
150
- # Placeholder EOS loss — simplified for now
151
- eos_logits = model.eos_head(out[:, -1])
152
- eos_loss = nn.functional.binary_cross_entropy_with_logits(
153
- eos_logits, torch.zeros(B, 1, device=device)
154
- )
155
-
156
- total_loss = flow_loss + eos_loss
157
-
158
- scaler.scale(total_loss).backward()
159
- scaler.unscale_(optimizer)
160
- nn.utils.clip_grad_norm_(model.parameters(), 1.0)
161
- scaler.step(optimizer)
162
- scaler.update()
163
- optimizer.zero_grad()
164
-
165
- if step < args.warmup_steps:
166
- lr_scale = min(1.0, (step + 1) / args.warmup_steps)
167
- for pg in optimizer.param_groups:
168
- pg["lr"] = args.lr * lr_scale
169
- else:
170
- scheduler.step()
171
-
172
- if step % args.log_interval == 0:
173
- logger.info(
174
- f"Step {step}/{args.max_steps} | flow: {flow_loss.item():.4f} | "
175
- f"eos: {eos_loss.item():.4f} | total: {total_loss.item():.4f}"
176
- )
177
-
178
- if step % args.save_interval == 0 and step > 0:
179
- state = {
180
- "model": model.state_dict(),
181
- "optimizer": optimizer.state_dict(),
182
- "scheduler": scheduler.state_dict(),
183
- "step": step,
184
- "loss": total_loss.item(),
185
- }
186
- ckpt_path = checkpoint_dir / f"flow_step_{step}.pt"
187
- torch.save(state, ckpt_path)
188
- logger.info(f"Saved checkpoint to {ckpt_path}")
189
-
190
- if total_loss.item() < best_loss:
191
- best_loss = total_loss.item()
192
- shutil.copy(ckpt_path, checkpoint_dir / "flow_best.pt")
193
- logger.info(f"New best loss: {best_loss:.4f}")
194
-
195
- step += 1
196
-
197
- state = {
198
- "model": model.state_dict(),
199
- "optimizer": optimizer.state_dict(),
200
- "scheduler": scheduler.state_dict(),
201
- "step": step,
202
- "loss": total_loss.item(),
203
- }
204
- torch.save(state, checkpoint_dir / "flow_final.pt")
205
- logger.info(f"Training complete. Final model saved to {checkpoint_dir / 'flow_final.pt'}")
206
-
207
-
208
- if __name__ == "__main__":
209
- train()
bible-tts/src/bible_tts/training/train_vqvae.py DELETED
@@ -1,138 +0,0 @@
1
- import argparse
2
- import logging
3
- import shutil
4
- from pathlib import Path
5
-
6
- import torch
7
- from torch import nn, optim
8
- from torch.utils.data import DataLoader
9
- from torch.optim.lr_scheduler import CosineAnnealingLR
10
-
11
- from bible_tts.data.audio_dataset import AudioDataset, collate_audio
12
- from bible_tts.models.vq_vae import VQVAE
13
-
14
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- def parse_args():
19
- parser = argparse.ArgumentParser()
20
- parser.add_argument("--manifest", type=str, required=True, help="Path to manifest JSONL")
21
- parser.add_argument("--checkpoint-dir", type=str, default="checkpoints")
22
- parser.add_argument("--batch-size", type=int, default=16)
23
- parser.add_argument("--lr", type=float, default=3e-4)
24
- parser.add_argument("--weight-decay", type=float, default=1e-4)
25
- parser.add_argument("--max-steps", type=int, default=200000)
26
- parser.add_argument("--warmup-steps", type=int, default=5000)
27
- parser.add_argument("--log-interval", type=int, default=100)
28
- parser.add_argument("--save-interval", type=int, default=10000)
29
- parser.add_argument("--seed", type=int, default=42)
30
- parser.add_argument("--resume", type=str, default=None)
31
- return parser.parse_args()
32
-
33
-
34
- def train():
35
- args = parse_args()
36
- torch.manual_seed(args.seed)
37
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
38
- logger.info(f"Using device: {device}")
39
-
40
- checkpoint_dir = Path(args.checkpoint_dir)
41
- checkpoint_dir.mkdir(parents=True, exist_ok=True)
42
-
43
- dataset = AudioDataset(args.manifest)
44
- loader = DataLoader(
45
- dataset,
46
- batch_size=args.batch_size,
47
- shuffle=True,
48
- num_workers=4,
49
- pin_memory=True,
50
- collate_fn=collate_audio,
51
- )
52
- logger.info(f"Dataset: {len(dataset)} examples")
53
-
54
- model = VQVAE().to(device)
55
- optimizer = optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
56
- scheduler = CosineAnnealingLR(optimizer, T_max=args.max_steps - args.warmup_steps)
57
- scaler = torch.amp.GradScaler("cuda" if torch.cuda.is_available() else "cpu")
58
-
59
- start_step = 0
60
- if args.resume:
61
- state = torch.load(args.resume, map_location=device)
62
- model.load_state_dict(state["model"])
63
- optimizer.load_state_dict(state["optimizer"])
64
- if "scheduler" in state:
65
- scheduler.load_state_dict(state["scheduler"])
66
- start_step = state["step"]
67
- logger.info(f"Resumed from step {start_step}")
68
-
69
- step = start_step
70
- best_loss = float("inf")
71
- while step < args.max_steps:
72
- for batch in loader:
73
- if step >= args.max_steps:
74
- break
75
-
76
- audio = batch["audio"].unsqueeze(1).to(device) # [B, 1, T]
77
- crop_len = min(audio.shape[-1], 48000) # 2 sec at 24kHz
78
- if audio.shape[-1] > crop_len:
79
- offset = torch.randint(0, audio.shape[-1] - crop_len, (1,)).item()
80
- audio = audio[..., offset : offset + crop_len]
81
-
82
- with torch.amp.autocast(device_type="cuda" if torch.cuda.is_available() else "cpu"):
83
- x_hat, indices, vq_loss = model(audio)
84
- recon_loss = nn.functional.l1_loss(x_hat, audio)
85
- total_loss = recon_loss + vq_loss
86
-
87
- scaler.scale(total_loss).backward()
88
- scaler.unscale_(optimizer)
89
- nn.utils.clip_grad_norm_(model.parameters(), 1.0)
90
- scaler.step(optimizer)
91
- scaler.update()
92
- optimizer.zero_grad()
93
-
94
- if step < args.warmup_steps:
95
- lr_scale = min(1.0, (step + 1) / args.warmup_steps)
96
- for pg in optimizer.param_groups:
97
- pg["lr"] = args.lr * lr_scale
98
- else:
99
- scheduler.step()
100
-
101
- if step % args.log_interval == 0:
102
- logger.info(
103
- f"Step {step}/{args.max_steps} | recon: {recon_loss.item():.4f} | "
104
- f"vq: {vq_loss.item():.4f} | total: {total_loss.item():.4f}"
105
- )
106
-
107
- if step % args.save_interval == 0 and step > 0:
108
- state = {
109
- "model": model.state_dict(),
110
- "optimizer": optimizer.state_dict(),
111
- "scheduler": scheduler.state_dict(),
112
- "step": step,
113
- "loss": total_loss.item(),
114
- }
115
- ckpt_path = checkpoint_dir / f"vqvae_step_{step}.pt"
116
- torch.save(state, ckpt_path)
117
- logger.info(f"Saved checkpoint to {ckpt_path}")
118
-
119
- if total_loss.item() < best_loss:
120
- best_loss = total_loss.item()
121
- shutil.copy(ckpt_path, checkpoint_dir / "vqvae_best.pt")
122
- logger.info(f"New best loss: {best_loss:.4f}")
123
-
124
- step += 1
125
-
126
- state = {
127
- "model": model.state_dict(),
128
- "optimizer": optimizer.state_dict(),
129
- "scheduler": scheduler.state_dict(),
130
- "step": step,
131
- "loss": total_loss.item(),
132
- }
133
- torch.save(state, checkpoint_dir / "vqvae_final.pt")
134
- logger.info(f"Training complete. Final model saved to {checkpoint_dir / 'vqvae_final.pt'}")
135
-
136
-
137
- if __name__ == "__main__":
138
- train()
bible-tts/tests/__init__.py DELETED
File without changes
bible-tts/tests/test_flow_lm.py DELETED
@@ -1,69 +0,0 @@
1
- import torch
2
- from bible_tts.models.flow_lm import (
3
- StreamingMultiheadAttention,
4
- StreamingTransformerLayer,
5
- StreamingTransformer,
6
- FlowMLP,
7
- FlowLMModel,
8
- )
9
- from bible_tts.models.lsd import lsd_decode
10
-
11
-
12
- def test_attention_shapes():
13
- attn = StreamingMultiheadAttention(d_model=256, num_heads=4)
14
- x = torch.randn(2, 10, 256)
15
- out, cache = attn(x)
16
- assert out.shape == x.shape
17
- assert cache.shape == (2, 2, 10, 4, 64)
18
-
19
-
20
- def test_attention_with_cache():
21
- attn = StreamingMultiheadAttention(d_model=256, num_heads=4)
22
- x1 = torch.randn(1, 5, 256)
23
- x2 = torch.randn(1, 3, 256)
24
- _, cache = attn(x1)
25
- out2, cache2 = attn(x2, cache)
26
- assert out2.shape == x2.shape
27
- assert cache2.shape[2] == 8
28
-
29
-
30
- def test_transformer_layer():
31
- layer = StreamingTransformerLayer(d_model=256, num_heads=4, ff_hidden=1024)
32
- x = torch.randn(2, 10, 256)
33
- out, cache = layer(x)
34
- assert out.shape == x.shape
35
-
36
-
37
- def test_transformer():
38
- tf = StreamingTransformer(d_model=256, num_layers=3, num_heads=4, ff_hidden=1024)
39
- x = torch.randn(2, 10, 256)
40
- out, caches = tf(x)
41
- assert len(caches) == 3
42
- assert out.shape == x.shape
43
-
44
-
45
- def test_flow_mlp():
46
- mlp = FlowMLP(d_model=256, latent_dim=64)
47
- c = torch.randn(2, 256)
48
- t1 = torch.randn(2, 1)
49
- t2 = torch.randn(2, 1)
50
- x = torch.randn(2, 64)
51
- out = mlp(c, t1, t2, x)
52
- assert out.shape == x.shape
53
-
54
-
55
- def test_flow_lm_forward():
56
- model = FlowLMModel()
57
- text = torch.randint(0, 88, (1, 50))
58
- codes = torch.randint(0, 512, (1, 100))
59
- last_out, is_eos = model(text, codes, lsd_steps=0)
60
- assert last_out.shape == (1, 256)
61
- assert is_eos.shape == (1, 1)
62
-
63
-
64
- def test_lsd_decode():
65
- def flow_fn(t_prev, t_curr, x_t):
66
- return -x_t
67
- x_0 = torch.randn(2, 64)
68
- result = lsd_decode(flow_fn, x_0, num_steps=4)
69
- assert result.shape == x_0.shape
bible-tts/tests/test_text_processor.py DELETED
@@ -1,26 +0,0 @@
1
- import torch
2
- from bible_tts.data.text_processor import CharTokenizer
3
-
4
-
5
- def test_tokenizer_roundtrip():
6
- tokenizer = CharTokenizer()
7
- text = "In the beginning God created the heavens and the earth."
8
- tokens = tokenizer.encode(text)
9
- decoded = tokenizer.decode(tokens)
10
- assert decoded == text, f"Roundtrip failed: {decoded} != {text}"
11
-
12
-
13
- def test_tokenizer_unknown_chars():
14
- tokenizer = CharTokenizer()
15
- text = "Hello\nWorld\t!"
16
- tokens = tokenizer.encode(text)
17
- decoded = tokenizer.decode(tokens)
18
- assert "\n" not in decoded and "\t" not in decoded
19
-
20
-
21
- def test_tokenizer_vocab_size():
22
- tokenizer = CharTokenizer()
23
- assert tokenizer.vocab_size == 3 + len(
24
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
25
- ".,!?;:-\"'()[]{}@#$%&*+=/\\<>~`| "
26
- )
bible-tts/tests/test_vq_vae.py DELETED
@@ -1,53 +0,0 @@
1
- import torch
2
- from bible_tts.models.vq_vae import VQVAE, ResBlock, Encoder, Decoder, VectorQuantizer
3
-
4
-
5
- def test_resblock_shapes():
6
- block = ResBlock(dim=64)
7
- x = torch.randn(2, 64, 100)
8
- out = block(x)
9
- assert out.shape == x.shape
10
-
11
-
12
- def test_encoder_output_shape():
13
- enc = Encoder()
14
- x = torch.randn(1, 1, 24000)
15
- z = enc(x)
16
- # stride 64 => 24000/64 = 375 frames
17
- assert z.shape[-1] == 375
18
- assert z.shape[1] == 64
19
-
20
-
21
- def test_quantizer_roundtrip():
22
- quant = VectorQuantizer(codebook_size=512, dim=64)
23
- z = torch.randn(2, 64, 100)
24
- z_q, indices, loss = quant(z)
25
- assert z_q.shape == z.shape
26
- assert indices.shape == (2, 100)
27
- assert loss.item() >= 0
28
-
29
-
30
- def test_decoder_output_shape():
31
- dec = Decoder()
32
- z = torch.randn(1, 64, 375)
33
- x = dec(z)
34
- assert x.shape[-1] == 24000
35
- assert x.shape[1] == 1
36
-
37
-
38
- def test_vqvae_forward():
39
- model = VQVAE()
40
- x = torch.randn(1, 1, 24000)
41
- x_hat, indices, vq_loss = model(x)
42
- assert x_hat.shape == x.shape
43
- assert indices.shape == (1, 375)
44
- assert vq_loss.item() >= 0
45
-
46
-
47
- def test_vqvae_encode_decode():
48
- model = VQVAE()
49
- x = torch.randn(1, 1, 24000)
50
- indices = model.encode_to_indices(x)
51
- x_recon = model.decode_from_indices(indices)
52
- assert indices.shape == (1, 375)
53
- assert x_recon.shape == x.shape