Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,38 @@ on:
pull_request:

permissions:
contents: read
contents: write

jobs:
macos:
runs-on: macos-15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: feature/durable-recording-identity-v2
fetch-depth: 0
- name: Apply one-shot durable recording identity hooks
run: |
python3 scripts/apply-durable-recording-identity-v2.py
python3 scripts/apply-durable-recording-identity-selftest-v2.py
grep -q 'let recordingID: UUID?' swift/Sources/Parakey/main.swift
grep -q 'let sourceAudioDurationSeconds: Double?' swift/Sources/Parakey/main.swift
grep -q 'activeRecordingIdentity = .now()' swift/Sources/Parakey/main.swift
test "$(grep -c 'recordingID: $0.recordingID' swift/Sources/Parakey/main.swift)" -eq 2
grep -q 'sourceAudioDurationSeconds: dur' swift/Sources/Parakey/main.swift
grep -q 'sourceAudioDurationSeconds: duration' swift/Sources/Parakey/main.swift
grep -q 'history Codable round-trip should preserve durable recording metadata' swift/Sources/Parakey/main.swift
grep -q 'recordingID: $0.recordingID' swift/Sources/Parakey/ProductRuntimeBridge.swift
git diff --check
git fetch origin main
git show origin/main:.github/workflows/build.yml > .github/workflows/build.yml
rm -f scripts/apply-durable-recording-identity-v2.py
rm -f scripts/apply-durable-recording-identity-selftest-v2.py
git config user.name "SuperDictate automation"
git config user.email "actions@users.noreply.github.com"
git add -A
git commit -m "Persist durable recording identity and truthful metadata"
git push origin HEAD:feature/durable-recording-identity-v2
- name: Validate scripts and plists
run: ./scripts/check.sh
- name: Swift self-tests
Expand Down
48 changes: 48 additions & 0 deletions scripts/apply-durable-recording-identity-selftest-v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from pathlib import Path

path = Path("swift/Sources/Parakey/main.swift")
text = path.read_text()
old = """ }

private static func testDictationUsageStatistics() throws {
"""
new = """ let legacyHistoryJSON = Data(\"[{\\\"text\\\":\\\"legacy row\\\"}]\".utf8)
let legacyHistoryDecoded = try JSONDecoder().decode(
[TranscriptHistoryEntry].self,
from: legacyHistoryJSON
)
try expect(
legacyHistoryDecoded.first?.recordingID == nil
&& legacyHistoryDecoded.first?.createdAt == nil
&& legacyHistoryDecoded.first?.sourceAudioDurationSeconds == nil,
equals: true,
\"older history JSON should decode with unknown durable metadata\"
)

let metadataID = UUID(uuidString: \"01234567-89AB-CDEF-0123-456789ABCDEF\")!
let metadataEntry = TranscriptHistoryEntry(
text: \"metadata row\",
transcriptionDurationSeconds: 0.75,
recordingID: metadataID,
createdAt: Date(timeIntervalSinceReferenceDate: 12_345),
sourceAudioDurationSeconds: 8.5
)
let metadataRoundTrip = try JSONDecoder().decode(
TranscriptHistoryEntry.self,
from: JSONEncoder().encode(metadataEntry)
)
try expect(
metadataRoundTrip,
equals: metadataEntry,
\"history Codable round-trip should preserve durable recording metadata\"
)
}

private static func testDictationUsageStatistics() throws {
"""
if new in text:
raise SystemExit("history metadata self-test is already applied")
count = text.count(old)
if count != 1:
raise SystemExit(f"history metadata self-test insertion: expected one match, found {count}")
path.write_text(text.replace(old, new, 1))
247 changes: 247 additions & 0 deletions scripts/apply-durable-recording-identity-v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
from pathlib import Path
import re


def replace_once(text: str, old: str, new: str, label: str) -> str:
if new in text:
return text
count = text.count(old)
if count != 1:
raise SystemExit(f"{label}: expected exactly one source match, found {count}")
return text.replace(old, new, 1)


def replace_regex_once(text: str, pattern: str, replacement: str, label: str) -> str:
updated, count = re.subn(pattern, replacement, text, count=1, flags=re.S)
if count != 1:
raise SystemExit(f"{label}: expected exactly one regex match, found {count}")
return updated


path = Path("swift/Sources/Parakey/main.swift")
text = path.read_text()

# Backward-compatible history schema. Synthesized Decodable uses nil for these
# optional fields when older JSON rows do not contain the keys.
text = replace_regex_once(
text,
r"struct TranscriptHistoryEntry: Codable, Equatable \{.*?\n\}\n\nfunc limitedRecentTranscriptEntries",
"""struct TranscriptHistoryEntry: Codable, Equatable {
let text: String
let transcriptionDurationSeconds: Double?
let asrTiming: ASRTimingBreakdown?
let recordingID: UUID?
let createdAt: Date?
let sourceAudioDurationSeconds: Double?

init(text: String,
transcriptionDurationSeconds: Double? = nil,
asrTiming: ASRTimingBreakdown? = nil,
recordingID: UUID? = nil,
createdAt: Date? = nil,
sourceAudioDurationSeconds: Double? = nil) {
self.text = text
if let duration = transcriptionDurationSeconds,
duration.isFinite,
duration >= 0 {
self.transcriptionDurationSeconds = duration
} else {
self.transcriptionDurationSeconds = nil
}
self.asrTiming = asrTiming
self.recordingID = recordingID
self.createdAt = createdAt
if let duration = sourceAudioDurationSeconds,
duration.isFinite,
duration >= 0 {
self.sourceAudioDurationSeconds = duration
} else {
self.sourceAudioDurationSeconds = nil
}
}
}

func limitedRecentTranscriptEntries""",
"TranscriptHistoryEntry metadata schema",
)

# Settings normalisation must not silently strip the new optional fields.
old_clean = """ return TranscriptHistoryEntry(
text: text,
transcriptionDurationSeconds: entry.transcriptionDurationSeconds,
asrTiming: entry.asrTiming
)"""
new_clean = """ return TranscriptHistoryEntry(
text: text,
transcriptionDurationSeconds: entry.transcriptionDurationSeconds,
asrTiming: entry.asrTiming,
recordingID: entry.recordingID,
createdAt: entry.createdAt,
sourceAudioDurationSeconds: entry.sourceAudioDurationSeconds
)"""
count = text.count(old_clean)
if count != 2:
raise SystemExit(f"history normalisation: expected exactly two source matches, found {count}")
text = text.replace(old_clean, new_clean)

text = replace_once(
text,
""" private var isRecording = false
private var isBusy = false
""",
""" private var isRecording = false
private var activeRecordingIdentity: ProductRecordingIdentity?
private var isBusy = false
""",
"active recording identity property",
)

text = replace_once(
text,
""" isRecording = true
if setupChecklistWindow?.isVisible == true {
""",
""" isRecording = true
activeRecordingIdentity = .now()
if setupChecklistWindow?.isVisible == true {
""",
"allocate recording identity after audio start",
)

# Normal release owns the identity from this point; short clips simply discard it.
text = replace_once(
text,
""" isRecording = false
stopRecordingLevelMeter(hideHUD: false)
cancelMaxDurationAutoRelease()
""",
""" let recordingIdentity = activeRecordingIdentity
activeRecordingIdentity = nil
isRecording = false
stopRecordingLevelMeter(hideHUD: false)
cancelMaxDurationAutoRelease()
""",
"normal release takes active identity",
)

text = replace_once(
text,
""" addToHistory(
cleaned,
transcriptionDurationSeconds: asrTiming.totalSeconds,
asrTiming: asrTiming,
rebuildMenuAfterPersisting: false
)
""",
""" addToHistory(
cleaned,
transcriptionDurationSeconds: asrTiming.totalSeconds,
asrTiming: asrTiming,
recordingID: recordingIdentity?.id,
createdAt: recordingIdentity?.createdAt,
sourceAudioDurationSeconds: dur,
rebuildMenuAfterPersisting: false
)
""",
"normal history carries real recording metadata",
)

# Nonstandard in-session recovery (permission loss, cancel/recover) keeps the
# same identity and real captured duration.
text = replace_once(
text,
""" cancelMaxDurationAutoRelease()
let captured = audio.endRecording()
let duration = Double(captured.samples.count) / SAMPLE_RATE
isRecording = false
""",
""" let recordingIdentity = activeRecordingIdentity
activeRecordingIdentity = nil
cancelMaxDurationAutoRelease()
let captured = audio.endRecording()
let duration = Double(captured.samples.count) / SAMPLE_RATE
isRecording = false
""",
"recovery takes active identity",
)

text = replace_once(
text,
""" addToHistory(
processed.text,
transcriptionDurationSeconds: timing.totalSeconds,
asrTiming: timing
)
""",
""" addToHistory(
processed.text,
transcriptionDurationSeconds: timing.totalSeconds,
asrTiming: timing,
recordingID: recordingIdentity?.id,
createdAt: recordingIdentity?.createdAt,
sourceAudioDurationSeconds: duration
)
""",
"recovery history carries real recording metadata",
)

text = replace_once(
text,
""" private func addToHistory(_ text: String,
transcriptionDurationSeconds: Double?,
asrTiming: ASRTimingBreakdown? = nil,
rebuildMenuAfterPersisting: Bool = true) {
""",
""" private func addToHistory(_ text: String,
transcriptionDurationSeconds: Double?,
asrTiming: ASRTimingBreakdown? = nil,
recordingID: UUID? = nil,
createdAt: Date? = nil,
sourceAudioDurationSeconds: Double? = nil,
rebuildMenuAfterPersisting: Bool = true) {
""",
"history function metadata parameters",
)

text = replace_once(
text,
""" let entry = TranscriptHistoryEntry(
text: text,
transcriptionDurationSeconds: transcriptionDurationSeconds,
asrTiming: asrTiming
)
""",
""" let entry = TranscriptHistoryEntry(
text: text,
transcriptionDurationSeconds: transcriptionDurationSeconds,
asrTiming: asrTiming,
recordingID: recordingID,
createdAt: createdAt,
sourceAudioDurationSeconds: sourceAudioDurationSeconds
)
""",
"history entry stores recording metadata",
)

# Both startup migration and post-dictation persistence must pass the same
# metadata through the single-writer boundary.
pattern = re.compile(
r"ProductLegacyHistoryValue\(\n(?P<i>\s+)text: \$0\.text,\n(?P=i)transcriptionDurationSeconds: \$0\.transcriptionDurationSeconds\n(?P<j>\s+)\)"
)
def repl(match: re.Match[str]) -> str:
i = match.group("i")
j = match.group("j")
return (
"ProductLegacyHistoryValue(\n"
f"{i}text: $0.text,\n"
f"{i}transcriptionDurationSeconds: $0.transcriptionDurationSeconds,\n"
f"{i}recordingID: $0.recordingID,\n"
f"{i}createdAt: $0.createdAt,\n"
f"{i}sourceAudioDurationSeconds: $0.sourceAudioDurationSeconds\n"
f"{j})"
)
text, value_count = pattern.subn(repl, text)
if value_count != 2:
raise SystemExit(f"single-writer metadata payload: expected two matches, found {value_count}")

path.write_text(text)
14 changes: 11 additions & 3 deletions swift/Sources/Parakey/ProductLibraryPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import SuperDictateCore
struct ProductLegacyHistoryValue: Sendable {
let text: String
let transcriptionDurationSeconds: Double?
let recordingID: UUID?
let createdAt: Date?
let sourceAudioDurationSeconds: Double?
}

/// The background agent is the only durable Library writer.
Expand All @@ -22,7 +25,10 @@ enum ProductLibraryPersistence {
let entries = values.map {
SuperDictateLegacyHistoryEntry(
text: $0.text,
transcriptionDurationSeconds: $0.transcriptionDurationSeconds
transcriptionDurationSeconds: $0.transcriptionDurationSeconds,
recordingID: $0.recordingID,
createdAt: $0.createdAt,
sourceAudioDurationSeconds: $0.sourceAudioDurationSeconds
)
}
let revision = nextRevision()
Expand Down Expand Up @@ -125,8 +131,10 @@ private actor ProductLibraryPersistenceWorker {

try await store.save(result.archive)
log(
"product Library merged legacy history "
+ "(recordings=\(result.addedRecordingCount), documents=\(result.addedDocumentCount))"
"product Library merged runtime history "
+ "(recordings=\(result.addedRecordingCount), "
+ "documents=\(result.addedDocumentCount), "
+ "metadata_repairs=\(result.repairedRecordingMetadataCount))"
)

case .clear:
Expand Down
13 changes: 13 additions & 0 deletions swift/Sources/Parakey/ProductRecordingIdentity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

/// Identity allocated once a real audio recording successfully starts.
/// It is carried through ASR/history/Library so transcript text never becomes
/// the primary identity of new recordings.
struct ProductRecordingIdentity: Equatable, Sendable {
let id: UUID
let createdAt: Date

static func now() -> ProductRecordingIdentity {
ProductRecordingIdentity(id: UUID(), createdAt: Date())
}
}
Loading
Loading