mfalt commited on
Commit
a745fed
·
0 Parent(s):

Initial release

Browse files
Files changed (7) hide show
  1. .gitattributes +35 -0
  2. README.md +92 -0
  3. config.json +0 -0
  4. manifest.json +20 -0
  5. model.safetensors +3 -0
  6. phrase_boost.py +84 -0
  7. pianissimo_mlx.py +147 -0
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - da
4
+ license: cc-by-4.0
5
+ pipeline_tag: automatic-speech-recognition
6
+ library_name: mlx
7
+ base_model: KlangAI/pianissimo-da
8
+ base_model_relation: quantized
9
+ tags:
10
+ - mlx
11
+ - speech
12
+ - audio
13
+ - fastconformer
14
+ - tdt
15
+ - danish
16
+ ---
17
+
18
+ # Klang Pianissimo for Danish: MLX 8-bit
19
+
20
+ A version of [Klang Pianissimo for Danish](https://huggingface.co/KlangAI/pianissimo-da), our Danish speech recognition model, for Macs with Apple silicon (M1 or later). It runs on the Mac's GPU using [MLX](https://github.com/ml-explore/mlx) and [parakeet-mlx](https://github.com/senstella/parakeet-mlx), without PyTorch or NeMo.
21
+
22
+ This repository contains the 8-bit version, with almost the same accuracy as the full-size model at less than a third of the download size.
23
+
24
+ ## Versions
25
+
26
+ | Repository | Weights | Download | FLEURS test | Speed | Memory, 5 min |
27
+ |---|---|---:|---:|---:|---:|
28
+ | [pianissimo-da](https://huggingface.co/KlangAI/pianissimo-da) | Original fp32 | 2.51 GB | **7.43** | | |
29
+ | [pianissimo-da-mlx-8bit](https://huggingface.co/KlangAI/pianissimo-da-mlx-8bit) | 8-bit | 759 MB | 7.49 | 151× | **2.2 GB** |
30
+ | [pianissimo-da-mlx-4bit](https://huggingface.co/KlangAI/pianissimo-da-mlx-4bit) | 4-bit | **528 MB** | 7.80 | 126× | **2.2 GB** |
31
+ | [pianissimo-da-mlx](https://huggingface.co/KlangAI/pianissimo-da-mlx) | bf16 | 1.25 GB | 7.48 | 140× | 2.7 GB |
32
+ | [pianissimo-da-mlx-fp32](https://huggingface.co/KlangAI/pianissimo-da-mlx-fp32) | fp32 | 2.51 GB | **7.43** | **173×** | 3.8 GB |
33
+
34
+ The FLEURS column shows word error rate (WER, %) on all 930 Danish test clips, scored with our evaluation normaliser; lower is better. Speed is audio duration divided by processing time (RTFx), measured on a 30-second clip.
35
+ Speed and memory were measured on the Swedish model, which has the same architecture, on an Apple M5 Pro GPU (24 GB). Memory is peak GPU memory use while transcribing a 5-minute recording.
36
+
37
+ For other computers, use the [ONNX versions](https://huggingface.co/KlangAI/pianissimo-da-onnx).
38
+
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
+
46
+ ```python
47
+ import sys
48
+ from huggingface_hub import snapshot_download
49
+
50
+ path = snapshot_download("KlangAI/pianissimo-da-mlx-8bit")
51
+ sys.path.append(path)
52
+ import pianissimo_mlx
53
+
54
+ model = pianissimo_mlx.load(path)
55
+ result = pianissimo_mlx.transcribe(model, "audio.wav")
56
+ print(result.text)
57
+ ```
58
+
59
+ Recordings longer than 2 minutes are transcribed in 2-minute chunks with a 15-second overlap. Set `chunk_duration` to change the chunk length in seconds, or to `None` for a single pass. `transcribe` also accepts a 16 kHz mono NumPy array. Timestamps are available in `result.sentences` and `sentence.tokens`.
60
+
61
+ Use the `pianissimo_mlx.py` loader included in this repository. It supports the 8-bit and 4-bit weights and reproduces the audio preprocessing used during training. Calling parakeet-mlx directly does not support these quantized weights and uses slightly different preprocessing, adding about 0.2 percentage points of WER.
62
+
63
+ ### Vocabulary
64
+
65
+ Our implementation of NeMo's phrase boosting supports custom vocabulary and matches NeMo's results on the original model. List names and terms to help the model spell them correctly.
66
+
67
+ ```python
68
+ vocabulary = ["Klang AI", "Pianissimo", "Folketinget", "Aarhus Universitet"]
69
+ result = pianissimo_mlx.transcribe(model, "audio.wav", boost=vocabulary)
70
+ ```
71
+
72
+ See the [main model card](https://huggingface.co/KlangAI/pianissimo-da#vocabulary) for vocabulary boosting with NeMo.
73
+
74
+ ## How they were made
75
+
76
+ All versions are converted from the released checkpoint and retain its local attention (256 frames on each side). The 8-bit and 4-bit versions use MLX's built-in quantization for the encoder's linear layers, with groups of 64 weights for 8-bit and 32 for 4-bit. The remaining weights in those two versions are stored in bfloat16.
77
+
78
+ FLEURS WER was measured with this code on Linux. In a separate Mac check, this version matched the normalised original fp32 reference on 187 of 200 FLEURS clips. See the [main model card](https://huggingface.co/KlangAI/pianissimo-da) for comparisons with other models.
79
+
80
+ ## License and citation
81
+
82
+ Released under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), like the original model.
83
+
84
+ ```bibtex
85
+ @misc{klang2026pianissimoda,
86
+ title = {Klang Pianissimo for Danish},
87
+ author = {{Klang}},
88
+ year = {2026},
89
+ howpublished = {Hugging Face model repository},
90
+ url = {https://huggingface.co/KlangAI/pianissimo-da}
91
+ }
92
+ ```
config.json ADDED
The diff for this file is too large to render. See raw diff
 
manifest.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "artifact": "pianissimo-da-mlx-8bit",
3
+ "checkpoint_sha256": "ef346b91fc125926ed8a5a69091b591bf59d0bc32e2d5d197f112a2a0b12d41b",
4
+ "mlx": "0.32.2",
5
+ "quantization": {
6
+ "group_size": 64,
7
+ "bits": 8,
8
+ "modules": "nn.Linear in encoder.layers"
9
+ },
10
+ "files": {
11
+ "config.json": {
12
+ "bytes": 200943,
13
+ "sha256": "93895461c005ccae88cffbec31c7ad05b9f44635cf3958670123c53a370013c8"
14
+ },
15
+ "model.safetensors": {
16
+ "bytes": 758783094,
17
+ "sha256": "61b8fec4b0a9d134812eb59f4482ffcca9ec03877b97df65189236513072b4f1"
18
+ }
19
+ }
20
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:61b8fec4b0a9d134812eb59f4482ffcca9ec03877b97df65189236513072b4f1
3
+ size 758783094
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 ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-da-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
9
+ import shutil
10
+ import subprocess
11
+
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
+
25
+ def load(repo_or_path):
26
+ """A Hugging Face repo id or a local directory with config.json and model.safetensors."""
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")
34
+ if q:
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
+
42
+ def read_audio(path):
43
+ """Any format ffmpeg reads, as 16 kHz mono float32."""
44
+ if shutil.which("ffmpeg") is None:
45
+ raise RuntimeError("reading audio files needs ffmpeg (brew install ffmpeg), or pass a 16 kHz numpy array")
46
+ out = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(path), "-f", "s16le", "-ac", "1",
47
+ "-ar", str(SAMPLE_RATE), "-"], capture_output=True, check=True).stdout
48
+ return np.frombuffer(out, np.int16).astype(np.float32) / 32768.0
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]])
56
+ x = mx.pad(x, [(n_fft // 2, n_fft // 2)])
57
+ frames = mx.as_strided(x, shape=((x.shape[0] - n_fft) // hop + 1, n_fft), strides=(hop, 1))
58
+ window = mx.array(np.pad(np.hanning(win), (n_fft - win) // 2).astype(np.float32))
59
+ power = mx.abs(mx.fft.rfft(frames * window)) ** 2
60
+ logmel = mx.log(mx.matmul(power, args._filterbanks.T.astype(mx.float32)) + 2 ** -24)
61
+ valid = audio.shape[0] // hop
62
+ logmel = logmel[:valid]
63
+ mean = logmel.mean(axis=0, keepdims=True)
64
+ std = mx.sqrt(((logmel - mean) ** 2).sum(axis=0, keepdims=True) / (valid - 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:
86
+ token.start += offset
87
+ token.end = token.start + token.duration
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:
94
+ tokens = merge_longest_common_subsequence(tokens, result.tokens, overlap_duration=overlap_duration)
95
+ mx.clear_cache()
96
+ if start + size >= len(audio):
97
+ break
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