@@ -25,8 +25,10 @@ const configuredDevice = Deno.env.get("LINE_DEVICE")?.trim() as
2525const version = Deno . env . get ( "LINE_VERSION" ) ?? undefined ;
2626const fromEnvInfo = readFromEnvInfo ( ) ;
2727const 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" ;
3032const frameMs = readNumberEnv ( "LINE_CALL_FRAME_MS" , 20 ) ;
3133const gain = readNumberEnv ( "LINE_CALL_GAIN" , 1 ) ;
3234const holdMs = readNumberEnv ( "LINE_CALL_HOLD_MS" , 1_000 ) ;
@@ -52,6 +54,7 @@ const payloadPrefix = hexToBytes(
5254
5355const authToken = await readAuthToken ( ) ;
5456const device = configuredDevice || ( authToken ? "ANDROID" : "ANDROIDSECONDARY" ) ;
57+ const deviceInfo = readDeviceInfo ( ) ;
5558const storage = storagePath ? new FileStorage ( storagePath ) : undefined ;
5659const 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+
130168async 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
181225async function loadWavForCall (
@@ -198,10 +242,22 @@ async function loadWavForCall(
198242}
199243
200244function 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+
205261function 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+
232297function isStringRecord ( value : unknown ) : value is Record < string , string > {
233298 return (
234299 typeof value === "object" &&
0 commit comments