Repository: streamlit/streamlit
File: frontend/lib/src/components/audio/backends/WaveSurferRecordBackend.ts (TypeScript)

Code before:
        this.recordEndReject(new Error("Invalid or empty recording"))
        this.recordEndResolve = null
        this.recordEndReject = null
      } else {
        // Defensive: avoid memory leak if both resolve/reject are null
        this.recordEndResolve = null
        this.recordEndReject = null
      }
    })

    this.recordPlugin.on("record-progress", (time: number) => {
      this.events.onRecordProgress?.(time)
    })

    // Listen for device-error event from WaveSurfer Record plugin
    // This fires when getUserMedia fails (e.g., permission denied)
    // @ts-expect-error - device-error is not in the official type definitions but is emitted by the plugin
    this.recordPlugin.on("device-error", (error: Error) => {
      this.isRecording = false

      // Check if this is a permission denied error
      // WaveSurfer may wrap the native error, so check both the error name and message
      const isPermissionDenied =
        error.name === "NotAllowedError" ||
        error.name === "PermissionDeniedError" ||
        error.message?.toLowerCase().includes("permission denied")

      if (isPermissionDenied) {
        this.events.onPermissionDenied?.()
      } else {
        this.events.onError?.(error)
      }
    })
  }

  setEventHandlers(events: RecordBackendEvents): void {
    this.events = events
  }

  async startRecording(): Promise<void> {
    if (!this.recordPlugin) {
      throw new Error("Record plugin not initialized")
    }

    if (this.isRecording) {
      return
    }

    const requestedSampleRate =
      typeof this.options.sampleRate === "number"
        ? this.options.sampleRate

Review comment (security):
**suggestion:** Given the amount of specific strings, we should move the logic for checking if the error is a Permission Denied into a reusable function and use it in the other codepaths in this file.

Code after:
    this.recordPlugin.on("record-end", (blob: Blob) => {
      this.isRecording = false
      this.events.onRecordEnd?.(blob)

      if (this.recordEndResolve && blob && blob.size > 0) {
        this.recordEndResolve(blob)
        this.recordEndResolve = null
        this.recordEndReject = null
      } else if (this.recordEndReject) {
        this.recordEndReject(new Error("Invalid or empty recording"))
        this.recordEndResolve = null
        this.recordEndReject = null
      } else {
        // Defensive: avoid memory leak if both resolve/reject are null
        this.recordEndResolve = null
        this.recordEndReject = null
      }
    })

    this.recordPlugin.on("record-progress", (time: number) => {
      this.events.onRecordProgress?.(time)
    })
  }

  setEventHandlers(events: RecordBackendEvents): void {
    this.events = events
  }

  async startRecording(): Promise<void> {
    if (!this.recordPlugin) {
      throw new Error("Record plugin not initialized")
    }

    if (this.isRecording) {
      return
    }

    const requestedSampleRate =
      typeof this.options.sampleRate === "number"
        ? this.options.sampleRate
        : undefined

    const initialConstraints: MediaTrackConstraints = {}
    if (requestedSampleRate !== undefined) {
      initialConstraints.sampleRate = { ideal: requestedSampleRate }
    }

    await this.startRecordingWithConstraints(
      initialConstraints,
      requestedSampleRate !== undefined
    )