Zum Hauptinhalt springen

Ton

Ein dritter Träger für den Handschlag, neben dem Code und dem Link — und der einzige, der eine Asymmetrie behebt, an der die anderen beiden nichts ändern.

Warum überhaupt Ton

Der Handschlag ist symmetrisch, die Geräte sind es nicht. Zwei Telefone gehen gut: jedes hat Kamera und Bildschirm, kann also einen Code zeigen und einen lesen. Ein Laptop und ein Telefon nicht. Der Laptop zeigt das Angebot, das Telefon scannt es — und dann soll das Telefon seine Antwort in eine Webcam halten, die schlecht sitzt, schlecht ist oder fehlt. In der Praxis weichen Leute auf Kopieren und Einfügen aus, also genau auf den Fall, den dieses Projekt mühelos machen will.

Also: QR in die eine Richtung, Ton in die andere. Die Idee stammt von vbocan/webrtc-oob-pairing, wo eine Workstation sich mit einem Telefon paart, das über einen hörbaren Ton antwortet.

Träger, kein Format. Es sind dieselben signierten Bytes, die auch in einen Code oder einen Link gehen, und beglaubigt wird weiterhin durch die Signatur in der Nutzlast — hier wie überall sonst. Ein Mikrofon ist kein vertrauenswürdiger Kanal und wird auch nicht als einer behandelt.

Was es kostet

Gemessen bei 48 kHz, für eine volle Sendung von 140 Byte:

ProtokollSekundenByte/s
normal13,510,3
fast9,315,1
fastest5,028,0

Eine kompakte Antwort (v3) misst rund 207 Byte, also zwei Sendungen: etwa 8 Sekunden mit fastest, 14 mit fast. Eine volle Antwort (v2) misst rund 758 Byte — sechs Sendungen, bestenfalls dreißig Sekunden —, weshalb dieser Träger das kompakte Format will statt des Formats der Einladung.

fast ist der Vorgabewert. Der Unterschied zu fastest sind fünf Sekunden gegen einen Raum: das schnellste Protokoll packt seine Symbole am dichtesten, und genau das lässt es als erstes an einem Echo oder einem Lüfter scheitern.

Die Rate, auf die sich beide Seiten einigen müssen

Der Codec ist bei 44,1 kHz stumm. Gemessen über die Raten, mit denen Browser tatsächlich starten: 16000, 24000, 32000, 48000 und 96000 tragen eine Nutzlast hin und zurück; 8000, 11025, 22050, 44100 und 88200 kodieren eine Wellenform, die zu nichts dekodiert — ohne Fehlermeldung in beide Richtungen.

Das ist keine Laborkuriosität. Die Vorgaberate eines Browsers folgt dem Ausgabegerät, und 44,1 kHz meldet ein großer Teil von ihnen; auf diesen Rechnern wäre jede Übertragung stillschweigend gescheitert. Beide Seiten fordern die Rate deshalb an, statt die vorgefundene zu nehmen:

import { AUDIO_SAMPLE_RATE } from '@le-space/libp2p-webrtc-qr'

const context = new AudioContext({ sampleRate: AUDIO_SAMPLE_RATE })

Der Browser rechnet zwischen ihr und der Hardware um, worin Browser gut sind. encodeToAudio und createAudioReceiver verweigern eine Rate, die sie nicht tragen können, mit einer Meldung, die den Ausweg nennt — lautes Scheitern ist besser als getragene Stille.

<qr-listen> fordert sie von sich aus an.

Die Grenze, die sich nicht meldet

Der Codec trägt höchstens 140 Byte pro Sendung und kürzt alles Längere stillschweigend — eine gültige Wellenform, die sauber zu den ersten 140 Byte dekodiert, und der Verlust steht nur auf stdout. Eine halbe Antwort, die als halbe Antwort verifiziert, ist das Schlimmste, was dieser Träger zu bieten hat. Deshalb wird hier gerahmt und zerlegt, und ein Rahmen, der die Grenze erreichen würde, wirft stattdessen.

Der Rahmenkopf ist <Index><Anzahl><Id><Id><Id>: — je eine Ziffer für Position und Anzahl, also höchstens neun Sendungen, dann drei Zeichen, die benennen, um welche Nutzlast es sich handelt. AUDIO_TRANSMISSION_LIMIT, AUDIO_HEADER_LENGTH und AUDIO_CHUNK_LIMIT sind exportiert, damit ein Konsument, der seine eigene Nutzlast bemisst, die Zahl lesen kann statt sie abzuschreiben.

Beide Hälften des Kopfes verdienen ihre Bytes. Die Grenze ist eine Byte-Zahl, und die Aufteilung zählt Bytes, keine Zeichen: String.length zählt UTF-16-Einheiten, weshalb eine Nutzlast mit irgendetwas außerhalb von ASCII in Stücke geschnitten wurde, die hier richtig aussahen und drüben abgeschnitten ankamen. Und die Id trennt eine Nutzlast von der anderen — hört man Teil 1 der einen und Teil 2 der anderen Antwort, setzt der Empfänger ohne sie etwas zusammen, das es nie gab, und meldet es als vollständig. Zwei Antworten sind sehr oft gleich lang, die Anzahl konnte sie also nie unterscheiden. Die Id wird aus der Nutzlast abgeleitet, sodass ein zweites Abspielen derselben Antwort die Lücken des ersten füllt, statt von vorn zu beginnen.

Senden

import { encodeToAudio } from '@le-space/libp2p-webrtc-qr'

const context = new AudioContext()
const { frames, seconds } = await encodeToAudio(answerPayload, {
protocol: 'fast', // AUDIO_PROTOCOLS: normal, fast, fastest
sampleRate: context.sampleRate,
volume: 15
})

for (const samples of frames) {
const buffer = context.createBuffer(1, samples.length, context.sampleRate)
buffer.copyToChannel(samples, 0)
const source = context.createBufferSource()
source.buffer = buffer
source.connect(context.destination)
source.start()
await new Promise(resolve => { source.onended = resolve })
}

Ein Puffer je Sendung, nicht einer für alle. Die Lücke dazwischen ist die Stelle, an der ein Empfänger, der ein Stück verpasst hat, die nächste Präambel sauber hört — und an der jemand das Telefon näher halten kann, ohne das schon Angekommene zu verlieren.

AUDIO_DEFAULT_PROTOCOL ist das, worauf protocol zurückfällt.

Zuhören

import { createAudioReceiver } from '@le-space/libp2p-webrtc-qr'

const receiver = await createAudioReceiver({ sampleRate: context.sampleRate })

// Gefüttert mit Blöcken von Mikrofon-Samples — aus einem AudioWorklet, einem
// ScriptProcessor oder einem aufgezeichneten Puffer. Die Antwort ist null,
// solange die Nutzlast nicht vollständig ist.
const payload = receiver.push(samples)
if (payload != null) {
receiver.close()
await session.acceptAnswer(payload)
}

Stücke dürfen in beliebiger Reihenfolge und mehrfach ankommen — dass jemand den Ton noch einmal abspielt, weil der erste Versuch übertönt wurde, ist der Normalfall und kein Fehler. Eine Wiederholung wird deshalb ignoriert und setzt nichts zurück. missing() nennt die noch ausstehenden Sendungen und total(), wie viele es insgesamt sind — die beiden Hälften einer Anzeige „2 von 3". Beide werden gebraucht: wer die Gesamtzahl allein aus den fehlenden Indizes ableitet, liegt falsch, sobald die Lücke nicht am Ende ist. reset() vergisst eine halb empfangene Nutzlast, close() gibt den Codec frei.

Die Abhängigkeit

Der Codec ist ggwave (MIT), eine optionale Peer-Abhängigkeit, geladen per await import() in dem Moment, in dem jemand den Kanal öffnet. Wer nur den Transport will, zahlt keine 150 KB WebAssembly für ein Merkmal, das er nie öffnet, und niemandem bricht der Build, weil ein Paket fehlt, das er nicht benutzt. Wer den Kanal ohne es erreicht, bekommt einen Fehler, der das Paket benennt und sagt, dass jeder andere Träger weiter funktioniert.

loadAudioCodec() ist genau dieses Laden, exportiert für Konsumenten, die es vor einer Geste vorwärmen wollen. resetAudioCodec() vergisst es wieder, was für Tests gedacht ist. frameForAudio() und parseAudioFrame() sind die Rahmung für sich, für alle, die diese Nutzlasten über eine andere Tonbibliothek tragen.

Was nicht bewiesen ist

Der Rundlauf ist als Schleife getestet: die Wellenform, die dieses Paket erzeugt, zurück in seinen eigenen Empfänger. Das beweist Rahmung, Zerlegung und Zusammensetzen. Es beweist keinen Raum. Ein Laptop-Lautsprecher in ein Telefon-Mikrofon auf Gesprächsabstand, mit Echo und Lüfter, ist eine Messung und Handarbeit — siehe #3.