diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 867e69b..ec16396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 94e6e02..8a76c76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` @@ -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. @@ -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 diff --git a/DESIGN.md b/DESIGN.md index 9304373..21c14b2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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) @@ -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). diff --git a/README.md b/README.md index 8d7b5dd..60a8063 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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+ diff --git a/Sources/TingleCore/FirmwareUpgrader.swift b/Sources/TingleCore/FirmwareUpgrader.swift index 84fef94..1dc1da5 100644 --- a/Sources/TingleCore/FirmwareUpgrader.swift +++ b/Sources/TingleCore/FirmwareUpgrader.swift @@ -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." } @@ -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.")) @@ -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 { diff --git a/Sources/TingleCore/Flasher.swift b/Sources/TingleCore/Flasher.swift index d8bfe60..355b635 100644 --- a/Sources/TingleCore/Flasher.swift +++ b/Sources/TingleCore/Flasher.swift @@ -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"] @@ -21,6 +24,7 @@ enum Flasher { case wrongFrequencyCount case payloadMissing case ejectFailed(String) + case verificationFailed(String) var errorDescription: String? { switch self { @@ -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. @@ -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() } @@ -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) { @@ -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 @@ -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. diff --git a/Sources/TingleCore/FloatingAlert.swift b/Sources/TingleCore/FloatingAlert.swift index 9bd177e..b38e978 100644 --- a/Sources/TingleCore/FloatingAlert.swift +++ b/Sources/TingleCore/FloatingAlert.swift @@ -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 } @@ -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() diff --git a/Sources/TingleCore/Prefs.swift b/Sources/TingleCore/Prefs.swift index 4c7e9c9..c23ec5a 100644 --- a/Sources/TingleCore/Prefs.swift +++ b/Sources/TingleCore/Prefs.swift @@ -5,8 +5,23 @@ import Foundation /// which caused two live bugs on 2026-07-11: a stale input pin haunting /// only the dev build, and the dev build re-offering a firmware upgrade /// the installed app had already done. One domain, one truth. -enum Prefs { - static let suite = UserDefaults(suiteName: "com.tutorintelligence.tingle") ?? .standard +public enum Prefs { + public static let suite = UserDefaults(suiteName: "com.tutorintelligence.tingle") ?? .standard + + /// Firmware bookkeeping is PER DEVICE: the flag records what tingle + /// last flashed onto a specific ting (identified by its USB serial + /// suffix). A global flag once marked a brand-new device as upgraded + /// because a DIFFERENT ting had been (2026-08-05) — the new unit's + /// shipped firmware then corrupted every button chirp over 3.5mm. + public static func lastFlashedFirmware(serial: String?) -> String? { + guard let serial else { return nil } + return suite.string(forKey: "lastFlashedFirmware.\(serial)") + } + + public static func setLastFlashedFirmware(_ version: String, serial: String?) { + guard let serial else { return } + suite.set(version, forKey: "lastFlashedFirmware.\(serial)") + } /// Beacon level (dBFS) of the last pilot lock — seeds the detector's /// single-beacon fast re-lock across backend AND app restarts. diff --git a/Sources/TingleCore/StatusItemController.swift b/Sources/TingleCore/StatusItemController.swift index d1161d9..b10c081 100644 --- a/Sources/TingleCore/StatusItemController.swift +++ b/Sources/TingleCore/StatusItemController.swift @@ -424,7 +424,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { // its running version over USB). let docked = lastBackendState == .connectedSerial || Flasher.isTingleiskMounted if !isFlashing, docked { - let flashed = Prefs.suite.string(forKey: "lastFlashedFirmware") + let flashed = Prefs.lastFlashedFirmware(serial: FirmwareUpgrader.dockedSerial()) if flashed == FirmwareUpgrader.version { firmwareItem.title = "ting firmware \(FirmwareUpgrader.version) (latest)" firmwareItem.isEnabled = false @@ -438,6 +438,24 @@ final class StatusItemController: NSObject, NSMenuDelegate { } @objc private func flashEP() { + // A ting on unknown/old firmware corrupts its own chirp words over + // the audio path (the stock engine plays slot samples on squeeze/ + // buttons; fw 1.0.8 fixes it — root-caused 2026-08-05). Flashing a + // payload onto unknown firmware produces a device that half-works, + // so route first-time devices through the full setup: firmware + // ritual, then the payload flash rides on the end of that flow. + if Prefs.lastFlashedFirmware(serial: FirmwareUpgrader.dockedSerial()) != FirmwareUpgrader.version { + FloatingAlert.show( + title: "New ting — firmware first", + text: """ + tingle hasn't seen this ting on firmware \(FirmwareUpgrader.version) yet, and older \ + firmware corrupts the button signals over the audio cable. Starting the \ + firmware upgrade — it installs the tingle payload at the end, so this is \ + the whole setup in one flow. + """) + upgradeFirmware() + return + } runFlasherOperation(.flash(frequencies: configStore.config.toneFrequencies), title: "Flash EP") } @@ -449,7 +467,15 @@ final class StatusItemController: NSObject, NSMenuDelegate { /// BOOT disk shows up (or the flow ends). private var firmwareInstructions: FloatingAlert? + private var firmwareFlowRunning = false + @objc private func upgradeFirmware() { + // One flow at a time: a re-click while a previous attempt is still + // waiting for the boot disk raced two flows — the stale one's + // "failed" popup landed next to the new one's "succeeded" + // (2026-08-05). + guard !firmwareFlowRunning else { return } + firmwareFlowRunning = true // Same live-status plumbing as Flash EP, but the operation also // spans the guided bootloader dance and firmware write. A one-line // menu ticker can't teach a four-step ritual, so a floating card @@ -461,8 +487,10 @@ final class StatusItemController: NSObject, NSMenuDelegate { 1. Take off the ting's lower lid. Keep USB connected. 2. Squeeze the handle — and KEEP IT SQUEEZED through every \ step until this card disappears. - 3. Still squeezing, double-click the small button above the \ - USB port. + 3. Still squeezing, DOUBLE-CLICK the small button above the \ + USB port — two quick presses in rapid succession, like a \ + mouse double-click. (Two slow separate presses just \ + power-cycles it.) 4. A disk named TING BOOT appears and tingle does the rest — \ you can let go when this card closes. """, showsOK: false) @@ -484,6 +512,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { } } completion: { [weak self] result in guard let self else { return } + self.firmwareFlowRunning = false self.firmwareInstructions?.close() self.firmwareInstructions = nil self.flashProgress?.close() diff --git a/Tests/tingle-tests/FlasherTests.swift b/Tests/tingle-tests/FlasherTests.swift new file mode 100644 index 0000000..5e402ee --- /dev/null +++ b/Tests/tingle-tests/FlasherTests.swift @@ -0,0 +1,62 @@ +import Foundation +import TingleCore + +func runFlasherTests() { + // --- write verification (the safety net that turns a bad flash into a + // loud error instead of a silent dead device) --- + let a = Data([1, 2, 3, 4]) + expect(Flasher.verificationMismatch(label: "main.py", expected: a, actual: a) == nil, + "flasher: identical bytes verify clean") + // truncation — the exact failure mode this device hit (4096 of 11548) + let truncated = Data([1, 2, 3]) + if let reason = Flasher.verificationMismatch(label: "main.py", expected: a, actual: truncated) { + expect(reason.contains("4 bytes") && reason.contains("3"), + "flasher: truncation reports both sizes") + } else { + expect(false, "flasher: truncation must be caught") + } + // same length, different content + expect(Flasher.verificationMismatch(label: "1.wav", expected: a, actual: Data([1, 2, 3, 9])) != nil, + "flasher: content mismatch caught") + // empty read-back (write silently produced nothing) + expect(Flasher.verificationMismatch(label: "main.py", expected: a, actual: Data()) != nil, + "flasher: empty read-back caught") + + // --- dry-bus config.json --- + let cfg = Flasher.configJSON() + guard let obj = try? JSONSerialization.jsonObject(with: cfg) as? [String: Any] else { + expect(false, "flasher: config.json is valid JSON"); return + } + let presets = obj["presets"] as? [[String: Any]] ?? [] + expect(presets.count == 4, "flasher: four presets (orange cycles 0-3)") + // EVERY preset must be dry SAMPLE-only — one non-dry preset the orange + // button can land on reintroduces the chirp-mangling bug. + for p in presets { + let effects = (p["list"] as? [[String: Any]] ?? []).compactMap { $0["effect"] as? String } + expect(effects == ["SAMPLE"], "flasher: preset \(p["pos"] ?? "?") is dry SAMPLE-only") + } + let samples = obj["samples"] as? [[String: Any]] ?? [] + expect(samples.count == 4, "flasher: four sample slots") + for (i, smp) in samples.enumerated() { + expect(smp["file"] as? String == "\(i + 1).wav", "flasher: sample \(i) -> \(i + 1).wav") + } + // pure ASCII (serial-write deploys drop non-ASCII) + expect(cfg.allSatisfy { $0 < 128 }, "flasher: config.json is pure ASCII") + + // --- per-device firmware bookkeeping (a global flag once marked a + // new device as upgraded because a DIFFERENT ting had been) --- + Prefs.setLastFlashedFirmware("9.9.9", serial: "TESTSERIALA") + expect(Prefs.lastFlashedFirmware(serial: "TESTSERIALA") == "9.9.9", + "prefs: firmware flag roundtrips per serial") + expect(Prefs.lastFlashedFirmware(serial: "TESTSERIALB") == nil, + "prefs: a different serial is unknown") + expect(Prefs.lastFlashedFirmware(serial: nil) == nil, + "prefs: no serial means unknown firmware") + Prefs.suite.removeObject(forKey: "lastFlashedFirmware.TESTSERIALA") + + // --- symbol WAV shape (25ms mono 16-bit @48k = 44 header + 2400 pcm) --- + let wav = Flasher.symbolWAV(symbol: 0) + expect(wav.count == 2444, "flasher: symbol WAV is 2444 bytes") + expect(wav.prefix(4) == Data("RIFF".utf8) && wav[8..<12] == Data("WAVE".utf8), + "flasher: symbol WAV has RIFF/WAVE header") +} diff --git a/Tests/tingle-tests/main.swift b/Tests/tingle-tests/main.swift index c53c045..f848a59 100644 --- a/Tests/tingle-tests/main.swift +++ b/Tests/tingle-tests/main.swift @@ -109,6 +109,64 @@ func expectEqual(_ a: T, _ b: T, _ label: String, } runTranscriptTyperTests() +// --decode-wav [channel]: run the PRODUCTION SymbolDetector over +// a raw line-in recording and print every decode with timestamps — for +// diagnosing field captures against known ground truth. +if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--decode-wav" { + let data = try! Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2])) + let channel = CommandLine.arguments.count >= 4 ? Int(CommandLine.arguments[3])! : 0 + // minimal RIFF parse: find 'data' chunk, assume 16-bit PCM + let bytes = [UInt8](data) + var offset = 12 + var pcmStart = 0, pcmLen = 0 + var channels = 1 + while offset + 8 < bytes.count { + let id = String(bytes: bytes[offset..: write the exact bytes Flash EP ships (WAVs + +// config.json) for independent cross-checking against the on-device copies. +if CommandLine.arguments.count >= 3, CommandLine.arguments[1] == "--dump-flash-bytes" { + let dir = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) + try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + for i in 0..<4 { + try! Flasher.symbolWAV(symbol: i).write(to: dir.appendingPathComponent("\(i + 1).wav")) + } + try! Flasher.configJSON().write(to: dir.appendingPathComponent("config.json")) + print("dumped flash bytes to \(dir.path)") + exit(0) +} + runConfigTests() runTriggerReconcilerTests() runTriggerRoutingTests() @@ -119,6 +177,7 @@ runSymbolDetectorTests() runBeaconScannerTests() runGuardrailTests() runRewriteTests() +runFlasherTests() runAudioEngineOpsTests() runAudioHardwareTests()