haptic_morse 2.0.0 copy "haptic_morse: ^2.0.0" to clipboard
haptic_morse: ^2.0.0 copied to clipboard

Convert text to Morse code and haptic vibration patterns in Dart.

example/haptic_morse_example.dart

// A self-verifying example. Every claim below is checked at runtime, and the
// program exits non-zero if any check fails. Run it with:
//
//   dart run example/haptic_morse_example.dart
//
// It is pure Dart, so it needs no Flutter and no device. CI runs it on every
// push, and test/readme_test.dart checks that the block quoted in README.md
// still matches what this prints.
//
// An example is a script; print is its output channel.
// ignore_for_file: avoid_print

import 'dart:math';

import 'package:haptic_morse/haptic_morse.dart';

void main() {
  final report = buildReport();
  report.lines.forEach(print);

  final failed = report.checks.entries.where((e) => !e.value).toList();
  if (failed.isNotEmpty) {
    throw StateError('failed: ${failed.map((e) => e.key).join(', ')}');
  }
}

/// Builds the full report without printing it.
///
/// Returned rather than printed so that tests can assert on the same lines the
/// example shows, instead of re-deriving them and drifting apart.
({List<String> lines, Map<String, bool> checks}) buildReport() {
  final report = _Report()
    ..line('haptic_morse - self-verifying example')
    ..line('=' * 60);

  _provePatternCarriesMessage(report);
  _proveLeadingZeroMatters(report);
  _proveTimingIsConformant(report);
  _proveSequenceIsWellFormed(report);
  _proveRoundTrips(report);
  _showFarnsworth(report);

  final passed = report.checks.values.where((v) => v).length;
  report
    ..blank()
    ..line('=' * 60)
    ..line('$passed/${report.checks.length} checks passed');

  return (lines: report.lines, checks: report.checks);
}

/// The strongest proof available without a motor: take the raw list of
/// integers handed to the platform, reinterpret it using only the documented
/// convention, and recover the original text from it.
void _provePatternCarriesMessage(_Report report) {
  const input = 'SOS';
  const morse = HapticMorse();

  final code = morse.convertTextToMorseString(input)!;
  final pattern = input.toVibrationPattern();
  final recoveredCode = readMorseFromPattern(pattern);
  final recoveredText = morse.decodeMorseString(recoveredCode);

  report
    ..section('1. The pattern actually carries the message')
    ..field('text', '"$input"')
    ..field('morse', code)
    ..field('pattern', '$pattern')
    ..blank()
    ..line('   Read back using only the platform rule (even index = motor')
    ..line('   off, odd index = motor on), with no reference to this package:')
    ..blank()
    ..field('timeline', timeline(pattern))
    ..field('recovered morse', recoveredCode)
    ..field('recovered text', '"$recoveredText"')
    ..check(
      'pattern decodes to the original morse',
      passed: recoveredCode == code,
    )
    ..check(
      'pattern decodes to the original text',
      passed: recoveredText == input,
    );
}

/// The bug that made 2.0.0 a breaking release, demonstrated rather than
/// described.
void _proveLeadingZeroMatters(_Report report) {
  const input = 'SOS';
  const morse = HapticMorse();

  final correct = input.toVibrationPattern();
  // Version 1.x emitted exactly this: the same durations with no leading off
  // delay, so the platform played every symbol in a silence slot.
  final legacy = correct.sublist(1);
  final legacyCode = readMorseFromPattern(legacy);
  final legacyText = morse.decodeMorseString(legacyCode) ?? '(undecodable)';

  report
    ..section('2. Why the leading zero matters')
    ..field('1.x pattern', '$legacy')
    ..field('1.x timeline', timeline(legacy))
    ..field('plays as morse', legacyCode)
    ..field('plays as text', '"$legacyText"')
    ..field('expected', '"$input"')
    ..check(
      'the 1.x pattern played as something else',
      passed: legacyText != input,
    )
    ..check(
      'the 2.0.0 pattern starts with an off delay',
      passed: correct.first == 0,
    );
}

/// ITU-R M.1677-1 defines Morse speed by the word PARIS, which measures
/// exactly 50 units. If that holds, every element and gap ratio is correct.
void _proveTimingIsConformant(_Report report) {
  const morse = HapticMorse();
  const unit = 100; // the default dot duration

  final word = morse.convertTextToModel('PARIS').totalDuration;
  final pair = morse.convertTextToModel('PARIS PARIS').totalDuration;
  final wordSpace = pair - 2 * word;
  final units = (word + wordSpace) ~/ unit;
  final wpm = 60000 / (word + wordSpace);

  report
    ..section('3. Timing matches the international standard')
    ..field('dot / dash', '$unit ms / ${3 * unit} ms   (spec ratio 1:3)')
    ..field('PARIS + space', '$units units   (spec 50)')
    ..field('speed', '${wpm.toStringAsFixed(1)} WPM   (spec 1200/dot = 12)')
    ..check('PARIS measures 50 units', passed: units == 50)
    ..check('speed matches 1200/dot', passed: wpm == 1200 / unit);
}

/// The sequence contract: strictly alternating, starting and ending with a
/// vibration, with no two adjacent gaps. Checked against inputs whose edges
/// used to break it.
void _proveSequenceIsWellFormed(_Report report) {
  const morse = HapticMorse();
  const awkward = [
    'SOS',
    '  leading space',
    'trailing space  ',
    'double  space',
    'unmapped tail ¥',
    'a ¥¥ word of unmapped characters',
  ];

  report.section('4. The sequence stays well formed at every edge');

  var all = true;
  for (final input in awkward) {
    final events = morse.convertTextToHapticEvents(input);
    final ok = isWellFormed(events);
    all &= ok;
    report.line('   ${ok ? '[ok]  ' : '[FAIL]'} '
        '${events.length.toString().padLeft(3)} events  "$input"');
  }

  report.check('every edge case is well formed', passed: all);
}

void _proveRoundTrips(_Report report) {
  const morse = HapticMorse();
  const samples = [
    'SOS',
    'HELLO WORLD',
    'THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG',
    'HELLO, WORLD!',
    r'COST: $5.00',
    'USER@EXAMPLE.COM',
    '0123456789',
  ];

  report.section('5. Encoding round-trips, punctuation included');

  var all = true;
  for (final sample in samples) {
    final viaCode =
        morse.decodeMorseString(morse.convertTextToMorseString(sample));
    final viaEvents =
        morse.decodeEvents(morse.convertTextToHapticEvents(sample));
    final ok = viaCode == sample && viaEvents == sample;
    all &= ok;
    report.line('   ${ok ? '[ok]  ' : '[FAIL]'} "$sample"');
  }

  report.check('every sample round-trips', passed: all);
}

/// Farnsworth timing keeps each character crisp while slowing the message,
/// the setting that matters most for reading Morse through skin.
void _showFarnsworth(_Report report) {
  final plain = HapticMorse.atSpeed(wordsPerMinute: 20);
  final learner = HapticMorse.atSpeed(
    wordsPerMinute: 20,
    effectiveWordsPerMinute: 8,
  );

  report
    ..section('6. Farnsworth timing, for readable haptics')
    ..line('                     symbol   letter gap   word gap');

  for (final (label, morse) in [('20 WPM', plain), ('20/8 WPM', learner)]) {
    final dot = morse.convertTextToHapticEvents('E').single.duration;
    final letter = morse.convertTextToHapticEvents('EE')[1].duration;
    final word = morse.convertTextToHapticEvents('E E')[1].duration;
    report.line('   ${label.padRight(14)}${'$dot ms'.padLeft(8)}'
        '${'$letter ms'.padLeft(13)}${'$word ms'.padLeft(11)}');
  }

  report.line('   Characters keep their shape; only the silences stretch.');
}

// ---------------------------------------------------------------------------
// Helpers that deliberately know nothing about HapticEvent. They work from the
// raw list of integers and the platform convention alone, which is what makes
// the checks above independent evidence rather than a restatement.
// ---------------------------------------------------------------------------

/// Splits a vibration pattern into (motor on?, milliseconds) segments.
///
/// Index 0 is an off delay and the list alternates from there, the rule shared
/// by Android's VibrationEffect.createWaveform and iOS.
List<({bool on, int ms})> segmentsOf(List<int> pattern) => [
      for (var i = 0; i < pattern.length; i++) (on: i.isOdd, ms: pattern[i]),
    ];

/// Reconstructs dots and dashes from a raw pattern.
///
/// Classifies by duration relative to the shortest vibration, the way someone
/// reading the waveform on a scope would.
String readMorseFromPattern(List<int> pattern) {
  final segments = segmentsOf(pattern).where((s) => s.ms > 0).toList();
  final unit = segments.where((s) => s.on).map((s) => s.ms).reduce(min);

  final buffer = StringBuffer();
  for (final segment in segments) {
    if (segment.on) {
      buffer.write(segment.ms >= 2 * unit ? '-' : '.');
    } else if (segment.ms >= 5 * unit) {
      buffer.write(' / ');
    } else if (segment.ms >= 2 * unit) {
      buffer.write(' ');
    }
  }
  return buffer.toString();
}

/// Renders a pattern as an on/off strip, one cell per unit.
String timeline(List<int> pattern) {
  final segments = segmentsOf(pattern).where((s) => s.ms > 0).toList();
  final unit = segments.where((s) => s.on).map((s) => s.ms).reduce(min);

  final buffer = StringBuffer();
  for (final segment in segments) {
    buffer.write((segment.on ? '#' : '_') * (segment.ms ~/ unit));
  }
  return buffer.toString();
}

/// Whether a sequence satisfies the contract documented on HapticEvent.
bool isWellFormed(List<HapticEvent> events) {
  if (events.isEmpty) return true;
  if (!events.first.isVibration || !events.last.isVibration) return false;
  for (var i = 1; i < events.length; i++) {
    if (events[i].isVibration == events[i - 1].isVibration) return false;
  }
  return events.every((e) => e.duration > 0);
}

/// Accumulates report lines and check results.
class _Report {
  final List<String> lines = [];
  final Map<String, bool> checks = {};

  void line(String text) => lines.add(text);

  void blank() => lines.add('');

  void section(String title) {
    blank();
    line(title);
  }

  /// A label/value row, padded so the values line up.
  void field(String label, String value) =>
      line('   ${label.padRight(16)}: $value');

  void check(String name, {required bool passed}) {
    checks[name] = passed;
    line('   ${passed ? '[ok]  ' : '[FAIL]'} $name');
  }
}
1
likes
160
points
141
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Convert text to Morse code and haptic vibration patterns in Dart.

Repository (GitHub)
View/report issues

Topics

#morse #haptic #vibration

License

MIT (license)

Dependencies

characters, meta, vibration

More

Packages that depend on haptic_morse