<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>scvalex.net</title>
  <id>https://scvalex.net/</id>
  <link rel="alternate" href="https://scvalex.net/" />
  <link rel="self" href="https://scvalex.net/atom.xml" />
  <updated>2026-05-05T00:00:00Z</updated>
  <author>
    <name>scvalex</name>
  </author>
  <entry>
    <title>
<![CDATA[On zram swap and zswap]]>
    </title>
    <link rel="alternate" type="text/html" href="https://scvalex.net/posts/84/" />
    <id>https://scvalex.net/posts/84/</id>
    <published>2026-05-05T00:00:00Z</published>
    <updated>2026-05-05T00:00:00Z</updated>
    <summary type="html">
<![CDATA[<p>I recently converted all my machines from <code>zram</code> swap to <code>zswap</code>.  In this post I go over the differences between the two and why <code>zswap</code> is almost certainly better for any general use-case.</p>
]]>
    </summary>
    <content type="html">
<![CDATA[<section>
<p>I recently converted all my machines from <code>zram</code> swap to <code>zswap</code>.  In this post I go over the differences between the two and why <code>zswap</code> is almost certainly better for any general use-case.</p>

</section>
<div id="floating-toc-v4"><section><h3>Contents <a href="https://scvalex.net/posts/84/#">↑ top ↑</a></h3><ul><li><a href="https://scvalex.net/posts/84/#prior-work">Prior work</a>
</li>
<li><a href="https://scvalex.net/posts/84/#linux-memory">Linux memory</a>
</li>
<li><a href="https://scvalex.net/posts/84/#zram-swap"><code>zram</code> swap</a>
</li>
<li><a href="https://scvalex.net/posts/84/#zswap"><code>zswap</code></a>
</li>
<li><a href="https://scvalex.net/posts/84/#conclusion">Conclusion</a>
</li></ul>
</section>
</div>
<section>
<h3 id="prior-work"><a href="https://scvalex.net/posts/84/#prior-work">Prior work</a></h3>
<p><a href="https://chrisdown.name/2026/03/24/zswap-vs-zram-when-to-use-what.html">This excellent post</a> by Chris Down is what got me to change the <code>zram</code> swap setup I’ve been using for 5 years on both desktops and servers.  Specifically, the discussion about behaviour under memory pressure buried in the middle of the post is what convinced me.</p>
<aside>
<p><a href="https://web.archive.org/web/20260422053614/https://gist.github.com/jboner/2841832">Archive link</a> to Chris Down’s post.</p>
</aside>
<p>That said, I think the above post errs into being too technical.  I suspect the author was trying to be comprehensive in order to establish ethos and preempt questions, but the result is an argument cluttered by details.</p>
<p>I am going to make the same argument here—that <em>basically everybody should use <code>zswap</code> instead of <code>zram</code> swap</em>—using simpler language and glossing over technical nuances.  Read Chris Down’s post for the nitty gritty details.</p>
</section>
<section>
<h3 id="linux-memory"><a href="https://scvalex.net/posts/84/#linux-memory">Linux memory</a></h3>
<p>Linux has several kinds of memory:</p>
<ul>
<li><strong>Free memory</strong>.  This is unused memory that can be allocated to programs right now.</li>
<li><strong>Anonymous/program memory</strong>.  This is memory allocated by programs and the kernel on the stack and on the heap.  This is what people mean by <strong>Used</strong> memory.</li>
<li><strong>Cache</strong>.  These are bytes that apps have recently read from disk, bytes that are buffered before being written to disk, but also the executable code of programs and <a href="https://manpages.debian.org/trixie/manpages-dev/mmap.2.en.html"><code>mmap</code></a>s.  So, basically everything for which a copy exists on the disk.  The cache can be “evicted” to free up memory.</li>
<li><strong>Swap</strong>.  These are pages of <strong>Anonymous/program memory</strong> that the kernel decided aren’t being used at the moment, so it has “swapped” them out.  If the system has a swap partition or swap file, swapping out means writing to disk and swapping in means reading from disk.  If the system has <code>zram</code> swap, then swapping just means (de)compressing pages in memory.</li>
</ul>
<aside>
<p>For our purposes, “a page” means 4 KB of memory.  We’re also going to pretend the <strong>Cache</strong> operates in 4 KB pages.</p>
</aside>
<p>We can see the different kinds of memory graphically in a system monitor like <a href="https://github.com/aristocratos/btop"><code>btop</code></a>:</p>
<div>
  <div>
    <img src="https://scvalex.net/r/84/btop-mem.png" alt="btop showing 30.9 GiB of main memory and 24.2 GiB of swap.  Of the main memory, 9.8 GiB are used, 21.1 GiB are available, 9.5 GiB are cached, and 10.3 GiB are free.Of swap, 7.6 GiB  are used and 16.6 GiB are free." loading="lazy" height=500 width=578>
  </div>
  <div>
    <code>btop</code> showing 30.9 GiB of main memory and 24.2 GiB of swap.  Of the main memory, 9.8 GiB are used, 21.1 GiB are available, 9.5 GiB are cached, and 10.3 GiB are free.<br>Of swap, 7.6 GiB  are used and 16.6 GiB are free.
  </div>
</div>
<aside>
<p><code>Available</code> is <code>Total - Used</code>.  Attentive readers might notice that <code>Free + Cached</code> is close to <code>Available</code>, but not exactly equal.  This is because Linux has a few more kinds of memory.  See the manpage of <a href="https://manpages.debian.org/trixie/procps/free.1.en.html"><code>free(1)</code></a> for details.</p>
</aside>
<p>The above is a screenshot from my laptop which has been running for 3 days.  The important observation is that <em>swap is being used even though the system has plenty of free memory</em>.</p>
<p>We can see what processes are currently being swapped with <a href="https://www.selenic.com/smem/"><code>smem</code></a>:</p>
<pre><code>$ smem --autosize -k -c &quot;swap command&quot; -s swap -r | sed -e &#39;s#/nix/store/[^/]*/##&#39; | head -n 20
   Swap Command
   2.8G bin/rust-analyzer
 418.0M hx
 193.9M usr/lib/zotero-bin-7.0.27/zotero-bin -app /nix/store/8i6idm7gf4wccmna246a004b8q7wa3z5-zotero-7.0.27/usr/lib/zotero-bin-7.0.27/app
 177.0M /run/current-system/sw/bin/harper-ls --stdio
 162.1M bin/node /nix/store/5kfn1vqwxr11micrqxw2klvl0bz6f9zg-tailwindcss-language-server-0.14.28/bin/tailwindcss-language-server --stdio
 161.6M /run/current-system/sw/bin/firefox
 158.8M bin/.thunderbird-wrapped_ --name thunderbird
 150.8M /run/current-system/sw/bin/birdtray
 135.8M ./target/release/dis dev
 118.6M bin/elisa
 116.0M libexec/electron/electron /nix/store/wfjrp90gxm2jcfq0vwbza81lywwjs6wq-signal-desktop-8.6.1/share/signal-desktop/app.a
 115.1M opt/Discord/.Discord-wrapped --type=zygote --no-zygote-sandbox
 100.0M bin/plasmashell --no-respawn
  98.6M lib/marksman/marksman server
  91.9M /proc/self/exe --type=renderer --crashpad-handler-pid=3340 --enable-crash-reporter=c8233cca-57ab-49cf-a7c9-87ac7d5730bc,no_channel --user-data-dir=/home/scvalex/.config/discord --standard
  81.7M /run/current-system/sw/bin/neochat
  79.5M bin/tailwindcss --minify --no-autoprefixer -o styles_out/r/screen3.css -i styles/screen3.css --watch
  61.5M lib/firefox/firefox -contentproc -isForBrowser -prefsHandle 0:36709 -prefMapHandle 1:294101 -jsInitHandle 2:156120 -parentBuildID
  61.1M /run/current-system/sw/bin/Discord --enable-speech-dispatcher
</code></pre>
<p>Half the swap usage comes from my editor (<a href="https://helix-editor.com/">Helix</a>, <a href="https://rust-analyzer.github.io/"><code>rust-analyzer</code></a>, <a href="https://tailwindcss.com/">TailwindCSS</a>, <a href="https://github.com/artempyanykh/marksman/"><code>marksman</code></a>, and <a href="https://writewithharper.com/"><code>harper-ls</code></a>).  I think what’s going on is that when I opened my blog to write this post, I also opened some Rust source file.  This triggered <code>rust-analyzer</code> to load all the type information into memory, but since I’m not doing any Rust coding, that memory was never touched again, the kernel noticed, so it swapped it out.</p>
<p>This is the behaviour we want.  There’s no point in keeping stuff in <strong>Used</strong> memory if it isn’t being actively used.  It’s better to swap out cold pages, so that the system has more <strong>Free</strong> memory it could use for other programs or for <strong>Cache</strong>.  The last bit is particularly important.  <em>Since <a href="https://gist.github.com/jboner/2841832">disk IO is 1000x slower than memory</a>, it’s better to fill memory with disk cache than with cold program data.</em></p>
<aside>
<p><a href="https://web.archive.org/web/20260422053614/https://gist.github.com/jboner/2841832">Archive link</a> to the latency numbers gist.</p>
</aside>
<p>The above is true under normal circumstances, but it’s even more important if the system is under memory pressure.  If I started a big build that needed 15 GB of memory, the kernel would first use the 10 GB of <strong>Free</strong> memory, then evict 5 GB of <strong>Cache</strong> to make room.</p>
<aside>
<p>Reducing the cache allocation of memory at a time when the system is likely to be reading and writing the same files multiple times isn’t great, but it’s workable.</p>
</aside>
<p>However, if I didn’t have any <strong>Swap</strong>, then I’d only have 3 GB of <strong>Free</strong>, so the kernel would evict the entire 10 GB of <strong>Cache</strong>, then OOM kill another 2 GB of programs.  Having programs OOM’d is bad, but not having any <strong>Cache</strong> and forcing the build to fully write every build artifact to disk only for it to be immediately read back is worse.</p>
<aside>
<p>Since the OOM killer usually goes for the biggest memory user, it will probably kill Firefox instead of the text editor with the Rust project I haven’t touched in days.  This used to happen all the time to me, but has stopped since I added some swap.</p>
</aside>
<p><em>When under memory pressure, something is going to get evicted to disk.  It’s better to swap out cold program memory than to reduce disk cache.</em></p>
<p>If you’re like me and remember the 2000’s when memory was scarce and “swapping” meant that your system would crawl to a halt while the disk made angry noises, the world has changed.  Running out of memory and putting the kernel in a situation where it has to swap out the big process that’s using 100% of the CPU is still bad, but that’s not what happens most of the time.  Usually, the kernel will be swapping out massive <a href="https://www.electronjs.org/">Electron</a> apps like Signal and Discord which slowly leak memory or programs that haven’t been touched in a while.  Also, SSD’s are 20x faster than spinning disks, so the penalty for swapping is a lot lower.  Also also, disk space is really cheap now, so there’s genuinely no reason not to just give the system a few tens of gigs of swap.</p>

</section>
<section>
<h3 id="zram-swap"><a href="https://scvalex.net/posts/84/#zram-swap"><code>zram</code> swap</a></h3>
<p>We’ve established that swap is good, but what kind of swap is better?  Let’s consider swap on <a href="https://www.kernel.org/doc/html/latest/admin-guide/blockdev/zram.html"><code>zram</code></a> first.  To quote the kernel docs:</p>
<blockquote>
<p>The zram module creates RAM-based block devices named /dev/zramN (N = 0, 1, …). Pages written to these disks are compressed and stored in memory itself. These disks allow very fast I/O and compression provides good amounts of memory savings. Some of the use cases include <code>/tmp</code> storage, <strong>use as swap disks</strong>, various caches under <code>/var</code> and maybe many more. :)</p>
</blockquote>
<aside>
<p>The kernel docs mention an expected compression ratio of 2:1 and I have seen online sources claim even better ratios.  This means <code>zram</code> only takes 50% as much space as the swap it’s compressing.</p>
<p>In my 5 years of using swap on <code>zram</code> and occasionally running <code>zramctl</code>, I have never seen it compress to below 80%.  I don’t know where the 50% or smaller numbers come from.  I hope somebody actually benchmarked and these aren’t just the generic <code>lz4</code> and <code>zstd</code> ratios for prose text.</p>
</aside>
<p>To use normal swap, we have a swap partition <code>/dev/sda2</code> and we call <code>swapon /dev/sda2</code> to activate it.  To use swap on <code>zram</code>, we first create a <code>/dev/zram0</code> device with <code>modprobe zram</code>, then we call <code>swapon /dev/zram0</code>.</p>
<p>Swap on <code>zram</code> is the closest real thing to the <a href="https://downloadmoreram.com/">Download More RAM</a> joke.  However, <em><code>zram</code> is not RAM because programs can’t use it directly</em>.  As in, there’s no way to run Firefox directly from <code>zram</code>.  Firefox always runs from normal memory, but just like how some of its pages can be swapped out to disk, they can also be swapped out to a different part of memory which happens to be compressed.  The pages then need to be copied and decompressed if Firefox accesses them.</p>
<p>The benefits of swap on <code>zram</code> over swap on disk is that <code>zram</code> is much faster since it’s in memory and is always available since we don’t need a special partition or file.  But being fast isn’t very useful because this memory is by definition infrequently accessed.  For example, being able to swap in my dormant 3 GB <code>rust-analyzer</code> half a second faster isn’t really valuable to me.  And not needing a pre-existing file or partition is a feature, but we can just create the file or partition in most cases.</p>
<p>The big downside of swap on <code>zram</code> is that it still uses memory.  Assuming an optimistic compression ratio of 2:1, my laptop would now be using 4 GB more memory with <code>zram</code>.  That’s paying 12% of my total memory for benefits I don’t find particularly compelling.</p>
<p>So, just because swap is good, <em>swap on <code>zram</code> is better than no swap, but we can easily do better with swap on disk</em>.</p>

</section>
<section>
<h3 id="zswap"><a href="https://scvalex.net/posts/84/#zswap"><code>zswap</code></a></h3>
<p>As we’ve seen, swap on <code>zram</code> has gotchas.  By contrast, <a href="https://www.kernel.org/doc/html/latest/admin-guide/mm/zswap.html"><code>zswap</code></a> is just normal swap fronted by a compressed in-memory cache.  To quote the kernel docs:</p>
<blockquote>
<p><code>Zswap</code> is a lightweight compressed cache for swap pages. It takes pages that are in the process of being swapped out and attempts to compress them into a dynamically allocated RAM-based memory pool. <code>Zswap</code> basically trades CPU cycles for potentially reduced swap I/O. This trade-off can also result in a significant performance improvement if reads from the compressed cache are faster than reads from a swap device.</p>
</blockquote>
<aside>
<p>As of NixOS 26.05, you can enable <code>zswap</code> with the <a href="https://search.nixos.org/options?channel=unstable&amp;query=boot.zswap"><code>boot.zswap</code></a> option.</p>
</aside>
<p>This is just the swap on <code>zram</code> trick, except the kernel knows that the compressed things are swap pages, so it can do specialized logic like evict the pages to disk when they’ve been cold for long enough.</p>
<p>In other words, <code>zswap</code> implements a proper memory hierarchy:</p>
<ul>
<li>Frequently accessed pages are stored in RAM,</li>
<li>Infrequently accessed pages are stored in <code>zswap</code>‘s’ compressed RAM, and</li>
<li>Cold pages are swapped to disk.</li>
</ul>
<p>So, <code>zswap</code> has the benefits of both swap on disk and swap on <code>zram</code>.  <em>When we have swap on disk, we should enable <code>zswap</code> as well</em>.</p>
</section>
<section>
<h3 id="conclusion"><a href="https://scvalex.net/posts/84/#conclusion">Conclusion</a></h3>
<p><strong>TL;DR</strong> For any general use-case, have a swap partition or swap file and enable <code>zswap</code>.  Don’t bother with swap on <code>zram</code> unless you really can’t have swap on disk.</p>
<p>I’ve been saying “general use-case” in this post, but I think it’s basically every use-case in 2026.  The usual example where swap on <code>zram</code> is better is an embedded device running on flash memory that degrades quickly if thrashed.  As <a href="https://chrisdown.name/2026/03/24/zswap-vs-zram-when-to-use-what.html">Chris Down’s post</a> points out, you can still get disk thrashing from the cache being evicted under memory pressure, so <code>zram</code> is not a panacea.  I would add that the Raspberry Pi 5 has been out since 2023 and has NVMe support, so I don’t think the “crappy flash memory” use-case is going to be a concern for much longer even in the embedded space.</p>
<aside>
<p>This post is about swap on <code>zram</code> specifically and not about <code>zram</code> in general.  For example, putting <code>/tmp</code> on <code>zram</code> seems like a perfectly legitimate use-case to me.</p>
</aside></section>]]>
    </content>
  </entry>
  <entry>
    <title>
<![CDATA[What the hell is a decibel?]]>
    </title>
    <link rel="alternate" type="text/html" href="https://scvalex.net/posts/83/" />
    <id>https://scvalex.net/posts/83/</id>
    <published>2026-04-25T00:00:00Z</published>
    <updated>2026-04-25T00:00:00Z</updated>
    <summary type="html">
<![CDATA[<p><a href="https://en.wikipedia.org/wiki/Decibel">Decibels</a> come up often in digital audio and I’ve always found them to be confusing.  In this post, I try to explain what decibels are and more importantly, how they’re used in practice.</p>
]]>
    </summary>
    <content type="html">
<![CDATA[<section>
<p><a href="https://en.wikipedia.org/wiki/Decibel">Decibels</a> come up often in digital audio and I’ve always found them to be confusing.  In this post, I try to explain what decibels are and more importantly, how they’re used in practice.</p>

<aside>
<p>This post is part of a series on <a href="https://scvalex.net/posts/audio/">#audio</a>:</p>
<ul>
<li><a href="https://scvalex.net/posts/78/">Making sounds with WebAssembly</a></li>
<li><a href="https://scvalex.net/posts/80/">Combining sine waves</a></li>
<li><span>What the hell is a decibel?</span></li>
</ul>
</aside>
</section>
<div id="floating-toc-v4"><section><h3>Contents <a href="https://scvalex.net/posts/83/#">↑ top ↑</a></h3><ul><li><a href="https://scvalex.net/posts/83/#a-unit-of-nothing">A unit of nothing</a>
</li>
<li><a href="https://scvalex.net/posts/83/#sound-pressure">Sound pressure</a>
</li>
<li><a href="https://scvalex.net/posts/83/#digital-audio">Digital audio</a>
</li>
<li><a href="https://scvalex.net/posts/83/#next-up">Next up</a>
</li></ul>
</section>
</div>
<section>
<h3 id="a-unit-of-nothing"><a href="https://scvalex.net/posts/83/#a-unit-of-nothing">A unit of nothing</a></h3>
<p>I think the most striking thing about decibels is how alien they are compared to other units of measurement we encounter in the real world.</p>
<p>For example, we often talk about centimeters.  If your display is configured properly, the following rectangle is 1 cm wide: <span></span>.  We can convert centimeters to millimeters or kilometers.  We can also convert centimeters to inches or miles.  However, whatever we convert them to, there is always a unit of length left—<em>we can never convert a centimeter to a plain number</em>.</p>
<p><a href="https://en.wikipedia.org/wiki/Decibel">Decibels</a> look like centimeters.  There’s an SI prefix, “deci”, followed by a thing, “bel”.  One would reasonably assume a “bel” is a unit of loudness or something of the sort, but no, it’s actually just the number <math display="inline"><mn>1.122</mn></math>(approximatley).  This means <em>we can evaluate a decibel value to a plain number</em>.  For example, <math display="inline"><mn>6</mn><mtext>&nbsp;</mtext><mtext>dB</mtext><mo>=</mo><mn>1.995</mn></math>.</p>
<aside>
<p>The abbreviation for a decibel is dB.  The ‘B’ is capitalized because it comes from the proper name Bell.  Mind you, the unit “bel” only has one ‘l’ in it and is written uncapitalized.</p>
</aside>
<p>Since decibels are plain numbers, we can reasonably tack on other units of measurement.  For example, my height is about <math display="inline"><mn>45.25</mn><mtext>&nbsp;</mtext><mtext>dB</mtext><mtext>&nbsp;</mtext><mtext>cm</mtext></math>.  This is how decibels are usually used in practice and <em>the unit of measurement is implied by the context</em>.  This is why the sound of breathing is 10 dB, the sound of a car is 50 dB, but the volume slider in every audio editing application goes from -60 dB to 0 dB.  Although the numbers all measure loudness, they don’t fit together.  This is because the first pair are in <a href="https://en.wikipedia.org/wiki/Sound_pressure">dB<sub>SPL</sub></a> and the second pair are in <a href="https://en.wikipedia.org/wiki/DBFS">dBFS</a>.</p>
<p>To add to the confusion, there are two different formulas for decibels and context determines which one to use.</p>
<aside>
<p>Really, there’s only one formula and it’s the power one.  The formula has to be adjusted in the way shown when talking about linear quantities like amplitudes.</p>
</aside>
<ul>
<li>
<p><em>Power formula</em>.  This applies to power values such as energy use in an amplifier.</p>
<ul>
<li>
<p>To evaluate <math display="inline"><mi>x</mi><mtext>&nbsp;</mtext><mtext>dB</mtext></math>to a number use: <math display="inline"><msup><mn>10</mn><mfrac><mrow><mi>x</mi></mrow><mrow><mn>10</mn></mrow></mfrac></msup></math>.</p>
</li>
<li>
<p>To compute the decibel value of the number <math display="inline"><mi>x</mi></math>use: <math display="inline"><mn>10</mn><mo>×</mo><msub><mi>log</mi><mn>10</mn></msub><mo>⁡</mo><mo symmetric="false" stretchy="false">(</mo><mi>x</mi><mo symmetric="false" stretchy="false">)</mo></math>.</p>
</li>
</ul>
</li>
<li>
<p><em>Amplitude formula</em>.  This applies to the loudness of sound and digital audio.</p>
<ul>
<li>
<p>To evaluate <math display="inline"><mi>x</mi><mtext>&nbsp;</mtext><mtext>dB</mtext></math>to a number use: <math display="inline"><msup><mn>10</mn><mfrac><mrow><mi>x</mi></mrow><mrow><mn>20</mn></mrow></mfrac></msup></math>.</p>
</li>
<li>
<p>To compute the decibel value of the number <math display="inline"><mi>x</mi></math>use: <math display="inline"><mn>20</mn><mo>×</mo><msub><mi>log</mi><mn>10</mn></msub><mo>⁡</mo><mo symmetric="false" stretchy="false">(</mo><mi>x</mi><mo symmetric="false" stretchy="false">)</mo></math>.</p>
</li>
</ul>
</li>
</ul>
<p>In the context of digital audio, the rule of thumb is that positive dB values refer to physical sound pressure and any dB values with a sign in front of them (e.g. +6 dB, -12 dB) refer to digital sound volume.  Both cases <em>use the amplitude formula</em>.</p>
<aside>
<p>Note that <math display="inline"><mn>0</mn><mtext>&nbsp;</mtext><mtext>dB</mtext><mo>=</mo><mn>1</mn></math>regardless of the formula used or the unit of measurement.  Also, there’s no dB value that evaluates to 0.</p>
</aside>
</section>
<section>
<h3 id="sound-pressure"><a href="https://scvalex.net/posts/83/#sound-pressure">Sound pressure</a></h3>
<p>The <a href="https://en.wikipedia.org/wiki/Inner_ear">human ear</a> is basically a spiral tube filled with liquid, one end of which is covered by a membrane.  When the membrane moves, it displaces the liquid and tiny hairs on the sides of the tube turn this motion into nerve impulses which is what we perceive as sound.</p>
<div>
  <div>
    <img src="https://scvalex.net/r/78/internal-ear.png" alt="Anatomy of the ear(source: Bruce Blaus via Wikipedia)" loading="lazy" height=450 width=450>
  </div>
  <div>
    Anatomy of the ear<br>(source: Bruce Blaus via <a href='https://commons.wikimedia.org/wiki/File:Blausen_0329_EarAnatomy_InternalEar.png'>Wikipedia</a>)
  </div>
</div>
<p>The critical bit is that the eardrum is moved by tiny changes in atmospheric pressure.  “Quiet” means the changes in pressure are small.  “Loud” means the changes in pressure are big.</p>
<p>The unit of measurement for atmospheric pressure is the Pascal.  The problem is that 1 Pa roughly translates to somebody screaming directly into your ear, so it’s not a good baseline in the context of human hearing.  Instead, the <a href="https://en.wikipedia.org/wiki/Sound_pressure#Sound_pressure_level">sound pressure level</a> of 0 dB<sub>SPL</sub> is usually taken to be 20 μPa which is the quietest sound a person can hear.</p>
<aside>
<p>There are three ways we can talk about sound strength: <a href="https://en.wikipedia.org/wiki/Sound_pressure">sound pressure</a>, <a href="https://en.wikipedia.org/wiki/Sound_power">sound power</a>, and <a href="https://en.wikipedia.org/wiki/Sound_intensity">sound intensity</a>.  Our eardrums pick up changes in pressure, so sound pressure is the concept that describes what we hear.</p>
</aside>
<p>Some <a href="https://en.wikipedia.org/wiki/Sound_pressure#Examples_of_sound_pressure">examples</a> of real world sound pressure levels are below.  We use the amplitude formula to convert between numbers in Pascals and decibels.  The rule of thumb for real world sound is that a difference of <em>6 dB is twice the pressure</em>, <em>10 dB is three times the pressure</em>, and <em>20 dB is ten times the pressure</em>.</p>
<div>
<div>
<table><thead><tr><th style="text-align: left">Sound</th><th style="text-align: right">dB</th><th style="text-align: right">Pa</th></tr></thead><tbody>
<tr><td style="text-align: left">Pain</td><td style="text-align: right">120 dB</td><td style="text-align: right">20 Pa</td></tr>
<tr><td style="text-align: left">Jet engine at 100m</td><td style="text-align: right">110 dB</td><td style="text-align: right">6.3 Pa</td></tr>
<tr><td style="text-align: left">Jackhammer</td><td style="text-align: right">100 dB</td><td style="text-align: right">2 Pa</td></tr>
<tr><td style="text-align: left">Petrol car travelling at 30 kph</td><td style="text-align: right">67-70 dB</td><td style="text-align: right">45-63 mPa</td></tr>
<tr><td style="text-align: left">Normal conversation</td><td style="text-align: right">40-60 dB</td><td style="text-align: right">2-20 mPa</td></tr>
<tr><td style="text-align: left">Calm room</td><td style="text-align: right">20-30 dB</td><td style="text-align: right">200-630 μPa</td></tr>
<tr><td style="text-align: left">Leaf rustling, light breathing</td><td style="text-align: right">10 dB</td><td style="text-align: right">63 μPa</td></tr>
<tr><td style="text-align: left">Threshold of hearing</td><td style="text-align: right">0 dB</td><td style="text-align: right">20 μPa</td></tr>
</tbody></table>
</div>
</div>
<p>The dB scale is logarithmic, so adding dB values is equivalent to multiplying the evaluated numbers.  For example, <math display="inline"><mn>20</mn><mtext>&nbsp;</mtext><mtext>dB</mtext></math>translates to <math display="inline"><mn>10</mn></math>times the pressure, but <math display="inline"><mn>20</mn><mo>+</mo><mn>20</mn><mo>=</mo><mn>40</mn><mtext>&nbsp;</mtext><mtext>dB</mtext></math>translates to <math display="inline"><mn>10</mn><mo>×</mo><mn>10</mn><mo>=</mo><mn>100</mn></math>times the pressure.  It’s convenient to use decibels when talking about sound pressure because numbers on the <math display="inline"><mn>0</mn><mi mathvariant="normal">…</mi><mn>120</mn><mtext>&nbsp;</mtext><mtext>dB</mtext></math>scale are much easier to express than numbers on the <math display="inline"><mn>0.00002</mn><mi mathvariant="normal">…</mi><mn>20</mn><mtext>&nbsp;</mtext><mtext>Pa</mtext></math>scale.</p>
</section>
<section>
<h3 id="digital-audio"><a href="https://scvalex.net/posts/83/#digital-audio">Digital audio</a></h3>
<p>Our model for digital audio is that we have a loudspeaker with a membrane.  We send floating point numbers in the <code>[-1.0, 1.0]</code> range to the loudspeaker to control the position of the membrane.  The membrane is pulled back by <code>-1.0</code> and pushed forward by <code>1.0</code>.  If we send 48,000 of these “samples” per second, the speaker produces sound.</p>
<p>We adjust the volume of the sound by scaling the samples by some factor in the <code>[0.0, +inf]</code> range.  For example, if we scale by <code>0.5</code>, the speaker membrane moves half as much, so the sound is quieter.  If we scale by <code>4.0</code>, the speaker membrane moves much more, so the sound is louder.  The final numbers have to be in the <code>[-1.0, 1.0]</code> range, so scaling by <code>4.0</code> only makes sense if we know the peaks in our samples are less than <code>0.25</code>.</p>
<p>The difficulty with volume is that scaling by <code>0.5</code> doesn’t lower the perceived volume by half because <a href="https://en.wikipedia.org/wiki/Weber%E2%80%93Fechner_law">our ears interpret changes in pressure as logarithmic</a>.  We have higher resolution for quiet sounds than for loud ones.  This makes evolutionary sense since it’s more important to hear the slightly increased rustling of the tall grass as the saber-tooth tiger is sneaking up on you than being able to distinguish between loud and super-loud.</p>
<p>This effect is easy to see with an example.  The two players below emit 440 Hz sine waves.  The volume adjustments for both go from <code>0.01</code> to <code>1.00</code>.  The difference between them is that the first slider is linear, so half-way is <code>0.5</code>, but the second slider is logarithmic, so half-way is <code>0.07</code>.  <strong>Turn up the volume on your computer and headset for this example.</strong></p>
<div>
  <noscript>
    This demo is a little WASM program.  JavaScript needs to be enabled for it to work.
  </noscript>
  <canvas id="canvas-volume-oscillator" width=500 height=120 style="width: 500px; height: 120px;"></canvas>
</div>
<aside>
<p>The code this the demo is in <a href="https://codeberg.org/scvalex/sound-stuff/src/branch/main/src/volume_oscillator_gen.rs#L151"><code>volume_oscillator_gen.rs</code></a>.</p>
</aside>
<p>If you turned up the volume before trying the example, you’ll have noticed that <em>there’s a point where the sound is so loud that making it louder doesn’t make it feel any louder</em>.  For me, that point is about <code>0.6</code> which is 60% of the linear slider, but 90% of the log slider.  Pressing both play buttons sends the sounds in stereo—the left/linear channel obviously stays saturated for longer than the right/log channel.  In other words, almost half of the linear slider is “too loud”, so the UI space is wasted.</p>
<aside>
<p>Modeling human sound perception as logarithmic is a spherical cow model.  Reality is messier because we hear high frequencies as louder than low frequencies.  Professional mastering of recordings involves volume normalization with something like <a href="https://en.wikipedia.org/wiki/LUFS">LUFS</a>.  Volume sliders usually just do the simple logarithmic adjustment.</p>
</aside>
<p>When converting a slider position to decibels in digital audio, <a href="https://dspguide.com/ch14/1.htm">we use the amplitude formula</a> ( <math display="inline"><mn>20</mn><mo>×</mo><mi>l</mi><mi>o</mi><msub><mi>g</mi><mn>10</mn></msub><mo symmetric="false" stretchy="false">(</mo><mi>x</mi><mo symmetric="false" stretchy="false">)</mo></math>).  There isn’t really an implied unit of measurement for these decibels since they represent pure numbers in a computer, so they’re called decibels relative to full scale (<a href="https://en.wikipedia.org/wiki/DBFS">dBFS</a>).</p>
<p>Since the numbers are frequently in the <code>[0.0, 1.0]</code> range, we often see negative decibel values like <math display="inline"><mi>−</mi><mn>60</mn><mtext>&nbsp;</mtext><mtext>dB</mtext></math>and <math display="inline"><mn>0</mn><mtext>&nbsp;</mtext><mtext>dB</mtext></math>.  There’s no way to represent 0 as decibels, but <math display="inline"><mi>−</mi><mn>60</mn><mtext>&nbsp;</mtext><mtext>dB</mtext><mo>=</mo><msup><mn>10</mn><mfrac><mrow><mi>−</mi><mn>60</mn></mrow><mrow><mn>20</mn></mrow></mfrac></msup><mo>=</mo><msup><mn>10</mn><mrow><mi>−</mi><mn>3</mn></mrow></msup><mo>=</mo><mn>0.001</mn></math>is so quiet it might as well be 0.  And <math display="inline"><mn>0</mn><mtext>&nbsp;</mtext><mtext>dB</mtext><mo>=</mo><msup><mn>10</mn><mfrac><mrow><mn>0</mn></mrow><mrow><mn>20</mn></mrow></mfrac></msup><mo>=</mo><mn>1.0</mn></math>.</p>
<p>The rule of thumb for digital audio is that <em>6 dB means 2x</em> and <em>-6 dB means 0.5x</em>.  So, increasing the volume by 6 dB just means scaling all the samples by 2.0.  If somebody says to <a href="https://www.kevinmuldoon.com/audio-levels-youtube/">cap the peaks in the sound to -6 dB</a>, they’re saying to make sure no samples have values outside the <code>[-0.5, 0.5]</code> range.</p>
<p>Since decibels are logarithms, adding them together translates into multiplying the scaling factors.  So, a gain effect of +6 dB followed by a gain of +18 dB has the cummulative effect of 24 dB of gain.  Since <math display="inline"><mn>24</mn><mo>=</mo><mn>4</mn><mo>×</mo><mn>6</mn></math>and 6 dB means 2x scaling, this translates to a cummulative scaling of <math display="inline"><msup><mn>2</mn><mn>4</mn></msup><mo>=</mo><mn>16</mn></math>.</p>
<aside>
<p>The words “volume” and “gain” both mean scaling the samples by some value.  They exist because audio filters and effects frequently have built-in scaling.  Gain is usually the scaling before the effect is applied and volume is the scaling after the effect.</p>
<p>For example, we might want to turn up the gain on a distortion effect to really distort the sound, but turn down the volume so that the output isn’t too loud.</p>
</aside>
</section>
<section>
<h3 id="next-up"><a href="https://scvalex.net/posts/83/#next-up">Next up</a></h3>
<p>That’s it for decibels.  When we see a graph with a scale from -60 dB to 0 dB, we now know that this means it goes from practically 0.0 to 1.0 and that the smaller values are over-emphasized to match how human hearing works.  We also know that +6 dB means scale the numbers by 2x and that keeping our sound peaks below -12 dB just means ensuring the absolute numbers don’t exceed 0.25.  Finally, if we hear someone say babies scream at 100 dB, we know that’s just a completely different unit of measurement.</p>
<p>Next, we’ll go back to our trusty sine waves and see how to break apart a sound into sines at different volumes.</p>
<script type="module">
  import init, {
      run_volume_oscillator,
  } from "/r/b/sound_stuff.js";

  async function run() {
    await init();

    run_volume_oscillator("canvas-volume-oscillator");
  }

  run();
</script></section>]]>
    </content>
  </entry>
  <entry>
    <title>
<![CDATA[A load balancer on every host]]>
    </title>
    <link rel="alternate" type="text/html" href="https://scvalex.net/posts/82/" />
    <id>https://scvalex.net/posts/82/</id>
    <published>2026-04-21T00:00:00Z</published>
    <updated>2026-04-21T00:00:00Z</updated>
    <summary type="html">
<![CDATA[<p>I’ve started running <a href="https://www.haproxy.org/">HAProxy</a> on every machine in my fleet.  This neatly solves the problem of connecting to services in my Kubernetes cluster, as well as making it possible to have nice URLs for local services running on weird ports.</p>
]]>
    </summary>
    <content type="html">
<![CDATA[<section>
<p>I’ve started running <a href="https://www.haproxy.org/">HAProxy</a> on every machine in my fleet.  This neatly solves the problem of connecting to services in my Kubernetes cluster, as well as making it possible to have nice URLs for local services running on weird ports.</p>

</section>
<div id="floating-toc-v4"><section><h3>Contents <a href="https://scvalex.net/posts/82/#">↑ top ↑</a></h3><ul><li><a href="https://scvalex.net/posts/82/#problem-statement">Problem statement</a>
</li>
<li><a href="https://scvalex.net/posts/82/#dns-load-balancing">DNS “load balancing”</a>
</li>
<li><a href="https://scvalex.net/posts/82/#load-balancer">Load balancer</a>
</li>
<li><a href="https://scvalex.net/posts/82/#haproxy">HAProxy</a>
</li>
<li><a href="https://scvalex.net/posts/82/#etc-hosts"><code>/etc/hosts</code></a>
</li>
<li><a href="https://scvalex.net/posts/82/#nixos-module">NixOS module</a>
</li>
<li><a href="https://scvalex.net/posts/82/#looking-back">Looking back</a>
</li></ul>
</section>
</div>
<section>
<h3 id="problem-statement"><a href="https://scvalex.net/posts/82/#problem-statement">Problem statement</a></h3>
<p>There are two problems to solve here.  The first is that I want to be able to <em>connect to any one of the multiple servers running some service</em>.  For example, there are three <a href="https://kubernetes.io/docs/concepts/architecture/#control-plane-components">Kubernetes masters</a> running in my cluster listening on addresses like <code>10.10.0.18:6443</code> and I want my admin tooling to connect to whichever of them is available.  This needs to work from every machine in the fleet and not just my workstation.</p>
<p>The second problem is that I would like to have <em>clean URLs for services running on ports other than 80</em>.  For example, I would like to use <code>http://livebook.local</code> for the <a href="https://livebook.dev/">Livebook</a> server listening on <code>http://127.0.0.1:30123</code>.  Getting a nice name is easy, getting rid of the port is harder.</p>
</section>
<section>
<h3 id="dns-load-balancing"><a href="https://scvalex.net/posts/82/#dns-load-balancing">DNS “load balancing”</a></h3>
<p>The first problem is obviously a load balancing problem.  Before we solve it properly, let’s look at something that doesn’t work, namely DNS load balancing.</p>
<p>When I first encountered this, I thought “Oh, I’ll just use <code>/etc/hosts</code>!”</p>
<pre><code>10.10.0.16 fsn-qpr-kube3.wg kube.eu1
10.10.0.17 fsn-qpr-kube2.wg kube.eu1
10.10.0.18 fsn-qpr-kube1.wg kube.eu1
</code></pre>
<div>Excerpt from <code>/etc/hosts</code>.  This does not work.</div>
<p>Each of the hosts has a unique name associated with its IP address, but they all also share the common name <code>kube.eu1</code>.  My thinking was that I could now use <code>https://kube.eu1:6443</code> as the address of the master and tools would randomly pick one of the IPs and fallback if they’re unreachable.</p>
<p>What happened in practice is that the system resolver picked one of the three IPs and always returned that.  So, tools would just stickily bind to one of the masters and if that one was down, the tool wouldn’t work.</p>
<aside>
<p>I’ve tried variations of this over the years and I’ve come to the conclusion that <code>/etc/hosts</code> is just not DNS.  It looks like DNS, has a DNS-like API, but it regularly behaves very much unlike DNS.</p>
</aside>
<p>My next thought was to use a real DNS server.  For example, I could add these internal addresses to a public subdomain like <code>kube.scvalex.net</code>.  DNS queries would now return all three IPs.  Unfortunately, this also doesn’t work because tools would have to be smart enough to try out multiple addresses and fallback gracefully.  I know I’ve never written a program that does this, so I wouldn’t want to rely on others putting in the effort.</p>
</section>
<section>
<h3 id="load-balancer"><a href="https://scvalex.net/posts/82/#load-balancer">Load balancer</a></h3>
<p>Enough beating around the bush—we need a load balancer.  The standard LB setup looks something like this:</p>
<div>
  <div>
    <img src="https://scvalex.net/r/82/normal-lb.png" alt="A normal load balancer setup" loading="lazy" height=486 width=625>
  </div>
  <div>
    A normal load balancer setup
  </div>
</div>
<p>We have three servers: <code>srv-0</code>, <code>srv-1</code>, and <code>srv-2</code> (currently down).  Sitting in front of them are two LBs: <code>lb-0</code> and <code>lb-1</code>.  When “Bob” wants to connect to service <code>srv</code>, he looks it up in DNS and gets back <code>lb-0</code> as its address.  “Bob” then connects to <code>lb-0</code> which proxies the request to one of <code>srv-0</code> or <code>srv-1</code>.  If any of the servers want to connect to service <code>srv</code> (e.g. because we’re running both Kubernetes masters and nodes on the same hosts), they do the same thing as “Bob” and go through the LB.  Since the LBs are continuously checking the health of their backends, they know that <code>srv-2</code> is currently down and won’t route any traffic to it.</p>
<p>When we upgrade any of the servers, we can just take them down and rely on the LB health checks to ensure clients don’t see too much of a disruption.</p>
<p>When we upgrade an LB, we first have to update DNS to point to the other one.  Then we wait for the update to fully propagate and for all clients to switch to the second LB.  Then we can finally take down the first LB.</p>
<aside>
<p>An even better upgrade scheme would be to reconfigure the network switch to route traffic to one or the other LB.  This avoids messing with DNS, but it requires access to a lower level of networking.</p>
</aside>
<p>The above is a lot of work.  I don’t want to maintain two extra servers, I don’t want to wait for DNS updates to propagate, and I most definitely don’t want a complicated upgrade procedure.  If you’re like me and have a mostly static infrastructure, then we can do away with the separate LB servers and just put an LB on each host.</p>
<div>
  <div>
    <img src="https://scvalex.net/r/82/mesh-lb.png" alt="A load balancer on every host" loading="lazy" height=453 width=625>
  </div>
  <div>
    A load balancer on every host
  </div>
</div>
<p>In this mesh setup, we have an LB on every client and on every server.  When we want to reach a service from any host, we connect to the LB listening on <code>localhost</code> which then proxies the request to the correct server.  The LBs on all the hosts are continuously health-checking each other, so they all know if any servers go down.</p>
<aside>
<p>The main downside of giving N hosts a load balancer each is that we have N configurations to update.  NixOS makes pushing these changes easy and we only need to make them when hosts are replaced or new services are added.</p>
</aside>
</section>
<section>
<h3 id="haproxy"><a href="https://scvalex.net/posts/82/#haproxy">HAProxy</a></h3>
<p>For our load balancer, we’re going to use <a href="https://www.haproxy.org/">HAProxy</a> because it’s been around for a long time, has <a href="https://docs.haproxy.org/3.3/configuration.html#4.2">an absurd number of configuration options</a>, and works flawlessly for what we’re trying to do.  It’s also pretty lightweight using only ~50 MB of memory after running for several days.</p>
<aside>
<p>Why not <a href="https://nginx.org/en/">Nginx</a>?</p>
<p>Nginx is a fine HTTP proxy, but it’s not a general purpose proxy.  It has a lot of features for rewriting requests which we won’t use.  Conversely, it makes setting up TCP proxies with health checks slightly more difficult because that’s not its primary use-case.</p>
<p>It’s also nice to have the public facing server be completely different from the private load balancer.  Our HAProxy definitely won’t allow public access into the internal network because it’s only listening on <code>lo</code>.</p>
</aside>
<aside>
<p>Other options would be <a href="https://kb.linuxvirtualserver.org/wiki/IPVS">IPVS</a> and <a href="https://wiki.nftables.org/wiki-nftables/index.php/Main_Page"><code>nftables</code></a>.  This is in fact what I was using before.  The problem with them is that they’re devilishly hard to debug—every error leads to debugging NAT and packet rewriting rules.  Using a userspace program like HAProxy introduces some overhead, but we get a human-readable log in return.</p>
</aside>
<p>The first problem we were trying to solve was access to the three Kubernetes masters.  These listen on multiple addresses like <code>10.10.0.18:6443</code> and we want them to be reachable with a single address.</p>
<pre><code>global
  log /dev/log local0
  daemon
defaults
  log global
  timeout connect 5s
  timeout client 1m
  timeout server 1m
frontend kube-eu1
  bind 127.0.0.100:6443
  mode tcp
  option tcplog
  use_backend kube-eu1_backend
backend kube-eu1_backend
  balance roundrobin
  mode tcp
  server server0 10.10.0.18:6443 check
  server server1 10.10.0.17:6443 check
  server server2 10.10.0.16:6443 check
</code></pre>
<div>Excerpt from <code>haproxy.conf</code></div>
<p>The backends use TLS, so we use HAProxy’s <code>mode tcp</code> to avoid having to deal with extra certificates.  We also turn on <code>option tcplog</code> to getter more detailed log lines.  Technically, TCP is the default, so we don’t need to bother, but I find this makes the configs more symmetric with the HTTP ones we’ll see later.</p>
<p>The <code>server server0 10.10.0.18:6443 check</code> lines list the backend server addresses.  The <code>check</code> option is what enables live TCP checks.  The configuration for these checks are the <code>timeout</code> options at the beginning of the file.</p>
<p>One interesting bit is the address HAProxy is configured to listen on: <code>127.0.0.<em>100</em>:6443</code>.  This works because, at least on NixOS, the <code>lo</code> interface is configured to have address <code>127.0.0.1/8</code>.  The <a href="https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing"><code>/8</code></a> means that any address which starts with <code>127</code> works, so we pick <code>127.0.0.100</code> to be HAProxy’s address on every host.</p>
<p>Our second problem was getting nice URLs for services running on weird ports.  The example was a Livebook server listening on <code>127.0.0.1:30123</code>.  A nice URL means port 80 and a nice name.  We do port 80 now and leave the nice name for the next section.</p>
<pre><code>frontend http
  bind 127.0.0.100:80
  mode http
  option httplog
  use_backend %[req.hdr(host),lower]

backend livebook.local
  balance roundrobin
  mode http
  server server0 127.0.0.1:30123 check
</code></pre>
<div>Excerpt from <code>haproxy.conf</code></div>
<p>We use <code>mode http</code> and <code>option httplog</code> here to get better logging.</p>
<p>The <code>use_backend %[req.hdr(host),lower]</code> bit tells HAProxy to extract the value of the <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Host"><code>Host</code> header</a>, lowercase it, and look for a backend with the same name.  So, if we type <code>http://livebook.local</code> in a browser, this sets <code>Host: livebook.local</code> in the request, which then makes HAProxy route it to the backend named <code>livebook.local</code>.</p>
<p>We use a similar configuration to access HTTP services in the Kubernetes cluster.  For example, I have my <a href="https://doc.traefik.io/traefik/reference/routing-configuration/kubernetes/gateway-api/">Traefik gateway</a> listening on port 30180.  It’s configured to route requests for <code>warrior.local</code> to the <a href="https://wiki.archiveteam.org/index.php/ArchiveTeam_Warrior">ArchiveTeam Warrior</a> service running somewhere in the cluster.  To redirect requests from any host outside the cluster into it, we add the following to the HAProxy config:</p>
<pre><code>backend warrior.local
  balance roundrobin
  mode http
  server server0 10.10.0.18:30180 check
  server server1 10.10.0.17:30180 check
  server server2 10.10.0.16:30180 check
</code></pre>
<div>Excerpt from <code>haproxy.conf</code></div>
<p>We don’t need another <code>frontend</code> definition because the existing one already selects the <code>backend</code> section using on the <code>Host</code> header.</p>

</section>
<section>
<h3 id="etc-hosts"><a href="https://scvalex.net/posts/82/#etc-hosts"><code>/etc/hosts</code></a></h3>
<p>With HAProxy set up, we can now reach the Kubernetes masters and HTTP services by hitting <code>127.0.0.100:6443</code> and <code>127.0.0.100:80</code> respectively.  Now we need names for these.  We could setup a DNS server, but it’s simpler to use <code>/etc/hosts</code> in this case:</p>
<pre><code>127.0.0.100 kube.eu1 warrior.local livebook.local
</code></pre>
<div>Excerpt from <code>/etc/hosts</code></div>
<p>I know I said earlier that <code>/etc/hosts</code> is weird, but it works as expected as long as no name has more than one IP address assigned to it.  Assigning multiple names to the same address is fine.</p>
<aside>
<p>Do not assign both IPv4 and IPv6 addresses for the same name in <span><code>/etc/hosts</code></span>.  Trust me when I say there is no joy to be found in doing this.</p>
</aside>
<p>With this in place, we can access the Kubernetes master at <code>https://kube.eu1:6443</code>, the local Livebook server at <code>http://livebook.local</code>, and the Warrior in the cluster at <code>http://warrior.local</code>, which is what we set out to do.</p>
</section>
<section>
<h3 id="nixos-module"><a href="https://scvalex.net/posts/82/#nixos-module">NixOS module</a></h3>
<p>The setup is complete, but it’s a bit annoying to have to keep <code>haproxy.conf</code> and <code>/etc/hosts</code> in sync, so I wrote a NixOS module to automate things: <a href="https://codeberg.org/scvalex/infra/src/branch/main/nixos/ab-local-proxy.nix"><code>ab-local-proxy.nix</code></a>.  I am not going to try to upstream this, but you’re welcome to copy it.</p>
<p>Using the module looks like this:</p>
<pre><code>ab<span>.</span>services<span>.</span>localProxy <span>=</span> <span>{</span>
  address <span>=</span> <span>&quot;127.0.0.100&quot;</span><span>;</span>
  tcpMappings <span>=</span> <span>[</span>
    <span>{</span>
      name <span>=</span> <span>&quot;kube-eu1&quot;</span><span>;</span>
      hostname <span>=</span> <span>&quot;kube.eu1&quot;</span><span>;</span>
      port <span>=</span> <span>6443</span><span>;</span>
      backends <span>=</span> <span>[</span><span>&quot;10.10.0.16:6443&quot;</span> <span>&quot;10.10.0.17:6443&quot;</span> <span>&quot;10.10.0.18:6443&quot;</span><span>]</span><span>;</span>
    <span>}</span>
  <span>]</span><span>;</span>
  httpMappings <span>=</span> <span>[</span>
    <span>{</span>
      hostname <span>=</span> <span>&quot;warrior.local&quot;</span><span>;</span>
      backends <span>=</span> <span>[</span><span>&quot;10.10.0.16:30180&quot;</span> <span>&quot;10.10.0.17:30180&quot;</span>  <span>&quot;10.10.0.18:30180&quot;</span><span>]</span><span>;</span>
    <span>}</span>
    <span>{</span>
      hostname <span>=</span> <span>&quot;livebook.local&quot;</span><span>;</span>
      backends <span>=</span> <span>[</span> <span>&quot;127.0.0.1:30123&quot;</span> <span>]</span><span>;</span>
    <span>}</span>
  <span>]</span><span>;</span>
<span>}</span><span>;</span>
</code></pre>
<div>Example usage of <a href="https://codeberg.org/scvalex/infra/src/branch/main/nixos/ab-local-proxy.nix"><code>ab-local-proxy.nix</code></a></div>
</section>
<section>
<h3 id="looking-back"><a href="https://scvalex.net/posts/82/#looking-back">Looking back</a></h3>
<p>The idea of doing something like this has been bouncing around in my head for years, but I balked at the thought of running “a heavy load balancer” on every host.  Instead, I kept trying and failing to get IPVS to work in a nice way.  My mistake was conceptualizing HAProxy as “heavy” when it really isn’t.</p>
<p>I’m currently in the process of cycling the VMs my Kubernetes cluster runs on and it’s so refreshing to see a clear log of “I see this backend going down” and “I see this backend is back up”.  It gives me confidence to make changes at a much faster pace than I would’ve previously allowed myself.</p>
</section>]]>
    </content>
  </entry>
  <entry>
    <title>
<![CDATA[Building the grandma videoconf]]>
    </title>
    <link rel="alternate" type="text/html" href="https://scvalex.net/posts/81/" />
    <id>https://scvalex.net/posts/81/</id>
    <published>2026-04-05T00:00:00Z</published>
    <updated>2026-04-05T00:00:00Z</updated>
    <summary type="html">
<![CDATA[<p>Grandma wants to talk to her grandkids and also see them.  The problem is that grandma cannot interact with modern technology at all.  No keyboard, no mouse, no touchscreen—the solution has to be fully automated.  Let’s build this.</p>
]]>
    </summary>
    <content type="html">
<![CDATA[<section>
<p>Grandma wants to talk to her grandkids and also see them.  The problem is that grandma cannot interact with modern technology at all.  No keyboard, no mouse, no touchscreen—the solution has to be fully automated.  Let’s build this.</p>

<div>
<div>
  <div>
    <a href="https://scvalex.net/r/81/videoconf-front.jpg" target="_blank"><img src="https://scvalex.net/r/81/videoconf-front.jpg" alt="Front view" loading="lazy">
      </a>
  </div>
  <div>
    Front view
  </div>
</div>
<div>
  <div>
    <a href="https://scvalex.net/r/81/videoconf-back.jpg" target="_blank"><img src="https://scvalex.net/r/81/videoconf-back.jpg" alt="Back view" loading="lazy">
      </a>
  </div>
  <div>
    Back view
  </div>
</div>
</div>
</section>
<div id="floating-toc-v4"><section><h3>Contents <a href="https://scvalex.net/posts/81/#">↑ top ↑</a></h3><ul><li><a href="https://scvalex.net/posts/81/#hardware-discussion">Hardware discussion</a>
</li>
<li><a href="https://scvalex.net/posts/81/#hardware-choices">Hardware choices</a>
</li>
<li><a href="https://scvalex.net/posts/81/#base-system">Base system</a>
</li>
<li><a href="https://scvalex.net/posts/81/#networking">Networking</a>
</li>
<li><a href="https://scvalex.net/posts/81/#ssh">SSH</a>
</li>
<li><a href="https://scvalex.net/posts/81/#vnc">VNC</a>
</li>
<li><a href="https://scvalex.net/posts/81/#video-conferencing">Video conferencing</a>
</li>
<li><a href="https://scvalex.net/posts/81/#looking-back">Looking back</a>
</li></ul>
</section>
</div>
<section>
<h3 id="hardware-discussion"><a href="https://scvalex.net/posts/81/#hardware-discussion">Hardware discussion</a></h3>
<p>Like everything else with this project, the hardware has to be outwardly simple.  The ideal would be a tablet, except that modern tablets require far too much user interaction.</p>
<p>For example, <em>the previous solution was an Android tablet with Signal installed</em>.  Grandma’s main problem was that she could not press the smallish “answer” button on incoming calls.  But even if we fixed that with an app that’s more user-friendly than Signal, the other problems would still remain:</p>
<ul>
<li>The tablet sometimes pops ups notification dialogs about updates and other things.  Grandma cannot read them and cannot even see the “Ok” button to dismiss them.</li>
<li>The tablet sometimes has a lag in turning on its screen.  This leads to grandma pressing on the power button for too long until the tablet turns off.  Then the tablet takes minutes to start back up.</li>
<li>Grandma sometimes presses the volume buttons when picking up the tablet and mutes it.  The buttons are then too small for her to see or feel out easily.</li>
</ul>
<p>I considered using a tablet and replacing the stock Android with something else, but everything in the mobile space seems to be very “special” for lack of a better word.  I would rather use the same tooling I use for everything else, so the device we’re building has to a PC.</p>
<aside>
<p><a href="https://postmarketos.org/">PostmarketOS</a> have been doing a great job in making mobile Linux less “special”, especially over the last year.  That said, it’s not quite stable enough for me to be willing to put on a machine I’ll only have access to every six months.</p>
</aside>
<p>The canonical “small PC” is a <a href="https://www.raspberrypi.com/">Raspberry Pi</a>.  The problem with Raspberry Pis is that they’re mobile-adjacent so special in their own ways.  For example, here is <a href="https://wiki.nixos.org/wiki/NixOS_on_ARM/Raspberry_Pi_4">the NixOS Wiki page on the RaspberryPi 4</a>.  It lists a lot of little things you need to configure to get everything working.  It’s a lot of friction.</p>
<p>The bigger problem with Raspberry Pis is that they run off of SD cards by default.  If you leave one turned on for long enough, you discover that the SD card degrades over about 2-3 months to the point where the system won’t boot.  It turns out <code>ext4</code> on an SD card is a bad idea.  You can try using something like <a href="https://en.wikipedia.org/wiki/F2FS">F2FS</a>, but now you have to figure out how to make a bootable SD card with that, so again, friction.</p>
<p>Raspberry Pi 5’s are much better at not being special.  You can even boot them from NVMe flash drives!  But you have to buy a separate NVMe shield, and an NVMe drive, not to mention the power adapter.  Oh, and you probably want a heatsink and a fan too.</p>
</section>
<section>
<h3 id="hardware-choices"><a href="https://scvalex.net/posts/81/#hardware-choices">Hardware choices</a></h3>
<p>If I were building something for myself, a Raspberry Pi 5 is probably what I would use.  However, what we’re building here needs to work without intervention for months at a time, so a “stick” form-factor PC is the way to go.  There aren’t many choices if we want a recent-ish processor, but this <a href="https://www.amazon.co.uk/DreamQuest-Mini-Windows-Preinstalled-Portable/dp/B0G4QF4MMF">DreamQuest one</a> fits the bill.  It features an <a href="https://www.intel.com/content/www/us/en/products/sku/231800/intel-processor-n95-6m-cache-up-to-3-40-ghz/specifications.html">Intel N95</a> released in 2023, 12 GB of RAM, and a SATA SSD.  It has 4 USB type-A ports, 2 type-C ports (one of which is the power port), an Ethernet port, 2 HDMI ports, and even a headphone jack.  Weight-wise, it feels a bit heavier than a mobile phone.</p>
<div>
  <div>
    <img src="https://scvalex.net/r/81/dreamquest.jpg" alt="DreamQuest stick PC compared to a mobile phone" loading="lazy">
  </div>
  <div>
    DreamQuest stick PC compared to a mobile phone
  </div>
</div>
<p>For the monitor, we want something small-ish so that it fits on grandma’s coffee table.  We also want it to be light enough that she can move it around.  I went with the <a href="https://www.raspberrypi.com/products/raspberry-pi-monitor/">Raspberry Pi monitor</a>.  It’s 15.6’ which is a bit big, but it’s USB powered so it doesn’t need an extra power cable, and weighs only about 1 kg.  It has built-in loudspeakers, so that’s one less component to worry about.  It has a flap on the back which means it doesn’t need a dedicated stand.</p>
<aside>
<p>The image quality on the Raspberry Pi monitor is surprisingly good.  At its lowest brightness setting, it kind of looks like a glowing e-ink display.  It obviously isn’t that with how much power it uses, but I could imagine hanging one on a wall in my living room as a sort of status display.</p>
</aside>
<div>
  <div>
    <img src="https://scvalex.net/r/81/rpi-monitor.png" alt="Raspberry Pi monitor" loading="lazy">
  </div>
  <div>
    Raspberry Pi monitor
  </div>
</div>
<p>The remaining components are any USB webcam and a bunch of cables.  I used some I had lying around.  The PC cost £200, the monitor was £100, a new webcam would’ve cost about £50, and let’s say the cables were £50.  This brings the cost of the whole setup to £400 which is about the same as a mid-range tablet.</p>
</section>
<section>
<h3 id="base-system"><a href="https://scvalex.net/posts/81/#base-system">Base system</a></h3>
<p>With the hardware settled, we now turn to software.  As a reminder, the primary goal is for the setup to be completely hands free—grandma can’t interact with the device in any meaningful way, so there must never be a “System update ready. Install?” prompt.  In other words, we need everything to be remotely manageable.</p>
<p>Since I expect I’ll have to build this several times, a secondary goal of mine is for the setup to be repeatable.  As such, we’re using <a href="https://nixos.org/">NixOS</a> and <a href="https://nix-community.github.io/home-manager/">Home Manager</a> because this allows us to configure everything through files.  We do remote deployments with <a href="https://github.com/zhaofengli/colmena">Colmena</a> because it’s what I’m familiar with (not that it matters much since all the NixOS deployment tools are essentially interchangeable).</p>
<aside>
<p>The full source code for this setup is available in <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a>.</p>
<p>The hostname for the videoconf device is <code>dor-qws-vid1</code>.  I will not attempt to explain this naming convention.</p>
</aside>
<p>The full configuration is available <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix">here</a>.  Below, we’re only going to look at the bits that make this device different from a regular desktop.</p>
<p>First, we need to auto-login into a graphical environment.  We do this trivially with <code>getty</code>:</p>
<pre><code>services<span>.</span>getty <span>=</span> <span>{</span>
  autologinUser <span>=</span> <span>&quot;auto&quot;</span><span>;</span>
  autologinOnce <span>=</span> false<span>;</span>
<span>}</span><span>;</span>
environment<span>.</span>loginShellInit <span>=</span> <span>&#39;&#39;
  [[ &quot;$(tty)&quot; == /dev/tty1 ]] &amp;&amp; exec dbus-run-session sway
&#39;&#39;</span><span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<p>For the desktop manager, I picked <a href="https://swaywm.org/">Sway</a> because we can configure it through a single file, it’s well supported, and it’s Wayland so we don’t have to deal with any X11 jank.  The configuration is basically the default one with the status bar commented out, window borders turned off, and some programs started automatically.</p>
<pre><code>programs<span>.</span>sway<span>.</span>enable <span>=</span> true<span>;</span>
home-manager<span>.</span>users<span>.</span>auto <span>=</span>
  <span>{</span> ... <span>}</span>:
  <span>{</span>
    <span># …</span>
    home<span>.</span>file<span>.</span>sway-config <span>=</span> <span>{</span>
      source <span>=</span> <span>./dor-qws-vid1/sway-config</span><span>;</span>
      target <span>=</span> <span>&quot;.config/sway/config&quot;</span><span>;</span>
    <span>}</span><span>;</span>
  <span>}</span><span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<pre><code># … default config

# bar {
# }

default_border none

output * bg /home/auto/Documents/wallpapers/forest-1.jpg fill
exec /run/current-system/sw/bin/wpaperd -d

exec wayvnc 127.0.0.1 5900

include /etc/sway/config.d/*
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1/sway-config"><code>~/.config/sway/config</code></a></div>
<p>We set the background to a nice photo of a forest and we start <a href="https://github.com/danyspin97/wpaperd"><code>wpaperd</code></a> to change it every few minutes.</p>
<pre><code>environment<span>.</span>systemPackages <span>=</span> <span>with</span> pkgs<span>;</span> <span>[</span>
  <span># …</span>
  wpaperd
<span>]</span><span>;</span>
home-manager<span>.</span>users<span>.</span>auto <span>=</span>
  <span>{</span> ... <span>}</span>:
  <span>{</span>
    <span># …</span>
    home<span>.</span>file<span>.</span>wpaperd-config <span>=</span> <span>{</span>
      source <span>=</span> <span>./dor-qws-vid1/wpaperd-config.toml</span><span>;</span>
      target <span>=</span> <span>&quot;.config/wpaperd/config.toml&quot;</span><span>;</span>
    <span>}</span><span>;</span>
  <span>}</span><span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<pre><code><span>[</span>default<span>]</span>
path <span>=</span> <span>&quot;/home/auto/Documents/wallpapers&quot;</span>
duration <span>=</span> <span>&quot;5m&quot;</span>
transition-time <span>=</span> <span>500</span>
</code></pre>
<div><a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1/wpaperd-config.toml"><code>~/.config/wpaperd/config.toml</code></a></div>
<p>Next, we want to be able to control screen brightness programmatically so that grandma doesn’t have to find the physical buttons on the back of the monitor.  The way to do this is with <a href="https://www.ddcutil.com/"><code>ddcutil</code></a> and it’s a bit arcane, but it does work consistently.</p>
<pre><code>environment<span>.</span>systemPackages <span>=</span> <span>with</span> pkgs<span>;</span> <span>[</span>
  <span># …</span>
  ddcutil
<span>]</span><span>;</span>

<span># Brightness control</span>
<span># https://wiki.nixos.org/wiki/Backlight#Via_ddcutil</span>
<span># Min: `ddcutil --bus=0 setvcp 10 0`</span>
<span># Max: `ddcutil --bus=0 setvcp 10 60`</span>
hardware<span>.</span>i2c<span>.</span>enable <span>=</span> true<span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<p>A command like <code>ddcutil --bus=0 setvcp 10 60</code> sets the brightness to 60%.  The <code>--bus</code> parameter has to be determined experimentally, but <code>ddcutil detect</code> can probably guess it.  The <code>setvcp 10</code> bit is the magic code to control brightness.  The last number is the brightness value and it only seems to do anything between 0 and 60 on this screen.</p>
<p>Finally, we want to turn off the screen at night so that it doesn’t disturb grandma’s sleep.  We can do this by sending Sway a command every morning and every evening.</p>
<pre><code><span>let</span>
  swaymsgService <span>=</span>
    <span>{</span> time<span>,</span> cmd <span>}</span>:
    <span>{</span>
      serviceConfig <span>=</span> <span>{</span>
        Type <span>=</span> <span>&quot;oneshot&quot;</span><span>;</span>
        WorkingDirectory <span>=</span> <span>&quot;/home/auto/&quot;</span><span>;</span>
      <span>}</span><span>;</span>
      startAt <span>=</span> <span>&quot;*-*-* <span><span>${</span>time<span>}</span></span>&quot;</span><span>;</span>
      script <span>=</span> <span>&#39;&#39;
        if [[ &quot;$(whoami)&quot; = &quot;auto&quot; ]]; then
          export SWAYSOCK=&quot;$(ls /run/user/1000/sway-*)&quot;
          <span><span>${</span>pkgs<span>.</span>sway<span>}</span></span>/bin/swaymsg &quot;<span><span>${</span>cmd<span>}</span></span>&quot;
        else
          echo &quot;Not auto user&quot;
        fi
      &#39;&#39;</span><span>;</span>
    <span>}</span><span>;</span>
<span>in</span>
<span>{</span>
  <span># …</span>
  <span># Turn off screen during the night</span>
  <span>#</span>
  <span># IMPORTANT: The timers need to be manually `systemctl enable`&#39;d for</span>
  <span># them to actually start.</span>
  systemd<span>.</span>user<span>.</span>services<span>.</span>turn-off-screen <span>=</span> swaymsgService <span>{</span>
    time <span>=</span> <span>&quot;20:00:00&quot;</span><span>;</span>
    cmd <span>=</span> <span>&quot;output * power off&quot;</span><span>;</span>
  <span>}</span><span>;</span>
  systemd<span>.</span>user<span>.</span>services<span>.</span>turn-on-screen <span>=</span> swaymsgService <span>{</span>
    time <span>=</span> <span>&quot;08:00:00&quot;</span><span>;</span>
    cmd <span>=</span> <span>&quot;output * power on&quot;</span><span>;</span>
  <span>}</span><span>;</span>
<span>}</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<aside>
<p>Note that we need <code>SWAYSOCK</code> set in order for <code>swaymsg</code> to work.  The Unix domain socket has a predictable name, so we just guess it.</p>
</aside>
</section>
<section>
<h3 id="networking"><a href="https://scvalex.net/posts/81/#networking">Networking</a></h3>
<p>With the base system in place, we can focus on networking.  The most important requirement here is for the machine to connect to the Internet out of the box and for us to be able to <code>ssh</code> into it regardless of what NAT it’s placed behind.</p>
<p>First, we configure <code>networkd</code> to use the wired connection if it’s available.</p>
<pre><code>networking<span>.</span>useNetworkd <span>=</span> true<span>;</span>
systemd<span>.</span>network<span>.</span>enable <span>=</span> true<span>;</span>
systemd<span>.</span>network<span>.</span>networks<span>.</span><span>&quot;10-lan&quot;</span> <span>=</span> <span>{</span>
  matchConfig<span>.</span>Name <span>=</span> <span>&quot;enp1s0&quot;</span><span>;</span>
  networkConfig<span>.</span>DHCP <span>=</span> <span>&quot;yes&quot;</span><span>;</span>
<span>}</span><span>;</span>
<span># Don&#39;t stall the boot if a cable isn&#39;t connected.</span>
systemd<span>.</span>network<span>.</span>wait-online<span>.</span>enable <span>=</span> false<span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<p>Next, we enable NetworkManager for wireless connectivity.  We have to use <code>nmtui</code> to pre-configure the WiFi password.  This could be done with flat files, but since this part is going to be different for every iteration of this device, I’m not going to bother.</p>
<pre><code><span># Use `nmtui` to configure the Wi-Fi networks.</span>
networking<span>.</span>networkmanager <span>=</span> <span>{</span>
  enable <span>=</span> true<span>;</span>
  wifi <span>=</span> <span>{</span>
    powersave <span>=</span> false<span>;</span>
  <span>}</span><span>;</span>
<span>}</span><span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
</section>
<section>
<h3 id="ssh"><a href="https://scvalex.net/posts/81/#ssh">SSH</a></h3>
<p>Now comes the important bit: we need a way to <code>ssh</code> into this machine.  We could setup a VPN, but in my experience, they tend to break unexpectedly when combined with residential networking.  Instead, we’ll use plain old <code>ssh</code> reverse tunnels.</p>
<p>The way this works is that our videoconf device <code>ssh</code>’s into a “middleman” host.  When we want to <code>ssh</code> into the videoconf, we first <code>ssh</code> into the middleman, then <code>ssh</code> from there into the videoconf.</p>
<p>The OpenSSH option for reverse tunnels is <a href="https://man.openbsd.org/ssh#R~2"><code>-R</code></a>.  We basically want the videoconf to be running <code>ssh -N -R 32022:localhost:2022 middleman</code> at all times.  In this command, 2022 is the videoconf’s <code>ssh</code> port and <code>32022</code> is a random port on the middleman host.</p>
<aside>
<p>I like running OpenSSH on port 2022.  Although, this does not improve security in any way, it does drastically cut down on log noise from script kiddies trying to connect.</p>
</aside>
<p>The key part of the above is “at all times”, so we need several more configuration options:</p>
<ul>
<li>We want to connect even if the middleman host gets rebuilt with a new SSH keypair.  So, we set <code>UserKnownHostsFile</code> to <code>/dev/null</code> and <code>StrictHostKeyChecking</code> to <code>no</code>.</li>
<li>If the videoconf’s connection to the middleman is interrupted in any way, we want to close the tunnel and then recreate it.  The videoconf can detect this happening with the <code>ServerAliveInterval</code> and <code>ServerAliveCountMax</code> options.</li>
<li>We also want the middleman host to detect if the connection drops, so we add <code>ClientAliveInterval</code> and <code>ClientAliveCountMax</code> to its OpenSSH config.</li>
<li>We want the <code>ssh</code> command to exit if the tunnel fails somehow (e.g. if the port is already bound on the middleman host).  This is what the <code>ExitOnForwardFailure</code> option does.</li>
</ul>
<p>So, the full command looks like:</p>
<pre><code># ssh \
    -o &quot;UserKnownHostsFile /dev/null&quot; \
    -o &quot;StrictHostKeyChecking no&quot; \
    -o &quot;ServerAliveInterval 30&quot; \
    -o &quot;ServerAliveCountMax 3&quot; \
    -o &quot;ExitOnForwardFailure yes&quot; \
    -o &quot;ControlMaster no&quot; \
    -N \
    -i /root/.ssh/id_ed25519 \
    -p 2022 \
    -R 32022:localhost:2022 \
    mid@mid.abstractbinary.org
</code></pre>
<div>This is the ultimate <code>ssh</code> configuration.<br>We'll be using the <a href="https://search.nixos.org/options?channel=unstable&amp;query=services.autossh-ng"><code>autossh-ng</code></a> module do this automatically.</div>
<p>Putting this all together, we first add a new <code>mid</code> user to the middleman host:</p>
<pre><code>users<span>.</span>users<span>.</span>mid <span>=</span> <span>{</span>
  isSystemUser <span>=</span> true<span>;</span>
  createHome <span>=</span> true<span>;</span>
  home <span>=</span> <span>&quot;/home/mid&quot;</span><span>;</span>
  group <span>=</span> <span>&quot;mid&quot;</span><span>;</span>
  openssh<span>.</span>authorizedKeys<span>.</span>keyFiles <span>=</span>
    config<span>.</span>users<span>.</span>users<span>.</span>root<span>.</span>openssh<span>.</span>authorizedKeys<span>.</span>keyFiles <span>++</span> cfg<span>.</span>extraKeyFiles<span>;</span>

  <span># Unless set, the default shell is `nologin` which allows tunnels</span>
  <span># to be formed, but doesn&#39;t allow the user to login or run commands.</span>
  <span># shell = &quot;${pkgs.coreutils}/bin/true&quot;;</span>
<span>}</span><span>;</span>
users<span>.</span>groups<span>.</span>mid <span>=</span> <span>{</span> <span>}</span><span>;</span>
services<span>.</span>openssh<span>.</span>settings <span>=</span> <span>{</span>
  ClientAliveInterval <span>=</span> <span>30</span><span>;</span>
  ClientAliveCountMax <span>=</span> <span>3</span><span>;</span>
<span>}</span><span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/nixos/ab-middleman.nix"><code>ab-middleman.nix</code></a></div>
<p>Then, we configure <a href="https://search.nixos.org/options?channel=unstable&amp;query=services.autossh-ng"><code>services.autossh-ng</code></a> on the videoconf to create the tunnel at boot and recreate it when it fails:</p>
<aside>
<p>The <code>autossh-ng</code> service was only added to NixOS in 26.05 which is not out yet at the time of writing.  We can still use it by <a href="https://nixos.org/manual/nixos/unstable/#sec-replace-modules">importing the module from the <code>nixos-unstable</code> branch</a>.</p>
</aside>
<pre><code>services<span>.</span>autossh-ng<span>.</span>sessions <span>=</span> <span>{</span>
  <span># …</span>
  forward-ssh <span>=</span> <span>{</span>
    user <span>=</span> <span>&quot;root&quot;</span><span>;</span>
    destination <span>=</span> <span>&quot;mid@mid.abstractbinary.org&quot;</span><span>;</span>
    extraArguments <span>=</span> <span>&quot;-i /root/.ssh/id_ed25519 -p 2022 -o \&quot;ControlMaster no\&quot; -R 32022:localhost:2022&quot;</span><span>;</span>
    hostKeyChecking <span>=</span> false<span>;</span>
    knownHostsFile <span>=</span> <span>&quot;/dev/null&quot;</span><span>;</span>
  <span>}</span><span>;</span>
<span>}</span><span>;</span>
</code></pre>
<p>Finally, we use the <a href="https://man.openbsd.org/ssh_config#ProxyJump"><code>ProxyJump</code></a> setting in our <code>~/.ssh/config</code> to tell SSH to go via the middleman:</p>
<pre><code>Host via-mid-dor-qws-vid1
  ProxyJump mid.abstractbinary.org:2022
  HostName localhost
  Port 32022
  User root
</code></pre>
<div><code>~/.ssh/config</code></div>
<aside>
<p>Note that the <code>HostName</code> is <code>localhost</code> in the settings.  This is because we’re <code>ssh</code>’ing into the middleman host, then into tunnel that is bound to <code>localhost</code> on it.</p>
</aside>
<p>Now, we can just use <code>ssh via-mid-dor-qws-vid1</code> to connect to the videoconf.  The fact that this is going through an intermediate host is abstracted from us.</p>
</section>
<section>
<h3 id="vnc"><a href="https://scvalex.net/posts/81/#vnc">VNC</a></h3>
<p>With the above in place, we can reliably <code>ssh</code> into the videoconf.  Strictly speaking, this is enough, but I’ve done enough tech support for older relatives to know that it is very useful to be able to see their screens.</p>
<p>Our two options are VNC and Remote Desktop.  The later is the more modern protocol and generally works better over bad connections, but all the RDP servers available in NixOS seem like a hassle to setup.  So, we just use <a href="https://github.com/any1/wayvnc"><code>wayvnc</code></a>.</p>
<p>It’s so easy to setup that we’ve already started it in the Sway section:</p>
<pre><code>exec wayvnc 127.0.0.1 5900
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1/sway-config"><code>~/.config/sway/config</code></a></div>
<p>With the above, <code>wayvnc</code> is listening on port 5900 on <code>localhost</code> on the videoconf.  To access this port, we setup another SSH reverse tunnel.  It’s the same code as in the previous section, except with 5900 instead of 2022:</p>
<pre><code>services<span>.</span>autossh-ng<span>.</span>sessions <span>=</span> <span>{</span>
  <span># …</span>
  forward-vnc <span>=</span> <span>{</span>
    user <span>=</span> <span>&quot;root&quot;</span><span>;</span>
    destination <span>=</span> <span>&quot;mid@mid.abstractbinary.org&quot;</span><span>;</span>
    extraArguments <span>=</span> <span>&quot;-i /root/.ssh/id_ed25519 -p 2022 -o \&quot;ControlMaster no\&quot; -R 35900:localhost:5900&quot;</span><span>;</span>
    hostKeyChecking <span>=</span> false<span>;</span>
    knownHostsFile <span>=</span> <span>&quot;/dev/null&quot;</span><span>;</span>
  <span>}</span><span>;</span>
<span>}</span><span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<p>Now, we can just use a VNC client like <a href="https://apps.kde.org/krdc/">KRDC</a> to connect.  The important KRDC settings are “Connect via SSH tunnel” and “Tunnel via loopback address”.</p>
<div>
  <div>
    <img src="https://scvalex.net/r/81/krdc.png" alt="KRDC settings" loading="lazy">
  </div>
  <div>
    KRDC settings
  </div>
</div>
</section>
<section>
<h3 id="video-conferencing"><a href="https://scvalex.net/posts/81/#video-conferencing">Video conferencing</a></h3>
<p>Finally, we get to the actual video conferencing bit.  We use <a href="https://meet.jit.si">Jitsi Meet</a> because it has never let me down and it runs in a web browser.</p>
<p>As a reminder, the solution we’re building has to be fully automatic.  The flow “we call grandma and grandma presses button to answer” doesn’t work because grandma can’t reliably press a button.</p>
<p>Instead, the plan is to <code>ssh</code> into the videoconf and run a <a href="https://www.selenium.dev/">Selenium</a> script that starts up Firefox, goes to Jitsi Meet, and joins a specific meeting.</p>
<aside>
<p>This setup effectively makes the videoconf into a spyware device because we can turn on the camera without grandma actually knowing.  This is easily mitigated by explaining to grandma that she should put a handkerchief over the webcam when she’s not talking to someone.</p>
</aside>
<p>At the system level, we need Firefox, <a href="https://firefox-source-docs.mozilla.org/testing/geckodriver/index.html"><code>geckodriver</code></a>, and Selenium installed:</p>
<pre><code>environment<span>.</span>systemPackages <span>=</span> <span>with</span> pkgs<span>;</span> <span>[</span>
  <span># …</span>
  geckodriver
  <span>(</span>python3<span>.</span>withPackages <span>(</span>
    python-pkgs: <span>with</span> python-pkgs<span>;</span> <span>[</span>
      selenium
    <span>]</span>
  <span>)</span><span>)</span>
<span>]</span><span>;</span>
programs<span>.</span>firefox<span>.</span>enable <span>=</span> true<span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<p>Then, we write a simple script to join a meeting.  The only trickyness is that we need <code>WAYLAND_DISPLAY</code> set for Firefox to actually start, and we need to configure some permissions in the profile to allow webcam and microphone access.</p>
<pre><code><span>#!/usr/bin/env python3</span>

<span>from</span> selenium <span>import</span> webdriver
<span>from</span> selenium.webdriver.common.by <span>import</span> By
<span>import</span> os
<span>import</span> argparse

<span>def</span> main():
    parser = argparse.<span>ArgumentParser</span>(<span>&quot;join-jitsi-meeting&quot;</span>)
    parser.<span>add_argument</span>(<span>&quot;meeting&quot;</span>, help=<span>&quot;Meeting code to join&quot;</span>)
    args = parser.<span>parse_args</span>()
    print(<span>&quot;Connecting to jitsi meeting &#39;%s&#39;…&quot;</span> % (args.<span>meeting</span>,))

    os.<span>environ</span>[<span>&quot;WAYLAND_DISPLAY&quot;</span>] = <span>&quot;wayland-1&quot;</span>
    options = webdriver.<span>FirefoxOptions</span>()
    options.<span>args</span> = [<span>&quot;-profile&quot;</span>, <span>&quot;/home/auto/.config/mozilla/firefox/zfegmmhu.default/&quot;</span>]
    options.<span>preferences</span>[<span>&quot;media.navigator.enabled&quot;</span>] = <span>True</span>
    options.<span>preferences</span>[<span>&quot;permissions.default.microphone&quot;</span>] = <span>1</span>
    options.<span>preferences</span>[<span>&quot;permissions.default.camera&quot;</span>] = <span>1</span>
    <span># options.binary_location = &quot;/run/current-system/sw/bin/firefox&quot;</span>
    driver = webdriver.<span>Firefox</span>(options=options)

    driver.<span>get</span>(<span>&quot;https://meet.jit.si&quot;</span>)

    <span># https://www.selenium.dev/documentation/webdriver/elements/</span>

    driver.<span>implicitly_wait</span>(<span>1.0</span>)
    room_text_box = driver.<span>find_element</span>(by=By.<span>ID</span>, value=<span>&quot;enter_room_field&quot;</span>)
    enter_room_button = driver.<span>find_element</span>(by=By.<span>ID</span>, value=<span>&quot;enter_room_button&quot;</span>)
    room_text_box.<span>send_keys</span>(args.<span>meeting</span>)
    enter_room_button.<span>click</span>()

    driver.<span>implicitly_wait</span>(<span>1.0</span>)
    name_text_box = driver.<span>find_element</span>(by=By.<span>ID</span>, value=<span>&quot;premeeting-name-input&quot;</span>)
    join_button = driver.<span>find_element</span>(by=By.<span>XPATH</span>, value=<span>&quot;//div[@aria-label=&#39;Join meeting&#39;]&quot;</span>)
    name_text_box.<span>send_keys</span>(<span>&quot;Petra&quot;</span>)
    join_button.<span>click</span>()

<span>if</span> __name__ == <span>&quot;__main__&quot;</span>:
    main()
</code></pre>
<div><a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1/join-jitsi-meeting.py"><code>join-jitsi-meeting.py</code></a></div>
<p>This script will likely break whenever Jitsi change their website, but we have it deployed through Home Manager, so it will be easy to fix:</p>
<pre><code>home-manager<span>.</span>users<span>.</span>auto <span>=</span>
  <span>{</span> ... <span>}</span>:
  <span>{</span>
    <span># …</span>
    home<span>.</span>file<span>.</span>join_jitsi_meeting <span>=</span> <span>{</span>
      source <span>=</span> <span>./dor-qws-vid1/join-jitsi-meeting.py</span><span>;</span>
      target <span>=</span> <span>&quot;scripts/join-jitsi-meeting.py&quot;</span><span>;</span>
    <span>}</span><span>;</span>
  <span>}</span><span>;</span>
</code></pre>
<div>Excerpt from <a href="https://codeberg.org/scvalex/infra/src/branch/main/hosts/dor-qws-vid1.nix"><code>dor-qws-vid1.nix</code></a></div>
<p>The last piece of the puzzle is to make the videoconf ring.  I just grabbed a phone ring sound file from <a href="https://pixabay.com/sound-effects/search/phone%20ring/">Pixabay</a> and we can play it with <code>paplay telephone-ring.ogg</code>.</p>
</section>
<section>
<h3 id="looking-back"><a href="https://scvalex.net/posts/81/#looking-back">Looking back</a></h3>
<p>That’s it—we built a video conferencing device for grandma.  We can manage it remotely, see its screen, and do video calls, all without grandma having to press any buttons.</p>
</section>]]>
    </content>
  </entry>
  <entry>
    <title>
<![CDATA[Combining sine waves]]>
    </title>
    <link rel="alternate" type="text/html" href="https://scvalex.net/posts/80/" />
    <id>https://scvalex.net/posts/80/</id>
    <published>2026-02-18T00:00:00Z</published>
    <updated>2026-02-18T00:00:00Z</updated>
    <summary type="html">
<![CDATA[<p>In <a href="https://scvalex.net/posts/78/">the first post in this series</a>, we generated sine waves.  In this post, we combine sine waves together to <em>explore harmonics, dissonance, and the 12 tone system used in Western music</em>.</p>
]]>
    </summary>
    <content type="html">
<![CDATA[<section>
<p>In <a href="https://scvalex.net/posts/78/">the first post in this series</a>, we generated sine waves.  In this post, we combine sine waves together to <em>explore harmonics, dissonance, and the 12 tone system used in Western music</em>.</p>

<aside>
<p>This post is part of a series on <a href="https://scvalex.net/posts/audio/">#audio</a>:</p>
<ul>
<li><a href="https://scvalex.net/posts/78/">Making sounds with WebAssembly</a></li>
<li><span>Combining sine waves</span></li>
<li><a href="https://scvalex.net/posts/83/">What the hell is a decibel?</a></li>
</ul>
</aside>
<aside>
<p>The full code for this post is available <a href="https://codeberg.org/scvalex/sound-stuff">here</a>.  The interesting files are:</p>
<ul>
<li><a href="https://codeberg.org/scvalex/sound-stuff/src/branch/main/src/sine_gen.rs"><code>sine_gen.rs</code></a></li>
<li><a href="https://codeberg.org/scvalex/sound-stuff/src/branch/main/src/note_board.rs"><code>note_board.rs</code></a></li>
</ul>
</aside>
</section>
<div id="floating-toc-v4"><section><h3>Contents <a href="https://scvalex.net/posts/80/#">↑ top ↑</a></h3><ul><li><a href="https://scvalex.net/posts/80/#waveform-graphs">Waveform graphs</a>
</li>
<li><a href="https://scvalex.net/posts/80/#harmonics">Harmonics</a>
</li>
<li><a href="https://scvalex.net/posts/80/#mixing-sounds">Mixing sounds</a>
</li>
<li><a href="https://scvalex.net/posts/80/#dissonance">Dissonance</a>
</li>
<li><a href="https://scvalex.net/posts/80/#12-tones">12 tones</a>
</li>
<li><a href="https://scvalex.net/posts/80/#next-up">Next up</a>
</li></ul>
</section>
</div>
<section>
<h3 id="waveform-graphs"><a href="https://scvalex.net/posts/80/#waveform-graphs">Waveform graphs</a></h3>
<p>As a reminder, our model of a loudspeaker is a device that takes floating point numbers in the <code>[-1.0, 1.0]</code> range as inputs.  Sending it <code>-1.0</code> pulls the membrane as far as it goes, and sending it <code>+1.0</code> pushes the membrane as far as possible.  We produce sound by pushing and pulling the membrane.</p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API">Web Audio</a> defaults to a sample rate of 48,000, so we need to send that many floats every second.  For performance reasons, instead of sending the numbers one by one, we send buffers of many numbers a few times a second.  We’re using buffers of 1200 samples or 0.025 seconds of sound in these examples.</p>
<p>An example buffer would look like this, except with far more numbers:</p>
<pre><code><span>[</span><span>0.0</span><span>,</span> <span>0.38</span><span>,</span> <span>0.71</span><span>,</span> <span>0.92</span><span>,</span> <span>1.0</span><span>,</span> <span>0.92</span><span>,</span> <span>0.71</span><span>,</span> <span>0.38</span><span>,</span> <span>0.0</span><span>,</span> -<span>0.38</span><span>,</span> ..<span>]</span>
</code></pre>
<p><em>If we were to just plot these membrane-position numbers, we’d get a <a href="https://en.wikipedia.org/wiki/Waveform">waveform graph</a>.</em>  Below is the sine wave generator from the previous post, except now we also show the waveform it is generating.</p>
<div>
  <noscript>
    This demo is a little WASM program.  JavaScript needs to be enabled for it to work.
  </noscript>
  <canvas id="canvas-waveform-vis" width=610 height=220 style="width: 610px; height: 220px;"></canvas>
</div>
<aside>
<p>If there are any discontinuities in the waveform, that’s just a display artifact.  It turns out to be surprisingly tricky to draw 1200 floats on a 600x200 pixel canvas without them overlapping in weird ways and without leaving gaps between them.</p>
</aside>
<p>The points in the displayed waveform are the actual values sent to the speaker, so this is not some sort of idealized representation.</p>
<p>If we increase the frequency of the sine wave, we get more ups-and-downs.  Decrease the frequency and we get fewer ups-and-downs.  Change the volume and we see the waveform’s height change in response (or more likely, the scale of the graph change).</p>
</section>
<section>
<h3 id="harmonics"><a href="https://scvalex.net/posts/80/#harmonics">Harmonics</a></h3>
<p>Sine waves sound clean, but also unnatural.  We will never hear a sine wave in nature—it’s just too idealized.  That said, something does come close: a vibrating string produces a sine wave at a base frequency and then other weaker sine waves at integer-multiple frequencies.</p>
<p>For example, plucking a string tuned to 220 Hz produces a sine wave at 220 Hz, then increasingly weaker ones at 440 Hz, 660 Hz, 880 Hz, and so on.  <em>These integer-multiple frequencies are called <a href="https://en.wikipedia.org/wiki/Harmonic">harmonics</a>.</em>  We start counting at one, so 220 Hz is the first harmonic, 440 Hz is the second, and so on.</p>
<aside>
<p>Bear in mind that this is the <a href="https://en.wikipedia.org/wiki/Spherical_cow">spherical cow model</a> of a vibrating string.</p>
<p>For example, a real violin string makes a much more complex sound because the violin’s wooden body absorbs some frequencies and amplifies others, adds time-delayed reverb, and has a bit of a drum effect.  Also, violin players do more than just pluck strings.</p>
</aside>
<p>Below is our sine wave generator, but with harmonics enabled:</p>
<div>
  <noscript>
    This demo is a little WASM program.  JavaScript needs to be enabled for it to work.
  </noscript>
  <canvas id="canvas-harmonics" width=610 height=220 style="width: 610px; height: 220px;"></canvas>
</div>
<p>If we turn off the first harmonic and activate just the second, we see that it’s just a sine wave with twice as many ups-and-downs.  Let’s activate both first and second harmonics and look at the result.</p>
<p>When we add them together, they’re both going up initially, so the resulting wave goes up faster.  The second harmonic then starts going down while the first is still going up, so they fight each other, and the result doesn’t reach as high.  Both then go down, leading to a faster down curve.  Then the second starts going up while the first is still going down and this creates a hump.  Both then go down at the same time.  This process creates the asymmetrical big-hump-little-hump pattern.  Adding more harmonics adds more humps.</p>
<p>More harmonics make the sound “fuller”.  Activating <em>both even and odd harmonics</em> makes it sound like an <em>ideal vibrating string</em>.  Activating <em>just the odd-numbered harmonics</em> makes it sound like an <em>ideal wind instrument</em>.  It also makes the waveform look like a bunch of cats waiting to be fed.</p>
<aside>
<p>Turning on many harmonics in this simulation also gives the sound a “droning” quality.  I think this is because the harmonics are all far too strong.  In a real string, the second one would be something like 20% as strong as the first.  The third would then be 5% as strong as the first.  This makes them hard to see on a waveform graph, so the simulation here just makes the strength of each higher harmonic be 90%, 80%, 70%, etc. of the first.</p>
</aside>
</section>
<section>
<h3 id="mixing-sounds"><a href="https://scvalex.net/posts/80/#mixing-sounds">Mixing sounds</a></h3>
<p>We’re talking about combining sine waves, but this begs the question: how do we actually implement adding two sounds together?</p>
<p>Suppose we have the buffer for a sine wave:</p>
<pre><code><span>[</span><span>0.0</span><span>,</span> <span>0.38</span><span>,</span> <span>0.71</span><span>,</span> <span>0.92</span><span>,</span> <span>1.0</span><span>,</span> <span>0.92</span><span>,</span> <span>0.71</span><span>,</span> <span>0.38</span><span>,</span> <span>0.0</span><span>,</span> ..<span>]</span>
</code></pre>
<p>We want to add its second harmonic to it:</p>
<pre><code><span>[</span><span>0.0</span><span>,</span> <span>0.71</span><span>,</span> <span>1.0</span><span>,</span> <span>0.71</span><span>,</span> <span>0.0</span><span>,</span> -<span>0.71</span><span>,</span> -<span>1.0</span><span>,</span> -<span>0.71</span><span>,</span> <span>0.0</span><span>,</span> ..<span>]</span>
</code></pre>
<p>How do we combine these two buffers into one?  In these examples, we do the simple thing and <em>just average them out</em>:</p>
<pre><code><span>[</span><span>0.0</span><span>,</span> <span>0.54</span><span>,</span> <span>0.85</span><span>,</span> <span>0.82</span><span>,</span> <span>0.5</span><span>,</span> <span>0.11</span><span>,</span> -<span>0.15</span><span>,</span> -<span>0.16</span><span>,</span> <span>0.0</span><span>]</span>
</code></pre>
<p>Adding the numbers pair-wise has the property that opposite motions of the speaker membrane cancel each other out as they should.  The problem with addition is that we might get numbers outside the <code>[-1.0, 1.0]</code> range, so we need to scale down by some factor.  We use the arithmetic average because it’s easy to code and easy to reason about.</p>
<p>Averaging sounds like this isn’t “correct” in that it usually makes the result sound quieter than it should.  Fixing this isn’t easy because the human ear is the product of billions of years of evolution.  It is excellent at hearing the big cat sneak up on you in the tall grass, but it is only mediocre at hearing tones accurately.  It perceives sound volume non-linearly and worse yet, it interprets high frequencies as louder than low frequencies.  If you want to learn more about this, the keyword to search for is <a href="https://www.izotope.com/en/learn/what-are-lufs">“LUFS”</a>.</p>
</section>
<section>
<h3 id="dissonance"><a href="https://scvalex.net/posts/80/#dissonance">Dissonance</a></h3>
<p>On the topic of the human ear evolving to do things other than hearing tones properly, there are certain combinations of sounds we all find irritating.  We call this dissonance.  It’s easier to show than to explain, so here are two of our sine wave generators:</p>
<aside>
<p><a href="https://en.wikipedia.org/wiki/Consonance_and_dissonance">Consonance and dissonance</a> are complex topics.  Some of it is physiological like I’m describing in this post and some of it is cultural (similar to how we all know swear words are bad).  I personally think the physiological component is the biggest one and this is supported by observing that all systems of music have evolved in ways that try to avoid it.</p>
</aside>
<div>
  <noscript>
    This demo is a little WASM program.  JavaScript needs to be enabled for it to work.
  </noscript>
  <canvas id="canvas-dissonance" width=610 height=280 style="width: 610px; height: 280px;"></canvas>
</div>
<p>If we press both play buttons above, we hear two sine waves at the same time.  The frequencies 294 and 440 Hz have a ratio of 1.5.  This is the first time we’ve heard two sines that are <em>not an integer multiple of each other</em>.  The combination sounds pretty good.</p>
<p>Now let’s lower the 440 towards 294.  We notice two things:</p>
<ul>
<li>The waveform graph start showing a repeating inflate-deflate pattern.  This becomes really obvious at around 320.</li>
<li>The sound develops a “beating” quality to it.  The closer the frequencies of the two sines, the slower the beating becomes.  I personally really don’t like the combination of 294 with 290, 295, or 300.</li>
</ul>
<p>Now let’s activate harmonics 1 through 4 on both generators.  Every additional harmonic adds another higher frequency beating to the combined sound making it more irritating.</p>
<p>Now let’s increase the frequency of the second generator back up to 440.  The beating goes away for the most part and the combination sounds fine.  I wouldn’t exactly call it pleasant sounding, but I also wouldn’t call it irritating.</p>
<p>It’s also interesting to hear how the “niceness” of the sound doesn’t simply increase linearly with the frequency difference.  For example, I like 294+440 much more than 294+430 or 294+450.  What’s going on here is that the 3<sup>rd</sup> harmonic of 294 is at 882 Hz and the 2<sup>nd</sup> harmonic of 430 is at 860 Hz and that’s close enough together that it creates the irritating beating sound.  This effect goes away if we turn off the 3rd harmonic in the generators.</p>
<p>The short of it is that <em>we don’t like hearing combinations of sine waves at very close frequencies</em>.  It’s also the case that <em>we don’t like it when combinations have harmonics at very close frequencies</em>. <a href="https://www.mpi.nl/world/materials/publications/levelt/Plomp_Levelt_Tonal_1965.pdf">Studies</a> have found that this is pretty universal among humans.</p>
<p>That’s all we’re going to say about dissonance here.  If you want to learn more, Minute Physics has an excellent video on <a href="https://nebula.tv/videos/minutephysics-the-physics-of-dissonance">The Physics of Dissonance</a> (<a href="https://www.youtube.com/watch?v=tCsl6ZcY9ag">YT link</a>).</p>
</section>
<section>
<h3 id="12-tones"><a href="https://scvalex.net/posts/80/#12-tones">12 tones</a></h3>
<p>We’ve been identifying sine waves by their frequencies.  This is precise, but it’s awkward in the same way that talking about colors using their RGB hexcodes is awkward.  As in, we can probably figure out what <code>#0069a8</code> and <code>#53eafd</code> are, but it’s much clearer if we say “blue” and “light blue”.  The words also tell us something about the relationship between the two that is not obvious from the hexcodes.</p>
<p>Instead of frequencies, let’s start using <a href="https://en.wikipedia.org/wiki/Scientific_pitch_notation">scientific pitch notation</a>.  This is what most people and tools have been using for the last century.</p>
<p>First, we split the entire sound spectrum into ranges such that <em>each range is twice as large as the previous one</em>.  For historical reasons, these ranges are called <a href="https://en.wikipedia.org/wiki/Octave"><em>octaves</em></a>.  In the simulator below, the columns are the octaves.  For example, octave 3 spans 130.81 Hz to 261.63 Hz and octave 4 spans 261.63 Hz to 523.25 Hz.</p>
<aside>
<p>Western music theory and notation doesn’t “make sense” in the same way in which English doesn’t make sense.  They’re both languages that evolved naturally and haphazardly over the centuries.</p>
<p>Asking “why is it called an octave if there are seven notes and twelve tones” is like asking “why is it called December if it’s the twelfth month”.  The answers are historical factoids rather rational explanations.  Funnily enough, they’re similar historical factoids, but we won’t get into that here.</p>
</aside>
<p>Next, we chop each octave into <a href="https://en.wikipedia.org/wiki/12_equal_temperament"><em>12 equally large parts</em></a>.  For historical reasons, we call these <em>semitones</em>.  Since each octave is twice as large as the previous one, we have to do the split into semitones geometrically as well.  So, the frequency of each semitone is <math display="inline"><mroot><mrow><mn>2</mn></mrow><mn>12</mn></mroot></math> times bigger than the previous one.  The semitones are the rows in the simulator below.</p>
<p>Next, we give each semitone a name.  For historical reasons, some of these are written as “notes”: <em>C, D, E, F, G, A, and B</em>.  The others are written as “notes with accidentals”: <em>C#, D#, F#, G#, and A#</em>.</p>
<p>Finally, we can uniquely identify any tone by its note, accidental if present, and octave.  Some important tones are:</p>
<ul>
<li>C4 at 261.63 Hz, also called “middle C” because it’s the middle key on a piano,</li>
<li>A4 at 440 Hz, which is the reference frequency and also the A string on a violin, and</li>
<li>A#4 at 466.16 Hz, which is the “trust me bro, I can tune a violin by ear and this is definitely what A4 sounds like” tone.</li>
</ul>
<div>
  <noscript>
    This demo is a little WASM program.  JavaScript needs to be enabled for it to work.
  </noscript>
  <canvas id="canvas-note-board" width=560 height=590 style="width: 560px; height: 590px;"></canvas>
</div>
<aside>
<p>To generate the table of tones, we start with <a href="https://en.wikipedia.org/wiki/A440_(pitch_standard)">A4=440 Hz</a> and then go up and down multiplying by <math display="inline"><mroot><mrow><mn>2</mn></mrow><mn>12</mn></mroot></math>(<a href="https://codeberg.org/scvalex/sound-stuff/src/branch/main/src/note.rs#L43">code</a>).</p>
<p>The frequency of A4 is set by <a href="https://www.iso.org/standard/3601.html">ISO 16</a>, but despite all the 4 digits in “440”, there’s nothing special about the number.  Other values have been used in the past and some orchestras still use slightly different numbers.  The only thing that matters is that the ratio between note frequencies is preserved and that everybody playing at the same time agrees on the root frequency.</p>
</aside>
<p>Most of the above notation is arbitrary—the only fundamental aspect is that since string and wind harmonics are multiples of each other, everything else also has to be in terms of multiples.  As we saw in the section on dissonance, it’s very easy to get two sine waves that sound irritating when played together, especially when harmonics are involved.  So, I’d say the primary purpose of music notation is to ensure everybody is playing the same frequencies and no dissonance slips in accidentally.</p>
<p>That said, splitting every octave into 12 semitones has some nice properties.  For example, I knew before writing the dissonance section that 294 and 440 Hz would sound good together because they’re D4 and A4 which are a <a href="https://en.wikipedia.org/wiki/Perfect_fifth">“perfect fifth”</a>.  <a href="https://www.chordatlas.com/chord-types/fifth">Every pair</a> of notes that are a fifth apart sounds good.</p>
</section>
<section>
<h3 id="next-up"><a href="https://scvalex.net/posts/80/#next-up">Next up</a></h3>
<p>That’s it for now.  In this post, we visualized sounds as waveform graphs, we introduced the idea of harmonics to talk about real strings, we saw how some combinations of sine waves sound dissonant, and we introduced a language to make talking about all this easier.</p>
<p>In other words, this post was all about combining sine waves in different ways.  Soon, we’ll do the opposite and see how any sound can be decomposed into sine waves through the Fourier transform.</p>
<p>But first, we need to <a href="https://scvalex.net/posts/83/">understand what decibels are</a> because they’ll came up as the unit of measurement on every graph going forward.</p>
<aside>
<p>Thanks to <a href="https://enpas.org">Max Staudt</a> and <a href="https://mazzo.li">Francesco Mazzoli</a> for reading through the drafts of this post.</p>
</aside>
<script type="module">
  import init, {
      run_dissonance,
      run_harmonics,
      run_note_board,
      run_waveform_vis,
  } from "/r/b/sound_stuff.js";

  async function run() {
    await init();

    run_waveform_vis("canvas-waveform-vis");
    run_harmonics("canvas-harmonics");
    run_dissonance("canvas-dissonance");
    run_note_board("canvas-note-board");
  }

  run();
</script></section>]]>
    </content>
  </entry>
</feed>
