Skip to content

Vault

The vault: plain files on disk are the source of truth.

Layout::

<vault>/
  <folder>/<sub-folder>/<recording-id>/
    meta.json                       title, tags, tracks, active transcript
    mic.flac, system.flac, ...      16 kHz mono tracks
    transcript.<name>.json          one file per transcription pass
  .murmurvault/index.db             derived search index; rebuild with `reindex`

Folders are real directories, so the vault can be browsed, backed up or grepped without the tool.

Recording dataclass

Metadata of one recording, stored as meta.json in its directory.

Attributes:

Name Type Description
id str

Unique id, also the directory name.

title str

Human-readable title.

created str

ISO timestamp of creation.

folder str

Folder path relative to the vault root; "" is the root.

tags list[str]

Sorted, normalised tags.

tracks dict[str, str]

Track name to file name inside the recording directory.

track_offsets dict[str, float]

Track name to start offset in seconds relative to the recording start.

duration float

Length in seconds.

source str

record or import.

active_transcript str | None

Name of the transcript pass shown by default.

path Path

Directory holding the recording's files (not serialised).

Source code in src/murmurvault/vault.py
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
@dataclass
class Recording:
    """Metadata of one recording, stored as ``meta.json`` in its directory.

    Attributes:
        id: Unique id, also the directory name.
        title: Human-readable title.
        created: ISO timestamp of creation.
        folder: Folder path relative to the vault root; ``""`` is the root.
        tags: Sorted, normalised tags.
        tracks: Track name to file name inside the recording directory.
        track_offsets: Track name to start offset in seconds relative to the recording start.
        duration: Length in seconds.
        source: ``record`` or ``import``.
        active_transcript: Name of the transcript pass shown by default.
        path: Directory holding the recording's files (not serialised).
    """

    id: str
    title: str
    created: str
    folder: str = ""
    tags: list[str] = field(default_factory=list)
    tracks: dict[str, str] = field(default_factory=dict)
    track_offsets: dict[str, float] = field(default_factory=dict)
    duration: float = 0.0
    source: str = "record"
    active_transcript: str | None = None
    path: Path = field(default=Path(), compare=False, repr=False)

    def to_dict(self) -> dict[str, Any]:
        """Serialise to the JSON-compatible content of ``meta.json``.

        Returns:
            The metadata without the in-memory ``path``.
        """
        data = asdict(self)
        data.pop("path")
        return data

to_dict()

Serialise to the JSON-compatible content of meta.json.

Returns:

Type Description
dict[str, Any]

The metadata without the in-memory path.

Source code in src/murmurvault/vault.py
145
146
147
148
149
150
151
152
153
def to_dict(self) -> dict[str, Any]:
    """Serialise to the JSON-compatible content of ``meta.json``.

    Returns:
        The metadata without the in-memory ``path``.
    """
    data = asdict(self)
    data.pop("path")
    return data

Segment dataclass

A timed piece of transcript.

Attributes:

Name Type Description
start float

Start time in seconds from the start of the recording.

end float

End time in seconds.

text str

Transcribed text.

speaker str | None

Speaker label, e.g. me, others or speaker 2.

track str | None

Name of the track the segment came from.

Source code in src/murmurvault/vault.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@dataclass
class Segment:
    """A timed piece of transcript.

    Attributes:
        start: Start time in seconds from the start of the recording.
        end: End time in seconds.
        text: Transcribed text.
        speaker: Speaker label, e.g. ``me``, ``others`` or ``speaker 2``.
        track: Name of the track the segment came from.
    """

    start: float
    end: float
    text: str
    speaker: str | None = None
    track: str | None = None

Transcript dataclass

One transcription pass over a recording.

Attributes:

Name Type Description
engine str

Name of the engine that produced it.

model str

Model used by the engine.

segments list[Segment]

Segments sorted by start time.

language str | None

Language passed to the engine, if any.

created str

ISO timestamp of the pass.

Source code in src/murmurvault/vault.py
 53
 54
 55
 56
 57
 58
 59
 60
 61
 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
@dataclass
class Transcript:
    """One transcription pass over a recording.

    Attributes:
        engine: Name of the engine that produced it.
        model: Model used by the engine.
        segments: Segments sorted by start time.
        language: Language passed to the engine, if any.
        created: ISO timestamp of the pass.
    """

    engine: str
    model: str
    segments: list[Segment]
    language: str | None = None
    created: str = field(default_factory=lambda: _now().isoformat(timespec="seconds"))

    def to_dict(self) -> dict[str, Any]:
        """Serialise to a JSON-compatible dict.

        Returns:
            The transcript as plain data.
        """
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Transcript:
        """Build a transcript from :meth:`to_dict` output.

        Args:
            data: Plain data as written to ``transcript.<name>.json``.

        Returns:
            The transcript.
        """
        segs = [Segment(**s) for s in data.get("segments", [])]
        return cls(
            engine=data["engine"],
            model=data["model"],
            segments=segs,
            language=data.get("language"),
            created=data.get("created", ""),
        )

    def text(self, timestamps: bool = True) -> str:
        """Render as plain text, one segment per line.

        Args:
            timestamps: Prefix each line with its start time.

        Returns:
            The rendered transcript.
        """
        lines = []
        for s in self.segments:
            who = f"{s.speaker}: " if s.speaker else ""
            stamp = f"[{fmt_time(s.start)}] " if timestamps else ""
            lines.append(f"{stamp}{who}{s.text.strip()}")
        return "\n".join(lines)

from_dict(data) classmethod

Build a transcript from :meth:to_dict output.

Parameters:

Name Type Description Default
data dict[str, Any]

Plain data as written to transcript.<name>.json.

required

Returns:

Type Description
Transcript

The transcript.

Source code in src/murmurvault/vault.py
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Transcript:
    """Build a transcript from :meth:`to_dict` output.

    Args:
        data: Plain data as written to ``transcript.<name>.json``.

    Returns:
        The transcript.
    """
    segs = [Segment(**s) for s in data.get("segments", [])]
    return cls(
        engine=data["engine"],
        model=data["model"],
        segments=segs,
        language=data.get("language"),
        created=data.get("created", ""),
    )

text(timestamps=True)

Render as plain text, one segment per line.

Parameters:

Name Type Description Default
timestamps bool

Prefix each line with its start time.

True

Returns:

Type Description
str

The rendered transcript.

Source code in src/murmurvault/vault.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def text(self, timestamps: bool = True) -> str:
    """Render as plain text, one segment per line.

    Args:
        timestamps: Prefix each line with its start time.

    Returns:
        The rendered transcript.
    """
    lines = []
    for s in self.segments:
        who = f"{s.speaker}: " if s.speaker else ""
        stamp = f"[{fmt_time(s.start)}] " if timestamps else ""
        lines.append(f"{stamp}{who}{s.text.strip()}")
    return "\n".join(lines)

to_dict()

Serialise to a JSON-compatible dict.

Returns:

Type Description
dict[str, Any]

The transcript as plain data.

Source code in src/murmurvault/vault.py
71
72
73
74
75
76
77
def to_dict(self) -> dict[str, Any]:
    """Serialise to a JSON-compatible dict.

    Returns:
        The transcript as plain data.
    """
    return asdict(self)

Vault

A directory tree of recordings.

Source code in src/murmurvault/vault.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
class Vault:
    """A directory tree of recordings."""

    def __init__(self, root: str | Path):
        """Open the vault at ``root``, creating it if needed.

        Args:
            root: Vault directory.
        """
        self.root = Path(root).expanduser()
        self.root.mkdir(parents=True, exist_ok=True)
        self.internal = self.root / INTERNAL
        self.internal.mkdir(exist_ok=True)

    # -- recordings -------------------------------------------------------------------------
    def new_recording(
        self, title: str = "", folder: str = "", tags: list[str] | None = None, source: str = "record"
    ) -> Recording:
        """Create an empty recording directory with its ``meta.json``.

        Args:
            title: Title; defaults to one derived from the current time.
            folder: Folder path inside the vault.
            tags: Initial tags.
            source: ``record`` or ``import``.

        Returns:
            The new recording.
        """
        now = _now()
        rid = f"{now:%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
        folder = normalize_folder(folder)
        path = self.root / folder / rid
        path.mkdir(parents=True)
        rec = Recording(
            id=rid,
            title=title or f"Recording {now:%Y-%m-%d %H:%M}",
            created=now.isoformat(timespec="seconds"),
            folder=folder,
            tags=sorted({normalize_tag(t) for t in tags or []}),
            source=source,
            path=path,
        )
        self.save(rec)
        return rec

    def save(self, rec: Recording) -> None:
        """Write ``meta.json`` atomically.

        Args:
            rec: The recording to save.
        """
        tmp = rec.path / (META + ".tmp")
        tmp.write_text(json.dumps(rec.to_dict(), indent=2) + "\n")
        tmp.replace(rec.path / META)

    def _load(self, meta: Path) -> Recording:
        data = json.loads(meta.read_text())
        rec = Recording(**data)
        rec.path = meta.parent
        # The directory is authoritative for the folder, so moves made outside the tool are honoured.
        rel = meta.parent.parent.relative_to(self.root)
        rec.folder = "" if str(rel) == "." else rel.as_posix()
        return rec

    def recordings(self, folder: str | None = None, tags: list[str] | None = None) -> list[Recording]:
        """List recordings, newest first.

        Args:
            folder: Only recordings in this folder or below it.
            tags: Only recordings carrying all of these tags.

        Returns:
            The matching recordings.
        """
        wanted_folder = normalize_folder(folder) if folder is not None else ""
        wanted_tags = {normalize_tag(t) for t in tags or []}
        out = []
        for meta in self.root.rglob(META):
            if INTERNAL in meta.relative_to(self.root).parts:
                continue
            try:
                rec = self._load(meta)
            except (json.JSONDecodeError, TypeError, KeyError):
                continue
            if wanted_folder and rec.folder != wanted_folder and not rec.folder.startswith(wanted_folder + "/"):
                continue
            if not wanted_tags <= set(rec.tags):
                continue
            out.append(rec)
        return sorted(out, key=lambda r: r.created, reverse=True)

    def get(self, ref: str) -> Recording:
        """Look up a recording by id or unique id prefix.

        Args:
            ref: Full id or a prefix of it.

        Returns:
            The recording.

        Raises:
            KeyError: If nothing matches or the prefix is ambiguous.
        """
        recs = self.recordings()
        matches = [r for r in recs if r.id == ref] or [r for r in recs if r.id.startswith(ref)]
        if not matches:
            raise KeyError(f"no recording matches {ref!r}")
        if len(matches) > 1:
            raise KeyError(f"{ref!r} is ambiguous: {', '.join(r.id for r in matches)}")
        return matches[0]

    def folders(self) -> list[str]:
        """List the folders that hold recordings.

        Returns:
            Sorted folder paths, excluding the root.
        """
        return sorted({r.folder for r in self.recordings()} - {""})

    def tags(self) -> dict[str, int]:
        """Count recordings per tag.

        Returns:
            Tag to number of recordings, sorted by tag.
        """
        counts: dict[str, int] = {}
        for r in self.recordings():
            for t in r.tags:
                counts[t] = counts.get(t, 0) + 1
        return dict(sorted(counts.items()))

    def move(self, rec: Recording, folder: str) -> Recording:
        """Move a recording to another folder, pruning folders left empty.

        Args:
            rec: The recording to move.
            folder: Destination folder; ``""`` for the root.

        Returns:
            The updated recording.
        """
        folder = normalize_folder(folder)
        dest = self.root / folder / rec.id
        if dest == rec.path:
            return rec
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(str(rec.path), str(dest))
        self._prune_empty(rec.path.parent)
        rec.path, rec.folder = dest, folder
        return rec

    def delete(self, rec: Recording) -> None:
        """Delete a recording's directory and prune folders left empty.

        Args:
            rec: The recording to delete.
        """
        shutil.rmtree(rec.path)
        self._prune_empty(rec.path.parent)

    def _prune_empty(self, directory: Path) -> None:
        while directory != self.root and directory.is_dir() and not any(directory.iterdir()):
            directory.rmdir()
            directory = directory.parent

    def set_tags(self, rec: Recording, add: Iterable[str] = (), remove: Iterable[str] = ()) -> Recording:
        """Add and remove tags, then save.

        Args:
            rec: The recording to change.
            add: Tags to add.
            remove: Tags to remove.

        Returns:
            The updated recording.
        """
        tags = set(rec.tags) | {normalize_tag(t) for t in add}
        tags -= {normalize_tag(t) for t in remove}
        rec.tags = sorted(tags)
        self.save(rec)
        return rec

    # -- transcripts ------------------------------------------------------------------------
    @staticmethod
    def transcript_names(rec: Recording) -> list[str]:
        """List the transcription passes stored for a recording.

        Args:
            rec: The recording.

        Returns:
            Sorted pass names.
        """
        return sorted(p.name[len("transcript.") : -len(".json")] for p in rec.path.glob("transcript.*.json"))

    def save_transcript(self, rec: Recording, name: str, transcript: Transcript, activate: bool = True) -> None:
        """Write a transcription pass, optionally making it the active one.

        Args:
            rec: The recording.
            name: Pass name, used in the file name.
            transcript: The transcript to write.
            activate: Make it the transcript shown and indexed by default.
        """
        path = rec.path / f"transcript.{name}.json"
        path.write_text(json.dumps(transcript.to_dict(), indent=2, ensure_ascii=False) + "\n")
        if activate:
            rec.active_transcript = name
            self.save(rec)

    @staticmethod
    def load_transcript(rec: Recording, name: str | None = None) -> Transcript | None:
        """Read a transcription pass.

        Args:
            rec: The recording.
            name: Pass name; defaults to the active one.

        Returns:
            The transcript, or None if there is none.
        """
        name = name or rec.active_transcript
        if not name:
            return None
        path = rec.path / f"transcript.{name}.json"
        if not path.exists():
            return None
        return Transcript.from_dict(json.loads(path.read_text()))

__init__(root)

Open the vault at root, creating it if needed.

Parameters:

Name Type Description Default
root str | Path

Vault directory.

required
Source code in src/murmurvault/vault.py
214
215
216
217
218
219
220
221
222
223
def __init__(self, root: str | Path):
    """Open the vault at ``root``, creating it if needed.

    Args:
        root: Vault directory.
    """
    self.root = Path(root).expanduser()
    self.root.mkdir(parents=True, exist_ok=True)
    self.internal = self.root / INTERNAL
    self.internal.mkdir(exist_ok=True)

delete(rec)

Delete a recording's directory and prune folders left empty.

Parameters:

Name Type Description Default
rec Recording

The recording to delete.

required
Source code in src/murmurvault/vault.py
363
364
365
366
367
368
369
370
def delete(self, rec: Recording) -> None:
    """Delete a recording's directory and prune folders left empty.

    Args:
        rec: The recording to delete.
    """
    shutil.rmtree(rec.path)
    self._prune_empty(rec.path.parent)

folders()

List the folders that hold recordings.

Returns:

Type Description
list[str]

Sorted folder paths, excluding the root.

Source code in src/murmurvault/vault.py
323
324
325
326
327
328
329
def folders(self) -> list[str]:
    """List the folders that hold recordings.

    Returns:
        Sorted folder paths, excluding the root.
    """
    return sorted({r.folder for r in self.recordings()} - {""})

get(ref)

Look up a recording by id or unique id prefix.

Parameters:

Name Type Description Default
ref str

Full id or a prefix of it.

required

Returns:

Type Description
Recording

The recording.

Raises:

Type Description
KeyError

If nothing matches or the prefix is ambiguous.

Source code in src/murmurvault/vault.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
def get(self, ref: str) -> Recording:
    """Look up a recording by id or unique id prefix.

    Args:
        ref: Full id or a prefix of it.

    Returns:
        The recording.

    Raises:
        KeyError: If nothing matches or the prefix is ambiguous.
    """
    recs = self.recordings()
    matches = [r for r in recs if r.id == ref] or [r for r in recs if r.id.startswith(ref)]
    if not matches:
        raise KeyError(f"no recording matches {ref!r}")
    if len(matches) > 1:
        raise KeyError(f"{ref!r} is ambiguous: {', '.join(r.id for r in matches)}")
    return matches[0]

load_transcript(rec, name=None) staticmethod

Read a transcription pass.

Parameters:

Name Type Description Default
rec Recording

The recording.

required
name str | None

Pass name; defaults to the active one.

None

Returns:

Type Description
Transcript | None

The transcript, or None if there is none.

Source code in src/murmurvault/vault.py
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
@staticmethod
def load_transcript(rec: Recording, name: str | None = None) -> Transcript | None:
    """Read a transcription pass.

    Args:
        rec: The recording.
        name: Pass name; defaults to the active one.

    Returns:
        The transcript, or None if there is none.
    """
    name = name or rec.active_transcript
    if not name:
        return None
    path = rec.path / f"transcript.{name}.json"
    if not path.exists():
        return None
    return Transcript.from_dict(json.loads(path.read_text()))

move(rec, folder)

Move a recording to another folder, pruning folders left empty.

Parameters:

Name Type Description Default
rec Recording

The recording to move.

required
folder str

Destination folder; "" for the root.

required

Returns:

Type Description
Recording

The updated recording.

Source code in src/murmurvault/vault.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def move(self, rec: Recording, folder: str) -> Recording:
    """Move a recording to another folder, pruning folders left empty.

    Args:
        rec: The recording to move.
        folder: Destination folder; ``""`` for the root.

    Returns:
        The updated recording.
    """
    folder = normalize_folder(folder)
    dest = self.root / folder / rec.id
    if dest == rec.path:
        return rec
    dest.parent.mkdir(parents=True, exist_ok=True)
    shutil.move(str(rec.path), str(dest))
    self._prune_empty(rec.path.parent)
    rec.path, rec.folder = dest, folder
    return rec

new_recording(title='', folder='', tags=None, source='record')

Create an empty recording directory with its meta.json.

Parameters:

Name Type Description Default
title str

Title; defaults to one derived from the current time.

''
folder str

Folder path inside the vault.

''
tags list[str] | None

Initial tags.

None
source str

record or import.

'record'

Returns:

Type Description
Recording

The new recording.

Source code in src/murmurvault/vault.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def new_recording(
    self, title: str = "", folder: str = "", tags: list[str] | None = None, source: str = "record"
) -> Recording:
    """Create an empty recording directory with its ``meta.json``.

    Args:
        title: Title; defaults to one derived from the current time.
        folder: Folder path inside the vault.
        tags: Initial tags.
        source: ``record`` or ``import``.

    Returns:
        The new recording.
    """
    now = _now()
    rid = f"{now:%Y%m%d-%H%M%S}-{secrets.token_hex(2)}"
    folder = normalize_folder(folder)
    path = self.root / folder / rid
    path.mkdir(parents=True)
    rec = Recording(
        id=rid,
        title=title or f"Recording {now:%Y-%m-%d %H:%M}",
        created=now.isoformat(timespec="seconds"),
        folder=folder,
        tags=sorted({normalize_tag(t) for t in tags or []}),
        source=source,
        path=path,
    )
    self.save(rec)
    return rec

recordings(folder=None, tags=None)

List recordings, newest first.

Parameters:

Name Type Description Default
folder str | None

Only recordings in this folder or below it.

None
tags list[str] | None

Only recordings carrying all of these tags.

None

Returns:

Type Description
list[Recording]

The matching recordings.

Source code in src/murmurvault/vault.py
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def recordings(self, folder: str | None = None, tags: list[str] | None = None) -> list[Recording]:
    """List recordings, newest first.

    Args:
        folder: Only recordings in this folder or below it.
        tags: Only recordings carrying all of these tags.

    Returns:
        The matching recordings.
    """
    wanted_folder = normalize_folder(folder) if folder is not None else ""
    wanted_tags = {normalize_tag(t) for t in tags or []}
    out = []
    for meta in self.root.rglob(META):
        if INTERNAL in meta.relative_to(self.root).parts:
            continue
        try:
            rec = self._load(meta)
        except (json.JSONDecodeError, TypeError, KeyError):
            continue
        if wanted_folder and rec.folder != wanted_folder and not rec.folder.startswith(wanted_folder + "/"):
            continue
        if not wanted_tags <= set(rec.tags):
            continue
        out.append(rec)
    return sorted(out, key=lambda r: r.created, reverse=True)

save(rec)

Write meta.json atomically.

Parameters:

Name Type Description Default
rec Recording

The recording to save.

required
Source code in src/murmurvault/vault.py
257
258
259
260
261
262
263
264
265
def save(self, rec: Recording) -> None:
    """Write ``meta.json`` atomically.

    Args:
        rec: The recording to save.
    """
    tmp = rec.path / (META + ".tmp")
    tmp.write_text(json.dumps(rec.to_dict(), indent=2) + "\n")
    tmp.replace(rec.path / META)

save_transcript(rec, name, transcript, activate=True)

Write a transcription pass, optionally making it the active one.

Parameters:

Name Type Description Default
rec Recording

The recording.

required
name str

Pass name, used in the file name.

required
transcript Transcript

The transcript to write.

required
activate bool

Make it the transcript shown and indexed by default.

True
Source code in src/murmurvault/vault.py
407
408
409
410
411
412
413
414
415
416
417
418
419
420
def save_transcript(self, rec: Recording, name: str, transcript: Transcript, activate: bool = True) -> None:
    """Write a transcription pass, optionally making it the active one.

    Args:
        rec: The recording.
        name: Pass name, used in the file name.
        transcript: The transcript to write.
        activate: Make it the transcript shown and indexed by default.
    """
    path = rec.path / f"transcript.{name}.json"
    path.write_text(json.dumps(transcript.to_dict(), indent=2, ensure_ascii=False) + "\n")
    if activate:
        rec.active_transcript = name
        self.save(rec)

set_tags(rec, add=(), remove=())

Add and remove tags, then save.

Parameters:

Name Type Description Default
rec Recording

The recording to change.

required
add Iterable[str]

Tags to add.

()
remove Iterable[str]

Tags to remove.

()

Returns:

Type Description
Recording

The updated recording.

Source code in src/murmurvault/vault.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def set_tags(self, rec: Recording, add: Iterable[str] = (), remove: Iterable[str] = ()) -> Recording:
    """Add and remove tags, then save.

    Args:
        rec: The recording to change.
        add: Tags to add.
        remove: Tags to remove.

    Returns:
        The updated recording.
    """
    tags = set(rec.tags) | {normalize_tag(t) for t in add}
    tags -= {normalize_tag(t) for t in remove}
    rec.tags = sorted(tags)
    self.save(rec)
    return rec

tags()

Count recordings per tag.

Returns:

Type Description
dict[str, int]

Tag to number of recordings, sorted by tag.

Source code in src/murmurvault/vault.py
331
332
333
334
335
336
337
338
339
340
341
def tags(self) -> dict[str, int]:
    """Count recordings per tag.

    Returns:
        Tag to number of recordings, sorted by tag.
    """
    counts: dict[str, int] = {}
    for r in self.recordings():
        for t in r.tags:
            counts[t] = counts.get(t, 0) + 1
    return dict(sorted(counts.items()))

transcript_names(rec) staticmethod

List the transcription passes stored for a recording.

Parameters:

Name Type Description Default
rec Recording

The recording.

required

Returns:

Type Description
list[str]

Sorted pass names.

Source code in src/murmurvault/vault.py
395
396
397
398
399
400
401
402
403
404
405
@staticmethod
def transcript_names(rec: Recording) -> list[str]:
    """List the transcription passes stored for a recording.

    Args:
        rec: The recording.

    Returns:
        Sorted pass names.
    """
    return sorted(p.name[len("transcript.") : -len(".json")] for p in rec.path.glob("transcript.*.json"))

fmt_time(seconds)

Format seconds as MM:SS, or H:MM:SS from one hour on.

Parameters:

Name Type Description Default
seconds float

Time in seconds; negative values are clamped to zero.

required

Returns:

Type Description
str

The formatted time.

Source code in src/murmurvault/vault.py
156
157
158
159
160
161
162
163
164
165
166
167
168
def fmt_time(seconds: float) -> str:
    """Format seconds as ``MM:SS``, or ``H:MM:SS`` from one hour on.

    Args:
        seconds: Time in seconds; negative values are clamped to zero.

    Returns:
        The formatted time.
    """
    seconds = max(0, int(seconds))
    h, rem = divmod(seconds, 3600)
    m, s = divmod(rem, 60)
    return f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"

normalize_folder(folder)

Return a clean relative folder path, rejecting anything that escapes the vault.

Parameters:

Name Type Description Default
folder str

Folder path such as work/team; leading and trailing slashes are ignored.

required

Returns:

Type Description
str

The normalised path, "" for the vault root.

Raises:

Type Description
ValueError

If a component is .. or starts with a dot.

Source code in src/murmurvault/vault.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def normalize_folder(folder: str) -> str:
    """Return a clean relative folder path, rejecting anything that escapes the vault.

    Args:
        folder: Folder path such as ``work/team``; leading and trailing slashes are ignored.

    Returns:
        The normalised path, ``""`` for the vault root.

    Raises:
        ValueError: If a component is ``..`` or starts with a dot.
    """
    folder = folder.strip().strip("/")
    if not folder:
        return ""
    parts = PurePosixPath(folder).parts
    for part in parts:
        if part in {"..", "."} or part.startswith("."):
            raise ValueError(f"invalid folder component: {part!r}")
    return "/".join(parts)

normalize_tag(tag)

Normalise a tag: strip a leading # and lower-case it.

Parameters:

Name Type Description Default
tag str

The tag as typed.

required

Returns:

Type Description
str

The normalised tag.

Raises:

Type Description
ValueError

If the tag is empty or contains whitespace or commas.

Source code in src/murmurvault/vault.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def normalize_tag(tag: str) -> str:
    """Normalise a tag: strip a leading ``#`` and lower-case it.

    Args:
        tag: The tag as typed.

    Returns:
        The normalised tag.

    Raises:
        ValueError: If the tag is empty or contains whitespace or commas.
    """
    tag = tag.strip().lstrip("#").lower()
    if not tag or any(c.isspace() or c == "," for c in tag):
        raise ValueError(f"invalid tag: {tag!r}")
    return tag