summaryrefslogtreecommitdiff
path: root/blog
diff options
context:
space:
mode:
Diffstat (limited to 'blog')
-rw-r--r--blog/blacklight_shader/blacklight.pngbin846587 -> 0 bytes
-rw-r--r--blog/blacklight_shader/index.html227
-rw-r--r--blog/blog.css99
-rw-r--r--blog/rockbox_stats/index.html560
-rw-r--r--blog/rockbox_stats/log-setting.bmpbin0 -> 153666 bytes
-rw-r--r--blog/rockbox_stats/playback-settings.bmpbin0 -> 153666 bytes
-rw-r--r--blog/rockbox_stats/player.bmpbin0 -> 153666 bytes
7 files changed, 659 insertions, 227 deletions
diff --git a/blog/blacklight_shader/blacklight.png b/blog/blacklight_shader/blacklight.png
deleted file mode 100644
index 2c5caf1..0000000
--- a/blog/blacklight_shader/blacklight.png
+++ /dev/null
Binary files differ
diff --git a/blog/blacklight_shader/index.html b/blog/blacklight_shader/index.html
deleted file mode 100644
index db4b54e..0000000
--- a/blog/blacklight_shader/index.html
+++ /dev/null
@@ -1,227 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Creating a Blacklight Shader - soaos</title>
-</head>
-
-<body>
- <a href="/">Go Home</a>
- <a href="..">Go Back</a>
- <h1>Creating a Blacklight Shader</h1>
- <font color="red">
- <b>NOTE: THIS POST WAS TRANSFERRED FROM MARKDOWN BY HAND SO I MIGHT HAVE MISSED SOME STUFF SORRY</b>
- </font>
- <p>today i wanted to take a bit of time to write about a shader i implemented for my in-progress game project (more
- on that soon™)</p>
- <p>i wanted to create a "blacklight" effect, where specific lights could reveal part of the base texture. this
- shader works with <b>spot lights</b> only, but could be extended to work with point lights</p>
- <figure>
- <img src="blacklight.png" alt="Example of shader running, showing hidden writing on a wall" width="100%">
- <figcaption>Example of shader running, showing hidden writing on a wall.</figcaption>
- </figure>
-
- <p>i wrote this shader in wgsl for a <a href="https://bevyengine.org" target="_blank">bevy engine</a> project, but
- it should translate easily to other shading languages</p>
-
- <p>the finished shader can be found as part of <a href="https://git.soaos.dev/soaos/bevy_blacklight_material"
- target="_blank">this repo</a></p>
-
- <h2>shader inputs</h2>
-
- <p>
- for this shader, i wanted the following features:
- <ul>
- <li>
- the number of lights should be dynamic
- </li>
- <li>
- the revealed portion of the object should match the area illuminated by each light
- </li>
- <li>
- the falloff of the light over distance should match the fading of the object
- </li>
- </ul>
-
- for this to work i need the following information about each light:
- <ul>
- <li>
- position (world space)
- </li>
- <li>
- direction (world space)
- </li>
- <li>
- range
- </li>
- <li>
- inner and outer angle
- </li>
- <li>
- these will control the falloff of the light at its edges
- </li>
- <li>
- outer angle should be less than pi/2 radians
- </li>
- <li>
- inner angle should be less than the outer angle
- </li>
- </ul>
-
- i also need some info from the vertex shader:
- <ul>
- <li>
- position (<b>world space!</b>)
- </li>
- <li>
- uv
- </li>
- </ul>
- </p>
- <p>bevy's default pbr vertex shader provides this information, but as long as you can get this info into your
- fragment
- shader you should be good to go</p>
-
- <p>lastly i'll take a base color texture and a sampler</p>
-
- <p>
- with all of that, i can start off the shader by setting up the inputs and fragment entry point:
-
- <pre>
- #import bevy_pbr::forward_io::VertexOutput;
-
- struct BlackLight {
- position: vec3&lt;f32&gt;,
- direction: vec3&lt;f32&gt;,
- range: f32,
- inner_angle: f32,
- outer_angle: f32,
- }
-
- @group(2) @binding(0) var&lt;storage&gt; lights: array&lt;BlackLight&gt;;
- @group(2) @binding(1) var base_texture: texture_2d&lt;f32&gt;;
- @group(2) @binding(2) var base_sampler: sampler;
-
- @fragment
- fn fragment(
- in: VertexOutput,
- ) -> @location(0) vec4&lt;f32&gt; {
- }
- </pre>
- (bevy uses group 2 for custom shader bindings)
- </p>
-
- <p>
- since the number of lights is dynamic, i use a <a
- href="https://google.github.io/tour-of-wgsl/types/arrays/runtime-sized-arrays/">storage buffer</a> to store
- that information
- </p>
-
- <h2>shader calculations</h2>
-
- <p>the first thing we'll need to know is how close to looking at the fragment the light source
- is</p>
-
- <p>
- we can get this information using some interesting math:
-
- <pre>
- let light = lights[0];
- let light_to_fragment_direction = normalize(in.world_position.xyz - light.position);
- let light_to_fragment_angle = acos(dot(light.direction, light_to_fragment_direction));
- </pre>
-
- the first step of this is taking the dot product of light direction and the direction from
- the light to the fragment
- </p>
-
- <p>since both direction vectors are normalized, the dot product will be between -1.0 and 1.0</p>
-
- <p>
- the dot product of two unit vectors is the cosine of the angle between them (<a
- href="https://math.libretexts.org/Bookshelves/Calculus/Calculus_(OpenStax)/12%3A_Vectors_in_Space/12.03%3A_The_Dot_Product#Evaluating_a_Dot_Product">proof
- here</a>)
- </p>
-
- <p>
- therefore, we take the arccosine of that dot product to get the angle between the light and
- the fragment
- </p>
-
- <p>
- once we have this angle we can plug it in to a falloff based on the angle properties of the
- light:
-
- <pre>
- let angle_inner_factor = light.inner_angle/light.outer_angle;
- let angle_factor = linear_falloff_radius(light_to_fragment_angle / light.outer_angle, angle_inner_factor);
- </pre>
- <pre>
- fn linear_falloff_radius(factor: f32, radius: f32) -> f32 {
- if factor &lt; radius { return 1.0; } else {
- return 1.0 - (factor - radius) / (1.0 - radius);
- }
- }
- </pre>
- </p>
- <p>
- next, we need to make sure the effect falls off properly over distance we can do this by getting the distance
- from the light to
- the fragment and normalizing it with the range of the light before plugging that into an inverse square falloff
- we'll use squared distance to avoid expensive and unnecessary square root operations:
- <pre>
- let light_distance_squared=distance_squared(in.world_position.xyz, light.position);
- let distance_factor=inverse_falloff_radius(saturate(light_distance_squared / (light.range * light.range)), 0.5);
- </pre>
- <pre>
- fn distance_squared(a: vec3f, b: vec3f) -> f32 {
- let vec = a - b;
- return dot(vec, vec);
- }
-
- fn inverse_falloff(factor: f32) -> f32 {
- return pow(1.0 - factor, 2.0);
- }
-
- fn inverse_falloff_radius(factor: f32, radius: f32) -> f32 {
- if factor &lt; radius { return 1.0; } else {
- return inverse_falloff((factor - radius) / (1.0 - radius));
- }
- }
- </pre>
- </p>
- <p>
- now we'll have a float multiplier between 0.0 and 1.0 for our angle and distance to the light we can get the
- resulting color by multiplying these with the base color texture:
- <pre>
- let base_color = textureSample(base_texture, base_sampler, in.uv);
- let final_color=base_color * angle_factor * distance_factor;
- </pre>
- this works for one light, but we need to refactor it to loop over all the provided blacklights:
- <pre>
-
- @fragment fn fragment( in: VertexOutput ) -> @location(0) vec4&lt;f32&gt; {
- let base_color = textureSample(base_texture, base_sampler, in.uv);
- var final_color = vec4f(0.0, 0.0, 0.0, 0.0);
- for (var i = u32(0); i &lt; arrayLength(&lights); i = i+1) {
- let light=lights[i];
- let light_to_fragment_direction = normalize(in.world_position.xyz - light.position);
- let light_to_fragment_angle = acos(dot(light.direction, light_to_fragment_direction));
- let angle_inner_factor = light.inner_angle / light.outer_angle;
- let angle_factor = linear_falloff_radius(light_to_fragment_angle / light.outer_angle, angle_inner_factor);
- let light_distance_squared = distance_squared(in.world_position.xyz, light.position);
- let distance_factor = inverse_falloff_radius(saturate(light_distance_squared / (light.range * light.range)), 0.5);
- final_color = saturate(final_color + base_color * angle_factor * distance_factor);
- }
- return final_color;
- }
- </pre>
- and with that, the shader is pretty much complete you can view the full completed shader code <a
- href="https://github.com/soaosdev/bevy_blacklight_material/blob/master/assets/shaders/blacklight_material.wgsl">here</a>
- </p>
- <p>have fun!</p>
-</body>
-
-</html> \ No newline at end of file
diff --git a/blog/blog.css b/blog/blog.css
new file mode 100644
index 0000000..372964d
--- /dev/null
+++ b/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/blog/rockbox_stats/index.html b/blog/rockbox_stats/index.html
new file mode 100644
index 0000000..4aca821
--- /dev/null
+++ b/blog/rockbox_stats/index.html
@@ -0,0 +1,560 @@
+<!DOCTYPE html>
+
+<html>
+
+<head>
+ <title></title>
+ <link rel="stylesheet" href="/style.css">
+ <link rel="stylesheet" href="/blog/blog.css">
+</head>
+
+<body>
+ <div class="text-section">
+ <a href="..">↰ Back</a>
+ <a href="/">⌂ Home</a>
+ </div>
+ <article>
+ <section>
+ <div class="text-section">
+ <!-- Header Section -->
+ <h1>Rockbox Stat Tracking</h1>
+ <p>September 22, 2025</p>
+ <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:/&lt;microSD0&gt;/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:
+ <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>
+ <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>
+ <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>
+ <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&lt;Utc&gt;,
+ duration: i64,
+ album_title: String,
+ album_artist: Option&lt;String&gt;,
+ song_genre: Option&lt;String&gt;,
+}
+
+//...later
+let history = sqlx::query_as::&lt;_, HistoryEntry&gt;(
+ /* 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>&lt;tr&gt;</code>
+ matching the data for each item:
+ <figure class="fig">
+ <pre><code>{% macro history_table(history) %}
+&lt;h3&gt;Playback History&lt;/h3&gt;
+&lt;div class=&quot;table-container&quot;&gt;
+ &lt;table&gt;
+ &lt;thead&gt;
+ &lt;tr&gt;
+ &lt;th&gt;Timestamp&lt;/th&gt;
+ &lt;th&gt;Played Duration&lt;/th&gt;
+ &lt;th&gt;Title&lt;/th&gt;
+ &lt;th&gt;Artists&lt;/th&gt;
+ &lt;th&gt;Album&lt;/th&gt;
+ &lt;th&gt;Genre&lt;/th&gt;
+ &lt;/tr&gt;
+ &lt;/thead&gt;
+ &lt;tbody&gt;
+ {% for item in history %}&lt;tr&gt;
+ &lt;td&gt;{{ item.timestamp | date(format=&quot;%Y-%m-%d %H:%M:%S&quot;) }}&lt;/td&gt;
+ &lt;td&gt;{{ item.duration | hms }}&lt;/td&gt;
+ &lt;td&gt;{{ item.song_title }}&lt;/td&gt;
+ &lt;td&gt;{{ item.song_artists }}&lt;/td&gt;
+ &lt;td&gt;{{ item.album_title }}&lt;/td&gt;
+ &lt;td&gt;{{ item.song_genre }}&lt;/td&gt;
+ &lt;/tr&gt;
+ {% endfor %}
+ &lt;/tbody&gt;
+ &lt;/table&gt;
+&lt;/div&gt;
+{% 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>
+</body>
+
+</html> \ No newline at end of file
diff --git a/blog/rockbox_stats/log-setting.bmp b/blog/rockbox_stats/log-setting.bmp
new file mode 100644
index 0000000..29789fa
--- /dev/null
+++ b/blog/rockbox_stats/log-setting.bmp
Binary files differ
diff --git a/blog/rockbox_stats/playback-settings.bmp b/blog/rockbox_stats/playback-settings.bmp
new file mode 100644
index 0000000..cee3cfb
--- /dev/null
+++ b/blog/rockbox_stats/playback-settings.bmp
Binary files differ
diff --git a/blog/rockbox_stats/player.bmp b/blog/rockbox_stats/player.bmp
new file mode 100644
index 0000000..452a057
--- /dev/null
+++ b/blog/rockbox_stats/player.bmp
Binary files differ