2
0
Эх сурвалжийг харах

✨ feat(rtc): add RTC manager support

- add agora-rtc-sdk-ng dependency (version 4.20.0)
- implement RtcManagerAdapter with join, createTracks, publish and destroy methods
- add RTC event handling and network quality monitoring
- add createRtcManager method to SttSdk core class
- add RTC related types and interfaces

✅ test(rtc): add RTC manager unit tests

- create rtc-manager-adapter.test.ts with comprehensive test cases
- add tests for RTC manager initialization, joining channels, and track management
- add tests for error handling and event emission
- update stt-sdk.test.ts to include RTC manager tests

📦 build(deps): add agora-rtc-sdk-ng dependency

- add agora-rtc-sdk-ng to dependencies and peerDependencies
- set version to 4.20.0 for compatibility
yourname 2 сар өмнө
parent
commit
c986873c6a

+ 2 - 0
packages/stt-sdk-core/package.json

@@ -27,9 +27,11 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
+    "agora-rtc-sdk-ng": "4.20.0",
     "agora-rtm": "^2.1.9"
   },
   "peerDependencies": {
+    "agora-rtc-sdk-ng": "4.20.0",
     "agora-rtm": "^2.1.9"
   },
   "devDependencies": {

+ 31 - 1
packages/stt-sdk-core/src/core/stt-sdk.ts

@@ -2,7 +2,14 @@ import { AGEventEmitter } from './event-emitter'
 import { SttError } from './stt-error'
 import { SttManagerAdapter } from '../managers/stt-manager-adapter'
 import { RtmManagerAdapter } from '../managers/rtm-manager-adapter'
-import type { ISttSdk, SttSdkConfig, ISttManagerAdapter, IRtmManagerAdapter } from '../types'
+import { RtcManagerAdapter } from '../managers/rtc-manager-adapter'
+import type {
+  ISttSdk,
+  SttSdkConfig,
+  ISttManagerAdapter,
+  IRtmManagerAdapter,
+  IRtcManagerAdapter,
+} from '../types'
 
 export class SttSdk
   extends AGEventEmitter<{
@@ -18,6 +25,7 @@ export class SttSdk
   private _config?: SttSdkConfig
   private _sttManagers: Set<SttManagerAdapter> = new Set()
   private _rtmManagers: Set<RtmManagerAdapter> = new Set()
+  private _rtcManagers: Set<RtcManagerAdapter> = new Set()
 
   constructor() {
     super()
@@ -98,6 +106,22 @@ export class SttSdk
     return manager
   }
 
+  createRtcManager(): IRtcManagerAdapter {
+    if (!this._initialized) {
+      throw new SttError('NOT_INITIALIZED', 'SDK must be initialized before creating managers')
+    }
+
+    const manager = new RtcManagerAdapter(this._config!.appId, this._config!.certificate)
+    this._rtcManagers.add(manager)
+
+    // 监听管理器错误事件并转发
+    manager.on('error', (error) => {
+      this.emit('error', error)
+    })
+
+    return manager
+  }
+
   async destroy(): Promise<void> {
     try {
       this.emit('destroying')
@@ -106,6 +130,7 @@ export class SttSdk
       const destroyPromises = [
         ...Array.from(this._sttManagers).map((manager) => manager.destroy()),
         ...Array.from(this._rtmManagers).map((manager) => manager.destroy()),
+        ...Array.from(this._rtcManagers).map((manager) => manager.destroy()),
       ]
 
       await Promise.allSettled(destroyPromises)
@@ -113,6 +138,7 @@ export class SttSdk
       // 清理资源
       this._sttManagers.clear()
       this._rtmManagers.clear()
+      this._rtcManagers.clear()
       this._config = undefined
       this._initialized = false
 
@@ -141,6 +167,10 @@ export class SttSdk
     return this._rtmManagers.size
   }
 
+  get rtcManagerCount(): number {
+    return this._rtcManagers.size
+  }
+
   private _setupLogging(logLevel?: string): void {
     // 设置日志级别
     const level = logLevel || 'warn'

+ 1 - 0
packages/stt-sdk-core/src/managers/index.ts

@@ -1,2 +1,3 @@
 export { SttManagerAdapter } from './stt-manager-adapter'
 export { RtmManagerAdapter } from './rtm-manager-adapter'
+export { RtcManagerAdapter } from './rtc-manager-adapter'

+ 220 - 0
packages/stt-sdk-core/src/managers/rtc-manager-adapter.ts

@@ -0,0 +1,220 @@
+import AgoraRTC, {
+  IAgoraRTCClient,
+  IMicrophoneAudioTrack,
+  ICameraVideoTrack,
+  UID,
+} from 'agora-rtc-sdk-ng'
+import { AGEventEmitter } from '../core/event-emitter'
+import { SttError } from '../core/stt-error'
+import type { IRtcManagerAdapter, RtcManagerConfig, RtcEventMap } from '../types'
+import { generateAgoraToken } from '../utils/token-utils'
+
+export class RtcManagerAdapter extends AGEventEmitter<RtcEventMap> implements IRtcManagerAdapter {
+  private _joined = false
+  private _config?: RtcManagerConfig
+  private _userId: string | number = ''
+  private _channel: string = ''
+  private _client?: IAgoraRTCClient
+  private _localTracks: {
+    audioTrack?: IMicrophoneAudioTrack
+    videoTrack?: ICameraVideoTrack
+  } = {}
+
+  private _appId: string = ''
+  private _certificate: string = ''
+
+  constructor(appId?: string, certificate?: string) {
+    super()
+    if (appId) {
+      this._appId = appId
+    }
+    if (certificate) {
+      this._certificate = certificate
+    }
+  }
+
+  async join(config: RtcManagerConfig): Promise<void> {
+    try {
+      const { channel, userId } = config
+
+      if (!channel || !userId) {
+        throw new SttError('INVALID_CONFIG', 'Missing required configuration parameters')
+      }
+
+      if (this._joined) {
+        throw new SttError('ALREADY_JOINED', 'RTC manager is already joined to a channel')
+      }
+
+      if (!this._appId) {
+        throw new SttError('APP_ID_REQUIRED', 'App ID is required for RTC connection')
+      }
+
+      if (!this._certificate) {
+        throw new SttError(
+          'CERTIFICATE_REQUIRED',
+          'Certificate is required for RTC token generation'
+        )
+      }
+
+      this._userId = userId
+      this._channel = channel
+      this._config = config
+
+      this.emit('connecting', { channel, userId })
+
+      // 创建RTC客户端
+      this._client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' })
+
+      // 设置事件监听
+      this._listenRtcEvents()
+
+      // 获取RTC token
+      const token = await generateAgoraToken({
+        appId: this._appId,
+        appCertificate: this._certificate,
+        uid: userId,
+        channel,
+      })
+
+      // 加入频道
+      await this._client.join(this._appId, channel, token || undefined, userId)
+
+      this._joined = true
+
+      this.emit('connected', { channel, userId })
+    } catch (error) {
+      this.emit('error', error as Error)
+      throw error
+    }
+  }
+
+  async createTracks(): Promise<void> {
+    if (!this._joined) {
+      throw new SttError(
+        'NOT_JOINED',
+        'RTC manager must be joined to a channel before creating tracks'
+      )
+    }
+
+    try {
+      // 创建麦克风和摄像头轨道
+      const tracks = await AgoraRTC.createMicrophoneAndCameraTracks({
+        AGC: false,
+      })
+
+      this._localTracks.audioTrack = tracks[0]
+      this._localTracks.videoTrack = tracks[1]
+
+      this.emit('localUserChanged', this._localTracks)
+    } catch (error) {
+      this.emit('error', error as Error)
+      throw error
+    }
+  }
+
+  async publish(): Promise<void> {
+    if (!this._joined) {
+      throw new SttError(
+        'NOT_JOINED',
+        'RTC manager must be joined to a channel before publishing tracks'
+      )
+    }
+
+    if (!this._localTracks.audioTrack || !this._localTracks.videoTrack) {
+      throw new SttError('TRACKS_NOT_CREATED', 'Audio and video tracks must be created first')
+    }
+
+    try {
+      await this._client!.publish([this._localTracks.videoTrack, this._localTracks.audioTrack])
+    } catch (error) {
+      this.emit('error', error as Error)
+      throw error
+    }
+  }
+
+  async destroy(): Promise<void> {
+    try {
+      this.emit('destroying')
+
+      // 关闭本地轨道
+      this._localTracks.audioTrack?.close()
+      this._localTracks.videoTrack?.close()
+
+      // 离开频道
+      if (this._joined && this._client) {
+        await this._client.leave()
+      }
+
+      // 清理资源
+      this._joined = false
+      this._config = undefined
+      this._userId = ''
+      this._channel = ''
+      this._localTracks = {}
+      this._client = undefined
+
+      this.emit('destroyed')
+
+      this.removeAllEventListeners()
+    } catch (error) {
+      this.emit('error', error as Error)
+      throw error
+    }
+  }
+
+  get isJoined(): boolean {
+    return this._joined
+  }
+
+  get config(): RtcManagerConfig | undefined {
+    return this._config
+  }
+
+  get userId(): string | number {
+    return this._userId
+  }
+
+  get channel(): string {
+    return this._channel
+  }
+
+  // 私有方法
+  private _listenRtcEvents(): void {
+    if (!this._client) return
+
+    this._client.on('network-quality', (quality) => {
+      this.emit('networkQuality', quality)
+    })
+
+    this._client.on('user-published', async (user, mediaType) => {
+      await this._client!.subscribe(user, mediaType)
+      if (mediaType === 'audio') {
+        this._playAudio(user.audioTrack)
+      }
+      this.emit('remoteUserChanged', {
+        userId: user.uid,
+        audioTrack: user.audioTrack,
+        videoTrack: user.videoTrack,
+      })
+    })
+
+    this._client.on('user-unpublished', async (user, mediaType) => {
+      await this._client!.unsubscribe(user, mediaType)
+      this.emit('remoteUserChanged', {
+        userId: user.uid,
+        audioTrack: user.audioTrack,
+        videoTrack: user.videoTrack,
+      })
+    })
+
+    this._client.on('stream-message', (uid: UID, stream: any) => {
+      this.emit('textstreamReceived', stream)
+    })
+  }
+
+  private _playAudio(audioTrack: IMicrophoneAudioTrack | undefined): void {
+    if (audioTrack && !audioTrack.isPlaying) {
+      audioTrack.play()
+    }
+  }
+}

+ 34 - 0
packages/stt-sdk-core/src/types/index.ts

@@ -50,6 +50,11 @@ export interface RtmManagerConfig {
   userName: string
 }
 
+export interface RtcManagerConfig {
+  channel: string
+  userId: string | number
+}
+
 // 选项接口
 export interface SttStartOptions {
   languages: ILanguageItem[]
@@ -96,6 +101,18 @@ export interface RtmEventMap {
   destroyed: () => void
 }
 
+export interface RtcEventMap {
+  connecting: (data: { channel: string; userId: string | number }) => void
+  connected: (data: { channel: string; userId: string | number }) => void
+  error: (error: Error) => void
+  localUserChanged: (tracks: any) => void
+  remoteUserChanged: (user: any) => void
+  networkQuality: (quality: any) => void
+  textstreamReceived: (textstream: any) => void
+  destroying: () => void
+  destroyed: () => void
+}
+
 // 数据接口
 export interface ILanguageItem {
   source?: string
@@ -120,10 +137,27 @@ export interface RtmChannelMetadata {
 }
 
 // SDK 主类接口
+export interface IRtcManagerAdapter {
+  join(config: RtcManagerConfig): Promise<void>
+  createTracks(): Promise<void>
+  publish(): Promise<void>
+  destroy(): Promise<void>
+  isJoined: boolean
+  config?: RtcManagerConfig
+  userId: string | number
+  channel: string
+
+  // 事件方法
+  on<K extends keyof RtcEventMap>(event: K, listener: RtcEventMap[K]): this
+  off<K extends keyof RtcEventMap>(event: K, listener: RtcEventMap[K]): this
+  emit<K extends keyof RtcEventMap>(event: K, ...args: any[]): this
+}
+
 export interface ISttSdk {
   initialize(config: SttSdkConfig): Promise<void>
   createSttManager(rtmManager: IRtmManagerAdapter): ISttManagerAdapter
   createRtmManager(): IRtmManagerAdapter
+  createRtcManager(): IRtcManagerAdapter
   destroy(): Promise<void>
   isInitialized: boolean
 }

+ 20 - 0
packages/stt-sdk-core/tests/core/stt-sdk.test.ts

@@ -87,6 +87,24 @@ describe('SttSdk', () => {
     })
   })
 
+  describe('createRtcManager', () => {
+    it('should create RTC manager when SDK is initialized', async () => {
+      await sdk.initialize({ appId: 'test-app-id', certificate: 'test-certificate' })
+
+      const manager = sdk.createRtcManager()
+
+      expect(manager).toBeDefined()
+      expect(sdk.rtcManagerCount).toBe(1)
+    })
+
+    it('should throw error when SDK is not initialized', () => {
+      expect(() => sdk.createRtcManager()).toThrow(SttError)
+      expect(() => sdk.createRtcManager()).toThrow(
+        'SDK must be initialized before creating managers'
+      )
+    })
+  })
+
   describe('destroy', () => {
     it('should destroy SDK successfully', async () => {
       await sdk.initialize({ appId: 'test-app-id', certificate: 'test-certificate' })
@@ -94,6 +112,7 @@ describe('SttSdk', () => {
       // 创建管理器以测试销毁功能
       const rtmManager = sdk.createRtmManager()
       sdk.createSttManager(rtmManager)
+      sdk.createRtcManager()
 
       await sdk.destroy()
 
@@ -101,6 +120,7 @@ describe('SttSdk', () => {
       expect(sdk.config).toBeUndefined()
       expect(sdk.sttManagerCount).toBe(0)
       expect(sdk.rtmManagerCount).toBe(0)
+      expect(sdk.rtcManagerCount).toBe(0)
     })
 
     it('should handle destroy when not initialized', async () => {

+ 232 - 0
packages/stt-sdk-core/tests/rtc-manager-adapter.test.ts

@@ -0,0 +1,232 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { RtcManagerAdapter } from '../src/managers/rtc-manager-adapter'
+import { SttError } from '../src/core/stt-error'
+
+// 模拟 AgoraRTC SDK
+vi.mock('agora-rtc-sdk-ng', () => ({
+  default: {
+    createClient: vi.fn(() => ({
+      join: vi.fn(),
+      leave: vi.fn(),
+      publish: vi.fn(),
+      subscribe: vi.fn(),
+      unsubscribe: vi.fn(),
+      on: vi.fn(),
+    })),
+    createMicrophoneAndCameraTracks: vi.fn(() =>
+      Promise.resolve([{ close: vi.fn(), isPlaying: false, play: vi.fn() }, { close: vi.fn() }])
+    ),
+  },
+}))
+
+// 模拟 token 工具
+vi.mock('../src/utils/token-utils', () => ({
+  generateAgoraToken: vi.fn(() => Promise.resolve('mock-token')),
+}))
+
+describe('RtcManagerAdapter', () => {
+  let manager: RtcManagerAdapter
+
+  beforeEach(() => {
+    manager = new RtcManagerAdapter('test-app-id', 'test-certificate')
+  })
+
+  afterEach(async () => {
+    await manager.destroy()
+  })
+
+  describe('constructor', () => {
+    it('should create instance with appId and certificate', () => {
+      expect(manager).toBeInstanceOf(RtcManagerAdapter)
+      expect(manager.isJoined).toBe(false)
+      expect(manager.config).toBeUndefined()
+    })
+
+    it('should create instance without parameters', () => {
+      const managerWithoutParams = new RtcManagerAdapter()
+      expect(managerWithoutParams).toBeInstanceOf(RtcManagerAdapter)
+    })
+  })
+
+  describe('join', () => {
+    it('should join channel successfully', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+
+      await manager.join(config)
+
+      expect(manager.isJoined).toBe(true)
+      expect(manager.config).toEqual(config)
+      expect(manager.userId).toBe('test-user')
+      expect(manager.channel).toBe('test-channel')
+    })
+
+    it('should throw error when missing required parameters', async () => {
+      await expect(manager.join({} as any)).rejects.toThrow(SttError)
+      await expect(manager.join({ channel: 'test' } as any)).rejects.toThrow(SttError)
+      await expect(manager.join({ userId: 'test' } as any)).rejects.toThrow(SttError)
+    })
+
+    it('should throw error when already joined', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      await manager.join(config)
+
+      await expect(manager.join(config)).rejects.toThrow(SttError)
+    })
+
+    it('should throw error when missing appId', async () => {
+      const managerWithoutAppId = new RtcManagerAdapter()
+      const config = { channel: 'test-channel', userId: 'test-user' }
+
+      await expect(managerWithoutAppId.join(config)).rejects.toThrow(SttError)
+    })
+
+    it('should throw error when missing certificate', async () => {
+      const managerWithoutCert = new RtcManagerAdapter('test-app-id')
+      const config = { channel: 'test-channel', userId: 'test-user' }
+
+      await expect(managerWithoutCert.join(config)).rejects.toThrow(SttError)
+    })
+
+    it('should emit connecting and connected events', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      const connectingSpy = vi.fn()
+      const connectedSpy = vi.fn()
+
+      manager.on('connecting', connectingSpy)
+      manager.on('connected', connectedSpy)
+
+      await manager.join(config)
+
+      expect(connectingSpy).toHaveBeenCalledWith({
+        channel: 'test-channel',
+        userId: 'test-user',
+      })
+      expect(connectedSpy).toHaveBeenCalledWith({
+        channel: 'test-channel',
+        userId: 'test-user',
+      })
+    })
+  })
+
+  describe('createTracks', () => {
+    it('should create tracks successfully', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      await manager.join(config)
+
+      // 先设置事件监听器
+      const localUserChangedSpy = vi.fn()
+      manager.on('localUserChanged', localUserChangedSpy)
+
+      await manager.createTracks()
+
+      // 验证事件被触发
+      expect(localUserChangedSpy).toHaveBeenCalled()
+    })
+
+    it('should throw error when not joined', async () => {
+      await expect(manager.createTracks()).rejects.toThrow(SttError)
+    })
+  })
+
+  describe('publish', () => {
+    it('should publish tracks successfully', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      await manager.join(config)
+      await manager.createTracks()
+
+      await expect(manager.publish()).resolves.not.toThrow()
+    })
+
+    it('should throw error when not joined', async () => {
+      await expect(manager.publish()).rejects.toThrow(SttError)
+    })
+
+    it('should throw error when tracks not created', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      await manager.join(config)
+
+      await expect(manager.publish()).rejects.toThrow(SttError)
+    })
+  })
+
+  describe('destroy', () => {
+    it('should destroy manager successfully', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      await manager.join(config)
+
+      await manager.destroy()
+
+      expect(manager.isJoined).toBe(false)
+      expect(manager.config).toBeUndefined()
+      expect(manager.userId).toBe('')
+      expect(manager.channel).toBe('')
+    })
+
+    it('should emit destroying and destroyed events', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      await manager.join(config)
+
+      const destroyingSpy = vi.fn()
+      const destroyedSpy = vi.fn()
+
+      manager.on('destroying', destroyingSpy)
+      manager.on('destroyed', destroyedSpy)
+
+      await manager.destroy()
+
+      expect(destroyingSpy).toHaveBeenCalled()
+      expect(destroyedSpy).toHaveBeenCalled()
+    })
+
+    it('should handle destroy when not joined', async () => {
+      await expect(manager.destroy()).resolves.not.toThrow()
+    })
+  })
+
+  describe('event handling', () => {
+    it('should handle network quality events', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      await manager.join(config)
+
+      const networkQualitySpy = vi.fn()
+      manager.on('networkQuality', networkQualitySpy)
+
+      // 模拟网络质量事件
+      const mockClient = await import('agora-rtc-sdk-ng')
+      const client = mockClient.default.createClient()
+      const networkQualityHandler = client.on.mock.calls.find(
+        (call: any) => call[0] === 'network-quality'
+      )?.[1]
+
+      if (networkQualityHandler) {
+        networkQualityHandler({ uplink: 5, downlink: 5 })
+        expect(networkQualitySpy).toHaveBeenCalledWith({ uplink: 5, downlink: 5 })
+      }
+    })
+
+    it('should handle user published events', async () => {
+      const config = { channel: 'test-channel', userId: 'test-user' }
+      await manager.join(config)
+
+      const remoteUserChangedSpy = vi.fn()
+      manager.on('remoteUserChanged', remoteUserChangedSpy)
+
+      // 模拟用户发布事件
+      const mockClient = await import('agora-rtc-sdk-ng')
+      const client = mockClient.default.createClient()
+      const userPublishedHandler = client.on.mock.calls.find(
+        (call: any) => call[0] === 'user-published'
+      )?.[1]
+
+      if (userPublishedHandler) {
+        const mockUser = { uid: 'remote-user', audioTrack: {}, videoTrack: {} }
+        await userPublishedHandler(mockUser, 'audio')
+        expect(remoteUserChangedSpy).toHaveBeenCalledWith({
+          userId: 'remote-user',
+          audioTrack: {},
+          videoTrack: {},
+        })
+      }
+    })
+  })
+})