mfalt commited on
Commit
7e7fb36
·
1 Parent(s): 89626ae

Add custom vocabulary (phrase boosting)

Browse files

transcribe(..., 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

Files changed (4) hide show
  1. README.md +11 -1
  2. phrase_boost.py +84 -0
  3. pianissimo_mlx.py +63 -23
  4. 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
- """Load Klang Pianissimo for MLX and transcribe Swedish speech on a Mac with Apple silicon.
 
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
- result = pianissimo_mlx.transcribe(model, "audio.wav")
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.alignment import (merge_longest_common_subsequence, merge_longest_contiguous,
 
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
- """The 128-band log spectrogram the model was trained on, computed the same way as in NeMo."""
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 file path or a 16 kHz mono numpy array. The result has .text, and .sentences
71
- with start and end times per sentence and per token.
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
- return model.generate(features(mx.array(audio), model.preprocessor_config))[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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