<template>
	<MkStickyContainer>
		<template #header>
			<MkPageHeader :actions="headerActions" :tabs="[]" :display-back-button="true" />
		</template>
		<MkSpacer :content-max="980">
			<div class="karaoke-page">
				<XAdServicePreroll v-if="!adDone" service="karaoke" @done="adDone = true" />
				<section v-else class="stage">
					<MkLoading v-if="loading" />
					<MkError v-else-if="error" @retry="fetchNote" />
					<template v-else-if="note && kit">
						<video
							v-if="kit.video"
							ref="videoEl"
							class="video"
							:src="kit.video.url"
							playsinline
							muted
						></video>
						<div v-else class="visual">
							<i class="ph-microphone-stage ph-bold"></i>
						</div>

						<audio ref="audioEl" :src="getPlaybackUrl(kit.accompaniment)" @timeupdate="syncTime" @ended="finishSinging"></audio>

						<div class="lyrics">
							<div class="prev">{{ previousLyric }}</div>
							<div class="current">{{ currentLyric }}</div>
							<div class="next">{{ nextLyric }}</div>
						</div>

						<div class="pitchMonitor">
							<div class="pitchHeader">
								<span>Pitch</span>
								<strong>{{ livePitchLabel }}</strong>
							</div>
							<div class="pitchLane">
								<div
									v-for="bar in pitchBars"
									:key="bar.key"
									class="targetPitch"
									:style="{
										left: `${bar.left}%`,
										width: `${bar.width}%`,
										top: `${bar.top}%`,
									}"
								></div>
								<div
									v-if="livePitchMarker"
									class="sungPitch"
									:style="{ top: `${livePitchMarker.top}%` }"
								>
									<span></span>
								</div>
								<div class="playhead"></div>
							</div>
						</div>

						<div class="controls">
							<label class="field">
								<span>Scoring</span>
								<select v-model="scoringMode" :disabled="singing">
									<option value="off">Off</option>
									<option value="simple">Simple</option>
									<option value="data">MIDI pitch</option>
								</select>
							</label>
							<MkButton primary :disabled="starting || singing || !$i" @click="startSinging">
								<i class="ph-record ph-bold"></i>
								{{ starting ? "Starting" : "Start" }}
							</MkButton>
							<MkButton :disabled="!singing" @click="finishSinging">
								<i class="ph-stop ph-bold"></i>
								Finish
							</MkButton>
						</div>

						<div v-if="singing" class="meter">
							<div :style="{ width: `${Math.round(inputLevel * 100)}%` }"></div>
						</div>

						<div class="songMeta">
							<h1>{{ noteTitle }}</h1>
							<Mfm v-if="songInfo || noteDescription" :text="songInfo || noteDescription" :author="note.user" />
							<div class="sub">
								<MkA :to="`/notes/${note.id}`">Open note</MkA>
								<span>@{{ note.user.username }}</span>
							</div>
						</div>
					</template>
				</section>

				<section v-if="recordedUrl" class="result">
					<div class="sectionHead">
						<h2>Result</h2>
						<strong v-if="score != null">{{ score }} / 100</strong>
					</div>
					<audio controls :src="recordedUrl"></audio>
					<div class="saveActions">
						<MkButton @click="discardRecording">Discard</MkButton>
						<MkButton :disabled="saving" @click="savePrivate">Save privately</MkButton>
						<MkButton :disabled="saving" @click="saveDraft">Save draft</MkButton>
						<MkButton primary :disabled="saving" @click="postAudioService">Post #AudioService</MkButton>
					</div>
				</section>
			</div>
		</MkSpacer>
	</MkStickyContainer>
</template>

<script lang="ts" setup>
import { computed, onBeforeUnmount, ref } from "vue";
import * as misskey from "iceshrimp-sdk";
import MkButton from "@/components/MkButton.vue";
import XAdServicePreroll from "@/components/AdServicePreroll.vue";
import * as os from "@/os";
import { $i } from "@/account";
import { definePageMetadata } from "@/scripts/page-metadata";
import { uploadFile } from "@/scripts/upload";
import { useRouter } from "@/router.js";

type Role = "accompaniment" | "lyrics" | "pitch" | "scoring" | "video" | "info";
type LyricLine = { time: number; text: string };
type MidiNote = { start: number; end: number; midi: number };
type PitchSample = { time: number; midi: number | null; frequency: number | null; level: number };
type KaraokeKit = Record<Role, misskey.entities.DriveFile | null> & {
	accompaniment: misskey.entities.DriveFile;
	lyrics: misskey.entities.DriveFile;
	pitch: misskey.entities.DriveFile;
	scoring: misskey.entities.DriveFile;
};

const props = defineProps<{ noteId: string }>();

const note = ref<misskey.entities.Note | null>(null);
const kit = ref<KaraokeKit | null>(null);
const loading = ref(false);
const error = ref(false);
const lyrics = ref<LyricLine[]>([]);
const midiNotes = ref<MidiNote[]>([]);
const songInfo = ref("");
const currentTime = ref(0);
const scoringMode = ref<"off" | "simple" | "data">("simple");
const starting = ref(false);
const singing = ref(false);
const saving = ref(false);
const recordedBlob = ref<Blob | null>(null);
const recordedUrl = ref<string | null>(null);
const score = ref<number | null>(null);
const inputLevel = ref(0);
const adDone = ref(false);
const livePitchMidi = ref<number | null>(null);
const livePitchFrequency = ref<number | null>(null);

const audioEl = ref<HTMLAudioElement | null>(null);
const videoEl = ref<HTMLVideoElement | null>(null);
const mediaRecorder = ref<MediaRecorder | null>(null);
let mediaStream: MediaStream | null = null;
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let levelFrame = 0;
let chunks: Blob[] = [];
let levelSamples: number[] = [];
let pitchSamples: PitchSample[] = [];
let singingCountRequestedAt = 0;
const router = useRouter();

const headerActions = computed(() => [
	{
		icon: "ph-note ph-bold ph-lg",
		text: "Open note",
		handler: () => {
			if (note.value) router.push(`/notes/${note.value.id}`);
		},
	},
]);

const noteTitle = computed(() => (note.value ? getNoteTitle(note.value) : "KaraokeService"));
const noteDescription = computed(() => (note.value ? getNoteDescription(note.value) : ""));
const lyricIndex = computed(() => {
	let index = -1;
	for (let i = 0; i < lyrics.value.length; i++) {
		if (lyrics.value[i].time <= currentTime.value) index = i;
		else break;
	}
	return index;
});
const previousLyric = computed(() => lyrics.value[lyricIndex.value - 1]?.text ?? "");
const currentLyric = computed(() => lyrics.value[lyricIndex.value]?.text ?? "♪");
const nextLyric = computed(() => lyrics.value[lyricIndex.value + 1]?.text ?? "");
const pitchWindowStart = computed(() => Math.max(0, currentTime.value - 1.5));
const pitchWindowEnd = computed(() => pitchWindowStart.value + 8);
const visiblePitchNotes = computed(() => midiNotes.value.filter((note) => note.end >= pitchWindowStart.value && note.start <= pitchWindowEnd.value));
const pitchCenterMidi = computed(() => {
	if (livePitchMidi.value != null) return livePitchMidi.value;
	const active = activeMidiNotesAt(currentTime.value);
	if (active.length > 0) return active.reduce((sum, note) => sum + note.midi, 0) / active.length;
	if (visiblePitchNotes.value.length > 0) return visiblePitchNotes.value.reduce((sum, note) => sum + note.midi, 0) / visiblePitchNotes.value.length;
	return 60;
});
const pitchMinMidi = computed(() => pitchCenterMidi.value - 12);
const pitchMaxMidi = computed(() => pitchCenterMidi.value + 12);
const pitchBars = computed(() => visiblePitchNotes.value.map((note, index) => {
	const start = Math.max(note.start, pitchWindowStart.value);
	const end = Math.min(note.end, pitchWindowEnd.value);
	return {
		key: `${note.start}:${note.end}:${note.midi}:${index}`,
		left: ((start - pitchWindowStart.value) / 8) * 100,
		width: Math.max(1, ((end - start) / 8) * 100),
		top: midiToPitchTop(note.midi),
	};
}));
const livePitchMarker = computed(() => livePitchMidi.value == null ? null : { top: midiToPitchTop(livePitchMidi.value) });
const livePitchLabel = computed(() => {
	if (livePitchFrequency.value == null || livePitchMidi.value == null) return singing.value ? "Listening" : "Start to sing";
	return `${midiToNoteName(livePitchMidi.value)} ${Math.round(livePitchFrequency.value)}Hz`;
});

function classifyFile(file: misskey.entities.DriveFile): Role | null {
	const text = `${file.name} ${file.comment ?? ""}`.toLowerCase();
	if (file.type.startsWith("video/")) return "video";
	if (isMidiFile(file)) return "pitch";
	if (text.includes("score") || text.includes("scoring") || text.includes("採点")) return "scoring";
	if (text.includes("pitch") || text.includes("tempo") || text.includes("melody") || text.includes("part") || text.includes("音程") || text.includes("テンポ") || text.includes("パート")) return "pitch";
	if (text.includes("lyrics") || text.includes("lyric") || text.endsWith(".lrc") || text.includes("歌詞")) return "lyrics";
	if (file.type.startsWith("audio/")) return "accompaniment";
	if (file.type.startsWith("text/") && (text.includes("info") || text.includes("metadata") || text.includes("song") || text.includes("楽曲情報"))) return "info";
	return null;
}

function isMidiFile(file: misskey.entities.DriveFile): boolean {
	const text = `${file.name} ${file.comment ?? ""}`.toLowerCase();
	const type = file.type.toLowerCase();
	return (
		text.endsWith(".mid") ||
		text.endsWith(".midi") ||
		type === "audio/midi" ||
		type === "audio/mid" ||
		type === "audio/x-midi" ||
		type === "audio/x-mid" ||
		type === "application/midi" ||
		type === "application/x-midi"
	);
}

function buildKit(target: misskey.entities.Note): KaraokeKit | null {
	const roles: Record<Role, misskey.entities.DriveFile[]> = {
		accompaniment: [],
		lyrics: [],
		pitch: [],
		scoring: [],
		video: [],
		info: [],
	};
	for (const file of target.files ?? []) {
		const role = classifyFile(file);
		if (!role) continue;
		roles[role].push(file);
	}
	const assigned = new Set(Object.values(roles).flat().map((file) => file.id));
	const fallbackData = (target.files ?? []).filter((file) => !assigned.has(file.id));
	for (const role of ["lyrics", "pitch", "scoring"] as const) {
		if (roles[role].length === 0 && fallbackData.length > 0) roles[role].push(fallbackData.shift()!);
	}
	if (roles.accompaniment.length !== 1 || roles.lyrics.length !== 1 || roles.pitch.length !== 1 || roles.scoring.length !== 1) return null;
	if (roles.video.length > 1 || roles.info.length > 1) return null;
	if (fallbackData.length > 0) return null;
	return {
		accompaniment: roles.accompaniment[0],
		lyrics: roles.lyrics[0],
		pitch: roles.pitch[0],
		scoring: roles.scoring[0],
		video: roles.video[0] ?? null,
		info: roles.info[0] ?? null,
	};
}

async function fetchText(file: misskey.entities.DriveFile): Promise<string> {
	const response = await fetch(file.url);
	return await response.text();
}

async function fetchArrayBuffer(file: misskey.entities.DriveFile): Promise<ArrayBuffer> {
	const response = await fetch(file.url);
	return await response.arrayBuffer();
}

function getPlaybackUrl(file: misskey.entities.DriveFile): string {
	if (!file.type.startsWith("audio/") && !file.type.startsWith("video/")) return file.url;
	const url = new URL(file.url, location.href);
	url.searchParams.set("stream", "1");
	return url.toString();
}

function parseLyrics(text: string): LyricLine[] {
	const lines = text.replace(/\r\n/g, "\n").split("\n");
	const parsed: LyricLine[] = [];
	for (const line of lines) {
		const match = line.match(/\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]\s*(.*)/);
		if (!match) continue;
		const min = Number(match[1]);
		const sec = Number(match[2]);
		const frac = Number((match[3] ?? "0").padEnd(3, "0"));
		parsed.push({ time: min * 60 + sec + frac / 1000, text: match[4] });
	}
	return parsed.sort((a, b) => a.time - b.time);
}

function parseMidiNotes(buffer: ArrayBuffer): MidiNote[] {
	const view = new DataView(buffer);
	let offset = 0;
	type MidiEvent =
		| { tick: number; type: "tempo"; mpqn: number }
		| { tick: number; type: "noteOn" | "noteOff"; midi: number };

	function readUint8(): number {
		return view.getUint8(offset++);
	}

	function readUint16(): number {
		const value = view.getUint16(offset, false);
		offset += 2;
		return value;
	}

	function readUint32(): number {
		const value = view.getUint32(offset, false);
		offset += 4;
		return value;
	}

	function readString(length: number): string {
		let value = "";
		for (let i = 0; i < length; i++) value += String.fromCharCode(readUint8());
		return value;
	}

	function readVarLength(): number {
		let value = 0;
		for (let i = 0; i < 4; i++) {
			const byte = readUint8();
			value = (value << 7) | (byte & 0x7f);
			if ((byte & 0x80) === 0) return value;
		}
		return value;
	}

	if (readString(4) !== "MThd") throw new Error("Invalid MIDI header");
	const headerLength = readUint32();
	const headerEnd = offset + headerLength;
	readUint16();
	const trackCount = readUint16();
	const division = readUint16();
	offset = headerEnd;
	if ((division & 0x8000) !== 0) throw new Error("SMPTE MIDI timing is not supported");

	const events: MidiEvent[] = [];
	for (let track = 0; track < trackCount; track++) {
		if (readString(4) !== "MTrk") throw new Error("Invalid MIDI track");
		const trackEnd = offset + readUint32();
		let tick = 0;
		let runningStatus = 0;

		while (offset < trackEnd) {
			tick += readVarLength();
			let status = readUint8();
			if (status < 0x80) {
				offset--;
				if (runningStatus === 0) throw new Error("Invalid MIDI running status");
				status = runningStatus;
			} else if (status < 0xf0) {
				runningStatus = status;
			}

			if (status === 0xff) {
				const metaType = readUint8();
				const length = readVarLength();
				if (metaType === 0x51 && length === 3) {
					const mpqn = (readUint8() << 16) | (readUint8() << 8) | readUint8();
					events.push({ tick, type: "tempo", mpqn });
				} else {
					offset += length;
				}
				continue;
			}

			if (status === 0xf0 || status === 0xf7) {
				offset += readVarLength();
				continue;
			}

			const eventType = status >> 4;
			const dataLength = eventType === 0x0c || eventType === 0x0d ? 1 : 2;
			const note = readUint8();
			const velocity = dataLength === 2 ? readUint8() : 0;
			if (eventType === 0x09 && velocity > 0) events.push({ tick, type: "noteOn", midi: note });
			if (eventType === 0x08 || (eventType === 0x09 && velocity === 0)) events.push({ tick, type: "noteOff", midi: note });
		}
		offset = trackEnd;
	}

	const tempos = events
		.filter((event): event is Extract<MidiEvent, { type: "tempo" }> => event.type === "tempo")
		.sort((a, b) => a.tick - b.tick);
	if (tempos[0]?.tick !== 0) tempos.unshift({ tick: 0, type: "tempo", mpqn: 500000 });

	function tickToSeconds(tick: number): number {
		let seconds = 0;
		let lastTick = 0;
		let tempo = 500000;
		for (const event of tempos) {
			if (event.tick > tick) break;
			seconds += ((event.tick - lastTick) * tempo) / division / 1000000;
			lastTick = event.tick;
			tempo = event.mpqn;
		}
		return seconds + ((tick - lastTick) * tempo) / division / 1000000;
	}

	const active = new Map<number, number[]>();
	const notes: MidiNote[] = [];
	const noteEvents = events
		.filter((event): event is Extract<MidiEvent, { type: "noteOn" | "noteOff" }> => event.type === "noteOn" || event.type === "noteOff")
		.sort((a, b) => a.tick - b.tick || (a.type === "noteOff" ? -1 : 1));

	for (const event of noteEvents) {
		if (event.type === "noteOn") {
			const starts = active.get(event.midi) ?? [];
			starts.push(event.tick);
			active.set(event.midi, starts);
			continue;
		}
		const starts = active.get(event.midi);
		const startTick = starts?.shift();
		if (startTick == null || event.tick <= startTick) continue;
		notes.push({ start: tickToSeconds(startTick), end: tickToSeconds(event.tick), midi: event.midi });
	}

	return notes.filter((note) => note.end - note.start >= 0.05).sort((a, b) => a.start - b.start);
}

async function fetchNote() {
	loading.value = true;
	error.value = false;
	try {
		const res = await os.api("notes/show", { noteId: props.noteId });
		const tags = (res.tags ?? []).map((tag) => tag.toLowerCase());
		const nextKit = tags.includes("karaokeservice") ? buildKit(res) : null;
		if (!nextKit) throw new Error("Invalid karaoke service note");
		note.value = res;
		kit.value = nextKit;
		lyrics.value = parseLyrics(await fetchText(nextKit.lyrics));
		midiNotes.value = parseMidiNotes(await fetchArrayBuffer(nextKit.pitch));
		songInfo.value = nextKit.info ? await fetchText(nextKit.info) : "";
	} catch (err) {
		console.error(err);
		error.value = true;
		note.value = null;
		kit.value = null;
		midiNotes.value = [];
	} finally {
		loading.value = false;
	}
}

function splitNoteText(target: misskey.entities.Note): string[] {
	return (target.text ?? "").replace(/\r\n/g, "\n").split("\n");
}

function getNoteTitle(target: misskey.entities.Note): string {
	return splitNoteText(target)[0]?.trim() || "(no title)";
}

function getNoteDescription(target: misskey.entities.Note): string {
	return splitNoteText(target).slice(1).join("\n").trim();
}

function syncTime() {
	currentTime.value = audioEl.value?.currentTime ?? 0;
	if (videoEl.value && audioEl.value && Math.abs(videoEl.value.currentTime - audioEl.value.currentTime) > 0.25) {
		videoEl.value.currentTime = audioEl.value.currentTime;
	}
}

async function startSinging() {
	if (!$i || !audioEl.value || singing.value || starting.value) return;
	starting.value = true;
	try {
		if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
			throw new Error("Microphone recording requires HTTPS or localhost.");
		}
		if (typeof MediaRecorder === "undefined") {
			throw new Error("This browser does not support audio recording.");
		}

		recordedBlob.value = null;
		if (recordedUrl.value) URL.revokeObjectURL(recordedUrl.value);
		recordedUrl.value = null;
		score.value = null;
		livePitchMidi.value = null;
		livePitchFrequency.value = null;
		chunks = [];
		levelSamples = [];
		pitchSamples = [];

		mediaStream = await navigator.mediaDevices.getUserMedia({
			audio: {
				echoCancellation: false,
				noiseSuppression: false,
				autoGainControl: false,
			},
		});
		mediaRecorder.value = new MediaRecorder(mediaStream);
		mediaRecorder.value.ondataavailable = (ev) => {
			if (ev.data.size > 0) chunks.push(ev.data);
		};
		mediaRecorder.value.onstop = () => {
			const blob = new Blob(chunks, { type: mediaRecorder.value?.mimeType || "audio/webm" });
			recordedBlob.value = blob;
			recordedUrl.value = URL.createObjectURL(blob);
			score.value = calculateScore();
			cleanupRecorder();
		};

		audioEl.value.currentTime = 0;
		if (videoEl.value) videoEl.value.currentTime = 0;
		singing.value = true;
		countSinging();
		setupLevelMeter(mediaStream);
		mediaRecorder.value.start(250);
		await audioEl.value.play();
		if (videoEl.value) await videoEl.value.play().catch(() => undefined);
	} catch (err) {
		console.error(err);
		singing.value = false;
		cleanupRecorder();
		await os.alert({
			type: "error",
			title: "Failed to start karaoke",
			text: getStartErrorMessage(err),
		});
	} finally {
		starting.value = false;
	}
}

function countSinging() {
	if (!note.value) return;
	const now = Date.now();
	if (now - singingCountRequestedAt < 1000) return;
	singingCountRequestedAt = now;
	os.api("notes/increment-service-view" as any, {
		noteId: note.value.id,
		service: "karaoke",
	}).then((res: any) => {
		if (res?.counted && note.value) note.value.viewCount = (note.value.viewCount ?? 0) + 1;
	}).catch(() => undefined);
}

function finishSinging() {
	if (!singing.value) return;
	singing.value = false;
	audioEl.value?.pause();
	videoEl.value?.pause();
	if (mediaRecorder.value?.state === "recording") mediaRecorder.value.stop();
}

function setupLevelMeter(stream: MediaStream) {
	audioContext = new AudioContext({ latencyHint: "interactive" });
	analyser = audioContext.createAnalyser();
	analyser.fftSize = 2048;
	audioContext.createMediaStreamSource(stream).connect(analyser);
	const data = new Float32Array(analyser.fftSize);
	const tick = () => {
		if (!analyser || !audioContext || !singing.value) return;
		currentTime.value = audioEl.value?.currentTime ?? currentTime.value;
		analyser.getFloatTimeDomainData(data);
		let sum = 0;
		for (const value of data) {
			sum += value * value;
		}
		const rms = Math.min(1, Math.sqrt(sum / data.length) * 3);
		const frequency = detectPitch(data, audioContext.sampleRate, rms / 3);
		livePitchFrequency.value = frequency;
		livePitchMidi.value = frequency == null ? null : frequencyToMidi(frequency);
		inputLevel.value = rms;
		levelSamples.push(rms);
		pitchSamples.push({
			time: currentTime.value,
			midi: livePitchMidi.value,
			frequency,
			level: rms,
		});
		levelFrame = requestAnimationFrame(tick);
	};
	tick();
}

function calculateScore(): number | null {
	if (scoringMode.value === "off") return null;
	if (scoringMode.value === "data") return calculateMidiPitchScore();
	if (levelSamples.length === 0) return 0;
	const active = levelSamples.filter((level) => level > 0.05);
	const activeRatio = active.length / levelSamples.length;
	const avg = active.reduce((sum, level) => sum + level, 0) / Math.max(1, active.length);
	return Math.max(0, Math.min(100, Math.round(50 + activeRatio * 35 + Math.min(avg, 0.5) * 30)));
}

function detectPitch(samples: Float32Array, sampleRate: number, rms: number): number | null {
	if (rms < 0.02) return null;
	const minLag = Math.floor(sampleRate / 1000);
	const maxLag = Math.min(Math.floor(sampleRate / 80), Math.floor(samples.length / 2));
	let bestLag = 0;
	let bestCorrelation = 0;

	for (let lag = minLag; lag <= maxLag; lag++) {
		let correlation = 0;
		let normalA = 0;
		let normalB = 0;
		for (let i = 0; i < samples.length - lag; i++) {
			const a = samples[i];
			const b = samples[i + lag];
			correlation += a * b;
			normalA += a * a;
			normalB += b * b;
		}
		if (normalA === 0 || normalB === 0) continue;
		correlation /= Math.sqrt(normalA * normalB);
		if (correlation > bestCorrelation) {
			bestCorrelation = correlation;
			bestLag = lag;
		}
	}

	if (bestLag === 0 || bestCorrelation < 0.45) return null;
	return sampleRate / bestLag;
}

function frequencyToMidi(frequency: number): number {
	return 69 + 12 * Math.log2(frequency / 440);
}

function midiToPitchTop(midi: number): number {
	const range = pitchMaxMidi.value - pitchMinMidi.value;
	return Math.max(4, Math.min(96, ((pitchMaxMidi.value - midi) / range) * 100));
}

function midiToNoteName(midi: number): string {
	const names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
	const rounded = Math.round(midi);
	const octave = Math.floor(rounded / 12) - 1;
	return `${names[((rounded % 12) + 12) % 12]}${octave}`;
}

function activeMidiNotesAt(time: number): MidiNote[] {
	return midiNotes.value.filter((note) => note.start <= time && time < note.end);
}

function calculateMidiPitchScore(): number {
	if (pitchSamples.length === 0 || midiNotes.value.length === 0) return 0;

	let targetSampleCount = 0;
	let credit = 0;
	let extraSingingCount = 0;

	for (const sample of pitchSamples) {
		const targets = activeMidiNotesAt(sample.time);
		if (targets.length === 0) {
			if (sample.midi != null) extraSingingCount++;
			continue;
		}

		targetSampleCount++;
		if (sample.midi == null) continue;

		const nearest = targets.reduce((best, note) => {
			return Math.abs(note.midi - sample.midi!) < Math.abs(best.midi - sample.midi!) ? note : best;
		}, targets[0]);
		const cents = Math.abs((sample.midi - nearest.midi) * 100);
		if (cents <= 50) {
			credit += 1;
		} else if (cents <= 100) {
			credit += 1 - ((cents - 50) / 50) * 0.5;
		}
	}

	if (targetSampleCount === 0) return 0;
	const baseScore = (credit / targetSampleCount) * 100;
	const extraPenalty = Math.min(10, (extraSingingCount / Math.max(1, pitchSamples.length)) * 20);
	return Math.max(0, Math.min(100, Math.round(baseScore - extraPenalty)));
}

function cleanupRecorder() {
	cancelAnimationFrame(levelFrame);
	mediaStream?.getTracks().forEach((track) => track.stop());
	mediaStream = null;
	audioContext?.close().catch(() => undefined);
	audioContext = null;
	analyser = null;
	inputLevel.value = 0;
	livePitchMidi.value = null;
	livePitchFrequency.value = null;
}

function getStartErrorMessage(err: unknown): string {
	if (err instanceof DOMException) {
		if (err.name === "NotAllowedError") return "Microphone permission was denied.";
		if (err.name === "NotFoundError") return "No microphone was found.";
		if (err.name === "NotReadableError") return "The microphone is already in use or cannot be opened.";
	}
	return err instanceof Error ? err.message : "Please check microphone permission and audio playback.";
}

function recordingFileName(): string {
	const safeTitle = noteTitle.value.replace(/[\\/:*?"<>|]/g, "_").slice(0, 80) || "karaoke";
	return `${safeTitle}-karaoke-${Date.now()}.webm`;
}

async function uploadRecording(): Promise<misskey.entities.DriveFile> {
	if (!recordedBlob.value) throw new Error("No recording");
	return await uploadFile(
		new File([recordedBlob.value], recordingFileName(), { type: recordedBlob.value.type || "audio/webm" }),
		undefined,
		recordingFileName(),
		true,
	);
}

function discardRecording() {
	recordedBlob.value = null;
	if (recordedUrl.value) URL.revokeObjectURL(recordedUrl.value);
	recordedUrl.value = null;
	score.value = null;
}

async function savePrivate() {
	saving.value = true;
	try {
		await uploadRecording();
		await os.alert({ type: "success", title: "Saved", text: "Saved to your drive." });
	} finally {
		saving.value = false;
	}
}

async function saveDraft() {
	saving.value = true;
	try {
		const file = await uploadRecording();
		const drafts = JSON.parse(localStorage.getItem("ffy:karaoke-drafts") ?? "[]");
		drafts.unshift({
			createdAt: new Date().toISOString(),
			sourceNoteId: note.value?.id,
			fileId: file.id,
			text: `${noteTitle.value}\n#AudioService`,
			score: score.value,
		});
		localStorage.setItem("ffy:karaoke-drafts", JSON.stringify(drafts.slice(0, 50)));
		await os.alert({ type: "success", title: "Draft saved", text: "Saved as a local karaoke draft." });
	} finally {
		saving.value = false;
	}
}

async function postAudioService() {
	saving.value = true;
	try {
		const file = await uploadRecording();
		await os.api("notes/create", {
			text: `${noteTitle.value}\n#AudioService`,
			fileIds: [file.id],
			visibility: "public",
		});
		await os.alert({ type: "success", title: "Posted", text: "Posted with #AudioService." });
		discardRecording();
	} finally {
		saving.value = false;
	}
}

fetchNote();

onBeforeUnmount(() => {
	if (singing.value) finishSinging();
	cleanupRecorder();
	if (recordedUrl.value) URL.revokeObjectURL(recordedUrl.value);
});

definePageMetadata(
	computed(() => ({
		title: noteTitle.value,
		icon: "ph-microphone-stage ph-bold ph-lg",
	})),
);
</script>

<style lang="scss" scoped>
.karaoke-page {
	display: grid;
	gap: 20px;
}

.stage,
.result {
	display: grid;
	gap: 16px;
	padding: 16px;
	border-radius: 8px;
	background: var(--panel);
	border: solid 1px var(--divider);
}

.video,
.visual {
	width: 100%;
	aspect-ratio: 16 / 9;
	border-radius: 8px;
	background: #111;
	color: #fff;
}

.visual {
	display: grid;
	place-items: center;
}

.visual i {
	font-size: 56px;
}

.lyrics {
	display: grid;
	gap: 8px;
	text-align: center;
	min-height: 120px;
	align-content: center;
	padding: 16px;
	border-radius: 8px;
	background: var(--bg);
}

.pitchMonitor {
	display: grid;
	gap: 8px;
}

.pitchHeader {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
	color: var(--fgTransparentWeak);
	font-size: 0.85em;
}

.pitchHeader strong {
	color: var(--fg);
	font-variant-numeric: tabular-nums;
}

.pitchLane {
	position: relative;
	height: 140px;
	overflow: hidden;
	border-radius: 8px;
	background:
		linear-gradient(to bottom, transparent calc(25% - 1px), var(--divider) 25%, transparent calc(25% + 1px)),
		linear-gradient(to bottom, transparent calc(50% - 1px), var(--divider) 50%, transparent calc(50% + 1px)),
		linear-gradient(to bottom, transparent calc(75% - 1px), var(--divider) 75%, transparent calc(75% + 1px)),
		var(--bg);
}

.targetPitch {
	position: absolute;
	height: 8px;
	transform: translateY(-50%);
	border-radius: 999px;
	background: var(--accent);
	opacity: 0.55;
}

.sungPitch {
	position: absolute;
	left: 18.75%;
	right: 0;
	height: 0;
	transform: translateY(-50%);
	border-top: solid 2px #ff4f8b;
	box-shadow: 0 0 10px rgba(255, 79, 139, 0.65);
}

.sungPitch span {
	position: absolute;
	left: -5px;
	top: -5px;
	width: 10px;
	height: 10px;
	border-radius: 50%;
	background: #ff4f8b;
}

.playhead {
	position: absolute;
	top: 0;
	bottom: 0;
	left: 18.75%;
	width: 2px;
	background: var(--fg);
	opacity: 0.35;
}

.prev,
.next {
	color: var(--fgTransparentWeak);
	min-height: 1.4em;
}

.current {
	font-size: 1.7rem;
	font-weight: 800;
	line-height: 1.35;
}

.controls {
	display: flex;
	flex-wrap: wrap;
	gap: 10px;
	align-items: end;
}

.field {
	display: grid;
	gap: 6px;
	min-width: 180px;
}

.field span {
	font-size: 0.85em;
	color: var(--fgTransparentWeak);
}

.field select {
	min-height: 40px;
	border-radius: 8px;
	padding: 0 10px;
	background: var(--bg);
	color: var(--fg);
	border: solid 1px var(--divider);
}

.meter {
	height: 10px;
	overflow: hidden;
	border-radius: 999px;
	background: var(--bg);
}

.meter > div {
	height: 100%;
	background: var(--accent);
	transition: width 80ms linear;
}

.songMeta h1 {
	margin: 0 0 8px;
	font-size: 1.3rem;
}

.sub {
	display: flex;
	gap: 10px;
	margin-top: 10px;
	color: var(--fgTransparentWeak);
}

.sectionHead {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
}

.sectionHead h2 {
	margin: 0;
	font-size: 1rem;
}

.result audio {
	width: 100%;
}

.saveActions {
	display: flex;
	flex-wrap: wrap;
	gap: 10px;
}
</style>
