Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,26 @@ if(APPLE AND ENABLE_MACOS_FRAMEWORK)
set(FRAMEWORK_VER_DIR ${FRAMEWORK_DIR}/Versions/A)
set(FRAMEWORK_INFO_PLIST ${CMAKE_CURRENT_BINARY_DIR}/${FRAMEWORK_NAME}-Info.plist)
# Ship all public headers (developers can opt into lower-level APIs).
file(GLOB FRAMEWORK_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/include/*.hpp)
# Keep source headers portable, but stage framework-qualified sibling
# includes as required for public Apple framework headers.
file(GLOB FRAMEWORK_SOURCE_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/include/*.hpp)
set(FRAMEWORK_STAGED_HEADER_DIR ${CMAKE_CURRENT_BINARY_DIR}/framework_headers)
set(FRAMEWORK_HEADERS)
foreach(SOURCE_HEADER IN LISTS FRAMEWORK_SOURCE_HEADERS)
get_filename_component(HEADER_NAME ${SOURCE_HEADER} NAME)
set(STAGED_HEADER ${FRAMEWORK_STAGED_HEADER_DIR}/${HEADER_NAME})
add_custom_command(
OUTPUT ${STAGED_HEADER}
COMMAND ${CMAKE_COMMAND}
-DINPUT_HEADER=${SOURCE_HEADER}
-DOUTPUT_HEADER=${STAGED_HEADER}
-DFRAMEWORK_NAME=${FRAMEWORK_NAME}
-P ${CMAKE_CURRENT_SOURCE_DIR}/scripts/stage_framework_header.cmake
DEPENDS ${SOURCE_HEADER} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/stage_framework_header.cmake
VERBATIM
)
list(APPEND FRAMEWORK_HEADERS ${STAGED_HEADER})
endforeach()

# Generate the plist at configure time. A multiline echo inside the custom target produces
# invalid Makefiles because the generated recipe contains unindented lines.
Expand Down
88 changes: 88 additions & 0 deletions scripts/avfdebug.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Usage: AVF_INPUT=path/to/input.m4a

set -euo pipefail

FILE_IN="${AVF_INPUT:-}"
TMPDIR_SAFE="${TMPDIR:-/tmp}"
export SWIFT_MODULE_CACHE_PATH="${SWIFT_MODULE_CACHE_PATH:-$TMPDIR_SAFE/swift_module_cache}"
export CLANG_MODULE_CACHE_PATH="${CLANG_MODULE_CACHE_PATH:-$TMPDIR_SAFE/clang_module_cache}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$TMPDIR_SAFE}"
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
mkdir -p "$SWIFT_MODULE_CACHE_PATH" "$CLANG_MODULE_CACHE_PATH"

if [ -z "$FILE_IN" ] ]; then
echo "AVF_INPUT env var must be set" >&2
exit 1
fi
if [ ! -f "$FILE_IN" ]; then
echo "input not found: $FILE_IN" >&2; exit 1
fi


SWIFT_SRC="$(mktemp "$TMPDIR_SAFE/avf_urltext.XXXXXX.swift")"
cat >"$SWIFT_SRC" <<'SWIFT'
import AVFoundation
import Foundation

let path = ProcessInfo.processInfo.environment["AVF_FILE"] ?? ""
if path.isEmpty {
fputs("AVF_FILE not set\n", stderr)
exit(1)
}
let url = URL(fileURLWithPath: path)
let asset = AVURLAsset(url: url)

print("AVF dump for \(path)")
for t in asset.tracks {
print("track id=\(t.trackID) media=\(t.mediaType.rawValue) language=\(t.languageCode ?? "nil")")
}

let groups = asset.chapterMetadataGroups(bestMatchingPreferredLanguages: Locale.preferredLanguages)
print("chapters=\(groups.count)")
for (idx, group) in groups.enumerated() {
let tr = group.timeRange
let start = CMTimeGetSeconds(tr.start)
let dur = CMTimeGetSeconds(tr.duration)
print(String(format: "[%02d] start=%.3f dur=%.3f", idx, start, dur))
for item in group.items {
let key = item.commonKey?.rawValue ?? (item.key?.description ?? "(no key)")
let ident = item.identifier?.rawValue ?? "(no ident)"
var val: String = "(non-string)"
if let s = item.stringValue {
val = s
} else if let d = item.dataValue {
val = "data len=\(d.count)"
}
let extra = item.extraAttributes ?? [:]
let locale = item.locale?.identifier ?? "nil"
print(" item key=\(key) ident=\(ident) locale=\(locale) value=\(val) extra=\(extra)")
}
}

// Dump raw text samples for every text track so the shell harness can grep payloads.
let textTracks = asset.tracks(withMediaType: .text)
for t in textTracks {
guard let reader = try? AVAssetReader(asset: asset) else { continue }
let output = AVAssetReaderTrackOutput(track: t, outputSettings: nil)
reader.add(output)
if !reader.startReading() { continue }
while let sb = output.copyNextSampleBuffer() {
let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb))
let dur = CMTimeGetSeconds(CMSampleBufferGetDuration(sb))
if let bb = CMSampleBufferGetDataBuffer(sb) {
let len = CMBlockBufferGetDataLength(bb)
var data = Data(count: len)
data.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) in
_ = CMBlockBufferCopyDataBytes(bb, atOffset: 0, dataLength: len, destination: ptr.baseAddress!)
}
let text = String(data: data, encoding: .utf8) ?? "<non-utf8 len=\(len)>"
print(String(format: "sample track=%d pts=%.3f dur=%.3f payload=%@", t.trackID, pts, dur, text))
}
}
}
SWIFT

trap 'rm -f "$SWIFT_SRC"' EXIT
LOG_OUT=$(AVF_FILE="$FILE_IN" swift "$SWIFT_SRC")
echo "$LOG_OUT"
234 changes: 234 additions & 0 deletions scripts/snapshot_baseline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
import argparse
import os
from dataclasses import dataclass
from datetime import datetime
from types import SimpleNamespace
from typing import List, Optional

import eval_refine as evalr
import refine_experiments as rexp


@dataclass
class SetSpec:
input_path: str
golden_path: str


def load_sets(path: str) -> List[SetSpec]:
sets: List[SetSpec] = []
input_path: Optional[str] = None
golden_path: Optional[str] = None
base_dir = os.path.dirname(path)
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
if line.startswith("Input:"):
input_path = os.path.join(base_dir, line.split("Input:", 1)[1].strip())
elif line.startswith("Golden:"):
golden = line.split("Golden:", 1)[1].strip()
if golden == "NOT_AVAILABLE_SO_FAR":
golden_path = None
else:
golden_path = os.path.join(base_dir, golden)
elif line.startswith("===="):
if input_path and golden_path:
sets.append(SetSpec(input_path=input_path, golden_path=golden_path))
input_path = None
golden_path = None
if input_path and golden_path:
sets.append(SetSpec(input_path=input_path, golden_path=golden_path))
return sets


def build_args() -> SimpleNamespace:
# Baseline defaults aligned with refine_experiments.py.
return SimpleNamespace(
mode="interval",
sample_rate=44100.0,
min_support=2,
min_score=0.0,
min_seconds=180.0,
max_seconds=600.0,
merge_window_seconds=60.0,
short_seconds=90.0,
single_short_min_score=3.2,
changeover_mode="wis",
cluster_gap_seconds=240.0,
sparse_min_span_seconds=0.0,
sparse_max_gap_seconds=0.0,
bin_seconds=30.0,
hmm_max_states=60,
hmm_stay_prob=0.95,
hysteresis_switch_ratio=0.55,
hysteresis_hold_bins=2,
interval_min_seconds=120.0,
repeat_penalty=0.0,
wis_support_scale=6.0,
wis_support_floor=0.3,
wis_duration_short=45.0,
wis_duration_mid=90.0,
wis_duration_short_factor=0.25,
wis_duration_mid_factor=0.5,
wis_gap_scale=0.0,
wis_gap_penalty_max=0.0,
wis_span_scale=180.0,
wis_span_floor=0.5,
wis_neighbor_support=5.0,
wis_neighbor_score=10.0,
wis_neighbor_short_seconds=90.0,
wis_neighbor_short_factor=0.5,
blend_noise_hard=False,
density_scale=0.0,
density_floor=1.0,
wis_streak_floor=0.6,
wis_gap_cv_floor=0.6,
)


def dedupe_tracks(tracks: List[rexp.Track]) -> List[rexp.Track]:
def track_key(track: rexp.Track) -> Optional[str]:
norm_title = rexp.normalize(track.title)
if not norm_title:
return None
return f"{rexp.normalize(track.artist)}|{norm_title}"

deduped: List[rexp.Track] = []
best_by_key = {}
for t in tracks:
key = track_key(t)
if not key:
deduped.append(t)
continue
current = best_by_key.get(key)
if not current:
best_by_key[key] = t
continue
if (t.score, t.support, -t.frame) > (current.score, current.support, -current.frame):
best_by_key[key] = t
if best_by_key:
deduped.extend(best_by_key.values())
tracks = sorted(deduped, key=lambda t: t.frame)
return tracks


def to_eval_tracks(tracks: List[rexp.Track]) -> List[evalr.Track]:
seen = set()
unique: List[evalr.Track] = []
for t in tracks:
key = (evalr.normalize(t.artist), evalr.normalize(t.title))
if key in seen:
continue
seen.add(key)
unique.append(evalr.Track(artist=t.artist, title=t.title))
return unique


def evaluate(refined: List[rexp.Track], golden: List[evalr.Track], inputs: List[evalr.Track]):
refined_tracks = to_eval_tracks(refined)
matched = []
for r in refined_tracks:
g = evalr.best_match(r, golden, relaxed=False)
if g:
matched.append((r, g))

matched_golden = {g for _, g in matched}
precision = len(matched) / len(refined_tracks) if refined_tracks else 0.0
recall = len(matched) / len(golden) if golden else 0.0

recoverable = 0
recoverable_missed = 0
near_recoverable = 0
for g in golden:
has_strict = False
has_near = False
for it in inputs:
if evalr.match_track(it, g, relaxed=False):
has_strict = True
break
if evalr.near_match(g, it):
has_near = True
if has_strict:
recoverable += 1
if g not in matched_golden:
recoverable_missed += 1
elif has_near:
near_recoverable += 1

recoverable_missed_rate = (recoverable_missed / recoverable) if recoverable else 0.0
return {
"refined": len(refined_tracks),
"golden": len(golden),
"matched": len(matched),
"precision": precision,
"recall": recall,
"recoverable": recoverable,
"recoverable_missed": recoverable_missed,
"recoverable_missed_rate": recoverable_missed_rate,
"near_recoverable": near_recoverable,
}


def run_set(set_spec: SetSpec, args: SimpleNamespace) -> List[rexp.Track]:
rows = rexp.parse_inputs(set_spec.input_path)
tracks = rexp.build_clusters(
rows,
int(args.cluster_gap_seconds * args.sample_rate),
args.min_support,
int(args.sparse_min_span_seconds * args.sample_rate),
int(args.sparse_max_gap_seconds * args.sample_rate),
)
tracks = rexp.pipeline(tracks, args.sample_rate, args)
tracks = rexp.weighted_interval_schedule(tracks, args.sample_rate, args.interval_min_seconds, args.repeat_penalty)
return dedupe_tracks(tracks)


def main() -> int:
parser = argparse.ArgumentParser(description="Snapshot baseline refinement metrics.")
parser.add_argument("--sets", default="Training/training_sets.txt")
parser.add_argument("--out", default="Training/metrics_baseline.md")
args = parser.parse_args()

sets = load_sets(args.sets)
if not sets:
print("No training sets found.")
return 1

baseline_args = build_args()
rows = []
for s in sets:
golden = evalr.parse_golden(s.golden_path)
inputs = evalr.parse_inputs(s.input_path)
refined = run_set(s, baseline_args)
metrics = evaluate(refined, golden, inputs)
rows.append((s, metrics))

ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(args.out, "w", encoding="utf-8") as f:
f.write("# Refinement Baseline Metrics\n\n")
f.write(f"Snapshot: {ts}\n\n")
f.write("Defaults:\n")
f.write("- mode: interval, changeover: wis\n")
f.write("- min_support: 2, merge_window: 60s, short_seconds: 90s, interval_min: 120s\n")
f.write("- WIS: support_scale=6.0 support_floor=0.3 span_scale=180 span_floor=0.5\n")
f.write("- WIS duration: short=45 mid=90 short_factor=0.25 mid_factor=0.5\n")
f.write("- WIS gap: scale=0.0 penalty_max=0.0\n\n")
f.write("| Set | Refined | Golden | Matched | Precision | Recall | Recoverable | Recoverable Missed | Recoverable Missed Rate | Near Recoverable |\n")
f.write("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n")
for s, m in rows:
name = os.path.basename(s.golden_path)
f.write(
f"| {name} | {m['refined']} | {m['golden']} | {m['matched']} | "
f"{m['precision']:.3f} | {m['recall']:.3f} | {m['recoverable']} | "
f"{m['recoverable_missed']} | {m['recoverable_missed_rate']:.3f} | "
f"{m['near_recoverable']} |\n"
)
print(f"Wrote {args.out}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
18 changes: 18 additions & 0 deletions scripts/stage_framework_header.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
if(NOT DEFINED INPUT_HEADER OR NOT DEFINED OUTPUT_HEADER OR NOT DEFINED FRAMEWORK_NAME)
message(FATAL_ERROR "INPUT_HEADER, OUTPUT_HEADER, and FRAMEWORK_NAME are required")
endif()

file(READ "${INPUT_HEADER}" header_contents)

# Source-tree headers use sibling quoted includes so standalone CMake builds
# remain portable. Public headers inside an Apple framework must address those
# same siblings through the framework namespace.
string(REGEX REPLACE
"#include[ \t]+\"([A-Za-z0-9_./-]+\\.hpp)\""
"#include <${FRAMEWORK_NAME}/\\1>"
staged_header_contents
"${header_contents}")

get_filename_component(output_directory "${OUTPUT_HEADER}" DIRECTORY)
file(MAKE_DIRECTORY "${output_directory}")
file(WRITE "${OUTPUT_HEADER}" "${staged_header_contents}")
Loading