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, 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.
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).
- 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, including the 多多 avatar artwork, 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.

