halkyon/apps/todo/tool/completion_sound.dart
Tizian 「ティツィアーン」 fd6647d752
feat(todo): a task done ticks on phones and chimes on desktops
The developer's request of 10 Nov 2026: completing a task should feel like
something. `CompletionFeedback`, held by `TodoDevice` and handed to each
person's `TodoPerson`, gives a light haptic tick on Android and iOS and, on
desktops, a short quiet chime of our own: two soft bell tones a fifth apart,
synthesized by `apps/todo/tool/completion_sound.dart` into
`assets/sounds/task_done.wav`, so it needs no one's licence. A task's check
and the selection's Complete call it once, however many tasks are done;
an undo is silent.

Settings' Tasks turns the chime off on a desktop, kept per device in
`<app support>/completion_feedback.json`; a phone is offered no switch. A
device that can't play loses only the sound. The player is `audioplayers`,
as Learn's sounds: on Linux it links GStreamer, so Todo's snap gains the
`audio-playback` plug and GStreamer's headers to build, as Learn's recipe has.

Whats-New: Completing a task now gives a light tap on phones and a short chime on computers, which Settings › Tasks turns off.
Whats-New-De: Eine erledigte Aufgabe gibt jetzt auf dem Handy ein leichtes Tippen und am Computer einen kurzen Klang, den du unter Einstellungen › Aufgaben ausschaltest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-10 23:07:58 +01:00

86 lines
3.1 KiB
Dart

// Writes Todo's chime of a task done (decided by the developer, 10 Nov
// 2026), played on desktops: two soft bell tones a fifth apart, A5 then E6,
// each fading away, quiet, a quarter of a second in all. Synthesized here, so
// it is ours and needs no licence of anyone's; run from apps/todo:
//
// dart run tool/completion_sound.dart
//
// It writes halkyon_todo_flutter/assets/sounds/task_done.wav, a mono 16-bit
// WAV at 44.1 kHz; the same every run.
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
const _rate = 44100;
/// The loudest the chime gets, of full scale.
const _peak = 0.3;
/// Each tone: when it starts and how long it rings, in seconds.
const _tones = [
(hertz: 880.0, start: 0.0, length: 0.16),
(hertz: 1318.51, start: 0.07, length: 0.18),
];
void main() {
final length = _tones
.map((tone) => tone.start + tone.length)
.reduce(math.max);
final count = (length * _rate).ceil();
final samples = Float64List(count);
for (final tone in _tones) {
final first = (tone.start * _rate).round();
final last = math.min(count, first + (tone.length * _rate).round());
for (var index = first; index < last; index++) {
final t = (index - first) / _rate;
// A few milliseconds rising, so it doesn't click, then fading as a
// bell does; a quiet octave above for its shimmer.
final attack = math.min(1, t / 0.004);
final fade = math.exp(-t * 28);
final end = math.min(1, (last - index) / (_rate * 0.01));
final wave =
math.sin(2 * math.pi * tone.hertz * t) +
0.2 * math.sin(4 * math.pi * tone.hertz * t);
samples[index] += wave * attack * fade * end;
}
}
final loudest = samples.map((sample) => sample.abs()).reduce(math.max);
final pcm = ByteData(count * 2);
for (var index = 0; index < count; index++) {
pcm.setInt16(
index * 2,
(samples[index] / loudest * _peak * 32767).round(),
Endian.little,
);
}
final file = File('halkyon_todo_flutter/assets/sounds/task_done.wav')
..createSync(recursive: true)
..writeAsBytesSync(_wav(pcm.buffer.asUint8List()));
stdout.writeln('Wrote ${file.path} (${file.lengthSync()} bytes).');
}
/// [pcm] in a WAV file's RIFF header: one channel, 16 bits at [_rate].
Uint8List _wav(Uint8List pcm) {
final header = ByteData(44);
void text(int offset, String value) {
for (var index = 0; index < value.length; index++) {
header.setUint8(offset + index, value.codeUnitAt(index));
}
}
text(0, 'RIFF');
header.setUint32(4, 36 + pcm.length, Endian.little);
text(8, 'WAVE');
text(12, 'fmt ');
header
..setUint32(16, 16, Endian.little)
..setUint16(20, 1, Endian.little) // PCM
..setUint16(22, 1, Endian.little) // one channel
..setUint32(24, _rate, Endian.little)
..setUint32(28, _rate * 2, Endian.little) // bytes a second
..setUint16(32, 2, Endian.little) // bytes a frame
..setUint16(34, 16, Endian.little); // bits a sample
text(36, 'data');
header.setUint32(40, pcm.length, Endian.little);
return Uint8List.fromList([...header.buffer.asUint8List(), ...pcm]);
}