iPhone client for one ambient room, and the phone side of the Passport push-to-talk accessory.
DuoDuo Pocket puts your DuoDuo in your pocket: hold a button, say what you need, let go, and the answer comes back to the phone and the Passport screen. Product page: openduo.ai/docs/pocket.
The app with its built-in demo content, on iPhone and on iPhone Duo (simulator), and the Passport running the pocket firmware.
- A native SwiftUI conversation with 多多 for one room: text, photos and files, hold-to-talk,
and an ambient mode in which the phone listens to the room and speaks the answers
(
docs/design/native-app.md). History comes from the channel and is cached for offline reading. - The app reaches the channel over an embedded Tailscale node (tsnet, userspace); no VPN profile.
- The Passport (a FoloToy AI Passport running the
openduo/pocket-passportfirmware) streams Opus over BLE while its button is held; the app uploads one voice note per press, tells the Passport the transcript, and forwards the answer to its screen. - Hold-to-talk in the app records with the phone microphone and uses the same voice-note path.
- Optional, off by default: 让多多知道我到了哪 (Let DuoDuo know where I've arrived). iOS visit
monitoring names the places you arrive at on the phone, and the next message tells your own
DuoDuo the place name (no coordinates) in an
X-Ambient-Placeheader.
DuoDuo Pocket is in TestFlight beta. Apply with the sign-up form; the TestFlight invitation arrives by email once the application is approved. Keep automatic updates on for DuoDuo Pocket in TestFlight: when your ambient at home is upgraded, an older app stops talking to it and shows 多多随身需要更新 until it is updated. Without a DuoDuo of your own, tap 先体验 (Try it first) on the first screen to see the app with built-in demo content.
- duoduo, the DuoDuo daemon, on your host.
- The ambient channel,
@openduo/channel-ambientfrom openduo/ambient, beside the daemon with a pocket room, reachable on your tailnet. Theduoduo-ambientskill walks your duoduo through it:npx -y skills add https://github.com/openduo/duoduo --skill duoduo-ambient. - Optionally, a FoloToy AI Passport running the pocket firmware from openduo/pocket-passport.
SwiftUI views ◀── AppModel (main actor; snapshots only while active)
▲ ▲ ▲
ConversationStore ──┘ AmbientController ──┘ PocketEngine ──┘
cache · cursor · the /live socket · Passport link · voice notes ·
outbox · turn state VoiceIO (AVAudioEngine, AEC, Opus) RESULT · replies
│ │ │
└──────────── tsnet (Go, gomobile) ─────────────────┴──▶ channel host (tailnet, HTTPS)
/api/state · /api/imlog · /api/messages · /api/upload · /api/attachment
/live (events; hello + Opus while ambient is on)
Passport ──BLE──▶ PassportLink ─▶ PocketEngine ─▶ RESULT / REPLY / REPLY_DONE / APP_STATE / WORK
- Everything dials out through tsnet; nothing listens on the phone.
- One
/livesocket per room: conversation events always; with ambient on, the app sayshelloon it to hold the room's capture seat. Turning ambient off closes it and opens a new one withouthello. Each connection resumes from the last row the app holds (since=<seq>). - Typed text and voice notes are messages (
POST /api/messages, oneIdempotency-Keyeach): a 202 accepts them, and their text, delivery and answer arrive as/liveevents. - Background budget: per audio frame only O(append) work on audio queues; SwiftUI is updated only while the app is active, from coalesced snapshots.
- Background modes:
bluetooth-central(state restoration relaunches the app for Passport traffic),audio(ambient mode keeps listening when locked, started and ended by the user) andfetch(Background App Refresh catches up answers for the Passport). Arrivals need no background mode: iOS relaunches the app to deliver a visit.
- Xcode with the iOS 17 SDK or later, XcodeGen, CMake.
- Go and gomobile (
go install golang.org/x/mobile/cmd/gomobile@latest && gomobile init). - A channel host from ambient reachable on your tailnet.
scripts/build-opus.shandscripts/build-tsbridge.sh.Config/Signing.local.xcconfig(gitignored) withDEVELOPMENT_TEAM = <team>. If your team cannot use the bundle iddev.openduo.pocket, setPOCKET_BUNDLE_ID = <your id>there too.DEVICE_UDID=<udid> scripts/build.sh.CONFIGURATION=Releasebuilds without the Debug fixtures and probes.- First launch walks through Tailscale login, channel host + room (with a live check) and the Passport. Settings keep host, HTTPS/port and room in UserDefaults.
- Debug builds show design fixtures with
-PocketDemo <screen>(list indocs/design/native-app.md§11), e.g.xcrun devicectl device process launch --device <udid> dev.openduo.pocket -- -PocketDemo chat-working -ui.appearance dark.
Tools/PassportSim/run.shruns the device side of the link on a Mac (pairing, INFO, presses of synthesized speech in real time, KEEPALIVE until REPLY_DONE). Commands go tologs/control.Tools/mock-channelanswers/api/messages,/live,/api/imlogand/api/statewith fake transcripts, for testing the upload/reply path without a channel.- Use a phone that does not share an Apple ID with the Mac: Continuity holds its own LE link to the Mac and can block connects.
docs/design/native-app.md— product UI design and decisions.docs/ble-protocol.md— the Passport link and voice-note contract, as implemented.docs/constants.md— every constant with its basis.docs/opus.md— libopus build choice.docs/design/icon/final/— the app icon source.
See SECURITY.md. The app talks only to a channel on your own tailnet.
The code is FSL-1.1-Apache-2.0 (see LICENSE). The app icon,
the 多多 / DuoDuo and OpenDuo names and the design mockups are OpenDuo brand assets with no open
licence; forks must replace them (see NOTICE). Bundled third-party code (libopus, Tailscale's
tsnet and its Go dependencies) keeps its own licences; see THIRD_PARTY_NOTICES.md.


