Skip to content

Commit bba6f4a

Browse files
committed
feat(example): support standalone group call command
1 parent 9660a66 commit bba6f4a

3 files changed

Lines changed: 86 additions & 16 deletions

File tree

docs/docs/call.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,11 @@ For a complete reusable script, see `example/call/call_on_command.ts`.
249249

250250
## Command Trigger Example
251251

252-
`example/call/call_on_command.ts` listens for `!call` in a 1:1 Talk chat. When
253-
another user sends the command, the example calls that user and streams the
254-
bundled `unity.wav`.
252+
`example/call/call_on_command.ts` listens for `!call` in a Talk chat. In a 1:1
253+
chat, the example calls the sender and streams the bundled `unity.wav`. In a
254+
group or room chat, it starts or joins the group call and streams the same audio
255+
with LINE.js only: route acquisition, PLANET join, SRTP setup, and RTP media are
256+
all handled by the library.
255257

256258
```sh
257259
LINE_AUTH_TOKEN_FILE=/path/to/auth-token.txt \
@@ -278,12 +280,13 @@ repository:
278280
| `LINE_DEVICE` | context-aware | Login device type. Defaults to `ANDROID` with an auth token, otherwise `ANDROIDSECONDARY`. |
279281
| `LINE_VERSION` | package default | LINE app version used in `x-line-application`. |
280282
| `LINE_CALL_DEVNAME` | package default | Device name sent in `fromEnvInfo.devname` during route acquisition. |
283+
| `LINE_CALL_DEVICE_INFO` | Android default | PLANET user-agent device info. Override only when emulating a specific native client. |
281284
| `LINE_CALL_FROM_ENV_INFO` | | JSON string map for the full `fromEnvInfo` route-acquisition field. |
282285
| `LINE_CALL_COMMAND` | `!call` | Chat command that starts the call. |
283286
| `LINE_CALL_WAV` | `./unity.wav` | WAV file to stream. The first CLI arg overrides this. |
284287
| `LINE_CALL_FRAME_MS` | `20` | Opus frame size. |
285288
| `LINE_CALL_PAYLOAD_PREFIX_HEX` | `00` | Native PLANET audio payload prefix. |
286-
| `LINE_CALL_MEDIA_KEY_MODE` | `audio-reverse-stage` | SRTP key selection mode for the observed Android audio path. |
289+
| `LINE_CALL_MEDIA_KEY_MODE` | mode default | SRTP key selection mode. Defaults to `audio-reverse-stage` for 1:1 calls and `audio-secret-sender` for group calls. |
287290
| `LINE_CALL_GAIN` | `1` | PCM gain before Opus encoding. |
288291
| `LINE_CALL_REPEAT_COUNT` | `1` | Number of times to replay the WAV before closing the call. |
289292
| `LINE_CALL_HOLD_MS` | `1000` | Extra hold time after audio finishes. |

example/call/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ artifacts.
66

77
## `call_on_command.ts`
88

9-
Listens for `!call` in a 1:1 Talk chat. When another user sends the command, the
10-
script starts an audio call to that user and streams `unity.wav`.
9+
Listens for `!call` in a Talk chat. In a 1:1 chat, the script starts an audio
10+
call to the sender and streams `unity.wav`. In a group or room chat, it starts or
11+
joins the group call and streams the same audio over the group media path.
1112

1213
```sh
1314
LINE_AUTH_TOKEN_FILE=/path/to/auth-token.txt \
@@ -33,12 +34,13 @@ Useful environment variables:
3334
| `LINE_DEVICE` | context-aware | Login device type. Defaults to `ANDROID` with an auth token, otherwise `ANDROIDSECONDARY`. |
3435
| `LINE_VERSION` | package default | LINE app version used in `x-line-application`. |
3536
| `LINE_CALL_DEVNAME` | package default | Device name sent in `fromEnvInfo.devname` for call route acquisition. |
37+
| `LINE_CALL_DEVICE_INFO` | Android default | PLANET user-agent device info. Override only when emulating a specific native client. |
3638
| `LINE_CALL_FROM_ENV_INFO` | | JSON string map for the full `fromEnvInfo` route-acquisition field. |
3739
| `LINE_CALL_COMMAND` | `!call` | Command text to trigger the call. |
3840
| `LINE_CALL_WAV` | `./unity.wav` | WAV file to stream. The first CLI arg overrides this. |
3941
| `LINE_CALL_FRAME_MS` | `20` | Opus frame size. |
4042
| `LINE_CALL_PAYLOAD_PREFIX_HEX` | `00` | Native PLANET audio payload prefix. |
41-
| `LINE_CALL_MEDIA_KEY_MODE` | `audio-reverse-stage` | PLANET SRTP media key mode. |
43+
| `LINE_CALL_MEDIA_KEY_MODE` | mode default | PLANET SRTP media key mode. Defaults to `audio-reverse-stage` for 1:1 calls and `audio-secret-sender` for group calls. |
4244
| `LINE_CALL_GAIN` | `1` | PCM gain before Opus encoding. |
4345
| `LINE_CALL_REPEAT_COUNT` | `1` | Number of times to replay the WAV before closing the call. |
4446
| `LINE_CALL_HOLD_MS` | `1000` | Extra time to keep the call open after playback. |

example/call/call_on_command.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ const configuredDevice = Deno.env.get("LINE_DEVICE")?.trim() as
2525
const version = Deno.env.get("LINE_VERSION") ?? undefined;
2626
const fromEnvInfo = readFromEnvInfo();
2727
const storagePath = Deno.env.get("LINE_STORAGE_FILE");
28-
const mediaKeyMode = (Deno.env.get("LINE_CALL_MEDIA_KEY_MODE") ??
29-
"audio-reverse-stage") as PlanetMediaKeyMode;
28+
const configuredMediaKeyMode = Deno.env.get("LINE_CALL_MEDIA_KEY_MODE")
29+
?.trim() as PlanetMediaKeyMode | undefined;
30+
const oneToOneMediaKeyMode = configuredMediaKeyMode ?? "audio-reverse-stage";
31+
const groupMediaKeyMode = configuredMediaKeyMode ?? "audio-secret-sender";
3032
const frameMs = readNumberEnv("LINE_CALL_FRAME_MS", 20);
3133
const gain = readNumberEnv("LINE_CALL_GAIN", 1);
3234
const holdMs = readNumberEnv("LINE_CALL_HOLD_MS", 1_000);
@@ -52,6 +54,7 @@ const payloadPrefix = hexToBytes(
5254

5355
const authToken = await readAuthToken();
5456
const device = configuredDevice || (authToken ? "ANDROID" : "ANDROIDSECONDARY");
57+
const deviceInfo = readDeviceInfo();
5558
const storage = storagePath ? new FileStorage(storagePath) : undefined;
5659
const client = authToken
5760
? await loginWithAuthToken(authToken, { device, version, storage })
@@ -83,16 +86,17 @@ async function handleMessage(message: TalkMessage): Promise<void> {
8386
const myMid = client.base.profile?.mid;
8487
if (!myMid) throw new Error("profile is not ready");
8588
const to = oneToOnePeer(message, myMid);
86-
if (!to) {
87-
await message.reply("1:1 chat only");
89+
const chatMid = groupChatMid(message);
90+
if (!to && !chatMid) {
91+
await message.reply("1:1 or group chat only");
8892
return;
8993
}
9094
if (activeCall) {
9195
await message.reply("call already running");
9296
return;
9397
}
94-
await message.reply("calling");
95-
activeCall = callAndPlay(to)
98+
await message.reply(to ? "calling" : "starting group call");
99+
activeCall = (to ? callAndPlay(to) : callGroupAndPlay(chatMid!))
96100
.finally(() => {
97101
activeCall = undefined;
98102
});
@@ -107,7 +111,8 @@ async function callAndPlay(to: string): Promise<void> {
107111
const transport = new PlanetTransport({
108112
localMid,
109113
timeoutMs,
110-
mediaKeyMode,
114+
mediaKeyMode: oneToOneMediaKeyMode,
115+
deviceInfo,
111116
});
112117

113118
try {
@@ -127,6 +132,39 @@ async function callAndPlay(to: string): Promise<void> {
127132
}
128133
}
129134

135+
async function callGroupAndPlay(chatMid: string): Promise<void> {
136+
const localMid = client.base.profile?.mid;
137+
if (!localMid) throw new Error("profile is not ready");
138+
139+
const state = await client.call.getGroupCall(chatMid)
140+
.catch(() => undefined);
141+
const online = Boolean((state as { online?: boolean } | undefined)?.online);
142+
const route = await client.call.acquireGroupRoute({
143+
chatMid,
144+
mediaType: "AUDIO",
145+
isInitialHost: !online,
146+
capabilities: ["AUDIO", "VIDEO"],
147+
} as never);
148+
const transport = new PlanetTransport({
149+
localMid,
150+
timeoutMs,
151+
mediaKeyMode: groupMediaKeyMode,
152+
deviceInfo,
153+
});
154+
155+
try {
156+
await transport.connect({ route });
157+
const joined = await transport.joinGroupDetailed({ roomId: chatMid });
158+
if (!joined.mediaReady) {
159+
throw new Error("group call joined, but media was not established");
160+
}
161+
await streamOpus(transport, audio, repeatCount);
162+
if (holdMs > 0) await sleep(holdMs);
163+
} finally {
164+
await transport.close();
165+
}
166+
}
167+
130168
async function acquireAudioRoute(to: string) {
131169
try {
132170
return await client.call.acquireRoute({
@@ -155,6 +193,8 @@ async function streamOpus(
155193
vbr: opusVbr,
156194
});
157195
const frameSamples = Math.floor((SAMPLE_RATE * frameMs) / 1000);
196+
let sentFrames = 0;
197+
let sentBytes = 0;
158198
try {
159199
for (let repeat = 0; repeat < repeats; repeat++) {
160200
for (let offset = 0; offset < samples.length; offset += frameSamples) {
@@ -166,16 +206,20 @@ async function streamOpus(
166206
channels: 1,
167207
});
168208
if (packet) {
169-
await transport.send(prepend(packet, payloadPrefix), {
209+
const payload = prepend(packet, payloadPrefix);
210+
await transport.send(payload, {
170211
timestampStep: frameSamples,
171212
});
213+
sentFrames++;
214+
sentBytes += payload.length;
172215
}
173216
await sleep(frameMs);
174217
}
175218
}
176219
} finally {
177220
encoder.close?.();
178221
}
222+
console.log(`[call] audio sent frames=${sentFrames} bytes=${sentBytes}`);
179223
}
180224

181225
async function loadWavForCall(
@@ -198,10 +242,22 @@ async function loadWavForCall(
198242
}
199243

200244
function oneToOnePeer(message: TalkMessage, myMid: string): string | null {
201-
if (message.to.type !== "USER" && message.to.type !== 0) return null;
245+
if (!isUserMidType(message.to.type)) return null;
202246
return message.from.id === myMid ? message.to.id : message.from.id;
203247
}
204248

249+
function groupChatMid(message: TalkMessage): string | null {
250+
return isGroupMidType(message.to.type) ? message.to.id : null;
251+
}
252+
253+
function isUserMidType(type: TalkMessage["to"]["type"]): boolean {
254+
return type === "USER" || type === 0;
255+
}
256+
257+
function isGroupMidType(type: TalkMessage["to"]["type"]): boolean {
258+
return type === "GROUP" || type === 2 || type === "ROOM" || type === 1;
259+
}
260+
205261
function downmixToMono(samples: Int16Array, channels: number): Int16Array {
206262
if (channels <= 1) return samples;
207263
const frames = Math.floor(samples.length / channels);
@@ -229,6 +285,15 @@ function readFromEnvInfo(): Record<string, string> | undefined {
229285
return devname ? { devname } : undefined;
230286
}
231287

288+
function readDeviceInfo(): string | undefined {
289+
const direct = Deno.env.get("LINE_CALL_DEVICE_INFO")?.trim();
290+
if (direct) return direct;
291+
if (device === "ANDROID" || device === "ANDROIDSECONDARY") {
292+
return `ANDROID\t${version ?? "26.6.2"}\tAndroid OS\t16`;
293+
}
294+
return undefined;
295+
}
296+
232297
function isStringRecord(value: unknown): value is Record<string, string> {
233298
return (
234299
typeof value === "object" &&

0 commit comments

Comments
 (0)