Cinema companion for films in languages you don't fully understand: original-language audio in one AirPod, a scrolling subtitle window as the visual sync anchor. iOS + Apple Watch remote.
0
stars
287
commits
Swift
primary language
Jul 20, 2026
updated
Single-user iOS app for cinema-goers who watch films in languages they
don't fully understand. Prepare a "session" by attaching a pre-extracted
original-language audio file (.m4a) and a subtitle file (.srt). In the
cinema, listen to the original audio through one AirPod while the on-screen
scrolling subtitle window acts as a visual sync anchor. Tapping any subtitle
line seeks the audio to that line's timestamp.
The in-cinema experience is fully offline; the only online path is the optional Catalog for pulling prepared sessions onto the phone (see below). No offset arithmetic, no calibration, no accounts, no onboarding, no settings.
Browse the catalog · Watch with the subtitle river · See what's inside
Allspeak is in private TestFlight beta. Want a build? Reach out and I'll add you as a tester - direct invite, no App Store review.
Requires an iPhone on iOS 26+ (Apple Watch on watchOS 26+ optional, for the remote). Everything below is for building from source.
brew install xcodegenxcodegen generate
open Allspeak.xcodeproj
Then in Xcode: pick the Allspeak scheme, choose a destination, and build /
run. There are no third-party Swift packages; all dependencies are system
frameworks (SwiftUI, AVFoundation, CoreData).
The MVP does not bundle audio extraction. You supply the .m4a and .srt
pair yourself (e.g. via a Mac-side script that strips them from an MKV
source) and transfer them to the iPhone through the Files app, AirDrop, or
iCloud Drive. Inside Allspeak:
+ on the Sessions screen.After the Light · 21:30).Choose audio files and pick one or more .m4a files via the
system file picker. Each file becomes a separate audio track on the
session (e.g. loudnorm-only, demucs+loudnorm, DFN v3, or a
different dubbing studio). Label each picked file before saving — the
first track becomes the default.Choose subtitles file and pick the .srt. The subtitle timeline
is shared across all tracks in the session.Picked files are copied into the app's Documents container at
Documents/sessions/<uuid>/track-<trackID>-<filename> (one per track)
plus a single subtitles file, and are independent of the original source
location after import. To add more tracks to an existing session (e.g.
drop in a new dub when it becomes available), use the Tracks action in
the session row's context menu.
scripts/bifrost.fish)The .m4a voice track is produced by scripts/bifrost.fish (fish, macOS):
decode, optional FPS retime, Demucs vocal separation, Sidon speech
restoration, then loudnorm. See the header comment in the script for full
usage.
Demucs runs on the Apple Silicon GPU via the
demucs-mlx port (base htdemucs),
roughly 20x faster than the old CPU path (~7 min vs ~3h for a 2h film). Base
htdemucs is used rather than htdemucs_ft: the 4-model _ft ensemble is far
heavier on this GPU for a quality difference that is inaudible after Sidon
restoration on cam sources. Two pins are REQUIRED:
mlx-audio-io==1.3.10 holds mlx at 0.31.0; mlx 0.31.2 made GPU streams
thread-local and crashes the port with There is no Stream(gpu, 1) in current thread (upstream regression,
ml-explore/mlx-lm#1179).
Drop it only once demucs-mlx runs on mlx >= 0.31.2.[convert] extra converts htdemucs's weights to MLX on first run (cached
in ~/.cache/demucs-mlx/); only htdemucs_ft ships pre-converted weights.The port writes all four stems to <out>/<track>/vocals.wav (no per-model
subdir), so the script resolves the vocals path with find. Sidon stays on CPU
(~25 min for a 2h film): its checkpoints are CUDA-traced TorchScript with float64,
which Apple's MPS backend does not support.
In PlayerView, a toolbar Menu appears when a session has more than one
track. Tapping it lists every track with a checkmark on the active one;
selecting another switches the playing audio while preserving the current
position (~100-300ms gap during reload, no crossfade). The same list lives
on the Apple Watch as a third TabView page — see below.
There is no mic matching, no offset arithmetic, and no calibration. When the dub drifts from the screen, tap the subtitle line currently showing — on the phone's scrolling subtitle window or in the watch's cue list — and the audio seeks to that line's timestamp. The skip controls cover the fine adjustment from there — ±0.5s on the phone's player, ±3s / ±1s on the watch transport.
Playing any session writes a per-screening JSONL log to
Documents/diagnostics/ (pulled via the Files app) capturing every play, pause,
skip, and seek with its source (phone or watch) for after-the-fact analysis on
the Mac. It is always on, has no UI, and no setting. Logs older than 30 days are
deleted when the next session starts.
Sessions prepared on the Mac no longer have to reach the phone over AirDrop.
The Mac uploads a session once to the personal catalog backend
(https://allspeak.pkarpovich.dev), and the phone imports it from anywhere -
LTE on the way to the cinema included.
The Sessions screen carries a Mine / Catalog segmented control below the
large title. Mine (the default) is the usual list of local sessions; Catalog
lists the sessions published to the backend, newest first, each row showing the
title, total download size, and track labels.
Catalog segment. Rows fetch on appear; a fetch failure shows
a plain inline error with a Retry button.What's inside list of every
audio track with its size, plus the subtitle row) or tap Import directly on
the row..srt) from Cloudflare R2 via
short-lived presigned URLs, then creates a regular local session through the
same importMultiTrackSession pipeline used for manual imports - an imported
session is indistinguishable from a hand-made one and appears under Mine
with its tracks (the server's default track pre-selected) and subtitles.BGContinuedProcessingTask: locking the phone
mid-download does not stop it, and the system shows a progress card. Files are
staged and sha256-verified one at a time under Application Support, so a
download interrupted by lock, expiry, or a killed app resumes by skipping the
files already verified on the next Import tap.Each imported session records the server id, revision, and per-file sha256
hashes in a sidecar server.json inside the session directory (invisible to the
rest of the app, deleted with the session folder - no Core Data schema change).
Mine rows for catalog-linked sessions show a Catalog · v<revision> badge.
When the last catalog fetch reports a higher revision, that row gains an Update
button and an N updates available banner appears above the list (no background
polling - the state derives from the store's last fetch). Tapping Update opens
a sync sheet that shows, per file, whether it changed or is the same, plus
the total download size. Applying the sync downloads only the changed files
and reconciles the local session with existing repository mutations (add / remove
track, replace subtitle, rename), preserving the current playback position.
The backend URL and read token are baked in at build time via an xcconfig, the same pattern used for signing. For a fresh checkout:
cp Allspeak/CatalogConfig.xcconfig.example Allspeak/CatalogConfig.xcconfig
# then edit Allspeak/CatalogConfig.xcconfig:
# ALLSPEAK_CATALOG_URL = https://allspeak.pkarpovich.dev
# ALLSPEAK_CATALOG_READ_TOKEN = <your read token>
Allspeak/CatalogConfig.xcconfig is git-ignored; Signing.xcconfig pulls it in
with #include? "CatalogConfig.xcconfig" (optional - an absent file does not
break the build, and undefined settings substitute as empty strings, so CI and
first-run builds still compile). The two values flow into Info.plist
(AllspeakCatalogURL / AllspeakCatalogReadToken) and are read by
CatalogConfig. On push to main,
.github/workflows/deploy-testflight.yml writes the real CatalogConfig.xcconfig
from the ALLSPEAK_CATALOG_URL / ALLSPEAK_CATALOG_READ_TOKEN GitHub secrets.
Allspeak ships with a companion watchOS app (AllspeakWatch) that lets you
resync subtitles in a cinema without taking the iPhone out of your pocket.
The watch is a thin remote: it sends commands (play/pause, skip ±1s / ±3s,
seek-to-cue, set volume) to the iPhone, which remains the audio host.
Transport controls · Tap a line to seek
Watch app
(Settings → Watch).updateApplicationContext and the full cue bundle via
transferFile (gzipped JSON, cached on-watch for restart resilience).3, 1); every skip tap plays a
click haptic. Play/Pause is a warm-tinted glowing pill. The Digital Crown
drives the phone's real system output volume (re-synced from the phone's
reported outputVolume while the Crown is idle) with haptic ticks at each
detent; the native Crown indicator fills with loudness so a full bar = max
volume. As a consequence of the system locking fill direction to rotation
direction, turning the Crown down raises the volume. Rapid rotation
coalesces into a single trailing-edge command.Single track placeholder when the
session has only one track.skip(+5) command rather than five
round-trips; mixed fine + coarse taps sum in the same window.currentTime + (now - serverDate) while
playing) so the UI never feels frozen. The progress readout re-anchors from
whichever source has the newer serverDate - the live sendMessage snapshot
or the latest-wins SessionMetadata application context (which now carries
its own serverDate anchor, refreshed on every playback state change). That
context is delivered in the background, so even after the watch has been
unreachable for a stretch (phone in pocket, screen off), the next wrist-down
Always-On redraw re-anchors to the freshest position rather than freezing.The commands round-tripped over WatchConnectivity are: play, pause,
togglePlayPause, skip(seconds:), seek(time:), switchTrack(id:),
setVolume(_:), and requestCueChunk(sessionID:revision:index:). Session
metadata delivered to the watch carries a tracks: [TrackInfo] array,
the current activeTrackID, and an optional serverDate playback anchor
(decoded with decodeIfPresent, so an older phone build still decodes). The
wire contract lives in
Allspeak/Watch/WireProtocol.swift — see the header comment there for
the protocol summary.
.glassEffect() with warm-tint overlays
per the design's chrome / plate variants.Session and AudioTrack entities
(one-to-many, cascade delete). Persistent history tracking is enabled;
lightweight migration carries pre-multitrack sessions forward by
back-filling a single AudioTrack(label: "Original", isDefault: true)
from the legacy Session.audioFilename field. The current model version is
Allspeak v5, which drops the two optional Session fields that carried the
removed cinema-sync files (catalogFilename, added in v3, and
dtwMapFilename, added in v4). Every migration in the chain is lightweight
and inferred, so a store written by any earlier build opens in place with its
sessions intact. File payloads (audio + srt) are not stored in Core Data —
only filenames.AVAudioPlayer per player session, .playback category,
.spokenAudio mode. Background audio is permitted via the audio entry in
UIBackgroundModes.MPNowPlayingInfoCenter (metadata + elapsed time) and
MPRemoteCommandCenter (play/pause, ±15s skip, scrub). The card and
remote-command transport (lock screen, Control Center, AirPods stem,
external Bluetooth remotes) are driven by the same AudioController
state — there is no separate playback path.Allspeak/Design/{Tokens,Glass,Icons}.swift. No inline hex outside
Tokens.swift.Tests use Swift Testing
(@Test, #expect, #require), not XCTest. Run from Xcode (⌘U) or:
xcodebuild test -scheme Allspeak \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'
Suites are tagged (.parser, .coreData, .storage, .audio, .catalog)
so subsets can be run with the --filter flag.
Conventional Commits — feat:,
fix:, refactor:, docs:, test:, chore:.
287 commits
Swift
96.6%
Shell
2.7%
Cinema companion for films in languages you don't fully understand: original-language audio in one AirPod, a scrolling subtitle window as the visual sync anchor. iOS + Apple Watch remote.
0
stars
287
commits
Swift
primary language
Jul 20, 2026
updated
Single-user iOS app for cinema-goers who watch films in languages they
don't fully understand. Prepare a "session" by attaching a pre-extracted
original-language audio file (.m4a) and a subtitle file (.srt). In the
cinema, listen to the original audio through one AirPod while the on-screen
scrolling subtitle window acts as a visual sync anchor. Tapping any subtitle
line seeks the audio to that line's timestamp.
The in-cinema experience is fully offline; the only online path is the optional Catalog for pulling prepared sessions onto the phone (see below). No offset arithmetic, no calibration, no accounts, no onboarding, no settings.
Browse the catalog · Watch with the subtitle river · See what's inside
Allspeak is in private TestFlight beta. Want a build? Reach out and I'll add you as a tester - direct invite, no App Store review.
Requires an iPhone on iOS 26+ (Apple Watch on watchOS 26+ optional, for the remote). Everything below is for building from source.
brew install xcodegenxcodegen generate
open Allspeak.xcodeproj
Then in Xcode: pick the Allspeak scheme, choose a destination, and build /
run. There are no third-party Swift packages; all dependencies are system
frameworks (SwiftUI, AVFoundation, CoreData).
The MVP does not bundle audio extraction. You supply the .m4a and .srt
pair yourself (e.g. via a Mac-side script that strips them from an MKV
source) and transfer them to the iPhone through the Files app, AirDrop, or
iCloud Drive. Inside Allspeak:
+ on the Sessions screen.After the Light · 21:30).Choose audio files and pick one or more .m4a files via the
system file picker. Each file becomes a separate audio track on the
session (e.g. loudnorm-only, demucs+loudnorm, DFN v3, or a
different dubbing studio). Label each picked file before saving — the
first track becomes the default.Choose subtitles file and pick the .srt. The subtitle timeline
is shared across all tracks in the session.Picked files are copied into the app's Documents container at
Documents/sessions/<uuid>/track-<trackID>-<filename> (one per track)
plus a single subtitles file, and are independent of the original source
location after import. To add more tracks to an existing session (e.g.
drop in a new dub when it becomes available), use the Tracks action in
the session row's context menu.
scripts/bifrost.fish)The .m4a voice track is produced by scripts/bifrost.fish (fish, macOS):
decode, optional FPS retime, Demucs vocal separation, Sidon speech
restoration, then loudnorm. See the header comment in the script for full
usage.
Demucs runs on the Apple Silicon GPU via the
demucs-mlx port (base htdemucs),
roughly 20x faster than the old CPU path (~7 min vs ~3h for a 2h film). Base
htdemucs is used rather than htdemucs_ft: the 4-model _ft ensemble is far
heavier on this GPU for a quality difference that is inaudible after Sidon
restoration on cam sources. Two pins are REQUIRED:
mlx-audio-io==1.3.10 holds mlx at 0.31.0; mlx 0.31.2 made GPU streams
thread-local and crashes the port with There is no Stream(gpu, 1) in current thread (upstream regression,
ml-explore/mlx-lm#1179).
Drop it only once demucs-mlx runs on mlx >= 0.31.2.[convert] extra converts htdemucs's weights to MLX on first run (cached
in ~/.cache/demucs-mlx/); only htdemucs_ft ships pre-converted weights.The port writes all four stems to <out>/<track>/vocals.wav (no per-model
subdir), so the script resolves the vocals path with find. Sidon stays on CPU
(~25 min for a 2h film): its checkpoints are CUDA-traced TorchScript with float64,
which Apple's MPS backend does not support.
In PlayerView, a toolbar Menu appears when a session has more than one
track. Tapping it lists every track with a checkmark on the active one;
selecting another switches the playing audio while preserving the current
position (~100-300ms gap during reload, no crossfade). The same list lives
on the Apple Watch as a third TabView page — see below.
There is no mic matching, no offset arithmetic, and no calibration. When the dub drifts from the screen, tap the subtitle line currently showing — on the phone's scrolling subtitle window or in the watch's cue list — and the audio seeks to that line's timestamp. The skip controls cover the fine adjustment from there — ±0.5s on the phone's player, ±3s / ±1s on the watch transport.
Playing any session writes a per-screening JSONL log to
Documents/diagnostics/ (pulled via the Files app) capturing every play, pause,
skip, and seek with its source (phone or watch) for after-the-fact analysis on
the Mac. It is always on, has no UI, and no setting. Logs older than 30 days are
deleted when the next session starts.
Sessions prepared on the Mac no longer have to reach the phone over AirDrop.
The Mac uploads a session once to the personal catalog backend
(https://allspeak.pkarpovich.dev), and the phone imports it from anywhere -
LTE on the way to the cinema included.
The Sessions screen carries a Mine / Catalog segmented control below the
large title. Mine (the default) is the usual list of local sessions; Catalog
lists the sessions published to the backend, newest first, each row showing the
title, total download size, and track labels.
Catalog segment. Rows fetch on appear; a fetch failure shows
a plain inline error with a Retry button.What's inside list of every
audio track with its size, plus the subtitle row) or tap Import directly on
the row..srt) from Cloudflare R2 via
short-lived presigned URLs, then creates a regular local session through the
same importMultiTrackSession pipeline used for manual imports - an imported
session is indistinguishable from a hand-made one and appears under Mine
with its tracks (the server's default track pre-selected) and subtitles.BGContinuedProcessingTask: locking the phone
mid-download does not stop it, and the system shows a progress card. Files are
staged and sha256-verified one at a time under Application Support, so a
download interrupted by lock, expiry, or a killed app resumes by skipping the
files already verified on the next Import tap.Each imported session records the server id, revision, and per-file sha256
hashes in a sidecar server.json inside the session directory (invisible to the
rest of the app, deleted with the session folder - no Core Data schema change).
Mine rows for catalog-linked sessions show a Catalog · v<revision> badge.
When the last catalog fetch reports a higher revision, that row gains an Update
button and an N updates available banner appears above the list (no background
polling - the state derives from the store's last fetch). Tapping Update opens
a sync sheet that shows, per file, whether it changed or is the same, plus
the total download size. Applying the sync downloads only the changed files
and reconciles the local session with existing repository mutations (add / remove
track, replace subtitle, rename), preserving the current playback position.
The backend URL and read token are baked in at build time via an xcconfig, the same pattern used for signing. For a fresh checkout:
cp Allspeak/CatalogConfig.xcconfig.example Allspeak/CatalogConfig.xcconfig
# then edit Allspeak/CatalogConfig.xcconfig:
# ALLSPEAK_CATALOG_URL = https://allspeak.pkarpovich.dev
# ALLSPEAK_CATALOG_READ_TOKEN = <your read token>
Allspeak/CatalogConfig.xcconfig is git-ignored; Signing.xcconfig pulls it in
with #include? "CatalogConfig.xcconfig" (optional - an absent file does not
break the build, and undefined settings substitute as empty strings, so CI and
first-run builds still compile). The two values flow into Info.plist
(AllspeakCatalogURL / AllspeakCatalogReadToken) and are read by
CatalogConfig. On push to main,
.github/workflows/deploy-testflight.yml writes the real CatalogConfig.xcconfig
from the ALLSPEAK_CATALOG_URL / ALLSPEAK_CATALOG_READ_TOKEN GitHub secrets.
Allspeak ships with a companion watchOS app (AllspeakWatch) that lets you
resync subtitles in a cinema without taking the iPhone out of your pocket.
The watch is a thin remote: it sends commands (play/pause, skip ±1s / ±3s,
seek-to-cue, set volume) to the iPhone, which remains the audio host.
Transport controls · Tap a line to seek
Watch app
(Settings → Watch).updateApplicationContext and the full cue bundle via
transferFile (gzipped JSON, cached on-watch for restart resilience).3, 1); every skip tap plays a
click haptic. Play/Pause is a warm-tinted glowing pill. The Digital Crown
drives the phone's real system output volume (re-synced from the phone's
reported outputVolume while the Crown is idle) with haptic ticks at each
detent; the native Crown indicator fills with loudness so a full bar = max
volume. As a consequence of the system locking fill direction to rotation
direction, turning the Crown down raises the volume. Rapid rotation
coalesces into a single trailing-edge command.Single track placeholder when the
session has only one track.skip(+5) command rather than five
round-trips; mixed fine + coarse taps sum in the same window.currentTime + (now - serverDate) while
playing) so the UI never feels frozen. The progress readout re-anchors from
whichever source has the newer serverDate - the live sendMessage snapshot
or the latest-wins SessionMetadata application context (which now carries
its own serverDate anchor, refreshed on every playback state change). That
context is delivered in the background, so even after the watch has been
unreachable for a stretch (phone in pocket, screen off), the next wrist-down
Always-On redraw re-anchors to the freshest position rather than freezing.The commands round-tripped over WatchConnectivity are: play, pause,
togglePlayPause, skip(seconds:), seek(time:), switchTrack(id:),
setVolume(_:), and requestCueChunk(sessionID:revision:index:). Session
metadata delivered to the watch carries a tracks: [TrackInfo] array,
the current activeTrackID, and an optional serverDate playback anchor
(decoded with decodeIfPresent, so an older phone build still decodes). The
wire contract lives in
Allspeak/Watch/WireProtocol.swift — see the header comment there for
the protocol summary.
.glassEffect() with warm-tint overlays
per the design's chrome / plate variants.Session and AudioTrack entities
(one-to-many, cascade delete). Persistent history tracking is enabled;
lightweight migration carries pre-multitrack sessions forward by
back-filling a single AudioTrack(label: "Original", isDefault: true)
from the legacy Session.audioFilename field. The current model version is
Allspeak v5, which drops the two optional Session fields that carried the
removed cinema-sync files (catalogFilename, added in v3, and
dtwMapFilename, added in v4). Every migration in the chain is lightweight
and inferred, so a store written by any earlier build opens in place with its
sessions intact. File payloads (audio + srt) are not stored in Core Data —
only filenames.AVAudioPlayer per player session, .playback category,
.spokenAudio mode. Background audio is permitted via the audio entry in
UIBackgroundModes.MPNowPlayingInfoCenter (metadata + elapsed time) and
MPRemoteCommandCenter (play/pause, ±15s skip, scrub). The card and
remote-command transport (lock screen, Control Center, AirPods stem,
external Bluetooth remotes) are driven by the same AudioController
state — there is no separate playback path.Allspeak/Design/{Tokens,Glass,Icons}.swift. No inline hex outside
Tokens.swift.Tests use Swift Testing
(@Test, #expect, #require), not XCTest. Run from Xcode (⌘U) or:
xcodebuild test -scheme Allspeak \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'
Suites are tagged (.parser, .coreData, .storage, .audio, .catalog)
so subsets can be run with the --filter flag.
Conventional Commits — feat:,
fix:, refactor:, docs:, test:, chore:.
287 commits
Swift
96.6%
Shell
2.7%