# --- standalone setup ----------------------------------------------------------
# Lifted from the Setup section and the helper cells above so this demo runs on its
# own in a fresh kernel - no earlier cell has to have been executed first.
import ctypes
import ctypes.util
import gc
import io
import time
import numpy as np
import torch
from dotenv import find_dotenv, load_dotenv
from pathlib import Path
# Knowledge/.env sets HF_TOKEN - authenticated HF Hub requests get higher rate limits
load_dotenv(find_dotenv(usecwd=True))
device = "cuda:0" if torch.cuda.is_available() else "cpu"
def vram(tag=""):
"Report current GPU memory (allocated / reserved). No-op on CPU."
if torch.cuda.is_available():
alloc = torch.cuda.memory_allocated() / 1e9
reserved = torch.cuda.memory_reserved() / 1e9
print(f"VRAM {tag:16s} {alloc:5.2f} GB allocated / {reserved:5.2f} GB reserved")
def free_memory():
"Run garbage collection and release cached CPU/GPU memory.\n\n Call right after `del`-ing a model/pipeline you are done with, e.g.\n `del asr; free_memory()`. `del` drops the Python reference; this then\n reclaims the RAM and hands the freed VRAM back to the CUDA allocator.\n "
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
# glibc keeps freed CPU allocations in its arenas instead of returning them
# to the OS, so RSS compounds across model sections (cpu-offloaded weights
# live in system RAM). malloc_trim(0) hands the freed arenas back. See
# dl-visualization-and-memory.instructions.md - not optional on a 12 GB box.
try:
ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6").malloc_trim(0)
except Exception:
pass
# All dataset downloads go to DL_tasks/datasets/ (gitignored)
DATA_DIR = Path("../../datasets")
DATA_DIR.mkdir(exist_ok=True)
# --- the demo ------------------------------------------------------------------
# sounddevice needs the PortAudio runtime (libportaudio2) and ALSA nodes under
# /dev/snd. On the knowledge-lab LXC those are passed in from the Proxmox host by
# `av_devices` in infra/proxmox/variables.tf - the camera's video node and its
# USB-Audio card are separate passthroughs.
import librosa
import sounddevice as sd
from IPython.display import Audio as AudioPlayer # `Audio` may already be datasets.Audio
from IPython.display import HTML, display
from rich import box
from rich.console import Console, Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
# Whisper's generate() emits five deprecation/config warnings PER CALL, which at one
# decode a second buries the transcript completely. Errors still get through.
from transformers.utils import logging as hf_logging
hf_logging.set_verbosity_error()
# Substring of the *PortAudio* device name, which is not the ALSA card id: this mic
# is card id "U2K" but shows up as "UGREEN camera 2K: USB Audio (hw:0,0)". Run
# `python -c "import sounddevice; print(sounddevice.query_devices())"` to list them.
MIC_HINT = "UGREEN" # None -> just take the first input device
def pick_microphone(hint=MIC_HINT):
"Return (device_index, native_sample_rate) for a capture device, preferring `hint`."
inputs = [(i, d) for i, d in enumerate(sd.query_devices()) if d["max_input_channels"] > 0]
if not inputs:
raise RuntimeError(
"no audio capture device: PortAudio sees no ALSA card. Check that /dev/snd "
"exists and holds controlC*/pcmC*D*c nodes - on the LXC that means adding "
"them to av_devices and running `just tf-apply`."
)
match = [(i, d) for i, d in inputs if hint and hint.lower() in d["name"].lower()]
idx, info = (match or inputs)[0]
return idx, int(info["default_samplerate"])
SR = 16000 # every model in this notebook expects 16 kHz mono
LISTEN_SECONDS = 30 # how long the demo listens; interrupt the kernel to stop early
CHUNK_SECONDS = 3.0 # how much trailing audio each decode sees (the context window)
HOP_SECONDS = 0.5 # how often that window is re-decoded
SILENCE_DBFS = -45.0 # windows quieter than this are not sent to the model at all
SHOW_ROWS = 12 # most recent rows kept on screen; the full log still feeds the chart
# A 3 s window advanced in 0.5 s hops means every moment of audio is decoded about
# six times, each time with more right-hand context. That is the point: a word at
# the edge of one window sits comfortably inside the next, which is where most of
# the accuracy of chunked ASR comes from. The cost is arithmetic - 6x the decodes.
# Measured on continuous real speech (whisper-small, fp16), decode vs window length:
# 2 s -> 144 ms 10 s -> 491 ms 20 s -> 650 ms
# 5 s -> 279 ms 15 s -> 511 ms
# A 3 s window is roughly 200 ms, so a 0.5 s hop runs at a real-time factor near
# 0.4: comfortably live, but four times the GPU of disjoint blocks. Widen HOP if
# the summary reports overflows.
#
# Overlap has to be undone before the transcript is readable - see _stitch.
# Pinning the language matters more than anything else here. Left to auto-detect,
# Whisper answers near-silence with a long hallucination in whatever script it
# guessed - measured on this box: 4759 ms and a screenful of Georgian. With the
# language pinned the same model returns " you" in 64 ms.
#
# The corollary is counter-intuitive: decode cost tracks generation LENGTH, not
# parameter count. Measured on one 10 s window, fp16, same audio:
# whisper-small 64 ms whisper-tiny 1410 ms
# (fp32: 150 ms) moonshine-tiny 790 ms
# The tiny models are slower because they hallucinate long repetitive strings on
# noise. So the accuracy pick is also the fast pick - do not "optimise" this by
# reaching for a smaller checkpoint.
GEN_KWARGS = {"language": "en", "task": "transcribe", "max_new_tokens": 96}
_console = Console(record=True, file=io.StringIO(), width=96,
force_terminal=True, force_jupyter=False)
_FRAGMENT = '<pre style="font-family:ui-monospace,monospace;line-height:1.3;margin:0">{code}</pre>'
def _html(renderable):
"Render a rich object to an HTML fragment suitable for display handle updates."
_console.print(renderable)
out = _console.export_html(inline_styles=True, code_format=_FRAGMENT)
_console.file = io.StringIO() # reset between frames, else the HTML grows forever
return HTML(out)
def _note(text, title=None, style="dim"):
"A one-off rich line, so nothing in this cell falls back to a bare print."
display(_html(Panel(Text.from_markup(text), title=title, border_style=style,
padding=(0, 1))))
def _grid(**rows):
"Two-column rich grid - the shape used for every stats block in this cell."
g = Table.grid(padding=(0, 2))
g.add_column(style="dim", justify="right")
g.add_column()
for k, v in rows.items():
g.add_row(k.replace("_", " "), str(v))
return g
def rvram(tag=""):
"vram(), rendered through rich instead of print. No-op on CPU."
if not torch.cuda.is_available():
return
display(_html(Panel(
_grid(**{"allocated": f"{torch.cuda.memory_allocated() / 1e9:.2f} GB",
"reserved": f"{torch.cuda.memory_reserved() / 1e9:.2f} GB"}),
title=f"[dim]VRAM {tag}[/]", border_style="dim", padding=(0, 1))))
# Section 7 leaves `asr` live, but this cell frees it on its last line. Reload it
# when it is gone so the cell can be re-run on its own (weights are cached on disk).
if "asr" not in globals():
from transformers import pipeline
asr = pipeline("automatic-speech-recognition", model="openai/whisper-small",
device=device,
dtype=torch.float16 if device != "cpu" else torch.float32)
rvram("whisper-small reloaded")
# rich (a project dependency) draws the panel; the in-place update is still an
# IPython display handle. rich's own Live goes through ipywidgets in Jupyter and
# appends a fresh view per refresh instead of replacing one - measured: 10 outputs
# for 5 updates - so render with rich, update with the handle.
# force_jupyter=False is load-bearing: left on, Console.print() calls display()
# itself and every frame leaks an extra output cell.
def _dbfs(x):
"RMS level of a float32 block, in dBFS. -inf for digital silence."
r = float(np.sqrt((x ** 2).mean())) if x.size else 0.0
return 20 * np.log10(r) if r > 0 else float("-inf")
def _stitch(have, new, max_overlap=60, min_anchor=3):
"""Merge an overlapping hypothesis into the transcript by aligning on shared words.
Consecutive windows overlap, but they are NOT identical. The model re-punctuates,
swaps a function word, and drops or adds a leading filler as context changes:
have : Honestly, they've been on THE slow and steady thing for so long.
new : They've been on THIS slow and steady thing for so long that
it's felt like
An exact suffix/prefix test fails here - "the" != "this" breaks the run - and the
whole sentence gets appended a second time. So instead find the longest run of
words the two share, treat that as the anchor, and keep only what follows it:
anchor: slow and steady thing for so long
added : that it's felt like
Comparison is on a normalised copy (lowercase, alphanumeric) so punctuation and
capitalisation differences do not break the anchor. `min_anchor` words must line
up before the anchor is trusted; below that it falls back to an exact
suffix/prefix match, and failing that appends everything.
Returns (merged, n_added).
"""
def norm(w):
return "".join(ch for ch in w.lower() if ch.isalnum())
if not have:
return list(new), len(new)
if not new:
return list(have), 0
tail = have[-max_overlap:]
a = [norm(w) for w in tail]
b = [norm(w) for w in new]
# Longest common contiguous run (classic DP table, one row at a time). `>=` on
# the comparison keeps the RIGHTMOST match, so a phrase repeated earlier in the
# transcript cannot pull the anchor backwards.
best_len = best_j = 0
prev = [0] * (len(b) + 1)
for i in range(1, len(a) + 1):
cur = [0] * (len(b) + 1)
for j in range(1, len(b) + 1):
if a[i - 1] and a[i - 1] == b[j - 1]:
cur[j] = prev[j - 1] + 1
if cur[j] >= best_len:
best_len, best_j = cur[j], j
prev = cur
if best_len >= min_anchor:
return list(have) + list(new[best_j:]), len(new) - best_j
for k in range(min(len(a), len(b), max_overlap), 0, -1): # short overlap: exact only
if a[-k:] == b[:k]:
return list(have) + list(new[k:]), len(new) - k
return list(have) + list(new), len(new)
def _view(elapsed, seconds, dbfs, rows, note="", fill=None):
"""One frame of the live view: header stats over one table row per decoded block.
A block is decoded once and never revised, so each row is final the moment it
appears - which is exactly what makes a table the right shape here rather than a
single paragraph that keeps being rewritten.
"""
head = Table.grid(padding=(0, 2))
head.add_column(style="dim", justify="right")
head.add_column()
meter = "#" * int(max(0.0, min(1.0, (dbfs + 60) / 60)) * 24)
head.add_row("elapsed", f"{elapsed:5.1f}s / {seconds:.0f}s")
head.add_row("level", f"{dbfs:6.1f} dBFS [dim]{meter}[/]")
if fill is not None:
done = int(max(0.0, min(1.0, fill)) * 24)
head.add_row("next block", f"[dim]{'=' * done}{'.' * (24 - done)}[/] {fill:4.0%}")
if note:
head.add_row("status", f"[dim]{note}[/]")
head.add_row("totals", f"{len(rows)} decode(s), "
f"{sum(r['added'] for r in rows)} words kept, "
f"{sum(r['ms'] for r in rows) / 1000:.1f}s decoding")
table = Table(box=box.SIMPLE_HEAD, pad_edge=False, expand=True,
header_style="dim", border_style="dim")
table.add_column("#", justify="right", style="dim", width=4, no_wrap=True)
table.add_column("window", justify="right", width=13, no_wrap=True)
table.add_column("dBFS", justify="right", width=6, no_wrap=True)
table.add_column("decode", justify="right", width=8, no_wrap=True)
table.add_column("+w", justify="right", width=3, no_wrap=True)
table.add_column("hypothesis for this window", overflow="ellipsis", ratio=1)
shown = rows[-SHOW_ROWS:]
if len(rows) > len(shown):
table.add_row("...", "", "", "", "",
f"[dim]{len(rows) - len(shown)} earlier decode(s) - "
"all of them are still in the chart[/]")
for r in shown:
# A decode that added nothing was pure overlap: correct, and the common case.
style = "" if r["added"] else "dim"
table.add_row(str(r["block"]), f"{r['from']:.1f}-{r['at']:.1f}s",
f"{r['dbfs']:.0f}", f"{r['ms']:.0f} ms",
f"[green]+{r['added']}[/]" if r["added"] else "0",
Text(r["text"] or "(empty)", style=style or "white"))
if not rows:
table.add_row("-", "", "", "", "", "[dim italic](nothing decoded yet)[/]")
return Panel(Group(head, Text(""), table), title="[cyan]live transcription[/]",
border_style="cyan", padding=(0, 1))
def live_transcribe(seconds=LISTEN_SECONDS, chunk_seconds=CHUNK_SECONDS,
hop_seconds=HOP_SECONDS, target_sr=SR):
"""Listen and transcribe at the same time, updating the text in place as you speak.
The two halves really do run concurrently: PortAudio fills a queue from its own
callback thread, so the microphone keeps capturing while Whisper decodes on the
GPU. Nothing is dropped during a decode - the audio that arrives mid-decode is
waiting in the queue when it finishes.
Text ACCUMULATES. A `chunk_seconds` window slides over the stream and is
re-decoded every `hop_seconds`, so consecutive hypotheses overlap heavily; the
repeated part is removed by _stitch and only the genuinely new words are kept.
Decode cost is constant (the window never grows) and the transcript only ever
gets longer.
What this is NOT is true streaming ASR. Whisper is an offline encoder-decoder
with no partial-hypothesis API, so a block only appears once it is complete -
the text arrives a chunk at a time, not word by word. For genuinely incremental
output you need a model built for it: Moonshine's streaming variant, an
RNN-T / TDT transducer, or Voxtral Transcribe 2.
Blocks below SILENCE_DBFS are skipped rather than decoded - see the note on
GEN_KWARGS for why feeding Whisper silence is actively harmful.
Returns (transcript, decode_log); decode_log feeds decode_chart().
"""
import queue as _queue
mic, native_sr = pick_microphone()
q, overflows = _queue.Queue(), 0
def on_audio(indata, frames, time_info, status):
"PortAudio callback thread - keep it cheap, just hand the samples over."
nonlocal overflows
if status.input_overflow:
overflows += 1
q.put(indata[:, 0].copy()) # copy: PortAudio reuses this buffer
view = display(_html(_view(0.0, seconds, float("-inf"), [], note="starting")),
display_id=True)
buf = np.zeros(0, dtype="float32")
window_n = int(chunk_seconds * native_sr) # trailing audio each decode sees
hop_n = int(hop_seconds * native_sr) # new audio required before re-decoding
fresh = 0 # unspent new samples since the last decode
words, decode_log, decode_time = [], [], 0.0
with sd.InputStream(device=mic, samplerate=native_sr, channels=1, dtype="float32",
blocksize=int(native_sr * 0.1), callback=on_audio):
_note(f"[bold]mic [{mic}][/] @ {native_sr} Hz -> "
f"{chunk_seconds:.0f}s window every {hop_seconds:.1f}s, "
f"listening {seconds:.0f}s - speak now",
title="[cyan]capture[/]", style="cyan")
t0 = time.perf_counter()
try:
while time.perf_counter() - t0 < seconds:
chunks = []
try:
chunks.append(q.get(timeout=0.2))
except _queue.Empty:
pass
while True: # drain whatever piled up while the last decode ran
try:
chunks.append(q.get_nowait())
except _queue.Empty:
break
if chunks:
fresh += sum(c.size for c in chunks)
buf = np.concatenate([buf, *chunks])[-window_n:] # slide, do not grow
elapsed = time.perf_counter() - t0
if fresh < hop_n or buf.size < int(0.8 * native_sr):
view.update(_html(_view(
elapsed, seconds, _dbfs(buf), decode_log,
fill=fresh / hop_n, note="waiting for the next hop")))
continue
fresh = 0
# Resample the window, not the live stream: ask ALSA for a rate the
# hardware does not offer and the audio comes back pitch-shifted.
audio = (librosa.resample(buf, orig_sr=native_sr, target_sr=target_sr)
if native_sr != target_sr else buf)
dbfs = _dbfs(audio)
# Never hand silence to Whisper: it does not return an empty string,
# it invents a fluent one. Measured room floor here is -53 dBFS RMS
# and speech sits well above -40, so -45 separates them cleanly.
if dbfs < SILENCE_DBFS:
view.update(_html(_view(
elapsed, seconds, dbfs, decode_log,
note=f"below {SILENCE_DBFS:.0f} dBFS - window skipped, not decoded")))
continue
t1 = time.perf_counter()
text = asr({"array": audio, "sampling_rate": target_sr},
generate_kwargs=GEN_KWARGS)["text"].strip()
dt = time.perf_counter() - t1
decode_time += dt
words, added = _stitch(words, text.split())
decode_log.append({"block": len(decode_log) + 1, "ms": 1000 * dt,
"words": len(text.split()), "added": added,
"dbfs": dbfs, "at": elapsed,
"from": max(0.0, elapsed - buf.size / native_sr),
"text": text})
view.update(_html(_view(elapsed, seconds, dbfs, decode_log)))
except KeyboardInterrupt:
view.update(_html(_view(time.perf_counter() - t0, seconds, float("-inf"),
decode_log, note="stopped")))
summary = Table.grid(padding=(0, 2))
summary.add_column(style="dim", justify="right")
summary.add_column()
n = len(decode_log)
rtf = (decode_time / (n * hop_seconds)) if n else float("nan")
summary.add_row("decodes", f"{n} [dim]({chunk_seconds:.0f}s window, "
f"{hop_seconds:.1f}s hop)[/]")
summary.add_row("mean decode", f"{1000 * decode_time / max(n, 1):.0f} ms "
f"[dim]of a {1000 * hop_seconds:.0f} ms budget[/]")
summary.add_row("real-time factor", f"{rtf:.3f}"
+ ("" if rtf < 1 else " [red](decode slower than audio)[/]"))
summary.add_row("words kept", f"{sum(r['added'] for r in decode_log)} "
f"[dim]of {sum(r['words'] for r in decode_log)} "
"decoded (rest was overlap)[/]")
if overflows:
summary.add_row("overflows", f"[red]{overflows}[/] (decode is not keeping up)")
display(_html(Panel(summary, title="[dim]done[/]", border_style="dim", padding=(0, 1))))
return " ".join(words), decode_log
def decode_chart(log, hop_seconds=HOP_SECONDS):
"""Decode latency per block, against the real-time budget (ECharts, repo standard).
The overlaid word count is the point of the chart: cost tracks how much text the
model GENERATES, not how much audio it was given. Every window here is the same
length, so any spread in the bars is generation length, nothing else.
"""
from pyecharts import options as opts
from pyecharts.charts import Bar, Line
if not log:
_note("no decodes to chart - nothing rose above the silence gate",
title="[yellow]empty[/]", style="yellow")
return None
x = [str(r["block"]) for r in log]
ms = [round(r["ms"], 1) for r in log]
words = [r["words"] for r in log]
budget = 1000 * hop_seconds
bar = (
Bar(init_opts=opts.InitOpts(width="760px", height="400px"))
.add_xaxis(x)
.add_yaxis("decode ms", ms, category_gap="30%", z=1)
.extend_axis(yaxis=opts.AxisOpts(name="words out", position="right"))
.set_global_opts(
title_opts=opts.TitleOpts(
title="Decode latency per block",
subtitle=f"{CHUNK_SECONDS:.0f}s window every {hop_seconds:.1f}s - "
f"under {budget:.0f} ms keeps up with real time",
),
xaxis_opts=opts.AxisOpts(name="decode"),
yaxis_opts=opts.AxisOpts(name="milliseconds"),
tooltip_opts=opts.TooltipOpts(trigger="axis"),
legend_opts=opts.LegendOpts(pos_top="8%"),
)
.set_series_opts(
label_opts=opts.LabelOpts(is_show=False),
markline_opts=opts.MarkLineOpts(
data=[opts.MarkLineItem(y=budget, name="real-time budget")],
label_opts=opts.LabelOpts(position="end", formatter="real time"),
),
)
)
line = (
Line()
.add_xaxis(x)
.add_yaxis("words out", words, yaxis_index=1, is_smooth=True,
label_opts=opts.LabelOpts(is_show=False))
)
return bar.overlap(line).render_notebook()
final_text, DECODE_LOG = live_transcribe()
display(_html(Panel(Text(final_text or "(nothing recognised)",
style="bold" if final_text else "dim italic"),
title="[green]final transcript[/]", border_style="green", padding=(0, 1))))
# End of notebook - release the Whisper pipeline we kept around for the mic demo.
del asr
free_memory()
rvram("final")
decode_chart(DECODE_LOG)