@chronicle.app/apple-voice-memos 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # @chronicle.app/apple-voice-memos
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your Voice Memos recordings and the transcripts Voice Memos made of them, from the library macOS keeps in step with iCloud.
4
+
5
+ ## Usage
6
+
7
+ ```sh
8
+ chronicle extract apple-voice-memos
9
+ chronicle extract apple-voice-memos --since 2024-01-01 --until 2025-01-01
10
+ chronicle extract apple-voice-memos --input "/path/to/Recordings/CloudRecordings.db"
11
+ ```
12
+
13
+ The library is `~/Library/Group Containers/group.com.apple.VoiceMemos.shared/Recordings/CloudRecordings.db`, opened read-only with built-in `node:sqlite`, and the audio files sit beside it. Voice Memos syncs the library through iCloud, so it holds the recordings made on every device signed in to your account: your iPhone, iPad, Apple Watch, and Mac. With `--input`, the audio files are read from the folder the database is in.
14
+
15
+ Recordings come out newest first, and `--since` and `--until` bound when each one started. A run reads only the library and its files, offline.
16
+
17
+ ## Schema
18
+
19
+ | Voice Memos thing | Chronicle node | Key | Properties |
20
+ | ----------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
21
+ | Recording a memo | `RecordAction` | `@type`, `source`, `sourceId` (the recording's UUID) | `agent` (you), `instrument` (the OS release and the kind of device), `result` (the recording), `timestamp` (when recording started) |
22
+ | The OS release the file names | `OperatingSystem` | `@type`, `source` (`apple-voice-memos`), `handle` (the tag as written, such as `Watch Version 11.5 (Build 22T572)`) | |
23
+ | The kind of device | `ProductModel` | `source` (`wikidata`), `sourceId` (its Wikidata ID, such as `Q2766`) | `name` (`iPhone`, `Apple Watch`, or `Mac`), `url` (its Wikidata page) |
24
+ | The recording | `AudioObject` | `@type`, `source` (`apple-voice-memos`), `sourceId` (its UUID) | `name` (its title), `duration`, `contentPath` and `mimeType` (its audio file, when it's on this Mac: `audio/mp4` for `.m4a`, `audio/x-quicktime` for `.qta`), `transcript` (its text) |
25
+ | You | `Person` | `@type`, `source` (`icloud`), `sourceId` (your account's DSID) | `handle` (your Apple Account email); `sameAs` `@me` |
26
+
27
+ `SHAPES.md` sketches the output, generated from the tests.
28
+
29
+ ### Decisions
30
+
31
+ **Recording a memo is a `RecordAction`.** Its `result` is the recording, keyed by the recording's UUID. Voice Memos writes the same UUID into the audio file, so the recording keeps its key wherever its file is read from. The action's `timestamp` is when recording started, which the audio file's own `date` tag agrees with.
32
+
33
+ **No `endTime`.** Voice Memos keeps the recording's duration but not when recording stopped. A memo can be paused, resumed later, or trimmed, so the start plus the duration isn't always when recording stopped, and the action has none. The recording's `duration` is its length.
34
+
35
+ **A file or transcript that doesn't read is reported.** The memo is kept, and the run's summary says how many didn't read, since that's how a format Voice Memos changed would show.
36
+
37
+ **Each record is read as of the library's last change.** A title or transcript can change long after the recording is made, so a record's observation time is when the library, or its write-ahead log, last changed, not when recording started. An unchanged library gives the same records on every run.
38
+
39
+ **The recording's file, when it's on this Mac.** A recording's `contentPath` is its audio file, captured at ingest. iCloud can keep a recording's audio in the cloud alone, leaving no file or an empty one; that recording has no `contentPath`. Older recordings are `.m4a` files, and newer ones `.qta`, QuickTime audio, which macOS declares as `audio/x-quicktime`.
40
+
41
+ **Deleted memos are left out.** A memo in Recently Deleted has an eviction date in the library, and isn't extracted.
42
+
43
+ **The instrument is what the file says, not which device.** The audio file's encoder tag, such as `com.apple.VoiceMemos (Watch Version 11.5 (Build 22T572))`, names the kind of device and the OS release that wrote the file, but never which device: no device name, model, or serial. The action's `instrument` is that release, an `OperatingSystem` whose `handle` is the tag as written, without the app's name, which the source already implies. Beside it is the kind of device, a `ProductModel` keyed by its Wikidata ID, so other sources can name the same one: [iPhone](https://www.wikidata.org/wiki/Q2766), [Apple Watch](https://www.wikidata.org/wiki/Q18010946), or [Mac](https://www.wikidata.org/wiki/Q75687) for an older `Mac OS X` tag. A device identified only by its kind would merge every iPhone you've owned into one, so there is no `Device`. Another source that knows which device it was can add it to the action's instruments.
44
+
45
+ - A tag that doesn't name the kind of device gives only the release. `iOS 13.1.2` could be an iPhone or an iPad, and `iPad Version …` could be a Mac: Voice Memos on a Mac is the iPad app, so a Mac's tag says `iPad`, with macOS's version and build.
46
+ - A tag that doesn't name an OS release, such as `aphone5 (null)`, which appears on memos from 2020 to 2022, gives no instrument.
47
+ - The tag is from whichever device last wrote the file. iOS 13 rewrote older recordings with its own tag, in the older `iOS 13.1.2` form. A tag in an older form (`iOS …` or `Mac OS X …`) naming a release that came out after the recording was made is from that rewrite, and gives no instrument. Voice Memos stopped writing those forms in 2020, so the plugin's list of their release dates is complete. A tag in the current form (`iPhone Version …`) is taken as written, with no dates to keep up.
48
+ - Editing a memo on another device, such as trimming it on a Mac, rewrites the tag with that device's release, and nothing in the file tells. The memo is then credited to the device that edited it.
49
+ - The extracted record keeps the whole tag.
50
+
51
+ **The words spoken are the recording's `transcript`.** Voice Memos transcribes recordings itself, from iOS 18 on. The text is the recording's `transcript`, plain text with no timings, not its `body`, since it's a machine's reading of what was said rather than text anyone wrote. It's Voice Memos' own transcript: when Voice Memos makes a new one, such as after a trim, it takes the old one's place. A transcript from another speech recognizer belongs to that source's own record of the recording, so it never replaces Apple's. A recording Voice Memos heard no speech in has an empty transcript, or only spaces, which is left out. The extracted record keeps when each word was spoken and the transcript's locale, which have no Chronicle terms yet.
52
+
53
+ **The agent is your iCloud account,** whose library this is, from `@chronicle.app/icloud`. It's omitted when no account can be read. An explicit `account` object (or null) describes a copied library without a host account lookup.
54
+
55
+ **The files are read in the plugin.** A transcript is JSON inside the audio file: a `.m4a` keeps it in its audio track's `tsrp` box, and a `.qta` in its track's QuickTime metadata under `com.apple.VoiceMemos.tsrp`. `mp4.ts` reads only the movie header, not the audio. The transcript lists its text in runs, each with when it was spoken; the extracted record keeps the runs and the locale. A file or transcript that doesn't read is logged and left out; the recording is kept.
56
+
57
+ ## Tests
58
+
59
+ The tests are smoke tests over a synthetic `CloudRecordings.db` and synthetic `.m4a` and `.qta` files with no real audio, written by the fixture, and pass an explicit iCloud account. They never read the host's Voice Memos library or iCloud account.
@@ -0,0 +1,65 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
+ import { type Transcript } from '../transcript.js';
4
+ import AppleVoiceMemosTransformer from './AppleVoiceMemosTransformer.js';
5
+ /** One recording as the library row and its file give it, named after the row's Core Data attributes. */
6
+ export interface VoiceMemo {
7
+ /** The recording's UUID, which Voice Memos also writes into the audio file. */
8
+ uniqueID: string;
9
+ /** When recording started, in UTC. */
10
+ date: string;
11
+ /** How long the recording lasts, in seconds, when the library says. */
12
+ duration: number | null;
13
+ /** The title. `New Recording 3` and the like until it's renamed. */
14
+ title: string | null;
15
+ /** The audio file's name in the library's folder, which starts with the local start time. */
16
+ path: string | null;
17
+ flags: number;
18
+ /** The audio file's absolute path, when it's on this Mac. */
19
+ file: string | null;
20
+ /** The file's `©too` tag: the app, and the kind of device and OS version that last wrote the file. */
21
+ encoder: string | null;
22
+ /** The transcript Voice Memos made, when the file has one. */
23
+ transcript: Transcript | null;
24
+ }
25
+ /**
26
+ * The Voice Memos library. It syncs through iCloud, so it holds the
27
+ * recordings made on every device signed in to the account. Their audio
28
+ * files sit beside the database.
29
+ */
30
+ export declare class AppleVoiceMemosExtractor extends SqliteExtractor<typeof AppleVoiceMemosExtractor> {
31
+ static source: string;
32
+ static description: string;
33
+ static delivery: "local";
34
+ static strategy: string;
35
+ static recordTypes: string[];
36
+ static default: boolean;
37
+ static defaultTransformer: typeof AppleVoiceMemosTransformer;
38
+ static newestFirst: boolean;
39
+ static schema: any;
40
+ private libraryChanged;
41
+ private unreadable;
42
+ setup(): Promise<void>;
43
+ /**
44
+ * When the library last changed, not when the run started. A recording's
45
+ * title and transcript can change after it's recorded, so each record is
46
+ * observed as of the library it was read from. An unchanged library gives
47
+ * the same time on every run, so its records come out the same.
48
+ */
49
+ protected asOfTime(): Date;
50
+ /** The recording's UUID: the same in the library and in the audio file. */
51
+ keyOf(record: Record): string | null;
52
+ occurredAt(record: Record): Date | undefined;
53
+ private query;
54
+ extract(): AsyncGenerator<Record>;
55
+ private memoOf;
56
+ private readFile;
57
+ private parse;
58
+ /**
59
+ * Say when files or transcripts didn't read. The memos are kept, but a
60
+ * format Voice Memos changed would otherwise only show in the log.
61
+ */
62
+ private hintUnreadable;
63
+ determineCount(): Promise<number | null>;
64
+ }
65
+ //# sourceMappingURL=AppleVoiceMemosExtractor.d.ts.map
@@ -0,0 +1,190 @@
1
+ import { statSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, getRow, iterateRows, timeRangeConditions, unixToSafariTimestamp, } from '@chronicle.app/etl-sqlite';
4
+ import { getICloudAccount } from '@chronicle.app/icloud';
5
+ import { z } from 'zod';
6
+ import { readRecordingMetadata } from '../mp4.js';
7
+ import { parseTranscript } from '../transcript.js';
8
+ import AppleVoiceMemosTransformer from './AppleVoiceMemosTransformer.js';
9
+ const DEFAULT_DB = `${process.env.HOME}/Library/Group Containers/group.com.apple.VoiceMemos.shared/Recordings/CloudRecordings.db`;
10
+ // since/until bounds land on ZDATE, which is seconds since the Apple epoch.
11
+ const appleBound = (date) => unixToSafariTimestamp(date.getTime() / 1000);
12
+ // A memo moved to Recently Deleted has an eviction date. It's left out.
13
+ const NOT_DELETED = 'ZEVICTIONDATE IS NULL';
14
+ /**
15
+ * The Voice Memos library. It syncs through iCloud, so it holds the
16
+ * recordings made on every device signed in to the account. Their audio
17
+ * files sit beside the database.
18
+ */
19
+ export class AppleVoiceMemosExtractor extends SqliteExtractor {
20
+ static source = 'apple-voice-memos';
21
+ static description = 'Your Voice Memos recordings and their transcripts';
22
+ static delivery = 'local';
23
+ static strategy = 'app-db';
24
+ static recordTypes = ['recordings'];
25
+ static default = true;
26
+ static defaultTransformer = AppleVoiceMemosTransformer;
27
+ static newestFirst = true;
28
+ static schema = SqliteExtractor.schema.omit({ input: true }).extend({
29
+ input: z.string().describe('Path to CloudRecordings.db').default(DEFAULT_DB),
30
+ account: z
31
+ .object({
32
+ accountID: z.string(),
33
+ email: z.string(),
34
+ displayName: z.string(),
35
+ dsid: z.string().optional(),
36
+ })
37
+ .nullable()
38
+ .optional(),
39
+ });
40
+ libraryChanged = null;
41
+ // Files and transcripts that didn't read this run, for the run's summary.
42
+ unreadable = { files: 0, transcripts: 0 };
43
+ async setup() {
44
+ await super.setup();
45
+ this.libraryChanged = lastChanged(this.config.input);
46
+ }
47
+ /**
48
+ * When the library last changed, not when the run started. A recording's
49
+ * title and transcript can change after it's recorded, so each record is
50
+ * observed as of the library it was read from. An unchanged library gives
51
+ * the same time on every run, so its records come out the same.
52
+ */
53
+ asOfTime() {
54
+ return this.libraryChanged ?? super.asOfTime();
55
+ }
56
+ /** The recording's UUID: the same in the library and in the audio file. */
57
+ keyOf(record) {
58
+ return record.data.uniqueID ?? null;
59
+ }
60
+ occurredAt(record) {
61
+ return new Date(record.data.date);
62
+ }
63
+ query(select) {
64
+ const range = timeRangeConditions('ZDATE', this.config, { convert: appleBound });
65
+ const where = [
66
+ 'ZUNIQUEID IS NOT NULL',
67
+ 'ZDATE IS NOT NULL',
68
+ NOT_DELETED,
69
+ ...range.conditions,
70
+ ].join(' AND ');
71
+ return { sql: `SELECT ${select} FROM ZCLOUDRECORDING WHERE ${where}`, values: range.values };
72
+ }
73
+ async *extract() {
74
+ if (!this.db)
75
+ throw new Error('Database not initialized');
76
+ const { sql, values } = this.query(`
77
+ ZUNIQUEID AS uniqueID, ZDATE AS date, ZDURATION AS duration,
78
+ ZENCRYPTEDTITLE AS title, ZPATH AS path, ZFLAGS AS flags`);
79
+ let ordered = `${sql} ORDER BY ZDATE DESC`;
80
+ const limit = this.getEffectiveLimit();
81
+ if (limit !== null) {
82
+ ordered += ' LIMIT ?';
83
+ values.push(limit);
84
+ }
85
+ const account = this.config.account === undefined ? await getICloudAccount() : this.config.account;
86
+ const folder = dirname(resolve(this.config.input));
87
+ this.unreadable = { files: 0, transcripts: 0 };
88
+ for (const row of iterateRows(this.db.prepare(ordered), ...values)) {
89
+ yield this.createRecord(this.memoOf(row, folder), { account }, { assertedAt: this.asOfTime() });
90
+ }
91
+ this.hintUnreadable();
92
+ }
93
+ memoOf(row, folder) {
94
+ const file = row.path ? onDisk(join(folder, row.path)) : null;
95
+ const metadata = file ? this.readFile(file) : null;
96
+ return {
97
+ uniqueID: row.uniqueID,
98
+ date: appleDate(row.date),
99
+ duration: row.duration ?? null,
100
+ title: row.title ?? null,
101
+ path: row.path ?? null,
102
+ flags: row.flags ?? 0,
103
+ file,
104
+ encoder: metadata?.encoder ?? null,
105
+ transcript: metadata?.transcript ? this.parse(metadata.transcript) : null,
106
+ };
107
+ }
108
+ // A file that doesn't read loses only its tags and transcript, not the memo.
109
+ readFile(file) {
110
+ try {
111
+ return readRecordingMetadata(file);
112
+ }
113
+ catch (error) {
114
+ this.logger.warn(`Can't read a voice memo's audio file: ${error.message}`);
115
+ this.unreadable.files++;
116
+ return null;
117
+ }
118
+ }
119
+ parse(json) {
120
+ try {
121
+ return parseTranscript(json);
122
+ }
123
+ catch (error) {
124
+ this.logger.warn(`Can't read a voice memo's transcript: ${error.message}`);
125
+ this.unreadable.transcripts++;
126
+ return null;
127
+ }
128
+ }
129
+ /**
130
+ * Say when files or transcripts didn't read. The memos are kept, but a
131
+ * format Voice Memos changed would otherwise only show in the log.
132
+ */
133
+ hintUnreadable() {
134
+ const { files, transcripts } = this.unreadable;
135
+ const action = 'Voice Memos may have changed how it stores recordings. Update Chronicle, and report it if that doesn’t help.';
136
+ if (files > 0) {
137
+ const memos = files === 1
138
+ ? '1 audio file couldn’t be read, so its memo has'
139
+ : `${files} audio files couldn’t be read, so their memos have`;
140
+ this.hint(`${memos} no transcript or device`, { action });
141
+ }
142
+ if (transcripts > 0) {
143
+ const memos = transcripts === 1
144
+ ? '1 transcript couldn’t be read, so its memo has'
145
+ : `${transcripts} transcripts couldn’t be read, so their memos have`;
146
+ this.hint(`${memos} none`, { action });
147
+ }
148
+ }
149
+ async determineCount() {
150
+ if (!this.db)
151
+ return null;
152
+ try {
153
+ const { sql, values } = this.query('COUNT(*) AS n');
154
+ return getRow(this.db.prepare(sql), ...values)?.n ?? null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ }
161
+ /**
162
+ * The file, when it's on this Mac. A recording iCloud hasn't downloaded has
163
+ * no file, or an empty one.
164
+ */
165
+ function onDisk(file) {
166
+ try {
167
+ return statSync(file).size > 0 ? file : null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ /**
174
+ * When the library last changed: the database, or its write-ahead log, which
175
+ * holds changes Core Data hasn't yet written back.
176
+ */
177
+ function lastChanged(database) {
178
+ const times = [database, `${database}-wal`].flatMap(file => {
179
+ try {
180
+ return [statSync(file).mtime.getTime()];
181
+ }
182
+ catch {
183
+ return [];
184
+ }
185
+ });
186
+ return new Date(Math.max(...times));
187
+ }
188
+ function appleDate(seconds) {
189
+ return new Date((seconds + APPLE_EPOCH_OFFSET_SECONDS) * 1000).toISOString();
190
+ }
@@ -0,0 +1,13 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ /**
4
+ * RecordAction agent: you timestamp (when recording started)
5
+ * instrument?: OperatingSystem (the release the file names, as written)
6
+ * ProductModel? (the kind of device, by its Wikidata ID)
7
+ * result: AudioObject (the recording, by its UUID) transcript?
8
+ */
9
+ export default class AppleVoiceMemosTransformer extends ChronicleTransformer {
10
+ static source: string;
11
+ transform(record: Record): Promise<ActionAndChildren[]>;
12
+ }
13
+ //# sourceMappingURL=AppleVoiceMemosTransformer.d.ts.map
@@ -0,0 +1,95 @@
1
+ import { extname } from 'node:path';
2
+ import { ChronicleTransformer } from '@chronicle.app/etl';
3
+ import { buildICloudPersonSchema } from '@chronicle.app/icloud';
4
+ import { readEncoder } from '../encoder.js';
5
+ const SOURCE = 'apple-voice-memos';
6
+ // What each kind of recording file is. `.qta` is a QuickTime audio movie,
7
+ // which macOS declares as `audio/x-quicktime`.
8
+ const MIME_TYPES = {
9
+ '.m4a': 'audio/mp4',
10
+ '.qta': 'audio/x-quicktime',
11
+ };
12
+ /**
13
+ * RecordAction agent: you timestamp (when recording started)
14
+ * instrument?: OperatingSystem (the release the file names, as written)
15
+ * ProductModel? (the kind of device, by its Wikidata ID)
16
+ * result: AudioObject (the recording, by its UUID) transcript?
17
+ */
18
+ export default class AppleVoiceMemosTransformer extends ChronicleTransformer {
19
+ static source = SOURCE;
20
+ async transform(record) {
21
+ if (record.extraction.recordType !== 'recordings')
22
+ return [];
23
+ const memo = record.data;
24
+ // Without an iCloud account the recorder can't be identified, so omit them.
25
+ const recorder = await buildICloudPersonSchema(record.context.account);
26
+ const instrument = buildInstrument(memo);
27
+ // Voice Memos keeps when recording started but not when it stopped: a
28
+ // memo can be paused, resumed later, or trimmed, so the start plus the
29
+ // recording's duration isn't always the end.
30
+ const action = {
31
+ '@type': 'RecordAction',
32
+ '@key': ['@type', 'source', 'sourceId'],
33
+ source: SOURCE,
34
+ sourceId: memo.uniqueID,
35
+ timestamp: new Date(memo.date),
36
+ ...(recorder && { agent: recorder }),
37
+ ...(instrument.length > 0 && { instrument }),
38
+ result: buildRecording(memo),
39
+ };
40
+ return [action];
41
+ }
42
+ }
43
+ /**
44
+ * What the recording was made on, from its file's encoder tag: the OS release,
45
+ * as the tag writes it, and the kind of device when the tag names one. The
46
+ * tag never says which device. A tag that doesn't name an OS release, or that
47
+ * iOS 13 left when it rewrote an older file, gives none.
48
+ */
49
+ function buildInstrument(memo) {
50
+ const recorder = memo.encoder ? readEncoder(memo.encoder, new Date(memo.date)) : null;
51
+ if (!recorder)
52
+ return [];
53
+ const release = {
54
+ '@type': 'OperatingSystem',
55
+ '@key': ['@type', 'source', 'handle'],
56
+ source: SOURCE,
57
+ handle: recorder.release,
58
+ };
59
+ if (!recorder.line)
60
+ return [release];
61
+ const { wikidataId, name } = recorder.line;
62
+ return [
63
+ release,
64
+ // Keyed like an ISBN: by the ID alone, so anything naming the same item
65
+ // merges with it. Its page's address, as a browser records it, joins a
66
+ // visit to that page.
67
+ {
68
+ '@type': 'ProductModel',
69
+ '@key': ['source', 'sourceId'],
70
+ source: 'wikidata',
71
+ sourceId: wikidataId,
72
+ name,
73
+ url: `https://www.wikidata.org/wiki/${wikidataId}`,
74
+ },
75
+ ];
76
+ }
77
+ /**
78
+ * The recording, keyed by its UUID, with its file when it's on this Mac and
79
+ * the transcript Voice Memos made, unless it heard no speech.
80
+ */
81
+ function buildRecording(memo) {
82
+ const mimeType = memo.file ? MIME_TYPES[extname(memo.file).toLowerCase()] : undefined;
83
+ const transcript = memo.transcript?.text.trim();
84
+ return {
85
+ '@type': 'AudioObject',
86
+ '@key': ['@type', 'source', 'sourceId'],
87
+ source: SOURCE,
88
+ sourceId: memo.uniqueID,
89
+ ...(memo.title && { name: memo.title }),
90
+ ...(memo.duration !== null && { duration: `PT${Math.floor(memo.duration)}S` }),
91
+ ...(memo.file && { contentPath: memo.file }),
92
+ ...(mimeType && { mimeType }),
93
+ ...(transcript && { transcript }),
94
+ };
95
+ }
@@ -0,0 +1,21 @@
1
+ /** What a recording's encoder tag says about where it was recorded. */
2
+ export interface Recorder {
3
+ /** The OS release, as the tag inside `com.apple.VoiceMemos (…)` writes it, such as `Watch Version 11.5 (Build 22T572)`. */
4
+ release: string;
5
+ /** The kind of device, when the tag names one. */
6
+ line: ProductLine | null;
7
+ }
8
+ export interface ProductLine {
9
+ /** Its Wikidata ID, such as `Q2766`, which other sources can name too. */
10
+ wikidataId: string;
11
+ name: string;
12
+ }
13
+ /**
14
+ * Read a recording's encoder tag, such as
15
+ * `com.apple.VoiceMemos (Watch Version 11.5 (Build 22T572))`. The tag names
16
+ * the OS release that wrote the file, and sometimes the kind of device, but
17
+ * never which device. `null` when it isn't Voice Memos' tag, when it doesn't
18
+ * name an OS release, or when it's from iOS 13's rewrite of an older file.
19
+ */
20
+ export declare function readEncoder(tag: string, recorded: Date): Recorder | null;
21
+ //# sourceMappingURL=encoder.d.ts.map
@@ -0,0 +1,54 @@
1
+ const LINES = {
2
+ iPhone: { wikidataId: 'Q2766', name: 'iPhone' },
3
+ Watch: { wikidataId: 'Q18010946', name: 'Apple Watch' },
4
+ Mac: { wikidataId: 'Q75687', name: 'Mac' },
5
+ };
6
+ // The tag's forms. `<device> Version <version> (Build <build>)` is the
7
+ // current one, and `iOS <version>` and `Mac OS X <version>` are older ones.
8
+ const TAG = /^com\.apple\.VoiceMemos \((.+)\)$/;
9
+ const VERSIONED = /^(iPhone|iPad|Watch) Version \d/;
10
+ const IOS = /^iOS (\d+)/;
11
+ const MAC_OS_X = /^Mac OS X (10\.\d+)/;
12
+ // When each release that wrote an older form came out. iOS 13 rewrote older
13
+ // recordings with its own tag, so an older-form tag naming a release that
14
+ // came out after the recording was made is from that rewrite. Voice Memos
15
+ // stopped writing these forms in 2020, so the list doesn't grow.
16
+ const OLDER_FORM_RELEASED = {
17
+ 'iOS 12': '2018-09-17',
18
+ 'iOS 13': '2019-09-19',
19
+ 'Mac OS X 10.14': '2018-09-24',
20
+ 'Mac OS X 10.15': '2019-10-07',
21
+ };
22
+ /**
23
+ * Read a recording's encoder tag, such as
24
+ * `com.apple.VoiceMemos (Watch Version 11.5 (Build 22T572))`. The tag names
25
+ * the OS release that wrote the file, and sometimes the kind of device, but
26
+ * never which device. `null` when it isn't Voice Memos' tag, when it doesn't
27
+ * name an OS release, or when it's from iOS 13's rewrite of an older file.
28
+ */
29
+ export function readEncoder(tag, recorded) {
30
+ const release = TAG.exec(tag)?.[1];
31
+ if (!release)
32
+ return null;
33
+ const versioned = VERSIONED.exec(release);
34
+ if (versioned) {
35
+ // `iPad` names no kind of device: Voice Memos on a Mac is the iPad app, so
36
+ // a Mac's tag says `iPad` too.
37
+ const device = versioned[1];
38
+ return { release, line: device === 'iPad' ? null : LINES[device] };
39
+ }
40
+ const ios = IOS.exec(release);
41
+ const macOSX = MAC_OS_X.exec(release);
42
+ let older;
43
+ if (ios)
44
+ older = `iOS ${ios[1]}`;
45
+ else if (macOSX)
46
+ older = `Mac OS X ${macOSX[1]}`;
47
+ else
48
+ return null;
49
+ const released = OLDER_FORM_RELEASED[older];
50
+ if (released && recorded < new Date(released))
51
+ return null;
52
+ // `iOS 13.1.2` doesn't say whether it was an iPhone or an iPad.
53
+ return { release, line: macOSX ? LINES.Mac : null };
54
+ }
@@ -0,0 +1,3 @@
1
+ export { AppleVoiceMemosExtractor } from './connectors/AppleVoiceMemosExtractor.js';
2
+ export { default as AppleVoiceMemosTransformer } from './connectors/AppleVoiceMemosTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // Export the extractor and transformer for plugin scanning
2
+ export { AppleVoiceMemosExtractor } from './connectors/AppleVoiceMemosExtractor.js';
3
+ export { default as AppleVoiceMemosTransformer } from './connectors/AppleVoiceMemosTransformer.js';
package/dist/mp4.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** What Voice Memos writes into a recording's file besides the audio. */
2
+ export interface RecordingMetadata {
3
+ /** The `©too` tag: the app, and the kind of device and OS version that last wrote the file. */
4
+ encoder: string | null;
5
+ /** The transcript's JSON, as Voice Memos stores it. */
6
+ transcript: string | null;
7
+ }
8
+ /**
9
+ * Read the encoder tag and the transcript from a recording. Both formats are
10
+ * QuickTime movies: an `.m4a` keeps its transcript in a track's `udta/tsrp`
11
+ * box, and a `.qta` in a track's metadata under `com.apple.VoiceMemos.tsrp`.
12
+ * Only the movie header (`moov`) is read, not the audio. Null when the file is
13
+ * missing, empty, or has no movie header.
14
+ */
15
+ export declare function readRecordingMetadata(path: string): RecordingMetadata | null;
16
+ //# sourceMappingURL=mp4.d.ts.map
package/dist/mp4.js ADDED
@@ -0,0 +1,172 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ // The metadata key a `.qta` file stores its transcript under.
3
+ const TRANSCRIPT_KEY = 'com.apple.VoiceMemos.tsrp';
4
+ // A movie header far larger than any recording's is a damaged file.
5
+ const MAX_MOOV = 64 * 1024 * 1024;
6
+ /**
7
+ * Read the encoder tag and the transcript from a recording. Both formats are
8
+ * QuickTime movies: an `.m4a` keeps its transcript in a track's `udta/tsrp`
9
+ * box, and a `.qta` in a track's metadata under `com.apple.VoiceMemos.tsrp`.
10
+ * Only the movie header (`moov`) is read, not the audio. Null when the file is
11
+ * missing, empty, or has no movie header.
12
+ */
13
+ export function readRecordingMetadata(path) {
14
+ const moov = readMoov(path);
15
+ if (!moov)
16
+ return null;
17
+ return { encoder: encoderOf(moov), transcript: transcriptOf(moov) };
18
+ }
19
+ /** The `moov` box's contents, found by reading only the top-level box headers. */
20
+ function readMoov(path) {
21
+ let fd;
22
+ try {
23
+ fd = openSync(path, 'r');
24
+ }
25
+ catch (error) {
26
+ if (error.code === 'ENOENT')
27
+ return null;
28
+ throw error;
29
+ }
30
+ try {
31
+ const length = fstatSync(fd).size;
32
+ const header = Buffer.alloc(16);
33
+ let at = 0;
34
+ while (at + 8 <= length) {
35
+ readSync(fd, header, 0, 16, at);
36
+ const box = boxAt(header, 0, length - at);
37
+ if (!box)
38
+ throw new Error(`Damaged box at byte ${at}`);
39
+ if (box.type === 'moov') {
40
+ const size = box.end - box.start;
41
+ if (size > MAX_MOOV)
42
+ throw new Error(`Movie header too large (${size} bytes)`);
43
+ const moov = Buffer.alloc(size);
44
+ readSync(fd, moov, 0, size, at + box.start);
45
+ return moov;
46
+ }
47
+ at += box.end;
48
+ }
49
+ return null;
50
+ }
51
+ finally {
52
+ closeSync(fd);
53
+ }
54
+ }
55
+ /**
56
+ * The box at `at`, with offsets relative to `buf`. A 32-bit size of 1 means a
57
+ * 64-bit size follows the type, and 0 means the box runs to `end`. Null when
58
+ * the header doesn't fit.
59
+ */
60
+ function boxAt(buf, at, end) {
61
+ if (at + 8 > end)
62
+ return null;
63
+ let size = buf.readUInt32BE(at);
64
+ const type = buf.toString('latin1', at + 4, at + 8);
65
+ let header = 8;
66
+ if (size === 1) {
67
+ if (at + 16 > buf.length)
68
+ return null;
69
+ size = Number(buf.readBigUInt64BE(at + 8));
70
+ header = 16;
71
+ }
72
+ else if (size === 0) {
73
+ size = end - at;
74
+ }
75
+ if (size < header || at + size > end)
76
+ return null;
77
+ return { type, offset: at, start: at + header, end: at + size };
78
+ }
79
+ /** The boxes inside `start`..`end`, optionally only those of one type. */
80
+ function childrenOf(buf, start, end, type) {
81
+ const boxes = [];
82
+ for (let at = start; at < end;) {
83
+ const box = boxAt(buf, at, end);
84
+ if (!box)
85
+ break;
86
+ if (!type || box.type === type)
87
+ boxes.push(box);
88
+ at = box.end;
89
+ }
90
+ return boxes;
91
+ }
92
+ /**
93
+ * Where a `meta` box's children start. An ISO `meta` box opens with a version
94
+ * and flags word, which is zero; a QuickTime one starts with its first child,
95
+ * whose size isn't.
96
+ */
97
+ function metaChildrenStart(buf, meta) {
98
+ return meta.end - meta.start >= 4 && buf.readUInt32BE(meta.start) === 0
99
+ ? meta.start + 4
100
+ : meta.start;
101
+ }
102
+ /** A metadata item's value: its `data` box, after the type and locale words. */
103
+ function dataOf(buf, item) {
104
+ const data = childrenOf(buf, item.start, item.end, 'data')[0];
105
+ if (!data || data.end - data.start < 8)
106
+ return null;
107
+ return buf.subarray(data.start + 8, data.end);
108
+ }
109
+ /** The `©too` tag in the movie's `udta/meta/ilst`. */
110
+ function encoderOf(moov) {
111
+ for (const udta of childrenOf(moov, 0, moov.length, 'udta')) {
112
+ for (const meta of childrenOf(moov, udta.start, udta.end, 'meta')) {
113
+ for (const ilst of childrenOf(moov, metaChildrenStart(moov, meta), meta.end, 'ilst')) {
114
+ for (const item of childrenOf(moov, ilst.start, ilst.end, '©too')) {
115
+ const value = dataOf(moov, item);
116
+ if (value)
117
+ return value.toString('utf8');
118
+ }
119
+ }
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ /** The transcript JSON from the first track that has one. */
125
+ function transcriptOf(moov) {
126
+ for (const trak of childrenOf(moov, 0, moov.length, 'trak')) {
127
+ for (const udta of childrenOf(moov, trak.start, trak.end, 'udta')) {
128
+ const tsrp = childrenOf(moov, udta.start, udta.end, 'tsrp')[0];
129
+ if (tsrp)
130
+ return moov.toString('utf8', tsrp.start, tsrp.end);
131
+ }
132
+ for (const meta of childrenOf(moov, trak.start, trak.end, 'meta')) {
133
+ const value = mdtaValue(moov, meta, TRANSCRIPT_KEY);
134
+ if (value)
135
+ return value.toString('utf8');
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+ /**
141
+ * The value of a QuickTime metadata key. The `keys` box lists the keys, and
142
+ * each `ilst` item's type is the 1-based index of its key.
143
+ */
144
+ function mdtaValue(buf, meta, key) {
145
+ const start = metaChildrenStart(buf, meta);
146
+ const keys = childrenOf(buf, start, meta.end, 'keys')[0];
147
+ const ilst = childrenOf(buf, start, meta.end, 'ilst')[0];
148
+ if (!keys || !ilst || keys.end - keys.start < 8)
149
+ return null;
150
+ // After the version and flags word, a count, then each key's size,
151
+ // namespace, and name.
152
+ const count = buf.readUInt32BE(keys.start + 4);
153
+ let index = 0;
154
+ for (let i = 1, at = keys.start + 8; i <= count && at + 8 <= keys.end; i++) {
155
+ const size = buf.readUInt32BE(at);
156
+ if (size < 8 || at + size > keys.end)
157
+ break;
158
+ const namespace = buf.toString('latin1', at + 4, at + 8);
159
+ if (namespace === 'mdta' && buf.toString('utf8', at + 8, at + size) === key) {
160
+ index = i;
161
+ break;
162
+ }
163
+ at += size;
164
+ }
165
+ if (!index)
166
+ return null;
167
+ for (const item of childrenOf(buf, ilst.start, ilst.end)) {
168
+ if (buf.readUInt32BE(item.offset + 4) === index)
169
+ return dataOf(buf, item);
170
+ }
171
+ return null;
172
+ }
@@ -0,0 +1,23 @@
1
+ /** A transcript Voice Memos made of a recording. */
2
+ export interface Transcript {
3
+ /** The text: every run joined. Empty when Voice Memos heard no speech. */
4
+ text: string;
5
+ /** The locale it was transcribed in, such as `en_CA`. */
6
+ locale: string | null;
7
+ /** Each run with when it was spoken, in seconds from the start of the recording. */
8
+ segments: TranscriptSegment[];
9
+ }
10
+ export interface TranscriptSegment {
11
+ text: string;
12
+ start: number;
13
+ end: number;
14
+ }
15
+ /**
16
+ * Parse the transcript JSON Voice Memos stores in a recording. Its
17
+ * `attributedString` is a run of text, each followed by its attributes: either
18
+ * `{runs: [text, i, …], attributeTable: [attributes, …]}`, where `i` indexes
19
+ * the table, or `[text, attributes, …]` with the attributes inline. A
20
+ * recording with no speech has `""`. Throws on any other shape.
21
+ */
22
+ export declare function parseTranscript(json: string): Transcript;
23
+ //# sourceMappingURL=transcript.d.ts.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Parse the transcript JSON Voice Memos stores in a recording. Its
3
+ * `attributedString` is a run of text, each followed by its attributes: either
4
+ * `{runs: [text, i, …], attributeTable: [attributes, …]}`, where `i` indexes
5
+ * the table, or `[text, attributes, …]` with the attributes inline. A
6
+ * recording with no speech has `""`. Throws on any other shape.
7
+ */
8
+ export function parseTranscript(json) {
9
+ const parsed = JSON.parse(json);
10
+ const locale = localeOf(parsed.locale);
11
+ const string = parsed.attributedString;
12
+ if (typeof string === 'string')
13
+ return { text: string, locale, segments: [] };
14
+ let pairs;
15
+ if (Array.isArray(string)) {
16
+ pairs = alternate(string).map(([text, attributes]) => [text, attributes]);
17
+ }
18
+ else if (isObject(string) && Array.isArray(string.runs)) {
19
+ const table = Array.isArray(string.attributeTable) ? string.attributeTable : [];
20
+ pairs = alternate(string.runs).map(([text, index]) => [
21
+ text,
22
+ typeof index === 'number' ? table[index] : undefined,
23
+ ]);
24
+ }
25
+ else {
26
+ throw new Error('Unknown transcript shape');
27
+ }
28
+ let text = '';
29
+ const segments = [];
30
+ for (const [run, attributes] of pairs) {
31
+ if (typeof run !== 'string')
32
+ throw new Error('Unknown transcript shape');
33
+ text += run;
34
+ const range = attributes?.timeRange;
35
+ if (Array.isArray(range) && typeof range[0] === 'number' && typeof range[1] === 'number') {
36
+ segments.push({ text: run, start: range[0], end: range[1] });
37
+ }
38
+ }
39
+ return { text, locale, segments };
40
+ }
41
+ /** `[a, b, c, d]` as `[[a, b], [c, d]]`. */
42
+ function alternate(items) {
43
+ const pairs = [];
44
+ for (let i = 0; i < items.length; i += 2)
45
+ pairs.push([items[i], items[i + 1]]);
46
+ return pairs;
47
+ }
48
+ function localeOf(locale) {
49
+ if (!isObject(locale))
50
+ return null;
51
+ return typeof locale.identifier === 'string' ? locale.identifier : null;
52
+ }
53
+ function isObject(value) {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
55
+ }
package/package.json CHANGED
@@ -1,6 +1,80 @@
1
1
  {
2
2
  "name": "@chronicle.app/apple-voice-memos",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js",
15
+ "shapes": "node src/shapes.test.js --update"
16
+ },
17
+ "keywords": [
18
+ "chronicle",
19
+ "apple-voice-memos",
20
+ "audio"
21
+ ],
22
+ "author": "Andrew Louis",
23
+ "license": "MIT",
24
+ "type": "module",
25
+ "description": "Chronicle plugin for your Voice Memos recordings and their transcripts, from the library macOS keeps in step with iCloud",
26
+ "devDependencies": {
27
+ "@chronicle.app/etl": "0.4.0",
28
+ "@chronicle.app/schema": "0.4.0",
29
+ "@chronicle.app/tsconfig": "0.4.0",
30
+ "@types/node": "^22.10.1",
31
+ "typescript": "^5.7.2"
32
+ },
33
+ "homepage": "https://chronicle.app",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/chronicle-app/chronicle.git",
37
+ "directory": "plugins/apple-voice-memos"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.org/"
42
+ },
43
+ "dependencies": {
44
+ "@chronicle.app/etl-sqlite": "0.4.0",
45
+ "@chronicle.app/icloud": "0.4.0",
46
+ "zod": "^3.24.1"
47
+ },
48
+ "peerDependencies": {
49
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
50
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=22.13.0"
54
+ },
55
+ "exports": {
56
+ ".": {
57
+ "types": "./dist/index.d.ts",
58
+ "import": "./dist/index.js"
59
+ }
60
+ },
61
+ "chronicle": {
62
+ "plugin": true,
63
+ "sources": {
64
+ "apple-voice-memos": {
65
+ "strategies": {
66
+ "app-db": {
67
+ "delivery": "local",
68
+ "recordTypes": [
69
+ "recordings"
70
+ ],
71
+ "default": true
72
+ }
73
+ },
74
+ "platforms": [
75
+ "darwin"
76
+ ]
77
+ }
78
+ }
79
+ }
80
+ }