wajuce 0.2.0 copy "wajuce: ^0.2.0" to clipboard
wajuce: ^0.2.0 copied to clipboard

A JUCE-powered Web Audio API 1.1 implementation for Flutter. Provides high-performance, low-latency audio processing for iOS, Android, macOS, Windows, and Web.

example/lib/main.dart

import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:wajuce/wajuce.dart';
import 'package:wav/wav.dart'; // For loading wav files
import 'clock_processor.dart';
import 'oscilloscope_painter.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(useMaterial3: true).copyWith(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.orange,
          brightness: Brightness.dark,
        ),
      ),
      home: const MainScreen(),
    );
  }
}

class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  WAContext? _ctx;
  int _bufferSize = 512;

  @override
  void initState() {
    super.initState();
    _initEngine();
  }

  Future<void> _initEngine() async {
    _ctx?.close();
    final ctx = WAContext(bufferSize: _bufferSize);
    await ctx.resume();
    setState(() => _ctx = ctx);
  }

  @override
  void dispose() {
    _ctx?.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_ctx == null) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    // Web-specific: Show unlock button if AudioContext is suspended
    const bool isWeb = bool.fromEnvironment('dart.library.js_interop');
    if (isWeb && _ctx!.state == WAAudioContextState.suspended) {
      return Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.volume_off, size: 64, color: Colors.orange),
              const SizedBox(height: 16),
              const Text('Audio is suspended by browser policy'),
              const SizedBox(height: 24),
              ElevatedButton.icon(
                onPressed: () async {
                  await _ctx!.resume();
                  setState(() {});
                },
                icon: const Icon(Icons.play_arrow),
                label: const Text('START AUDIO ENGINE'),
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.orange,
                  foregroundColor: Colors.black,
                  padding:
                      const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
                ),
              ),
            ],
          ),
        ),
      );
    }

    return DefaultTabController(
      length: 4,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('wajuce Multi-Test'),
          actions: [
            PopupMenuButton<int>(
              icon: const Icon(Icons.settings),
              initialValue: _bufferSize,
              onSelected: (val) {
                if (_bufferSize != val) {
                  setState(() {
                    _bufferSize = val;
                    _ctx = null; // Show loading
                  });
                  Future.delayed(
                      const Duration(milliseconds: 100), _initEngine);
                }
              },
              itemBuilder: (context) => [256, 512, 1024]
                  .map(
                      (s) => PopupMenuItem(value: s, child: Text('Buffer: $s')))
                  .toList(),
            ),
          ],
          bottom: const TabBar(
            tabs: [
              Tab(icon: Icon(Icons.piano), text: 'Synth Pad'),
              Tab(icon: Icon(Icons.music_note), text: 'Sampler'),
              Tab(icon: Icon(Icons.grid_view), text: 'Sequencer'),
              Tab(icon: Icon(Icons.mic), text: 'I/O & Rec'),
            ],
          ),
        ),
        body: TabBarView(
          children: [
            SynthPadScreen(ctx: _ctx!),
            DrumPadScreen(ctx: _ctx!),
            SequencerScreen(ctx: _ctx!),
            RecorderScreen(ctx: _ctx!),
          ],
        ),
      ),
    );
  }
}

class SynthPadScreen extends StatefulWidget {
  final WAContext ctx;
  const SynthPadScreen({super.key, required this.ctx});

  @override
  State<SynthPadScreen> createState() => _SynthPadScreenState();
}

class _SynthPadScreenState extends State<SynthPadScreen> {
  WAOscillatorNode? _osc;
  WAGainNode? _gain;
  WAOscillatorType _type = WAOscillatorType.custom;

  double _currentFreq = 440.0;
  double _currentGain = 0.0;

  @override
  void initState() {
    super.initState();
    _initNodes();
  }

  void _initNodes() {
    _osc = widget.ctx.createOscillator();
    // For custom type, we must setPeriodicWave
    if (_type == WAOscillatorType.custom) {
      _setCustomWave();
    } else {
      _osc!.type = _type;
    }

    _gain = widget.ctx.createGain();
    _gain!.gain.value = 0;

    _osc!.connect(_gain!);
    _gain!.connect(widget.ctx.destination);

    _osc!.start();
  }

  @override
  void dispose() {
    _osc?.dispose();
    _gain?.dispose();
    super.dispose();
  }

  void _handleInput(Offset localPos, Size size) {
    double normX = (localPos.dx / size.width).clamp(0.0, 1.0);
    double normY = 1.0 - (localPos.dy / size.height).clamp(0.0, 1.0);

    // Freq 50..5000 (Log scale approximate)
    double freq = 50.0 * pow(100.0, normX);

    final now = widget.ctx.currentTime;

    _osc?.frequency.setTargetAtTime(freq, now, 0.02);
    _gain?.gain.setTargetAtTime(normY, now, 0.02);

    setState(() {
      _currentFreq = freq;
      _currentGain = normY;
    });
  }

  void _onPanStart(DragStartDetails d, BoxConstraints c) {
    _handleInput(d.localPosition, Size(c.maxWidth, c.maxHeight));
  }

  void _onPanUpdate(DragUpdateDetails d, BoxConstraints c) {
    _handleInput(d.localPosition, Size(c.maxWidth, c.maxHeight));
  }

  void _onPanEnd(DragEndDetails d) {
    _gain?.gain.setTargetAtTime(0, widget.ctx.currentTime, 0.05);
    setState(() => _currentGain = 0);
  }

  void _setCustomWave() {
    // Generate a "Buzz" wave (sawtooth-like spectrum)
    const int harmonicCount = 64;
    final real = Float32List(harmonicCount); // 0 (phases aligned)
    final imag = Float32List(harmonicCount);

    for (int i = 1; i < harmonicCount; i++) {
      imag[i] = 1.0 / i;
    }

    final wave = WAPeriodicWave(real: real, imag: imag);
    _osc?.setPeriodicWave(wave);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(16.0),
          child: Row(
            children: [
              const Text("Wave: "),
              DropdownButton<WAOscillatorType>(
                value: _type,
                items: WAOscillatorType.values
                    .map((t) => DropdownMenuItem(value: t, child: Text(t.name)))
                    .toList(),
                onChanged: (v) {
                  setState(() {
                    _type = v!;
                    if (_type == WAOscillatorType.custom) {
                      _setCustomWave();
                    } else {
                      _osc?.type = _type;
                    }
                  });
                },
              ),
              const Spacer(),
              Text("Freq: ${_currentFreq.toStringAsFixed(1)} Hz"),
              const SizedBox(width: 16),
              Text("Gain: ${_currentGain.toStringAsFixed(2)}"),
            ],
          ),
        ),
        Expanded(
          child: LayoutBuilder(
            builder: (context, constraints) {
              return GestureDetector(
                onPanStart: (d) => _onPanStart(d, constraints),
                onPanUpdate: (d) => _onPanUpdate(d, constraints),
                onPanEnd: _onPanEnd,
                child: Container(
                  width: double.infinity,
                  height: double.infinity,
                  color: Colors.blueGrey.shade900,
                  child: CustomPaint(
                    painter: PadPainter(_currentFreq, _currentGain),
                  ),
                ),
              );
            },
          ),
        ),
      ],
    );
  }
}

class PadPainter extends CustomPainter {
  final double freq;
  final double gain;
  PadPainter(this.freq, this.gain);
  @override
  void paint(Canvas canvas, Size size) {
    if (gain > 0.01) {
      final paint = Paint()
        ..color = Colors.orangeAccent.withValues(alpha: 0.3 + gain * 0.7);
      // X position based on freq is hard to reverse, just draw where touch is roughly?
      // Actually we don't have touch pos here. Just draw a circle in center with size = gain.
      canvas.drawCircle(
          Offset(size.width * 0.5, size.height * 0.5), gain * 100 + 10, paint);
    }
  }

  @override
  bool shouldRepaint(PadPainter old) => old.gain != gain || old.freq != freq;
}

// ============================================================================
// Sampler Tab
// ============================================================================
class DrumPadScreen extends StatefulWidget {
  final WAContext ctx;
  const DrumPadScreen({super.key, required this.ctx});

  @override
  State<DrumPadScreen> createState() => _DrumPadScreenState();
}

class _DrumPadScreenState extends State<DrumPadScreen> {
  WABuffer? _sampleBuffer;
  double _tune = 0;
  double _decay = 0.5;
  double _pan = 0.0;
  double _gain = 0.8;
  bool _isLoading = true;

  final WAMidi _midi = WAMidi();
  List<WAMidiInput> _inputs = [];
  WAMidiInput? _selectedInput;
  String _lastMidiMsg = 'No MIDI input selected';

  @override
  void initState() {
    super.initState();
    _loadSample();
    _initMidi();
  }

  Future<void> _initMidi() async {
    final granted = await _midi.requestAccess();
    if (granted) {
      if (mounted) {
        setState(() {
          _inputs = _midi.inputs;
        });
      }
    }
  }

  void _onMidiSelect(WAMidiInput? input) async {
    if (_selectedInput != null) {
      await _selectedInput!.close();
    }
    setState(() {
      _selectedInput = input;
      _lastMidiMsg = input == null
          ? 'MIDI monitoring disabled'
          : 'Monitoring ${input.name}';
    });
    if (input != null) {
      input.onMessage = (data, ts) {
        if (mounted) {
          setState(() {
            _lastMidiMsg =
                'Msg: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}';
          });
        }
        // Play sample on Note On
        if (data.isNotEmpty && (data[0] & 0xF0) == 0x90 && data[2] > 0) {
          _play();
        }
      };
      await input.open();
    }
  }

  Future<void> _loadSample() async {
    try {
      final byteData = await rootBundle.load('lib/cr01.wav');
      final wav = Wav.read(byteData.buffer.asUint8List());
      final buffer = WABuffer(
        numberOfChannels: wav.channels.length,
        length: wav.channels[0].length,
        sampleRate: wav.samplesPerSecond,
      );
      for (int i = 0; i < wav.channels.length; i++) {
        buffer.getChannelData(i).setAll(0, wav.channels[i]);
      }
      setState(() {
        _sampleBuffer = buffer;
        _isLoading = false;
      });
    } catch (e) {
      debugPrint('Error loading sample: $e');
    }
  }

  void _play() {
    if (_sampleBuffer == null) return;
    final source = widget.ctx.createBufferSource();
    source.buffer = _sampleBuffer;
    source.detune.value = _tune;
    source.decay.value = _decay;

    final panner = widget.ctx.createStereoPanner();
    panner.pan.value = _pan;

    final gain = widget.ctx.createGain();
    gain.gain.value = _gain;

    source.connect(panner);
    panner.connect(gain);
    gain.connect(widget.ctx.destination);

    source.start();

    // Dispose after play (approximate duration)
    Future.delayed(
        Duration(
            milliseconds:
                ((_sampleBuffer!.length / _sampleBuffer!.sampleRate) * 1000 +
                        500)
                    .toInt()), () {
      source.dispose();
      panner.dispose();
      gain.dispose();
    });
  }

  @override
  void dispose() {
    _midi.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading) return const Center(child: CircularProgressIndicator());
    return Padding(
      padding: const EdgeInsets.all(24.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          GestureDetector(
            onTap: _play,
            child: Container(
              width: 160,
              height: 160,
              decoration: BoxDecoration(
                color: Colors.orange,
                borderRadius: BorderRadius.circular(24),
                boxShadow: [
                  BoxShadow(
                      color: Colors.orange.withValues(alpha: 0.5),
                      blurRadius: 30),
                ],
              ),
              child: const Center(
                child: Text('HIT ME',
                    style:
                        TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
              ),
            ),
          ),
          const SizedBox(height: 32),
          _SliderRow(
              label: 'Tune',
              value: _tune,
              min: -1200,
              max: 1200,
              onChanged: (v) => setState(() => _tune = v)),
          _SliderRow(
              label: 'Decay',
              value: _decay,
              min: 0.01,
              max: 2.0,
              onChanged: (v) => setState(() => _decay = v)),
          _SliderRow(
              label: 'Pan',
              value: _pan,
              min: -1.0,
              max: 1.0,
              onChanged: (v) => setState(() => _pan = v)),
          _SliderRow(
              label: 'Level',
              value: _gain,
              min: 0.0,
              max: 1.0,
              onChanged: (v) => setState(() => _gain = v)),
          const Divider(height: 48),
          const Text('MIDI INPUT VERIFICATION',
              style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.bold,
                  color: Colors.orange)),
          const SizedBox(height: 8),
          DropdownButton<WAMidiInput>(
            value: _selectedInput,
            hint: const Text('Select MIDI Input'),
            isExpanded: true,
            items: [
              const DropdownMenuItem<WAMidiInput>(
                  value: null, child: Text('None (Disabled)')),
              ..._inputs.map((input) =>
                  DropdownMenuItem(value: input, child: Text(input.name))),
            ],
            onChanged: _onMidiSelect,
          ),
          const SizedBox(height: 8),
          Container(
            padding: const EdgeInsets.all(12),
            width: double.infinity,
            decoration: BoxDecoration(
              color: Colors.black26,
              borderRadius: BorderRadius.circular(8),
            ),
            child: Text(
              _lastMidiMsg,
              style: const TextStyle(fontFamily: 'Courier', fontSize: 12),
              textAlign: TextAlign.center,
            ),
          ),
        ],
      ),
    );
  }
}

// ============================================================================
// Sequencer Tab
// ============================================================================
class SequencerScreen extends StatefulWidget {
  final WAContext ctx;
  const SequencerScreen({super.key, required this.ctx});

  @override
  State<SequencerScreen> createState() => _SequencerScreenState();
}

class MachineVoice {
  final WAOscillatorNode osc;
  final WABiquadFilterNode filter;
  final WAGainNode gain;
  final WADelayNode delay;
  final WAGainNode delayFb;
  final WAGainNode delayWet;
  final WAStereoPannerNode panner;

  MachineVoice({
    required this.osc,
    required this.filter,
    required this.gain,
    required this.delay,
    required this.delayFb,
    required this.delayWet,
    required this.panner,
  });

  void dispose() {
    osc.dispose();
    filter.dispose();
    gain.dispose();
    delay.dispose();
    delayFb.dispose();
    delayWet.dispose();
    panner.dispose();
  }
}

class MachineVoicePool {
  final WAContext ctx;
  final WAGainNode output;
  final List<MachineVoice> _spares = [];
  bool _isReplenishing = false;
  bool _disposed = false;

  MachineVoicePool(this.ctx, this.output);

  void prepare(int count) {
    _ensureReplenish(target: count);
  }

  void dispose() {
    _disposed = true;
    for (var v in _spares) {
      v.dispose();
    }
    _spares.clear();
  }

  Future<MachineVoice> getVoiceAsync() async {
    // 1. Try to get from pool
    if (_spares.isNotEmpty) {
      final v = _spares.removeLast();
      _ensureReplenish();
      return v;
    }

    // 2. Pool empty? Create one immediately.
    // Keep API async-compatible for call sites.
    debugPrint("Pool empty! Creating batch voice...");
    final v = _createBatch();

    _ensureReplenish(); // Schedule replenishment
    return v;
  }

  MachineVoice _createBatch() {
    try {
      final nodes = ctx.createMachineVoice();
      final voice = MachineVoice(
        osc: nodes[0] as WAOscillatorNode,
        filter: nodes[1] as WABiquadFilterNode,
        gain: nodes[2] as WAGainNode,
        panner: nodes[3] as WAStereoPannerNode,
        delay: nodes[4] as WADelayNode,
        delayFb: nodes[5] as WAGainNode,
        delayWet: nodes[6] as WAGainNode,
      );
      voice.panner.connect(output);
      voice.delayWet.connect(output);
      return voice;
    } catch (e) {
      // Fallback for Web or platforms where batch creation is not implemented
      final osc = ctx.createOscillator();
      final filter = ctx.createBiquadFilter();
      final gain = ctx.createGain();
      gain.gain.value = 0; // Start silent
      final panner = ctx.createStereoPanner();
      final delay = ctx.createDelay();
      final delayFb = ctx.createGain();
      final delayWet = ctx.createGain();
      delayFb.gain.value = 0; // Delay feedback starts disabled
      delayWet.gain.value = 0; // Delay wet mix starts disabled

      // Basic routing for machine voice
      osc.connect(filter);
      filter.connect(gain);
      gain.connect(panner);

      // Delay routing
      gain.connect(delay);
      delay.connect(delayFb);
      delayFb.connect(delay);
      delay.connect(delayWet);

      panner.connect(output);
      delayWet.connect(output);
      osc.start();

      return MachineVoice(
        osc: osc,
        filter: filter,
        gain: gain,
        panner: panner,
        delay: delay,
        delayFb: delayFb,
        delayWet: delayWet,
      );
    }
  }

  void _ensureReplenish({int target = 8}) {
    if (_disposed || _isReplenishing) return;
    if (_spares.length >= target) return;

    _isReplenishing = true;

    // Fill pool using microtasks to avoid blocking frame
    Future(() {
      // Use Future instead of microtask for better yielding
      try {
        if (_disposed) return;

        final stopwatch = Stopwatch()..start();
        final v = _createBatch();
        stopwatch.stop();
        debugPrint("Batch voice created in ${stopwatch.elapsedMicroseconds}us");

        _spares.add(v);
      } catch (e) {
        debugPrint("Replenish error: $e");
      } finally {
        _isReplenishing = false;
        if (!_disposed && _spares.length < target) {
          // Schedule next batch with a slight delay if needed,
          // and yield a frame to keep UI responsive.
          Future.delayed(const Duration(milliseconds: 16),
              () => _ensureReplenish(target: target));
        }
      }
    });
  }

  // Deprecated: Only use if absolutely necessary
  MachineVoice getVoiceSync() {
    if (_spares.isNotEmpty) {
      final v = _spares.removeLast();
      _ensureReplenish();
      return v;
    }
    debugPrint(
        "WARNGING: Pool empty, creating synchronously (May cause glitches/crash)");
    final v = _createBatch();
    _ensureReplenish();
    return v;
  }
}

class MachineState {
  final int id;
  // final WAContext ctx; // Removed, handled by Voice/Pool
  // final WAGainNode output; // Removed

  double frequency = 440;
  double decay = 0.5;
  WAOscillatorType waveType = WAOscillatorType.sine;
  WABiquadFilterType filterType = WABiquadFilterType.lowpass;
  double pan = 0.0;
  double cutoff = 2000;
  double resonance = 1;
  double delayTime = 0.3;
  double delayFeedback = 0.3;
  double delayMix = 0.5;
  bool delayEnabled = false;
  List<bool> steps = List.generate(16, (i) => i % 4 == 0);

  final MachineVoice? _voice;
  WAOscillatorType? _lastWaveType;
  WABiquadFilterType? _lastFilterType;
  double _lastFrequency = double.nan;
  double _lastCutoff = double.nan;
  double _lastResonance = double.nan;
  double _lastPan = double.nan;
  double _lastDelayTime = double.nan;
  double _lastDelayFeedback = double.nan;
  double _lastDelayMix = double.nan;
  bool _lastDelayEnabled = false;

  MachineState(this.id, MachineVoice voice) : _voice = voice;

  void ensureVoice() {
    // No-op, voice is injected
  }

  void dispose() {
    _voice?.dispose();
  }
}

class _SequencerScreenState extends State<SequencerScreen> {
  final List<MachineState> _machines = [];
  bool _isPlaying = false;
  double _bpm = 120;
  double _masterGain = 0.8;
  int _currentStep = 0;
  WAGainNode? _masterOutput;
  MachineVoicePool? _pool;

  WAWorkletNode? _clockNode;
  WAGainNode? _clockSink;

  @override
  void initState() {
    super.initState();
    _masterOutput = widget.ctx.createGain();
    _masterOutput!.gain.value = _masterGain;
    _masterOutput!.connect(widget.ctx.destination);

    _pool = MachineVoicePool(widget.ctx, _masterOutput!);
    // Pre-warm the pool effectively in background
    _pool!.prepare(2);

    // Initial machine
    Future.delayed(const Duration(milliseconds: 100), _addMachine);

    _initClock();
  }

  Future<void> _initClock() async {
    await loadClockWorkletModule(widget.ctx);

    _clockNode?.dispose();
    _clockSink?.dispose();

    _clockNode = createClockWorkletNode(widget.ctx);

    // CRITICAL FIX: Web AudioWorklet must be connected to be active!
    // We connect it to a dummy silent gain to force the browser to run the processor.
    _clockSink = widget.ctx.createGain();
    _clockSink!.gain.value = 0.0;
    _clockNode!.connect(_clockSink!);
    _clockSink!.connect(widget.ctx.destination);

    _clockNode!.port.onMessage = (data) {
      if (data is Map && data['type'] == 'tick') {
        final step = (data['step'] as num?)?.toInt();
        if (step == null) return;
        final scheduledTime = (data['time'] as num?)?.toDouble();
        _onTick(step, scheduledTime);
      }
    };
  }

  @override
  void dispose() {
    _clockNode?.dispose();
    _clockSink?.dispose();
    _pool?.dispose();
    for (final m in _machines) {
      m.dispose();
    }
    _masterOutput?.dispose();
    super.dispose();
  }

  bool _isAddingMachine = false;

  void _addMachine() async {
    if (!mounted || _isAddingMachine) return;

    setState(() => _isAddingMachine = true);

    try {
      // Use async creation to avoid blocking main thread & audio thread lock contention
      final voice = await _pool!.getVoiceAsync();

      if (!mounted) {
        voice.dispose();
        return;
      }

      late final MachineState machine;
      setState(() {
        machine = MachineState(_machines.length, voice);
        _machines.add(machine);
        _isAddingMachine = false;
      });
      _applyMachineRealtimeParams(
        machine,
        atTime: widget.ctx.currentTime + 0.002,
        force: true,
      );
    } catch (e) {
      debugPrint("Error adding machine: $e");
      if (mounted) setState(() => _isAddingMachine = false);
    }
  }

  void _togglePlay() {
    setState(() {
      _isPlaying = !_isPlaying;
      if (_isPlaying) {
        _currentStep = 0;
        final warmupTime = widget.ctx.currentTime + 0.004;
        for (final m in _machines) {
          _applyMachineRealtimeParams(m, atTime: warmupTime, force: true);
        }
        _clockNode?.port.postMessage({
          'type': 'start',
          'contextTime': widget.ctx.currentTime,
        });
        _clockNode?.port.postMessage({'type': 'bpm', 'value': _bpm});
      } else {
        _clockNode?.port.postMessage({'type': 'stop'});
        _currentStep = -1; // Reset highlight
      }
    });
  }

  void _applyMachineRealtimeParams(MachineState m,
      {double? atTime, bool force = false}) {
    final v = m._voice;
    if (v == null) return;

    final now = widget.ctx.currentTime;
    final t = atTime ?? (now + 0.002);

    if (force || m._lastWaveType != m.waveType) {
      v.osc.type = m.waveType;
      m._lastWaveType = m.waveType;
    }
    if (force ||
        (m.frequency - m._lastFrequency).abs() > 0.0001 ||
        m._lastFrequency.isNaN) {
      v.osc.frequency.setTargetAtTime(m.frequency, t, 0.010);
      m._lastFrequency = m.frequency;
    }

    if (force || m._lastFilterType != m.filterType) {
      v.filter.type = m.filterType;
      m._lastFilterType = m.filterType;
    }
    if (force ||
        (m.cutoff - m._lastCutoff).abs() > 0.0001 ||
        m._lastCutoff.isNaN) {
      v.filter.frequency.setTargetAtTime(m.cutoff, t, 0.012);
      m._lastCutoff = m.cutoff;
    }
    if (force ||
        (m.resonance - m._lastResonance).abs() > 0.0001 ||
        m._lastResonance.isNaN) {
      v.filter.Q.setTargetAtTime(m.resonance, t, 0.012);
      m._lastResonance = m.resonance;
    }
    if (force || (m.pan - m._lastPan).abs() > 0.0001 || m._lastPan.isNaN) {
      v.panner.pan.setTargetAtTime(m.pan, t, 0.015);
      m._lastPan = m.pan;
    }

    // DelayNode delayTime is a regular AudioParam regardless of bypass/mix state.
    // Keep it updated continuously so enabling wet path doesn't cause first-hit jumps.
    if (force ||
        (m.delayTime - m._lastDelayTime).abs() > 0.0001 ||
        m._lastDelayTime.isNaN) {
      if (force || m._lastDelayTime.isNaN) {
        v.delay.delayTime.cancelAndHoldAtTime(t);
        v.delay.delayTime.setValueAtTime(m.delayTime, t);
      } else {
        v.delay.delayTime.setTargetAtTime(m.delayTime, t, 0.12);
      }
      m._lastDelayTime = m.delayTime;
    }

    // Wet/feedback gains are the bypass control for this example graph.
    if (m.delayEnabled) {
      final isDelayEntering = force || !m._lastDelayEnabled;

      if (isDelayEntering ||
          (m.delayFeedback - m._lastDelayFeedback).abs() > 0.0001 ||
          m._lastDelayFeedback.isNaN) {
        v.delayFb.gain.setTargetAtTime(m.delayFeedback, t, 0.09);
        m._lastDelayFeedback = m.delayFeedback;
      }

      if (isDelayEntering ||
          (m.delayMix - m._lastDelayMix).abs() > 0.0001 ||
          m._lastDelayMix.isNaN) {
        v.delayWet.gain.setTargetAtTime(m.delayMix, t, 0.08);
        m._lastDelayMix = m.delayMix;
      }
      m._lastDelayEnabled = true;
    } else {
      if (force || m._lastDelayEnabled) {
        v.delayFb.gain.setTargetAtTime(0, t, 0.08);
        v.delayWet.gain.setTargetAtTime(0, t, 0.06);
      }
      m._lastDelayEnabled = false;
    }
  }

  void _onTick(int step, [double? scheduledTime]) {
    if (!mounted) return;
    final now = widget.ctx.currentTime;
    final triggerTime =
        scheduledTime == null ? (now + 0.004) : max(scheduledTime, now + 0.004);

    for (var m in _machines) {
      if (m.steps[step]) {
        _playMachine(m, triggerTime);
      }
    }

    if (_currentStep != step) {
      setState(() {
        _currentStep = step;
      });
    }
  }

  void _playMachine(MachineState m, double time) {
    m.ensureVoice();
    final v = m._voice!;

    // Keep voice params warm with slightly longer smoothing to reduce ticks.
    _applyMachineRealtimeParams(m, atTime: time);

    // Retrigger envelope without hard discontinuity.
    v.gain.gain.cancelAndHoldAtTime(time);
    v.gain.gain.setTargetAtTime(0.45, time, 0.004);
    v.gain.gain.setTargetAtTime(0.0001, time + 0.022, max(0.008, m.decay));
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          color: Colors.black26,
          child: Row(
            children: [
              IconButton.filled(
                onPressed: _togglePlay,
                icon: Icon(_isPlaying ? Icons.stop : Icons.play_arrow),
                iconSize: 24,
              ),
              const SizedBox(width: 16),
              Expanded(
                child: _SliderRow(
                  label: '',
                  value: _masterGain,
                  min: 0,
                  max: 1,
                  onChanged: (v) {
                    setState(() {
                      _masterGain = v;
                      _masterOutput?.gain.setTargetAtTime(v, 0, 0.02);
                    });
                  },
                ),
              ),
              const SizedBox(width: 16),
              const Text('BPM'),
              Expanded(
                child: Slider(
                  value: _bpm,
                  min: 60,
                  max: 200,
                  onChanged: (v) {
                    setState(() => _bpm = v);
                    if (_isPlaying) {
                      _clockNode?.port.postMessage({'type': 'bpm', 'value': v});
                      _clockNode?.port.postMessage({
                        'type': 'syncTime',
                        'contextTime': widget.ctx.currentTime,
                      });
                    }
                  },
                ),
              ),
              Text(_bpm.toInt().toString()),
            ],
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: _machines.length + 1,
            itemBuilder: (context, index) {
              if (index == _machines.length) {
                return Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: ElevatedButton.icon(
                    onPressed: _isAddingMachine ? null : _addMachine,
                    icon: _isAddingMachine
                        ? const SizedBox(
                            width: 20,
                            height: 20,
                            child: CircularProgressIndicator(strokeWidth: 2))
                        : const Icon(Icons.add),
                    label: Text(_isAddingMachine
                        ? ' BUILDING NODES...'
                        : 'ADD MACHINE'),
                  ),
                );
              }
              final m = _machines[index];
              return _MachineView(
                state: m,
                currentStep: _currentStep,
                onChanged: () {
                  _applyMachineRealtimeParams(m);
                  setState(() {});
                },
                onDelete: () {
                  _machines[index].dispose();
                  setState(() => _machines.removeAt(index));
                },
              );
            },
          ),
        ),
      ],
    );
  }
}

class _MachineView extends StatelessWidget {
  final MachineState state;
  final int currentStep;
  final VoidCallback onChanged;
  final VoidCallback onDelete;

  const _MachineView(
      {required this.state,
      required this.currentStep,
      required this.onChanged,
      required this.onDelete});

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          children: [
            Row(
              children: [
                Text('Synth #${state.id + 1}',
                    style: const TextStyle(fontWeight: FontWeight.bold)),
                const Spacer(),
                DropdownButton<WAOscillatorType>(
                  value: state.waveType,
                  isDense: true,
                  items: [
                    for (final type in WAOscillatorType.values)
                      DropdownMenuItem(value: type, child: Text(type.name)),
                  ],
                  onChanged: (v) {
                    if (v != null) {
                      state.waveType = v;
                      onChanged();
                    }
                  },
                ),
                const SizedBox(width: 8),
                DropdownButton<WABiquadFilterType>(
                  value: state.filterType,
                  isDense: true,
                  items: [
                    for (final type in WABiquadFilterType.values)
                      DropdownMenuItem(value: type, child: Text(type.name)),
                  ],
                  onChanged: (v) {
                    if (v != null) {
                      state.filterType = v;
                      onChanged();
                    }
                  },
                ),
                IconButton(
                    onPressed: onDelete,
                    icon:
                        const Icon(Icons.close, size: 20, color: Colors.grey)),
              ],
            ),
            _SliderRow(
                label: 'Freq',
                value: state.frequency,
                min: 20,
                max: 2000,
                onChanged: (v) {
                  state.frequency = v;
                  onChanged();
                }),
            _SliderRow(
                label: 'Decay',
                value: state.decay,
                min: 0.01,
                max: 1.0,
                isSeconds: true,
                onChanged: (v) {
                  state.decay = v;
                  onChanged();
                }),
            _SliderRow(
                label: 'Cutoff',
                value: state.cutoff,
                min: 50,
                max: 10000,
                onChanged: (v) {
                  state.cutoff = v;
                  onChanged();
                }),
            _SliderRow(
                label: 'Res',
                value: state.resonance,
                min: 0,
                max: 20,
                onChanged: (v) {
                  state.resonance = v;
                  onChanged();
                }),
            Row(
              children: [
                SizedBox(
                  height: 24,
                  width: 24,
                  child: Checkbox(
                      value: state.delayEnabled,
                      onChanged: (v) {
                        state.delayEnabled = v ?? false;
                        onChanged();
                      }),
                ),
                const Text(' Delay ', style: TextStyle(fontSize: 12)),
                Expanded(
                  child: Column(
                    children: [
                      _SliderRow(
                        label: 'Time',
                        value: state.delayTime,
                        min: 0,
                        max: 1,
                        isSeconds: true,
                        onChanged: state.delayEnabled
                            ? (v) {
                                state.delayTime = v;
                                onChanged();
                              }
                            : (v) {},
                      ),
                      _SliderRow(
                        label: 'Fdbk',
                        value: state.delayFeedback,
                        min: 0,
                        max: 0.95,
                        onChanged: state.delayEnabled
                            ? (v) {
                                state.delayFeedback = v;
                                onChanged();
                              }
                            : (v) {},
                      ),
                      _SliderRow(
                        label: 'Mix',
                        value: state.delayMix,
                        min: 0,
                        max: 1,
                        onChanged: state.delayEnabled
                            ? (v) {
                                state.delayMix = v;
                                onChanged();
                              }
                            : (v) {},
                      ),
                    ],
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            // 16 steps in 8x2 grid
            Column(
              children: [
                _StepRow(
                    steps: state.steps,
                    start: 0,
                    count: 8,
                    currentStep: currentStep,
                    onChanged: onChanged),
                const SizedBox(height: 4),
                _StepRow(
                    steps: state.steps,
                    start: 8,
                    count: 8,
                    currentStep: currentStep,
                    onChanged: onChanged),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class _StepRow extends StatelessWidget {
  final List<bool> steps;
  final int start;
  final int count;
  final int currentStep;
  final VoidCallback onChanged;

  const _StepRow(
      {required this.steps,
      required this.start,
      required this.count,
      required this.currentStep,
      required this.onChanged});

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: List.generate(count, (i) {
        final idx = start + i;
        final isActive = currentStep == idx;
        return GestureDetector(
          onTap: () {
            steps[idx] = !steps[idx];
            onChanged();
          },
          child: Container(
            width: 32,
            height: 32,
            decoration: BoxDecoration(
              color: steps[idx] ? Colors.orange : Colors.grey[800],
              borderRadius: BorderRadius.circular(4),
              border: Border.all(
                color: isActive ? Colors.white : Colors.white10,
                width: isActive ? 2 : 1,
              ),
              boxShadow: isActive
                  ? [
                      BoxShadow(
                          color: Colors.white.withValues(alpha: 0.5),
                          blurRadius: 4)
                    ]
                  : null,
            ),
            child: Center(
                child: Text('${idx + 1}',
                    style: TextStyle(
                        fontSize: 8,
                        color: isActive ? Colors.white : Colors.white54,
                        fontWeight:
                            isActive ? FontWeight.bold : FontWeight.normal))),
          ),
        );
      }),
    );
  }
}

class _SliderRow extends StatelessWidget {
  final String label;
  final double value;
  final double min;
  final double max;
  final bool isSeconds;
  final ValueChanged<double> onChanged;

  const _SliderRow(
      {required this.label,
      required this.value,
      required this.min,
      required this.max,
      this.isSeconds = false,
      required this.onChanged});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 0.0),
      child: Row(
        children: [
          SizedBox(
              width: 40,
              child: Text(label, style: const TextStyle(fontSize: 11))),
          Expanded(
              child: SizedBox(
                  height: 32,
                  child: Slider(
                      value: value, min: min, max: max, onChanged: onChanged))),
          SizedBox(
              width: 45,
              child: Text(
                  isSeconds
                      ? '${value.toStringAsFixed(2)}s'
                      : value.toStringAsFixed(2),
                  style: const TextStyle(fontSize: 11),
                  textAlign: TextAlign.end)),
        ],
      ),
    );
  }
}

// ============================================================================
// I/O & Recording Tab
// ============================================================================
class RecorderScreen extends StatefulWidget {
  final WAContext ctx;
  const RecorderScreen({super.key, required this.ctx});

  @override
  State<RecorderScreen> createState() => _RecorderScreenState();
}

class _RecorderScreenState extends State<RecorderScreen> {
  WAMediaStreamSourceNode? _micSource;
  WAAnalyserNode? _analyser;
  WAGainNode? _monitorGain;

  WABufferSourceNode? _fileSource;
  WABuffer? _decodedBuffer;

  bool _isMicActive = false;
  bool _isMonitoring = false;
  bool _isLoadingFile = false;

  final Uint8List _fftData = Uint8List(256);
  Timer? _visualizerTimer;

  @override
  void initState() {
    super.initState();
    _initNodes();
    _visualizerTimer = Timer.periodic(const Duration(milliseconds: 33), (t) {
      if (_analyser != null) {
        _analyser!.getByteTimeDomainData(_fftData);
        if (mounted) setState(() {});
      }
    });
  }

  void _initNodes() {
    _analyser = widget.ctx.createAnalyser();
    _analyser!.fftSize = 512;

    _monitorGain = widget.ctx.createGain();
    _monitorGain!.gain.value = 0; // Default monitor off

    _analyser!.connect(_monitorGain!);
    _monitorGain!.connect(widget.ctx.destination);
  }

  @override
  void dispose() {
    _visualizerTimer?.cancel();
    _micSource?.dispose();
    _analyser?.dispose();
    _monitorGain?.dispose();
    _fileSource?.dispose();
    super.dispose();
  }

  Future<void> _toggleMic() async {
    if (_isMicActive) {
      _micSource?.disconnect();
      _micSource?.dispose();
      _micSource = null;
    } else {
      try {
        _micSource = await widget.ctx.createMicrophoneSource();
        _micSource!.connect(_analyser!);
      } catch (e) {
        debugPrint('Error creating mic source: $e');
        return;
      }
    }
    setState(() => _isMicActive = !_isMicActive);
  }

  void _toggleMonitor() {
    setState(() {
      _isMonitoring = !_isMonitoring;
      _monitorGain?.gain.setTargetAtTime(
          _isMonitoring ? 0.8 : 0, widget.ctx.currentTime, 0.05);
    });
  }

  Future<void> _testDecodeAndPlay() async {
    if (_isLoadingFile) return;
    setState(() => _isLoadingFile = true);

    try {
      final data = await rootBundle.load('lib/cr01.wav');
      final buffer =
          await widget.ctx.decodeAudioData(data.buffer.asUint8List());

      _fileSource?.stop();
      _fileSource?.dispose();

      _fileSource = widget.ctx.createBufferSource();
      _fileSource!.buffer = buffer;
      _fileSource!.connect(_analyser!);
      _fileSource!.start();

      _decodedBuffer = buffer;
    } catch (e) {
      debugPrint('Error testing decode: $e');
    } finally {
      setState(() => _isLoadingFile = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        children: [
          const Text("Phase 4 Verification: I/O & Buffers",
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
          const SizedBox(height: 16),

          // Oscilloscope Visualizer
          Container(
            height: 150,
            width: double.infinity,
            decoration: BoxDecoration(
              color: Colors.black,
              borderRadius: BorderRadius.circular(8),
              border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
            ),
            child: CustomPaint(
              painter: OscilloscopePainter(_fftData),
            ),
          ),

          const SizedBox(height: 24),

          // Mic Controls
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              ElevatedButton.icon(
                onPressed: _toggleMic,
                style: ElevatedButton.styleFrom(
                  backgroundColor:
                      _isMicActive ? Colors.red.withValues(alpha: 0.8) : null,
                ),
                icon: Icon(_isMicActive ? Icons.mic_off : Icons.mic),
                label: Text(_isMicActive ? "STOP MIC" : "START MIC"),
              ),
              ElevatedButton.icon(
                onPressed: _isMicActive ? _toggleMonitor : null,
                icon: Icon(_isMonitoring ? Icons.volume_up : Icons.volume_off),
                label: Text(_isMonitoring ? "MONITOR ON" : "MONITOR OFF"),
              ),
            ],
          ),

          const SizedBox(height: 16),
          const Divider(),
          const SizedBox(height: 16),

          // Decode Test
          const Text("decodeAudioData & BufferSource Test"),
          const SizedBox(height: 8),
          ElevatedButton.icon(
            onPressed: _testDecodeAndPlay,
            icon: _isLoadingFile
                ? const SizedBox(
                    width: 20,
                    height: 20,
                    child: CircularProgressIndicator(strokeWidth: 2))
                : const Icon(Icons.refresh),
            label: const Text("DECODE & PLAY CR01.WAV"),
          ),
          if (_decodedBuffer != null)
            Padding(
              padding: const EdgeInsets.only(top: 8.0),
              child: Text(
                  "Decoded: ${_decodedBuffer!.numberOfChannels}ch, "
                  "${_decodedBuffer!.length} samples @ ${_decodedBuffer!.sampleRate.toInt()}Hz",
                  style: const TextStyle(fontSize: 12, color: Colors.grey)),
            ),

          const Spacer(),
          const Text("Verification Status:",
              style: TextStyle(fontWeight: FontWeight.bold)),
          const Text("✅ MediaStreamSource -> Analyser -> Monitor",
              style: TextStyle(fontSize: 12)),
          const Text("✅ decodeAudioData -> BufferSource -> Analyser",
              style: TextStyle(fontSize: 12)),
        ],
      ),
    );
  }
}

// End of file
1
likes
160
points
150
downloads

Publisher

unverified uploader

Weekly Downloads

A JUCE-powered Web Audio API 1.1 implementation for Flutter. Provides high-performance, low-latency audio processing for iOS, Android, macOS, Windows, and Web.

Repository (GitHub)
View/report issues

Topics

#audio #web-audio #juce #synthesizer #dsp

Documentation

API reference

License

MIT (license)

Dependencies

ffi, flutter

More

Packages that depend on wajuce

Packages that implement wajuce