diff options
Diffstat (limited to 'content/blog')
| -rw-r--r-- | content/blog/_index.md | 6 | ||||
| -rw-r--r-- | content/blog/blog.css | 99 | ||||
| -rw-r--r-- | content/blog/rockbox_stats/index.md | 547 | ||||
| -rw-r--r-- | content/blog/rockbox_stats/log-setting.bmp | bin | 0 -> 153666 bytes | |||
| -rw-r--r-- | content/blog/rockbox_stats/playback-settings.bmp | bin | 0 -> 153666 bytes | |||
| -rw-r--r-- | content/blog/rockbox_stats/player.bmp | bin | 0 -> 153666 bytes | |||
| -rw-r--r-- | content/blog/terminal_renderer_mkii/cover.png | bin | 0 -> 6038269 bytes | |||
| -rw-r--r-- | content/blog/terminal_renderer_mkii/david.png | bin | 0 -> 27218 bytes | |||
| -rw-r--r-- | content/blog/terminal_renderer_mkii/davidbayer.png | bin | 0 -> 4213 bytes | |||
| -rw-r--r-- | content/blog/terminal_renderer_mkii/davidthreshold.png | bin | 0 -> 3324 bytes | |||
| -rw-r--r-- | content/blog/terminal_renderer_mkii/index.md | 167 | ||||
| -rw-r--r-- | content/blog/vscode_buttplug/buttplug-0.0.1.vsix | bin | 0 -> 632364 bytes | |||
| -rw-r--r-- | content/blog/vscode_buttplug/index.md | 63 |
13 files changed, 882 insertions, 0 deletions
diff --git a/content/blog/_index.md b/content/blog/_index.md new file mode 100644 index 0000000..44bbfa0 --- /dev/null +++ b/content/blog/_index.md @@ -0,0 +1,6 @@ ++++ +title = "soaos blog" +template = "blog.html" +page_template = "post.html" +sort_by = "date" ++++
\ No newline at end of file diff --git a/content/blog/blog.css b/content/blog/blog.css new file mode 100644 index 0000000..372964d --- /dev/null +++ b/content/blog/blog.css @@ -0,0 +1,99 @@ +html { + background: url("/assets/bg.jpg"); + background-attachment: fixed; + background-size: cover; + image-rendering: pixelated; +} + +figure { + background-color: var(--bg0); + margin: 0; + margin-bottom: 1em; +} + +figure > img, figure > * > img, figure > svg, figure > * svg { + background-color: var(--bg-dim); +} + +.cover-image > img { + width: 100%; + height: auto; + padding: 0 50px; + display: block; + box-sizing: border-box; +} + +figcaption { + padding: 0.5em; + font-style: italic; +} + +body { + max-width: 800px; + margin: 1em auto; + background-color: var(--bg1); +} + +.text-section { + padding: 1em; +} + +.fig { + margin: 0.5em; +} + +.fig-horizontal { + width: min-content; + margin: 0.5em auto; +} + +.fig-right { + float: right; + width: min-content; +} + +.fig-left { + float: left; + width: min-content; +} + + +.fig > img, .fig > * > img { + padding: 0.5em; +} + +pre { + padding: 1em 0.5em; + margin: 0; + overflow: scroll; + background-color: var(--bg-dim); +} + +code { + background-color: var(--bg-dim); +} + +table.schema-table { + border-collapse: collapse; + display: inline-table; + margin-bottom: 0.5em; + background-color: var(--bg-dim); +} + +.schema-table th { + background-color: var(--bg0); +} + +.schema-table th,td { + border: 2px solid var(--bg2); + padding: 0.25em; +} + + + +@media (max-width: 700px) { + .fig-left, .fig-right { + float: unset; + margin: 0.5em auto; + } +}
\ No newline at end of file diff --git a/content/blog/rockbox_stats/index.md b/content/blog/rockbox_stats/index.md new file mode 100644 index 0000000..626cbe9 --- /dev/null +++ b/content/blog/rockbox_stats/index.md @@ -0,0 +1,547 @@ ++++ +title = "Rockbox Stat Tracking" +date = "2025-09-02" ++++ + +<article> +<section> +<div class="text-section"> +<p>In this post I talk about how I went about setting up a <a href="/rockstats" target="_blank">stat visualization page</a> for my rockbox mp3 player.</p> +</div> +<figure class="cover-image"> +<svg width="100%" height="360" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" viewBox="0 0 640 360"> +<rect width="640" height="360" x="0" y="0" fill="rgb(0,0,0)" fill-opacity="0"></rect> +<polyline points="427.2 233.9 440.6 240.7 455.6 240.7" fill="transparent" stroke="#e67e80" class="zr0-cls-0"></polyline> +<polyline points="202.3 203.4 192.6 206.3 177.6 206.3" fill="transparent" stroke="#e69875" class="zr0-cls-0"></polyline> +<polyline points="220 113.6 197.5 135.5 182.5 135.5" fill="transparent" stroke="#dbbc7f" class="zr0-cls-0"></polyline> +<polyline points="273.2 69.5 190.3 117.4 190.3 117.4" fill="transparent" stroke="#a7c080" class="zr0-cls-0"></polyline> +<polyline points="304.6 61 201.7 99.3 201.7 99.3" fill="transparent" stroke="#83c092" class="zr0-cls-0"></polyline> +<polyline points="310.7 60.4 217.9 81.2 217.9 81.2" fill="transparent" stroke="#7fbbb3" class="zr0-cls-0"></polyline> +<polyline points="315.4 60.1 242.2 63.1 242.2 63.1" fill="transparent" stroke="#d699b6" class="zr0-cls-0"></polyline> +<polyline points="318.6 60 318.4 45 303.4 45" fill="transparent" stroke="#f85552" class="zr0-cls-0"></polyline> +<path d="M320 60A120 120 0 1 1 223.6 251.5L320 180Z" fill="#e67e80" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="0" ecmeta_ssr_type="chart" class="zr0-cls-1"></path> +<path d="M223.6 251.5A120 120 0 0 1 203.6 150.7L320 180Z" fill="#e69875" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="1" ecmeta_ssr_type="chart" class="zr0-cls-2"></path> +<path d="M203.6 150.7A120 120 0 0 1 247.9 84.1L320 180Z" fill="#dbbc7f" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="2" ecmeta_ssr_type="chart" class="zr0-cls-3"></path> +<path d="M247.9 84.1A120 120 0 0 1 301.3 61.5L320 180Z" fill="#a7c080" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="3" ecmeta_ssr_type="chart" class="zr0-cls-4"></path> +<path d="M301.3 61.5A120 120 0 0 1 307.8 60.6L320 180Z" fill="#83c092" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="4" ecmeta_ssr_type="chart" class="zr0-cls-5"></path> +<path d="M307.8 60.6A120 120 0 0 1 313.6 60.2L320 180Z" fill="#7fbbb3" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="5" ecmeta_ssr_type="chart" class="zr0-cls-6"></path> +<path d="M313.6 60.2A120 120 0 0 1 317.2 60L320 180Z" fill="#d699b6" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="6" ecmeta_ssr_type="chart" class="zr0-cls-7"></path> +<path d="M317.2 60A120 120 0 0 1 320 60L320 180Z" fill="#f85552" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="7" ecmeta_ssr_type="chart" class="zr0-cls-8"></path> +<text dominant-baseline="central" text-anchor="start" style="font-size:16px;font-family:unifont;" xml:space="preserve" transform="translate(460.5907 240.6868)" fill="var(--fg)">Progressive Rock</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(172.5853 206.2935)" fill="var(--fg)">Alternative</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(177.5244 135.5094)" fill="var(--fg)">Post-Rock</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(185.3496 117.4094)" fill="var(--fg)">Post-Hardcore</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(196.7011 99.3094)" fill="var(--fg)">Post-Metal</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(212.8733 81.2094)" fill="var(--fg)">Rock</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(237.2337 63.1094)" fill="var(--fg)">Shoegaze</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" xml:space="preserve" transform="translate(298.4082 45.0094)" fill="var(--fg)">Progressive Metal</text> +<style> +.zr0-cls-0:hover { +cursor:pointer; +} +.zr0-cls-1:hover { +cursor:pointer; +fill:rgba(253,138,140,1); +} +.zr0-cls-2:hover { +cursor:pointer; +fill:rgba(253,167,128,1); +} +.zr0-cls-3:hover { +cursor:pointer; +fill:rgba(240,206,139,1); +} +.zr0-cls-4:hover { +cursor:pointer; +fill:rgba(183,211,140,1); +} +.zr0-cls-5:hover { +cursor:pointer; +fill:rgba(144,211,160,1); +} +.zr0-cls-6:hover { +cursor:pointer; +fill:rgba(139,205,196,1); +} +.zr0-cls-7:hover { +cursor:pointer; +fill:rgba(235,168,200,1); +} +.zr0-cls-8:hover { +cursor:pointer; +fill:rgba(255,93,90,1); +} +</style> +</svg> +<figcaption>A static site generation experiment</figcaption> +</figure> +</section> +<section class="text-section"> + <h2>Preamble: Digital Sovereignity & Rockbox</h2> + <p> + I've been building up a pretty sizeable collection of digital music + over the last couple of years. I think there's a lot of value in owning + the music I pay for and being able to choose how I listen to it. + Purchasing music also allows me to support artists in a more direct + and substantial way than the fractions of cents for using streaming services, + but that's more of a happy consequence than some moral obligation I feel. + </p> + <p> + Over the years, I've enjoyed listening to my music in a variety of ways. + For years I kept all of my music files on all of my devices and used + various local music clients depending on the platform, most notably mpd + and ncmpcpp on linux. Eventually, as I charged headlong into the glorious + world of self-hosting, I began using a central Jellyfin media server that + I stream music and video from. It's super convenient, and works on all of + my devices (including my TV!). + </p> + <p> + My media server is great, and it's been the primary way I listen to music + for a while now. But it has limitations. For example, I don't expose my media + server to the internet, so I'm unable to stream from it while I'm out and + about. And even if I could, the bandwidth requirements would be pretty high. + I figured I would need a dedicated music player if I wanted to take my music + library on the go, and settled on the HIFI Walker H2 after reading some + online recommendations. The ability to install <a href="https://rockbox.org" target="_blank">Rockbox</a>, an open-source firmware, + was a big factor in my decision. I couldn't tell you how the device works + out of the box, since I flashed the firmware pretty much immediately once I got it, + but I've been super impressed with how the device works while running Rockbox. + </p> + <p> + <figure class="fig fig-right"> + <img src="player.bmp" alt="Screenshot of Rockbox player showing cool theme."> + <figcaption>I'm using a modified version of the <a + href="https://themes.rockbox.org/index.php?themeid=3266&target=aigoerosq" + target="_blank">InfoMatrix-v2</a> theme, which looks great.</figcaption> + </figure> + Rockbox comes with many codecs for common audio formats including FLAC and MP3. The + device boots extremely quickly, and the interface is snappy. Virtually every aspect + of the user experience is tweakable and customizable to a crazy degree. I've even begun + listening to music on my player even at home, since a device specifically for the + purpose provides less distraction while I'm trying to be productive. + </p> + <p> + All this to say I'm pretty much sold on Rockbox. But there's certain things I + still miss from my days of being a user of popular services like Spotify with + fancy APIs and data tracking. Things like Spotify wrapped or third-party apps + for visualizing playback statistics are a fun way to see what my listening history + looks like and could potentially be used to help find more music that I'd enjoy. + This is why when I noticed that Rockbock has a playback logging feature, a little + lightbulb lit up over my head. + </p> +</section> +<section class="text-section"> + <h2>Generating and Parsing Logs</h2> + <p> + <figure class="fig fig-right"> + <img src="log-setting.bmp" alt="Logging"> + <figcaption>The logging feature can be accessed through the settings menu.</figcaption> + </figure> + Rockbox has a feature that logs playback information to a text file. This feature can + be enabled by setting <b>Playback Settings > Logging</b> to "On". With this setting enabled, a + new line gets added to the end of the <b>.rockbox/playback.log</b> file every time you play a track, + containing info about what you played and when. + </p> + <p> + The logging feature is actually already used by the LastFM scrobbler plugin that comes preloaded with + Rockbox, which is probably the simplest way to get insights into your playback. However, + I personally want to avoid using third-party services as much as possible, because it's more fun. + </p> + <p> + If I take a look at a logfile generated after a bit of listening, I'll see that I've wound up with + a series of lines that each look something like this: + <figure class="fig"> + <pre><samp>1758478258:336689:336718:/<microSD0>/Music/This Is The Glasshouse/This Is The Glasshouse - 867/This Is The Glasshouse - 867 - 01 Streetlight By Streetlight.flac</samp></pre> + <figcaption>An example of a log entry for "Streetlight by Streetlight" by This is the Glasshouse. + </figcaption> + </figure> + </p> + <p> + I wasn't really able to find any information online about the format of these logs, but they appear + to be simple enough to figure out. From what I can tell, each event is broken up into 4 pieces: + <ol> + <li><b>Timestamp:</b> The number of milliseconds since the UNIX epoch. + <li><b>Playback Duration:</b> The amount of the song that was played, in milliseconds. + <li><b>Total Track Length:</b> The length of the played track, in milliseconds. + <li><b>File Path:</b> An absolute path to the file containing the track on the filesystem. + </ol> + All of this is enough to know what I was listening to and when. I can use the file path to check for + audio tags which can help glean even more information about my listening habits. + </p> + <p>Now that I have this information and know how to interpret it, I'm ready to start processing it!</p> +</section> +<section class="text-section"> + <h2>Analyzing Playback History</h2> + <p> + In order to get some useful information out of my playback history, I think it's a good idea to start by + building + a database. I created a sqlite database with the following tables: + <div class="sunken-panel" style="width: min-content;"> + <table class="schema-table"> + <thead> + <tr> + <th colspan="3">songs</th> + </tr> + </thead> + <tbody> + <tr> + <td>id</td> + <td>i64</td> + <td>PK</td> + </tr> + <tr> + <td>title</td> + <td>String</td> + <td></td> + </tr> + <tr> + <td>artists</td> + <td>JSON</td> + <td></td> + </tr> + <tr> + <td>album_id</td> + <td>i64?</td> + <td></td> + </tr> + <tr> + <td>genre</td> + <td>String?</td> + <td></td> + </tr> + </tbody> + </table> + </div> + <div class="sunken-panel" style="width: min-content;"> + <table class="schema-table"> + <thead> + <tr> + <th colspan="3">albums</th> + </tr> + </thead> + <tbody> + <tr> + <td>id</td> + <td>i64</td> + <td>PK</td> + </tr> + <tr> + <td>title</td> + <td>String</td> + <td></td> + </tr> + <tr> + <td>artist</td> + <td>String</td> + <td></td> + </tr> + <tr> + <td>cover_art</td> + <td>Blob?</td> + <td></td> + </tr> + </tbody> + </table> + </div> + <div class="sunken-panel" style="width: min-content;"> + <table class="schema-table"> + <thead> + <tr> + <th colspan="3">history</th> + </tr> + </thead> + <tbody> + <tr> + <td>id</td> + <td>i64</td> + <td>PK</td> + </tr> + <tr> + <td>timestamp</td> + <td>Datetime</td> + <td></td> + </tr> + <tr> + <td>duration</td> + <td>i64</td> + <td></td> + </tr> + <tr> + <td>song_id</td> + <td>i64</td> + <td></td> + </tr> + </tbody> + </table> + </div> + <br> + I can add more columns later, but this is a good place to start. + </p> + <p> + Now, as I read through the logfile line-by-line, I can check if each album exists before + inserting it into the database: + <figure class="fig"> + <pre><code>for line in log_file.lines().flatten() { +println!("{line}"); +// Skip comments +if line.starts_with("#") { +continue; +} +let chunks = line.split(":").collect::<Vec<_>>(); + +let timestamp = DateTime::from_timestamp_secs( +i64::from_str_radix(chunks[0], 10).context("Failed to parse timestamp")?, +) +.context("Failed to convert timestamp")?; + +// Load tags from file on device +let file_path = chunks[chunks.len() - 1][1..] +.split_once("/") +.context("Missing file")? +.1; +let tags = Tag::new() +.read_from_path(args.mount_point.join(file_path)) +.context("Failed to read audio tags")?; + +//... +}</code></pre> + <figcaption>Parsing log entry and loading audio metadata.</figcaption> + </figure> + <figure class="fig"> + <pre><code>if let Some(existing_album) = +sqlx::query("SELECT id FROM albums WHERE title=$1 AND artist=$2") +.bind(album_title) +.bind(album_artist) +.fetch_optional(&mut *db) +.await +.context("Failed to execute query to find existing album")? +{ +let album_id: i64 = existing_album.get("id"); +info!("Album already exists, id {album_id}"); +//... +} else { +info!("Inserting new album: {album_title} by {album_artist}"); +//... +let result = sqlx::query( +"INSERT INTO albums (title, artist, cover_art) VALUES ($1, $2, $3);", +) +.bind(album_title) +.bind(album_artist) +.bind(cover) +.execute(&mut *db) +.await +.context("Failed to execute query to insert album into database")?; + +//... +}</code></pre> + <figcaption>Checking for an album with matching artist and title before creating a new row in the + database.</figcaption> + </figure> + I did something similar with the <b>songs</b> and <b>history</b> tables, basically building up a cache + of history information and skipping anything that's already in the database on repeat runs. + </p> + <p> + With this database constructed, it's pretty easy to get a bunch of different information + about my listening. For example (forgive me if my SQL skills are kind of ass lol): + <figure class="fig"> + <pre><code>SELECT +songs.title AS song_title, +songs.artists AS song_artists, +songs.genre AS song_genre, +albums.title AS album_title, +albums.artist AS album_artist, +history.timestamp AS timestamp, +history.duration AS duration +FROM history +CROSS JOIN songs ON songs.id = history.song_id +CROSS JOIN albums ON albums.id = songs.album_id +ORDER BY timestamp DESC;</code></pre> + <figcaption>Querying for a list of each history entry along with track metadata, sorted from most to + least recent.</figcaption> + </figure> + <figure class="fig"> + <pre><code>SELECT +songs.genre, +SUM(history.duration) AS total_duration +FROM history +CROSS JOIN songs ON history.song_id = songs.id +GROUP BY genre +ORDER BY total_duration DESC +LIMIT 10; </code></pre> + <figcaption>Querying for the top 10 most listened genres by playtime.</figcaption> + </figure> + </p> + <p> + It's all well and good to be able to view this information using a database client, + but it would be really cool if I could visualize this data somehow. + </p> +</section> +<section class="text-section"> + <h2>Visualizing this Data Somehow</h2> + <p> + I wanted to make this data available on my website for people to view, and for a bunch of mostly trivial + reasons I won't get into here, I have a couple of requirements for pages on this site: + <ol> + <li>Pages need to be static. + <li>Pages need to be JavaScript-free. + </ol> + This means any chart rendering needs to be done automatically at build time before + deploying. I don't currently use a static site generator for my site (just for fun), + so I'm basically going to need to write one specifically to generate this page. + </p> + <p> + I won't get too deep into the specifics of how I queried the database and generated each visualization + on + the page, but I can explain the visualizations I created using the queries from the previous section. + For the + listening history I wanted to generate a table displaying the information. To accomplish this, I first + used a combination of <a href="https://crates.io/crates/sqlx" target="_blank">sqlx</a>'s ability to convert a row to a struct and <a href="https://crates.io/crates/serde" target="_blank">serde</a> to serialize + the rows as JSON values. + <figure class="fig"> + <pre><code>#[derive(Serialize, Deserialize, FromRow)] +struct HistoryEntry { +song_title: String, +song_artists: Value, +timestamp: DateTime<Utc>, +duration: i64, +album_title: String, +album_artist: Option<String>, +song_genre: Option<String>, +} + +//...later +let history = sqlx::query_as::<_, HistoryEntry>( +/* SELECT... */ +).fetch_all(&mut *db).await; + +//...later still, tera context accepts +let mut context = tera::Context::new(); +context.insert("history", &history); +</code></pre> + <figcaption>Struct definition for a history entry, allowing conversion from a sqlx row and + de/serialization from/to JSON.</figcaption> + </figure> + </p> + <p> + In order to keep the generation as painless as possible, I decided to use the <a href="https://keats.github.io/tera" target="_blank">Tera</a> template + engine, which allows me to define a template HTML file and substitute in values from + a context which I can define before rendering. In the case of the table, I can just generate a <code><tr></code> + matching the data for each item: + <figure class="fig"> + <pre><code>{% macro history_table(history) %} +<h3>Playback History</h3> +<div class="table-container"> +<table> +<thead> + <tr> + <th>Timestamp</th> + <th>Played Duration</th> + <th>Title</th> + <th>Artists</th> + <th>Album</th> + <th>Genre</th> + </tr> +</thead> +<tbody> + {% for item in history %}<tr> + <td>{{ item.timestamp | date(format="%Y-%m-%d %H:%M:%S") }}</td> + <td>{{ item.duration | hms }}</td> + <td>{{ item.song_title }}</td> + <td>{{ item.song_artists }}</td> + <td>{{ item.album_title }}</td> + <td>{{ item.song_genre }}</td> + </tr> + {% endfor %} +</tbody> +</table> +</div> +{% endmacro history_table %}</code></pre> + <figcaption> + A Tera macro for generating a table from a list of playback history items. + I used a macro so I can re-use this later if I want to add time range views. + (last month, year, etc.) + </figcaption> + </figure> + </p> + <p> + I wrote similar macros for each of the visualizations I wanted to create. Most are + easy, but for my top 10 genres I wanted to display a pie chart. I found a pretty decent + data visualization crate called <a href="https://crates.io/crates/charming" target="_blank">charming</a> that's able to render to html, however + the output contains javascript so it's a no-go for me. Luckily, it can also render to + an SVG which I can embed nicely within the page. + <figure class="fig"> +<svg width="100%" height="360" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" viewBox="0 0 640 360"> +<rect width="640" height="360" x="0" y="0" fill="rgb(0,0,0)" fill-opacity="0"></rect> +<polyline points="427.2 233.9 440.6 240.7 455.6 240.7" fill="transparent" stroke="#e67e80" class="zr0-cls-0"></polyline> +<polyline points="202.3 203.4 192.6 206.3 177.6 206.3" fill="transparent" stroke="#e69875" class="zr0-cls-0"></polyline> +<polyline points="220 113.6 197.5 135.5 182.5 135.5" fill="transparent" stroke="#dbbc7f" class="zr0-cls-0"></polyline> +<polyline points="273.2 69.5 190.3 117.4 190.3 117.4" fill="transparent" stroke="#a7c080" class="zr0-cls-0"></polyline> +<polyline points="304.6 61 201.7 99.3 201.7 99.3" fill="transparent" stroke="#83c092" class="zr0-cls-0"></polyline> +<polyline points="310.7 60.4 217.9 81.2 217.9 81.2" fill="transparent" stroke="#7fbbb3" class="zr0-cls-0"></polyline> +<polyline points="315.4 60.1 242.2 63.1 242.2 63.1" fill="transparent" stroke="#d699b6" class="zr0-cls-0"></polyline> +<polyline points="318.6 60 318.4 45 303.4 45" fill="transparent" stroke="#f85552" class="zr0-cls-0"></polyline> +<path d="M320 60A120 120 0 1 1 223.6 251.5L320 180Z" fill="#e67e80" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="0" ecmeta_ssr_type="chart" class="zr0-cls-1"></path> +<path d="M223.6 251.5A120 120 0 0 1 203.6 150.7L320 180Z" fill="#e69875" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="1" ecmeta_ssr_type="chart" class="zr0-cls-2"></path> +<path d="M203.6 150.7A120 120 0 0 1 247.9 84.1L320 180Z" fill="#dbbc7f" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="2" ecmeta_ssr_type="chart" class="zr0-cls-3"></path> +<path d="M247.9 84.1A120 120 0 0 1 301.3 61.5L320 180Z" fill="#a7c080" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="3" ecmeta_ssr_type="chart" class="zr0-cls-4"></path> +<path d="M301.3 61.5A120 120 0 0 1 307.8 60.6L320 180Z" fill="#83c092" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="4" ecmeta_ssr_type="chart" class="zr0-cls-5"></path> +<path d="M307.8 60.6A120 120 0 0 1 313.6 60.2L320 180Z" fill="#7fbbb3" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="5" ecmeta_ssr_type="chart" class="zr0-cls-6"></path> +<path d="M313.6 60.2A120 120 0 0 1 317.2 60L320 180Z" fill="#d699b6" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="6" ecmeta_ssr_type="chart" class="zr0-cls-7"></path> +<path d="M317.2 60A120 120 0 0 1 320 60L320 180Z" fill="#f85552" stroke="var(--bg0)" stroke-linejoin="round" ecmeta_series_index="0" ecmeta_data_index="7" ecmeta_ssr_type="chart" class="zr0-cls-8"></path> +<text dominant-baseline="central" text-anchor="start" style="font-size:16px;font-family:unifont;" xml:space="preserve" transform="translate(460.5907 240.6868)" fill="var(--fg)">Progressive Rock</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(172.5853 206.2935)" fill="var(--fg)">Alternative</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(177.5244 135.5094)" fill="var(--fg)">Post-Rock</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(185.3496 117.4094)" fill="var(--fg)">Post-Hardcore</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(196.7011 99.3094)" fill="var(--fg)">Post-Metal</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(212.8733 81.2094)" fill="var(--fg)">Rock</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" transform="translate(237.2337 63.1094)" fill="var(--fg)">Shoegaze</text> +<text dominant-baseline="central" text-anchor="end" style="font-size:16px;font-family:unifont;" xml:space="preserve" transform="translate(298.4082 45.0094)" fill="var(--fg)">Progressive Metal</text> +<style> +.zr0-cls-0:hover { +cursor:pointer; +} +.zr0-cls-1:hover { +cursor:pointer; +fill:rgba(253,138,140,1); +} +.zr0-cls-2:hover { +cursor:pointer; +fill:rgba(253,167,128,1); +} +.zr0-cls-3:hover { +cursor:pointer; +fill:rgba(240,206,139,1); +} +.zr0-cls-4:hover { +cursor:pointer; +fill:rgba(183,211,140,1); +} +.zr0-cls-5:hover { +cursor:pointer; +fill:rgba(144,211,160,1); +} +.zr0-cls-6:hover { +cursor:pointer; +fill:rgba(139,205,196,1); +} +.zr0-cls-7:hover { +cursor:pointer; +fill:rgba(235,168,200,1); +} +.zr0-cls-8:hover { +cursor:pointer; +fill:rgba(255,93,90,1); +} + + + +</style> +</svg> + <figcaption>Here's one I generated just now.</figcaption> + </figure> + </p> + <p> + And that's pretty much all there is to it! The finished thing can be found <a href="/rockstats" target="_blank">here</a>. + </p> +</section> +</article>
\ No newline at end of file diff --git a/content/blog/rockbox_stats/log-setting.bmp b/content/blog/rockbox_stats/log-setting.bmp Binary files differnew file mode 100644 index 0000000..29789fa --- /dev/null +++ b/content/blog/rockbox_stats/log-setting.bmp diff --git a/content/blog/rockbox_stats/playback-settings.bmp b/content/blog/rockbox_stats/playback-settings.bmp Binary files differnew file mode 100644 index 0000000..cee3cfb --- /dev/null +++ b/content/blog/rockbox_stats/playback-settings.bmp diff --git a/content/blog/rockbox_stats/player.bmp b/content/blog/rockbox_stats/player.bmp Binary files differnew file mode 100644 index 0000000..452a057 --- /dev/null +++ b/content/blog/rockbox_stats/player.bmp diff --git a/content/blog/terminal_renderer_mkii/cover.png b/content/blog/terminal_renderer_mkii/cover.png Binary files differnew file mode 100644 index 0000000..b3ddfd9 --- /dev/null +++ b/content/blog/terminal_renderer_mkii/cover.png diff --git a/content/blog/terminal_renderer_mkii/david.png b/content/blog/terminal_renderer_mkii/david.png Binary files differnew file mode 100644 index 0000000..6cfa884 --- /dev/null +++ b/content/blog/terminal_renderer_mkii/david.png diff --git a/content/blog/terminal_renderer_mkii/davidbayer.png b/content/blog/terminal_renderer_mkii/davidbayer.png Binary files differnew file mode 100644 index 0000000..af4bfc4 --- /dev/null +++ b/content/blog/terminal_renderer_mkii/davidbayer.png diff --git a/content/blog/terminal_renderer_mkii/davidthreshold.png b/content/blog/terminal_renderer_mkii/davidthreshold.png Binary files differnew file mode 100644 index 0000000..6c6e014 --- /dev/null +++ b/content/blog/terminal_renderer_mkii/davidthreshold.png diff --git a/content/blog/terminal_renderer_mkii/index.md b/content/blog/terminal_renderer_mkii/index.md new file mode 100644 index 0000000..1797a4a --- /dev/null +++ b/content/blog/terminal_renderer_mkii/index.md @@ -0,0 +1,167 @@ ++++ +title = "Terminal Renderer Mk. II - Rendering to Text with Compute" +date = "2025-10-02" ++++ +<section> +<div class="text-section"> +<p>This week I brought my terminal renderer to the next level by performing text rendering on the GPU. +</p> +</div> +<figure class="cover-image"> +<img src="cover.png" alt="" style="width:100%;"> + <figcaption>The Stanford Dragon, outlined and rendered as Braille characters in a terminal emulator. <a href="https://tv.soaos.dev/w/fBnDAUPsTPHaoPeNNxBGch" target="_blank"> +Full video</a> +</figcaption> +</figure> +</section> +<section class="text-section"> +<h2>Context</h2> +<h3>Unicode Braille</h3> +<p> +I first messed around with rendering images to the terminal with Braille characters in like 2022 I +think? I wrote a simple CLI tool +that applied a threshold to an input image and output it as Braille characters in the terminal. <a + href="https://tv.soaos.dev/w/twpHAu4Jv8LJc9YjZbfw5g" target="_blank">Here's a recording I took back + when I did it.</a> +</p> + +<p> +<figure class="fig fig-right"> +<div class="centered"> + <table class="schema-table"> + <tbody> + <tr> + <td>0</td> + <td>3</td> + </tr> + <tr> + <td>1</td> + <td>4</td> + </tr> + <tr> + <td>2</td> + <td>5</td> + </tr> + <tr> + <td>6</td> + <td>7</td> + </tr> + </tbody> + </table> +</div> +<figcaption>The corresponding bit position for each braille dot.</figcaption> +</figure> +This effect is pretty cool, and it was pretty easy to implement as well. The trick lies in how the +<a href="https://en.wikipedia.org/wiki/Braille_Patterns#Block" target="_blank">Unicode Braille block</a> +is laid out. Every 8-dot Braille combination happens to add up to 256 combinations, the perfect amount to +fit in the range between <code>0x2800</code> (⠀) and <code>0x28FF</code> (⣿). In other words, every +character +within the block can be represented by changing the value of a single byte. +</p> +<p> +The lowest 6 bits of the pattern map on to a 6-dot braille pattern. However, due +to historical reasons the 8-dot values were tacked on after the fact, which adds +a slightly annoying mapping to the conversion process. Either way, it's a lot easier +than it could be to just read a pixel value, check its brightness, and then use a +bitwise operation to set/clear a dot. +</p> +<h3>Ordered Dithering</h3> +<p> +Comparing the brightnes of a pixel against a constant threshold is a fine way to +display black and white images, but it's far from ideal and often results in the loss +of a lot of detail from the original image. +</p> +<figure class="fig fig-horizontal"> +<div class="horizontal-container"> + <img src="david.png" alt="" /> + <img src="davidthreshold.png" alt="" /> + <img src="davidbayer.png" alt="" /> +</div> +<figcaption>From left to right: Original image, threshold, and ordered dither. <a + href="https://en.wikipedia.org/wiki/Dither" target="_blank">Wikipedia</a></figcaption> +</figure> +<p>By using <a href="https://en.wikipedia.org/wiki/Ordered_dithering" target="_blank">ordered dithering</a>, +we +can preserve much more of the subtleties of the original image. While not the "truest" version of +dithering possible, +ordered dithering (and <i>Bayer</i> dithering in particular) provides a few advantages that make it very +well suited to realtime computer graphics: +<ul> +<li>Each pixel is dithered independent of any other pixel in the image, making it extremely + parallelizable and good for shaders.</li> +<li>It's visually stable, changes to one part of the image won't disturb other areas.</li> +<li>It's dead simple.</li> +</ul> +Feel free to read up on the specifics of threshold maps and stuff, but for the purposes of this little +explanation it's +enough to know that it's basically just a matrix of 𝓃⨉𝓃 values between 0 and 1, and then to determine +whether a pixel (𝓍,𝓎) +is white or black, you check the brightness against the threshold value at (𝓍%𝓃,𝓎%𝓃) in the map. +</p> +</section> +<section class="text-section"> +<h2>The old way™</h2> +<p> +My first attempt at <i>realtime</i> terminal graphics with ordered dithering +(<a href="https://tv.soaos.dev/w/dzHBnPJXtDBwtSvirgwTvY" target="_blank">I put a video up at the time</a>) +ran entirely on the CPU. I pre-calculated the threshold map at the beginning of execution and ran each +frame +through a sequential function to dither it and convert it to Braille characters. +</p> +<p> +To be honest, I never noticed +any significant performance issues doing this, as you can imagine the image size required to fill a +terminal +screen is signficantly smaller than a normal window. However, I knew I could easily perform the +dithering on the GPU +as a post-processing effect, so I eventually wrote a shader to do that. In combination with another +effect I used to +add outlines to objects, I was able to significantly improve the visual fidelity of the experience. A +good example of +where the renderer was at until like a week ago can be seen in <a + href="https://tv.soaos.dev/w/9Pf2tP3PYY5pJ3Cimhqs9x" target="_blank">this video</a>. +</p> +<p> +Until now I hadn't really considered moving the text conversion to the GPU. I mean, <i>G</i>PU is for +graphics, +right? I just copied the <i>entire framebuffer</i> back onto the CPU after dithering +and used the same sequential conversion algorithm. Then I had an idea that would drastically reduce the +amount +of copying necessary. +</p> +</section> +<section class="text-section"> +<h2>Compute post-processing</h2> +<p> +What if, instead of extracting and copying the framebuffer every single frame, we "rendered" the text on +the GPU +and read <i>that</i> back instead? Assuming each pixel in a texture is 32 bits (RGBA8), and knowing that +each braille +character is a block of 8 pixels, could we not theoretically shave off <i>at least</i> 7/8 of the bytes +copied? +</p> +<p> +As it turns out, it's remarkably easy to do. I'm using the <a href="https://bevy.org" + target="_blank">Bevy engine</a>, +and hooking in a compute node to my existing post-processing render pipeline worked right out of the +box. +I allocated a storage buffer large enough to hold the necessary amount of characters, read it back each +frame, and dumped +the contents into the terminal. +</p> +<p> +I used UTF-32 encoding on the storage buffer because I knew I could easily convert a "wide string" into +UTF-8 before printing it, and +32 bits provides a consistent space to fill for each workgroup in the shader versus a variable-length + encoding like UTF-8. <a href="https://tv.soaos.dev/w/fBnDAUPsTPHaoPeNNxBGch" target="_blank">Here's a video of the new renderer working</a>. +Although now that I think about it, I could probably switch to using UTF-16 since all the Braille +characters could be represented +in 2 bytes, and that would be half the size of the UTF-32 text, which is half empty bytes anyways. +</p> +<p> +Okay so I went and tried that but remembered that shaders only accept 32-bit primitive types, so it doesn't matter anyways. This little side quest has been a part of my +broader efforts to revive a project I +spent a lot of time on. I'm taking the opportunity to really dig in and rework some of the stuff I'm not +totally happy with. So there might be quite a few of this kind of post in the near future. Stay tuned. +</p> +</section> diff --git a/content/blog/vscode_buttplug/buttplug-0.0.1.vsix b/content/blog/vscode_buttplug/buttplug-0.0.1.vsix Binary files differnew file mode 100644 index 0000000..f57f8d3 --- /dev/null +++ b/content/blog/vscode_buttplug/buttplug-0.0.1.vsix diff --git a/content/blog/vscode_buttplug/index.md b/content/blog/vscode_buttplug/index.md new file mode 100644 index 0000000..6abe64d --- /dev/null +++ b/content/blog/vscode_buttplug/index.md @@ -0,0 +1,63 @@ ++++ +title = "Visual Studio Code Buttplug Integration" +description = "Vibe coding." +date = "2026-01-29" ++++ + +[tl;dr: download VSIX here lol](./buttplug-0.0.1.vsix) + +This is perhaps one of the most important project ideas I've come up with in a while. I pounded a redbull at like 8pm and then an angel of the Lord appeared to me and said "Man, it would be really funny if you added buttplug support to vscode". So I did. I was inspired by various buttplug mods for random games that probably have no business having buttplug mods, like Ultrakill's [UKButt](https://github.com/PITR-DEV/ukbutt-mod) mod and the pioneering mind who made one for [Webfishing](https://github.com/elliotcubit/WebfishingButtplug). + +I've been wanting to dip my toes into VS Code extension development for a while, and something like buttplug integration seemed like an interesting step up from a basic hello world. Plus I get to be the guy who made a buttplug extension for a text editor, and bring that up at networking events, parties, and job interviews. + +So you might be wondering what the hell "buttplug integration" actually means. As it turns out, ass hardware is pretty advanced these days, with all kinds of bluetooth doohickeys that irradiate your insides and connect to your Google Home to your prostate and shit. Naturally, open source protocols for interfacing with smart sex toys have also emerged. [Buttplug.io](https://buttplug.io/) seems like a pretty popular choice for this, and it's actually pretty easy to work with. + +That still leaves the question of what buttplug integration should look like for a text editor. This is a subject that invites rigorous debate. I was looking at this from a very practical standpoint, so my main goal was to provide developers with an enhanced level of immersion that would boost their coding productivity. When I think of immersion, I think of the main document I'm editing. I want to *feel* the code as I'm writing it... in my ass. So that's where I started: + +```ts +// Vibe on type +context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(async (e) => { + if (config!.get('typingStrength') === 0) { return; } + //... + const activeEditor = vscode.window.activeTextEditor; + + if (!activeEditor) { + return; + } + + await selected_device?.vibrate((config!.get('typingStrength') as number) * (config!.get('strength') as number)); + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(async () => await selected_device?.stop(), config!.get('typingDuration') as number * 1000); +})); +``` +(The full code for this extension is available [here](https://git.soaos.dev/vscode-buttplug.git/tree/).) + +Basically what this snippet does is watches for any edits to the currently open text editor and provides "haptic feedback" for each keystroke. +I made the strength and duration of these vibrations, along with an overall strength multiplier, configurable by the user. + +This on its own would be great, but it isn't enough. This extension needs to be a holistic overhaul of the modern developer experience. And as modern developers, actually writing code takes up such a miniscule amount of our time. Sitting on your ass waiting for your insanely bloated app to build is a much more important activity, and one that is normally very boring. Well, no more: + +```ts +// Vibrate continuously while building +context.subscriptions.push(vscode.tasks.onDidStartTask(async e => { + if (config!.get('buildStrength') === 0) { return; } + if (e.execution.task.group === vscode.TaskGroup.Build) { + building = true; + await selected_device?.vibrate((config!.get('buildStrength') as number) * (config!.get('strength') as number)); + } +})); + +// Stop vibration once build tasks all finish +context.subscriptions.push(vscode.tasks.onDidEndTask(async e => { + if ((vscode.tasks.taskExecutions).every(t => t.task.group !== vscode.TaskGroup.Build)) { + building = false; + await selected_device?.stop(); + } +})); +``` + +The extension obliterates your asshole relentlessly while a build task is in progress (sorry rust devs, although I have a feeling you won't mind). This is extremely useful, since the vibration only stops once all build steps are complete, letting you know it's ok to stop scrolling Instagram reels and get back to work. + +That covers the basic functionality of the extension, outside of a few utility commands like a killswitch. If you want to try it out, just download the [intiface central](https://intiface.com/central/) app, install the extension, connect and install your hardware, and godspeed.
\ No newline at end of file |