Instructions to use KlangAI/pianissimo-sv-mlx-4bit with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use KlangAI/pianissimo-sv-mlx-4bit with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] hf download KlangAI/pianissimo-sv-mlx-4bit --local-dir pianissimo-sv-mlx-4bit
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
Add custom vocabulary (phrase boosting)
Browse filestranscribe(..., boost=[...]) adds phrase boosting: our implementation of
NeMo's phrase boosting (phrase_boost.py), using the model's tokenizer.
Claude-Session: https://claude.ai/code/session_01JeoiSkEdCzw16SqBenVs8w
- README.md +11 -1
- phrase_boost.py +84 -0
- pianissimo_mlx.py +63 -23
- tokenizer.model +3 -0
README.md
CHANGED
|
@@ -39,7 +39,7 @@ For other computers, use the [ONNX versions](https://huggingface.co/KlangAI/pian
|
|
| 39 |
## Usage
|
| 40 |
|
| 41 |
```bash
|
| 42 |
-
pip install parakeet-mlx==0.5.2 huggingface_hub
|
| 43 |
brew install ffmpeg # to read audio files
|
| 44 |
```
|
| 45 |
|
|
@@ -60,6 +60,16 @@ Audio longer than 2 minutes is transcribed in 2-minute chunks with 15 s overlap.
|
|
| 60 |
|
| 61 |
Use `pianissimo_mlx.py` from this repository rather than parakeet-mlx's own `transcribe`. It can load the 8-bit and 4-bit versions, which parakeet-mlx cannot do on its own, and it prepares the audio exactly as it was prepared during training. parakeet-mlx prepares it slightly differently, which adds about 0.2 percentage points of WER.
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
## How they were made
|
| 64 |
|
| 65 |
All versions are converted from the released checkpoint and keep its local attention (256 frames on each side). In the 8-bit and 4-bit versions, the encoder's linear layers are quantized with MLX's built-in quantization, in groups of 64 weights for 8-bit and 32 for 4-bit. Everything else is stored in bfloat16.
|
|
|
|
| 39 |
## Usage
|
| 40 |
|
| 41 |
```bash
|
| 42 |
+
pip install parakeet-mlx==0.5.2 huggingface_hub sentencepiece
|
| 43 |
brew install ffmpeg # to read audio files
|
| 44 |
```
|
| 45 |
|
|
|
|
| 60 |
|
| 61 |
Use `pianissimo_mlx.py` from this repository rather than parakeet-mlx's own `transcribe`. It can load the 8-bit and 4-bit versions, which parakeet-mlx cannot do on its own, and it prepares the audio exactly as it was prepared during training. parakeet-mlx prepares it slightly differently, which adds about 0.2 percentage points of WER.
|
| 62 |
|
| 63 |
+
### Vocabulary
|
| 64 |
+
|
| 65 |
+
Supports custom vocabulary using our custom implementation of NeMo's phrase boosting. The implementation matches NeMo's results on the original model. Listing names and terms increases the chance that the model spells them correctly.
|
| 66 |
+
|
| 67 |
+
```python
|
| 68 |
+
result = pianissimo_mlx.transcribe(model, "audio.wav", boost=["Klang AI", "Pianissimo", "RixVox", "Common Voice"])
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Test results are on the [main model card](https://huggingface.co/KlangAI/pianissimo-sv#vocabulary).
|
| 72 |
+
|
| 73 |
## How they were made
|
| 74 |
|
| 75 |
All versions are converted from the released checkpoint and keep its local attention (256 frames on each side). In the 8-bit and 4-bit versions, the encoder's linear layers are quantized with MLX's built-in quantization, in groups of 64 weights for 8-bit and 32 for 4-bit. Everything else is stored in bfloat16.
|
phrase_boost.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phrase boosting for Klang Pianissimo. A numpy port of NeMo's GPU-PB boosting tree
|
| 2 |
+
(ContextGraph and GPUBoostingTreeModel, NeMo 2.7, Apache 2.0)."""
|
| 3 |
+
import math
|
| 4 |
+
from collections import deque
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import sentencepiece as spm
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class _Node:
|
| 11 |
+
__slots__ = ("id", "children", "token_score", "node_score", "is_end", "fail")
|
| 12 |
+
|
| 13 |
+
def __init__(self, node_id, token_score, node_score, is_end):
|
| 14 |
+
self.id = node_id
|
| 15 |
+
self.children = {}
|
| 16 |
+
self.token_score = token_score
|
| 17 |
+
self.node_score = node_score
|
| 18 |
+
self.is_end = is_end
|
| 19 |
+
self.fail = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PhraseBoost:
|
| 23 |
+
def __init__(self, phrases, tokenizer_path, vocab_size, context_score=1.0, depth_scaling=2.0):
|
| 24 |
+
sp = spm.SentencePieceProcessor(model_file=str(tokenizer_path))
|
| 25 |
+
self.vocab_size = vocab_size
|
| 26 |
+
self.root = _Node(0, 0.0, 0.0, False)
|
| 27 |
+
self.root.fail = self.root
|
| 28 |
+
self.nodes = [self.root]
|
| 29 |
+
for phrase in dict.fromkeys(phrases):
|
| 30 |
+
self._add(sp.encode(phrase), context_score, depth_scaling)
|
| 31 |
+
self._fill_fail()
|
| 32 |
+
self._cache = {}
|
| 33 |
+
|
| 34 |
+
def _add(self, tokens, context_score, depth_scaling):
|
| 35 |
+
node = self.root
|
| 36 |
+
for i, token in enumerate(tokens):
|
| 37 |
+
last = i == len(tokens) - 1
|
| 38 |
+
child = node.children.get(token)
|
| 39 |
+
if child is None:
|
| 40 |
+
token_score = context_score * depth_scaling + math.log(i + 1) if i > 0 else context_score
|
| 41 |
+
child = _Node(len(self.nodes), token_score, node.node_score + token_score, last)
|
| 42 |
+
node.children[token] = child
|
| 43 |
+
self.nodes.append(child)
|
| 44 |
+
else:
|
| 45 |
+
child.token_score = max(context_score, child.token_score)
|
| 46 |
+
child.node_score = node.node_score + child.token_score
|
| 47 |
+
child.is_end = last or child.is_end
|
| 48 |
+
node = child
|
| 49 |
+
|
| 50 |
+
def _fill_fail(self):
|
| 51 |
+
queue = deque()
|
| 52 |
+
for child in self.root.children.values():
|
| 53 |
+
child.fail = self.root
|
| 54 |
+
queue.append(child)
|
| 55 |
+
while queue:
|
| 56 |
+
node = queue.popleft()
|
| 57 |
+
for token, child in node.children.items():
|
| 58 |
+
fail = node.fail
|
| 59 |
+
while token not in fail.children and fail is not self.root:
|
| 60 |
+
fail = fail.fail
|
| 61 |
+
child.fail = fail.children.get(token, self.root)
|
| 62 |
+
queue.append(child)
|
| 63 |
+
|
| 64 |
+
def scores(self, state):
|
| 65 |
+
"""Bonus and next state for every token from `state`."""
|
| 66 |
+
if state not in self._cache:
|
| 67 |
+
bonus = np.zeros(self.vocab_size, dtype=np.float32)
|
| 68 |
+
nxt = np.full(self.vocab_size, -1, dtype=np.int64)
|
| 69 |
+
node, taken_back = self.nodes[state], 0.0
|
| 70 |
+
while True:
|
| 71 |
+
for token, child in node.children.items():
|
| 72 |
+
if nxt[token] < 0:
|
| 73 |
+
bonus[token] = taken_back + child.token_score
|
| 74 |
+
nxt[token] = child.id
|
| 75 |
+
if node is self.root:
|
| 76 |
+
unset = nxt < 0
|
| 77 |
+
bonus[unset] = taken_back
|
| 78 |
+
nxt[unset] = 0
|
| 79 |
+
break
|
| 80 |
+
if not node.is_end:
|
| 81 |
+
taken_back += node.fail.node_score - node.node_score
|
| 82 |
+
node = node.fail
|
| 83 |
+
self._cache[state] = (bonus, nxt)
|
| 84 |
+
return self._cache[state]
|
pianissimo_mlx.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
| 1 |
-
"""
|
|
|
|
| 2 |
|
| 3 |
-
The model itself runs in parakeet-mlx. This file loads the 8-bit and 4-bit versions, prepares the
|
| 4 |
-
audio exactly as it was prepared during training (parakeet-mlx does it slightly differently, which
|
| 5 |
-
adds about 0.2 percentage points of WER), and splits long recordings into overlapping pieces.
|
| 6 |
-
|
| 7 |
-
import pianissimo_mlx
|
| 8 |
model = pianissimo_mlx.load("KlangAI/pianissimo-sv-mlx-8bit")
|
| 9 |
-
|
| 10 |
-
print(result.text)
|
| 11 |
"""
|
| 12 |
import json
|
| 13 |
import os
|
|
@@ -17,10 +12,13 @@ import subprocess
|
|
| 17 |
import mlx.core as mx
|
| 18 |
import mlx.nn as nn
|
| 19 |
import numpy as np
|
| 20 |
-
from parakeet_mlx
|
|
|
|
| 21 |
sentences_to_result, tokens_to_sentences)
|
| 22 |
from parakeet_mlx.utils import from_config
|
| 23 |
|
|
|
|
|
|
|
| 24 |
SAMPLE_RATE = 16000
|
| 25 |
|
| 26 |
|
|
@@ -29,7 +27,7 @@ def load(repo_or_path):
|
|
| 29 |
path = repo_or_path
|
| 30 |
if not os.path.isdir(path):
|
| 31 |
from huggingface_hub import snapshot_download
|
| 32 |
-
path = snapshot_download(repo_or_path, allow_patterns=["config.json", "model.safetensors"])
|
| 33 |
cfg = json.load(open(os.path.join(path, "config.json")))
|
| 34 |
model = from_config(cfg)
|
| 35 |
q = cfg.get("quantization")
|
|
@@ -37,6 +35,7 @@ def load(repo_or_path):
|
|
| 37 |
nn.quantize(model, group_size=q["group_size"], bits=q["bits"],
|
| 38 |
class_predicate=lambda p, m: isinstance(m, nn.Linear) and p.startswith("encoder.layers."))
|
| 39 |
model.load_weights(os.path.join(path, "model.safetensors"))
|
|
|
|
| 40 |
return model
|
| 41 |
|
| 42 |
|
|
@@ -50,7 +49,7 @@ def read_audio(path):
|
|
| 50 |
|
| 51 |
|
| 52 |
def features(audio, args):
|
| 53 |
-
"""
|
| 54 |
n_fft, win = args.n_fft, int(args.window_size * args.sample_rate)
|
| 55 |
hop = int(args.window_stride * args.sample_rate)
|
| 56 |
x = mx.concatenate([audio[:1], audio[1:] - 0.97 * audio[:-1]])
|
|
@@ -66,24 +65,21 @@ def features(audio, args):
|
|
| 66 |
return mx.expand_dims((logmel - mean) / (std + 1e-5), 0)
|
| 67 |
|
| 68 |
|
| 69 |
-
def transcribe(model, audio, chunk_duration=120.0, overlap_duration=15.0):
|
| 70 |
-
"""audio is a
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
Audio longer than chunk_duration seconds is transcribed in overlapping pieces and joined where
|
| 74 |
-
they overlap, which keeps memory use flat however long the recording is. Pass
|
| 75 |
-
chunk_duration=None to transcribe the whole recording in one pass."""
|
| 76 |
if isinstance(audio, (str, os.PathLike)):
|
| 77 |
audio = read_audio(audio)
|
| 78 |
audio = np.asarray(audio, dtype=np.float32)
|
|
|
|
| 79 |
if chunk_duration is None or len(audio) <= chunk_duration * SAMPLE_RATE:
|
| 80 |
-
return _transcribe_piece(model, audio)
|
| 81 |
|
| 82 |
step = int((chunk_duration - overlap_duration) * SAMPLE_RATE)
|
| 83 |
size = int(chunk_duration * SAMPLE_RATE)
|
| 84 |
tokens = []
|
| 85 |
for start in range(0, len(audio), step):
|
| 86 |
-
result = _transcribe_piece(model, audio[start:start + size])
|
| 87 |
offset = start / SAMPLE_RATE
|
| 88 |
for sentence in result.sentences:
|
| 89 |
for token in sentence.tokens:
|
|
@@ -92,7 +88,6 @@ def transcribe(model, audio, chunk_duration=120.0, overlap_duration=15.0):
|
|
| 92 |
if not tokens:
|
| 93 |
tokens = result.tokens
|
| 94 |
else:
|
| 95 |
-
# the same joining parakeet-mlx uses for its own chunked transcription
|
| 96 |
try:
|
| 97 |
tokens = merge_longest_contiguous(tokens, result.tokens, overlap_duration=overlap_duration)
|
| 98 |
except RuntimeError:
|
|
@@ -103,5 +98,50 @@ def transcribe(model, audio, chunk_duration=120.0, overlap_duration=15.0):
|
|
| 103 |
return sentences_to_result(tokens_to_sentences(tokens))
|
| 104 |
|
| 105 |
|
| 106 |
-
def _transcribe_piece(model, audio):
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Klang Pianissimo on Apple silicon with parakeet-mlx: loads the quantized versions, computes NeMo's
|
| 2 |
+
audio features, splits long audio into chunks and supports phrase boosting.
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
model = pianissimo_mlx.load("KlangAI/pianissimo-sv-mlx-8bit")
|
| 5 |
+
print(pianissimo_mlx.transcribe(model, "audio.wav", boost=["Klang AI", "Pianissimo", "RixVox", "Common Voice"]).text)
|
|
|
|
| 6 |
"""
|
| 7 |
import json
|
| 8 |
import os
|
|
|
|
| 12 |
import mlx.core as mx
|
| 13 |
import mlx.nn as nn
|
| 14 |
import numpy as np
|
| 15 |
+
from parakeet_mlx import tokenizer
|
| 16 |
+
from parakeet_mlx.alignment import (AlignedToken, merge_longest_common_subsequence, merge_longest_contiguous,
|
| 17 |
sentences_to_result, tokens_to_sentences)
|
| 18 |
from parakeet_mlx.utils import from_config
|
| 19 |
|
| 20 |
+
from phrase_boost import PhraseBoost
|
| 21 |
+
|
| 22 |
SAMPLE_RATE = 16000
|
| 23 |
|
| 24 |
|
|
|
|
| 27 |
path = repo_or_path
|
| 28 |
if not os.path.isdir(path):
|
| 29 |
from huggingface_hub import snapshot_download
|
| 30 |
+
path = snapshot_download(repo_or_path, allow_patterns=["config.json", "model.safetensors", "tokenizer.model"])
|
| 31 |
cfg = json.load(open(os.path.join(path, "config.json")))
|
| 32 |
model = from_config(cfg)
|
| 33 |
q = cfg.get("quantization")
|
|
|
|
| 35 |
nn.quantize(model, group_size=q["group_size"], bits=q["bits"],
|
| 36 |
class_predicate=lambda p, m: isinstance(m, nn.Linear) and p.startswith("encoder.layers."))
|
| 37 |
model.load_weights(os.path.join(path, "model.safetensors"))
|
| 38 |
+
model.tokenizer_path = os.path.join(path, "tokenizer.model")
|
| 39 |
return model
|
| 40 |
|
| 41 |
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def features(audio, args):
|
| 52 |
+
"""NeMo's log-mel featurizer, ported to MLX."""
|
| 53 |
n_fft, win = args.n_fft, int(args.window_size * args.sample_rate)
|
| 54 |
hop = int(args.window_stride * args.sample_rate)
|
| 55 |
x = mx.concatenate([audio[:1], audio[1:] - 0.97 * audio[:-1]])
|
|
|
|
| 65 |
return mx.expand_dims((logmel - mean) / (std + 1e-5), 0)
|
| 66 |
|
| 67 |
|
| 68 |
+
def transcribe(model, audio, chunk_duration=120.0, overlap_duration=15.0, boost=None, boost_alpha=1.0):
|
| 69 |
+
"""audio is a path or a 16 kHz mono numpy array. Audio longer than chunk_duration seconds is
|
| 70 |
+
transcribed in overlapping chunks (None for one pass). boost lists phrases to spell as written."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
if isinstance(audio, (str, os.PathLike)):
|
| 72 |
audio = read_audio(audio)
|
| 73 |
audio = np.asarray(audio, dtype=np.float32)
|
| 74 |
+
tree = PhraseBoost(boost, model.tokenizer_path, vocab_size=len(model.vocabulary)) if boost else None
|
| 75 |
if chunk_duration is None or len(audio) <= chunk_duration * SAMPLE_RATE:
|
| 76 |
+
return _transcribe_piece(model, audio, tree, boost_alpha)
|
| 77 |
|
| 78 |
step = int((chunk_duration - overlap_duration) * SAMPLE_RATE)
|
| 79 |
size = int(chunk_duration * SAMPLE_RATE)
|
| 80 |
tokens = []
|
| 81 |
for start in range(0, len(audio), step):
|
| 82 |
+
result = _transcribe_piece(model, audio[start:start + size], tree, boost_alpha)
|
| 83 |
offset = start / SAMPLE_RATE
|
| 84 |
for sentence in result.sentences:
|
| 85 |
for token in sentence.tokens:
|
|
|
|
| 88 |
if not tokens:
|
| 89 |
tokens = result.tokens
|
| 90 |
else:
|
|
|
|
| 91 |
try:
|
| 92 |
tokens = merge_longest_contiguous(tokens, result.tokens, overlap_duration=overlap_duration)
|
| 93 |
except RuntimeError:
|
|
|
|
| 98 |
return sentences_to_result(tokens_to_sentences(tokens))
|
| 99 |
|
| 100 |
|
| 101 |
+
def _transcribe_piece(model, audio, tree, alpha):
|
| 102 |
+
mel = features(mx.array(audio), model.preprocessor_config)
|
| 103 |
+
if tree is None:
|
| 104 |
+
return model.generate(mel)[0]
|
| 105 |
+
encoded, lengths = model.encoder(mel)
|
| 106 |
+
mx.eval(encoded, lengths)
|
| 107 |
+
return sentences_to_result(tokens_to_sentences(_boosted_greedy(model, encoded, int(lengths[0]), tree, alpha)))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _boosted_greedy(model, encoded, length, tree, alpha):
|
| 111 |
+
"""parakeet-mlx's greedy TDT decoding with the phrase bonus added."""
|
| 112 |
+
blank = len(model.vocabulary)
|
| 113 |
+
hypothesis, last_token, hidden_state = [], None, None
|
| 114 |
+
boost_state, step, new_symbols = 0, 0, 0
|
| 115 |
+
while step < length:
|
| 116 |
+
decoder_out, (hidden, cell) = model.decoder(
|
| 117 |
+
mx.array([[last_token]]) if last_token is not None else None, hidden_state)
|
| 118 |
+
decoder_out = decoder_out.astype(encoded.dtype)
|
| 119 |
+
decoder_hidden = (hidden.astype(encoded.dtype), cell.astype(encoded.dtype))
|
| 120 |
+
joint_out = model.joint(encoded[:, step:step + 1], decoder_out)
|
| 121 |
+
|
| 122 |
+
token_logits = joint_out[0, 0, :, :blank + 1]
|
| 123 |
+
pred_token = int(mx.argmax(token_logits))
|
| 124 |
+
token_probs = mx.softmax(token_logits, axis=-1)
|
| 125 |
+
entropy = -mx.sum(token_probs * mx.log(token_probs + 1e-10), axis=-1)
|
| 126 |
+
confidence = float(1.0 - entropy / mx.log(mx.array(blank + 1, dtype=token_probs.dtype)))
|
| 127 |
+
decision = int(mx.argmax(joint_out[0, 0, :, blank + 1:]))
|
| 128 |
+
|
| 129 |
+
if pred_token != blank:
|
| 130 |
+
bonus, next_states = tree.scores(boost_state)
|
| 131 |
+
logits = np.array(token_logits[0, :blank].astype(mx.float32))
|
| 132 |
+
pred_token = int(np.argmax(logits + alpha * bonus))
|
| 133 |
+
boost_state = int(next_states[pred_token])
|
| 134 |
+
hypothesis.append(AlignedToken(
|
| 135 |
+
pred_token, start=step * model.time_ratio, duration=model.durations[decision] * model.time_ratio,
|
| 136 |
+
confidence=confidence, text=tokenizer.decode([pred_token], model.vocabulary)))
|
| 137 |
+
last_token = pred_token
|
| 138 |
+
hidden_state = decoder_hidden
|
| 139 |
+
|
| 140 |
+
step += model.durations[decision]
|
| 141 |
+
new_symbols += 1
|
| 142 |
+
if model.durations[decision] != 0:
|
| 143 |
+
new_symbols = 0
|
| 144 |
+
elif model.max_symbols is not None and model.max_symbols <= new_symbols:
|
| 145 |
+
step += 1
|
| 146 |
+
new_symbols = 0
|
| 147 |
+
return hypothesis
|
tokenizer.model
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eacec2b0a77f336d4a2ca4a25a7047575d3c2b74de47e997f4c205126ed3135e
|
| 3 |
+
size 360916
|