monowave 0.3.0 copy "monowave: ^0.3.0" to clipboard
monowave: ^0.3.0 copied to clipboard

Headless audio for Flutter: microphone capture, waveform peaks, and non-destructive editing, from one C core across all six Flutter targets.

Monowave #

Monowave is headless audio for Flutter: microphone capture, waveform peaks, and non-destructive editing, on all six Flutter targets from one C core.

Headless. The package exports no widget. A decode hands back a zero-copy view over min/max peaks plus the viewport math to place them; capture hands back reduced frames. What the waveform looks like is entirely yours - nothing under lib/ imports package:flutter/widgets.dart, material.dart or cupertino.dart, and CI enforces it with a grep.

Player-agnostic. WaveformTimeline maps Duration to samples and back, and that is the whole of monowave's relationship with playback. It never sees a player, so just_audio, media_kit or your own engine are each a few lines of adapter in the host and none of them are a dependency here.

Six targets, one implementation. Android, iOS, macOS, Windows, Linux and web all run the same C - reached over dart:ffi natively and over WASM on web. CI asserts the peaks come out byte-identical on every one, which is the only reason "one core" is a claim rather than a hope.

Bounded by pixels, not by file length. Peaks are a mipmap pyramid, so zooming picks a level instead of re-reading data. A three-hour recording resolves a frame in about 6 microseconds against a 16,667 microsecond budget, and never reaches the Dart heap.

Install #

flutter pub add monowave

Monowave compiles its native code with a Dart build hook, so consumers need native assets enabled once, per machine:

flutter config --enable-native-assets

There is no CocoaPods pod, no Gradle plugin and no per-ABI binary in the package - the C is built from source in src/ as part of your build, for whatever you are targeting.

Capture needs a microphone permission, which monowave deliberately does not request: a headless package has no UI to explain why it is asking, and you do. Declare the usage strings and ask with whatever permission plugin you already have. iOS Info.plist:

<key>NSMicrophoneUsageDescription</key><string>...</string>

Android AndroidManifest.xml:

<uses-permission android:name="android.permission.RECORD_AUDIO" />

Decode #

Every call into the C core crosses MonowavePlatform. Initialize once - on native that resolves the code asset, on web it instantiates the WASM module, which is the reason this is asynchronous at all:

final monowave = MonowavePlatform.instance;
await monowave.ensureInitialized();

final peaks = await monowave.decodeFile(path);   // WAV, MP3 or FLAC

The decoder streams the file a bucket at a time, so an audiobook is never resident in memory. On web there is no filesystem - use decodeBytes there.

WaveformPeaks is a pyramid: level 0 is the finest resolution held, and each level above it covers twice as many samples per min/max pair. Reduction is always min/max, never an average - averaging collapses transients and renders speech as a flat sausage. peaks.rms(level) carries loudness as a second series to overlay, not as a replacement.

Peaks the C core produced are backed by memory it owns. Call dispose() when the waveform leaves the screen; any view handed out beforehand dangles afterwards, so drop those first.

Draw #

WaveformViewport is pure math: which part of the audio is on screen and at what zoom. resolve picks the mipmap level for you and returns the slice to draw, already in painter coordinates - so a CustomPainter is a loop with no arithmetic of its own:

class WavePainter extends CustomPainter {
  WavePainter(this.peaks, this.viewport);

  final WaveformPeaks peaks;
  final WaveformViewport viewport;

  @override
  void paint(Canvas canvas, Size size) {
    final window = viewport.resolve(peaks);
    final mid = size.height / 2;
    final scale = size.height / 2 / 32768;
    final paint = Paint()..color = const Color(0xFF3B82F6);

    for (var i = 0; i < window.pairCount; i++) {
      final x = window.xOfFirstPair + i * window.pixelsPerPair;
      canvas.drawRect(
        Rect.fromLTRB(
          x,
          mid - window.maxAt(i) * scale,     // max is the top...
          x + window.pixelsPerPair,
          mid - window.minAt(i) * scale,     // ...and min is negative
        ),
        paint,
      );
    }
  }

  // WaveformViewport is immutable but carries no `==`, so compare the fields.
  @override
  bool shouldRepaint(WavePainter old) =>
      !identical(old.peaks, peaks) ||
      old.viewport.startSample != viewport.startSample ||
      old.viewport.samplesPerPixel != viewport.samplesPerPixel ||
      old.viewport.widthPx != viewport.widthPx;
}

window.xOfFirstPair is usually slightly negative. That is deliberate: the window snaps outward to whole pairs so panning stays smooth instead of stepping a bar at a time.

Gestures produce new viewports rather than mutating one, so this binds to any state management:

var viewport = WaveformViewport.fitted(peaks, width);

viewport = viewport.pannedBy(details.delta.dx).clampedTo(peaks);
viewport = viewport.zoomedAt(focalX, details.scale).clampedTo(peaks);

clampedTo is what stops the audio being lost off-screen - it bounds scroll, refuses to zoom out past the whole file, and refuses to zoom in past the finest level, since there is no finer data in memory and the result would just be stretched.

A playhead and a seek are the timeline composed with the viewport:

final timeline = WaveformTimeline.of(peaks);

final x = viewport.xForSample(timeline.sampleAt(player.position));
player.seek(timeline.timeAt(viewport.sampleAtX(localX)));

Capture #

The audio thread reduces each hop and publishes it through a lock-free ring; the Dart side drains that ring on a timer. No PCM crosses into Dart, and nothing in the path allocates on the audio thread.

final session = await monowave.openCapture(
  const CaptureConfig(
    maxDuration: Duration(minutes: 5),
    recordTo: '/path/to/take.wav',   // omit to keep only the reduction
  ),
);

await session.start();
session.frames.listen((frame) => setState(() {}));   // ~86/sec at defaults

final peaks = await session.stop();   // caller owns these - dispose them

recordTo streams raw PCM to a 16-bit WAV through a second ring, separate from the reduced frames, because the two run at 44,100/sec against 86/sec and a dropped visualizer frame is cosmetic where a dropped sample is a hole. That is why pcmDropped is reported apart from dropped.

pause() and resume() stop the device without touching the rings, the accumulator or the history, so a take continues rather than restarting. The peaks from stop() come from the audio thread's own history rather than from what the visualizer happened to collect, so they are complete even if the app was backgrounded and missed drains.

For a live meter, draw from session.scope - a fixed-capacity rolling window that allocates nothing per frame:

for (var i = 0; i < scope.length; i++) {
  final height = scope.amplitudeAt(i) * trackHeight;   // 0..1, either direction
}

Repaint on scope.revision, not on scope.length. The scope is a ring that mutates in place, so once it is full the length never changes again and a shouldRepaint keyed on it silently stops repainting.

openCapture throws CaptureUnavailable when the microphone permission has not already been granted - see Install.

Edit #

An edit is a value, not a command against a buffer. A WaveformDocument is a list of regions, each one a range in the source plus a gain and two fade lengths; nothing decodes, copies or mutates audio, and the source is untouched until an export reads it.

var doc = WaveformDocument.of(peaks);

doc = doc.applying(DeleteEdit(selection));
doc = doc.applying(GainEdit(selection, 1.5));
doc = doc.applying(FadeEdit(selection, fadeIn: 2048, fadeOut: 2048));

TrimEdit, DeleteEdit, SplitEdit, GainEdit and FadeEdit form a sealed set, so a renderer or an exporter can switch over them exhaustively and the compiler catches a kind that was not handled.

The waveform updates without a round trip through the decoder:

final preview = doc.previewPeaks(peaks);   // concatenates slices, scales by gain

Undo is snapshots rather than inverse operations - cheap precisely because an edit is a value, and correct for edits that have no inverse anyway (a fade destroys the samples it fades):

final history = EditHistory(WaveformDocument.of(peaks));

history.apply(DeleteEdit(selection));
if (history.canUndo) history.undo();

history.current;          // the document as it stands
history.undoLabel;        // 'Delete' - for a menu item

Selections live in source samples, so they survive a zoom, a resize and a rotation without drifting:

var selection = WaveformSelection.at(viewport.sampleAtX(down.localPosition.dx));
selection = selection.extendedTo(sample).clampedTo(peaks);

// Cut on a sign change and avoid the click that cutting mid-swing produces.
final cut = WaveformSnap.toZeroCrossing(peaks, selection.start);

WaveformSnap resolves to the finest level's resolution - about 3 ms with a 128-sample base at 44.1 kHz - not to an exact sample, which is worth stating plainly because "zero crossing" usually implies exactness. Sample-exact snapping would mean a decode per gesture.

Export writes 16-bit PCM WAV. Always WAV: an edit list is meant to reproduce the source exactly where it did not change it, and re-encoding to a lossy format would quietly break that.

await monowave.exportWav(
  sourcePath: path,
  outputPath: out,
  document: doc,
);

Peaks without a decoder #

Two codecs cover the cases where decoding is the wrong move.

Voice notes. CompactBars summarizes peaks into a fixed-width byte array - 64 bars is 64 bytes, and base64s to 88 characters. The sender computes bars at record time and uploads them beside the audio; the receiver draws from those bytes with no decoder, no native code and no waiting. That removes the entire decode path from the common case, which is what the messaging apps do.

final bars = CompactBars.encode(peaks);            // dBFS-scaled by default
await upload(audio, CompactBars.toBase64(bars));

// Receiver:
final heights = CompactBars.heights(CompactBars.fromBase64(encoded));

Scaling defaults to BarScale.dbfs rather than linear because normal speech peaks well below full scale, and a linear waveform of a voice note looks nearly flat.

Precomputed peaks. WaveformDat reads and writes the BBC audiowaveform binary format, so peaks can be generated server-side on upload and shipped to a client that owns no decoder - and monowave interoperates with the peaks.js ecosystem for free.

final peaks = WaveformDat.decode(bytes);

Testing #

import 'package:monowave/testing.dart';

final platform = FakeMonowavePlatform();
platform.install();
addTearDown(FakeMonowavePlatform.uninstall);

platform.decoded[path] = somePeaks;

// ...drive your controller, then assert on the request rather than on bytes:
expect(platform.decodeRequests, [path]);
expect(platform.exports.single.$3.regions, hasLength(2));

A host's tests should not need a microphone, an audio file or a native core, so every seam has a fake. FakeCaptureSession drives the same state a real session does - recording flag, frame stream, scope - with no device, and nextDecodeError / nextCaptureError make the failure paths reachable.

Platform support #

Decode Draw Capture Export
Android, iOS, macOS, Windows, Linux yes yes yes yes
Web decodeBytes only yes no no

Web has no filesystem, so decodeFile and exportWav are native-only; feed decodeBytes instead. Capture on web needs a WebAudio path that does not exist yet - doc/architecture.md has the reasoning.

Decoding covers WAV, MP3 and FLAC. AAC/M4A needs a platform decoder monowave does not carry, and reports DecodeFailure.unsupportedFormat; the voice-note path avoids the question entirely by computing peaks at record time.

Architecture #

lib/
  monowave.dart              public surface - no widgets
  testing.dart               test doubles (never imported by lib/src)
  src/
    capture/                 CaptureSession, CaptureScope, CaptureConfig
    codec/                   CompactBars, WaveformDat
    edit/                    WaveformDocument, WaveformEdit, EditHistory
    model/                   WaveformPeaks, WaveformViewport, WaveformTimeline,
                             WaveformSelection
    platform/                MonowavePlatform - ffi / wasm behind one seam
    native/                  generated ffigen bindings
src/                         the C core: peaks, decode, capture, export
hook/build.dart              builds src/ as a code asset during your build
assets/monowave.wasm         the same core for web, built by tool/build_wasm.sh

MonowavePlatform is an interface rather than the bindings directly, so tests run against an in-memory fake with no native code and no device - and so the package could be federated later without touching callers. The web/native split is a conditional import on dart.library.js_interop, which is what keeps dart:ffi out of a web build.

There is no flutter: plugin: block in the pubspec, and that is not an oversight: monowave has no platform plugin classes. The native side is a code asset reached over FFI, not a registered plugin reached over a method channel.

The full reasoning - why min/max, why a pyramid, why two rings in capture, what the six-target determinism check actually asserts - is in doc/architecture.md.

Contributing #

bun install && bun run hooks:install
flutter pub get
dart test

Tests are package:test, not flutter_test - there is no widget tree to bind, and the engine suite runs in seconds as a result.

The example gallery is the reference renderer. Monowave ships no widget, so every painter and gesture a host would write lives there:

cd example && flutter run

Rebuilding the WASM core after changing src/ (needs emscripten 6.0.4, pinned to match the committed artifact byte-for-byte):

./tool/build_wasm.sh

License #

MIT. See LICENSE.

1
likes
0
points
203
downloads

Publisher

verified publishermonorithm.dev

Weekly Downloads

Headless audio for Flutter: microphone capture, waveform peaks, and non-destructive editing, from one C core across all six Flutter targets.

Repository (GitHub)
View/report issues

Topics

#audio #waveform #ffi #media

License

unknown (license)

Dependencies

code_assets, ffi, flutter, hooks, native_toolchain_c

More

Packages that depend on monowave