@chronicle.app/apple-voice-memos 0.0.0-stage → 0.5.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 +21 -0
- package/README.md +58 -2
- package/dist/connectors/AppleVoiceMemosExtractor.d.ts +72 -0
- package/dist/connectors/AppleVoiceMemosExtractor.js +228 -0
- package/dist/connectors/AppleVoiceMemosTransformer.d.ts +13 -0
- package/dist/connectors/AppleVoiceMemosTransformer.js +95 -0
- package/dist/encoder.d.ts +21 -0
- package/dist/encoder.js +54 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mp4.d.ts +16 -0
- package/dist/mp4.js +172 -0
- package/dist/transcript.d.ts +23 -0
- package/dist/transcript.js +55 -0
- package/package.json +101 -4
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
|
-
#
|
|
1
|
+
# @chronicle.app/apple-voice-memos
|
|
2
2
|
|
|
3
|
-
|
|
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`, `result.sourceId` (the recording it made) | `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/apple`. 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,72 @@
|
|
|
1
|
+
import { Record, type Survey, type SurveyOptions } 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 databases: {
|
|
40
|
+
tables: {
|
|
41
|
+
ZCLOUDRECORDING: string[];
|
|
42
|
+
};
|
|
43
|
+
}[];
|
|
44
|
+
static schema: any;
|
|
45
|
+
private libraryChanged;
|
|
46
|
+
private unreadable;
|
|
47
|
+
setup(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* When the library last changed, not when the run started. A recording's
|
|
50
|
+
* title and transcript can change after it's recorded, so each record is
|
|
51
|
+
* observed as of the library it was read from. An unchanged library gives
|
|
52
|
+
* the same time on every run, so its records come out the same.
|
|
53
|
+
*/
|
|
54
|
+
protected asOfTime(): Date;
|
|
55
|
+
/** The recording's UUID: the same in the library and in the audio file. */
|
|
56
|
+
keyOf(record: Record): string | null;
|
|
57
|
+
occurredAt(record: Record): Date | undefined;
|
|
58
|
+
private query;
|
|
59
|
+
extract(): AsyncGenerator<Record>;
|
|
60
|
+
private memoOf;
|
|
61
|
+
private readFile;
|
|
62
|
+
private parse;
|
|
63
|
+
/**
|
|
64
|
+
* Say when files or transcripts didn't read. The memos are kept, but a
|
|
65
|
+
* format Voice Memos changed would otherwise only show in the log.
|
|
66
|
+
*/
|
|
67
|
+
private hintUnreadable;
|
|
68
|
+
determineCount(): Promise<number | null>;
|
|
69
|
+
/** The recordings a run reads and when they started, with how long they last, to the second. */
|
|
70
|
+
survey(options?: SurveyOptions): Promise<Survey | null>;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=AppleVoiceMemosExtractor.d.ts.map
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, allRows, countsByMonth, getRow, iterateRows, monthSql, timeRangeConditions, unixToSafariTimestamp, } from '@chronicle.app/etl-sqlite';
|
|
4
|
+
import { getICloudAccount } from '@chronicle.app/apple';
|
|
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
|
+
// What extract() and survey() read, which a CloudRecordings.db must have.
|
|
29
|
+
static databases = [
|
|
30
|
+
{
|
|
31
|
+
tables: {
|
|
32
|
+
ZCLOUDRECORDING: [
|
|
33
|
+
'ZUNIQUEID',
|
|
34
|
+
'ZDATE',
|
|
35
|
+
'ZDURATION',
|
|
36
|
+
'ZENCRYPTEDTITLE',
|
|
37
|
+
'ZPATH',
|
|
38
|
+
'ZFLAGS',
|
|
39
|
+
'ZEVICTIONDATE',
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
static schema = SqliteExtractor.schema.omit({ input: true }).extend({
|
|
45
|
+
input: z.string().describe('Path to CloudRecordings.db').default(DEFAULT_DB),
|
|
46
|
+
account: z
|
|
47
|
+
.object({
|
|
48
|
+
accountID: z.string(),
|
|
49
|
+
email: z.string(),
|
|
50
|
+
displayName: z.string(),
|
|
51
|
+
dsid: z.string().optional(),
|
|
52
|
+
})
|
|
53
|
+
.nullable()
|
|
54
|
+
.optional(),
|
|
55
|
+
});
|
|
56
|
+
libraryChanged = null;
|
|
57
|
+
// Files and transcripts that didn't read this run, for the run's summary.
|
|
58
|
+
unreadable = { files: 0, transcripts: 0 };
|
|
59
|
+
async setup() {
|
|
60
|
+
await super.setup();
|
|
61
|
+
this.libraryChanged = lastChanged(this.config.input);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* When the library last changed, not when the run started. A recording's
|
|
65
|
+
* title and transcript can change after it's recorded, so each record is
|
|
66
|
+
* observed as of the library it was read from. An unchanged library gives
|
|
67
|
+
* the same time on every run, so its records come out the same.
|
|
68
|
+
*/
|
|
69
|
+
asOfTime() {
|
|
70
|
+
return this.libraryChanged ?? super.asOfTime();
|
|
71
|
+
}
|
|
72
|
+
/** The recording's UUID: the same in the library and in the audio file. */
|
|
73
|
+
keyOf(record) {
|
|
74
|
+
return record.data.uniqueID ?? null;
|
|
75
|
+
}
|
|
76
|
+
occurredAt(record) {
|
|
77
|
+
return new Date(record.data.date);
|
|
78
|
+
}
|
|
79
|
+
query(select) {
|
|
80
|
+
const range = timeRangeConditions('ZDATE', this.config, { convert: appleBound });
|
|
81
|
+
const where = [
|
|
82
|
+
'ZUNIQUEID IS NOT NULL',
|
|
83
|
+
'ZDATE IS NOT NULL',
|
|
84
|
+
NOT_DELETED,
|
|
85
|
+
...range.conditions,
|
|
86
|
+
].join(' AND ');
|
|
87
|
+
return { sql: `SELECT ${select} FROM ZCLOUDRECORDING WHERE ${where}`, values: range.values };
|
|
88
|
+
}
|
|
89
|
+
async *extract() {
|
|
90
|
+
if (!this.db)
|
|
91
|
+
throw new Error('Database not initialized');
|
|
92
|
+
const { sql, values } = this.query(`
|
|
93
|
+
ZUNIQUEID AS uniqueID, ZDATE AS date, ZDURATION AS duration,
|
|
94
|
+
ZENCRYPTEDTITLE AS title, ZPATH AS path, ZFLAGS AS flags`);
|
|
95
|
+
let ordered = `${sql} ORDER BY ZDATE DESC`;
|
|
96
|
+
const limit = this.getEffectiveLimit();
|
|
97
|
+
if (limit !== null) {
|
|
98
|
+
ordered += ' LIMIT ?';
|
|
99
|
+
values.push(limit);
|
|
100
|
+
}
|
|
101
|
+
const account = this.config.account === undefined ? await getICloudAccount() : this.config.account;
|
|
102
|
+
const folder = dirname(resolve(this.config.input));
|
|
103
|
+
this.unreadable = { files: 0, transcripts: 0 };
|
|
104
|
+
for (const row of iterateRows(this.db.prepare(ordered), ...values)) {
|
|
105
|
+
yield this.createRecord(this.memoOf(row, folder), { account }, { assertedAt: this.asOfTime() });
|
|
106
|
+
}
|
|
107
|
+
this.hintUnreadable();
|
|
108
|
+
}
|
|
109
|
+
memoOf(row, folder) {
|
|
110
|
+
const file = row.path ? onDisk(join(folder, row.path)) : null;
|
|
111
|
+
const metadata = file ? this.readFile(file) : null;
|
|
112
|
+
return {
|
|
113
|
+
uniqueID: row.uniqueID,
|
|
114
|
+
date: appleDate(row.date),
|
|
115
|
+
duration: row.duration ?? null,
|
|
116
|
+
title: row.title ?? null,
|
|
117
|
+
path: row.path ?? null,
|
|
118
|
+
flags: row.flags ?? 0,
|
|
119
|
+
file,
|
|
120
|
+
encoder: metadata?.encoder ?? null,
|
|
121
|
+
transcript: metadata?.transcript ? this.parse(metadata.transcript) : null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// A file that doesn't read loses only its tags and transcript, not the memo.
|
|
125
|
+
readFile(file) {
|
|
126
|
+
try {
|
|
127
|
+
return readRecordingMetadata(file);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
this.logger.warn(`Can't read a voice memo's audio file: ${error.message}`);
|
|
131
|
+
this.unreadable.files++;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
parse(json) {
|
|
136
|
+
try {
|
|
137
|
+
return parseTranscript(json);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
this.logger.warn(`Can't read a voice memo's transcript: ${error.message}`);
|
|
141
|
+
this.unreadable.transcripts++;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Say when files or transcripts didn't read. The memos are kept, but a
|
|
147
|
+
* format Voice Memos changed would otherwise only show in the log.
|
|
148
|
+
*/
|
|
149
|
+
hintUnreadable() {
|
|
150
|
+
const { files, transcripts } = this.unreadable;
|
|
151
|
+
const action = 'Voice Memos may have changed how it stores recordings. Update Chronicle, and report it if that doesn’t help.';
|
|
152
|
+
if (files > 0) {
|
|
153
|
+
const memos = files === 1
|
|
154
|
+
? '1 audio file couldn’t be read, so its memo has'
|
|
155
|
+
: `${files} audio files couldn’t be read, so their memos have`;
|
|
156
|
+
this.hint(`${memos} no transcript or device`, { action });
|
|
157
|
+
}
|
|
158
|
+
if (transcripts > 0) {
|
|
159
|
+
const memos = transcripts === 1
|
|
160
|
+
? '1 transcript couldn’t be read, so its memo has'
|
|
161
|
+
: `${transcripts} transcripts couldn’t be read, so their memos have`;
|
|
162
|
+
this.hint(`${memos} none`, { action });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async determineCount() {
|
|
166
|
+
if (!this.db)
|
|
167
|
+
return null;
|
|
168
|
+
try {
|
|
169
|
+
const { sql, values } = this.query('COUNT(*) AS n');
|
|
170
|
+
return getRow(this.db.prepare(sql), ...values)?.n ?? null;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** The recordings a run reads and when they started, with how long they last, to the second. */
|
|
177
|
+
async survey(options = {}) {
|
|
178
|
+
if (!this.db)
|
|
179
|
+
return null;
|
|
180
|
+
const { sql, values } = this.query(`COUNT(*) AS n, MIN(ZDATE) AS first, MAX(ZDATE) AS last,
|
|
181
|
+
COALESCE(ROUND(SUM(ZDURATION)), 0) AS seconds`);
|
|
182
|
+
const row = getRow(this.db.prepare(sql), ...values);
|
|
183
|
+
const months = this.query(`${monthSql(`ZDATE + ${APPLE_EPOCH_OFFSET_SECONDS}`)} AS month, COUNT(*) AS n`);
|
|
184
|
+
const byMonth = options.byMonth &&
|
|
185
|
+
countsByMonth(allRows(this.db.prepare(`${months.sql} GROUP BY month`), ...months.values));
|
|
186
|
+
return {
|
|
187
|
+
recordings: {
|
|
188
|
+
count: row.n,
|
|
189
|
+
...(row.first !== null && { earliest: new Date(appleDate(row.first)) }),
|
|
190
|
+
...(row.last !== null && { latest: new Date(appleDate(row.last)) }),
|
|
191
|
+
...(byMonth && { byMonth }),
|
|
192
|
+
measures: [
|
|
193
|
+
{ key: 'recordingSeconds', label: 'recordings', value: row.seconds, unit: 'seconds' },
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The file, when it's on this Mac. A recording iCloud hasn't downloaded has
|
|
201
|
+
* no file, or an empty one.
|
|
202
|
+
*/
|
|
203
|
+
function onDisk(file) {
|
|
204
|
+
try {
|
|
205
|
+
return statSync(file).size > 0 ? file : null;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* When the library last changed: the database, or its write-ahead log, which
|
|
213
|
+
* holds changes Core Data hasn't yet written back.
|
|
214
|
+
*/
|
|
215
|
+
function lastChanged(database) {
|
|
216
|
+
const times = [database, `${database}-wal`].flatMap(file => {
|
|
217
|
+
try {
|
|
218
|
+
return [statSync(file).mtime.getTime()];
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
return new Date(Math.max(...times));
|
|
225
|
+
}
|
|
226
|
+
function appleDate(seconds) {
|
|
227
|
+
return new Date((seconds + APPLE_EPOCH_OFFSET_SECONDS) * 1000).toISOString();
|
|
228
|
+
}
|
|
@@ -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/apple';
|
|
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
|
+
// Keyed by the recording it made, which has the memo's ID.
|
|
33
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
34
|
+
source: SOURCE,
|
|
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
|
package/dist/encoder.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
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,103 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/apple-voice-memos",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.5.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.5.0",
|
|
28
|
+
"@chronicle.app/schema": "0.5.0",
|
|
29
|
+
"@chronicle.app/tsconfig": "0.5.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.5.0",
|
|
45
|
+
"@chronicle.app/apple": "0.5.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
|
+
"locations": [
|
|
73
|
+
{
|
|
74
|
+
"path": "~/Library/Group Containers/group.com.apple.VoiceMemos.shared/Recordings/CloudRecordings.db"
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
"databases": [
|
|
78
|
+
{
|
|
79
|
+
"tables": {
|
|
80
|
+
"ZCLOUDRECORDING": [
|
|
81
|
+
"ZUNIQUEID",
|
|
82
|
+
"ZDATE",
|
|
83
|
+
"ZDURATION",
|
|
84
|
+
"ZENCRYPTEDTITLE",
|
|
85
|
+
"ZPATH",
|
|
86
|
+
"ZFLAGS",
|
|
87
|
+
"ZEVICTIONDATE"
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"platforms": [
|
|
95
|
+
"darwin"
|
|
96
|
+
],
|
|
97
|
+
"requires": [
|
|
98
|
+
"full-disk-access"
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|