chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+577
@@ -0,0 +1,577 @@
|
||||
# Hex HotKey Semantics
|
||||
|
||||
## Overview
|
||||
|
||||
Hex uses a **threshold-based recording system** that behaves differently depending on whether your hotkey is **modifier-only** (e.g., Option) or **a regular hotkey** (e.g., Cmd+A).
|
||||
|
||||
The key insight: **Modifier-only hotkeys need protection from accidental triggers** (like quick Option taps for special characters), while regular hotkeys are inherently intentional.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Modifier-Only Hotkeys (e.g., Option, Option+Command)
|
||||
|
||||
**Timeline: Press Option → START recording**
|
||||
|
||||
**Before 0.3s (< 0.3s):**
|
||||
- Release → DISCARD (silent)
|
||||
- Click → DISCARD (silent)
|
||||
- Press A → DISCARD (silent)
|
||||
- Add Shift → DISCARD (silent)
|
||||
- All actions trigger silent discard
|
||||
|
||||
**After 0.3s (≥ 0.3s):**
|
||||
- Release → STOP (transcribe)
|
||||
- Click → NOP (ignore, keep recording)
|
||||
- Press A → NOP (ignore, keep recording)
|
||||
- Add Shift → NOP (ignore, keep recording)
|
||||
- ESC → CANCEL (only way to stop)
|
||||
|
||||
**Key points:**
|
||||
- **< 0.3s**: Everything except ESC triggers **silent discard** (no sound)
|
||||
- **≥ 0.3s**: Only **ESC cancels** (with sound), everything else is **ignored**
|
||||
- Recording continues until you release the modifier or press ESC
|
||||
|
||||
---
|
||||
|
||||
### Regular Hotkeys (e.g., Cmd+A, Option+K)
|
||||
|
||||
**Timeline: Press Cmd+A → START recording**
|
||||
|
||||
**Before 0.2s (< minimumKeyTime):**
|
||||
- Release → DISCARD (silent)
|
||||
- Click → DISCARD (silent)
|
||||
|
||||
**Between 0.2s - 1.0s:**
|
||||
- Press different key (e.g., Cmd+B) → STOP (with sound)
|
||||
- Add modifier (e.g., Shift) → STOP (with sound)
|
||||
|
||||
**After 1.0s (> 1.0s):**
|
||||
- Press different key → NOP (ignore, keep recording)
|
||||
- Add modifier → NOP (ignore, keep recording)
|
||||
- Allows typing while recording
|
||||
|
||||
**Any time:**
|
||||
- Release → STOP (transcribe if long enough)
|
||||
- ESC → CANCEL
|
||||
|
||||
**Key points:**
|
||||
- **< minimumKeyTime** (default 0.2s): **Silent discard**
|
||||
- **0.2s - 1.0s**: Different key/modifier triggers **stop** (with sound)
|
||||
- **> 1.0s**: Everything is **ignored** (allows typing while recording)
|
||||
- Release or ESC always stops
|
||||
|
||||
---
|
||||
|
||||
## Constants & Thresholds
|
||||
|
||||
```swift
|
||||
// For modifier-only hotkeys (system safety)
|
||||
modifierOnlyMinimumDuration = 0.3s
|
||||
|
||||
// For all hotkeys (user-configurable)
|
||||
minimumKeyTime = 0.2s (default)
|
||||
|
||||
// Other thresholds
|
||||
doubleTapThreshold = 0.3s // Max time between taps
|
||||
pressAndHoldCancelThreshold = 1.0s // For regular hotkeys only
|
||||
```
|
||||
|
||||
### Effective Thresholds
|
||||
|
||||
The **actual threshold** used depends on the hotkey type:
|
||||
|
||||
```swift
|
||||
// Modifier-only (e.g., Option)
|
||||
effectiveThreshold = max(minimumKeyTime, 0.3s)
|
||||
// User sets 0.1s → uses 0.3s
|
||||
// User sets 0.5s → uses 0.5s
|
||||
|
||||
// Regular (e.g., Cmd+A)
|
||||
effectiveThreshold = minimumKeyTime
|
||||
// User sets 0.1s → uses 0.1s
|
||||
// User sets 0.5s → uses 0.5s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recording Decision Matrix
|
||||
|
||||
When you **release** the hotkey, should we transcribe the recording?
|
||||
|
||||
**Modifier-only (Option):**
|
||||
- Duration < 0.3s → Discard (silent)
|
||||
- Duration ≥ 0.3s → Transcribe
|
||||
|
||||
**Regular (Cmd+A):**
|
||||
- Duration < 0.2s (or < minimumKeyTime) → Discard (silent)
|
||||
- Duration ≥ 0.2s (or ≥ minimumKeyTime) → Transcribe
|
||||
|
||||
*Note: minimumKeyTime can be adjusted by user, but modifier-only always enforces 0.3s minimum*
|
||||
|
||||
---
|
||||
|
||||
## Detailed Behaviors
|
||||
|
||||
### 1. Modifier-Only: Option
|
||||
|
||||
#### Scenario A: Quick tap (< 0.3s)
|
||||
```
|
||||
User: Hold Option (0.1s) → Release
|
||||
↓
|
||||
START ────→ DISCARD (silent)
|
||||
|
||||
Result: No transcription, no sound
|
||||
Why: Likely accidental (Option+Click, Option+A for special chars)
|
||||
```
|
||||
|
||||
#### Scenario B: Hold and click (< 0.3s)
|
||||
```
|
||||
User: Hold Option (0.25s) → Click mouse
|
||||
↓
|
||||
START ────→ DISCARD (silent)
|
||||
|
||||
Result: No transcription, no sound, click passes through
|
||||
Why: Option+Click is for duplicating items in macOS
|
||||
```
|
||||
|
||||
#### Scenario C: Hold and press A (< 0.3s)
|
||||
```
|
||||
User: Hold Option (0.2s) → Press A
|
||||
↓
|
||||
START ────→ DISCARD (silent)
|
||||
|
||||
Result: No transcription, Option+A passes through to macOS
|
||||
Why: Option+A might be for special character "å"
|
||||
```
|
||||
|
||||
#### Scenario D: Hold longer (≥ 0.3s)
|
||||
```
|
||||
User: Hold Option (0.5s) → Release
|
||||
↓
|
||||
START ───────────→ TRANSCRIBE
|
||||
|
||||
Result: Audio transcribed and pasted
|
||||
```
|
||||
|
||||
#### Scenario E: Hold, then click (≥ 0.3s)
|
||||
```
|
||||
User: Hold Option (0.5s) → Click mouse
|
||||
↓
|
||||
START ───────────→ (ignored, keeps recording)
|
||||
|
||||
Result: Recording continues, click passes through
|
||||
Why: After 0.3s, we assume intentional recording
|
||||
```
|
||||
|
||||
#### Scenario F: Hold, then add Shift (≥ 0.3s)
|
||||
```
|
||||
User: Hold Option (0.5s) → Add Shift
|
||||
↓
|
||||
START ───────────→ (ignored, keeps recording)
|
||||
|
||||
Result: Recording continues, Option+Shift passes through
|
||||
Why: User might be pressing Shift for capital letters while speaking
|
||||
```
|
||||
|
||||
#### Scenario G: ESC cancels anytime
|
||||
```
|
||||
User: Hold Option (any duration) → Press ESC
|
||||
↓
|
||||
START ───────────→ CANCEL (with sound)
|
||||
|
||||
Result: Recording cancelled, cancel sound plays
|
||||
Why: ESC is explicit "I want to cancel" gesture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Regular Hotkey: Cmd+A
|
||||
|
||||
#### Scenario A: Quick tap (< 0.2s)
|
||||
```
|
||||
User: Hold Cmd+A (0.1s) → Release
|
||||
↓
|
||||
START ────→ DISCARD (silent)
|
||||
```
|
||||
|
||||
#### Scenario B: Press different key within 1s
|
||||
```
|
||||
User: Hold Cmd+A (0.5s) → Press Cmd+B
|
||||
↓
|
||||
START ───────────→ STOP (with sound)
|
||||
|
||||
Result: Recording stopped, audio discarded
|
||||
Why: User is likely using other Cmd shortcuts
|
||||
```
|
||||
|
||||
#### Scenario C: Press different key after 1s
|
||||
```
|
||||
User: Hold Cmd+A (1.5s) → Press Cmd+B
|
||||
↓
|
||||
START ───────────────────→ (ignored, keeps recording)
|
||||
|
||||
Result: Recording continues, Cmd+B passes through
|
||||
Why: After 1s, assume user is transcribing while typing
|
||||
```
|
||||
|
||||
#### Scenario D: Add modifier (< 1s)
|
||||
```
|
||||
User: Hold Cmd+A (0.5s) → Add Shift (Cmd+Shift+A)
|
||||
↓
|
||||
START ───────────→ STOP (with sound)
|
||||
|
||||
Result: Recording stopped
|
||||
Why: Cmd+Shift+A is likely a different command
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Multi-Modifier: Option+Command
|
||||
|
||||
**Behaves like single modifier** (uses 0.3s threshold):
|
||||
|
||||
```
|
||||
User: Hold Option+Command (0.25s) → Add Shift
|
||||
↓
|
||||
START ────→ DISCARD (silent)
|
||||
|
||||
User: Hold Option+Command (0.5s) → Add Shift
|
||||
↓
|
||||
START ───────────→ (ignored, keeps recording)
|
||||
```
|
||||
|
||||
**Partial release = full release:**
|
||||
```
|
||||
User: Hold Option+Command → Release Command (keep Option)
|
||||
↓
|
||||
START ───────────→ STOP
|
||||
|
||||
Result: Recording stopped and transcribed (if ≥ 0.3s)
|
||||
Why: Releasing any part of the hotkey = release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Double-Tap Lock
|
||||
|
||||
**Quick double-tap locks recording on** (hands-free mode)
|
||||
|
||||
### Timeline
|
||||
|
||||
```
|
||||
0.0s: Tap hotkey ──────────→ START
|
||||
0.1s: Release ─────────────→ STOP
|
||||
0.2s: Tap again ───────────→ START
|
||||
0.3s: Release ─────────────→ LOCK! (Δt = 0.2s < 0.3s)
|
||||
|
||||
Now recording is locked on:
|
||||
- Release doesn't stop it
|
||||
- Tap hotkey again to stop
|
||||
- ESC also stops
|
||||
|
||||
5.0s: Tap hotkey ──────────→ STOP
|
||||
```
|
||||
|
||||
### Sequence
|
||||
|
||||
1. **Tap 1** (t=0.0s) → START recording
|
||||
2. **Release** (t=0.1s) → STOP (normal behavior)
|
||||
3. **Tap 2** (t=0.2s, within 0.3s window) → START recording again
|
||||
4. **Release** (t=0.3s, Δt=0.2s < 0.3s) → **LOCK!** (hands-free mode)
|
||||
5. Recording continues until:
|
||||
- Tap hotkey again → STOP
|
||||
- Press ESC → STOP
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Timing window**: 2nd tap must be within **0.3s** of 1st release
|
||||
2. **Lock engages**: On **2nd release**, not 2nd press
|
||||
3. **Exit lock**: Tap hotkey again OR press ESC
|
||||
4. **Too slow**: If 2nd tap > 0.3s after 1st release, treated as new recording
|
||||
|
||||
---
|
||||
|
||||
## Output Types
|
||||
|
||||
- **`.discard`** (silent)
|
||||
- Stop recording
|
||||
- Discard audio
|
||||
- Pass keys through to macOS
|
||||
|
||||
- **`.stop`** (with stop sound)
|
||||
- Stop recording
|
||||
- Transcribe if duration ≥ threshold
|
||||
|
||||
- **`.cancel`** (with cancel sound)
|
||||
- Stop recording
|
||||
- Discard audio
|
||||
- Play cancel sound
|
||||
|
||||
---
|
||||
|
||||
## Key Interception
|
||||
|
||||
**When does Hex intercept (block) key events from reaching other apps?**
|
||||
|
||||
**Key Interception Rules:**
|
||||
|
||||
- **Press modifier-only hotkey (Option)** → No (passes through)
|
||||
- **Press regular hotkey (Cmd+A)** → Yes (blocked)
|
||||
- **`.discard` output** → No (passes through) ← CRITICAL!
|
||||
- **`.cancel` output** → Yes (blocked)
|
||||
- **Mouse clicks** → Never intercepted (always pass through)
|
||||
|
||||
**Example:**
|
||||
```
|
||||
User: Press Option (0.2s) → Press A
|
||||
↓
|
||||
START → DISCARD (passes through)
|
||||
|
||||
Result: Hex discards recording silently
|
||||
macOS sees Option+A
|
||||
Special character dialog appears ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dirty State
|
||||
|
||||
**Prevents re-triggering until full release**
|
||||
|
||||
### What triggers dirty?
|
||||
|
||||
**Modifier-only (Option):**
|
||||
- Add extra modifier within 0.3s → dirty
|
||||
- Press any key within 0.3s → dirty
|
||||
- Click mouse within 0.3s → dirty
|
||||
|
||||
**Regular (Cmd+A):**
|
||||
- Press different key within 1s → dirty
|
||||
- Change modifiers within 1s → dirty
|
||||
|
||||
### Dirty behavior
|
||||
|
||||
```
|
||||
User: Hold Option (0.1s) → Add Shift → Release Shift → Press Option again
|
||||
↓
|
||||
START → DISCARD (dirty=true) → (ignored) → (ignored)
|
||||
|
||||
User must release EVERYTHING (∅) to clear dirty
|
||||
|
||||
→ Release all keys → Now Option works again
|
||||
```
|
||||
|
||||
### State Flow
|
||||
|
||||
**CLEAN** → [trigger dirty condition] → **DIRTY** → [full release (all keys)] → **CLEAN**
|
||||
|
||||
- **CLEAN**: Accepts hotkey input
|
||||
- **DIRTY**: Ignores all input until full release (all keys released)
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
**Processing order:**
|
||||
|
||||
1. **Is ESC pressed?**
|
||||
- YES → CANCEL (exit)
|
||||
|
||||
2. **Are we dirty?**
|
||||
- YES → Ignore input (unless full release)
|
||||
|
||||
3. **Does chord match hotkey exactly?**
|
||||
- NO → Check if recording active
|
||||
- Recording active → Handle based on elapsed time
|
||||
- Not recording → Ignore input
|
||||
- YES → Continue to step 4
|
||||
|
||||
4. **Is recording active?**
|
||||
- NO → START recording
|
||||
- YES → Check hotkey type and elapsed time
|
||||
|
||||
5. **If recording active:**
|
||||
- **Modifier-only?**
|
||||
- YES → Check elapsed < max(0.3s, minimumKeyTime)
|
||||
- YES → DISCARD or (ignore if ≥0.3s)
|
||||
- NO → (ignore)
|
||||
- **Regular hotkey?**
|
||||
- Check elapsed < 1s
|
||||
- YES → Check elapsed < minimumKeyTime
|
||||
- YES → DISCARD
|
||||
- NO → STOP
|
||||
- NO → (ignore)
|
||||
|
||||
6. **Final action:**
|
||||
- START or STOP (depending on current state)
|
||||
|
||||
---
|
||||
|
||||
## State Machine
|
||||
|
||||
**States:**
|
||||
|
||||
- **IDLE**
|
||||
- Transition: chord matches hotkey → PRESS & HOLD
|
||||
|
||||
- **PRESS & HOLD** (recording)
|
||||
- On release (normal):
|
||||
- Check elapsed time
|
||||
- If < 0.3s (modifier-only) or < minimumKeyTime (regular) → DISCARD
|
||||
- If ≥ threshold → Check last tap timing
|
||||
- If Δt < 0.3s → LOCK
|
||||
- Otherwise → STOP → IDLE
|
||||
- On other input:
|
||||
- Check elapsed time
|
||||
- If < 0.3s → DISCARD → IDLE
|
||||
- If ≥ 0.3s → (ignore, keep recording)
|
||||
|
||||
- **LOCK** (hands-free recording)
|
||||
- Transition: Tap hotkey again OR press ESC → STOP → IDLE
|
||||
|
||||
---
|
||||
|
||||
## Examples with Full Explanations
|
||||
|
||||
### Example 1: Quick Option tap for special character
|
||||
|
||||
```
|
||||
Goal: Type "å" (Option+A)
|
||||
|
||||
Timeline:
|
||||
t=0.0s Press Option → START recording
|
||||
t=0.15s Press A → DISCARD (< 0.3s)
|
||||
Option+A passes to macOS
|
||||
|
||||
macOS sees: Option+A
|
||||
Result: Special character dialog appears ✅
|
||||
Hex: Silent discard, no transcription
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- Recording starts immediately (responsive)
|
||||
- But discarded if < 0.3s (safety)
|
||||
- Keys pass through (Option+A reaches macOS)
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Intentional voice recording with Option
|
||||
|
||||
```
|
||||
Goal: Record a voice note
|
||||
|
||||
Timeline:
|
||||
t=0.0s Press Option → START recording
|
||||
t=0.5s Still holding... (recording audio)
|
||||
t=2.0s Release Option → STOP, TRANSCRIBE
|
||||
|
||||
Result: Audio transcribed and pasted ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Option-click to duplicate
|
||||
|
||||
```
|
||||
Goal: Duplicate file in Finder
|
||||
|
||||
Timeline:
|
||||
t=0.0s Press Option → START recording
|
||||
t=0.2s Click file → DISCARD (< 0.3s)
|
||||
Click passes through
|
||||
|
||||
Finder sees: Option+Click
|
||||
Result: File duplicated ✅
|
||||
Hex: Silent discard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 4: Recording while typing
|
||||
|
||||
```
|
||||
Goal: Dictate code comments while typing
|
||||
|
||||
Timeline:
|
||||
t=0.0s Press Option → START recording
|
||||
t=0.5s Still talking... (recording)
|
||||
t=2.0s Press Cmd+Tab → IGNORED (> 0.3s)
|
||||
Cmd+Tab passes through
|
||||
t=3.0s Type some code → IGNORED
|
||||
t=5.0s Release Option → STOP, TRANSCRIBE
|
||||
|
||||
Result: Audio transcribed ✅
|
||||
Cmd+Tab worked ✅
|
||||
Typing worked ✅
|
||||
```
|
||||
|
||||
**Why:** After 0.3s, Hex assumes you're intentionally recording and ignores other input (except ESC).
|
||||
|
||||
---
|
||||
|
||||
### Example 5: Accidental recording cancellation
|
||||
|
||||
```
|
||||
Goal: Cancel accidental recording
|
||||
|
||||
Timeline:
|
||||
t=0.0s Press Option → START recording
|
||||
t=1.0s "Oh no, accident!"
|
||||
t=1.5s Press ESC → CANCEL (with sound)
|
||||
|
||||
Result: Recording cancelled ✅
|
||||
Cancel sound plays
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
**Modifier-only (Option):**
|
||||
|
||||
- **Time < 0.3s:**
|
||||
- Release → Discard
|
||||
- Click → Discard
|
||||
- Press key → Discard
|
||||
- Add modifier → Discard
|
||||
|
||||
- **Time ≥ 0.3s:**
|
||||
- Release → Transcribe
|
||||
- Click → Ignore (keep recording)
|
||||
- Press key → Ignore (keep recording)
|
||||
- Add modifier → Ignore (keep recording)
|
||||
- ESC → Cancel
|
||||
|
||||
**Regular (Cmd+A):**
|
||||
|
||||
- **Time < minimumKeyTime:**
|
||||
- Release → Discard
|
||||
|
||||
- **Time: minimumKeyTime - 1s:**
|
||||
- Other key → Stop
|
||||
- Add modifier → Stop
|
||||
|
||||
- **Time > 1s:**
|
||||
- Other key → Ignore (keep recording)
|
||||
- Add modifier → Ignore (keep recording)
|
||||
|
||||
- **Any time:**
|
||||
- Release → Transcribe (if long enough)
|
||||
- ESC → Cancel
|
||||
|
||||
---
|
||||
|
||||
## Implementation Files
|
||||
|
||||
- **Core Logic**: `HexCore/Sources/HexCore/Logic/HotKeyProcessor.swift`
|
||||
- **Recording Decision**: `HexCore/Sources/HexCore/Logic/RecordingDecision.swift`
|
||||
- **Feature Integration**: `Hex/Features/Transcription/TranscriptionFeature.swift`
|
||||
- **Tests**: `HexCore/Tests/HexCoreTests/HotKeyProcessorTests.swift`
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 2.0
|
||||
**Last Updated:** 2025-11-14
|
||||
**Total Tests:** 46 passing ✅
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Parakeet Short-Audio Padding Plan
|
||||
|
||||
## Problem Statement
|
||||
- FluidAudio's Parakeet TDT v3 decoder refuses to run (or produces unstable text) when the input WAV is shorter than its internal chunk size.
|
||||
- Hex often emits <250 ms clips when users tap-to-talk, so we must “pack” (pad) the PCM before `AsrManager.transcribe(_:)` is called.
|
||||
- WhisperKit already zero-pads to 30 s internally, but Parakeet expects callers to hand it an audio segment that spans an entire chunk.
|
||||
|
||||
## Goals (Parakeet-first)
|
||||
1. Detect when a captured recording is below Parakeet’s minimum duration (TBD) and ensure the on-disk file we hand to FluidAudio meets or exceeds that window.
|
||||
2. Keep padding transparent to users: no audible artifacts, no extra latency, and logs that explain when/why padding ran (`HexLog.parakeet`).
|
||||
3. Limit scope to Parakeet for now; Whisper will keep using its existing flow unless we discover regressions.
|
||||
|
||||
## Constraints & Research Tasks
|
||||
- [ ] Confirm Parakeet’s required sample count / duration. FluidAudio docs hint that v3 expects whole “chunk_duration” intervals (default 1.5 s @ 16 kHz mono). Need an authoritative value from:
|
||||
- `FluidAudio/Documentation/Models/ASR/LastChunkHandling.md`
|
||||
- Hugging Face card for `parakeet-tdt-0.6b-v3-coreml`
|
||||
- Any sample scripts in `FluidAudio` repo (look for `chunk_duration`, `min_samples`, or padding helpers).
|
||||
- [ ] Determine whether Parakeet tolerates zero-padding vs. repeating tail samples. Preference is zero-padding unless docs warn about VAD slip.
|
||||
- [ ] Verify whether padding must retain the original WAV container (RIFF header). If `AsrManager` only reads PCM floats, we can rewrite the whole file; otherwise we may need to append silence samples and fix the header lengths.
|
||||
|
||||
## Current Pipeline Touchpoints
|
||||
1. `Hex/Clients/RecordingClient.swift:667` sets up `AVAudioRecorder` at 16 kHz mono float and writes to `recording.wav` in `FileManager.default.temporaryDirectory`.
|
||||
2. `Hex/Features/Transcription/TranscriptionFeature.swift:320` calls `recording.stopRecording()` and passes the resulting URL into `TranscriptionClient.transcribe`.
|
||||
3. `Hex/Clients/TranscriptionClient.swift:242` routes Parakeet variants straight to `ParakeetClient.transcribe(_:)` with no preprocessing.
|
||||
4. `Hex/Clients/ParakeetClient.swift:118` hands the URL to `AsrManager.transcribe(url)` and returns the text.
|
||||
|
||||
These spots give us two obvious injection points: (a) mutate the WAV immediately after `recording.stopRecording()` returns, or (b) intercept inside `ParakeetClient` before calling FluidAudio.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 – Instrumentation & Guardrails
|
||||
1. Add a lightweight helper (e.g., `ShortClipInspector`) that loads the WAV header, validates sample rate/channels, and returns total duration.
|
||||
2. Insert the inspector inside `TranscriptionClient` right before the Parakeet branch so we can log current clip lengths (`HexLog.parakeet.debug("clip=0.18s")`).
|
||||
3. Ship this logging first (behind a debug flag if needed) to confirm real-world distributions before we enable padding.
|
||||
|
||||
### Phase 2 – Padding Helper (Parakeet Only)
|
||||
1. Create a new utility in `Hex/Audio/ShortClipPadder.swift` (or similar) that:
|
||||
- Accepts a source `URL`, desired minimum samples, and output `URL` (can be the same file if we rewrite safely).
|
||||
- Reads PCM floats via `AVAudioFile` or `ExtAudioFile`, counts samples, and if below threshold, appends zeros until `minSamples` is met.
|
||||
- Rewrites the WAV header chunk sizes so the file stays valid.
|
||||
2. Unit test this helper in `HexCoreTests` with fixtures (<50 ms clip, >threshold clip) to guarantee correct math and metadata.
|
||||
3. Make the minimum configurable (env var or `HexSettings`) so we can tweak without hard-coding; default to whatever FluidAudio recommends.
|
||||
|
||||
### Phase 3 – Integration Path
|
||||
1. Inside `TranscriptionClient.transcribe`, when `isParakeet(model)` is true:
|
||||
- Inspect clip duration.
|
||||
- If below threshold, call the padder and produce a temporary padded file (e.g., `recording_padded.wav`).
|
||||
- Pass the padded URL to `parakeet.transcribe` and clean up temp files afterward.
|
||||
2. Alternatively (if we want to contain logic), embed the padding call in `ParakeetClient.transcribe` so every backend use (future features/tests) benefits without touching the rest of the app flow.
|
||||
3. Ensure padding happens off the main actor to avoid UI stalls—`TranscriptionClient` already runs inside an async task, so heavy I/O should stay there.
|
||||
|
||||
### Phase 4 – Telemetry & Recovery UX
|
||||
1. Emit structured logs when padding occurs, including original duration and pad length, so we can grep Console for “Parakeet padded clip”.
|
||||
2. If FluidAudio still rejects the clip after padding, surface a user-friendly error and optionally auto-retry with a slightly longer pad.
|
||||
3. Consider capturing anonymized counts (number of padded clips per session) for future tuning once analytics hooks exist.
|
||||
|
||||
## Validation Strategy
|
||||
- **Unit tests:**
|
||||
- `ShortClipPadderTests` verifying sample count math, header rewrites, and idempotent behavior when clip already meets threshold.
|
||||
- **Integration smoke test:**
|
||||
- CLI harness that feeds a synthetic 150 ms WAV through `ParakeetClient` with padding enabled; ensure the decoder returns text (or at least does not throw).
|
||||
- **Manual QA:**
|
||||
- Tap hotkey quickly on macOS 15 (Intel + Apple Silicon). Confirm transcripts appear instead of “clip too short” errors, and latency increase is negligible (<5 ms for padding).
|
||||
|
||||
## Open Questions
|
||||
1. Does `AsrManager.transcribe` stream audio internally (making padding unnecessary if we tweak its chunk config) or does it expect fully sized files only?
|
||||
2. Should we preserve raw, unpadded recordings for history exports while only padding the temporary file handed to Parakeet?
|
||||
3. Do we need to bump the `RecordingDecisionEngine` thresholds so we still discard accidental tap-noise clips, or is padding enough?
|
||||
4. Could insanely short clips (e.g., <10 ms) produce audible pops once padded? If so, we may need a pre-smoothing step (fade in/out) before zero-fill.
|
||||
|
||||
> Next action: gather the concrete min-duration requirement from FluidAudio docs and pick an injection point (TranscriptionClient vs. ParakeetClient) so we can spike the padding helper.
|
||||
Executable
+302
@@ -0,0 +1,302 @@
|
||||
# Hex Release Pipeline - GitHub Actions Plan
|
||||
|
||||
## Context
|
||||
|
||||
Need to set up automated release pipeline for macOS app distribution via:
|
||||
- **Sparkle** (in-app updates via S3)
|
||||
- **Homebrew Cask** (requires ZIP artifact)
|
||||
- **GitHub Releases** (visibility/download page)
|
||||
|
||||
## Current State
|
||||
|
||||
### What Works ✅
|
||||
- `scripts/tool.py` - Python script, works locally
|
||||
- `tools/release.ts` - TypeScript/Effect version, works locally
|
||||
- Both use local notarytool keychain profile (`AC_PASSWORD`)
|
||||
- Both upload to S3, generate Sparkle appcast
|
||||
|
||||
### What's Broken ❌
|
||||
- `.github/workflows/release.yml` - Incomplete, has TODO stubs
|
||||
- No ZIP creation (Homebrew needs this)
|
||||
- No GitHub release integration
|
||||
|
||||
## Standard Approaches (2025)
|
||||
|
||||
### Option A: Pure GitHub Actions YAML
|
||||
**How most projects do it:**
|
||||
```yaml
|
||||
- uses: apple-actions/import-codesign-certs@v2
|
||||
- run: xcodebuild ...
|
||||
- uses: lando/notarize-action@v2
|
||||
- run: create-dmg ...
|
||||
- uses: actions/create-release@v1
|
||||
```
|
||||
|
||||
**Pros:** Standard, lots of examples, community actions
|
||||
**Cons:** Verbose YAML, hard to test locally, logic spread across steps
|
||||
|
||||
### Option B: Call Local Script (What You Have)
|
||||
**Use existing `release.ts` from workflow:**
|
||||
```yaml
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: bun run tools/release.ts --bucket hex-updates
|
||||
```
|
||||
|
||||
**Pros:** Test locally, type-safe, reusable, Effect composability
|
||||
**Cons:** Need env var auth fallback for CI
|
||||
|
||||
### Option C: Hybrid
|
||||
Use actions for signing/notarization, script for build/upload
|
||||
|
||||
## Recommended: Option B (Enhanced)
|
||||
|
||||
Why? You already built a complete, working TypeScript tool. Just need CI auth.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
GitHub Actions Workflow
|
||||
├─ Setup (checkout, certs, Bun)
|
||||
├─ Call: bun run tools/release.ts
|
||||
│ ├─ Detects CI via env
|
||||
│ ├─ Uses APPLE_ID/PASSWORD instead of keychain
|
||||
│ ├─ Builds, signs, notarizes
|
||||
│ ├─ Creates DMG + ZIP
|
||||
│ ├─ Uploads to S3 (Sparkle)
|
||||
│ └─ Returns artifacts paths
|
||||
└─ Create GitHub Release (upload DMG + ZIP)
|
||||
```
|
||||
|
||||
### Required Changes
|
||||
|
||||
#### 1. Modify `release.ts`
|
||||
**Add CI detection:**
|
||||
```typescript
|
||||
const getNotarizeArgs = () => {
|
||||
if (process.env.GITHUB_ACTIONS) {
|
||||
return [
|
||||
"--apple-id", process.env.APPLE_ID!,
|
||||
"--password", process.env.APPLE_ID_PASSWORD!,
|
||||
"--team-id", process.env.TEAM_ID!
|
||||
]
|
||||
}
|
||||
return ["--keychain-profile", "AC_PASSWORD"]
|
||||
}
|
||||
```
|
||||
|
||||
**Add ZIP creation:**
|
||||
```typescript
|
||||
// After DMG creation
|
||||
yield* runCommandCheck("ditto", "-c", "-k", "--keepParent",
|
||||
appBundle, join(updatesDir, `Hex-${newVersion}.zip`))
|
||||
```
|
||||
|
||||
#### 2. Update Workflow
|
||||
Replace `.github/workflows/release.yml` with:
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_bump:
|
||||
type: choice
|
||||
options: [patch, minor, major]
|
||||
default: patch
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Xcode
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.2.app
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd tools && bun install
|
||||
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
|
||||
run: |
|
||||
# Decode and import cert to default keychain
|
||||
echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/cert.p12
|
||||
security import /tmp/cert.p12 -P "$MACOS_CERTIFICATE_PWD" -A
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "" login.keychain-db
|
||||
rm /tmp/cert.p12
|
||||
|
||||
- name: Build and release
|
||||
run: bun run tools/release.ts --bucket hex-updates
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
TEAM_ID: ${{ secrets.TEAM_ID }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(cat Hex/Info.plist | grep -A1 CFBundleShortVersionString | tail -1 | sed 's/.*<string>\(.*\)<\/string>.*/\1/')
|
||||
# release.ts already tags + creates the release with the aggregated changelog.
|
||||
# Keep this step only if we ever skip that behavior in CI.
|
||||
gh release create "v$VERSION" \
|
||||
--title "Hex v$VERSION" \
|
||||
--notes-file build/release-notes-$VERSION.md \
|
||||
updates/Hex-$VERSION.dmg \
|
||||
updates/Hex-$VERSION.zip
|
||||
```
|
||||
|
||||
### Required Secrets (9)
|
||||
|
||||
Set via: `gh secret set SECRET_NAME`
|
||||
|
||||
```bash
|
||||
# Apple Developer
|
||||
APPLE_ID # your@email.com
|
||||
APPLE_ID_PASSWORD # xxxx-xxxx-xxxx-xxxx (app-specific)
|
||||
TEAM_ID # QC99C9JE59
|
||||
DEVELOPMENT_TEAM # QC99C9JE59 (same as TEAM_ID)
|
||||
|
||||
# Code Signing
|
||||
MACOS_CERTIFICATE # base64 encoded .p12
|
||||
MACOS_CERTIFICATE_PWD # .p12 password
|
||||
|
||||
# S3 / Sparkle
|
||||
AWS_ACCESS_KEY_ID # S3 access
|
||||
AWS_SECRET_ACCESS_KEY # S3 secret
|
||||
|
||||
# Auto-provided
|
||||
GITHUB_TOKEN # Auto-available in actions
|
||||
```
|
||||
|
||||
#### How to Generate
|
||||
|
||||
**App-specific password:**
|
||||
```bash
|
||||
# 1. Go to appleid.apple.com
|
||||
# 2. Sign in > Security > App-Specific Passwords
|
||||
# 3. Generate for "Hex GitHub Actions"
|
||||
# 4. Copy xxxx-xxxx-xxxx-xxxx
|
||||
```
|
||||
|
||||
**Certificate base64:**
|
||||
```bash
|
||||
# Export from Keychain Access as .p12
|
||||
base64 -i DeveloperID.p12 | pbcopy
|
||||
# Paste into secret
|
||||
```
|
||||
|
||||
## Comparison: What Others Do
|
||||
|
||||
### Electron Apps (Sparkle, DMG)
|
||||
- **VSCode**: Custom Azure Pipelines + scripts
|
||||
- **Obsidian**: Electron-builder in Actions
|
||||
- **Raycast**: Similar to our Option B (script from workflow)
|
||||
|
||||
### Native macOS Apps
|
||||
- **Bartender**: Manual releases
|
||||
- **Alfred**: Private infrastructure
|
||||
- **Rectangle**: GitHub Actions with notarize action
|
||||
|
||||
**Trend:** Moving toward script-based (easier to test/debug locally)
|
||||
|
||||
## Homebrew Cask Setup
|
||||
|
||||
After first release:
|
||||
|
||||
```bash
|
||||
# 1. Create cask
|
||||
brew create --cask hex
|
||||
|
||||
# 2. Test
|
||||
brew install --cask hex
|
||||
|
||||
# 3. Submit PR to homebrew/cask
|
||||
# OR create personal tap: homebrew-hex
|
||||
```
|
||||
|
||||
**Cask needs:**
|
||||
- ZIP artifact (not DMG) - universal convention
|
||||
- GitHub release URL
|
||||
- SHA256 (auto-calculated by brew)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Local Testing
|
||||
```bash
|
||||
# Full release (dry-run flag needed?)
|
||||
bun run tools/release.ts --bucket hex-updates-test
|
||||
|
||||
# Test specific steps
|
||||
bun run tools/release.ts --skip-upload
|
||||
```
|
||||
|
||||
### CI Testing
|
||||
```bash
|
||||
# Trigger workflow
|
||||
gh workflow run release.yml
|
||||
|
||||
# Monitor
|
||||
gh run list
|
||||
gh run view <run-id>
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. ✅ Modify `release.ts` - add CI auth
|
||||
2. ✅ Add ZIP creation to `release.ts`
|
||||
3. ✅ Replace workflow YAML
|
||||
4. ✅ Set all 9 secrets
|
||||
5. ✅ Test release to staging S3 bucket
|
||||
6. ✅ First real release
|
||||
7. ✅ Submit Homebrew cask
|
||||
|
||||
## ✅ IMPLEMENTED (Tag-Based Approach)
|
||||
|
||||
### Version Management
|
||||
**Solved:** Git tags determine version
|
||||
- Push `v0.2.12` → builds version `0.2.12`
|
||||
- Auto-increments build number
|
||||
- Updates all version files
|
||||
|
||||
### Configuration
|
||||
**Solved:** Effect Config system
|
||||
- Auto-detects CI vs local environment
|
||||
- Uses keychain locally, env vars in CI
|
||||
- Type-safe configuration
|
||||
|
||||
### Artifacts
|
||||
**Solved:** Creates both DMG + ZIP
|
||||
- DMG for direct download / Sparkle
|
||||
- ZIP for Homebrew cask
|
||||
- Both notarized and signed
|
||||
|
||||
### Changelog Automation
|
||||
**Solved:** Changesets CLI drives SemVer + release notes
|
||||
- Contributors run `bunx changeset` to capture a patch/minor/major summary
|
||||
- Release tool enforces pending changesets, runs `changeset version`, and copies the aggregated `CHANGELOG.md` into `Hex/Resources/changelog.md`
|
||||
- GitHub releases reuse the generated changelog section via `--notes-file`, so Sparkle, the in-app sheet, and GitHub stay identical
|
||||
|
||||
## Remaining Questions
|
||||
|
||||
1. **S3 bucket:** Staging bucket for pre-release testing?
|
||||
2. **Rollback:** Failed notarization handling?
|
||||
3. **Certificate expiry:** Track and rotate Developer ID cert
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
- [x] Update `release.ts` with Effect Config
|
||||
- [x] Add CI detection (Apple creds optional)
|
||||
- [x] Add ZIP creation for Homebrew
|
||||
- [x] Rewrite workflow for tag-based releases
|
||||
- [x] Document release process
|
||||
- [ ] Configure secrets (manual step)
|
||||
- [ ] First release test
|
||||
- [ ] Submit Homebrew cask
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
# Hex Release Process
|
||||
|
||||
## Overview
|
||||
|
||||
Releases are triggered by pushing git tags. The system automatically:
|
||||
- Builds & signs the app
|
||||
- Notarizes with Apple
|
||||
- Creates DMG + ZIP artifacts
|
||||
- Uploads to S3 for Sparkle updates
|
||||
- Creates GitHub release
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Create and push a tag
|
||||
git tag v0.2.12
|
||||
git push origin v0.2.12
|
||||
|
||||
# GitHub Actions will automatically:
|
||||
# 1. Build Hex v0.2.12
|
||||
# 2. Notarize with Apple
|
||||
# 3. Upload to S3 (Sparkle)
|
||||
# 4. Create GitHub release with DMG + ZIP
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Tag-based versioning:**
|
||||
- Tag `v0.2.12` → builds version `0.2.12`
|
||||
- Updates `Info.plist` and `project.pbxproj`
|
||||
- Auto-increments build number
|
||||
|
||||
**Effect Config system:**
|
||||
```typescript
|
||||
// Reads from environment variables
|
||||
BUCKET=hex-updates // Default
|
||||
VERSION=v0.2.12 // From git tag
|
||||
APPLE_ID=your@email.com // CI only
|
||||
APPLE_ID_PASSWORD=xxxx-xxxx // CI only
|
||||
AWS_ACCESS_KEY_ID=... // Required
|
||||
AWS_SECRET_ACCESS_KEY=... // Required
|
||||
```
|
||||
|
||||
**Local vs CI:**
|
||||
- **Local**: Uses keychain profile `AC_PASSWORD`
|
||||
- **CI**: Uses `APPLE_ID` / `APPLE_ID_PASSWORD` env vars
|
||||
|
||||
## Local Testing
|
||||
|
||||
```bash
|
||||
# Setup keychain profile (one-time)
|
||||
xcrun notarytool store-credentials "AC_PASSWORD"
|
||||
|
||||
# Test release locally (doesn't upload)
|
||||
cd tools
|
||||
VERSION=v0.2.12-test \
|
||||
AWS_ACCESS_KEY_ID=... \
|
||||
AWS_SECRET_ACCESS_KEY=... \
|
||||
bun run release.ts
|
||||
```
|
||||
|
||||
## Required Secrets
|
||||
|
||||
Set via: `gh secret set SECRET_NAME`
|
||||
|
||||
### Apple (Notarization)
|
||||
```bash
|
||||
APPLE_ID # your@email.com
|
||||
APPLE_ID_PASSWORD # App-specific password from appleid.apple.com
|
||||
TEAM_ID # QC99C9JE59
|
||||
```
|
||||
|
||||
### Code Signing
|
||||
```bash
|
||||
MACOS_CERTIFICATE # base64 -i cert.p12 | pbcopy
|
||||
MACOS_CERTIFICATE_PWD # Certificate password
|
||||
```
|
||||
|
||||
### AWS (S3 / Sparkle)
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY
|
||||
```
|
||||
|
||||
## Artifacts
|
||||
|
||||
Each release creates:
|
||||
- `Hex-{version}.dmg` - Signed, notarized DMG
|
||||
- `Hex-{version}.zip` - For Homebrew cask
|
||||
- `hex-latest.dmg` - Always points to latest
|
||||
- `appcast.xml` - Sparkle update feed
|
||||
|
||||
## Homebrew Cask
|
||||
|
||||
After first release, update `hex.rb`:
|
||||
|
||||
```bash
|
||||
# Get SHA256
|
||||
curl -L https://github.com/kitlangton/Hex/releases/download/v0.2.12/Hex-v0.2.12.zip -o Hex.zip
|
||||
shasum -a 256 Hex.zip
|
||||
|
||||
# Update hex.rb with version and SHA
|
||||
```
|
||||
|
||||
Submit to:
|
||||
- **Personal tap**: `homebrew-hex` (easier)
|
||||
- **Official cask**: PR to `homebrew/homebrew-cask`
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
### CFBundleVersion Requirements
|
||||
|
||||
**NEVER manually edit CFBundleVersion or reuse build numbers.** The release pipeline automatically increments CFBundleVersion with each release to ensure Sparkle can properly generate the appcast feed.
|
||||
|
||||
- `updates/` directory must only contain DMGs with strictly increasing CFBundleVersion values
|
||||
- Duplicate build numbers will block appcast generation and break updates for existing users
|
||||
- The release script preserves the last 3 DMGs in `updates/` for delta generation
|
||||
- Older versions are automatically moved to `updates/old_updates/`
|
||||
|
||||
If you accidentally create a release with a duplicate CFBundleVersion:
|
||||
1. Delete the problematic DMG from `updates/`
|
||||
2. Move any other old DMGs to `updates/old_updates/`
|
||||
3. Regenerate appcast: `./bin/generate_appcast --maximum-deltas 3 updates`
|
||||
4. Re-upload cleaned artifacts to S3
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Notarization fails
|
||||
- Check Apple ID credentials
|
||||
- Verify app-specific password
|
||||
- Ensure `TEAM_ID` is correct
|
||||
|
||||
### S3 upload fails
|
||||
- Verify AWS credentials
|
||||
- Check bucket permissions
|
||||
- Ensure bucket exists
|
||||
|
||||
### Build fails
|
||||
- Check Xcode version (16.2)
|
||||
- Verify code signing setup
|
||||
- Check certificate validity
|
||||
|
||||
### Sparkle updates not appearing
|
||||
- Verify appcast.xml lists versions in descending CFBundleVersion order
|
||||
- Check that CFBundleVersion values are unique and strictly increasing
|
||||
- Ensure no duplicate build numbers exist in updates/
|
||||
- Test feed URL: https://hex-updates.s3.amazonaws.com/appcast.xml
|
||||
|
||||
## Files
|
||||
|
||||
- `tools/release.ts` - Main release script (Effect)
|
||||
- `.github/workflows/release.yml` - CI workflow
|
||||
- `bin/generate_appcast` - Sparkle appcast generator
|
||||
- `hex.rb` - Homebrew cask formula
|
||||
Reference in New Issue
Block a user