【TTS实战】获取设备播放和麦克风权限

音频播放

根据 MDN 文档,音频自动播放需满足以下任一条件:

  • 音频被静音或音量为 0。
  • 用户已与页面发生交互(点击、触摸、按键等)。
  • 站点已列入浏览器自动播放白名单。

实际开发中通常采用第②种方案(交互后播放)。

注意点

  • 在 iOS 中,播放音频必须先获取麦克风权限,再获取音频播放权限,否则无法正常播放。
  • 在 iOS 中,通过内网 IP 地址 访问网页时无法播放音频,原因是该环境下无法获取麦克风权限。
    • 解决办法:通过内网穿透将内网 IP 映射为公网域名访问。
  • 在 PC 中,可通过预检测 AudioContext 状态,若为 running,则认为站点已列入浏览器自动播放白名单
    • 在 H5 不能预检测 AudioContext 状态。
  • 在 iOS 中,即使用户已与页面发生交互,AudioContext 状态也不会直接变成 running,仍需如下操作:
    1. 调用 resume 方法并成功返回;注意首次调用可能 (暂未找到规律)在 resume 方法调用 一直卡住,需做超时处理。
    2. 等待 50 ms。
    3. 最终校验 AudioContext 是否为 running 状态。

交互实现

代码实现

typescript 复制代码
/**
 * 判断当前环境是否为 iOS 设备。
 *
 * @returns 如果是iOS设备,返回true;否则返回false。
 */
function isIos(): boolean {
  const userAgent = navigator.userAgent.toLowerCase()

  return /iphone|ipad|ipod/.test(userAgent)
}


/**
 * 判断当前环境是否为 Android 设备。
 *
 * @returns 如果是Android设备,返回true;否则返回false。
 */
function isAndroid(): boolean {
  const userAgent = navigator.userAgent.toLowerCase()

  return /android/.test(userAgent)
}


enum AudioPermissionErrorType {
  NO_USER_INTERACTION_ERROR = 'NoUserInteractionError',
  AUTOPLAY_BLOCKED = 'AutoplayBlocked',
  AUTOPLAY_UNKNOWN_ERROR = 'AutoplayUnknownError',

  SECURE_CONTEXT_ERROR = 'SecureContextError',
  PERMISSION_DENIED_PERMANENTLY = 'PermissionDeniedPermanently',
  NOT_ALLOWED_ERROR = 'NotAllowedError',
  MEDIA_DEVICES_API_UNAVAILABLE = 'MediaDevicesApiUnavailable',
  NOT_FOUND_ERROR = 'NotFoundError',
  NOT_READABLE_ERROR = 'NotReadableError',
  OVERCONSTRAINED_ERROR = 'OverconstrainedError',
  SECURITY_ERROR = 'SecurityError',
  MICROPHONE_UNKNOWN_ERROR = 'MicrophoneUnknownError',
  MICROPHONE_TIMEOUT_ERROR = 'MicrophoneTimeoutError',
}

class AudioPermissionError extends Error {
  public name: AudioPermissionErrorType

  private constructor(name: AudioPermissionErrorType, message: string) {
    super(message)
    this.name = name
  }

  public static create(
    name: AudioPermissionErrorType,
    message?: string,
  ): AudioPermissionError {
    const codeMessageMap = {
      [AudioPermissionErrorType.NO_USER_INTERACTION_ERROR]: '需要用户交互才能播放音频',
      [AudioPermissionErrorType.AUTOPLAY_BLOCKED]: '自动播放被浏览器阻止',
      [AudioPermissionErrorType.AUTOPLAY_UNKNOWN_ERROR]: '未知自动播放错误',

      [AudioPermissionErrorType.SECURE_CONTEXT_ERROR]: '麦克风权限需要在安全上下文(HTTPS)中使用',
      [AudioPermissionErrorType.PERMISSION_DENIED_PERMANENTLY]: '音频权限已被永久拒绝,需在浏览器设置中开启麦克风权限',
      [AudioPermissionErrorType.MEDIA_DEVICES_API_UNAVAILABLE]: 'MediaDevices API不可用',
      [AudioPermissionErrorType.NOT_ALLOWED_ERROR]: '麦克风权限被拒绝',
      [AudioPermissionErrorType.NOT_READABLE_ERROR]: '音频设备被其他应用程序占用',
      [AudioPermissionErrorType.NOT_FOUND_ERROR]: '未找到音频输入设备',
      [AudioPermissionErrorType.SECURITY_ERROR]: '安全策略阻止访问麦克风',
      [AudioPermissionErrorType.OVERCONSTRAINED_ERROR]: '音频设备不满足指定的约束条件',
      [AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR]: '未知麦克风权限错误',
      [AudioPermissionErrorType.MICROPHONE_TIMEOUT_ERROR]: '获取麦克风权限超时,请检查设备权限设置或重试',

    }

    return new AudioPermissionError(name, message || codeMessageMap[name as keyof typeof codeMessageMap])
  }
}

/**
 * 音频播放权限管理类
 *
 * 根据MDN文档,音频播放权限需要满足以下条件之一:
 * 1. 音频被静音或音量为0
 * 2. 用户已与网站产生交互(点击、触摸、按键等)
 * 3. 网站已被加入自动播放白名单
 * 4. 通过Permissions Policy授权
 *
 * 跨平台实现策略:
 * - PC端:预检测AudioContext状态,如果suspended则监听用户交互
 * - iOS Safari:必须在用户交互事件的调用栈中调用resume(),需要50ms延迟确保状态变更
 * - Android:直接在用户交互后检测AudioContext状态
 *
 *
 *
 * 实现原理:
 *    PC:
 *      1.在PC端首先预检测AudioContext状态:
 *        如果状态是 "running" 状态则可以播放音频,
 *        如果状态不是 "running" 状态则需要监听用户交互事件,
 *      2.用户交互事件触发后,再检测AudioContext状态:
 *        如果状态是 "running" 状态则可以播放音频,
 *        如果状态不是 "running" 状态则抛出异常。
 *
 *    IOS:
 *      1.用户交互事件触发后,再检测AudioContext状态:
 *        如果状态是 "suspended" 则需要调用 resume 方法,并且需要延迟 50ms,
 *        如果状态不是 "suspended" 状态进行下一步判断,
 *      2.获取AudioContext状态:
 *        如果状态是 "running" 状态则可以播放音频,
 *        如果状态不是 "running" 状态则抛出异常。
 *
 *    Android:
 *      1.用户交互事件触发后,再检测AudioContext状态:
 *        如果状态是 "running" 状态则可以播放音频,
 *        如果状态不是 "running" 状态则抛出异常。
 */
class AudioPlaybackPermission {
  // 用户是否已产生交互行为
  private userInteracted: boolean = false

  private executeContext!: AudioPermission

  constructor() {
    this.initPlaybackPermission()
  }

  /**
   * 初始化播放权限检测
   */
  private initPlaybackPermission(): void {
    // 根据MDN文档,PC平台可以预检测AudioContext状态
    if (!isIos() && !isAndroid()) {
      this.checkInitialAudioContextState().catch(() => {
        this.setupUserInteractionListeners()
      })
    }
    else {
      this.setupUserInteractionListeners()
    }
  }

  /**
   * 检测初始AudioContext状态(仅PC平台)
   */
  private async checkInitialAudioContextState(): Promise<void> {
    const testContext = new (window.AudioContext || (window as any).webkitAudioContext)()

    if (testContext.state === 'running') {
      this.userInteracted = true
    }

    await testContext.close()

    if (this.userInteracted) {
      return Promise.resolve()
    }
    else {
      // eslint-disable-next-line prefer-promise-reject-errors
      return Promise.reject()
    }
  }

  /**
   * 设置用户交互监听器
   */
  private setupUserInteractionListeners(): void {
    const interactionEvents = [
      'click',
      'touchstart',
      'touchend',
      'keydown',
      'mousedown',
      'pointerdown',
    ]

    const handleInteraction = (): void => {
      this.userInteracted = true

      // 清理所有事件监听器
      interactionEvents.forEach((eventType: string) => {
        document.removeEventListener(eventType, handleInteraction)
      })
    }

    interactionEvents.forEach((eventType) => {
      document.addEventListener(eventType, handleInteraction, { passive: true, once: true })
    })
  }

  /**
   * 请求播放权限
   * 根据Web Audio API最佳实践实现
   */
  public async requestPlaybackPermission(): Promise<void> {
    let audioContext: AudioContext | null = null
    try {
      // 检查用户交互状态
      if (!this.userInteracted) {
        throw AudioPermissionError.create(AudioPermissionErrorType.NO_USER_INTERACTION_ERROR)
      }

      // iOS平台特殊处理:先申请麦克风权限
      if (isIos() && this.executeContext) {
        await this.executeContext.requestMicrophonePermission()
      }

      audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()

      // 根据MDN文档,检查AudioContext状态并处理
      if (isIos() && audioContext.state === 'suspended') {
        // iOS Safari特殊处理:必须在用户交互的调用栈中resume
        await Promise.race([
          audioContext.resume(),
          new Promise((_, reject) =>
            setTimeout(() => reject(AudioPermissionError.create(AudioPermissionErrorType.AUTOPLAY_BLOCKED)), 3000),
          ),
        ])

        // iOS可能需要短暂延迟确保状态变更
        await new Promise(resolve => setTimeout(resolve, 50))
      }

      // 验证最终状态
      if (audioContext.state !== 'running') {
        throw AudioPermissionError.create(AudioPermissionErrorType.AUTOPLAY_BLOCKED)
      }

      return Promise.resolve()
    }
    catch (error) {
      if (error instanceof AudioPermissionError) {
        return Promise.reject(error)
      }

      const unknownError = AudioPermissionError.create(
        AudioPermissionErrorType.AUTOPLAY_UNKNOWN_ERROR,
        error instanceof Error ? error.message : undefined,
      )
      return Promise.reject(unknownError)
    }
    finally {
      if (audioContext && audioContext.state !== 'closed') {
        try {
          await audioContext.close()
        }
        catch { }
      }
    }
  }

  /**
   * 执行上下文设置
   * @param context 执行上下文
   */
  setExecuteContext(context: AudioPermission): void {
    this.executeContext = context
  }
}

/**
 * 麦克风权限管理类
 *
 * 根据MDN文档,getUserMedia需要满足:
 * 1. 安全上下文(HTTPS)
 * 2. 用户明确授权
 * 3. 顶级文档上下文或通过Permissions Policy授权的iframe
 *
 *
 * 权限状态说明:
 * - granted: 用户已授权,可直接访问麦克风
 * - denied: 用户已拒绝,需要用户手动在浏览器设置中重新开启
 * - prompt: 首次访问,会弹出授权对话框
 *
 *
 * 平台差异:
 * - 桌面端:通过浏览器原生授权对话框处理
 * - iOS Safari:权限被拒绝后,需要用户在系统设置中重新开启
 * - Android Chrome:权限处理与桌面端类似,但某些版本可能有缓存问题
 *
 *
 * 注意事项:
 * 1. 浏览器会永久缓存用户的权限决定
 * 2. 在非HTTPS环境下(除localhost外)会直接失败
 * 3. 某些浏览器在隐私模式下可能有不同行为
 * 4. iframe中使用需要正确配置Permissions Policy
 *
 *
 * 实现原理:
 * - PC平台:
 *     1. 通过授权弹框申请权限:
 *          如果用户拒绝了权限,会抛出异常
 *          如果用户点击了允许,会返回成功
 *     2.第二次申请权限,会根据用户第一次申请权限的结果:
 *          如果用户第一次申请权限失败,会抛出异常
 *          如果用户第一次申请权限成功,会返回成功
 * - 移动端:
 *     1. 通过授权弹框申请权限:
 *          如果用户拒绝了权限,会抛出异常
 *          如果用户点击了允许,会返回成功
 *      2.第二次申请权限,会根据用户第一次申请权限的结果:
 *          如果用户第一次申请权限失败,会抛出异常
 *          如果用户第一次申请权限成功,会返回成功
 *
 *  iOS平台:
 *      1. 通过授权弹框申请权限:
 *          如果用户拒绝了权限,会抛出异常
 *          如果用户点击了允许,会返回成功
 *      2.第二次申请权限,会根据用户第一次申请权限的结果:
 *          如果用户第一次申请权限失败,会抛出异常
 *          如果用户第一次申请权限成功,会返回成功
 *
 *
 */

class MicrophonePermission {
  private microphonePermissionGranted: boolean = false
  private permissionDeniedPermanently: boolean = false

  /**
   * 检查是否在安全上下文中
   */
  private isSecureContext(): boolean {
    return window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost'
  }

  /**
   * 带超时的getUserMedia调用
   * @param constraints - 媒体约束
   * @param timeoutMs - 超时时间(毫秒)
   * @returns Promise<MediaStream>
   */
  private async getUserMediaWithTimeout(
    constraints: MediaStreamConstraints,
        timeoutMs: number = 10000,
  ): Promise<MediaStream> {
    return new Promise((resolve, reject) => {
      // 设置超时定时器
      const timeoutId = setTimeout(() => {
        reject(AudioPermissionError.create(
          AudioPermissionErrorType.MICROPHONE_TIMEOUT_ERROR,
        ))
      }, timeoutMs)

      // 调用getUserMedia
      navigator.mediaDevices.getUserMedia(constraints)
        .then((stream) => {
          clearTimeout(timeoutId)
          resolve(stream)
        })
        .catch((error) => {
          clearTimeout(timeoutId)
          reject(error)
        })
    })
  }

  /**
   * 请求麦克风权限
   * 根据MediaDevices.getUserMedia规范实现
   */
  public async requestMicrophonePermission(): Promise<void> {
    try {
      // 检查安全上下文
      if (!this.isSecureContext()) {
        throw AudioPermissionError.create(
          AudioPermissionErrorType.SECURE_CONTEXT_ERROR,
        )
      }

      //   检查是否已被永久拒绝
      if (this.permissionDeniedPermanently) {
        throw AudioPermissionError.create(AudioPermissionErrorType.PERMISSION_DENIED_PERMANENTLY)
      }

      // 如果权限已获取,直接返回
      if (this.microphonePermissionGranted) {
        return Promise.resolve()
      }

      // 检查MediaDevices API可用性
      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
        throw AudioPermissionError.create(
          AudioPermissionErrorType.MEDIA_DEVICES_API_UNAVAILABLE,
        )
      }

      // 请求麦克风权限
      const stream = await this.getUserMediaWithTimeout({
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: true,
        },
      })

      // 权限获取成功
      this.microphonePermissionGranted = true

      // 立即停止媒体流,我们只是测试权限
      stream.getTracks().forEach((track) => {
        track.stop()
      })

      return Promise.resolve()
    }
    catch (error) {
      this.microphonePermissionGranted = false

      if (error instanceof AudioPermissionError) {
        return Promise.reject(error)
      }

      if (error instanceof DOMException) {
        let errorType: AudioPermissionErrorType

        switch (error.name) {
          case 'NotFoundError':
            errorType = AudioPermissionErrorType.NOT_FOUND_ERROR
            break
          case 'NotAllowedError':
            this.permissionDeniedPermanently = true
            errorType = AudioPermissionErrorType.NOT_ALLOWED_ERROR
            break
          case 'NotReadableError':
            errorType = AudioPermissionErrorType.NOT_READABLE_ERROR
            break
          case 'OverconstrainedError':
            errorType = AudioPermissionErrorType.OVERCONSTRAINED_ERROR
            break
          case 'SecurityError':
            errorType = AudioPermissionErrorType.SECURITY_ERROR
            break
          default:
            errorType = AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR
        }

        const audioError = AudioPermissionError.create(errorType)
        return Promise.reject(audioError)
      }

      const unknownError = AudioPermissionError.create(
        AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR,
        error instanceof Error ? error.message : undefined,
      )
      return Promise.reject(unknownError)
    }
  }

  /**
   * 检查麦克风权限状态
   */
  public isMicrophonePermissionGranted(): boolean {
    return this.microphonePermissionGranted
  }
}

/**
 * 统一的音频权限管理类
 */
class AudioPermission {
  public playbackPermission: AudioPlaybackPermission
  public microphonePermission: MicrophonePermission

  constructor() {
    this.playbackPermission = new AudioPlaybackPermission()
    this.microphonePermission = new MicrophonePermission()

    this.playbackPermission.setExecuteContext(this)
  }

  /**
   * 请求音频播放权限
   */
  public async requestPlaybackPermission(): Promise<void> {
    return this.playbackPermission.requestPlaybackPermission()
  }

  /**
   * 请求麦克风权限
   */
  public async requestMicrophonePermission(): Promise<void> {
    return this.microphonePermission.requestMicrophonePermission()
  }

  /**
   * 检查麦克风权限状态
   */
  public isMicrophonePermissionGranted(): boolean {
    return this.microphonePermission.isMicrophonePermissionGranted()
  }
}

let audioPermissionInstance: AudioPermission | null = null

/**
 * 创建或获取音频权限管理实例
 */
function createAudioPermission(): AudioPermission {
  if (!audioPermissionInstance) {
    audioPermissionInstance = new AudioPermission()
  }
  return audioPermissionInstance
}

export default createAudioPermission
export { AudioPermission, AudioPermissionError, AudioPermissionErrorType, AudioPlaybackPermission, MicrophonePermission }
js 复制代码
import createAudioPermission from "./create-audio-permission"

const audioPermissionInstance = createAudioPermission()

const palyButton = document.createElement('button')
palyButton.innerHTML = '浏览器播放'
palyButton.addEventListener('click', () => {
    // 获取音频播放权限
  audioPermissionInstance.requestPlaybackPermission().then(() => {
    console.log('播放权限获取成功')
  }).catch((error) => {
    console.log(error.message)
  })
})
document.body.appendChild(palyButton)

在 iOS中,获取音频播放权限,必须先获取麦克风权限

实战效果

用户初始进入时,因为并没有和网页产生交互,所以是静音状态。

  • 在 PC、Android中,当用户点击"打开声音",就等于和网页产生了点击交互,也就能顺利获取音频播放权限。

  • 在 iOS 中,用户点击"打开声音",首先需要获取浏览器麦克风权限,才能顺利获取音频播放权限。

麦克风

根据 MDN 文档,调用 navigator.mediaDevices.getUserMedia 需同时满足:

  1. 安全上下文(HTTPS)。
  2. 用户明确授权。
  3. 顶级文档上下文,或通过 Permissions-Policy 授权的 <iframe>

前两条必须同时满足;若页面嵌入 <iframe>,还需满足第三条。

注意点

  • 在 iOS 中,通过内网 IP 地址 访问网页时无法获取麦克风权限。
    • 解决办法:通过内网穿透将内网 IP 映射为公网域名访问。
  • 浏览器会永久缓存用户的麦克风权限决定;若用户首次弹框即拒绝,后续将默认拒绝。
  • 在小米安卓手机中,如果用户拒绝了麦克风权限,后续调用 navigator.mediaDevices.getUserMedia 一直卡住,需做超时处理。

交互实现

代码实现

typescript 复制代码
/**
 * 判断当前环境是否为 iOS 设备。
 *
 * @returns 如果是iOS设备,返回true;否则返回false。
 */
function isIos(): boolean {
  const userAgent = navigator.userAgent.toLowerCase()

  return /iphone|ipad|ipod/.test(userAgent)
}


/**
 * 判断当前环境是否为 Android 设备。
 *
 * @returns 如果是Android设备,返回true;否则返回false。
 */
function isAndroid(): boolean {
  const userAgent = navigator.userAgent.toLowerCase()

  return /android/.test(userAgent)
}


enum AudioPermissionErrorType {
  NO_USER_INTERACTION_ERROR = 'NoUserInteractionError',
  AUTOPLAY_BLOCKED = 'AutoplayBlocked',
  AUTOPLAY_UNKNOWN_ERROR = 'AutoplayUnknownError',

  SECURE_CONTEXT_ERROR = 'SecureContextError',
  PERMISSION_DENIED_PERMANENTLY = 'PermissionDeniedPermanently',
  NOT_ALLOWED_ERROR = 'NotAllowedError',
  MEDIA_DEVICES_API_UNAVAILABLE = 'MediaDevicesApiUnavailable',
  NOT_FOUND_ERROR = 'NotFoundError',
  NOT_READABLE_ERROR = 'NotReadableError',
  OVERCONSTRAINED_ERROR = 'OverconstrainedError',
  SECURITY_ERROR = 'SecurityError',
  MICROPHONE_UNKNOWN_ERROR = 'MicrophoneUnknownError',
  MICROPHONE_TIMEOUT_ERROR = 'MicrophoneTimeoutError',
}

class AudioPermissionError extends Error {
  public name: AudioPermissionErrorType

  private constructor(name: AudioPermissionErrorType, message: string) {
    super(message)
    this.name = name
  }

  public static create(
    name: AudioPermissionErrorType,
    message?: string,
  ): AudioPermissionError {
    const codeMessageMap = {
      [AudioPermissionErrorType.NO_USER_INTERACTION_ERROR]: '需要用户交互才能播放音频',
      [AudioPermissionErrorType.AUTOPLAY_BLOCKED]: '自动播放被浏览器阻止',
      [AudioPermissionErrorType.AUTOPLAY_UNKNOWN_ERROR]: '未知自动播放错误',

      [AudioPermissionErrorType.SECURE_CONTEXT_ERROR]: '麦克风权限需要在安全上下文(HTTPS)中使用',
      [AudioPermissionErrorType.PERMISSION_DENIED_PERMANENTLY]: '音频权限已被永久拒绝,需在浏览器设置中开启麦克风权限',
      [AudioPermissionErrorType.MEDIA_DEVICES_API_UNAVAILABLE]: 'MediaDevices API不可用',
      [AudioPermissionErrorType.NOT_ALLOWED_ERROR]: '麦克风权限被拒绝',
      [AudioPermissionErrorType.NOT_READABLE_ERROR]: '音频设备被其他应用程序占用',
      [AudioPermissionErrorType.NOT_FOUND_ERROR]: '未找到音频输入设备',
      [AudioPermissionErrorType.SECURITY_ERROR]: '安全策略阻止访问麦克风',
      [AudioPermissionErrorType.OVERCONSTRAINED_ERROR]: '音频设备不满足指定的约束条件',
      [AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR]: '未知麦克风权限错误',
      [AudioPermissionErrorType.MICROPHONE_TIMEOUT_ERROR]: '获取麦克风权限超时,请检查设备权限设置或重试',

    }

    return new AudioPermissionError(name, message || codeMessageMap[name as keyof typeof codeMessageMap])
  }
}

/**
 * 音频播放权限管理类
 *
 * 根据MDN文档,音频播放权限需要满足以下条件之一:
 * 1. 音频被静音或音量为0
 * 2. 用户已与网站产生交互(点击、触摸、按键等)
 * 3. 网站已被加入自动播放白名单
 * 4. 通过Permissions Policy授权
 *
 * 跨平台实现策略:
 * - PC端:预检测AudioContext状态,如果suspended则监听用户交互
 * - iOS Safari:必须在用户交互事件的调用栈中调用resume(),需要50ms延迟确保状态变更
 * - Android:直接在用户交互后检测AudioContext状态
 *
 * 自动播放策略限制:
 * - Chrome 66+:需要用户交互或MEI(Media Engagement Index)足够高
 * - Safari:严格要求用户交互,AudioContext创建时默认为suspended状态
 * - Firefox:相对宽松,但仍会阻止明显的自动播放行为
 *
 *
 *
 * 实现原理:
 *    PC:
 *      1.在PC端首先预检测AudioContext状态:
 *        如果状态是 "running" 状态则可以播放音频,
 *        如果状态不是 "running" 状态则需要监听用户交互事件,
 *      2.用户交互事件触发后,再检测AudioContext状态:
 *        如果状态是 "running" 状态则可以播放音频,
 *        如果状态不是 "running" 状态则抛出异常。
 *
 *    IOS:
 *      1.用户交互事件触发后,再检测AudioContext状态:
 *        如果状态是 "suspended" 则需要调用 resume 方法,并且需要延迟 50ms,
 *        如果状态不是 "suspended" 状态进行下一步判断,
 *      2.获取AudioContext状态:
 *        如果状态是 "running" 状态则可以播放音频,
 *        如果状态不是 "running" 状态则抛出异常。
 *
 *    Android:
 *      1.用户交互事件触发后,再检测AudioContext状态:
 *        如果状态是 "running" 状态则可以播放音频,
 *        如果状态不是 "running" 状态则抛出异常。
 */
class AudioPlaybackPermission {
  // 用户是否已产生交互行为
  private userInteracted: boolean = false

  private executeContext!: AudioPermission

  constructor() {
    this.initPlaybackPermission()
  }

  /**
   * 初始化播放权限检测
   */
  private initPlaybackPermission(): void {
    // 根据MDN文档,PC平台可以预检测AudioContext状态
    if (!isIos() && !isAndroid()) {
      this.checkInitialAudioContextState().catch(() => {
        this.setupUserInteractionListeners()
      })
    }
    else {
      this.setupUserInteractionListeners()
    }
  }

  /**
   * 检测初始AudioContext状态(仅PC平台)
   */
  private async checkInitialAudioContextState(): Promise<void> {
    const testContext = new (window.AudioContext || (window as any).webkitAudioContext)()

    if (testContext.state === 'running') {
      this.userInteracted = true
    }

    await testContext.close()

    if (this.userInteracted) {
      return Promise.resolve()
    }
    else {
      // eslint-disable-next-line prefer-promise-reject-errors
      return Promise.reject()
    }
  }

  /**
   * 设置用户交互监听器
   */
  private setupUserInteractionListeners(): void {
    const interactionEvents = [
      'click',
      'touchstart',
      'touchend',
      'keydown',
      'mousedown',
      'pointerdown',
    ]

    const handleInteraction = (): void => {
      this.userInteracted = true

      // 清理所有事件监听器
      interactionEvents.forEach((eventType: string) => {
        document.removeEventListener(eventType, handleInteraction)
      })
    }

    interactionEvents.forEach((eventType) => {
      document.addEventListener(eventType, handleInteraction, { passive: true, once: true })
    })
  }

  /**
   * 请求播放权限
   * 根据Web Audio API最佳实践实现
   */
  public async requestPlaybackPermission(): Promise<void> {
    let audioContext: AudioContext | null = null
    try {
      // 检查用户交互状态
      if (!this.userInteracted) {
        throw AudioPermissionError.create(AudioPermissionErrorType.NO_USER_INTERACTION_ERROR)
      }

      // iOS平台特殊处理:先申请麦克风权限
      if (isIos() && this.executeContext) {
        await this.executeContext.requestMicrophonePermission()
      }

      audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()

      // 根据MDN文档,检查AudioContext状态并处理
      if (isIos() && audioContext.state === 'suspended') {
        // iOS Safari特殊处理:必须在用户交互的调用栈中resume
        await Promise.race([
          audioContext.resume(),
          new Promise((_, reject) =>
            setTimeout(() => reject(AudioPermissionError.create(AudioPermissionErrorType.AUTOPLAY_BLOCKED)), 3000),
          ),
        ])

        // iOS可能需要短暂延迟确保状态变更
        await new Promise(resolve => setTimeout(resolve, 50))
      }

      // 验证最终状态
      if (audioContext.state !== 'running') {
        throw AudioPermissionError.create(AudioPermissionErrorType.AUTOPLAY_BLOCKED)
      }

      return Promise.resolve()
    }
    catch (error) {
      if (error instanceof AudioPermissionError) {
        return Promise.reject(error)
      }

      const unknownError = AudioPermissionError.create(
        AudioPermissionErrorType.AUTOPLAY_UNKNOWN_ERROR,
        error instanceof Error ? error.message : undefined,
      )
      return Promise.reject(unknownError)
    }
    finally {
      if (audioContext && audioContext.state !== 'closed') {
        try {
          await audioContext.close()
        }
        catch { }
      }
    }
  }

  /**
   * 执行上下文设置
   * @param context 执行上下文
   */
  setExecuteContext(context: AudioPermission): void {
    this.executeContext = context
  }
}

/**
 * 麦克风权限管理类
 *
 * 根据MDN文档,getUserMedia需要满足:
 * 1. 安全上下文(HTTPS)
 * 2. 用户明确授权
 * 3. 顶级文档上下文或通过Permissions Policy授权的iframe
 *
 *
 * 权限状态说明:
 * - granted: 用户已授权,可直接访问麦克风
 * - denied: 用户已拒绝,需要用户手动在浏览器设置中重新开启
 * - prompt: 首次访问,会弹出授权对话框
 *
 *
 * 平台差异:
 * - 桌面端:通过浏览器原生授权对话框处理
 * - iOS Safari:权限被拒绝后,需要用户在系统设置中重新开启
 * - Android Chrome:权限处理与桌面端类似,但某些版本可能有缓存问题
 *
 *
 * 注意事项:
 * 1. 浏览器会永久缓存用户的权限决定
 * 2. 在非HTTPS环境下(除localhost外)会直接失败
 * 3. 某些浏览器在隐私模式下可能有不同行为
 * 4. iframe中使用需要正确配置Permissions Policy
 *
 *
 * 实现原理:
 * - PC平台:
 *     1. 通过授权弹框申请权限:
 *          如果用户拒绝了权限,会抛出异常
 *          如果用户点击了允许,会返回成功
 *     2.第二次申请权限,会根据用户第一次申请权限的结果:
 *          如果用户第一次申请权限失败,会抛出异常
 *          如果用户第一次申请权限成功,会返回成功
 * - 移动端:
 *     1. 通过授权弹框申请权限:
 *          如果用户拒绝了权限,会抛出异常
 *          如果用户点击了允许,会返回成功
 *      2.第二次申请权限,会根据用户第一次申请权限的结果:
 *          如果用户第一次申请权限失败,会抛出异常
 *          如果用户第一次申请权限成功,会返回成功
 *
 *  iOS平台:
 *      1. 通过授权弹框申请权限:
 *          如果用户拒绝了权限,会抛出异常
 *          如果用户点击了允许,会返回成功
 *      2.第二次申请权限,会根据用户第一次申请权限的结果:
 *          如果用户第一次申请权限失败,会抛出异常
 *          如果用户第一次申请权限成功,会返回成功
 *
 *
 */

class MicrophonePermission {
  private microphonePermissionGranted: boolean = false
  private permissionDeniedPermanently: boolean = false

  /**
   * 检查是否在安全上下文中
   */
  private isSecureContext(): boolean {
    return window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost'
  }

  /**
   * 带超时的getUserMedia调用
   * @param constraints - 媒体约束
   * @param timeoutMs - 超时时间(毫秒)
   * @returns Promise<MediaStream>
   */
  private async getUserMediaWithTimeout(
    constraints: MediaStreamConstraints,
        timeoutMs: number = 10000,
  ): Promise<MediaStream> {
    return new Promise((resolve, reject) => {
      // 设置超时定时器
      const timeoutId = setTimeout(() => {
        reject(AudioPermissionError.create(
          AudioPermissionErrorType.MICROPHONE_TIMEOUT_ERROR,
        ))
      }, timeoutMs)

      // 调用getUserMedia
      navigator.mediaDevices.getUserMedia(constraints)
        .then((stream) => {
          clearTimeout(timeoutId)
          resolve(stream)
        })
        .catch((error) => {
          clearTimeout(timeoutId)
          reject(error)
        })
    })
  }

  /**
   * 请求麦克风权限
   * 根据MediaDevices.getUserMedia规范实现
   */
  public async requestMicrophonePermission(): Promise<void> {
    try {
      // 检查安全上下文
      if (!this.isSecureContext()) {
        throw AudioPermissionError.create(
          AudioPermissionErrorType.SECURE_CONTEXT_ERROR,
        )
      }

      //   检查是否已被永久拒绝
      if (this.permissionDeniedPermanently) {
        throw AudioPermissionError.create(AudioPermissionErrorType.PERMISSION_DENIED_PERMANENTLY)
      }

      // 如果权限已获取,直接返回
      if (this.microphonePermissionGranted) {
        return Promise.resolve()
      }

      // 检查MediaDevices API可用性
      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
        throw AudioPermissionError.create(
          AudioPermissionErrorType.MEDIA_DEVICES_API_UNAVAILABLE,
        )
      }

      // 请求麦克风权限
      const stream = await this.getUserMediaWithTimeout({
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: true,
        },
      })

      // 权限获取成功
      this.microphonePermissionGranted = true

      // 立即停止媒体流,我们只是测试权限
      stream.getTracks().forEach((track) => {
        track.stop()
      })

      return Promise.resolve()
    }
    catch (error) {
      this.microphonePermissionGranted = false

      if (error instanceof AudioPermissionError) {
        return Promise.reject(error)
      }

      if (error instanceof DOMException) {
        let errorType: AudioPermissionErrorType

        switch (error.name) {
          case 'NotFoundError':
            errorType = AudioPermissionErrorType.NOT_FOUND_ERROR
            break
          case 'NotAllowedError':
            this.permissionDeniedPermanently = true
            errorType = AudioPermissionErrorType.NOT_ALLOWED_ERROR
            break
          case 'NotReadableError':
            errorType = AudioPermissionErrorType.NOT_READABLE_ERROR
            break
          case 'OverconstrainedError':
            errorType = AudioPermissionErrorType.OVERCONSTRAINED_ERROR
            break
          case 'SecurityError':
            errorType = AudioPermissionErrorType.SECURITY_ERROR
            break
          default:
            errorType = AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR
        }

        const audioError = AudioPermissionError.create(errorType)
        return Promise.reject(audioError)
      }

      const unknownError = AudioPermissionError.create(
        AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR,
        error instanceof Error ? error.message : undefined,
      )
      return Promise.reject(unknownError)
    }
  }

  /**
   * 检查麦克风权限状态
   */
  public isMicrophonePermissionGranted(): boolean {
    return this.microphonePermissionGranted
  }
}

/**
 * 统一的音频权限管理类
 */
class AudioPermission {
  public playbackPermission: AudioPlaybackPermission
  public microphonePermission: MicrophonePermission

  constructor() {
    this.playbackPermission = new AudioPlaybackPermission()
    this.microphonePermission = new MicrophonePermission()

    this.playbackPermission.setExecuteContext(this)
  }

  /**
   * 请求音频播放权限
   */
  public async requestPlaybackPermission(): Promise<void> {
    return this.playbackPermission.requestPlaybackPermission()
  }

  /**
   * 请求麦克风权限
   */
  public async requestMicrophonePermission(): Promise<void> {
    return this.microphonePermission.requestMicrophonePermission()
  }

  /**
   * 检查麦克风权限状态
   */
  public isMicrophonePermissionGranted(): boolean {
    return this.microphonePermission.isMicrophonePermissionGranted()
  }
}

let audioPermissionInstance: AudioPermission | null = null

/**
 * 创建或获取音频权限管理实例
 */
function createAudioPermission(): AudioPermission {
  if (!audioPermissionInstance) {
    audioPermissionInstance = new AudioPermission()
  }
  return audioPermissionInstance
}

export default createAudioPermission
export { AudioPermission, AudioPermissionError, AudioPermissionErrorType, AudioPlaybackPermission, MicrophonePermission }
js 复制代码
import createAudioPermission from "./create-audio-permission"

const audioPermissionInstance = createAudioPermission()


const microphoneButton = document.createElement('button')
microphoneButton.innerHTML = '麦克风权限'
microphoneButton.addEventListener('click', () => {
  // 获取麦克风权限
  audioPermissionInstance.requestMicrophonePermission().then(() => {
    console.log('麦克风权限获取成功')
  }).catch((error) => {
    console.log(error.message)
  })
})
document.body.appendChild(microphoneButton)

实战效果

相关推荐
小猪猪屁38 分钟前
WebAssembly 从零到实战:前端性能革命完全指南
前端·vue.js·webassembly
EMT39 分钟前
记一个Vue.extend的用法
前端·vue.js
RaidenLiu43 分钟前
Riverpod 3:组合与参数化的进阶实践
前端·flutter
jason_yang1 小时前
vue3自定义渲染内容如何当参数传递
前端·javascript·vue.js
年年测试1 小时前
Browser Use 浏览器自动化 Agent:让浏览器自动为你工作
前端·数据库·自动化
维维酱1 小时前
React Fiber 架构与渲染流程
前端·react.js
gitboyzcf1 小时前
基于Taro4最新版微信小程序、H5的多端开发简单模板
前端·vue.js·taro
姓王者1 小时前
解决Tauri2.x拖拽事件问题
前端
冲!!1 小时前
vue3存储/获取本地或会话存储,封装存储工具,结合pina使用存储
前端·javascript·vue.js