Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
50cd3d7
feat(director): add remote director mode functionality
f Feb 26, 2026
96f14e4
feat(api): Add Director Mode API documentation
f Feb 26, 2026
4a3e135
docs(security): Add security policy guidelines and recommendations
f Feb 27, 2026
3b8eabc
fix: add session token auth and connection limit to DirectorServer
iamcanturk Feb 27, 2026
a1551f7
fix: enforce connection limit, offload broadcast to background queue,…
iamcanturk Feb 27, 2026
f5ebad8
Merge commit from fork
f Feb 27, 2026
9ff2fda
Merge branch 'master' into advisory-fix-1
iamcanturk Feb 27, 2026
53bc99b
fix: broadcast only to authenticated connections after merge conflict…
iamcanturk Feb 27, 2026
3524fa9
Merge commit from fork
f Feb 27, 2026
248f232
fix: always show page sidebar and read current page on play
Mar 7, 2026
6f481f9
feat: add cue color and brightness settings for annotation text
Mar 7, 2026
83bfdce
Merge pull request #20 from nathanael/fix/sidebar-and-play-current-page
f Mar 8, 2026
e5b566b
Merge pull request #21 from nathanael/feat/cue-color-brightness
f Mar 8, 2026
cb4a10b
Fix: Keep text visible after WPM auto-scroll reaches the end
ecwilsonaz Mar 24, 2026
41ccdec
Apply same fix to ExternalDisplayView and BrowserServer
ecwilsonaz Mar 24, 2026
cd53d97
Stop BrowserServer timer from advancing after scroll completes
ecwilsonaz Mar 24, 2026
fcaa54c
fix: downmix multi-channel audio to mono for SFSpeechRecognizer
chrishutchins Mar 26, 2026
6a1c7c0
Add overlay transparency with blur and slider
bhupathii Mar 31, 2026
24a4ca1
Merge pull request #33 from bhupathii/master
f Apr 21, 2026
4e02896
Merge pull request #32 from chrishutchins/fix/multichannel-speech-rec…
f Apr 21, 2026
6521569
Merge pull request #30 from ecwilsonaz/fix/keep-text-visible-after-wp…
f Apr 21, 2026
29b203e
fix: voice tracking highlight and mic stall bugs in word tracking mode
Apr 22, 2026
a399fc3
fix: start playback from selected page and install key monitor in pin…
lagilist May 6, 2026
f90c3a0
Merge pull request #44 from lagilist/fix/start-from-selected-page
f May 8, 2026
46384a5
Merge pull request #37 from afkehaya/fix/voice-tracking-bugs
f May 8, 2026
6c34baa
Bump version to 1.5.2
f May 8, 2026
79bbc47
fix: keep recognition alive across jumps so tracking resumes instantly
samgutentag Jul 8, 2026
5f4f94f
fix: retry when the speech recognizer is transiently unavailable
samgutentag Jul 8, 2026
de4e891
fix: harden jump anchor and retry paths against lifecycle races
samgutentag Jul 8, 2026
b5c040d
Merge pull request #67 from samgutentag/fix/recognition-resilience
f Jul 9, 2026
5c9331b
feat: add language detection and resilient microphone handling
f Jul 9, 2026
1ec7a78
fix: generate auditable Homebrew cask URLs
f Jul 9, 2026
aefe299
Delete CNAME
f Jul 9, 2026
b83e18e
fix: responsive word highlighting and microphone-only voice-activated…
f Jul 9, 2026
929477d
Bump version to 1.6.1
github-actions[bot] Jul 9, 2026
75822e2
fix: keep mouse-follow overlays within screen bounds
f Jul 9, 2026
1c94b2c
Bump version to 1.6.2
github-actions[bot] Jul 9, 2026
ce11831
fix: RTL text rendering for Arabic, Hebrew, and other right-to-left s…
MHegazy89 Aug 8, 2026
f731154
Bump version to 1.6.3
github-actions[bot] Aug 8, 2026
02b9f91
Fix RTL rendering for Hebrew and Arabic scripts
Jonathan-Asher Aug 9, 2026
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
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Textream/Textream.xcodeproj/project.pbxproj
git commit -m "Bump version to ${{ steps.version.outputs.number }}" || echo "No changes to commit"
git push origin HEAD:refs/heads/main || echo "Push skipped"
git push origin HEAD:refs/heads/master || echo "Push skipped"

build:
needs: version
Expand Down Expand Up @@ -146,17 +146,17 @@ jobs:

if MacOS.version >= :tahoe
sha256 "${SHA256_26}"
url "https://github.com/f/textream/releases/download/${VERSION}/Textream.dmg"
url "https://github.com/f/textream/releases/download/v#{version}/Textream.dmg"
else
sha256 "${SHA256_15}"
url "https://github.com/f/textream/releases/download/${VERSION}/Textream-macos15.dmg"
url "https://github.com/f/textream/releases/download/v#{version}/Textream-macos15.dmg"
end

name "Textream"
desc "macOS teleprompter that highlights your script in real-time as you speak"
homepage "https://github.com/f/textream"

depends_on macos: ">= :sequoia"
depends_on macos: :sequoia

app "Textream.app"

Expand Down
129 changes: 129 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ View your teleprompter on **any device** — phone, tablet, or another computer
- **Configurable port** — Default port 7373, adjustable in advanced settings.
- **Fully local** — All traffic stays on your local network. Nothing leaves your Wi-Fi.

### Director Mode

Let someone else control your teleprompter remotely. A director can write, edit, and push scripts to your teleprompter in real time from any browser.

- **Enable in Settings → Director** — Starts a dedicated HTTP + WebSocket server (default port 7575).
- **Remote web UI** — The director opens a mobile-friendly web page with a full-featured script editor.
- **Live text editing** — The director types or pastes a script, hits Go, and your teleprompter starts immediately with word tracking.
- **Read-locked highlighting** — Already-read text is highlighted and locked in the web editor. Only unread text remains editable.
- **Real-time sync** — Word progress, waveform, mic status, and audio levels stream to the director's browser at 10 Hz.
- **Single-page mode** — Director mode works with a single page of text. Multi-page scripts are not used.
- **Editor disabled** — When director mode is active, the macOS editor is replaced with a QR code overlay so the director has full control.
- **QR code** — Scan or share the QR code from Settings or the editor overlay to connect the director instantly.

### File Support

- **PowerPoint notes import** — Drop a .pptx file to extract presenter notes as pages. For Keynote or Google Slides, export to PowerPoint first.
Expand Down Expand Up @@ -189,6 +202,7 @@ Textream/
├── SettingsView.swift # Tabbed settings UI
├── MarqueeTextView.swift # Word flow layout and highlighting
├── BrowserServer.swift # Remote connection HTTP + WebSocket server
├── DirectorServer.swift # Director mode HTTP + WebSocket server
├── PresentationNotesExtractor.swift # PPTX presenter notes extraction
├── UpdateChecker.swift # GitHub release update checker
└── Assets.xcassets/ # App icon and colors
Expand All @@ -204,6 +218,121 @@ textream://read?text=Hello%20world

It also registers as a macOS Service, so you can select text in any app and send it to Textream via the Services menu.

## Director Mode API

The Director Mode exposes an HTTP server and a WebSocket server on your local network. You can build your own director client using the protocol below.

### Ports

| Service | Default Port | Configurable in |
|---|---|---|
| **HTTP** (serves the built-in web UI) | `7575` | Settings → Director → Advanced |
| **WebSocket** (bidirectional communication) | `7576` (HTTP port + 1) | Automatic |

### Connecting

1. Open a WebSocket connection to `ws://<mac-ip>:<ws-port>` (e.g. `ws://192.168.1.42:7576`).
2. The server immediately begins sending **state frames** as JSON at ~10 Hz once a script is active.
3. Send **command frames** as JSON to control the teleprompter.

### Commands (Client → App)

Send JSON messages over the WebSocket:

#### `setText` — Start reading a new script

```json
{
"type": "setText",
"text": "Welcome everyone to today's live stream..."
}
```

Replaces the current text, starts word tracking, and opens the teleprompter overlay. This is equivalent to pressing **Go** in the built-in web UI.

#### `updateText` — Edit unread text while active

```json
{
"type": "updateText",
"text": "Welcome everyone to today's live stream We changed the rest of the script...",
"readCharCount": 42
}
```

Updates the full script text while preserving the read position. `readCharCount` is the number of characters already read (locked). Only text after this offset is replaced. Use this for live editing during a read.

#### `stop` — Stop the teleprompter

```json
{
"type": "stop"
}
```

Stops word tracking and dismisses the overlay.

### State (App → Client)

The server broadcasts a JSON object on every tick (~100 ms):

```json
{
"words": ["Welcome", "everyone", "to", "today's", "live", "stream"],
"highlightedCharCount": 24,
"totalCharCount": 120,
"isActive": true,
"isDone": false,
"isListening": true,
"fontColor": "#F5F5F7",
"lastSpokenText": "Welcome everyone to today's",
"audioLevels": [0.12, 0.34, 0.08, ...]
}
```

| Field | Type | Description |
|---|---|---|
| `words` | `string[]` | The script split into words (same order as displayed in the overlay). |
| `highlightedCharCount` | `int` | Number of characters recognized so far. Use this to determine the read boundary. |
| `totalCharCount` | `int` | Total character count of the full script. |
| `isActive` | `bool` | `true` when the teleprompter overlay is visible and a script is loaded. |
| `isDone` | `bool` | `true` when `highlightedCharCount >= totalCharCount` (finished reading). |
| `isListening` | `bool` | `true` when the microphone is actively listening. |
| `fontColor` | `string` | CSS color of the text in the overlay (user preference). |
| `lastSpokenText` | `string` | Last recognized speech fragment. |
| `audioLevels` | `double[]` | Array of audio level samples (0.0–1.0) for waveform visualization. |

When the overlay is not active, the server sends a frame with `isActive: false` and empty arrays.

### Example: Minimal Python Client

```python
import asyncio, json, websockets

async def director():
async with websockets.connect("ws://192.168.1.42:7576") as ws:
# Send a script
await ws.send(json.dumps({
"type": "setText",
"text": "Hello everyone, welcome to the show."
}))

# Listen for state updates
async for msg in ws:
state = json.loads(msg)
pct = 0
if state["totalCharCount"] > 0:
pct = state["highlightedCharCount"] / state["totalCharCount"] * 100
print(f"Progress: {pct:.0f}% Done: {state['isDone']}")
if state["isDone"]:
break

# Stop
await ws.send(json.dumps({"type": "stop"}))

asyncio.run(director())
```

## License

MIT
Expand Down
67 changes: 67 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Security Policy

## Supported Versions

| Version | Supported |
|---------|-----------|
| Latest release | ✅ |
| Older releases | ❌ |

Only the latest release receives security updates. Please keep Textream up to date.

## Reporting a Vulnerability

If you discover a security vulnerability in Textream, **please do not open a public issue.**

Instead, report it privately:

- **Email:** [fka@fka.dev](mailto:fka@fka.dev)
- **Subject:** `[SECURITY] Textream — <brief description>`

Please include:

1. A description of the vulnerability
2. Steps to reproduce the issue
3. Potential impact
4. Suggested fix (if any)

You should receive an acknowledgment within **48 hours**. Once confirmed, a fix will be prioritized and released as soon as possible.

## Security Considerations

### On-Device Processing

All speech recognition runs locally via Apple's Speech framework. No audio data, transcripts, or scripts are sent to external servers. There are no accounts, analytics, or telemetry.

### Network Servers

Textream includes two optional network servers that bind to your **local network only**:

| Server | Default Port | Purpose |
|--------|-------------|---------|
| **Remote Connection** (BrowserServer) | `8080` | Read-only teleprompter mirror for a browser |
| **Director Mode** (DirectorServer) | `7575` / `7576` | Remote script editing via HTTP + WebSocket |

**Important:**

- Both servers are **disabled by default** and must be explicitly enabled in Settings.
- Servers listen on **all local interfaces** (`0.0.0.0`). Anyone on the same network can connect when enabled.
- There is **no authentication** on these servers. Do not enable them on untrusted or public networks.
- The HTTP server serves a single-page web UI. The WebSocket server handles real-time communication.
- Disable the servers when not in use.

### Permissions

Textream requests the following macOS permissions:

- **Microphone** — Required for speech recognition and voice-activated features.
- **Speech Recognition** — Required for on-device word tracking.
- **Local Network** — Required when Remote Connection or Director Mode is enabled.

No other permissions are requested or required.

## Recommendations

- Only enable network servers on trusted private networks.
- Disable Remote Connection and Director Mode when not actively in use.
- Keep Textream updated to the latest version via Homebrew or GitHub Releases.
4 changes: 2 additions & 2 deletions Textream/Textream.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = dev.fka.textream;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down Expand Up @@ -312,7 +312,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.6.3;
PRODUCT_BUNDLE_IDENTIFIER = dev.fka.textream;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
Expand Down
51 changes: 42 additions & 9 deletions Textream/Textream/BrowserServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct BrowserState: Codable {
let isListening: Bool
let isDone: Bool
let fontColor: String
let cueColor: String
let hasNextPage: Bool
let isActive: Bool
let highlightWords: Bool
Expand Down Expand Up @@ -186,21 +187,28 @@ class BrowserServer {

let charCount: Int
let mode = NotchSettings.shared.listeningMode
// Check if scroll already reached the end, to stop advancing the timer
let scrollDone = totalCharCount > 0 && charOffsetForWordProgress(timerWordProgress) >= totalCharCount
switch mode {
case .wordTracking:
charCount = speechRecognizer?.recognizedCharCount ?? 0
case .classic:
timerWordProgress += NotchSettings.shared.scrollSpeed * 0.1
if !scrollDone {
timerWordProgress += NotchSettings.shared.scrollSpeed * 0.1
}
charCount = charOffsetForWordProgress(timerWordProgress)
case .silencePaused:
if speechRecognizer?.isListening == true && (speechRecognizer?.isSpeaking ?? false) {
if !scrollDone && speechRecognizer?.isListening == true && (speechRecognizer?.isSpeaking ?? false) {
timerWordProgress += NotchSettings.shared.scrollSpeed * 0.1
}
charCount = charOffsetForWordProgress(timerWordProgress)
}

let effective = min(charCount, totalCharCount)
let isDone = totalCharCount > 0 && effective >= totalCharCount
let rawDone = totalCharCount > 0 && effective >= totalCharCount
// In classic/silence-paused modes on the last page, suppress Done so the
// browser keeps showing the prompter text (speaker may still be talking).
let isDone = rawDone && (mode == .wordTracking || hasNextPage)

let highlightWords = mode == .wordTracking

Expand All @@ -212,6 +220,7 @@ class BrowserServer {
isListening: speechRecognizer?.isListening ?? false,
isDone: isDone,
fontColor: NotchSettings.shared.fontColorPreset.cssColor,
cueColor: NotchSettings.shared.cueColorPreset.cssColor,
hasNextPage: hasNextPage,
isActive: true,
highlightWords: highlightWords,
Expand All @@ -224,7 +233,7 @@ class BrowserServer {
let state = BrowserState(
words: [], highlightedCharCount: 0, totalCharCount: 0,
audioLevels: [], isListening: false, isDone: false,
fontColor: "#ffffff", hasNextPage: false, isActive: false,
fontColor: "#ffffff", cueColor: "#ffffff", hasNextPage: false, isActive: false,
highlightWords: true, lastSpokenText: ""
)
broadcast(state)
Expand Down Expand Up @@ -416,18 +425,35 @@ class BrowserServer {
}
function rgba(rgb,a){return 'rgba('+rgb[0]+','+rgb[1]+','+rgb[2]+','+a+')';}

// Any letter or digit, in any script. An explicit range list silently
// demotes every unlisted script (Hebrew, Arabic, Greek, Thai…) to an
// annotation, so those words render as dim cues and never highlight.
const WORDY=/[\\p{L}\\p{N}]/u;

// Scripts written right-to-left
const RTL_CHAR=/[\\p{Script=Hebrew}\\p{Script=Arabic}\\p{Script=Syriac}\\p{Script=Thaana}\\p{Script=Nko}\\p{Script=Samaritan}\\p{Script=Mandaic}\\p{Script=Adlam}]/gu;

// Detect annotation words: [bracket] or emoji-only (no letters/digits)
function isAnnotation(w){
if(w.startsWith('[')&&w.endsWith(']'))return true;
return!/[a-zA-Z0-9\\u00C0-\\u024F\\u0400-\\u04FF\\u3000-\\u9FFF\\uAC00-\\uD7AF]/.test(w);
return!WORDY.test(w);
}

// Count letters+digits in a word
function letterCount(w){
let n=0;for(const ch of w)if(/[a-zA-Z0-9\\u00C0-\\u024F\\u0400-\\u04FF\\u3000-\\u9FFF\\uAC00-\\uD7AF]/.test(ch))n++;
let n=0;for(const ch of w)if(WORDY.test(ch))n++;
return Math.max(1,n);
}

// Base direction for the script: whichever side has more letters wins, so
// a Hebrew script opening with a Latin product name still reads right-to-left.
function baseDirection(words){
const s=words.join(' ');
const rtl=(s.match(RTL_CHAR)||[]).length;
const letters=(s.match(/\\p{L}/gu)||[]).length;
return rtl>letters-rtl?'rtl':'ltr';
}

/* ---- connection ---- */

function connect(){
Expand Down Expand Up @@ -457,14 +483,21 @@ class BrowserServer {
const c=document.getElementById('text-container'),
words=s.words||[],
fc=s.fontColor||'#ffffff',
cc=s.cueColor||fc,
rgb=parseColor(fc),
crgb=parseColor(cc),
hlWords=s.highlightWords!==false,
hcc=s.highlightedCharCount||0;

// Rebuild spans only when words change
const wordKey=words.length+'|'+(words[0]||'')+'|'+(words[words.length-1]||'');
if(wordKey!==prevWordKey){
c.innerHTML='';
// Let the browser's bidi engine order the line; it keeps embedded
// left-to-right runs (names, numbers) in reading order.
const dir=baseDirection(words);
c.setAttribute('dir',dir);
c.style.textAlign=dir==='rtl'?'right':'left';
let cp=0;
for(let i=0;i<words.length;i++){
const wd=words[i],ann=isAnnotation(wd);
Expand Down Expand Up @@ -510,10 +543,10 @@ class BrowserServer {

if(!hlWords){
// Classic / silence-paused: uniform color, no per-word highlight
color=ann?'rgba(255,255,255,0.4)':fc;
color=ann?rgba(crgb,0.4):fc;
} else if(ann){
// Annotation: italic, white with varying opacity
color=isFullyLit?'rgba(255,255,255,0.5)':'rgba(255,255,255,0.2)';
// Annotation: cue color with varying opacity
color=isFullyLit?rgba(crgb,0.5):rgba(crgb,0.2);
} else if(isFullyLit){
// Already read: dimmed
color=rgba(rgb,0.3);
Expand Down
Loading