AXN10 Woes

I bought a Cambridge Audio AX system for my office because it was cheap and it let me play records too. It’s really not too bad but I have my suspicions over the AXN10 Network Streamer. I’ve noticed it has a tendency to stop playing after a while. It plays fine for a good four to six months and then it’s like it runs out of buffer space or something and can only play music in five second chunks followed by a five second pauses. It’s peculiar. It’s also done it three times now so it’s now something that needs investigating.

My current setup uses my music stores on a WD MyCloud Mirror (that also needs fixing) that is streamed out via AssetUPnP running on an Ubuntu 22.04.5 LTS box to my CXNv2 and my AXN10. I originally used Twonky but Twonky is now, effectively, a dead platform. It doesn’t and never will support the Disk Number fields so multi-disk albums are a problem with Twonky.

I was hoping that I could use AssetUPnP to log the music and the duration played but I’m not convinced it’s logging is that capable. It seemed to log connections from the TV but not from the CA AXN which is a bit of a problem when I’m interested in what the AXN is doing.

The AXN itself doesn’t seem to do any logging either but, obviously, the Stream Magic app connects over APIs to the device so I was wondering whether there was a hidden interface there that I could poke. I’m really not the best when it comes to reverse engineering interfaces so I did something really dumb and just put in the IP address of the AXN into a browser followed by /debug. Surprisingly, this returned a “Remote debug not enabled” message which immediately made me think that there must be a way to enable remote debug. Time to go digging…

Ecosia wasn’t really of much help here but Google, especially Googles AI bot was most helpful. Initially it pointed me to a very nice project on GitHub (https://github.com/noahhusby/aiostreammagic)

Now, I’m no python developer (I’m no developer at all, really) but by explaining what I was trying to achieve Google came up with the following program:

import asyncio
import csv
import os
import sys
from datetime import datetime
from aiostreammagic import StreamMagicClient

# CONFIGURATION
AXN10_IP = "IP GOES HERE"  # Replace with your exact AXN10 IP
LOG_FILE = "/usr/local/bin/axn10-tracker/streamer_playback_log.csv"

class PlaybackTracker:
    def __init__(self):
        self.current_track = None
        self.current_artist = None
        self.play_start_time = None
        self.last_state = None

    def log_to_csv(self, start_time, stop_time, artist, track, duration_seconds):
        """Appends a completed playback record to the CSV file."""
        file_exists = os.path.isfile(LOG_FILE)
        
        try:
            with open(LOG_FILE, mode='a', newline='', encoding='utf-8') as f:
                writer = csv.writer(f)
                if not file_exists:
                    writer.writerow(["Start Time", "Stop Time", "Artist", "Track", "Played Duration (Sec)", "Played Duration (Min)"])
                
                duration_minutes = round(duration_seconds / 60, 2)
                writer.writerow([
                    start_time.strftime("%Y-%m-%d %H:%M:%S"),
                    stop_time.strftime("%Y-%m-%d %H:%M:%S"),
                    artist or "Unknown Artist",
                    track or "Unknown Track",
                    round(duration_seconds, 1),
                    duration_minutes
                ])
            print(f"Logged to CSV: '{track}' by {artist} - {duration_minutes} mins.", flush=True)
        except Exception as e:
            print(f"Failed writing to CSV: {e}", file=sys.stderr, flush=True)

    async def on_state_change(self, client: StreamMagicClient, *args):
        """Official callback engine triggered immediately on AXN10 updates."""
        # Grab the core play state object safely
        ps_obj = client.play_state
        if ps_obj:
            # Extract simple state string (e.g., 'play', 'pause', 'stop', 'idle')
            play_state = getattr(ps_obj, 'state', 'stopped').lower()
            
            # Extract nested metadata objects
            metadata = getattr(ps_obj, 'metadata', None)
            track_title = getattr(metadata, 'title', None) if metadata else None
            artist_name = getattr(metadata, 'artist', None) if metadata else None
        else:
            play_state = "stopped"
            track_title = None
            artist_name = None

        # Clean diagnostic line showing exactly what song has been processed
        print(f"[TRACKER STATUS] State: {play_state.upper()} | Title: '{track_title}' by {artist_name}", flush=True)

        now = datetime.now()

        # CASE 1: Streamer just entered a play cycle
        if play_state == "play" and self.last_state != "play":
            self.play_start_time = now
            self.current_track = track_title
            self.current_artist = artist_name
            self.last_state = "play"
            print(f"Playback started: '{track_title}' by {artist_name}", flush=True)

        # CASE 2: Tracks shifted during active streaming
        elif play_state == "play" and self.current_track != track_title:
            if self.play_start_time:
                duration = (now - self.play_start_time).total_seconds()
                if duration > 2:
                    self.log_to_csv(self.play_start_time, now, self.current_artist, self.current_track, duration)
            
            self.play_start_time = now
            self.current_track = track_title
            self.current_artist = artist_name
            print(f"Track changed: '{track_title}' by {artist_name}", flush=True)

        # CASE 3: Playback stopped, paused, or returned to idle
        elif play_state in ["pause", "stop", "idle"] and self.last_state == "play":
            if self.play_start_time:
                duration = (now - self.play_start_time).total_seconds()
                if duration > 2:
                    self.log_to_csv(self.play_start_time, now, self.current_artist, self.current_track, duration)
            self.play_start_time = None
            self.current_track = None
            self.last_state = play_state
            print(f"Playback stopped ({play_state}).", flush=True)

async def main():
    tracker = PlaybackTracker()
    print(f"Initializing event pipeline for AXN10 at {AXN10_IP}...", flush=True)
    
    while True:
        try:
            client = StreamMagicClient(AXN10_IP)
            
            # 1. Bind our handler logic block
            await client.register_state_update_callbacks(tracker.on_state_change)
            
            # 2. ACTUALLY EXECUTE THE NETWORK HANDSHAKE CONNECTION
            await client.connect()
            
            print("Connected! Active socket listening for streaming metrics...", flush=True)
            
            # Keep the context block alive indefinitely
            while True:
                await asyncio.sleep(3600)
                
        except Exception as e:
            print(f"Network drop encountered: {e}. Reconnecting in 15 seconds...", file=sys.stderr, flush=True)
            await asyncio.sleep(15)

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\nShutdown signal captured.")

This logs every event to a CSV and lets me keep track of everything that has been played and for how long. It also came up with a companion program to summarise the data for me. I probably could have done that bit myself but I was more interested in having something work that hacking around trying to make things work.

import csv
from datetime import datetime
from collections import defaultdict

LOG_FILE = "/usr/local/bin/axn10-tracker/streamer_playback_log.csv"

def analyze_logs():
    total_seconds = 0
    track_counts = defaultdict(int)
    track_durations = defaultdict(float)
    
    try:
        with open(LOG_FILE, mode='r', encoding='utf-8') as f:
            reader = csv.DictReader(f)
            
            for row in reader:
                # Calculate overall cumulative time
                seconds = float(row["Played Duration (Sec)"])
                total_seconds += seconds
                
                # Group data by track identifier
                track_identifier = f"'{row['Track']}' by {row['Artist']}"
                track_counts[track_identifier] += 1
                track_durations[track_identifier] += seconds
                
    except FileNotFoundError:
        print(f"Error: Could not find '{LOG_FILE}'. Make sure the logger script has run and recorded at least one song.")
        return

    # Convert total seconds into hours and minutes
    total_minutes = total_seconds / 60
    total_hours = total_minutes / 60
    
    print("=" * 50)
    print("      CAMBRIDGE AUDIO AXN10 PLAYBACK REPORT      ")
    print("=" * 50)
    print(f"Total Listening Time: {total_hours:.2f} Hours ({int(total_minutes)} Minutes)")
    print("-" * 50)
    
    # Sort and find the tracks listened to for the longest total duration
    print("Top 5 Most Listened-To Tracks (By Actual Duration):")
    sorted_by_duration = sorted(track_durations.items(), key=lambda x: x[1], reverse=True)
    
    for i, (track, seconds) in enumerate(sorted_by_duration[:5], 1):
        mins = seconds / 60
        print(f" {i}. {track} — Listened for {mins:.1f} mins total")

if __name__ == "__main__":
    analyze_logs()

The next thing we worked on was making it into a service so that it would always run. The last thing I needed was to forget to log a week or month’s worth of data and complete stuff my numbers up. Adding it as a service was pretty trivial.

First create the logger service:

sudo vi /etc/systemd/system/axn10-logger.service

   [Unit]
   Description=Cambridge Audio AXN10 Playback Logger
   After=network-online.target
   Wants=network-online.target

   [Service]
   Type=simple
   User=YOUR_UBUNTU_USERNAME
   WorkingDirectory=/usr/local/bin/axn10-tracker
   ExecStart=/usr/local/bin/axn10-tracker/venv/bin/python3 /usr/local/bin/axn10-tracker/axn10_logger.py
   Restart=always
   RestartSec=10
   StandardOutput=journal
   StandardError=journal

   [Install]
   WantedBy=multi-user.target

then enable and run the service:

sudo systemctl daemon-reload
sudo systemctl enable axn10-logger.service
sudo systemctl start axn10-logger.service

It actually took a few iterations to get the logging code to properly run as a service and to log the data correctly but Google helped me achieve something I could not have done on my own.

So now, every time I play something on my AXN it gets logged to a file and over time it will show me, not only the total play time between failures, it will also show me what artists / albums / songs I’m listening to the most. That’s pretty cool.

MakeMKV

Under the Advanced settings it is possible to tweak what MakeMKV is looking for when it rips a disc.

The original string is:

-sel:all,+sel:(favlang|nolang),-sel:mvcvideo,=100:all,-10:favlang

Which basically selects favourite language files and no language files, and deselects mvcvideo (3D) files. No idea what the bit on the end does but it does explain why my 3D films never worked properly.

From looking on the MakeMKV forum I’m now using:

-sel:all,+sel:(favlang|nolang|mvcvideo),=100:all,-10:favlang

which should let me rip 3D films and, now that I’ve sent falling to English, only selects the English tracks.

Ubuntu 24.04 on a Terra-Master T12-423

My reasons for putting Ubuntu are explained here: Confession Time and I’m using Ubuntu because that’s what Terra-Master use themselves so it should work just fine and it’s the distribution I’m slightly more familiar with at the moment. Debian would be my alternative choice.

First things first. Open up the case and remove the small SanDisk USB stick. This contains the TOS boot loader and we don’t want that anymore. Go and put it in a marked envelope in a drawer somewhere just in case you ever want to put it back.

Step 2 – not for the faint of heart. Update the BIOS. The full thread is here: https://forum.terra-master.com/en/viewtopic.php?t=4238 and with that done the rest is pretty straightforward.

Next we need to change the boot order. With the TOS USB stick removed that’s an awful lot easier. For some reason, for me at least, the TOS USB stick always took priority no matter what I did so with that out of the way it’s a lot easier to do things now.

I actually burned Ubuntu to DVD rather than a flash drive simply because I don’t think the flash drives I have are very reliable. They are incredibly slow for some reason too. So, I opted for DVD.

Slap the DVD in and run through the basic setup until the drive configuration page presents itself. Here choose the custom layout option. I have two Seagate IronWolf disks as my boot disks so I wanted a mirrored raid boot setup. Not having done that before I found a very easy to follow video (and I really hate having to follow videos) here:

Installing Ubuntu 24.04 With RAID

and a really useful page here:

Ubuntu 24.04 with software RAID1 and UEFI

The only changes to the process I made was to include in my four 10TB raid disks into the process. That means that at the end of the video I have a filesystem that looks something like:

md0 RAID1 /boot
md1 RAID1 SWAP
md2 RAID1 /root
md3 RAID5 /storage

It also means that as the storage is built at installation time I can just sit back and let the RAID configure itself for a few hours.

Because I always choose a minimised installation I need to add a few things in to make the environment usable so this is what I do:

sudo apt update
sudo apt install htop
sudo apt install avahi-daemon
sudo apt install vim
sudo apt install cron
sudo apt upgrade

Once cron and vim are installed I set up my lazy mdstat rebuild page.

I have a little web server with a basic html page of:

<!DOCTYPE html>
<html>
<head>
<title>
RAID Rebuild Status
</title>
<meta http-equiv="refresh" content="60" >
</head>
<body>
<embed src="raid.txt" width="100%" height="1000">
</body>
</html>

It’s really not much of a page. Then on the T12 I add in a cron job:

* * * * * cat /proc/mdstat | ssh ricardo@server.local 'cat - > /home/ricardo/docker/dumkid/html/raid.txt'

This means that I can point my phone / laptop / work computer to my little webpage and see how the rebuild is going without having to log in to the T12 itself.

Apple Screensavers

Putting this here so I don’t forget.

Hacking a ScreenSaver means swapping out an existing asset for a new one.

Use this location:

/Library/Application Support/com.apple.idleassetsd/Customer/4KSDR240FPS

Use this asset:

E161929C-0819-4BC2-8359-550C081C7D54

(Scottish Castle)

Xcode

I can never remember this so, to stop me having to constantly refer to my non-work computer, here is how to accept the new Xcode update:

sudo xcodebuild -licence accept

Ubuntu

As much as I love Docker (even though I’m not brilliant with it) sometimes I just need a proper server. In this case I need a nice shiny WebDav server with the ability to show web pages too.

I’m using Ubuntu for this because, as much as I like Debian, I find Ubuntu just that little bit easier to work with. Except Snap. Snap can burn in hell. The rest of it is pretty nice though.

The plan is to use Nginx as the web /WebDav server and if I can configure it up the way I need and get it doing everything I want I can then get the UNIX team at work to build me one for the office. I can’t help but wonder if they can manage that.

Ubuntu on USB

I can never remember how to do this and the Ubuntu pages are, surprisingly, useless. So, for future reference:

  • Download Ubuntu server from: https://ubuntu.com/download/server
  • Write it to a USB stick: dd if=ubuntu-24.04.1-live-server-amd64.iso of=/dev/disk2 bs=1m
  • Set PC to boot from USB stick
  • Boot.

In my case I’m using a small Dell OptiPlex 7070 Micro. So, to get into the BIOS it’s:

  • Boot
  • Hammer F2
  • General -> Advanced Boot Options -> Enable Legacy Option ROMs
  • General ->Boot Sequence -> Legacy External Devices
  • Reboot
  • Hammer F2
  • General -> Boot Sequence -> USB Storage Device

It should now be possible to boot from the USB stick.

When partitioning the disk don’t get confused between the USB stick filesystem and the PC filesystem. It’s pretty easy once you realise that the PC is NVME.

Fun with the Cambridge Audio CXN-v2

Buckle up, this is a ride.

First off, I have to say that I do love the Cambridge Audio kit, I’ve not got anything fancy, just a CXA-60, a CXN(v2), a CXN and a pair of SX-60s. I’ve been after some Cambridge Audio separates for ages since I originally saw them in Richer Sounds many years ago. When my small Panasonic network player died it was more costly to get it fixed than to buy a new one and, since it was cheaper to buy a new one, I just went for the CXA-60 and the CXN(v2) and kept the speakers I had. The Cambridge Audio stuff does sound truly fabulous and properly love it.

There’s just one (smallish) problem.

I use iTunes to rip all my media and Meta to keep the data tidy. The back end storage is a WD My Cloud Mirror Gen 2 running Twonky 8.1. Twonky isn’t perfect and sometimes it needs a helping hand to properly recognise all album tracks but I really like the sort order it uses and it’s pretty useful for media management.

Most discs tend to have data something like:

The Title, Track Number and Disc Number are the important fields.

With most albums the tracks play in the order they’re expected to, track 1, track2 track 3 etc.

However, there is a bit of a problem with multi-disc albums. For some reason the play order is not set. Sometimes track 1, disc 1 plays first and sometimes track 1 disc 2 plays first. The play order is always alphabetic. So, really, the sort order for multi-disc albums is track number followed by alphabetic sorting of the resultant list. For albums that have two discs this is irritating because the play order is completely out of sequence, for compilations that have around five discs this is a nightmare.

Because I’d given feedback on the Cambridge Audio app I was given the opportunity to do some beta testing with the new app. Naturally, I gave that a go.

One of the things I noticed was that the app displayed a different sort order to the CXN for multi-disc albums (the proper order) but when the album was played via the app the actual play order was different from the displayed play order. That was kind of annoying really.

Investigating things further I used my LG TV do navigate the album structure to see if the same problem occurred there too. The LG TV doesn’t play AIFF files though so, as far as the TV was concerned, there was no content in any of the directories I was looking at. I had to convert all the AIFF files to MP4 files and then the TV could see the files. Not only could the TV see the files but they were displayed and played in the correct order too. To my mind, this meant that the CXN was not asking for the disc number when generating a list of assets to play.

After much back-and-forth with Cambridge Audio support I managed to get the developers to take a look at the problem. The upshot there is that as the albums play correctly when played through a USB stick and other NAS (Synology in this case) there must be a problem with the WD NAS and the way it presents its data. I’m completely not convinced that is the case because the LG TV plays everything correctly and if there was a problem with the NAS that could not happen.

At this point I was thinking I should just buy a new NAS, especially as a disk has died in the WD one, but I’m just too stubborn to give up like that and, although I can’t seem to generate DLNA traffic, I wondered whether it would be possible to snoop the traffic between the CXN and the NAS. Turns out it totally is. I mirrored the NAS port and used my Mac to read all the traffic. Using a packet capture of:

tcpdump -i en5 port 9000 host wdmycloudmirror.local -XAvvv -w cxn.cap --print

Using Wireshark I managed to filter out the HTTP traffic and I could see that when the request came for the album details it looked like this:

<?xml version="1.0" ?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
	<s:Body>
		<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
			<ObjectID>0$1$12$18640</ObjectID>
			<BrowseFlag>BrowseDirectChildren</BrowseFlag>
			<Filter>dc:title,upnp:class,upnp:album,upnp:originalTrackNumber,id,res,res@protection,res@duration,upnp:searchClass,upnp:artist,upnp:genre,upnp:albumArtURI</Filter>
			<StartingIndex>0</StartingIndex>
			<RequestedCount>100</RequestedCount>
			<SortCriteria>+upnp:originalTrackNumber,+dc:title</SortCriteria>
		</u:Browse>
	</s:Body>
</s:Envelope>


Here it’s pretty easy to see that the sorting requested by the CXN is:

+upnp:originalTrackNumber,+dc:title

So it’s not asking for the disc number at all. The same request from the app looks like this:

<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
	<s:Body>
		<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
			<ObjectID>0$1$12$18640</ObjectID>
			<BrowseFlag>BrowseDirectChildren</BrowseFlag>
			<Filter>*</Filter>
			<StartingIndex>0</StartingIndex>
			<RequestedCount>100</RequestedCount>
			<SortCriteria/>
		</u:Browse>
	</s:Body>
</s:Envelope>

Again, here it’s pretty easy to see that no sorting has been applied to the results. Also, no filtering has been applied to the original request either.

Now, not being a software developer I’m only making a suggestion here but I reckon that adding +pv:numberOfThisDisc to the filtering and sorting of the CXN requests would completely fix the problem with multi-disc albums. I’ve suggested that but it’s out of my hands and I just have to hope that my proposal is accepted…