shithub: riscv

Download patch

ref: c6cdee420da18fc02c44c257505f3c43a331c7a4
parent: b777d3fe7d947c7bb9595aed5a571e311872b5f6
author: Sigrid <[email protected]>
date: Tue Apr 13 09:20:27 EDT 2021

audio/: zuke, mkplist, readtags

--- /dev/null
+++ b/sys/src/cmd/audio/libtags/8859.c
@@ -1,0 +1,29 @@
+/* http://en.wikipedia.org/wiki/ISO/IEC_8859-1 */
+#include "tagspriv.h"
+
+int
+iso88591toutf8(uchar *o, int osz, const uchar *s, int sz)
+{
+	int i;
+
+	for(i = 0; i < sz && osz > 1 && s[i] != 0; i++){
+		if(s[i] >= 0xa0 && osz < 3)
+			break;
+
+		if(s[i] >= 0xc0){
+			*o++ = 0xc3;
+			*o++ = s[i] - 0x40;
+			osz--;
+		}else if(s[i] >= 0xa0){
+			*o++ = 0xc2;
+			*o++ = s[i];
+			osz--;
+		}else{
+			*o++ = s[i];
+		}
+		osz--;
+	}
+
+	*o = 0;
+	return i;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/flac.c
@@ -1,0 +1,108 @@
+/* https://xiph.org/flac/format.html */
+#include "tagspriv.h"
+
+#define beu3(d)   ((d)[0]<<16 | (d)[1]<<8  | (d)[2]<<0)
+
+int
+tagflac(Tagctx *ctx)
+{
+	uchar *d;
+	int sz, last;
+	uvlong g;
+
+	d = (uchar*)ctx->buf;
+	/* 8 bytes for marker, block type, length. 18 bytes for the stream info */
+	if(ctx->read(ctx, d, 8+18) != 8+18 || memcmp(d, "fLaC\x00", 5) != 0)
+		return -1;
+
+	sz = beu3(&d[5]); /* size of the stream info */
+	ctx->samplerate = beu3(&d[18]) >> 4;
+	ctx->channels = ((d[20]>>1) & 7) + 1;
+	g = (uvlong)(d[21] & 0xf)<<32 | beu3(&d[22])<<8 | d[25];
+	ctx->duration = g * 1000 / ctx->samplerate;
+
+	/* skip the rest of the stream info */
+	if(ctx->seek(ctx, sz-18, 1) != 8+sz)
+		return -1;
+
+	for(last = 0; !last;){
+		if(ctx->read(ctx, d, 4) != 4)
+			return -1;
+
+		sz = beu3(&d[1]);
+		if((d[0] & 0x80) != 0)
+			last = 1;
+
+		if((d[0] & 0x7f) == 6){ /* 6 = picture */
+			int n, offset;
+			char *mime;
+
+			if(sz < 16 || ctx->read(ctx, d, 8) != 8) /* type, mime length */
+				return -1;
+			sz -= 8;
+			n = beuint(&d[4]);
+			mime = ctx->buf+20;
+			if(n >= sz || n >= ctx->bufsz-1 || ctx->read(ctx, mime, n) != n)
+				return -1;
+			sz -= n;
+			mime[n] = 0;
+			ctx->read(ctx, d, 4); /* description */
+			sz -= 4;
+			offset = beuint(d) + ctx->seek(ctx, 0, 1) + 20;
+			ctx->read(ctx, d, 20);
+			sz -= 20;
+			n = beuint(&d[16]);
+			tagscallcb(ctx, Timage, "", mime, offset, n, nil);
+			if(ctx->seek(ctx, sz, 1) <= 0)
+				return -1;
+		}else if((d[0] & 0x7f) == 4){ /* 4 = vorbis comment */
+			int i, numtags, tagsz, vensz;
+			char *k, *v;
+
+			if(sz < 12 || ctx->read(ctx, d, 4) != 4)
+				return -1;
+
+			sz -= 4;
+			vensz = leuint(d);
+			if(vensz < 0 || vensz > sz-4)
+				return -1;
+			/* skip vendor, read the number of tags */
+			if(ctx->seek(ctx, vensz, 1) < 0 || ctx->read(ctx, d, 4) != 4)
+				return -1;
+			sz -= vensz + 4;
+			numtags = leuint(d);
+
+			for(i = 0; i < numtags && sz > 4; i++){
+				if(ctx->read(ctx, d, 4) != 4)
+					return -1;
+				tagsz = leuint(d);
+				sz -= 4;
+				if(tagsz > sz)
+					return -1;
+
+				/* if it doesn't fit, ignore it */
+				if(tagsz+1 > ctx->bufsz){
+					if(ctx->seek(ctx, tagsz, 1) < 0)
+						return -1;
+					continue;
+				}
+
+				k = ctx->buf;
+				if(ctx->read(ctx, k, tagsz) != tagsz)
+					return -1;
+				/* some tags have a stupid '\r'; ignore */
+				if(k[tagsz-1] == '\r')
+					k[tagsz-1] = 0;
+				k[tagsz] = 0;
+
+				if((v = strchr(k, '=')) != nil){
+					*v++ = 0;
+					cbvorbiscomment(ctx, k, v);
+				}
+			}
+		}else if(ctx->seek(ctx, sz, 1) <= 0)
+			return -1;
+	}
+
+	return 0;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/id3genres.c
@@ -1,0 +1,42 @@
+#include "tagspriv.h"
+
+const char *id3genres[Numgenre] =
+{
+	"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk",
+	"Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies",
+	"Other", "Pop", "Rhythm and Blues", "Rap", "Reggae", "Rock",
+	"Techno", "Industrial", "Alternative", "Ska", "Death Metal",
+	"Pranks", "Soundtrack", "Euro-Techno", "Ambient", "Trip-Hop",
+	"Vocal", "Jazz & Funk", "Fusion", "Trance", "Classical",
+	"Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel",
+	"Noise", "Alternative Rock", "Bass", "Soul", "Punk rock", "Space",
+	"Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic",
+	"Gothic", "Darkwave", "Techno-Industrial", "Electronic",
+	"Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy",
+	"Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk",
+	"Jungle", "Native American", "Cabaret", "New Wave", "Psychedelic",
+	"Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal", "Acid Punk",
+	"Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
+	"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion",
+	"Bebop", "Latin", "Revival", "Celtic", "Bluegrass", "Avantgarde",
+	"Gothic Rock", "Progressive Rock", "Psychedelic Rock", "Symphonic Rock",
+	"Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic",
+	"Humour", "Speech", "Chanson", "Opera", "Chamber Music", "Sonata",
+	"Symphony", "Booty Bass", "Primus", "Porn groove", "Satire", "Slow Jam",
+	"Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad",
+	"Rhythmic Soul", "Freestyle", "Duet", "Punk rock", "Drum Solo", "A capella",
+	"Euro-House", "Dance Hall", "Goa Trance", "Drum & Bass",
+	"Club-House", "Hardcore Techno", "Terror", "Indie", "BritPop",
+	"Afro-punk", "Polsk Punk", "Beat", "Christian Gangsta Rap", "Heavy Metal",
+	"Black Metal", "Crossover", "Contemporary Christian", "Christian Rock",
+	"Merengue", "Salsa", "Thrash Metal", "Anime", "Jpop", "Synthpop",
+	"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat",
+	"Breakbeat", "Chillout", "Downtempo", "Dub", "EBM", "Eclectic",
+	"Electro", "Electroclash", "Emo", "Experimental", "Garage",
+	"Global", "IDM", "Illbient", "Industro-Goth", "Jam Band",
+	"Krautrock", "Leftfield", "Lounge", "Math Rock", "New Romantic",
+	"Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance", "Shoegaze",
+	"Space Rock", "Trop Rock", "World Music", "Neoclassical",
+	"Audiobook", "Audio Theatre", "Neue Deutsche Welle", "Podcast",
+	"Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient",
+};
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/id3v1.c
@@ -1,0 +1,48 @@
+/*
+ * http://en.wikipedia.org/wiki/ID3
+ * Space-padded strings are mentioned there. This is wrong and is a lie.
+ */
+#include "tagspriv.h"
+
+enum
+{
+	Insz = 128,
+	Outsz = 61,
+};
+
+int
+tagid3v1(Tagctx *ctx)
+{
+	uchar *in, *out;
+
+	if(ctx->bufsz < Insz+Outsz)
+		return -1;
+	in = (uchar*)ctx->buf;
+	out = in + Insz;
+
+	if(ctx->seek(ctx, -Insz, 2) < 0)
+		return -1;
+	if(ctx->read(ctx, in, Insz) != Insz || memcmp(in, "TAG", 3) != 0)
+		return -1;
+
+	if((ctx->found & 1<<Ttitle) == 0 && iso88591toutf8(out, Outsz, &in[3], 30) > 0)
+		txtcb(ctx, Ttitle, "", out);
+	if((ctx->found & 1<<Tartist) == 0 && iso88591toutf8(out, Outsz, &in[33], 30) > 0)
+		txtcb(ctx, Tartist, "", out);
+	if((ctx->found & 1<<Talbum) == 0 && iso88591toutf8(out, Outsz, &in[63], 30) > 0)
+		txtcb(ctx, Talbum, "", out);
+
+	in[93+4] = 0;
+	if((ctx->found & 1<<Tdate) == 0 && in[93] != 0)
+		txtcb(ctx, Tdate, "", &in[93]);
+
+	if((ctx->found & 1<<Ttrack) == 0 && in[125] == 0 && in[126] > 0){
+		snprint((char*)out, Outsz, "%d", in[126]);
+		txtcb(ctx, Ttrack, "", out);
+	}
+
+	if((ctx->found & 1<<Tgenre) == 0 && in[127] < Numgenre)
+		txtcb(ctx, Tgenre, "", id3genres[in[127]]);
+
+	return 0;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/id3v2.c
@@ -1,0 +1,471 @@
+/*
+ * Have fun reading the following:
+ *
+ * http://id3.org/id3v2.4.0-structure
+ * http://id3.org/id3v2.4.0-frames
+ * http://id3.org/d3v2.3.0
+ * http://id3.org/id3v2-00
+ * http://mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm
+ * http://wiki.hydrogenaud.io/index.php?title=MP3#VBRI.2C_XING.2C_and_LAME_headers
+ * http://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header#VBRIHeader
+ */
+#include "tagspriv.h"
+
+#define synchsafe(d) (uint)(((d)[0]&127)<<21 | ((d)[1]&127)<<14 | ((d)[2]&127)<<7 | ((d)[3]&127)<<0)
+
+static int
+v2cb(Tagctx *ctx, char *k, char *v)
+{
+	k++;
+	if(strcmp(k, "AL") == 0 || strcmp(k, "ALB") == 0)
+		txtcb(ctx, Talbum, k-1, v);
+	else if(strcmp(k, "PE1") == 0 || strcmp(k, "PE2") == 0 || strcmp(k, "P1") == 0 || strcmp(k, "P2") == 0)
+		txtcb(ctx, Tartist, k-1, v);
+	else if(strcmp(k, "IT2") == 0 || strcmp(k, "T2") == 0)
+		txtcb(ctx, Ttitle, k-1, v);
+	else if(strcmp(k, "YE") == 0 || strcmp(k, "YER") == 0 || strcmp(k, "DRC") == 0)
+		txtcb(ctx, Tdate, k-1, v);
+	else if(strcmp(k, "RK") == 0 || strcmp(k, "RCK") == 0)
+		txtcb(ctx, Ttrack, k-1, v);
+	else if(strcmp(k, "CO") == 0 || strcmp(k, "CON") == 0){
+		for(; v[0]; v++){
+			if(v[0] == '(' && v[1] <= '9' && v[1] >= '0'){
+				int i = atoi(&v[1]);
+				if(i < Numgenre)
+					txtcb(ctx, Tgenre, k-1, id3genres[i]);
+				for(v++; v[0] && v[0] != ')'; v++);
+				v--;
+			}else if(v[0] != '(' && v[0] != ')'){
+				txtcb(ctx, Tgenre, k-1, v);
+				break;
+			}
+		}
+	}else if(strcmp(k, "XXX") == 0 && strncmp(v, "REPLAYGAIN_", 11) == 0){
+		int type = -1;
+		v += 11;
+		if(strncmp(v, "TRACK_", 6) == 0){
+			v += 6;
+			if(strcmp(v, "GAIN") == 0)
+				type = Ttrackgain;
+			else if(strcmp(v, "PEAK") == 0)
+				type = Ttrackpeak;
+		}else if(strncmp(v, "ALBUM_", 6) == 0){
+			v += 6;
+			if(strcmp(v, "GAIN") == 0)
+				type = Talbumgain;
+			else if(strcmp(v, "PEAK") == 0)
+				type = Talbumpeak;
+		}
+		if(type >= 0)
+			txtcb(ctx, type, k-1, v+5);
+		else
+			return 0;
+	}else{
+		txtcb(ctx, Tunknown, k-1, v);
+	}
+	return 1;
+}
+
+static int
+rva2(Tagctx *ctx, char *tag, int sz)
+{
+	uchar *b, *end;
+
+	if((b = memchr(tag, 0, sz)) == nil)
+		return -1;
+	b++;
+	for(end = (uchar*)tag+sz; b+4 < end; b += 5){
+		int type = b[0];
+		float peak;
+		float va = (float)(b[1]<<8 | b[2]) / 512.0f;
+
+		if(b[3] == 24){
+			peak = (float)(b[4]<<16 | b[5]<<8 | b[6]) / 32768.0f;
+			b += 2;
+		}else if(b[3] == 16){
+			peak = (float)(b[4]<<8 | b[5]) / 32768.0f;
+			b += 1;
+		}else if(b[3] == 8){
+			peak = (float)b[4] / 32768.0f;
+		}else
+			return -1;
+
+		if(type == 1){ /* master volume */
+			char vas[16], peaks[8];
+			snprint(vas, sizeof(vas), "%+.5f dB", va);
+			snprint(peaks, sizeof(peaks), "%.5f", peak);
+			vas[sizeof(vas)-1] = 0;
+			peaks[sizeof(peaks)-1] = 0;
+
+			if(strcmp((char*)tag, "track") == 0){
+				txtcb(ctx, Ttrackgain, "RVA2", vas);
+				txtcb(ctx, Ttrackpeak, "RVA2", peaks);
+			}else if(strcmp((char*)tag, "album") == 0){
+				txtcb(ctx, Talbumgain, "RVA2", vas);
+				txtcb(ctx, Talbumpeak, "RVA2", peaks);
+			}
+			break;
+		}
+	}
+	return 0;
+}
+
+static int
+resync(uchar *b, int sz)
+{
+	int i;
+
+	if(sz < 4)
+		return sz;
+	for(i = 0; i < sz-2; i++){
+		if(b[i] == 0xff && b[i+1] == 0x00 && (b[i+2] & 0xe0) == 0xe0){
+			memmove(&b[i+1], &b[i+2], sz-i-2);
+			sz--;
+		}
+	}
+	return sz;
+}
+
+static int
+unsyncread(void *buf, int *sz)
+{
+	int i;
+	uchar *b;
+
+	b = buf;
+	for(i = 0; i < *sz; i++){
+		if(b[i] == 0xff){
+			if(i+1 >= *sz || (b[i+1] == 0x00 && i+2 >= *sz))
+				break;
+			if(b[i+1] == 0x00 && (b[i+2] & 0xe0) == 0xe0){
+				memmove(&b[i+1], &b[i+2], *sz-i-2);
+				(*sz)--;
+			}
+		}
+	}
+	return i;
+}
+
+static int
+nontext(Tagctx *ctx, uchar *d, int tsz, int unsync)
+{
+	int n, offset;
+	char *b, *tag;
+	Tagread f;
+
+	tag = ctx->buf;
+	n = 0;
+	f = unsync ? unsyncread : nil;
+	if(strcmp((char*)d, "APIC") == 0){
+		offset = ctx->seek(ctx, 0, 1);
+		if((n = ctx->read(ctx, tag, 256)) == 256){ /* APIC mime and description should fit */
+			b = tag + 1; /* mime type */
+			for(n = 1 + strlen(b) + 2; n < 253; n++){
+				if(tag[0] == 0 || tag[0] == 3){ /* one zero byte */
+					if(tag[n] == 0){
+						n++;
+						break;
+					}
+				}else if(tag[n] == 0 && tag[n+1] == 0 && tag[n+2] == 0){
+					n += 3;
+					break;
+				}
+			}
+			tagscallcb(ctx, Timage, "APIC", b, offset+n, tsz-n, f);
+			n = 256;
+		}
+	}else if(strcmp((char*)d, "PIC") == 0){
+		offset = ctx->seek(ctx, 0, 1);
+		if((n = ctx->read(ctx, tag, 256)) == 256){ /* PIC description should fit */
+			b = tag + 1; /* mime type */
+			for(n = 5; n < 253; n++){
+				if(tag[0] == 0 || tag[0] == 3){ /* one zero byte */
+					if(tag[n] == 0){
+						n++;
+						break;
+					}
+				}else if(tag[n] == 0 && tag[n+1] == 0 && tag[n+2] == 0){
+					n += 3;
+					break;
+				}
+			}
+			tagscallcb(ctx, Timage, "PIC", strcmp(b, "JPG") == 0 ? "image/jpeg" : "image/png", offset+n, tsz-n, f);
+			n = 256;
+		}
+	}else if(strcmp((char*)d, "RVA2") == 0 && tsz >= 6+5){
+		/* replay gain. 6 = "track\0", 5 = other */
+		if(ctx->bufsz >= tsz && (n = ctx->read(ctx, tag, tsz)) == tsz)
+			rva2(ctx, tag, unsync ? resync((uchar*)tag, n) : n);
+	}
+
+	return ctx->seek(ctx, tsz-n, 1) < 0 ? -1 : 0;
+}
+
+static int
+text(Tagctx *ctx, uchar *d, int tsz, int unsync)
+{
+	char *b, *tag;
+
+	if(ctx->bufsz >= tsz+1){
+		/* place the data at the end to make best effort at charset conversion */
+		tag = &ctx->buf[ctx->bufsz - tsz - 1];
+		if(ctx->read(ctx, tag, tsz) != tsz)
+			return -1;
+	}else{
+		ctx->seek(ctx, tsz, 1);
+		return 0;
+	}
+
+	if(unsync)
+		tsz = resync((uchar*)tag, tsz);
+
+	tag[tsz] = 0;
+	b = &tag[1];
+
+	switch(tag[0]){
+	case 0: /* iso-8859-1 */
+		if(iso88591toutf8((uchar*)ctx->buf, ctx->bufsz, (uchar*)b, tsz) > 0)
+			v2cb(ctx, (char*)d, ctx->buf);
+		break;
+	case 1: /* utf-16 */
+	case 2:
+		if(utf16to8((uchar*)ctx->buf, ctx->bufsz, (uchar*)b, tsz) > 0)
+			v2cb(ctx, (char*)d, ctx->buf);
+		break;
+	case 3: /* utf-8 */
+		if(*b)
+			v2cb(ctx, (char*)d, b);
+		break;
+	}
+
+	return 0;
+}
+
+static int
+isid3(uchar *d)
+{
+	/* "ID3" version[2] flags[1] size[4] */
+	return (
+		d[0] == 'I' && d[1] == 'D' && d[2] == '3' &&
+		d[3] < 0xff && d[4] < 0xff &&
+		d[6] < 0x80 && d[7] < 0x80 && d[8] < 0x80 && d[9] < 0x80
+	);
+}
+
+static const uchar bitrates[4][4][16] = {
+	{
+		{0},
+		{0,  4,  8, 12, 16, 20, 24, 28, 32, 40, 48, 56, 64,  72,  80, 0}, /* v2.5 III */
+		{0,  4,  8, 12, 16, 20, 24, 28, 32, 40, 48, 56, 64,  72,  80, 0}, /* v2.5 II */
+		{0, 16, 24, 28, 32, 40, 48, 56, 64, 72, 80, 88, 96, 112, 128, 0}, /* v2.5 I */
+	},
+	{ {0}, {0}, {0}, {0} },
+	{
+		{0},
+		{0,  4,  8, 12, 16, 20, 24, 28, 32, 40, 48, 56, 64,  72,  80, 0}, /* v2 III */
+		{0,  4,  8, 12, 16, 20, 24, 28, 32, 40, 48, 56, 64,  72,  80, 0}, /* v2 II */
+		{0, 16, 24, 28, 32, 40, 48, 56, 64, 72, 80, 88, 96, 112, 128, 0}, /* v2 I */
+	},
+	{
+		{0},
+		{0, 16, 20, 24, 28, 32, 40,  48,  56,  64,  80,  96, 112, 128, 160, 0}, /* v1 III */
+		{0, 16, 24, 28, 32, 40, 48,  56,  64,  80,  96, 112, 128, 160, 192, 0}, /* v1 II */
+		{0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 0}, /* v1 I */
+	}
+};
+
+static const uint samplerates[4][4] = {
+	{11025, 12000,  8000, 0},
+	{    0,     0,     0, 0},
+	{22050, 24000, 16000, 0},
+	{44100, 48000, 32000, 0},
+};
+
+static const int chans[] = {2, 2, 2, 1};
+
+static const int samplesframe[4][4] = {
+	{0,    0,    0,   0},
+	{0,  576, 1152, 384},
+	{0,  576, 1152, 384},
+	{0, 1152, 1152, 384},
+};
+
+static void
+getduration(Tagctx *ctx, int offset)
+{
+	uvlong n, framelen, samplespf, toc;
+	uchar *b;
+	uint x;
+	int xversion, xlayer, xbitrate, i;
+
+	if(ctx->read(ctx, ctx->buf, 256) != 256)
+		return;
+
+	x = beuint((uchar*)ctx->buf);
+	xversion = x >> 19 & 3;
+	xlayer = x >> 17 & 3;
+	xbitrate = x >> 12 & 0xf;
+	ctx->bitrate = 2000*(int)bitrates[xversion][xlayer][xbitrate];
+	samplespf = samplesframe[xversion][xlayer];
+
+	ctx->samplerate = samplerates[xversion][x >> 10 & 3];
+	ctx->channels = chans[x >> 6 & 3];
+
+	if(ctx->samplerate > 0){
+		framelen = (uvlong)144*ctx->bitrate / ctx->samplerate;
+		if((x & (1<<9)) != 0) /* padding */
+			framelen += xlayer == 3 ? 4 : 1; /* for I it's 4 bytes */
+
+		if(memcmp(&ctx->buf[0x24], "Info", 4) == 0 || memcmp(&ctx->buf[0x24], "Xing", 4) == 0){
+			b = (uchar*)ctx->buf + 0x28;
+			x = beuint(b); b += 4;
+			if((x & 1) != 0){ /* number of frames is set */
+				n = beuint(b); b += 4;
+				ctx->duration = n * samplespf * 1000 / ctx->samplerate;
+			}
+
+			if((x & 2) != 0){ /* file size is set */
+				n = beuint(b); b += 4;
+				if(ctx->duration == 0 && framelen > 0)
+					ctx->duration = n * samplespf * 1000 / framelen / ctx->samplerate;
+
+				if((x & 4) != 0 && ctx->toc != nil){ /* TOC is set */
+					toc = offset + 100 + (char*)b - ctx->buf;
+					if((x & 8) != 0) /* VBR scale */
+						toc += 4;
+					for(i = 0; i < 100; i++){
+						/*
+						 * offset = n * b[i] / 256
+						 * ms = i * duration / 100
+						 */
+						ctx->toc(ctx, i * ctx->duration / 100, toc + (n * b[i]) / 256);
+					}
+					b += 100;
+					if((x & 8) != 0) /* VBR scale */
+						b += 4;
+				}
+			}
+			offset += (char*)b - ctx->buf;
+		}else if(memcmp(&ctx->buf[0x24], "VBRI", 4) == 0){
+			n = beuint((uchar*)&ctx->buf[0x32]);
+			ctx->duration = n * samplespf * 1000 / ctx->samplerate;
+
+			if(ctx->duration == 0 && framelen > 0){
+				n = beuint((uchar*)&ctx->buf[0x28]); /* file size */
+				ctx->duration = n * samplespf * 1000 / framelen / ctx->samplerate;
+			}
+		}
+	}
+
+	if(ctx->bitrate > 0 && ctx->duration == 0) /* worst case -- use real file size instead */
+		ctx->duration = (ctx->seek(ctx, 0, 2) - offset)/(ctx->bitrate / 1000) * 8;
+}
+
+int
+tagid3v2(Tagctx *ctx)
+{
+	int sz, exsz, framesz;
+	int ver, unsync, offset;
+	uchar d[10], *b;
+
+	if(ctx->read(ctx, d, sizeof(d)) != sizeof(d))
+		return -1;
+	if(!isid3(d)){ /* no tags, but the stream information is there */
+		if(d[0] != 0xff || (d[1] & 0xfe) != 0xfa)
+			return -1;
+		ctx->seek(ctx, -(int)sizeof(d), 1);
+		getduration(ctx, 0);
+		return 0;
+	}
+
+header:
+	ver = d[3];
+	unsync = d[5] & (1<<7);
+	sz = synchsafe(&d[6]);
+
+	if(ver == 2 && (d[5] & (1<<6)) != 0) /* compression */
+		return -1;
+
+	if(ver > 2){
+		if((d[5] & (1<<4)) != 0) /* footer */
+			sz -= 10;
+		if((d[5] & (1<<6)) != 0){ /* skip extended header */
+			if(ctx->read(ctx, d, 4) != 4)
+				return -1;
+			exsz = (ver >= 3) ? beuint(d) : synchsafe(d);
+			if(ctx->seek(ctx, exsz, 1) < 0)
+				return -1;
+			sz -= exsz;
+		}
+	}
+
+	framesz = (ver >= 3) ? 10 : 6;
+	for(; sz > framesz;){
+		int tsz, frameunsync;
+
+		if(ctx->read(ctx, d, framesz) != framesz)
+			return -1;
+		sz -= framesz;
+
+		/* return on padding */
+		if(memcmp(d, "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", framesz) == 0)
+			break;
+		if(ver >= 3){
+			tsz = (ver == 3) ? beuint(&d[4]) : synchsafe(&d[4]);
+			if(tsz < 0 || tsz > sz)
+				break;
+			frameunsync = d[9] & (1<<1);
+			d[4] = 0;
+
+			if((d[9] & 0x0c) != 0){ /* compression & encryption */
+				ctx->seek(ctx, tsz, 1);
+				sz -= tsz;
+				continue;
+			}
+			if(ver == 4 && (d[9] & 1<<0) != 0){ /* skip data length indicator */
+				ctx->seek(ctx, 4, 1);
+				sz -= 4;
+				tsz -= 4;
+			}
+		}else{
+			tsz = beuint(&d[3]) >> 8;
+			if(tsz > sz)
+				return -1;
+			frameunsync = 0;
+			d[3] = 0;
+		}
+		sz -= tsz;
+
+		if(d[0] == 'T' && text(ctx, d, tsz, unsync || frameunsync) != 0)
+			return -1;
+		else if(d[0] != 'T' && nontext(ctx, d, tsz, unsync || frameunsync) != 0)
+			return -1;
+	}
+
+	offset = ctx->seek(ctx, sz, 1);
+	sz = ctx->bufsz <= 2048 ? ctx->bufsz : 2048;
+	b = nil;
+	for(exsz = 0; exsz < 2048; exsz += sz){
+		if(ctx->read(ctx, ctx->buf, sz) != sz)
+			break;
+		for(b = (uchar*)ctx->buf; (b = memchr(b, 'I', sz - 1 - ((char*)b - ctx->buf))) != nil; b++){
+			ctx->seek(ctx, (char*)b - ctx->buf + offset + exsz, 0);
+			if(ctx->read(ctx, d, sizeof(d)) != sizeof(d))
+				return 0;
+			if(isid3(d))
+				goto header;
+		}
+		for(b = (uchar*)ctx->buf; (b = memchr(b, 0xff, sz-3)) != nil; b++){
+			if((b[1] & 0xe0) == 0xe0){
+				offset = ctx->seek(ctx, (char*)b - ctx->buf + offset + exsz, 0);
+				exsz = 2048;
+				break;
+			}
+		}
+	}
+
+	if(b != nil)
+		getduration(ctx, offset);
+
+	return 0;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/it.c
@@ -1,0 +1,14 @@
+#include "tagspriv.h"
+
+int
+tagit(Tagctx *ctx)
+{
+	char d[4+26+1];
+
+	if(ctx->read(ctx, d, 4+26) != 4+26 || memcmp(d, "IMPM", 4) != 0)
+		return -1;
+	d[4+26] = 0;
+	txtcb(ctx, Ttitle, "", d+4);
+
+	return 0;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/m4a.c
@@ -1,0 +1,154 @@
+/* http://wiki.multimedia.cx/?title=QuickTime_container */
+/* https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html */
+#include "tagspriv.h"
+
+#define beuint16(d) (ushort)((d)[0]<<8 | (d)[1]<<0)
+
+int
+tagm4a(Tagctx *ctx)
+{
+	uvlong duration;
+	uchar *d;
+	int sz, type, dtype, i, skip, n;
+
+	d = (uchar*)ctx->buf;
+	/* 4 bytes for atom size, 4 for type, 4 for data - exect "ftyp" to come first */
+	if(ctx->read(ctx, d, 4+4+4) != 4+4+4 || memcmp(d+4, "ftypM4A ", 8) != 0)
+		return -1;
+	sz = beuint(d) - 4; /* already have 8 bytes */
+
+	for(;;){
+		if(ctx->seek(ctx, sz, 1) < 0)
+			return -1;
+		if(ctx->read(ctx, d, 4) != 4) /* size */
+			break;
+		sz = beuint(d);
+		if(sz == 0)
+			continue;
+		if(ctx->read(ctx, d, 4) != 4) /* type */
+			return -1;
+		if(sz < 8)
+			continue;
+
+		d[4] = 0;
+
+		if(memcmp(d, "meta", 4) == 0){
+			sz = 4;
+			continue;
+		}else if(
+			memcmp(d, "udta", 4) == 0 ||
+			memcmp(d, "ilst", 4) == 0 ||
+			memcmp(d, "trak", 4) == 0 ||
+			memcmp(d, "mdia", 4) == 0 ||
+			memcmp(d, "minf", 4) == 0 ||
+			memcmp(d, "moov", 4) == 0 ||
+			memcmp(d, "trak", 4) == 0 ||
+			memcmp(d, "stbl", 4) == 0){
+			sz = 0;
+			continue;
+		}else if(memcmp(d, "stsd", 4) == 0){
+			sz -= 8;
+			if(ctx->read(ctx, d, 8) != 8)
+				return -1;
+			sz -= 8;
+
+			for(i = beuint(&d[4]); i > 0 && sz > 0; i--){
+				if(ctx->read(ctx, d, 8) != 8) /* size + format */
+					return -1;
+				sz -= 8;
+				skip = beuint(d) - 8;
+
+				if(memcmp(&d[4], "mp4a", 4) == 0){ /* audio */
+					n = 6+2 + 2+4+2 + 2+2 + 2+2 + 4; /* read a bunch at once */
+					/* reserved+id, ver+rev+vendor, channels+bps, ?+?, sample rate */
+					if(ctx->read(ctx, d, n) != n)
+						return -1;
+					skip -= n;
+					sz -= n;
+					ctx->channels = beuint16(&d[16]);
+					ctx->samplerate = beuint(&d[24])>>16;
+				}
+
+				if(ctx->seek(ctx, skip, 1) < 0)
+					return -1;
+				sz -= skip;
+			}
+			continue;
+		}
+
+		sz -= 8;
+		type = -1;
+		if(memcmp(d, "\251nam", 4) == 0)
+			type = Ttitle;
+		else if(memcmp(d, "\251alb", 4) == 0)
+			type = Talbum;
+		else if(memcmp(d, "\251ART", 4) == 0)
+			type = Tartist;
+		else if(memcmp(d, "\251gen", 4) == 0 || memcmp(d, "gnre", 4) == 0)
+			type = Tgenre;
+		else if(memcmp(d, "\251day", 4) == 0)
+			type = Tdate;
+		else if(memcmp(d, "covr", 4) == 0)
+			type = Timage;
+		else if(memcmp(d, "trkn", 4) == 0)
+			type = Ttrack;
+		else if(memcmp(d, "mdhd", 4) == 0){
+			if(ctx->read(ctx, d, 4) != 4)
+				return -1;
+			sz -= 4;
+			duration = 0;
+			if(d[0] == 0){ /* version 0 */
+				if(ctx->read(ctx, d, 16) != 16)
+					return -1;
+				sz -= 16;
+				duration = beuint(&d[12]) / beuint(&d[8]);
+			}else if(d[1] == 1){ /* version 1 */
+				if(ctx->read(ctx, d, 28) != 28)
+					return -1;
+				sz -= 28;
+				duration = ((uvlong)beuint(&d[20])<<32 | beuint(&d[24])) / (uvlong)beuint(&d[16]);
+			}
+			ctx->duration = duration * 1000;
+			continue;
+		}
+
+		if(type < 0)
+			continue;
+
+		if(ctx->seek(ctx, 8, 1) < 0) /* skip size and "data" */
+			return -1;
+		sz -= 8;
+		if(ctx->read(ctx, d, 8) != 8) /* read data type and 4 bytes of whatever else */
+			return -1;
+		sz -= 8;
+		d[0] = 0;
+		dtype = beuint(d);
+
+		if(type == Ttrack){
+			if(ctx->read(ctx, d, 4) != 4)
+				return -1;
+			sz -= 4;
+			snprint((char*)d, ctx->bufsz, "%d", beuint(d));
+			txtcb(ctx, type, "", d);
+		}else if(type == Tgenre){
+			if(ctx->read(ctx, d, 2) != 2)
+				return -1;
+			sz -= 2;
+			if((i = d[1]-1) >= 0 && i < Numgenre)
+				txtcb(ctx, type, "", id3genres[i]);
+		}else if(dtype == 1){ /* text */
+			if(sz >= ctx->bufsz) /* skip tags that can't fit into memory. ">=" because of '\0' */
+				continue;
+			if(ctx->read(ctx, d, sz) != sz)
+				return -1;
+			d[sz] = 0;
+			txtcb(ctx, type, "", d);
+			sz = 0;
+		}else if(type == Timage && dtype == 13) /* jpeg cover image */
+			tagscallcb(ctx, Timage, "", "image/jpeg", ctx->seek(ctx, 0, 1), sz, nil);
+		else if(type == Timage && dtype == 14) /* png cover image */
+			tagscallcb(ctx, Timage, "", "image/png", ctx->seek(ctx, 0, 1), sz, nil);
+	}
+
+	return 0;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/mkfile
@@ -1,0 +1,24 @@
+</$objtype/mkfile
+LIB=libtags.a$O
+
+OFILES=\
+	8859.$O\
+	flac.$O\
+	id3genres.$O\
+	id3v1.$O\
+	id3v2.$O\
+	it.$O\
+	m4a.$O\
+	opus.$O\
+	s3m.$O\
+	tags.$O\
+	utf16.$O\
+	vorbis.$O\
+	wav.$O\
+	xm.$O\
+
+HFILES=\
+	tags.h\
+	tagspriv.h\
+
+</sys/src/cmd/mklib
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/opus.c
@@ -1,0 +1,91 @@
+#include "tagspriv.h"
+
+int
+tagopus(Tagctx *ctx)
+{
+	char *v;
+	uchar *d, h[4];
+	int sz, numtags, i, npages;
+
+	d = (uchar*)ctx->buf;
+	/* need to find vorbis frame with type=3 */
+	for(npages = 0; npages < 2; npages++){ /* vorbis comment is the second header */
+		int nsegs;
+		if(ctx->read(ctx, d, 27) != 27)
+			return -1;
+		if(memcmp(d, "OggS", 4) != 0)
+			return -1;
+
+		/* calculate the size of the packet */
+		nsegs = d[26];
+		if(ctx->read(ctx, d, nsegs+8) != nsegs+8)
+			return -1;
+		for(sz = i = 0; i < nsegs; sz += d[i++]);
+
+		if(memcmp(&d[nsegs], "OpusHead", 8) == 0){
+			if(ctx->read(ctx, d, 8) != 8 || d[0] != 1)
+				return -1;
+			sz -= 8;
+			ctx->channels = d[1];
+			ctx->samplerate = leuint(&d[4]);
+		}else if(memcmp(&d[nsegs], "OpusTags", 8) == 0){
+			break;
+		}
+
+		ctx->seek(ctx, sz-8, 1);
+	}
+
+	if(npages < 3){
+		if(ctx->read(ctx, d, 4) != 4)
+			return -1;
+		sz = leuint(d);
+		if(ctx->seek(ctx, sz, 1) < 0 || ctx->read(ctx, h, 4) != 4)
+			return -1;
+		numtags = leuint(h);
+
+		for(i = 0; i < numtags; i++){
+			if(ctx->read(ctx, h, 4) != 4)
+				return -1;
+			if((sz = leuint(h)) < 0)
+				return -1;
+
+			if(ctx->bufsz < sz+1){
+				if(ctx->seek(ctx, sz, 1) < 0)
+					return -1;
+				continue;
+			}
+			if(ctx->read(ctx, ctx->buf, sz) != sz)
+				return -1;
+			ctx->buf[sz] = 0;
+
+			if((v = strchr(ctx->buf, '=')) == nil)
+				return -1;
+			*v++ = 0;
+			cbvorbiscomment(ctx, ctx->buf, v);
+		}
+	}
+
+	/* calculate the duration */
+	if(ctx->samplerate > 0){
+		sz = ctx->bufsz <= 4096 ? ctx->bufsz : 4096;
+		for(i = sz; i < 65536+16; i += sz - 16){
+			if(ctx->seek(ctx, -i, 2) <= 0)
+				break;
+			v = ctx->buf;
+			if(ctx->read(ctx, v, sz) != sz)
+				break;
+			for(; v != nil && v < ctx->buf+sz;){
+				v = memchr(v, 'O', ctx->buf+sz - v - 14);
+				if(v != nil && v[1] == 'g' && v[2] == 'g' && v[3] == 'S' && (v[5] & 4) == 4){ /* last page */
+					uvlong g = leuint(v+6) | (uvlong)leuint(v+10)<<32;
+					ctx->duration = g * 1000 / 48000; /* granule positions are always 48KHz */
+					return 0;
+				}
+				if(v != nil)
+					v++;
+			}
+		}
+	}
+
+	return 0;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/s3m.c
@@ -1,0 +1,16 @@
+#include "tagspriv.h"
+
+int
+tags3m(Tagctx *ctx)
+{
+	char d[28+1+1], *s;
+
+	if(ctx->read(ctx, d, 28+1+1) != 28+1+1 || (d[28] != 0x1a && d[28] != 0) || d[29] != 0x10)
+		return -1;
+	d[28] = 0;
+	for(s = d+27; s != d-1 && (*s == ' ' || *s == 0); s--);
+	s[1] = 0;
+	txtcb(ctx, Ttitle, "", d);
+
+	return 0;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/tags.c
@@ -1,0 +1,66 @@
+#include "tagspriv.h"
+
+typedef struct Getter Getter;
+
+struct Getter
+{
+	int (*f)(Tagctx *ctx);
+	int format;
+};
+
+extern int tagflac(Tagctx *ctx);
+extern int tagid3v1(Tagctx *ctx);
+extern int tagid3v2(Tagctx *ctx);
+extern int tagit(Tagctx *ctx);
+extern int tagm4a(Tagctx *ctx);
+extern int tagopus(Tagctx *ctx);
+extern int tags3m(Tagctx *ctx);
+extern int tagvorbis(Tagctx *ctx);
+extern int tagwav(Tagctx *ctx);
+extern int tagxm(Tagctx *ctx);
+
+static const Getter g[] =
+{
+	{tagid3v2, Fmp3},
+	{tagid3v1, Fmp3},
+	{tagvorbis, Fogg},
+	{tagflac, Fflac},
+	{tagm4a, Fm4a},
+	{tagopus, Fopus},
+	{tagwav, Fwav},
+	{tagit, Fit},
+	{tagxm, Fxm},
+	{tags3m, Fs3m},
+};
+
+void
+tagscallcb(Tagctx *ctx, int type, const char *k, const char *s, int offset, int size, Tagread f)
+{
+	if(type != Tunknown){
+		ctx->found |= 1<<type;
+		ctx->num++;
+	}
+	ctx->tag(ctx, type, k, s, offset, size, f);
+}
+
+int
+tagsget(Tagctx *ctx)
+{
+	int i, res;
+
+	ctx->channels = ctx->samplerate = ctx->bitrate = ctx->duration = 0;
+	ctx->found = 0;
+	ctx->format = Funknown;
+	res = -1;
+	for(i = 0; i < (int)(sizeof(g)/sizeof(g[0])); i++){
+		ctx->num = 0;
+		if(g[i].f(ctx) == 0){
+			if(ctx->num > 0)
+				res = 0;
+			ctx->format = g[i].format;
+		}
+		ctx->seek(ctx, 0, 0);
+	}
+
+	return res;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/tags.h
@@ -1,0 +1,86 @@
+#pragma lib "/sys/src/cmd/audio/libtags/libtags.a$O"
+
+typedef struct Tagctx Tagctx;
+typedef int (*Tagread)(void *buf, int *cnt);
+
+/* Tag type. */
+enum
+{
+	Tunknown = -1,
+	Tartist,
+	Talbum,
+	Ttitle,
+	Tdate, /* "2014", "2015/02/01", but the year goes first */
+	Ttrack, /* "1", "01", "1/4", but the track number goes first */
+	Talbumgain,
+	Talbumpeak,
+	Ttrackgain,
+	Ttrackpeak,
+	Tgenre,
+	Timage,
+};
+
+/* Format of the audio file. */
+enum
+{
+	Funknown = -1,
+	Fmp3,
+	Fogg,
+	Fflac,
+	Fm4a,
+	Fopus,
+	Fwav,
+	Fit,
+	Fxm,
+	Fs3m,
+
+	Fmax,
+};
+
+/* Tag parser context. You need to set it properly before parsing an audio file using libtags. */
+struct Tagctx
+{
+	/* Read function. This is what libtags uses to read the file. */
+	int (*read)(Tagctx *ctx, void *buf, int cnt);
+
+	/* Seek function. This is what libtags uses to seek through the file. */
+	int (*seek)(Tagctx *ctx, int offset, int whence);
+
+	/* Callback that is used by libtags to inform about the tags of a file.
+	 * "type" is the tag's type (Tartist, ...) or Tunknown if libtags doesn't know how to map a tag kind to
+	 * any of these. "k" is the raw key like "TPE1", "TPE2", etc. "s" is the null-terminated string unless "type" is
+	 * Timage. "offset" and "size" define the placement and size of the image cover ("type" = Timage)
+	 * inside the file, and "f" is not NULL in case reading the image cover requires additional
+	 * operations on the data, in which case you need to read the image cover as a stream and call this
+	 * function to apply these operations on the contents read.
+	 */
+	void (*tag)(Tagctx *ctx, int type, const char *k, const char *s, int offset, int size, Tagread f);
+
+	/* Approximate millisecond-to-byte offsets within the file, if available. This callback is optional. */
+	void (*toc)(Tagctx *ctx, int ms, int offset);
+
+	/* Auxiliary data. Not used by libtags. */
+	void *aux;
+
+	/* Memory buffer to work in. */
+	char *buf;
+
+	/* Size of the buffer. Must be at least 256 bytes. */
+	int bufsz;
+
+	/* Here goes the stuff libtags sets. It should be accessed after tagsget() returns.
+	 * A value of 0 means it's undefined.
+	 */
+	int channels; /* Number of channels. */
+	int samplerate; /* Hz */
+	int bitrate; /* Bitrate, bits/s. */
+	int duration; /* ms */
+	int format; /* Fmp3, Fogg, Fflac, Fm4a */
+
+	/* Private, don't touch. */
+	int found;
+	int num;
+};
+
+/* Parse the file using this function. Returns 0 on success. */
+extern int tagsget(Tagctx *ctx);
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/tagspriv.h
@@ -1,0 +1,39 @@
+#include <u.h>
+#include <libc.h>
+#include "tags.h"
+
+enum
+{
+	Numgenre = 192,
+};
+
+#define beuint(d) (uint)(((uchar*)(d))[0]<<24 | ((uchar*)(d))[1]<<16 | ((uchar*)(d))[2]<<8 | ((uchar*)(d))[3]<<0)
+#define leuint(d) (uint)(((uchar*)(d))[3]<<24 | ((uchar*)(d))[2]<<16 | ((uchar*)(d))[1]<<8 | ((uchar*)(d))[0]<<0)
+
+extern const char *id3genres[Numgenre];
+
+/*
+ * Converts (to UTF-8) at most sz bytes of src and writes it to out buffer.
+ * Returns the number of bytes converted.
+ * You need sz*2+1 bytes for out buffer to be completely safe.
+ */
+int iso88591toutf8(uchar *out, int osz, const uchar *src, int sz);
+
+/*
+ * Converts (to UTF-8) at most sz bytes of src and writes it to out buffer.
+ * Returns the number of bytes converted or < 0 in case of error.
+ * You need sz*4+1 bytes for out buffer to be completely safe.
+ * UTF-16 defaults to big endian if there is no BOM.
+ */
+int utf16to8(uchar *out, int osz, const uchar *src, int sz);
+
+/*
+ * This one is common for both vorbis.c and flac.c
+ * It maps a string k to tag type and executes the callback from ctx.
+ * Returns 1 if callback was called, 0 otherwise.
+ */
+void cbvorbiscomment(Tagctx *ctx, char *k, char *v);
+
+void tagscallcb(Tagctx *ctx, int type, const char *k, const char *s, int offset, int size, Tagread f);
+
+#define txtcb(ctx, type, k, s) tagscallcb(ctx, type, k, (const char*)s, 0, 0, nil)
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/utf16.c
@@ -1,0 +1,59 @@
+/* Horror stories: http://en.wikipedia.org/wiki/UTF-16 */
+#include "tagspriv.h"
+
+#define rchr(s) (be ? ((s)[0]<<8 | (s)[1]) : ((s)[1]<<8 | (s)[0]))
+
+static const uchar mark[] = {0x00, 0x00, 0xc0, 0xe0, 0xf0};
+
+int
+utf16to8(uchar *o, int osz, const uchar *s, int sz)
+{
+	int i, be, c, c2, wr, j;
+
+	i = 0;
+	be = 1;
+	if(s[0] == 0xfe && s[1] == 0xff)
+		i += 2;
+	else if(s[0] == 0xff && s[1] == 0xfe){
+		be = 0;
+		i += 2;
+	}
+
+	for(; i < sz-1 && osz > 1;){
+		c = rchr(&s[i]);
+		i += 2;
+		if(c >= 0xd800 && c <= 0xdbff && i < sz-1){
+			c2 = rchr(&s[i]);
+			if(c2 >= 0xdc00 && c2 <= 0xdfff){
+				c = 0x10000 | (c - 0xd800)<<10 | (c2 - 0xdc00);
+				i += 2;
+			}else
+				return -1;
+		}else if(c >= 0xdc00 && c <= 0xdfff)
+			return -1;
+
+		if(c < 0x80)
+			wr = 1;
+		else if(c < 0x800)
+			wr = 2;
+		else if(c < 0x10000)
+			wr = 3;
+		else
+			wr = 4;
+
+		osz -= wr;
+		if(osz < 1)
+			break;
+
+		o += wr;
+		for(j = wr; j > 1; j--){
+			*(--o) = (c & 0xbf) | 0x80;
+			c >>= 6;
+		}
+		*(--o) = c | mark[wr];
+		o += wr;
+	}
+
+	*o = 0;
+	return i;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/vorbis.c
@@ -1,0 +1,125 @@
+/*
+ * https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-810005
+ * https://wiki.xiph.org/VorbisComment
+ */
+#include "tagspriv.h"
+
+void
+cbvorbiscomment(Tagctx *ctx, char *k, char *v){
+	if(*v == 0)
+		return;
+	if(cistrcmp(k, "album") == 0)
+		txtcb(ctx, Talbum, k, v);
+	else if(cistrcmp(k, "title") == 0)
+		txtcb(ctx, Ttitle, k, v);
+	else if(cistrcmp(k, "artist") == 0)
+		txtcb(ctx, Tartist, k, v);
+	else if(cistrcmp(k, "tracknumber") == 0)
+		txtcb(ctx, Ttrack, k, v);
+	else if(cistrcmp(k, "date") == 0)
+		txtcb(ctx, Tdate, k, v);
+	else if(cistrcmp(k, "replaygain_track_peak") == 0)
+		txtcb(ctx, Ttrackpeak, k, v);
+	else if(cistrcmp(k, "replaygain_track_gain") == 0)
+		txtcb(ctx, Ttrackgain, k, v);
+	else if(cistrcmp(k, "replaygain_album_peak") == 0)
+		txtcb(ctx, Talbumpeak, k, v);
+	else if(cistrcmp(k, "replaygain_album_gain") == 0)
+		txtcb(ctx, Talbumgain, k, v);
+	else if(cistrcmp(k, "genre") == 0)
+		txtcb(ctx, Tgenre, k, v);
+	else
+		txtcb(ctx, Tunknown, k, v);
+}
+
+int
+tagvorbis(Tagctx *ctx)
+{
+	char *v;
+	uchar *d, h[4];
+	int sz, numtags, i, npages;
+
+	d = (uchar*)ctx->buf;
+	/* need to find vorbis frame with type=3 */
+	for(npages = 0; npages < 2; npages++){ /* vorbis comment is the second header */
+		int nsegs;
+		if(ctx->read(ctx, d, 27) != 27)
+			return -1;
+		if(memcmp(d, "OggS", 4) != 0)
+			return -1;
+
+		/* calculate the size of the packet */
+		nsegs = d[26];
+		if(ctx->read(ctx, d, nsegs+1) != nsegs+1)
+			return -1;
+		for(sz = i = 0; i < nsegs; sz += d[i++]);
+
+		if(d[nsegs] == 3) /* comment */
+			break;
+		if(d[nsegs] == 1 && sz >= 28){ /* identification */
+			if(ctx->read(ctx, d, 28) != 28)
+				return -1;
+			sz -= 28;
+			ctx->channels = d[10];
+			ctx->samplerate = leuint(&d[11]);
+			if((ctx->bitrate = leuint(&d[15])) == 0) /* maximum */
+				ctx->bitrate = leuint(&d[19]); /* nominal */
+		}
+
+		ctx->seek(ctx, sz-1, 1);
+	}
+
+	if(npages < 3) {
+		if(ctx->read(ctx, &d[1], 10) != 10 || memcmp(&d[1], "vorbis", 6) != 0)
+			return -1;
+		sz = leuint(&d[7]);
+		if(ctx->seek(ctx, sz, 1) < 0 || ctx->read(ctx, h, 4) != 4)
+			return -1;
+		numtags = leuint(h);
+
+		for(i = 0; i < numtags; i++){
+			if(ctx->read(ctx, h, 4) != 4)
+				return -1;
+			if((sz = leuint(h)) < 0)
+				return -1;
+
+			if(ctx->bufsz < sz+1){
+				if(ctx->seek(ctx, sz, 1) < 0)
+					return -1;
+				continue;
+			}
+			if(ctx->read(ctx, ctx->buf, sz) != sz)
+				return -1;
+			ctx->buf[sz] = 0;
+
+			if((v = strchr(ctx->buf, '=')) == nil)
+				return -1;
+			*v++ = 0;
+			cbvorbiscomment(ctx, ctx->buf, v);
+		}
+	}
+
+	/* calculate the duration */
+	if(ctx->samplerate > 0){
+		sz = ctx->bufsz <= 4096 ? ctx->bufsz : 4096;
+		for(i = sz; i < 65536+16; i += sz - 16){
+			if(ctx->seek(ctx, -i, 2) <= 0)
+				break;
+			v = ctx->buf;
+			if(ctx->read(ctx, v, sz) != sz)
+				break;
+			for(; v != nil && v < ctx->buf+sz;){
+				v = memchr(v, 'O', ctx->buf+sz - v - 14);
+				if(v != nil && v[1] == 'g' && v[2] == 'g' && v[3] == 'S' && (v[5] & 4) == 4){ /* last page */
+					uvlong g = leuint(v+6) | (uvlong)leuint(v+10)<<32;
+					ctx->duration = g * 1000 / ctx->samplerate;
+					return 0;
+				}
+				if(v != nil)
+					v++;
+			}
+		}
+	}
+
+	return 0;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/wav.c
@@ -1,0 +1,87 @@
+#include "tagspriv.h"
+
+#define le16u(d) (u16int)((d)[0] | (d)[1]<<8)
+
+static struct {
+	char *s;
+	int type;
+}t[] = {
+	{"IART", Tartist},
+	{"ICRD", Tdate},
+	{"IGNR", Tgenre},
+	{"INAM", Ttitle},
+	{"IPRD", Talbum},
+	{"ITRK", Ttrack},
+};
+
+int
+tagwav(Tagctx *ctx)
+{
+	uchar *d;
+	int i, n, info;
+	u32int csz;
+	uvlong sz;
+
+	d = (uchar*)ctx->buf;
+
+	sz = 1;
+	info = 0;
+	for(i = 0; i < 8 && sz > 0; i++){
+		if(ctx->read(ctx, d, 4+4+(i?0:4)) != 4+4+(i?0:4))
+			return -1;
+		if(i == 0){
+			if(memcmp(d, "RIFF", 4) != 0 || memcmp(d+8, "WAVE", 4) != 0)
+				return -1;
+			sz = leuint(d+4);
+			if(sz < 4)
+				return -1;
+			sz -= 4;
+			continue;
+		}else if(memcmp(d, "INFO", 4) == 0){
+			info = 1;
+			ctx->seek(ctx, -4, 1);
+			continue;
+		}
+
+		if(sz <= 8)
+			break;
+		sz -= 4+4;
+		csz = leuint(d+4);
+		if(sz < csz)
+			break;
+		sz -= csz;
+
+		if(i == 1){
+			if(memcmp(d, "fmt ", 4) != 0 || csz < 16)
+				return -1;
+			if(ctx->read(ctx, d, 16) != 16)
+				return -1;
+			csz -= 16;
+			ctx->channels = le16u(d+2);
+			ctx->samplerate = leuint(d+4);
+			ctx->duration = sz*1000 / leuint(d+8);
+		}else if(memcmp(d, "LIST", 4) == 0){
+			sz = csz - 4;
+			continue;
+		}else if(memcmp(d, "data", 4) == 0){
+			break;
+		}else if(info){
+			csz++;
+			for(n = 0; n < nelem(t); n++){
+				if(memcmp(d, t[n].s, 4) == 0){
+					if(ctx->read(ctx, d, csz) != csz)
+						return -1;
+					d[csz-1] = 0;
+					txtcb(ctx, t[n].type, "", d);
+					csz = 0;
+					break;
+				}
+			}
+		}
+
+		if(ctx->seek(ctx, csz, 1) < 0)
+			return -1;
+	}
+
+	return i > 0 ? 0 : -1;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/libtags/xm.c
@@ -1,0 +1,15 @@
+#include "tagspriv.h"
+
+int
+tagxm(Tagctx *ctx)
+{
+	char d[17+20+1], *s;
+
+	if(ctx->read(ctx, d, 17+20) != 17+20 || memcmp(d, "Extended Module: ", 17) != 0)
+		return -1;
+	d[17+20] = 0;
+	for(s = d+17; *s == ' '; s++);
+	txtcb(ctx, Ttitle, "", s);
+
+	return 0;
+}
--- a/sys/src/cmd/audio/mkfile
+++ b/sys/src/cmd/audio/mkfile
@@ -1,7 +1,7 @@
 </$objtype/mkfile
 
-LIBS=libogg libvorbis libFLAC
-PROGS=pcmconv oggdec oggenc mp3dec mp3enc flacdec flacenc wavdec sundec mixfs
+LIBS=libogg libvorbis libFLAC libtags
+PROGS=pcmconv oggdec oggenc mp3dec mp3enc flacdec flacenc wavdec sundec mixfs readtags zuke
 #libs must be made first
 DIRS=$LIBS $PROGS
 
--- /dev/null
+++ b/sys/src/cmd/audio/readtags/mkfile
@@ -1,0 +1,13 @@
+</$objtype/mkfile
+<../config
+
+TARG=readtags
+CFLAGS=$CFLAGS -I../libtags
+
+OFILES=\
+	readtags.$O\
+	
+HFILES=\
+	../libtags/tags.h\
+
+</sys/src/cmd/mkone
--- /dev/null
+++ b/sys/src/cmd/audio/readtags/readtags.c
@@ -1,0 +1,112 @@
+#include <u.h>
+#include <libc.h>
+#include <tags.h>
+
+typedef struct Aux Aux;
+
+struct Aux
+{
+	int fd;
+};
+
+static const char *t2s[] =
+{
+	[Tartist] = "artist",
+	[Talbum] = "album",
+	[Ttitle] = "title",
+	[Tdate] = "date",
+	[Ttrack] = "track",
+	[Talbumgain] = "albumgain",
+	[Talbumpeak] = "albumpeak",
+	[Ttrackgain] = "trackgain",
+	[Ttrackpeak] = "trackpeak",
+	[Tgenre] = "genre",
+	[Timage] = "image",
+};
+
+static void
+tag(Tagctx *ctx, int t, const char *k, const char *v, int offset, int size, Tagread f)
+{
+	USED(ctx); USED(k); USED(f);
+	if(t == Timage)
+		print("%-12s %s %d %d\n", t2s[t], v, offset, size);
+	else if(t != Tunknown)
+		print("%-12s %s\n", t2s[t], v);
+}
+
+static void
+toc(Tagctx *ctx, int ms, int offset)
+{
+	USED(ctx); USED(ms); USED(offset);
+}
+
+static int
+ctxread(Tagctx *ctx, void *buf, int cnt)
+{
+	Aux *aux = ctx->aux;
+	return read(aux->fd, buf, cnt);
+}
+
+static int
+ctxseek(Tagctx *ctx, int offset, int whence)
+{
+	Aux *aux = ctx->aux;
+	return seek(aux->fd, offset, whence);
+}
+
+static void
+usage(void)
+{
+	fprint(2, "usage: %s FILE...\n", argv0);
+	exits("usage");
+}
+
+void
+main(int argc, char **argv)
+{
+	int i;
+	char buf[256];
+	Aux aux;
+	Tagctx ctx =
+	{
+		.read = ctxread,
+		.seek = ctxseek,
+		.tag = tag,
+		.toc = toc,
+		.buf = buf,
+		.bufsz = sizeof(buf),
+		.aux = &aux,
+	};
+
+	ARGBEGIN{
+	default:
+		usage();
+	}ARGEND
+
+	if(argc < 1)
+		usage();
+
+	for(i = 0; i < argc; i++){
+		print("*** %s\n", argv[i]);
+		if((aux.fd = open(argv[i], OREAD)) < 0)
+			print("failed to open\n");
+		else{
+			if(tagsget(&ctx) != 0)
+				print("no tags or failed to read tags\n");
+			else{
+				if(ctx.duration > 0)
+					print("%-12s %d ms\n", "duration", ctx.duration);
+				if(ctx.samplerate > 0)
+					print("%-12s %d\n", "samplerate", ctx.samplerate);
+				if(ctx.channels > 0)
+					print("%-12s %d\n", "channels", ctx.channels);
+				if(ctx.bitrate > 0)
+					print("%-12s %d\n", "bitrate", ctx.bitrate);
+			}
+			close(aux.fd);
+		}
+		print("\n");
+	}
+
+	exits(nil);
+}
--- /dev/null
+++ b/sys/src/cmd/audio/zuke/icy.c
@@ -1,0 +1,49 @@
+#include <u.h>
+#include <libc.h>
+#include <bio.h>
+#include "plist.h"
+#include "icy.h"
+
+int
+icyfill(Meta *m)
+{
+	char *s, *s0, *e, *p, *path, *d;
+	int f, n;
+
+	path = strdup(m->path);
+	s = strchr(path, ':')+3;
+	if((e = strchr(s, '/')) != nil)
+		*e++ = 0;
+	if((p = strchr(s, ':')) != nil)
+		*p = '!';
+	p = smprint("tcp!%s", s);
+	free(path);
+	f = -1;
+	if((d = netmkaddr(p, "tcp", "80")) != nil)
+		f = dial(d, nil, nil, nil);
+	free(p);
+	if(f < 0)
+		return -1;
+	fprint(f, "GET /%s HTTP/0.9\r\nIcy-MetaData: 1\r\n\r\n", e ? e : "");
+	s0 = malloc(4096);
+	if((n = readn(f, s0, 4095)) > 0){
+		s0[n] = 0;
+		for(s = s0; s = strchr(s, '\n');){
+			s++;
+			if(strncmp(s, "icy-name:", 9) == 0 && (e = strchr(s, '\r')) != nil){
+				*e = 0;
+				m->artist[0] = strdup(s+9);
+				m->numartist = 1;
+				s = e+1;
+			}else if(strncmp(s, "icy-url:", 8) == 0 && (e = strchr(s, '\r')) != nil){
+				*e = 0;
+				m->title = strdup(s+8);
+				s = e+1;
+			}
+		}
+	}
+	free(s0);
+	close(f);
+
+	return n > 0 ? 0 : -1;
+}
--- /dev/null
+++ b/sys/src/cmd/audio/zuke/icy.h
@@ -1,0 +1,1 @@
+int icyfill(Meta *m);
--- /dev/null
+++ b/sys/src/cmd/audio/zuke/mkfile
@@ -1,0 +1,17 @@
+</$objtype/mkfile
+<../config
+
+TARG=mkplist zuke
+
+HFILES=\
+	plist.h\
+	icy.h\
+	../libtags/tags.h\
+
+default:V: all
+
+$O.mkplist: icy.$O plist.$O mkplist.$O
+
+$O.zuke: plist.$O zuke.$O
+
+</sys/src/cmd/mkmany
--- /dev/null
+++ b/sys/src/cmd/audio/zuke/mkplist.c
@@ -1,0 +1,355 @@
+#include <u.h>
+#include <libc.h>
+#include <bio.h>
+#include <tags.h>
+#include "plist.h"
+#include "icy.h"
+
+enum
+{
+	Maxname = 256+2, /* seems enough? */
+	Maxdepth = 16, /* max recursion depth */
+};
+
+#define MAX(a, b) (a > b ? a : b)
+
+static Biobuf *bf, out;
+static Meta *curr;
+static Meta *all;
+static int numall;
+static int firstiscomposer;
+static int keepfirstartist;
+
+static char *fmts[] =
+{
+	[Fmp3] = "mp3",
+	[Fogg] = "ogg",
+	[Fflac] = "flac",
+	[Fm4a] = "m4a",
+	[Fopus] = "opus",
+	[Fwav] = "wav",
+	[Fit] = "mod",
+	[Fxm] = "mod",
+	[Fs3m] = "mod",
+};
+
+static Meta *
+newmeta(void)
+{
+	if(numall == 0){
+		free(all);
+		all = nil;
+	}
+	if(all == nil)
+		all = mallocz(sizeof(Meta), 1);
+	else if((numall & (numall-1)) == 0)
+		all = realloc(all, numall*2*sizeof(Meta));
+
+	if(all == nil)
+		return nil;
+
+	memset(&all[numall++], 0, sizeof(Meta));
+	return &all[numall-1];
+}
+
+static void
+cb(Tagctx *ctx, int t, const char *k, const char *v, int offset, int size, Tagread f)
+{
+	int i, iscomposer;
+
+	USED(ctx);
+
+	switch(t){
+	case Tartist:
+		if(curr->numartist < Maxartist){
+			iscomposer = strcmp(k, "TCM") == 0 || strcmp(k, "TCOM") == 0;
+			/* prefer lead performer/soloist, helps when TP2/TPE2 is the first one and is set to "VA" */
+			/* always put composer first, if available */
+			if(iscomposer || (!keepfirstartist && (strcmp(k, "TP1") == 0 || strcmp(k, "TPE1") == 0))){
+				if(curr->numartist > 0)
+					curr->artist[curr->numartist] = curr->artist[curr->numartist-1];
+				curr->artist[0] = strdup(v);
+				curr->numartist++;
+				keepfirstartist = 1;
+				firstiscomposer = iscomposer;
+				return;
+			}
+
+			for(i = 0; i < curr->numartist; i++){
+				if(cistrcmp(curr->artist[i], v) == 0)
+					return;
+			}
+			curr->artist[curr->numartist++] = strdup(v);
+		}
+		break;
+	case Talbum:
+		if(curr->album == nil)
+			curr->album = strdup(v);
+		break;
+	case Ttitle:
+		if(curr->title == nil)
+			curr->title = strdup(v);
+		break;
+	case Tdate:
+		if(curr->date == nil)
+			curr->date = strdup(v);
+		break;
+	case Ttrack:
+		if(curr->track == nil)
+			curr->track = strdup(v);
+		break;
+	case Timage:
+		if(curr->imagefmt == nil){
+			curr->imagefmt = strdup(v);
+			curr->imageoffset = offset;
+			curr->imagesize = size;
+			curr->imagereader = f != nil;
+		}
+		break;
+	}
+}
+
+static int
+ctxread(Tagctx *ctx, void *buf, int cnt)
+{
+	USED(ctx);
+	return Bread(bf, buf, cnt);
+}
+
+static int
+ctxseek(Tagctx *ctx, int offset, int whence)
+{
+	USED(ctx);
+	return Bseek(bf, offset, whence);
+}
+
+static char buf[4096];
+static Tagctx ctx =
+{
+	.read = ctxread,
+	.seek = ctxseek,
+	.tag = cb,
+	.buf = buf,
+	.bufsz = sizeof(buf),
+	.aux = nil,
+};
+
+static uvlong
+modduration(char *path)
+{
+	static int moddec = -1;
+	int f, pid, p[2], n;
+	char t[1024], *s;
+
+	if(moddec < 0)
+		moddec = close(open("/bin/audio/moddec", OEXEC)) == 0;
+	if(!moddec)
+		return 0;
+
+	pipe(p);
+	if((pid = rfork(RFPROC|RFFDG|RFNOTEG|RFCENVG|RFNOWAIT)) == 0){
+		dup(f = open(path, OREAD), 0); close(f);
+		close(1);
+		dup(p[1], 2); close(p[1]);
+		close(p[0]);
+		execl("/bin/audio/moddec", "moddec", "-r", "0", nil);
+		sysfatal("execl: %r");
+	}
+	close(p[1]);
+
+	n = pid > 0 ? readn(p[0], t, sizeof(t)-1) : -1;
+	close(p[0]);
+	if(n > 0){
+		t[n] = 0;
+		for(s = t; s != nil; s = strchr(s+1, '\n')){
+			if(*s == '\n')
+				s++;
+			if(strncmp(s, "duration: ", 10) == 0)
+				return strtod(s+10, nil)*1000.0;
+		}
+	}
+
+	return 0;
+}
+
+static void
+scanfile(char *path)
+{
+	int res;
+	char *s;
+
+	if((bf = Bopen(path, OREAD)) == nil){
+		fprint(2, "%s: %r\n", path);
+		return;
+	}
+	if((curr = newmeta()) == nil)
+		sysfatal("no memory");
+	firstiscomposer = keepfirstartist = 0;
+	res = tagsget(&ctx);
+	if(ctx.format != Funknown){
+		if(res != 0)
+			fprint(2, "%s: no tags\n", path);
+	}else{
+		numall--;
+		Bterm(bf);
+		return;
+	}
+
+	if(ctx.duration == 0){
+		if(ctx.format == Fit || ctx.format == Fxm || ctx.format == Fs3m)
+			ctx.duration = modduration(path);
+		if(ctx.duration == 0)
+			fprint(2, "%s: no duration\n", path);
+	}
+	if(curr->title == nil){
+		if((s = utfrrune(path, '/')) == nil)
+			s = path;
+		curr->title = strdup(s+1);
+	}
+	curr->path = strdup(path);
+	curr->duration = ctx.duration;
+	if(ctx.format >= nelem(fmts))
+		sysfatal("mkplist needs a rebuild with updated libtags");
+	curr->filefmt = fmts[ctx.format];
+	Bterm(bf);
+}
+
+static int
+scan(char **dir, int depth)
+{
+	char *path;
+	Dir *buf, *d;
+	long n;
+	int dirfd, len;
+
+	if((dirfd = open(*dir, OREAD)) < 0)
+		sysfatal("%s: %r", *dir);
+	len = strlen(*dir);
+	if((*dir = realloc(*dir, len+1+Maxname)) == nil)
+		sysfatal("no memory");
+	path = *dir;
+	path[len] = '/';
+
+	for(n = 0, buf = nil; n >= 0;){
+		if((n = dirread(dirfd, &buf)) < 0){
+			path[len] = 0;
+			scanfile(path);
+			break;
+		}
+		if(n == 0){
+			free(buf);
+			break;
+		}
+
+		for(d = buf; n > 0; n--, d++){
+			if(strcmp(d->name, ".") == 0 || strcmp(d->name, "..") == 0)
+				continue;
+
+			path[len+1+Maxname-2] = 0;
+			strncpy(&path[len+1], d->name, Maxname);
+			if(path[len+1+Maxname-2] != 0)
+				sysfatal("Maxname=%d was a bad choice", Maxname);
+
+			if((d->mode & DMDIR) == 0){
+				scanfile(path);
+			}else if(depth < Maxdepth){ /* recurse into the directory */
+				scan(dir, depth+1);
+				path = *dir;
+			}else{
+				fprint(2, "%s: too deep\n", path);
+			}
+		}
+		free(buf);
+	}
+
+	close(dirfd);
+
+	return 0;
+}
+
+static int
+cmpmeta(void *a_, void *b_)
+{
+	Meta *a, *b;
+	char *ae, *be;
+	int i, x;
+
+	a = a_;
+	b = b_;
+
+	ae = utfrrune(a->path, '/');
+	be = utfrrune(b->path, '/');
+	if(ae != nil && be != nil && (x = cistrncmp(a->path, b->path, MAX(ae-a->path, be-b->path))) != 0) /* different path */
+		return x;
+
+	/* same path, must be the same album/cd, but first check */
+	for(i = 0; i < a->numartist && i < b->numartist; i++){
+		if((x = cistrcmp(a->artist[i], b->artist[i])) != 0){
+			if(a->album != nil && b->album != nil && cistrcmp(a->album, b->album) != 0)
+				return x;
+		}
+	}
+
+	if(a->date != nil || b->date != nil){
+		if(a->date == nil && b->date != nil) return -1;
+		if(a->date != nil && b->date == nil) return 1;
+		if((x = atoi(a->date) - atoi(b->date)) != 0) return x;
+	}else if(a->album != nil || b->album != nil){
+		if(a->album == nil && b->album != nil) return -1;
+		if(a->album != nil && b->album == nil) return 1;
+		if((x = cistrcmp(a->album, b->album)) != 0) return x;
+	}
+
+	if(a->track != nil || b->track != nil){
+		if(a->track == nil && b->track != nil) return -1;
+		if(a->track != nil && b->track == nil) return 1;
+		if((x = atoi(a->track) - atoi(b->track)) != 0) return x;
+	}
+
+	return cistrcmp(a->path, b->path);
+}
+
+void
+main(int argc, char **argv)
+{
+	char *dir, wd[4096];
+	int i;
+
+	if(argc < 2){
+		fprint(2, "usage: mkplist DIR [DIR2 ...] > noise.plist\n");
+		exits("usage");
+	}
+	getwd(wd, sizeof(wd));
+
+	Binit(&out, 1, OWRITE);
+
+	for(i = 1; i < argc; i++){
+		if(strncmp(argv[i], "http://", 7) == 0 || strncmp(argv[i], "https://", 8) == 0){
+			if((curr = newmeta()) == nil)
+				sysfatal("no memory");
+			curr->title = argv[i];
+			curr->path = argv[i];
+			curr->filefmt = "";
+			if(icyfill(curr) != 0)
+				fprint(2, "%s: %r\n", argv[i]);
+		}else{
+			if(argv[i][0] == '/')
+				dir = strdup(argv[i]);
+			else
+				dir = smprint("%s/%s", wd, argv[i]);
+			cleanname(dir);
+			scan(&dir, 0);
+		}
+	}
+	qsort(all, numall, sizeof(Meta), cmpmeta);
+	for(i = 0; i < numall; i++){
+		if(all[i].numartist < 1)
+			fprint(2, "no artists: %s\n", all[i].path);
+		if(all[i].title == nil)
+			fprint(2, "no title: %s\n", all[i].path);
+		printmeta(&out, all+i);
+	}
+	Bterm(&out);
+	fprint(2, "found %d tagged tracks\n", numall);
+	exits(nil);
+}
--- /dev/null
+++ b/sys/src/cmd/audio/zuke/plist.c
@@ -1,0 +1,27 @@
+#include <u.h>
+#include <libc.h>
+#include <bio.h>
+#include "plist.h"
+
+void
+printmeta(Biobuf *b, Meta *m)
+{
+	int i;
+
+	Bprint(b, "%c %s\n%c %s\n", Ppath, m->path, Pfilefmt, m->filefmt);
+	for(i = 0; i < m->numartist; i++)
+		Bprint(b, "%c %s\n", Partist, m->artist[i]);
+	if(m->album != nil)
+		Bprint(b, "%c %s\n", Palbum, m->album);
+	if(m->title != nil)
+		Bprint(b, "%c %s\n", Ptitle, m->title);
+	if(m->date != nil)
+		Bprint(b, "%c %s\n", Pdate, m->date);
+	if(m->track != nil)
+		Bprint(b, "%c %s\n", Ptrack, m->track);
+	if(m->duration > 0)
+		Bprint(b, "%c %llud\n", Pduration, m->duration);
+	if(m->imagesize > 0)
+		Bprint(b, "%c %d %d %d %s\n", Pimage, m->imageoffset, m->imagesize, m->imagereader, m->imagefmt);
+	Bprint(b, "\n");
+}
--- /dev/null
+++ b/sys/src/cmd/audio/zuke/plist.h
@@ -1,0 +1,52 @@
+/* Playlist begins with "# x\n" where x is the total number of records.
+ * Each record begins with "# x y\n" where x is record index, y is its size in bytes.
+ * Records are sorted according to mkplist.c:/^cmpmeta function.
+ * This makes it somewhat easy to just load the whole playlist into memory once,
+ * map all (Meta*)->... fields to it, saving on memory allocations, and using the same
+ * data to provide poor's man full text searching.
+ * Encoding: mkplist.c:/^printmeta/.
+ * Decoding: zuke.c:/^readplist/.
+ */
+enum
+{
+	Precord='#',
+
+	Palbum=			'a',
+	Partist=		'A',
+	Pbasename=		'b',
+	Pdate=			'd',
+	Pduration=		'D',
+	Pimage=			'i',
+	Ptitle=			't',
+	Ptrack=			'T',
+	Ppath=			'p',
+	Pfilefmt=		'f',
+
+	/* unused */
+	Pchannels=		'c',
+	Psamplerate=	's',
+
+	Maxartist=16, /* max artists for a track */
+};
+
+typedef struct Meta Meta;
+
+struct Meta
+{
+	char *artist[Maxartist];
+	char *album;
+	char *title;
+	char *date;
+	char *track;
+	char *path;
+	char *basename;
+	char *imagefmt;
+	char *filefmt;
+	uvlong duration;
+	int numartist;
+	int imageoffset;
+	int imagesize;
+	int imagereader; /* non-zero if a special reader required */
+};
+
+void printmeta(Biobuf *b, Meta *m);
--- /dev/null
+++ b/sys/src/cmd/audio/zuke/zuke.c
@@ -1,0 +1,1372 @@
+#include <u.h>
+#include <libc.h>
+#include <bio.h>
+#include <thread.h>
+#include <draw.h>
+#include <mouse.h>
+#include <keyboard.h>
+#include <plumb.h>
+#include <ctype.h>
+#include "plist.h"
+
+#define MAX(a,b) ((a)>=(b)?(a):(b))
+#define MIN(a,b) ((a)<=(b)?(a):(b))
+#define CLAMP(x,min,max) MAX(min, MIN(max, x))
+
+typedef struct Color Color;
+typedef struct Player Player;
+typedef struct Playlist Playlist;
+
+enum
+{
+	Cstart = 1,
+	Cstop,
+	Ctoggle,
+	Cseekrel,
+
+	Everror = 1,
+	Evready,
+
+	Seek = 10, /* 10 seconds */
+	Seekfast = 60, /* a minute */
+
+	Bps = 44100*2*2, /* 44100KHz, stereo, s16 for a sample */
+	Relbufsz = Bps/2, /* 0.5s */
+
+	Dback = 0,
+	Dfhigh,
+	Dfmed,
+	Dflow,
+	Dfinv,
+	Dbmed,
+	Dblow,
+	Dbinv,
+	Numcolors,
+};
+
+struct Color {
+	u32int rgb;
+	Image *im;
+};
+
+struct Player
+{
+	Channel *ctl;
+	Channel *ev;
+	Channel *img;
+	double seek;
+	int pcur;
+};
+
+struct Playlist
+{
+	Meta *m;
+	int n;
+	char *raw;
+	int rawsz;
+};
+
+int mainstacksize = 32768;
+
+static int debug;
+static int audio = -1;
+static int volume;
+static int pnotifies;
+static Playlist *pl;
+static Player *playernext;
+static Player *playercurr;
+static vlong byteswritten;
+static int pcur, pcurplaying;
+static int scroll, scrollsz;
+static Font *f;
+static Image *cover;
+static Channel *playc;
+static Mousectl *mctl;
+static Keyboardctl *kctl;
+static int colwidth[10];
+static int mincolwidth[10];
+static char *cols = "AatD";
+static int colspath;
+static int *shuffle;
+static Rectangle seekbar;
+static int seekmx, newseekmx = -1;
+static double seekoff; /* ms */
+static Lock audiolock;
+static int audioerr = 0;
+static Biobuf out;
+static char *covers[] =
+{
+	"art", "folder", "cover", "Cover", "scans/CD", "Scans/Front", "Covers/Front"
+};
+
+static Color colors[Numcolors] =
+{
+	[Dback]  = {0xf0f0f0},
+	[Dfhigh] = {0xffffff},
+	[Dfmed]  = {0x343434},
+	[Dflow]  = {0xa5a5a5},
+	[Dfinv]  = {0x323232},
+	[Dbmed]  = {0x72dec2},
+	[Dblow]  = {0x404040},
+	[Dbinv]  = {0xffb545},
+};
+
+static int Scrollwidth;
+static int Scrollheight;
+static int Coversz;
+
+static void
+audioon(void)
+{
+	lock(&audiolock);
+	if(audio < 0 && (audio = open("/dev/audio", OWRITE|OCEXEC)) < 0 && audioerr == 0){
+		fprint(2, "%r\n");
+		audioerr = 1;
+	}
+	unlock(&audiolock);
+}
+
+static void
+audiooff(void)
+{
+	lock(&audiolock);
+	close(audio);
+	audio = -1;
+	audioerr = 0;
+	unlock(&audiolock);
+}
+
+#pragma varargck type "P" uvlong
+static int
+positionfmt(Fmt *f)
+{
+	char *s, tmp[16];
+	u64int sec;
+
+	s = tmp;
+	sec = va_arg(f->args, int);
+	if(sec >= 3600){
+		s = seprint(s, tmp+sizeof(tmp), "%02lld:", sec/3600);
+		sec %= 3600;
+	}
+	s = seprint(s, tmp+sizeof(tmp), "%02lld:", sec/60);
+	sec %= 60;
+	seprint(s, tmp+sizeof(tmp), "%02lld", sec);
+
+	return fmtstrcpy(f, tmp);
+}
+
+static char *
+getcol(Meta *m, int c)
+{
+	static char tmp[32];
+
+	switch(c){
+	case Palbum: return m->album;
+	case Partist: return m->artist[0];
+	case Pdate: return m->date;
+	case Ptitle: return (!colspath && *m->title == 0) ? m->basename : m->title;
+	case Ptrack: snprint(tmp, sizeof(tmp), "%4s", m->track); return m->track ? tmp : nil;
+	case Ppath: return m->path;
+	case Pduration:
+		tmp[0] = 0;
+		if(m->duration > 0)
+			snprint(tmp, sizeof(tmp), "%8P", m->duration/1000);
+		return tmp;
+	default: sysfatal("invalid column '%c'", c);
+	}
+
+	return nil;
+}
+
+static void
+adjustcolumns(void)
+{
+	int i, n, x, total, width;
+
+	if(mincolwidth[0] == 0){
+		for(i = 0; cols[i] != 0; i++)
+			mincolwidth[i] = 1;
+		for(n = 0; n < pl->n; n++){
+			for(i = 0; cols[i] != 0; i++){
+				if((x = stringwidth(f, getcol(pl->m+n, cols[i]))) > mincolwidth[i])
+					mincolwidth[i] = x;
+			}
+		}
+	}
+
+	total = 0;
+	n = 0;
+	width = Dx(screen->r);
+	for(i = 0; cols[i] != 0; i++){
+		if(cols[i] == Pduration || cols[i] == Pdate || cols[i] == Ptrack)
+			width -= mincolwidth[i] + 8;
+		else{
+			total += mincolwidth[i];
+			n++;
+		}
+	}
+	colspath = 0;
+	for(i = 0; cols[i] != 0; i++){
+		if(cols[i] == Ppath || cols[i] == Pbasename)
+			colspath = 1;
+		if(cols[i] == Pduration || cols[i] == Pdate || cols[i] == Ptrack)
+			colwidth[i] = mincolwidth[i];
+		else
+			colwidth[i] = (width - Scrollwidth - n*8) * mincolwidth[i] / total;
+	}
+}
+
+static Meta *
+getmeta(int i)
+{
+	return &pl->m[shuffle != nil ? shuffle[i] : i];
+}
+
+static void
+updatescrollsz(void)
+{
+	scrollsz = Dy(screen->r)/f->height - 2;
+}
+
+static void
+redraw(int full)
+{
+	Image *col;
+	Point p, sp;
+	Rectangle sel, r;
+	int i, j, left, right, scrollcenter, w;
+	uvlong dur, msec;
+	char tmp[32];
+
+	lockdisplay(display);
+	updatescrollsz();
+	scroll = CLAMP(scroll, 0, pl->n - scrollsz);
+	left = screen->r.min.x;
+	if(scrollsz < pl->n) /* adjust for scrollbar */
+		left += Scrollwidth + 1;
+
+	if(full){
+		draw(screen, screen->r, colors[Dback].im, nil, ZP);
+
+		adjustcolumns();
+		if(scrollsz < pl->n){ /* scrollbar */
+			p.x = sp.x = screen->r.min.x + Scrollwidth;
+			p.y = screen->r.min.y;
+			sp.y = screen->r.max.y;
+			line(screen, p, sp, Endsquare, Endsquare, 0, colors[Dflow].im, ZP);
+
+			r = screen->r;
+			r.max.x = r.min.x + Scrollwidth - 1;
+			r.min.x += 1;
+			if(scroll < 1)
+				scrollcenter = 0;
+			else
+				scrollcenter = (Dy(screen->r)-Scrollheight*5/4)*scroll / (pl->n - scrollsz);
+			r.min.y += scrollcenter + Scrollheight/4;
+			r.max.y = r.min.y + Scrollheight;
+			draw(screen, r, colors[Dblow].im, nil, ZP);
+		}
+
+		p.x = sp.x = left;
+		p.y = 0;
+		sp.y = screen->r.max.y;
+		for(i = 0; cols[i+1] != 0; i++){
+			p.x += colwidth[i] + 4;
+			sp.x = p.x;
+			line(screen, p, sp, Endsquare, Endsquare, 0, colors[Dflow].im, ZP);
+			p.x += 4;
+		}
+
+		sp.x = sp.y = 0;
+		p.x = left + 2;
+		p.y = screen->r.min.y + 2;
+
+		for(i = scroll; i < pl->n; i++, p.y += f->height){
+			if(i < 0)
+				continue;
+			if(p.y > screen->r.max.y)
+				break;
+
+			if(pcur == i){
+				sel.min.x = left;
+				sel.min.y = p.y;
+				sel.max.x = screen->r.max.x;
+				sel.max.y = p.y + f->height;
+				draw(screen, sel, colors[Dbinv].im, nil, ZP);
+				col = colors[Dfinv].im;
+			}else{
+				col = colors[Dfmed].im;
+			}
+
+			sel = screen->r;
+
+			p.x = left + 2 + 3;
+			for(j = 0; cols[j] != 0; j++){
+				sel.max.x = p.x + colwidth[j];
+				replclipr(screen, 0, sel);
+				string(screen, p, col, sp, f, getcol(getmeta(i), cols[j]));
+				p.x += colwidth[j] + 8;
+			}
+			replclipr(screen, 0, screen->r);
+
+			if(pcurplaying == i){
+				Point rightp, leftp;
+				leftp.y = rightp.y = p.y - 1;
+				leftp.x = left;
+				rightp.x = screen->r.max.x;
+				line(screen, leftp, rightp, 0, 0, 0, colors[Dflow].im, sp);
+				leftp.y = rightp.y = p.y + f->height;
+				line(screen, leftp, rightp, 0, 0, 0, colors[Dflow].im, sp);
+			}
+		}
+	}
+
+	msec = 0;
+	dur = getmeta(pcurplaying)->duration;
+	if(pcurplaying >= 0){
+		msec = byteswritten*1000/Bps;
+		if(dur > 0){
+			snprint(tmp, sizeof(tmp), "%s%P/%P 100%%",
+				shuffle != nil ? "∫ " : "",
+				dur/1000, dur/1000);
+			w = stringwidth(f, tmp);
+			msec = MIN(msec, dur);
+			snprint(tmp, sizeof(tmp), "%s%P/%P %d%%",
+				shuffle != nil ? "∫ " : "",
+				(uvlong)(newseekmx >= 0 ? seekoff : msec)/1000,
+				dur/1000, volume);
+		}else{
+			snprint(tmp, sizeof(tmp), "%s%P %d%%",
+				shuffle != nil ? "∫ " : "",
+				msec/1000, 100);
+			w = stringwidth(f, tmp);
+			snprint(tmp, sizeof(tmp), "%s%P %d%%",
+				shuffle != nil ? "∫ " : "",
+				msec/1000, volume);
+		}
+	}else{
+		snprint(tmp, sizeof(tmp), "%s%d%%", shuffle != nil ? "∫ " : "", 100);
+		w = stringwidth(f, tmp);
+		snprint(tmp, sizeof(tmp), "%s%d%%", shuffle != nil ? "∫ " : "", volume);
+	}
+	r = screen->r;
+	right = r.max.x - w - 4;
+	r.min.x = left;
+	r.min.y = r.max.y - f->height - 4;
+	if(pcurplaying < 0 || dur == 0)
+		r.min.x = right;
+	draw(screen, r, colors[Dblow].im, nil, ZP);
+	p = addpt(Pt(r.max.x-stringwidth(f, tmp)-4, r.min.y), Pt(2, 2));
+	r.max.x = right;
+	string(screen, p, colors[Dfhigh].im, sp, f, tmp);
+	sel = r;
+
+	if(cover != nil && full){
+		r.max.x = r.min.x;
+		r.min.x = screen->r.max.x - cover->r.max.x - 8;
+		draw(screen, r, colors[Dblow].im, nil, ZP);
+		r = screen->r;
+		r.min.x = r.max.x - cover->r.max.x - 8;
+		r.min.y = r.max.y - cover->r.max.y - 8 - f->height - 4;
+		r.max.y = r.min.y + cover->r.max.y + 8;
+		draw(screen, r, colors[Dblow].im, nil, ZP);
+		draw(screen, insetrect(r, 4), cover, nil, ZP);
+	}
+
+	/* seek bar */
+	seekbar = ZR;
+	if(pcurplaying >= 0 && dur > 0){
+		r = insetrect(sel, 3);
+		draw(screen, r, colors[Dback].im, nil, ZP);
+		seekbar = r;
+		r.max.x = r.min.x + Dx(r) * (double)msec / (double)dur;
+		draw(screen, r, colors[Dbmed].im, nil, ZP);
+	}
+
+	flushimage(display, 1);
+	unlockdisplay(display);
+}
+
+static void
+coverload(void *player_)
+{
+	int p[2], pid, fd, i;
+	char *prog, *path, *s, tmp[32];
+	Meta *m;
+	Channel *ch;
+	Player *player;
+	Image *newcover;
+
+	threadsetname("cover");
+	player = player_;
+	m = getmeta(player->pcur);
+	pid = -1;
+	ch = player->img;
+	fd = -1;
+	prog = nil;
+
+	if(m->imagefmt != nil && m->imagereader == 0){
+		if(strcmp(m->imagefmt, "image/png") == 0)
+			prog = "png";
+		else if(strcmp(m->imagefmt, "image/jpeg") == 0)
+			prog = "jpg";
+	}
+
+	if(prog == nil){
+		path = strdup(m->path);
+		if(path != nil && (s = utfrrune(path, '/')) != nil){
+			*s = 0;
+
+			for(i = 0; i < nelem(covers) && prog == nil; i++){
+				if((s = smprint("%s/%s.jpg", path, covers[i])) != nil && (fd = open(s, OREAD)) >= 0)
+					prog = "jpg";
+				free(s);
+				s = nil;
+				if(fd < 0 && (s = smprint("%s/%s.png", path, covers[i])) != nil && (fd = open(s, OREAD)) >= 0)
+					prog = "png";
+				free(s);
+			}
+		}
+		free(path);
+	}
+
+	if(prog == nil)
+		goto done;
+
+	if(fd < 0){
+		fd = open(m->path, OREAD);
+		seek(fd, m->imageoffset, 0);
+	}
+	pipe(p);
+	if((pid = rfork(RFPROC|RFFDG|RFNOTEG|RFCENVG|RFNOWAIT)) == 0){
+		dup(fd, 0); close(fd);
+		dup(p[1], 1); close(p[1]);
+		if(!debug){
+			dup(fd = open("/dev/null", OWRITE), 2);
+			close(fd);
+		}
+		snprint(tmp, sizeof(tmp), "%s -9t | resample -x%d", prog, Coversz);
+		execl("/bin/rc", "rc", "-c", tmp, nil);
+		sysfatal("execl: %r");
+	}
+	close(fd);
+	close(p[1]);
+
+	if(pid > 0){
+		newcover = readimage(display, p[0], 1);
+		sendp(ch, newcover);
+	}
+	close(p[0]);
+done:
+	if(pid < 0)
+		sendp(ch, nil);
+	chanclose(ch);
+	chanfree(ch);
+	if(pid >= 0)
+		postnote(PNGROUP, pid, "interrupt");
+	threadexits(nil);
+}
+
+static int
+playerret(Player *player)
+{
+	return recvul(player->ev) == Everror ? -1 : 0;
+}
+
+static void
+pnotify(Player *p)
+{
+	Meta *m;
+	char *s;
+	int i;
+
+	if(!pnotifies)
+		return;
+
+	if(p != nil){
+		m = getmeta(p->pcur);
+		for(i = 0; cols[i] != 0; i++)
+			Bprint(&out, "%s\t", (s = getcol(m, cols[i])) ? s : "");
+	}
+	Bprint(&out, "\n");
+	Bflush(&out);
+}
+
+static void
+stop(Player *player)
+{
+	if(player == nil)
+		return;
+
+	if(player == playernext)
+		playernext = nil;
+	if(!getmeta(player->pcur)->filefmt[0])
+		playerret(player);
+	if(player == playercurr)
+		pnotify(nil);
+	sendul(player->ctl, Cstop);
+}
+
+static void
+start(Player *player)
+{
+	if(player == nil)
+		return;
+	if(!getmeta(player->pcur)->filefmt[0])
+		playerret(player);
+	pnotify(player);
+	sendul(player->ctl, Cstart);
+}
+
+static void playerthread(void *player_);
+
+static Player *
+newplayer(int pcur, int loadnext)
+{
+	Player *player;
+
+	if(playernext != nil && loadnext){
+		if(pcur == playernext->pcur){
+			player = playernext;
+			playernext = nil;
+			goto done;
+		}
+		stop(playernext);
+		playernext = nil;
+	}
+
+	player = mallocz(sizeof(*player), 1);
+	player->ctl = chancreate(sizeof(ulong), 0);
+	player->ev = chancreate(sizeof(ulong), 0);
+	player->pcur = pcur;
+
+	threadcreate(playerthread, player, 4096);
+	if(getmeta(pcur)->filefmt[0] && playerret(player) < 0)
+		return nil;
+
+done:
+	if(pcur < pl->n-1 && playernext == nil && loadnext)
+		playernext = newplayer(pcur+1, 0);
+
+	return player;
+}
+
+static void
+playerthread(void *player_)
+{
+	char *buf, cmd[64], seekpos[12], *fmt;
+	Player *player;
+	Ioproc *io;
+	Image *thiscover;
+	ulong c;
+	int p[2], fd, pid, noinit, trycoverload;
+	long n, r;
+	vlong boffset, boffsetlast;
+	Meta *cur;
+
+	threadsetname("player");
+	player = player_;
+	noinit = 0;
+	boffset = 0;
+	buf = nil;
+	trycoverload = 1;
+	io = nil;
+	pid = -1;
+
+restart:
+	cur = getmeta(player->pcur);
+	fmt = cur->filefmt;
+	fd = -1;
+	if(*fmt){
+		if((fd = open(cur->path, OREAD)) < 0){
+			fprint(2, "%r\n");
+			sendul(player->ev, Everror);
+			chanclose(player->ev);
+			goto freeplayer;
+		}
+	}else{
+		sendul(player->ev, Evready);
+		chanclose(player->ev);
+	}
+
+	pipe(p);
+	if((pid = rfork(RFPROC|RFFDG|RFNOTEG|RFCENVG|RFNOWAIT)) == 0){
+		close(p[1]);
+		if(fd < 0)
+			fd = open("/dev/null", OREAD);
+		dup(fd, 0); close(fd);
+		dup(p[0], 1); close(p[0]);
+		if(!debug){
+			dup(fd = open("/dev/null", OWRITE), 2);
+			close(fd);
+		}
+		if(*fmt){
+			snprint(cmd, sizeof(cmd), "/bin/audio/%sdec", fmt);
+			snprint(seekpos, sizeof(seekpos), "%g", (double)boffset/Bps);
+			execl(cmd, cmd, boffset ? "-s" : nil, seekpos, nil);
+		}else{
+			execl("/bin/play", "play", "-o", "/fd/1", cur->path, nil);
+		}
+		close(0);
+		close(1);
+		exits("%r");
+	}
+	if(pid < 0)
+		sysfatal("rfork: %r");
+	if(fd >= 0)
+		close(fd);
+	close(p[0]);
+
+	c = 0;
+	if(!noinit){
+		if(*fmt){
+			sendul(player->ev, Evready);
+			chanclose(player->ev);
+		}
+		buf = malloc(Relbufsz);
+		if((io = ioproc()) == nil)
+			sysfatal("player: %r");
+		if((n = ioreadn(io, p[1], buf, Relbufsz)) < 0)
+			fprint(2, "player: %r\n");
+		if(recv(player->ctl, &c) < 0 || c != Cstart)
+			goto freeplayer;
+		if(n < 1)
+			goto next;
+		audioon();
+		boffset = iowrite(io, audio, buf, n);
+		noinit = 1;
+	}
+
+	boffsetlast = boffset;
+	byteswritten = boffset;
+	pcurplaying = player->pcur;
+	if(c != Cseekrel)
+		redraw(1);
+
+	while(1){
+		n = ioread(io, p[1], buf, Relbufsz);
+		if(n <= 0)
+			break;
+
+		thiscover = nil;
+		if(player->img != nil && nbrecv(player->img, &thiscover) != 0){
+			freeimage(cover);
+			cover = thiscover;
+			redraw(1);
+			player->img = nil;
+		}
+		r = nbrecv(player->ctl, &c);
+		if(r < 0){
+			audiooff();
+			goto stop;
+		}else if(r != 0){
+			if(c == Ctoggle){
+				audiooff();
+				if(recv(player->ctl, &c) < 0 || c == Cstop)
+					goto stop;
+			}else if(c == Cseekrel){
+				boffset = MAX(0, boffset + player->seek*Bps);
+				n = 0;
+				break;
+			}else if(c == Cstop){
+				audiooff();
+				goto stop;
+			}
+		}
+
+		boffset += n;
+		byteswritten = boffset;
+		audioon();
+		iowrite(io, audio, buf, n);
+		if(trycoverload){
+			trycoverload = 0;
+			player->img = chancreate(sizeof(Image*), 0);
+			proccreate(coverload, player, 4096);
+		}
+		if(labs(boffset/Relbufsz - boffsetlast/Relbufsz) > 0){
+			boffsetlast = boffset;
+			redraw(0);
+		}
+	}
+
+	if(n < 1){ /* seeking backwards or end of the song */
+		close(p[1]);
+		p[1] = -1;
+		if(c != Cseekrel || (getmeta(pcurplaying)->duration && boffset >= getmeta(pcurplaying)->duration/1000*Bps)){
+next:
+			playercurr = nil;
+			playercurr = newplayer((player->pcur+1) % pl->n, 1);
+			start(playercurr);
+			goto stop;
+		}
+		goto restart;
+	}
+
+stop:
+	if(player->img != nil)
+		freeimage(recvp(player->img));
+freeplayer:
+	chanfree(player->ctl);
+	chanfree(player->ev);
+	if(pid >= 0)
+		postnote(PNGROUP, pid, "interrupt");
+	closeioproc(io);
+	if(p[1] >= 0)
+		close(p[1]);
+	if(player == playercurr)
+		playercurr = nil;
+	if(player == playernext)
+		playernext = nil;
+	free(buf);
+	free(player);
+	threadexits(nil);
+}
+
+static void
+toggle(Player *player)
+{
+	if(player != nil)
+		sendul(player->ctl, Ctoggle);
+}
+
+static void
+seekrel(Player *player, double off)
+{
+	if(player != nil && *getmeta(pcurplaying)->filefmt){
+		player->seek = off;
+		sendul(player->ctl, Cseekrel);
+	}
+}
+
+static void
+writeplist(void)
+{
+	int i;
+
+	for(i = 0; i < pl->n; i++)
+		printmeta(&out, pl->m+i);
+}
+
+static void
+freeplist(Playlist *pl)
+{
+	if(pl != nil){
+		free(pl->m);
+		free(pl->raw);
+	}
+	free(pl);
+}
+
+static char *
+readall(int f)
+{
+	int bufsz, sz, n;
+	char *s;
+
+	bufsz = 1023;
+	s = nil;
+	for(sz = 0;; sz += n){
+		if(bufsz-sz < 1024){
+			bufsz *= 2;
+			s = realloc(s, bufsz);
+		}
+		if((n = readn(f, s+sz, bufsz-sz-1)) < 1)
+			break;
+	}
+	if(n < 0 || sz < 1){
+		free(s);
+		return nil;
+	}
+	s[sz] = 0;
+
+	return s;
+}
+
+static Playlist *
+readplist(int fd)
+{
+	char *raw, *s, *e, *a[5], *b;
+	Playlist *pl;
+	int plsz;
+	Meta *m;
+
+	if((raw = readall(fd)) == nil)
+		return nil;
+
+	plsz = 0;
+	for(s = raw; (s = strchr(s, '\n')) != nil; s++){
+		if(*(++s) == '\n')
+			plsz++;
+	}
+
+	if((pl = calloc(1, sizeof(*pl))) == nil || (pl->m = calloc(plsz+1, sizeof(Meta))) == nil){
+		freeplist(pl);
+		werrstr("no memory");
+		return nil;
+	}
+
+	pl->raw = raw;
+	for(s = pl->raw, m = pl->m;; s = e){
+		if((e = strchr(s, '\n')) == nil)
+			break;
+		s += 2;
+		*e++ = 0;
+		switch(s[-2]){
+		case 0:
+			if(m->path != nil){
+				pl->n++;
+				m++;
+			}
+			break;
+		case Pimage:
+			if(tokenize(s, a, nelem(a)) >= 4){
+				m->imageoffset = atoi(a[0]);
+				m->imagesize = atoi(a[1]);
+				m->imagereader = atoi(a[2]);
+				m->imagefmt = a[3];
+			}
+			break;
+		case Pduration:
+			m->duration = strtoull(s, nil, 0);
+			break;
+		case Partist:
+			if(m->numartist < Maxartist)
+				m->artist[m->numartist++] = s;
+			break;
+		case Pfilefmt: m->filefmt = s; break;
+		case Palbum:   m->album = s; break;
+		case Pdate:    m->date = s; break;
+		case Ptitle:   m->title = s; break;
+		case Ptrack:   m->track = s; break;
+		case Ppath:
+			m->path = s;
+			m->basename = (b = utfrrune(s, '/')) == nil ? s : b+1;
+			break;
+		}
+	}
+	if(m != nil && m->path != nil)
+		pl->n++;
+
+	return pl;
+}
+
+static void
+recenter(void)
+{
+	updatescrollsz();
+	scroll = pcur - scrollsz/2 + 1;
+}
+
+static void
+search(char d)
+{
+	Meta *m;
+	static char buf[64];
+	static int sz;
+	int inc, i, a, cycle;
+
+	inc = (d == '/' || d == 'n') ? 1 : -1;
+	if(d == '/' || d == '?')
+		sz = enter(inc > 0 ? "forward:" : "backward:", buf, sizeof(buf), mctl, kctl, nil);
+	if(sz < 1)
+		return;
+
+	cycle = 1;
+	for(i = pcur+inc; i >= 0 && i < pl->n;){
+		m = getmeta(i);
+		for(a = 0; a < m->numartist; a++){
+			if(cistrstr(m->artist[a], buf) != nil)
+				break;
+		}
+		if(m->album != nil && cistrstr(m->album, buf) != nil)
+			break;
+		if(m->title != nil && cistrstr(m->title, buf) != nil)
+			break;
+		if(cistrstr(m->path, buf) != nil)
+			break;
+onemore:
+		i += inc;
+	}
+	if(i >= 0 && i < pl->n){
+		pcur = i;
+		recenter();
+		redraw(1);
+	}else if(cycle && i+inc < 0){
+		cycle = 0;
+		i = pl->n;
+		goto onemore;
+	}else if(cycle && i+inc >= pl->n){
+		cycle = 0;
+		i = -1;
+		goto onemore;
+	}
+}
+
+static void
+chvolume(int d)
+{
+	int f, l, r, ol, or;
+	Biobuf b;
+	char *s, *a[4];
+
+	if((f = open("/dev/volume", ORDWR)) < 0)
+		return;
+	Binit(&b, f, OREAD);
+
+	l = r = 0;
+	for(; (s = Brdline(&b, '\n')) != nil;){
+		if(strncmp(s, "master", 6) == 0 && tokenize(s, a, 3) == 3){
+			l = ol = atoi(a[1]);
+			r = or = atoi(a[2]);
+			for(;;){
+				l += d;
+				r += d;
+				fprint(f, "master %d %d\n", l, r);
+				Bseek(&b, 0, 0);
+				for(; (s = Brdline(&b, '\n')) != nil;){
+					if(strncmp(s, "master", 6) == 0 && tokenize(s, a, 3) == 3){
+						if(atoi(a[1]) == l && atoi(a[2]) == r)
+							goto end;
+						if(atoi(a[1]) != ol && atoi(a[2]) != or)
+							goto end;
+						if(l < 0 || r < 0 || l > 100 || r > 100)
+							goto end;
+						break;
+					}
+				}
+			}
+		}
+	}
+
+end:
+	volume = (l+r)/2;
+	if(volume > 100)
+		volume = 100;
+	else if(volume < 0)
+		volume = 0;
+
+	Bterm(&b);
+	close(f);
+}
+
+static void
+toggleshuffle(void)
+{
+	int i, m, xi, a, c, pcurnew, pcurplayingnew;
+
+	if(shuffle == nil){
+		if(pl->n < 2)
+			return;
+
+		m = pl->n;
+		if(pl->n < 4){
+			a = 1;
+			c = 3;
+			m = 7;
+		}else{
+			m += 1;
+			m |= m >> 1;
+			m |= m >> 2;
+			m |= m >> 4;
+			m |= m >> 8;
+			m |= m >> 16;
+			a = 1 + nrand(m/4)*4;     /* 1 ≤ a < m   && a mod 4 = 1 */
+			c = 3 + nrand((m-2)/2)*2; /* 3 ≤ c < m-1 && c mod 2 = 1 */
+		}
+
+		shuffle = malloc(pl->n*sizeof(*shuffle));
+		xi = pcurplaying < 0 ? pcur : pcurplaying;
+		pcurplayingnew = -1;
+		pcurnew = 0;
+		for(i = 0; i < pl->n;){
+			if(xi < pl->n){
+				if(pcur == xi)
+					pcurnew = i;
+				if(pcurplaying == xi)
+					pcurplayingnew = i;
+				shuffle[i++] = xi;
+			}
+			xi = (a*xi + c) & m;
+		}
+		pcur = pcurnew;
+		pcurplaying = pcurplayingnew;
+	}else{
+		pcur = shuffle[pcur];
+		if(pcurplaying >= 0)
+			pcurplaying = shuffle[pcurplaying];
+		free(shuffle);
+		shuffle = nil;
+	}
+
+	stop(playernext);
+	if(pcur < pl->n-1)
+		playernext = newplayer(pcur+1, 0);
+}
+
+static void
+plumbaudio(void *kbd)
+{
+	int i, f, pf;
+	Playlist *p;
+	Plumbmsg *m;
+	char *s, *e;
+	Rune c;
+
+	threadsetname("audio/plumb");
+	if((f = plumbopen("audio", OREAD)) >= 0){
+		while((m = plumbrecv(f)) != nil){
+			s = m->data;
+			if(strncmp(s, "key", 3) == 0 && isspace(s[3])){
+				for(s = s+4; isspace(*s); s++);
+				for(; (i = chartorune(&c, s)) > 0 && c != Runeerror; s += i)
+					sendul(kbd, c);
+				continue;
+			} 
+			if(*s != '/' && m->wdir != nil)
+				s = smprint("%s/%.*s", m->wdir, m->ndata, m->data);
+
+			if((e = strrchr(s, '.')) != nil && strcmp(e, ".plist") == 0 && (pf = open(s, OREAD)) >= 0){
+				p = readplist(pf);
+				close(pf);
+				if(p == nil)
+					continue;
+
+				freeplist(pl);
+				pl = p;
+				memset(mincolwidth, 0, sizeof(mincolwidth)); /* readjust columns */
+				sendul(playc, 0);
+			}else{
+				for(i = 0; i < pl->n; i++){
+					if(strcmp(pl->m[i].path, s) == 0){
+						sendul(playc, i);
+						break;
+					}
+				}
+			}
+
+			if(s != m->data)
+				free(s);
+			plumbfree(m);
+		}
+	}
+
+	threadexits(nil);
+}
+
+static void
+usage(void)
+{
+	fprint(2, "usage: %s [-s] [-G] [-c aAdDtTp]\n", argv0);
+	sysfatal("usage");
+}
+
+void
+threadmain(int argc, char **argv)
+{
+	Rune key;
+	Mouse m;
+	ulong ind;
+	enum {
+		Emouse,
+		Eresize,
+		Ekey,
+		Eplay,
+	};
+	Alt a[] = {
+		{ nil, &m, CHANRCV },
+		{ nil, nil, CHANRCV },
+		{ nil, &key, CHANRCV },
+		{ nil, &ind, CHANRCV },
+		{ nil, nil, CHANEND },
+	};
+	int n, scrolling, oldpcur, oldbuttons, pnew, shuffled, nogui;
+	char buf[64];
+
+	shuffled = 0;
+	nogui = 0;
+	ARGBEGIN{
+	case 'd':
+		debug++;
+		break;
+	case 's':
+		shuffled = 1;
+		break;
+	case 'c':
+		cols = EARGF(usage());
+		if(strlen(cols) >= nelem(colwidth))
+			sysfatal("max %d columns allowed", nelem(colwidth));
+		break;
+	case 'G':
+		nogui = 1;
+		break;
+	default:
+		usage();
+		break;
+	}ARGEND;
+
+	if((pl = readplist(0)) == nil){
+		fprint(2, "playlist: %r\n");
+		sysfatal("playlist error");
+	}
+	close(0);
+
+	Binit(&out, 1, OWRITE);
+	if(nogui){
+		writeplist();
+		Bterm(&out);
+		threadexitsall(nil);
+	}
+	pnotifies = fd2path(1, buf, sizeof(buf)) == 0 && strcmp(buf, "/dev/cons") != 0;
+
+	if(initdraw(nil, nil, "zuke") < 0)
+		sysfatal("initdraw: %r");
+	f = display->defaultfont;
+	Scrollwidth = MAX(14, stringwidth(f, "#"));
+	Scrollheight = MAX(16, f->height);
+	Coversz = MAX(64, stringwidth(f, "∫ 00:00:00/00:00:00 100%"));
+	unlockdisplay(display);
+	display->locking = 1;
+	if((mctl = initmouse(nil, screen)) == nil)
+		sysfatal("initmouse: %r");
+	if((kctl = initkeyboard(nil)) == nil)
+		sysfatal("initkeyboard: %r");
+
+	a[0].c = mctl->c;
+	a[1].c = mctl->resizec;
+	a[2].c = kctl->c;
+	a[3].c = chancreate(sizeof(ind), 0);
+	playc = a[3].c;
+
+	for(n = 0; n < Numcolors; n++)
+		colors[n].im = allocimage(display, Rect(0,0,1,1), RGB24, 1, colors[n].rgb<<8 | 0xff);
+
+	srand(time(0));
+	pcurplaying = -1;
+	chvolume(0);
+	fmtinstall('P', positionfmt);
+	threadsetname("zuke");
+
+	if(shuffled){
+		pcur = nrand(pl->n);
+		toggleshuffle();
+		recenter();
+	}
+
+	redraw(1);
+	oldbuttons = 0;
+	scrolling = 0;
+
+	proccreate(plumbaudio, kctl->c, 4096);
+
+	for(;;){
+		oldpcur = pcur;
+		if(seekmx != newseekmx){
+			seekmx = newseekmx;
+			redraw(0);
+		}
+
+		switch(alt(a)){
+		case Emouse:
+			if(ptinrect(m.xy, seekbar)){
+				seekoff = getmeta(pcurplaying)->duration * (double)(m.xy.x-1-seekbar.min.x) / (double)Dx(seekbar);
+				if(seekoff < 0)
+					seekoff = 0;
+				newseekmx = m.xy.x;
+			}else{
+				newseekmx = -1;
+			}
+
+			if(m.buttons != 2)
+				scrolling = 0;
+			if(m.buttons == 0)
+				break;
+			if(m.buttons == 8){
+				scroll = MAX(scroll-scrollsz/4-1, 0);
+				redraw(1);
+				break;
+			}else if(m.buttons == 16){
+				scroll = MIN(scroll+scrollsz/4+1, pl->n-scrollsz);
+				redraw(1);
+				break;
+			}
+
+
+			if(oldbuttons == 0 && !scrolling && ptinrect(m.xy, insetrect(seekbar, -4))){
+				if(ptinrect(m.xy, seekbar))
+					seekrel(playercurr, seekoff/1000.0 - byteswritten/Bps);
+				break;
+			}
+
+			n = (m.xy.y - screen->r.min.y)/f->height;
+
+			if(oldbuttons == 0 && m.xy.x <= screen->r.min.x+Scrollwidth){
+				if(m.buttons == 1){
+					scroll = MAX(0, scroll-n-1);
+					redraw(1);
+					break;
+				}else if(m.buttons == 4){
+					scroll = MIN(scroll+n+1, pl->n-scrollsz);
+					redraw(1);
+					break;
+				}else if(m.buttons == 2){
+					scrolling = 1;
+				}
+			}
+
+			if(scrolling){
+				if(scrollsz >= pl->n)
+					break;
+				scroll = (m.xy.y - screen->r.min.y - Scrollheight/4)*(pl->n-scrollsz) / (Dy(screen->r)-Scrollheight/2);
+				scroll = CLAMP(scroll, 0, pl->n-scrollsz);
+				redraw(1);
+			}else if(m.buttons == 1 || m.buttons == 2){
+				n += scroll;
+				if(n < pl->n){
+					pcur = n;
+					if(m.buttons == 2){
+						stop(playercurr);
+						playercurr = newplayer(pcur, 1);
+						start(playercurr);
+					}
+				}
+			}
+			break;
+		case Eresize: /* resize */
+			if(getwindow(display, Refnone) < 0)
+				sysfatal("getwindow: %r");
+			redraw(1);
+			break;
+		case Ekey:
+			switch(key){
+			case Kleft:
+				seekrel(playercurr, -(double)Seek);
+				break;
+			case Kright:
+				seekrel(playercurr, Seek);
+				break;
+			case ',':
+				seekrel(playercurr, -(double)Seekfast);
+				break;
+			case '.':
+				seekrel(playercurr, Seekfast);
+				break;
+			case Kup:
+				pcur--;
+				break;
+			case Kpgup:
+				pcur -= scrollsz;
+				break;
+			case Kdown:
+				pcur++;
+				break;
+			case Kpgdown:
+				pcur += scrollsz;
+				break;
+			case Kend:
+				pcur = pl->n-1;
+				scroll = pl->n-scrollsz;
+				break;
+			case Khome:
+				pcur = 0;
+				break;
+			case '\n':
+playcur:
+				stop(playercurr);
+				playercurr = newplayer(pcur, 1);
+				start(playercurr);
+				break;
+			case 'q':
+			case Kdel:
+				stop(playercurr);
+				goto end;
+			case 'i':
+			case 'o':
+				if(pcur == pcurplaying)
+					oldpcur = -1;
+				pcur = pcurplaying;
+				recenter();
+				break;
+			case 'b':
+			case '>':
+				if(playercurr == nil)
+					break;
+				pnew = pcurplaying;
+				if(++pnew >= pl->n)
+					pnew = 0;
+				stop(playercurr);
+				playercurr = newplayer(pnew, 1);
+				start(playercurr);
+				redraw(1);
+				break;
+			case 'z':
+			case '<':
+				if(playercurr == nil)
+					break;
+				pnew = pcurplaying;
+				if(--pnew < 0)
+					pnew = pl->n-1;
+				stop(playercurr);
+				playercurr = newplayer(pnew, 1);
+				start(playercurr);
+				redraw(1);
+				break;
+			case '-':
+				chvolume(-1);
+				redraw(0);
+				break;
+			case '+':
+			case '=':
+				chvolume(+1);
+				redraw(0);
+				break;
+			case 'v':
+				stop(playercurr);
+				playercurr = nil;
+				pcurplaying = -1;
+				freeimage(cover);
+				cover = nil;
+				redraw(1);
+				break;
+			case 's':
+				toggleshuffle();
+				recenter();
+				redraw(1);
+				break;
+			case 'c':
+			case 'p':
+			case ' ':
+				toggle(playercurr);
+				break;
+			case '/':
+			case '?':
+			case 'n':
+			case 'N':
+				search(key);
+				break;
+			}
+			break;
+		case Eplay:
+			pcur = ind;
+			recenter();
+			if(playercurr != nil)
+				goto playcur;
+			break;
+		}
+
+		if(pcur != oldpcur){
+			pcur = CLAMP(pcur, 0, pl->n-1);
+			if(pcur < scroll)
+				scroll = pcur;
+			else if(pcur > scroll + scrollsz)
+				scroll = pcur - scrollsz;
+			scroll = CLAMP(scroll, 0, pl->n-scrollsz);
+
+			if(pcur != oldpcur)
+				redraw(1);
+		}
+	}
+
+end:
+	threadexitsall(nil);
+}