Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
name: CI
# PRs only: main is squash-merge-only, so a push to main is always a tree
# that just passed this exact suite — re-running it burned a macOS runner
# per merge for nothing (auto-release does its own version self-test).
on:
push:
branches: [main]
pull_request:

jobs:
Expand Down
17 changes: 11 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ dictation, or the device payload. Add a regression test with every bug fix.
firmware…"): lower lid off, USB attached, HOLD the handle the whole
time, and while holding, DOUBLE-CLICK the small button above the USB
port -> "TING BOOT" volume appears; drop the .uf2 there. NOT the
power-ritual button dance.
power-ritual button dance. The double-click must be two RAPID presses
(mouse-double-click speed) — two deliberate presses just power-cycle
(cost a failed flow, 2026-08-05).
- Never present NSAlert.runModal() — a modal session parks the main run
loop and freezes dictation/serial/menu while open. Use FloatingAlert.
- Serial REPL access (`/dev/cu.usbmodemEPTXP*`, 115200): `\r\x03\x03`
Expand Down Expand Up @@ -125,9 +127,10 @@ dictation, or the device payload. Add a regression test with every bug fix.
the typing worker converges on the latest hypothesis.
- TranscriptTyper corrections are bounded to the volatile region; any
external typing during dictation must freezeVolatile() first.
- TCC (mic/accessibility) keys off the code signature: ad-hoc rebuilds of
the .app re-prompt every time until Developer ID signing lands. The dev
binary attributes permissions to the invoking terminal instead.
- TCC (mic/accessibility) keys off the code signature: release builds are
Developer ID-signed so grants persist across updates; ad-hoc .app
rebuilds re-prompt every time. The dev binary attributes permissions to
the invoking terminal instead.
PermissionsMonitor owns the UX: "!" icon badge, fix-it menu items, launch
prompts, and a tccutil-reset repair for the stale-Accessibility-row case.

Expand Down Expand Up @@ -162,8 +165,10 @@ public key live in scripts/bundle.sh's Info.plist; private key is in the
SPARKLE_ED_PRIVATE_KEY repo secret and Josh's login keychain, account
"tingle") → tags → GitHub Release with the .app zip → bumps the cask in
tutorintelligence/homebrew-tap. `tools/test_next_version.py` covers the
version math. Until the Apple Developer secrets land the build is unsigned
but releases still cut. Optional secrets: MACOS_CERT_P12_BASE64,
version math. Release builds are Developer ID-signed, notarized, and
stapled (hardened runtime + entitlements in packaging/entitlements.plist;
Sparkle nested executables signed inside-out — the bare Autoupdate binary
is the one notarization rejects when missed). Secrets: MACOS_CERT_P12_BASE64,
MACOS_CERT_PASSWORD, NOTARY_KEY / NOTARY_KEY_ID / NOTARY_ISSUER_ID (App
Store Connect API key — the team Apple ID is Microsoft-federated, so
app-specific passwords are unavailable), TAP_DEPLOY_KEY (SSH deploy key
Expand Down
23 changes: 14 additions & 9 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,19 @@ summon-agent script.
## Distribution

`scripts/bundle.sh` assembles `tingle.app` (SwiftPM release build + Info.plist
+ resource bundle). Release flow (`.github/workflows/release.yml`): tag →
tests → Developer ID sign → notarize → GitHub Release → bump the cask
(template: [packaging/tingle.rb](packaging/tingle.rb)) in
`tutorintelligence/homebrew-tap`. Blocked on Apple Developer enrollment; until
then ad-hoc signing re-prompts TCC on every rebuild.
+ resource bundle) and signs it: Developer ID with hardened runtime, secure
timestamps, and entitlements (mic, Apple Events); Sparkle's nested
executables are signed inside-out. Release flow
(`.github/workflows/auto-release.yml`, on every push to main): version from
the merge subject → build → sign → notarize + staple → Sparkle-signed
appcast → GitHub Release → bump the cask in `tutorintelligence/homebrew-tap`.
The stable signing identity is what lets TCC grants survive updates.

## Roadmap

1. Custom language model (`SFCustomLanguageModelData`: phrases, templates,
custom pronunciations) for domain adaptation beyond contextual strings.
2. Apple Developer enrollment → signing/notarization/tap → first release.
3. Exploration: the firmware's ADC event messages (type 0x1X) may carry
2. Exploration: the firmware's ADC event messages (type 0x1X) may carry
higher-rate handle data (merged-tap recovery, analog gestures).

## Device reference (fw 1.0.4, all measured on hardware)
Expand All @@ -209,7 +210,11 @@ then ad-hoc signing re-prompts TCC on every rebuild.
1/2 with val 0=white, 1=green, 2=orange (green/orange act only with the
handle released). ADC events are type 0x1X (unused).
- Line out 2 VRMS, scaled by the volume knob; the engine reproduces up to
19kHz at full level. Factory FX presets inject sample playback after
pitch effects, so tones survive PIXIE/ROBOT.
19kHz at full level. Factory FX presets can pitch-shift or attenuate the
ultrasonic chirps (a mangled chirp measured ~80dB below a clean one) — so
FLASH EP writes a `config.json` with four DRY (`SAMPLE`-only) presets:
the orange button cycles the active preset, and dry presets keep every
one of them chirp-safe. Without it, green/orange decoded over USB (which
bypasses audio) but not over the 3.5mm chirp path.
- Green/orange state changes commit ~10 ticks (~164ms) after the press
(stock priming debounce).
20 changes: 9 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,22 @@ You need:
2. **A USB line-in adapter.** The ting's curly cable outputs *line-level*
audio, and Mac headphone jacks don't accept line-in — plugging the ting
straight into your laptop's 3.5mm port will not work. Any USB audio
interface with a line input is fine; a cheap one that works well is the
interface with a line input is fine; two that are tested and work well:
the [Sonos Line-In adapter](https://www.bhphotovideo.com/c/product/1754583-REG/sonos_ldnglww1blk_sonos_line_in_adapter.html)
(smallest option — a bare USB-C dongle) and the
[Cubilux USB-C line-in adapter](https://www.amazon.com/dp/B0CNCL21RR)
(line-in + mic-in + headphone-out on one USB-C plug).
(bulkier; adds mic-in + headphone-out).
3. Optionally, **a USB-C cable** to the ting for docked use (instant button
events, battery readout) and for the one-time flash.

Wiring: ting line-out → adapter **line-in** port → Mac USB. Turn the green
volume knob under the ting's lid up to around halfway.

**Setting up a new ting:** dock it over USB and click **Flash EP** in the
tingle menu — for a ting tingle hasn't seen before, this runs the guided
firmware upgrade first (shipped firmware corrupts the button signals over
the audio cable) and installs everything in one flow.

## Getting started

### Install with Homebrew (recommended)
Expand All @@ -49,15 +56,6 @@ volume knob under the ting's lid up to around halfway.
brew install --cask tutorintelligence/tap/tingle
```

tingle isn't code-signed yet (Apple Developer enrollment in progress).
macOS tags downloaded apps with a quarantine flag so Gatekeeper can vet
them on first launch — and unsigned apps fail that check with "Apple
could not verify tingle is free of malware". The cask therefore removes
the quarantine flag after install. If you'd rather keep Gatekeeper in
the loop, build from source below instead, then approve the app under
System Settings → Privacy & Security → "Open Anyway". Once signing
lands, none of this applies.

### Install from source

You need Xcode Command Line Tools (`xcode-select --install`) and macOS 26+
Expand Down
12 changes: 10 additions & 2 deletions Sources/TingleCore/FirmwareUpgrader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ enum FirmwareUpgrader {
case .unzipFailed:
return "Could not extract the firmware from Teenage Engineering's zip."
case .bootloaderTimeout:
return "Never saw the TING BOOT disk. The trick is to KEEP the handle squeezed the whole time: lid off, USB connected, squeeze and hold, and while still holding, double-click the small button above the USB port. Run the upgrade again to retry."
return "Never saw the TING BOOT disk. Two things trip people up: KEEP the handle squeezed the whole time, and the button needs a true DOUBLE-CLICK — two quick presses in rapid succession, like a mouse double-click (two deliberate separate presses just power-cycle it). Lid off, USB connected, squeeze and hold, double-click the small button above the USB port. Run the upgrade again to retry."
case .tingdiskTimeout:
return "Firmware was written, but TINGDISK didn't come back. Power-cycle the ting and run Flash EP from the menu."
}
Expand Down Expand Up @@ -78,7 +78,7 @@ enum FirmwareUpgrader {
report("Reinstalling the tingle payload…")
try Flasher.flashEP(frequencies: frequencies, progress: report)

Prefs.suite.set(version, forKey: "lastFlashedFirmware")
Prefs.setLastFlashedFirmware(version, serial: dockedSerial())
log.info("firmware upgrade to \(version, privacy: .public) complete")
DispatchQueue.main.async {
completion(.success("Firmware \(version) and the tingle event engine are on the ting."))
Expand All @@ -90,6 +90,14 @@ enum FirmwareUpgrader {
}
}

/// Identity of the docked ting: the unique suffix of its CDC device
/// path (e.g. /dev/cu.usbmodemEPTXP3R31 -> "EPTXP3R31"). Nil when no
/// ting is enumerated over USB.
static func dockedSerial() -> String? {
DetectionCoordinator.findSerialDevicePath()
.map { $0.replacingOccurrences(of: "/dev/cu.usbmodem", with: "") }
}

// MARK: - Steps

private static func fetchUF2() throws -> Data {
Expand Down
115 changes: 92 additions & 23 deletions Sources/TingleCore/Flasher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import AppKit
import AVFoundation
import os

/// FLASH EP: writes mode-tone WAVs plus the tingle event engine (main.py — the
/// FLASH EP: writes the chirp WAVs, a dry-bus config.json (so FX presets
/// never mangle the chirps), and the tingle event engine (main.py — the
/// ting executes /fat/main.py at boot, see DESIGN.md "key discovery") to the
/// TINGDISK volume, with backup of what was there, cleans up AppleDouble junk,
/// and ejects. "Restore stock" deletes the overrides instead — without
/// main.py and 1-4.wav the device is 100% stock.
enum Flasher {
/// TINGDISK volume. Every write is read back and verified before the eject,
/// so a truncated or failed flash fails loudly instead of leaving a device
/// that reports success but doesn't work. Backs up what was there, cleans up
/// AppleDouble junk, ejects. "Restore stock" deletes the overrides instead —
/// without main.py and 1-4.wav the device is 100% stock.
public enum Flasher {
static let volumeURL = URL(fileURLWithPath: "/Volumes/TINGDISK", isDirectory: true)

private static let sampleFileNames = ["1.wav", "2.wav", "3.wav", "4.wav"]
Expand All @@ -21,6 +24,7 @@ enum Flasher {
case wrongFrequencyCount
case payloadMissing
case ejectFailed(String)
case verificationFailed(String)

var errorDescription: String? {
switch self {
Expand All @@ -34,10 +38,64 @@ enum Flasher {
return "Config must define exactly 4 tone frequencies."
case .payloadMissing:
return "The tingle_main.py device payload is missing from the app's resources."
case .verificationFailed(let detail):
return "Flash verification failed — \(detail). Re-seat the ting over USB and flash again."
}
}
}

/// The device config.json: four sample slots mapped to the chirp WAVs,
/// and four DRY presets (SAMPLE effect only). The dry bus is load-
/// bearing, not cosmetic: the orange button cycles the active preset,
/// and any factory FX preset (pitch-shift, lo-fi, telephone) mangles or
/// attenuates the ultrasonic chirps — measured as button chirps landing
/// ~80dB below the beacons, so green/orange never decoded over the 3.5mm
/// audio path (they were fine over USB, which bypasses audio). Four dry
/// presets mean every preset the orange button can select plays the
/// chirp clean. Kept minimal + ASCII on purpose.
public static func configJSON() -> Data {
var presets = ""
for pos in 0..<4 {
presets += """
{"pos":\(pos),"list":[{"effect":"SAMPLE"}],"trigger":{"row":0}}\(pos < 3 ? "," : "")
"""
}
var samples = ""
for pos in 0..<4 {
samples += """
{"pos":\(pos),"file":"\(pos + 1).wav","playmode":"oneshot"}\(pos < 3 ? "," : "")
"""
}
let json = "{\"name\":\"tingle passthrough\",\"samples\":[\(samples)],\"presets\":[\(presets)]}"
return Data(json.utf8)
}

/// Pure comparison for the write-verification (unit-tested): returns a
/// human-readable mismatch reason, or nil when the read-back is exact.
public static func verificationMismatch(label: String, expected: Data, actual: Data) -> String? {
if expected.count != actual.count {
return "\(label) wrote \(expected.count) bytes but \(actual.count) landed on the device"
}
if expected != actual {
return "\(label) content did not match after write-back"
}
return nil
}

/// Write `data` to `url`, then read it straight back and fail loudly on
/// any mismatch — the safety net that turns a truncated/failed/reverted
/// flash (which used to report success and leave a dead device) into a
/// clear, actionable error.
private static func writeVerified(_ data: Data, to url: URL, label: String) throws {
try? FileManager.default.removeItem(at: url)
try data.write(to: url)
let actual = try Data(contentsOf: url)
if let reason = verificationMismatch(label: label, expected: data, actual: actual) {
log.error("verification failed: \(reason, privacy: .public)")
throw FlasherError.verificationFailed(reason)
}
}

/// The device event engine shipped as main.py. Bundled via Package.swift
/// resources from Sources/TingleCore/Resources/tingle_main.py, which must
/// be kept byte-identical with the source of truth at device/tingle_main.py.
Expand Down Expand Up @@ -114,31 +172,33 @@ enum Flasher {
progress("Backing up disk contents…")
try backupExisting()

let fm = FileManager.default
// Coded chirp symbols from SymbolSet (the config's toneFrequencies
// are ignored — symbol shapes are the air-gap contract between
// Flasher and SymbolDetector).
// Flasher and SymbolDetector). Every write is read back and verified.
for index in 0..<4 {
progress("Writing symbol \(index + 1) of 4…")
let url = volumeURL.appendingPathComponent("\(index + 1).wav")
try? fm.removeItem(at: url)
try writeSymbolWAV(symbol: index, to: url)
try writeVerified(symbolWAV(symbol: index), to: url, label: "\(index + 1).wav")
let sweep = SymbolSet.sweeps[index]
log.info("wrote \(url.lastPathComponent, privacy: .public) chirp \(Int(sweep.start))->\(Int(sweep.end))Hz")
}

progress("Writing event engine (main.py)…")
let mainPyURL = volumeURL.appendingPathComponent("main.py")
try? fm.removeItem(at: mainPyURL)
try payload.write(to: mainPyURL)
log.info("wrote main.py event engine (\(payload.count) bytes)")
// Dry-bus FX presets so the orange button can never select a preset
// that mangles the chirps (see configJSON()).
progress("Writing FX presets (config.json)…")
try writeVerified(configJSON(), to: volumeURL.appendingPathComponent("config.json"),
label: "config.json")

// TODO: also ship a config.json with dry-bus FX presets so symbols
// bypass the active FX preset; today the device config.json is left alone
// (pitch-shifting presets like PIXIE/ROBOT mangle the tones — see
// DESIGN.md "Known limitation").
progress("Writing event engine (main.py)…")
try writeVerified(payload, to: volumeURL.appendingPathComponent("main.py"), label: "main.py")
log.info("wrote main.py event engine (\(payload.count) bytes), verified")

removeAppleDoubleFiles()
// Flush the FAT to the device before ejecting: a read-back can be
// served from the page cache, so sync is what actually commits the
// bytes to the ting's flash.
progress("Flushing…")
syncVolume()
progress("Ejecting TINGDISK…")
try eject()
}
Expand Down Expand Up @@ -168,10 +228,11 @@ enum Flasher {
/// Duration stays 80ms: the event engine triggers a chirp's second
/// burst ~114ms after the first (fw 1.0.8 ticks), relying on the
/// sample having finished.
static func writeSymbolWAV(symbol: Int, to url: URL) throws {
// Assembled by hand as Data (RIFF header + 16-bit PCM) rather than
// via AVAudioFile: no file handle stays open on the volume, so the
// eject that follows can't hit fBsyErr from our own writer.
/// One coded chirp symbol as a mono 16-bit WAV (RIFF header + PCM),
/// assembled by hand as Data — no file handle stays open on the volume,
/// so the eject can't hit fBsyErr from our own writer. Pure, so the
/// exact bytes can be verified after writing.
public static func symbolWAV(symbol: Int) -> Data {
let sampleRate = SymbolSet.sampleRate
var pcm = Data(capacity: SymbolSet.frameCount * 2)
for value in SymbolSet.pcm(symbol: symbol) {
Expand All @@ -194,7 +255,7 @@ enum Flasher {
append("data"); append32(UInt32(pcm.count))
wav.append(pcm)

try wav.write(to: url)
return wav
}

// MARK: - Volume housekeeping
Expand Down Expand Up @@ -236,6 +297,14 @@ enum Flasher {
}
}

/// Flush filesystem buffers to the physical device before ejecting.
private static func syncVolume() {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/sync")
try? task.run()
task.waitUntilExit()
}

private static func eject() throws {
// Freshly written files leave the volume briefly "busy" (OSStatus
// -47, fBsyErr) — Spotlight/fseventsd touch new files. Retry.
Expand Down
10 changes: 9 additions & 1 deletion Sources/TingleCore/FloatingAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ final class FloatingAlert: NSObject, NSWindowDelegate {
live.append(alert)
alert.panel.center()
alert.panel.makeKeyAndOrderFront(nil)
alert.panel.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true)
return alert
}
Expand Down Expand Up @@ -61,7 +62,14 @@ final class FloatingAlert: NSObject, NSWindowDelegate {
defer: false
)
panel.title = "tingle"
panel.level = .floating
// Guidance cards must survive the user working elsewhere: NSPanel
// HIDES on app deactivate by default, which made the firmware-
// ritual card vanish the moment focus moved (user left squeezing
// the handle with no instructions). Stick above everything, on
// every Space.
panel.level = .statusBar
panel.hidesOnDeactivate = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.isReleasedWhenClosed = false

super.init()
Expand Down
Loading
Loading