scrypted-tuya 0.1.2-beta → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ # Tuya WebRTC talkback (Merkury CW048)
2
+
3
+ This note is for the next engineer/model. It records what actually worked on a live camera, not Tuya’s marketing docs.
4
+
5
+ **Status (2026-09-06):** live RTMP talkback uses the DAC-inverse μ-law encoder (`talkback-dac.ts`) plus a **telephone bandpass 300–3400 Hz**. Speech is usable at normal tempo; a 4 kHz whistle remains (firmware byte-play). Restricted 8-code book killed the whistle but was too quiet.
6
+
7
+ ## Camera
8
+
9
+ | | |
10
+ | ----------- | ------------------------------------------------------------------ |
11
+ | Model | Merkury CW048 Smart 1080p |
12
+ | DID | `eba0193b64396cb4fbyqwf` |
13
+ | productId | `hepu0fueo4rmwfac` |
14
+ | UID | `az1727388496362z0d7d` |
15
+ | Region | US (`protect-us.ismartlife.me`, MQTT `m1.tuyaus.com`) |
16
+ | p2pType | **4** (WebRTC). Not classic Tuya P2P command-channel. |
17
+ | Skill audio | `codecType: 101`, `dataBit: 16`, `sampleRate: 8000`, `channels: 1` |
18
+
19
+ Tuya enum (after `TUYA_CODEC_VIDEO_MAX = 99`): ADPCM=100, **PCM=101**, AAC_RAW=102, …, **G711U=105**, G711A=106. So `codecType: 101` is **PCM**, not G.711. That describes capture, not necessarily the speaker path.
20
+
21
+ ## Native ThingP2P is a dead end on this device
22
+
23
+ ThingCamera 7.10.2 / ThingP2P V3+V4 (PlayCover frameworks, macCatalyst worker in `/Users/resonaura/tuya-exp/p2p_camera_worker.mm`):
24
+
25
+ - ICE + MQTT answer succeed (`RTC_CONNECTION_MODE_P2P`).
26
+ - `connectWithP2pConnectParams` returns **-20002** if username/password are null (skips `SendAuthorizationInfo`).
27
+ - Preview callback never fires (`errcode -40205`), no incoming audio.
28
+ - MQTT `close_reason: 100` after ~15 s.
29
+ - Wrong `SendAuthorizationInfo` (admin/MD5/configV4 passwd) → immediate `close_reason: 100`.
30
+ - Protect `configV4` username/passwd stamped on the native token → ICE **-58**, no answer.
31
+
32
+ Do **not** spend more time on channel-0 auth for this p2pType-4 camera. Talkback that the original Protect web app uses is **WebRTC + MQTT protocol 312 `speaker`**.
33
+
34
+ ## Working transport
35
+
36
+ 1. Protect cookies → `POST /api/jarvis/mqtt` and `/api/jarvis/config` or `configV4` (ICE + `auth`).
37
+ 2. MQTT WSS to `m1.tuyaus.com`. In: `/av/u/{msid}`. Out: `/av/moto/{motoId}/u/{did}`.
38
+ 3. Native `tuya-streamer` (`bridge/apps/native`, libdatachannel v0.22.3) builds a PeerConnection.
39
+ 4. MQTT protocol **302** `type=offer`, `msg.mode="webrtc"`, plus `auth` and ICE token. Do **not** stamp configV4 username/passwd onto this offer (ICE -58).
40
+ 5. Answer + candidates on MQTT → `setRemoteDescription` / `addIceCandidate`.
41
+ 6. MQTT protocol **312** `type=speaker`, `msg.mode="webrtc"`, `cmdValue: 1`. Camera replies `resCode: 0`.
42
+ 7. RTP talkback: UDP to `127.0.0.1:{talkback_port}` from Node; C++ rewrites SSRC/seq/timestamp and `Track::send()`s a **full RTP packet** (12-byte header + payload). No packetizer on the send track.
43
+ 8. `cmdValue: 0` to close speaker.
44
+
45
+ Harness: `/Users/resonaura/tuya-exp/test_webrtc_talkback.js`
46
+ Encoder that sounds OK: `/Users/resonaura/tuya-exp/encode_talkback_dac.py`
47
+ Binary: `bridge/apps/native/build/tuya-streamer`
48
+
49
+ ### SDP that the camera accepts
50
+
51
+ Offer we used (camera **rejects L16**):
52
+
53
+ ```
54
+ m=audio 9 UDP/TLS/RTP/SAVPF 10 0 8
55
+ a=sendrecv
56
+ a=rtpmap:10 L16/8000/1
57
+ a=rtpmap:0 PCMU/8000/1
58
+ a=rtpmap:8 PCMA/8000/1
59
+ ```
60
+
61
+ Answer:
62
+
63
+ ```
64
+ m=audio 9 UDP/TLS/RTP/SAVPF 0 8
65
+ a=sendrecv
66
+ a=rtpmap:0 PCMU/8000/1
67
+ a=rtpmap:8 PCMA/8000/1
68
+ ```
69
+
70
+ Wire PT for talkback: **0** (PCMU). PT=8 (PCMA) was worse. L16 is never in the answer.
71
+
72
+ ## Asymmetric audio (the whole bug)
73
+
74
+ | | Camera → us (mic) | Us → camera (speaker) |
75
+ | ----------- | -------------------------------------------- | ----------------------------------- |
76
+ | SDP | PCMU/PCMA | PCMU/PCMA |
77
+ | RTP PT | **0** | **0** |
78
+ | Payload | **640 B raw s16le PCM**, 320 samples, ~40 ms | Must be **G.711 μ-law** (see below) |
79
+ | Timestamp Δ | **+320** | +160 per 160 B / 20 ms packet |
80
+
81
+ Dumping 640-byte RX payloads as `s16le` 8 kHz is real speech (RMS ~6500). Same bytes as μ-law are not speech.
82
+
83
+ **Speaker path (proven by listening):**
84
+
85
+ 1. Firmware treats PT=0 as G.711 μ-law and runs `tuya_g711_decode`.
86
+ 2. Each μ-law byte becomes one int16 (2 bytes LE).
87
+ 3. The DAC / `tal_ao` plays **each of those bytes as an 8-bit sample at 8 kHz**.
88
+ 4. So 160 μ-law bytes → 160 int16 → 320 DAC ticks → **40 ms of playback for a 20 ms packet** → **exactly 2× slow, pitch −1 octave** (“vinyl”).
89
+
90
+ That is why:
91
+
92
+ - Naive PCM16 640 B / PT=0 → **noise** (linear bytes fed to μ-law decode). Big-endian too. Camera does **not** want L16 on ingress even though it **sends** L16-like PCM on egress.
93
+ - Naive μ-law 320 B / 40 ms, `ts += 320` → **intelligible vinyl** (correct μ-law, then byte-play stretches 2×).
94
+ - 2× speed / +1 octave precompensation (`asetrate=16000,aresample=8000` then μ-law) → **normal tempo**, still hiss + 4 kHz.
95
+
96
+ ## How hiss was removed
97
+
98
+ Broadband hiss = μ-law bytes interpreted as linear PCM16 (wrong codec), or LSB of decoded int16 (quantization) mixed into the 8-bit DAC stream.
99
+
100
+ Fix: do **not** send linear PCM on PT=0. Send μ-law, but **choose codes so the two decoded bytes are the DAC ticks we want**, not “encode 8 kHz PCM then hope”.
101
+
102
+ Inverse model:
103
+
104
+ - Target DAC is 8 kHz, 8-bit, **0 = silence** (μ-law `0xFF` decodes to `(0,0)`; confirmed **no whistle in silence**).
105
+ - Each μ-law byte produces two DAC ticks (LSB, MSB of the decoded int16 LE).
106
+ - Wire rate must be **half** of DAC rate: 8000 μ-law bytes/s on the wire → 16000 DAC ticks/s? Wait: 160 samples/20 ms = 8000 μ-law/s → 16000 bytes/s at DAC = **2× 8 kHz**. That is the stretch. So for 4.2 s of speaker audio we send **2.1 s** of μ-law (16800 bytes).
107
+
108
+ Encoder: `/Users/resonaura/tuya-exp/encode_talkback_dac.py`.
109
+
110
+ ## How the 4 kHz whistle was removed
111
+
112
+ User matched the tone on a generator: **exactly 4000 Hz**. That is Nyquist of 8 kHz byte-play: LSB and MSB of each decoded int16 alternating.
113
+
114
+ - Silence `0xFF` → `(0,0)` → **no 4 kHz**.
115
+ - Speech using the full μ-law table → 4 kHz **gated with voice** (like a noise gate): mid-level G.711 codes pin LSB at ~124/132 (bias `0x84`) and put the envelope in MSB.
116
+ - `(s,s)` packing did **not** kill it: almost no μ-law code has LSB == MSB except silence. Nearest-neighbour still emits a 4 kHz square.
117
+
118
+ **Fix that worked:** restrict the codebook to μ-law bytes whose decoded `|LSB − MSB| ≤ 3`.
119
+
120
+ Working codes (8):
121
+
122
+ `[0, 1, 103, 127, 128, 129, 231, 255]`
123
+
124
+ Notable pairs:
125
+
126
+ | μ-law | decoded LE bytes | notes |
127
+ | -------- | ---------------- | ----------------------------------------- |
128
+ | 255, 127 | 0, 0 | silence |
129
+ | 231 | 4, 1 | quiet positive |
130
+ | 103 | 252, 254 | near-full as unsigned; noisy if DAC is U8 |
131
+ | 128, 129 | ~124, ~121–125 | full-scale positive |
132
+ | 0, 1 | ~132, ~130–134 | full-scale negative |
133
+
134
+ Pipeline that killed the whistle:
135
+
136
+ 1. `lowpass=1600`, `aresample=4000`, `s8` (signed 8-bit so WAV silence → 0).
137
+ 2. One μ-law byte per 4 kHz sample, chosen from the 8-code set (both DAC ticks ≈ that sample).
138
+ 3. RTP: **PT=0, 160 bytes, 20 ms, timestamp += 160**, marker on **every** packet (`MARKER_ALL=1`).
139
+ 4. Silence padding with **0xFF**, never 0x00 (μ-law 0x00 is −32124, a rail slam).
140
+
141
+ Side effect: voice is **bitcrushed / robotic**. Only ~3 usable amplitude steps. That is the remaining distortion.
142
+
143
+ ## Packet spec (current best)
144
+
145
+ ```
146
+ PT 0
147
+ payload μ-law from encode_talkback_dac.py (restricted codebook)
148
+ frame 160 bytes
149
+ ptime 20 ms
150
+ timestamp +160 per packet (peer.cpp: payload < 640 → += payload_bytes)
151
+ marker 1 on every packet
152
+ silence byte 0xFF
153
+ SSRC rewritten to audio send-track SSRC in peer.cpp
154
+ ```
155
+
156
+ `peer.cpp` timestamp rule:
157
+
158
+ ```
159
+ if payload_bytes >= 640: ts += payload_bytes / 2 # PCM16 640 B (camera TX)
160
+ else: ts += payload_bytes # G.711 160/320 B
161
+ ```
162
+
163
+ Do not send 640 B linear PCM on PT=0 for talkback.
164
+
165
+ ## Experiments (same 4.2 s 8 kHz s16le clip)
166
+
167
+ | | Payload | Result |
168
+ | --- | ----------------------------- | ------------------------------------------------------------------------ |
169
+ | A | PCMU 160 B / 20 ms | Distorted + noise |
170
+ | B | s16le 640 B / 40 ms PT=0 | No words, whistle, holes |
171
+ | C | PCMA 320 B / 40 ms | “Better”, 2× slow, noise |
172
+ | D | PCMA 160 B / 20 ms | 2× slow, noise, whistle |
173
+ | E | PCMA 16 kHz 320 B / 20 ms | Unintelligible |
174
+ | F | PCMU 320 B / 40 ms | **Best naive intelligibility**, exactly 2× slow, −1 octave, hiss + 4 kHz |
175
+ | G | s16le 640 B / 40 ms, ts+=320 | Noise |
176
+ | H | s16be 640 B / 40 ms | Noise (endianness is **not** the fix) |
177
+ | I | 2× precomp PCMU 160 B / 20 ms | Tempo OK, words under loud hiss |
178
+ | J | Inverse DAC map, full table | Hiss gone, speech OK-ish, **4 kHz whistle** |
179
+ | K | Codebook `\|LSB−MSB\|≤16` | Whistle quieter, **gated with voice** |
180
+ | L | Codebook `≤3` (8 codes) | **Whistle gone.** Speech robotic. |
181
+
182
+ ## Integration into the app (shipped)
183
+
184
+ `rtmp.service.ts` now:
185
+
186
+ 1. Accepts any RTMP audio codec (AAC, MP3, Speex, Nellymoser, PCM, …) as FLV tags.
187
+ 2. ffmpeg: soxr → 8 kHz → **telephone 300–3400 Hz** → soxr 16 kHz s16le.
188
+ 3. `talkback-dac.ts`: 16 kHz u8 pairs → μ-law LUT (same inverse as the file tests).
189
+ 4. RTP 160 B / 20 ms, PT=0, marker every packet, `ts += 160`.
190
+ 5. MQTT 312 speaker on publish / off on unpublish (already wired).
191
+
192
+ `peer.cpp` must keep `ts += payload_bytes` for 160 B frames (do not divide by 2).
193
+
194
+ ## Telephone filter
195
+
196
+ **Before this integration: no.** The file harness only low-passed at ~3 kHz. Lows were not cut.
197
+
198
+ **Now in the app:** ffmpeg `highpass=f=300:poles=2,lowpass=f=3400:poles=2` after resampling to 8 kHz (ITU G.711 / POTS band), then soxr upsample to 16 kHz so pairing emits 8000 μ-law bytes/s (same ratio as the working file tests). Any RTMP audio codec is decoded by ffmpeg first (`-f flv`).
199
+
200
+ ## Next work
201
+
202
+ - 4 kHz whistle is a firmware DAC artifact; restricted codebook removes it but wrecks loudness.
203
+ - Replay harness: `MARKER_ALL=1 AUDIO_FILE=... FRAME_SIZE=160 FRAME_MS=20 PAYLOAD_TYPE=0 node /Users/resonaura/tuya-exp/test_webrtc_talkback.js`
204
+
205
+ ## Files
206
+
207
+ | Path | Role |
208
+ | --------------------------------------------------- | ------------------------------------------- |
209
+ | `docs/tuya-webrtc-talkback.md` | This note |
210
+ | `/Users/resonaura/tuya-exp/test_webrtc_talkback.js` | MQTT + RTP harness |
211
+ | `/Users/resonaura/tuya-exp/encode_talkback_dac.py` | Working encoder |
212
+ | `bridge/apps/native/src/webrtc/peer.cpp` | RTP rewrite + send |
213
+ | `bridge/apps/server/src/streaming/talkback-dac.ts` | LUT encoder (u8 pair → μ-law) |
214
+ | `bridge/apps/server/src/streaming/rtmp.service.ts` | Live RTMP → telephone → DAC μ-law → UDP RTP |
215
+ | `src/accessories/camera.ts` | Scrypted stream options (`pcm_alaw` today) |
package/icon.png ADDED
Binary file
Binary file
package/package.json CHANGED
@@ -13,7 +13,8 @@
13
13
  "scrypted-deploy": "scrypted-deploy",
14
14
  "scrypted-changelog": "scrypted-changelog",
15
15
  "scrypted-package-json": "scrypted-package-json",
16
- "scrypted-readme": "scrypted-readme"
16
+ "scrypted-readme": "scrypted-readme",
17
+ "audio:talkback": "npx tsx scripts/audio/talkback-sample.ts"
17
18
  },
18
19
  "keywords": [
19
20
  "scrypted",
@@ -41,9 +42,9 @@
41
42
  "@types/qrcode-svg": "^1.1.5",
42
43
  "@types/ws": "^8.18.0"
43
44
  },
44
- "version": "0.1.2-beta",
45
+ "version": "2.1.0",
45
46
  "packageManager": "npm@10.9.2+sha512.8ab88f10f224a0c614cb717a7f7c30499014f77134120e9c1f0211ea3cf3397592cbe483feb38e0c4b3be1c54e347292c76a1b5edb94a3289d5448484ab8ac81",
46
- "description": "Unofficial standalone Scrypted Tuya plugin with maximum advertised camera quality selection.",
47
+ "description": "Unofficial standalone Scrypted Tuya plugin with maximum advertised camera quality selection and Smart Life P2P bridge integration.",
47
48
  "repository": {
48
49
  "type": "git",
49
50
  "url": "git+https://github.com/resonaura/scrypted-tuya.git"
@@ -53,7 +54,6 @@
53
54
  "url": "https://github.com/resonaura/scrypted-tuya/issues"
54
55
  },
55
56
  "publishConfig": {
56
- "access": "public",
57
- "tag": "beta"
57
+ "access": "public"
58
58
  }
59
59
  }
@@ -0,0 +1,120 @@
1
+ import { execSync, spawn } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import axios from "axios";
5
+
6
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
7
+
8
+ async function getCamerasFromBridge(apiBase: string): Promise<any[]> {
9
+ try {
10
+ const res = await axios.get(`${apiBase}/api/cameras`, { timeout: 3000 });
11
+ return res.data || [];
12
+ } catch {
13
+ return [];
14
+ }
15
+ }
16
+
17
+ async function main() {
18
+ console.log("🎙️ Tuya Talkback Sample (RTMP / Intercom wire format)\n");
19
+
20
+ const bridgePort = process.env.PORT || "6766";
21
+ const bridgeHost = process.env.BRIDGE_HOST || "localhost";
22
+ const rtmpPort = process.env.RTMP_PORT || "1935";
23
+ let apiBase = `http://${bridgeHost}:${bridgePort}`;
24
+
25
+ let cameras = await getCamerasFromBridge(apiBase);
26
+ if (cameras.length === 0 && !process.env.PORT) {
27
+ // Also try legacy port 8656
28
+ const altCameras = await getCamerasFromBridge(`http://${bridgeHost}:8656`);
29
+ if (altCameras.length > 0) {
30
+ cameras = altCameras;
31
+ apiBase = `http://${bridgeHost}:8656`;
32
+ }
33
+ }
34
+ const targetFilter = (
35
+ process.argv.slice(2).find((a) => !a.toLowerCase().endsWith(".wav")) ||
36
+ process.env.TARGET_CAM ||
37
+ ""
38
+ ).toLowerCase();
39
+
40
+ let targetCam = cameras.find(
41
+ (c: any) =>
42
+ c.name?.toLowerCase().includes(targetFilter) ||
43
+ c.did?.toLowerCase().includes(targetFilter),
44
+ );
45
+
46
+ if (!targetCam && cameras.length > 0) {
47
+ targetCam = cameras[0];
48
+ }
49
+
50
+ const slug = targetCam
51
+ ? targetCam.name
52
+ ?.toLowerCase()
53
+ .replace(/\bcamera\b/g, " ")
54
+ .replace(/[^a-z0-9]+/g, "-")
55
+ .replace(/^-+|-+$/g, "") || targetCam.did
56
+ : targetFilter || "tuya-cam";
57
+
58
+ console.log(`🎯 Targeting Camera: ${targetCam?.name || slug} (${targetCam?.did || slug})\n`);
59
+
60
+ const wavArgs = process.argv.slice(2).filter((a) => a.toLowerCase().endsWith(".wav"));
61
+ const defaultWav = path.resolve(__dirname, "../../audio/plop.wav");
62
+ const wavList = wavArgs.length
63
+ ? wavArgs.map((w) => {
64
+ const cands = [w, path.resolve(process.cwd(), w), path.resolve(__dirname, "../../audio", path.basename(w))];
65
+ const found = cands.find((c) => fs.existsSync(c));
66
+ return found || w;
67
+ })
68
+ : [defaultWav];
69
+
70
+ for (const wav of wavList) {
71
+ if (!fs.existsSync(wav)) {
72
+ console.error(`❌ Audio file not found: ${wav}`);
73
+ continue;
74
+ }
75
+
76
+ const rtmpUrl = `rtmp://${bridgeHost}:${rtmpPort}/talk/${slug}`;
77
+ console.log(`\n▶ Streaming ${path.basename(wav)} -> ${rtmpUrl}`);
78
+
79
+ await new Promise<void>((resolve, reject) => {
80
+ // Stream audio with low-delay flags matching talkback specification
81
+ const ffmpegArgs = [
82
+ "-hide_banner",
83
+ "-loglevel", "info",
84
+ "-re",
85
+ "-i", wav,
86
+ "-vn",
87
+ "-c:a", "aac",
88
+ "-b:a", "16k",
89
+ "-ar", "16000",
90
+ "-ac", "1",
91
+ "-f", "flv",
92
+ rtmpUrl,
93
+ ];
94
+
95
+ const proc = spawn("ffmpeg", ffmpegArgs, { stdio: "inherit" });
96
+ proc.on("close", (code) => {
97
+ if (code === 0) {
98
+ console.log(`✅ Finished ${path.basename(wav)} successfully!`);
99
+ resolve();
100
+ } else {
101
+ console.warn(`⚠️ FFmpeg exited with code ${code}`);
102
+ resolve();
103
+ }
104
+ });
105
+ proc.on("error", (err) => {
106
+ console.error(`❌ FFmpeg error: ${err.message}`);
107
+ reject(err);
108
+ });
109
+ });
110
+
111
+ await sleep(500);
112
+ }
113
+
114
+ console.log("\n🎉 Talkback playback complete.");
115
+ }
116
+
117
+ main().catch((err) => {
118
+ console.error("❌ Fatal error:", err);
119
+ process.exit(1);
120
+ });