open static method

Future<AudioRingSink?> open({
  1. required int sampleRate,
  2. required int channels,
  3. Duration depth = kDefaultRingDepth,
  4. String workletUrl = kWorkletAssetUrl,
})

Opens a sink for sampleRate/channels, or returns null when this page cannot host one.

Null covers a page that is not cross-origin isolated (no shared memory to play from), a browser without AudioWorklet, and a worklet module that will not load. Every one of them means "keep the sink you have".

The context is created AT sampleRate so the browser resamples to the device rate for us; feeding a 44.1 kHz stream to a 48 kHz device otherwise plays it slightly sharp.

workletUrl exists so a test can serve the module from its own tree.

Implementation

static Future<AudioRingSink?> open({
  required int sampleRate,
  required int channels,
  Duration depth = kDefaultRingDepth,
  String workletUrl = kWorkletAssetUrl,
}) async {
  if (sampleRate <= 0 || channels <= 0) return null;

  final ring = SharedAudioRing.allocate(
    capacityFrames: (depth.inMicroseconds * sampleRate) ~/ 1000000,
    channels: channels,
    sampleRate: sampleRate,
  );
  // No shared memory means the worklet would be reading a buffer the
  // producer's writes never reach. Better to decline than to play silence.
  if (!ring.isSharedAcrossThreads) return null;

  web.AudioContext? context;
  try {
    context = web.AudioContext(
      web.AudioContextOptions(sampleRate: sampleRate.toDouble()),
    );

    // Verify the browser actually HONOURED the requested rate.
    //
    // Asking for a rate is a request, not a guarantee: WebKit ties an
    // AudioContext to the hardware audio session and has historically run at
    // the device rate regardless of what was asked for. The failure that
    // buys is silent and total — samples produced for 48 kHz fed to a
    // 44.1 kHz context play about 9% sharp, for the whole stream, with
    // nothing anywhere reporting a problem. Declining costs us the
    // off-thread path on that device and keeps playback correct, which is
    // the right way round.
    if (context.sampleRate.round() != sampleRate) {
      await context.close().toDart;
      return null;
    }

    // `audioWorklet` is undefined on a browser without it, and reading
    // `.addModule` off undefined throws into the catch below — but say so
    // explicitly, because "no AudioWorklet" and "the module failed to load"
    // are different problems and only one of them is a bug worth chasing.
    if (!context.has('audioWorklet')) {
      await context.close().toDart;
      return null;
    }
    await context.audioWorklet.addModule(workletUrl).toDart;

    final node = web.AudioWorkletNode(
      context,
      _kProcessor,
      web.AudioWorkletNodeOptions(
        numberOfInputs: 0,
        numberOfOutputs: 1,
        outputChannelCount: <int>[channels].jsify()! as JSArray<JSNumber>,
        processorOptions: _processorOptions(ring),
      ),
    );
    final gain = web.GainNode(context);
    node.connect(gain);
    gain.connect(context.destination);

    // Autoplay policy: a context created before a user gesture starts
    // suspended. Resuming is best effort — if it is refused the sink still
    // works and starts when the page resumes it.
    unawaited(context.resume().toDart.then<void>((_) {}, onError: (_) {}));

    return AudioRingSink._(context, node, gain, ring);
  } on Object {
    try {
      await context?.close().toDart;
    } on Object {
      // Nothing to salvage; the caller falls back.
    }
    return null;
  }
}