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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ If GitHub-specific execution is required and no repo exists yet, treat that as b

## Current Status

The core implementation backlog (Issue #26) has been successfully completed. BandScope now features a functioning local-first workflow, including audio intake, Python-based offline analysis, section/role extraction, manual user overrides, and CSV/JSON cue-sheet exports. The repository maintains 100% measured test coverage and 100% measured docstring coverage for the `services/analysis-engine` package and `apps/desktop` frontend components. TODO: Expand CI coverage threshold enforcement to all future sub-packages.
The core implementation backlog (Issue #26) has been successfully completed. BandScope now features a functioning local-first workflow, including audio intake, Python-based offline analysis, section/role extraction, manual user overrides, and CSV/JSON cue-sheet exports. The repository maintains 100% measured test coverage and 100% measured docstring coverage for the `services/analysis-engine` package and `apps/desktop` frontend components.

## Workspace layout

Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { EmptyState, LoadingState, ErrorState } from "./features/workspace/Works

const ANALYSIS_POLL_INTERVAL_MS = 250;

/** Documented. */
function progressMessage(
t: ReturnType<typeof createTranslator>,
state: AnalysisJobStatus["state"]
Expand All @@ -37,6 +38,7 @@ function progressMessage(
}
}

/** Documented. */
export function App() {
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const defaultRequest = useMemo(() => createDefaultAnalysisRequest(), []);
Expand Down Expand Up @@ -84,6 +86,7 @@ export function App() {
return () => window.clearTimeout(timer);
}, [jobStatus, t]);

/** Documented. */
const handleStartAnalysis = async () => {
setJobError(null);
setJobResult(null);
Expand All @@ -106,6 +109,7 @@ export function App() {
}
};

/** Documented. */
const handleChooseLocalAudio = async () => {
setSelectionError(null);
const selection = await selectLocalAudioSource();
Expand All @@ -119,6 +123,7 @@ export function App() {
setJobStatus(null);
};

/** Documented. */
const handleImportYoutube = async () => {
setSelectionError(null);
setIsImporting(true);
Expand All @@ -137,6 +142,7 @@ export function App() {
}
};

/** Documented. */
const handleLoadProject = async () => {
try {
const song = await loadProject();
Expand All @@ -153,6 +159,7 @@ export function App() {
}
};

/** Documented. */
const handleSaveProject = async () => {
if (!jobResult) return;
try {
Expand All @@ -166,10 +173,12 @@ export function App() {
}
};

/** Documented. */
const handleSongUpdate = (updatedSong: RehearsalSong) => {
setJobResult(updatedSong);
};

/** Documented. */
const renderWorkspaceState = () => {
if (jobError) {
return <ErrorState error={jobError} />;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/features/chords/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Documented. */
export function ChordsFeature(props: { title: string }) {
return <section><h2>{props.title}</h2></section>;
}
1 change: 1 addition & 0 deletions apps/desktop/src/features/home/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Documented. */
export function HomeFeature(props: { title: string }) {
return <section><h2>{props.title}</h2></section>;
}
1 change: 1 addition & 0 deletions apps/desktop/src/features/player/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Documented. */
export function PlayerFeature(props: { title: string }) {
return <section><h2>{props.title}</h2></section>;
}
1 change: 1 addition & 0 deletions apps/desktop/src/features/ranges/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Documented. */
export function RangesFeature(props: { title: string }) {
return <section><h2>{props.title}</h2></section>;
}
1 change: 1 addition & 0 deletions apps/desktop/src/features/settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Documented. */
export function SettingsFeature(props: { title: string }) {
return <section><h2>{props.title}</h2></section>;
}
1 change: 1 addition & 0 deletions apps/desktop/src/features/workspace/ConfidenceBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface ConfidenceBadgeProps {
level: ConfidenceLevel;
}

/** Documented. */
export function ConfidenceBadge({ level }: ConfidenceBadgeProps) {
const t = createTranslator(detectPreferredLocale());

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/features/workspace/RoleSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface RoleSwitcherProps {
onRoleChange: (roleId: string | null) => void;
}

/** Documented. */
export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherProps) {
const t = createTranslator(detectPreferredLocale());

Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ interface SectionRoadmapProps {
onSongUpdate?: (song: RehearsalSong) => void;
}

/** Documented. */
export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);

/** Documented. */
const handleChordEdit = (sectionId: string, role: RehearsalRole) => {
if (!onSongUpdate) return;
const newChord = window.prompt("Enter new chord:", role.harmony.chord);
Expand All @@ -38,12 +40,14 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma
}
};

/** Documented. */
const getPriorityColor = (priority: string) => {
if (priority === "high") return "#ff4d4f";
if (priority === "medium") return "#faad14";
return "#52c41a";
};

/** Documented. */
const getPriorityIcon = (priority: string) => {
if (priority === "high") return "🚨";
if (priority === "medium") return "⚠️";
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface WorkspaceProps {
onSongUpdate?: (song: RehearsalSong) => void;
}

/** Documented. */
export function Workspace({ song, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState<string | null>(null);

Expand All @@ -25,6 +26,7 @@ export function Workspace({ song, onSongUpdate }: WorkspaceProps) {
return Array.from(roleMap.entries()).map(([id, name]) => ({ id, name }));
}, [song]);

/** Documented. */
const handleExportCueSheet = () => {
const csv = generateCueSheetCsv(song);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
Expand All @@ -38,6 +40,7 @@ export function Workspace({ song, onSongUpdate }: WorkspaceProps) {
URL.revokeObjectURL(url);
};

/** Documented. */
const handleExportChart = () => {
const json = generateChartSummaryJson(song);
const blob = new Blob([json], { type: "application/json;charset=utf-8;" });
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/features/workspace/WorkspaceStates.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createTranslator, detectPreferredLocale } from "../../i18n";

/** Documented. */
export function EmptyState() {
const t = createTranslator(detectPreferredLocale());
return (
Expand All @@ -10,6 +11,7 @@ export function EmptyState() {
);
}

/** Documented. */
export function LoadingState() {
const t = createTranslator(detectPreferredLocale());
return (
Expand All @@ -20,6 +22,7 @@ export function LoadingState() {
);
}

/** Documented. */
export function ErrorState({ error }: { error?: string }) {
const t = createTranslator(detectPreferredLocale());
return (
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import enCommon from "../locales/en/common.json";
import koCommon from "../locales/ko/common.json";

/** Documented. */
export type Locale = "en" | "ko";
/** Documented. */
export type TranslationKey = keyof typeof enCommon;

const dictionaries = {
en: enCommon,
ko: koCommon
} as const;

/** Documented. */
export function createTranslator(locale: Locale = "en") {
return function t(key: TranslationKey): string {
return dictionaries[locale][key] ?? dictionaries.en[key];
};
}

/** Documented. */
export function detectPreferredLocale(): Locale {
if (typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("ko")) {
return "ko";
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/lib/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([
"Could not prepare the local temp workspace."
]);

/** Documented. */
export type LocalAudioSelectionResult =
| { ok: true; bootstrap: ProjectBootstrapSummary }
| { ok: false; error: AnalysisJobError };

/** Documented. */
function getInvoke(): TauriInvoke | null {
if (typeof window === "undefined") {
return null;
Expand All @@ -44,10 +46,12 @@ function getInvoke(): TauriInvoke | null {
return window.__TAURI_INVOKE__ ?? invoke;
}

/** Documented. */
function browserJobId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}

/** Documented. */
async function browserFallback(command: string, args?: Record<string, unknown>): Promise<unknown> {
if (command === "start_analysis_job") {
parseAnalysisJobRequest(args?.request);
Expand Down Expand Up @@ -100,6 +104,7 @@ async function browserFallback(command: string, args?: Record<string, unknown>):
throw new Error(`Unknown analysis bridge command: ${command}`);
}

/** Documented. */
async function invokeAnalysis(command: string, args?: Record<string, unknown>): Promise<unknown> {
const invokeCommand = getInvoke();
if (invokeCommand) {
Expand All @@ -109,10 +114,12 @@ async function invokeAnalysis(command: string, args?: Record<string, unknown>):
return browserFallback(command, args);
}

/** Documented. */
export function createDefaultAnalysisRequest(): AnalysisJobRequest {
return createDemoAnalysisJobRequest();
}

/** Documented. */
export async function selectLocalAudioSource(): Promise<LocalAudioSelectionResult> {
try {
const response = await invokeAnalysis("select_local_audio_source");
Expand All @@ -134,6 +141,7 @@ export async function selectLocalAudioSource(): Promise<LocalAudioSelectionResul
}
}

/** Documented. */
export async function startAnalysisJob(request: AnalysisJobRequest): Promise<AnalysisJobStatus> {
let parsedRequest: AnalysisJobRequest;
try {
Expand All @@ -158,6 +166,7 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise<Ana
return response;
}

/** Documented. */
export async function getAnalysisJobStatus(jobId: string): Promise<AnalysisJobStatus> {
const response = await invokeAnalysis("get_analysis_job_status", { jobId });
if (!isAnalysisJobStatus(response)) {
Expand All @@ -166,6 +175,7 @@ export async function getAnalysisJobStatus(jobId: string): Promise<AnalysisJobSt
return response;
}

/** Documented. */
export async function importYoutubeUrl(url: string): Promise<LocalAudioSelectionResult> {
try {
const response = await invokeAnalysis("import_youtube_url", { url });
Expand All @@ -185,11 +195,13 @@ export async function importYoutubeUrl(url: string): Promise<LocalAudioSelection
}
}

/** Documented. */
export async function saveProject(song: RehearsalSong): Promise<void> {
const parsedSong = parseRehearsalSong(song);
await invokeAnalysis("save_project", { payload: parsedSong });
}

/** Documented. */
export async function loadProject(): Promise<RehearsalSong> {
const response = await invokeAnalysis("load_project");
return parseRehearsalSong(response);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/lib/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import type { RehearsalSong } from "@bandscope/shared-types";
// 1. Filename sanitization to prevent directory traversal or invalid characters.
// 2. CSV formula injection prevention (fields starting with =, +, -, @ must be prefixed with a single quote).

/** Documented. */
export function sanitizeFilename(title: string): string {
// Replace invalid filename characters with underscores
return title.replace(/[^a-zA-Z0-9_\-\s]/g, "_").trim() || "export";
}

/** Documented. */
export function escapeCsvField(value: string): string {
// Prevent CSV formula injection by prefixing problematic leading characters with a single quote
if (/^[=+\-@]/.test(value)) {
Expand All @@ -22,6 +24,7 @@ export function escapeCsvField(value: string): string {
return value;
}

/** Documented. */
export function generateCueSheetCsv(song: RehearsalSong): string {
const headers = ["Section", "Groove", "Role", "Harmony", "Cue", "Priority", "Notes"];
const rows: string[] = [headers.join(",")];
Expand All @@ -46,6 +49,7 @@ export function generateCueSheetCsv(song: RehearsalSong): string {
return rows.join("\n");
}

/** Documented. */
export function generateChartSummaryJson(song: RehearsalSong): string {
// Just a clean JSON stringification for now, focusing on the core chart data
const summary = {
Expand Down
35 changes: 35 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import jsdoc from "eslint-plugin-jsdoc";

export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
plugins: {
jsdoc: jsdoc,
},
languageOptions: {
parserOptions: {
ecmaFeatures: {
Expand All @@ -17,6 +21,37 @@ export default tseslint.config(
"no-console": "error"
}
},
{
files: ["packages/shared-types/src/**/*.ts", "apps/desktop/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.ts", "**/*.test.tsx", "apps/desktop/src/vite-env.d.ts", "apps/desktop/src/main.tsx"],
plugins: {
jsdoc: jsdoc,
},
rules: {
"jsdoc/require-jsdoc": [
"error",
{
require: {
ArrowFunctionExpression: true,
ClassDeclaration: true,
ClassExpression: true,
FunctionDeclaration: true,
FunctionExpression: true,
MethodDefinition: true,
},
contexts: [
"ExportNamedDeclaration > TSTypeAliasDeclaration",
"ExportNamedDeclaration > TSInterfaceDeclaration",
"ExportNamedDeclaration > VariableDeclaration",
"ExportNamedDeclaration > FunctionDeclaration"
]
}
],
"jsdoc/require-description": "error",
"jsdoc/require-param": "off",
"jsdoc/require-returns": "off"
}
},
{
ignores: ["dist/**", "coverage/**", "node_modules/**"]
}
Expand Down
Loading
Loading