Skip to content

Live transcription

Real-time draft transcription.

Audio is always written to disk first (see audio.Recorder); this module only listens to the tracks, cuts them into chunks at pauses, and transcribes each chunk on a single worker thread. The draft is replaced by an accurate pass over the full recording after it stops.

LiveTranscriber

Transcribes tracks in chunks while they are being recorded.

Source code in src/murmurvault/live.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
class LiveTranscriber:
    """Transcribes tracks in chunks while they are being recorded."""

    def __init__(
        self,
        engine: Engine,
        on_segment: Callable[[Segment], None],
        *,
        language: str | None = None,
        min_chunk_s: float = 3.0,
        max_chunk_s: float = 12.0,
    ):
        """Start the worker thread.

        Args:
            engine: Engine for the draft pass.
            on_segment: Called from the worker thread with each new segment.
            language: Language code, or None to auto-detect.
            min_chunk_s: Minimum chunk length in seconds.
            max_chunk_s: Maximum chunk length in seconds.
        """
        self.engine = engine
        self.on_segment = on_segment
        self.language = language
        self.min_s, self.max_s = min_chunk_s, max_chunk_s
        self.buffers: dict[str, np.ndarray] = {}
        self.consumed: dict[str, int] = {}
        self.segments: list[Segment] = []
        self.lock = threading.Lock()
        self.jobs: queue.Queue[tuple[str, int, np.ndarray] | None] = queue.Queue()
        self.worker = threading.Thread(target=self._run, name="live-transcriber", daemon=True)
        self.worker.start()

    def backlog(self) -> int:
        """Number of chunks waiting for the engine.

        Returns:
            Queue length; growing means the engine is slower than real time.
        """
        return self.jobs.qsize()

    def listener(self, track: str, block: np.ndarray) -> None:
        """Receive a block from a track's writer thread and queue a chunk when one is complete.

        Args:
            track: Track name.
            block: 16 kHz mono samples.
        """
        with self.lock:
            buf = np.concatenate([self.buffers.get(track, np.zeros(0, np.float32)), block])
            cut = find_cut(buf, self.min_s, self.max_s)
            if cut is not None:
                start = self.consumed.get(track, 0)
                self.jobs.put((track, start, buf[:cut]))
                self.consumed[track] = start + cut
                buf = buf[cut:]
            self.buffers[track] = buf

    def _run(self) -> None:
        while (job := self.jobs.get()) is not None:
            track, start, chunk = job
            if _rms(chunk) < EMPTY_RMS:
                continue
            try:
                segs = self.engine.transcribe(chunk, language=self.language, fast=True)
            except Exception as exc:  # noqa: BLE001 -- the draft is best effort; the audio is safe on disk
                logger.warning("live transcription failed: %s", exc)
                continue
            offset = start / SR
            for s in segs:
                seg = Segment(
                    start=round(offset + s.start, 3),
                    end=round(offset + s.end, 3),
                    text=s.text,
                    speaker=TRACK_SPEAKER.get(track),
                    track=track,
                )
                self.segments.append(seg)
                try:
                    self.on_segment(seg)
                except Exception as exc:  # noqa: BLE001 -- a display error must not stop the worker
                    logger.warning("live segment callback failed: %s", exc)

    def stop(self) -> list[Segment]:
        """Flush what is still buffered and wait for the backlog.

        Returns:
            The draft segments sorted by time, relative to each track's start.
        """
        with self.lock:
            for track, buf in self.buffers.items():
                if len(buf) > SR // 2:
                    self.jobs.put((track, self.consumed.get(track, 0), buf))
            self.buffers.clear()
        self.jobs.put(None)
        self.worker.join()
        return sorted(self.segments, key=lambda s: s.start)

__init__(engine, on_segment, *, language=None, min_chunk_s=3.0, max_chunk_s=12.0)

Start the worker thread.

Parameters:

Name Type Description Default
engine Engine

Engine for the draft pass.

required
on_segment Callable[[Segment], None]

Called from the worker thread with each new segment.

required
language str | None

Language code, or None to auto-detect.

None
min_chunk_s float

Minimum chunk length in seconds.

3.0
max_chunk_s float

Maximum chunk length in seconds.

12.0
Source code in src/murmurvault/live.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def __init__(
    self,
    engine: Engine,
    on_segment: Callable[[Segment], None],
    *,
    language: str | None = None,
    min_chunk_s: float = 3.0,
    max_chunk_s: float = 12.0,
):
    """Start the worker thread.

    Args:
        engine: Engine for the draft pass.
        on_segment: Called from the worker thread with each new segment.
        language: Language code, or None to auto-detect.
        min_chunk_s: Minimum chunk length in seconds.
        max_chunk_s: Maximum chunk length in seconds.
    """
    self.engine = engine
    self.on_segment = on_segment
    self.language = language
    self.min_s, self.max_s = min_chunk_s, max_chunk_s
    self.buffers: dict[str, np.ndarray] = {}
    self.consumed: dict[str, int] = {}
    self.segments: list[Segment] = []
    self.lock = threading.Lock()
    self.jobs: queue.Queue[tuple[str, int, np.ndarray] | None] = queue.Queue()
    self.worker = threading.Thread(target=self._run, name="live-transcriber", daemon=True)
    self.worker.start()

backlog()

Number of chunks waiting for the engine.

Returns:

Type Description
int

Queue length; growing means the engine is slower than real time.

Source code in src/murmurvault/live.py
 95
 96
 97
 98
 99
100
101
def backlog(self) -> int:
    """Number of chunks waiting for the engine.

    Returns:
        Queue length; growing means the engine is slower than real time.
    """
    return self.jobs.qsize()

listener(track, block)

Receive a block from a track's writer thread and queue a chunk when one is complete.

Parameters:

Name Type Description Default
track str

Track name.

required
block ndarray

16 kHz mono samples.

required
Source code in src/murmurvault/live.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def listener(self, track: str, block: np.ndarray) -> None:
    """Receive a block from a track's writer thread and queue a chunk when one is complete.

    Args:
        track: Track name.
        block: 16 kHz mono samples.
    """
    with self.lock:
        buf = np.concatenate([self.buffers.get(track, np.zeros(0, np.float32)), block])
        cut = find_cut(buf, self.min_s, self.max_s)
        if cut is not None:
            start = self.consumed.get(track, 0)
            self.jobs.put((track, start, buf[:cut]))
            self.consumed[track] = start + cut
            buf = buf[cut:]
        self.buffers[track] = buf

stop()

Flush what is still buffered and wait for the backlog.

Returns:

Type Description
list[Segment]

The draft segments sorted by time, relative to each track's start.

Source code in src/murmurvault/live.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def stop(self) -> list[Segment]:
    """Flush what is still buffered and wait for the backlog.

    Returns:
        The draft segments sorted by time, relative to each track's start.
    """
    with self.lock:
        for track, buf in self.buffers.items():
            if len(buf) > SR // 2:
                self.jobs.put((track, self.consumed.get(track, 0), buf))
        self.buffers.clear()
    self.jobs.put(None)
    self.worker.join()
    return sorted(self.segments, key=lambda s: s.start)

find_cut(buf, min_s, max_s)

Decide where to end the current chunk.

Cut at the end when the buffer ends in a pause; once it exceeds max_s, cut at the quietest frame.

Parameters:

Name Type Description Default
buf ndarray

Buffered 16 kHz samples not yet transcribed.

required
min_s float

Minimum chunk length in seconds.

required
max_s float

Maximum chunk length in seconds.

required

Returns:

Type Description
int | None

Sample index to cut at, or None to keep buffering.

Source code in src/murmurvault/live.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def find_cut(buf: np.ndarray, min_s: float, max_s: float) -> int | None:
    """Decide where to end the current chunk.

    Cut at the end when the buffer ends in a pause; once it exceeds ``max_s``, cut at the quietest frame.

    Args:
        buf: Buffered 16 kHz samples not yet transcribed.
        min_s: Minimum chunk length in seconds.
        max_s: Maximum chunk length in seconds.

    Returns:
        Sample index to cut at, or None to keep buffering.
    """
    n = len(buf)
    if n < min_s * SR:
        return None
    if _rms(buf[-3 * FRAME :]) < SILENCE_RMS:
        return n
    if n < max_s * SR:
        return None
    # Forced cut: the quietest 100 ms frame after the minimum length, so words are rarely split.
    lo = int(min_s * SR) // FRAME
    frames = buf[: (n // FRAME) * FRAME].reshape(-1, FRAME)
    energies = np.sqrt(np.mean(np.square(frames[lo:]), axis=1))
    if not len(energies):
        return n
    return (lo + int(np.argmin(energies)) + 1) * FRAME